線にすると、消えるもの
GPSログを可視化するとき、まず思いつくのは地図に線を引くことです。 実際それが目的に合っている場面は多い。どこを通ったかが分かります。
ただ、線にした瞬間に各点が持っていた情報が消えます。 速度、心拍、標高、時刻。1点ごとに5つの値があるのに、 線は「通った」ことしか表しません。
| 記録されていた値 | 範囲 |
|---|---|
| 座標(緯度・経度) | 南北 6,403m × 東西 1,275m |
| 標高 | 2.5 〜 38.7 m |
| 心拍 | 90 〜 135 bpm |
| 速度(累積距離から算出) | 中央値 4.9 km/h |
| 時刻 | 2時間31分 |
点のまま描いて、属性を色に割り当てる。 それだけで、地図には出てこないものが見えるはずです。
1秒に1点、という密度
使ったのは TCX ファイルです。ランニングウォッチの標準的な出力形式で、
<Trackpoint> ごとに時刻・座標・標高・累積距離・心拍が入っています。
<Trackpoint> <Time>2026-07-15T06:43:51+09:00</Time> <Position> <LatitudeDegrees>35.639818</LatitudeDegrees> <LongitudeDegrees>139.740607</LongitudeDegrees> </Position> <AltitudeMeters>6.11</AltitudeMeters> <DistanceMeters>0.0</DistanceMeters> <HeartRateBpm><Value>101</Value></HeartRateBpm> </Trackpoint>
| 項目 | 値 |
|---|---|
| トラックポイント数 | 8,989 点 |
| サンプリング間隔 | 平均 1.01 秒(実質 1Hz) |
| 総距離 | 11.12 km |
| 総時間 | 2時間31分 |
| 点の間隔 | 約 1.2 m |
アクセスログの可視化では、時刻 × サブネット × 分類の3軸でした。GPSは位置が2軸あり、そこに標高・心拍・速度・時刻が乗ります。次元が多いぶん、何を色に割り当てるかで見え方が変わります。
8,989点を、どう持たせるか
全点をブラウザへ渡したいのですが、JSONで素直に書くと 375KB になりました。 記事に埋め込むには重すぎます。
差分と整数化で圧縮する
GPSの点は前の点とほとんど変わりません。1秒で1.2mしか動かないからです。 そこで差分にして、さらに小数を整数へ寄せます。
// 緯度経度:最小値からの差を 1e-6 度単位の整数に(約0.11m精度) la = Math.round((lat - la0) * 1e6); // さらに前の点との差分をとる d[i] = v[i] - v[i-1]; // 結果:ほとんどの値が -50〜50 の範囲に収まる [2438,5032,61,101,0, -6,-10,0,0,42, 0,0,0,5,-42, ...]
| 形式 | サイズ | 点数 |
|---|---|---|
| 素直な JSON(小数のまま) | 375 KB | 8,989 |
| 差分+整数化 | 106 KB | 8,989 |
| 2点に1点へ間引き | 56 KB | 4,495 |
3割以下に収まったので、間引かずに全点を載せることにしました。 復元は累積和を取るだけです。
let la=0, lo=0, al=0, hr=0, sp=0; for (let i=0; i<n; i++) { la += d[i*5]; lo += d[i*5+1]; al += d[i*5+2]; hr += d[i*5+3]; sp += d[i*5+4]; pts[i] = { lat: la0 + la/1e6, lon: lo0 + lo/1e6, alt: al/10, hr: hr, spd: sp/10 }; }
float32 では、緯度が足りない
GPU が扱うのは f32 です。だから CPU 側も Float32Array で持てばよい——
そう考えて実装したら、距離が 5% ほど水増しされました。
| 緯度経度の保持 | 積算した距離 |
|---|---|
| Float32Array | 11.57 km |
| Float64Array | 10.99 km |
| (TCX が記録した累積距離) | 11.12 km |
刻みが、歩幅より粗い
原因は単純でした。緯度 35.63738 という値そのものが、float32 の精度を使い切っています。
元の値 35.63738 float32 35.63737869262695 ← 誤差 0.146 m // 隣り合う表現可能な値の間隔 最小刻み 0.425 m ← この粒度でしか表せない
1秒ごとの点は、平均1.2m しか離れていません。 そこへ 0.4m 刻みの量子化が乗ると、 各点がわずかにずれ、その揺れが8,989回ぶん積み上がります。 経路の形は変わらないのに、長さだけが伸びる。
// ✗ 緯度経度のまま float32 にする lat = new Float32Array(n); // 刻み 0.4m // ○ 差分をとってから float32 へ lat = new Float64Array(n); // CPU 側は倍精度 x = (lon[i] - lon0) * mPerLon; // メートルへ gpuBuf[i] = x; // ここで初めて f32
インスタンス描画で、点を撒く
8,989点それぞれに四角形を描きます。頂点バッファに 8,989 × 4 頂点を積むのではなく、 四角形1つぶんの頂点を、8,989回インスタンス描画します。
struct Uniforms { view : vec4<f32>, // 中心x, 中心y, ズーム, アスペクト params : vec4<f32>, // 点サイズ, 色モード, 進捗, 総点数 }; @group(0) @binding(0) var<uniform> U : Uniforms; // 各点の属性(storage buffer) @group(0) @binding(1) var<storage, read> pts : array<vec4<f32>>; @group(0) @binding(2) var<storage, read> att : array<vec4<f32>>; @vertex fn vs(@builtin(vertex_index) vi : u32, @builtin(instance_index) ii : u32) -> VSOut { // 四角形の4隅(トライアングルストリップ) let corner = array<vec2<f32>,4>( vec2(-1.0,-1.0), vec2(1.0,-1.0), vec2(-1.0, 1.0), vec2(1.0, 1.0)); let p = pts[ii]; // この点の位置 let c = corner[vi]; ... }
layout:'auto' を使うと、シェーダ内で参照していないバインディングは黙って落とされます。
属性バッファを宣言だけして使わないと、バインドグループ生成時にエラーになります。
色モードで使い分ける場合も、すべてのバッファを何らかの形で参照する必要があります。
これは銀河の実装でも踏んだ点でした。
加算合成で、密度を光らせる
同じ場所に点が重なると明るくなるよう、加算ブレンドを使います。 滞留した場所ほど光る、という表現になります。
blend: {
color: { srcFactor: 'src-alpha',
dstFactor: 'one', // 加算
operation: 'add' },
alpha: { srcFactor: 'one',
dstFactor: 'one', operation: 'add' },
}
色を、切り替えてみる
実データです。ドラッグで移動、ピンチかホイールで拡大できます。 色の基準を変えると、同じ軌跡から違うものが見えます。
※ 距離は座標から積分した値のため、TCX が記録する累積距離(11.12km)とはわずかに異なります。
見えたもの
滞留が、光る
1Hzで記録しているので、止まっている間も点が打たれ続けます。 信号待ちで40秒止まれば、そこに40点が重なる。 加算合成では、それが明るい塊になります。
速度が 0.5km/h を下回る区間を数えると、15秒以上の停止が7箇所、 最長は40秒でした。信号1回ぶんです。 点の密集を見れば、どこで足を止めたかが分かります。
坂が、現れる
標高で色を分けると、経路のどこが高いかが出ます。 差は36.2mですが、勾配は場所によって±10%を超えます。
| 勾配 | 値 |
|---|---|
| 下位5%(下り) | −10.3 % |
| 中央値 | 0.1 % |
| 上位5%(上り) | +12.6 % |
速度は、意外と一定
中央値 4.9 km/h、95パーセンタイルでも 6.7 km/h。 歩行速度はほとんど変わりません。 色が大きく変わるのは、止まった場所と—— そして、明らかにおかしい数点です。
点のまま持つ、ということ
線を引くのは、点を捨てて経路だけ残す操作でした。 点のまま描くと、1点あたり5つの値がそのまま残ります。 色をどれに割り当てるかで、同じ軌跡が別のものに見える。
そして密度そのものが情報になる。 止まっていた時間は、点の重なりとして現れます。 間引いてしまうと、この情報が薄まる——だから全点を持ちました。
この記事のまとめ
- 1Hz の GPS ログは、1点あたり座標・標高・心拍・速度・時刻を持つ。線にすると消える。
- 差分+整数化で 375KB → 106KB。全8,989点を間引かずに載せられた。
- インスタンス描画で、四角形1つを点数ぶん描く。頂点バッファは4頂点だけ。
- 加算合成により、滞留した場所が明るく光る。密度が情報になる。
layout:'auto'は参照していないバインディングを落とす。全バッファを使う。- 15秒以上の停止が7箇所、最長40秒。閾値を変えると20箇所・13分になる。
- 歩行速度は中央値 4.9 km/h でほぼ一定。大きく外れる点は、測位の飛び。
- 緯度経度を float32 で持つと刻みが 0.425m になり、距離が5%水増しされた。原点を引いてから f32 にする。
時速109kmで歩く4点、101秒の記録断絶、そして 「11m格子の93%が複数回通過」という集計の錯覚。 心拍と勾配の相関が、予想よりずっと弱かった話も含めて。