地図ライブラリを使わずに、走った全ルートを1枚のアートにする ― Strava polyline をエリア別 bbox 投影で可視化した
⚠ NOTICE — この記事の文体について
本記事の文章は 生成 AI(Claude)の協力のもとで執筆しています。実装内容・コード・設計判断はすべて筆者本人のものですが、説明の構成や言い回しに AI 特有のクセが残っている可能性があります。バイブコーディング(AI ペアプロ駆動開発)の実践レポートとして読んでもらえると幸いです。
はじめに
毎朝走った GPS の軌跡が、Strava に全部たまっている。これを1枚の絵にまとめたら面白いはずだ、と思ったのが /map の出発点だった。
軌跡を見せるサービスはもう山ほどある。ただ、ああいう「地図の上に線を重ねる」タイプは作りたくなかった。地図タイルを消して、線だけが闇に浮かぶ。背景は純黒、線はネオンイエローグリーン1色。それ以外は何も無い。そういう絵がほしかった。
やってみると、見た目より厄介だったのは描画ではなく投影のほうで、途中で「毎朝の地元ランが点に潰れる」という壁にぶつかった。その壁の越え方が、この実装のいちばんの肝になっている。
なぜ地図タイルを使わなかったのか
正直なところ、地図ライブラリを使うかどうかで悩んだ時間はあまりない。最初から「地図はいらない」と思っていた。理由はシンプルで、地図の上に線が乗っている絵が、自分の作りたいものではなかったからだ。
地図が背景にあると、どうしても「よくある地図サービスの画面」に見えてしまう。自分がほしかったのは、真っ黒の上にネオン色の線だけが浮かぶ絵だ。地名も道路も無くていい。線の形だけで、自分が走ってきた場所が分かる。そっちのほうが SNS に流したときにも目を引くし、このサイトの黒っぽい雰囲気にもそのまま馴染む。だから地図は最初から外すつもりでいた。
技術的に見ても、地図ライブラリを避ける理由はそろっていた。
mapbox-gl も react-leaflet も、タイルの読み込みと描画のためにそれなりのサイズと外部リクエストを抱える。線を描きたいだけの自分には重すぎた。デザインの方向も合わない。タイルが入ると、どう転んでも「地図サービスの画面」になってしまう。純黒に線1色というこのサイトのトーンには、地図そのものが邪魔だった。
決め手はもう一つあって、後で OG 画像(next/og)と描画ロジックを共有したかった。Web の SVG と OG 画像で同じ投影を使い回す、という狙いがあると、間に地図タイルを挟む選択肢はそもそも取れない。
そんなわけで、Strava が返す polyline を自前でデコードして SVG で描く、という地味な方針に落ち着いた。
Strava の polyline をデコードする
Strava は走行ルートを Google の Encoded Polyline Algorithm Format で返す。緯度経度の配列を差分エンコードして文字列に詰めた形式だ。これをデコードする関数は lib/strava-format.ts にある。地図ライブラリを入れない以上、ここは自前で書くしかない。
// lib/strava-format.ts
export function decodePolyline(encoded: string): [number, number][] {
const points: [number, number][] = [];
let index = 0,
lat = 0,
lng = 0;
while (index < encoded.length) {
let shift = 0,
result = 0,
byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lat += result & 1 ? ~(result >> 1) : result >> 1;
shift = 0;
result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lng += result & 1 ? ~(result >> 1) : result >> 1;
points.push([lat / 1e5, lng / 1e5]);
}
return points;
}
このデコード自体は既に当日ランのルート描画(トップページ・OG画像)で使っていたものなので、軌跡アートでも同じ関数を再利用した。1本の polyline を緯度経度の配列に戻すところまではこれで済む。
全ランを1つの bbox に投影したら、地元ランが消えた
問題はここからだった。
全ランの緯度経度をまとめて1つの bounding box(bbox)に収め、その範囲を SVG のビューポートに等比投影する。素直に考えればこれで全軌跡が1枚に収まるはずだった。
ところが実際にやると、毎朝走っている地元のルートが、ほぼ点になって潰れた。
理由は地理にある。自分の走行地点は、毎朝の地元ランから、たまの遠征まで、関東〜中部に 約200km×300km の範囲で散らばっていた。この全体を1つの bbox に収めると、スケールは一番外側の遠征地点に合わせて決まる。すると、数 km 四方に収まる地元ランは、全体から見れば米粒のような領域に圧縮されてしまう。毎朝積み重ねたルートが、いちばん潰れる。
最初に出てきた絵を見たときは、正直がっかりした。頭の中では、毎朝走っているルートの形がしっかり見える絵を思い描いていた。でも実際に出たのは、遠征で行った場所だけが大きく描かれて、地元のルートは画面のすみに小さな点が固まっているだけ。一番たくさん走っている場所が、一番見えない。想像と全然ちがう絵だった。
つまり「全部を1枚に」と「地元ランをちゃんと見せる」は、1つの枠ではうまく両立しない。線を描く前の、場所の分け方から考え直す必要があった。
開始地点を8km閾値でクラスタリングする
解き方はこうだ。ランを開始地点の近さでエリアにまとめ、エリアごとに別々の bbox で投影する。こうすればどのエリアも自分の中で最適なズームになり、地元ランも遠征も等しい大きさで描ける。
クラスタリングは lib/heatmap-geo.ts の clusterRunsByArea で、開始地点の greedy クラスタリングとして実装した。各ランの開始地点(polyline の先頭座標)を順に見て、既存クラスタの重心から一定距離以内に最近傍があれば吸収、なければ新しいクラスタを作る。
// lib/heatmap-geo.ts
/**
* 同一エリアと見なす開始地点間の距離 (度)。0.08 度 ≈ 約 8km。
*/
const CLUSTER_THRESHOLD_DEG = 0.08;
export function clusterRunsByArea(activities: readonly RunActivity[]): RunCluster[] {
const clusters: MutableCluster[] = [];
for (const run of activities) {
const start = startPointOf(run);
if (!start) continue;
const [lat, lng] = start;
let nearest: MutableCluster | null = null;
let nearestDist = Infinity;
for (const c of clusters) {
const d = Math.hypot(lat - c.centroidLat, lng - c.centroidLng);
if (d < nearestDist) {
nearestDist = d;
nearest = c;
}
}
if (nearest && nearestDist < CLUSTER_THRESHOLD_DEG) {
nearest.runs.push(run);
// 重心を逐次更新 (累積平均)。
const n = nearest.runs.length;
nearest.centroidLat += (lat - nearest.centroidLat) / n;
nearest.centroidLng += (lng - nearest.centroidLng) / n;
} else {
clusters.push({ centroidLat: lat, centroidLng: lng, runs: [run] });
}
}
// 本数降順でソートし、上位を主要エリア、残りを「その他」に集約
clusters.sort((a, b) => b.runs.length - a.runs.length);
// ...
}
距離は緯度経度のユークリッド距離(度)で近似している。対象範囲が中部〜関東に限られていて、この緯度では経度方向の圧縮もわずかなので、8km スケールのざっくりしたクラスタ判定にはこれで十分だった。厳密な大円距離を持ち出すほどの精度はここでは要らない。
閾値 0.08(約8km)の根拠は、実データで確かめた。312本の polyline でこの閾値だと主要5エリアで9割をカバーできることを確認したうえで固定した。
この8kmという数字は、一発で決まったわけではない。最初はもっと小さい値から始めた。すると同じ町の中なのにエリアが細かく分かれすぎて、似たような小さい絵がいくつも並んでしまった。逆に大きくしすぎると、別の町まで1つのエリアにまとまって、また地元ランが潰れる。小さくしたり大きくしたりを何度か繰り返して、ちょうど「同じ生活圏は1つにまとまり、遠征は別になる」あたりが8kmだった。
実際のデータで試すと、この値で主要な5エリアが全体の9割をカバーできた。残りの1割は、たまにしか行かない遠征だけ。狙いどおりの分かれ方に落ち着いたときは、これでいける、という手応えがあった。
上位5エリアを「エリア1〜5」として個別カードにし、それを超えた小さなエリアは1つの「その他」に集約している。エリア数を無制限にするとカードが散らかるので、ここは表示の都合で頭打ちにした。
エリアごとに自前 bbox 投影する
クラスタが決まったら、エリア単位で polyline を SVG 座標に投影する。projectClusterPolylines がそれで、単一 polyline 用の投影と同じ「等比投影・アスペクト比維持・中央寄せ」の算法を、エリア内の全 polyline をまとめた共通 bbox 向けに広げたものだ。
// lib/heatmap-geo.ts(投影の核)
const innerW = width - padding * 2;
const innerH = height - padding * 2;
const latRange = maxLat - minLat || MIN_COORD_RANGE;
const lngRange = maxLng - minLng || MIN_COORD_RANGE;
const scale = Math.min(innerW / lngRange, innerH / latRange);
const offsetX = padding + (innerW - lngRange * scale) / 2;
const offsetY = padding + (innerH - latRange * scale) / 2;
const trails: ProjectedTrail[] = decoded.map((points) => {
const segments = points.map(([lat, lng]) => {
// 経度→x (右が東)、緯度→y (上が北。SVG は下方向が +y なので maxLat 基準で反転)。
const x = roundCoord(offsetX + (lng - minLng) * scale);
const y = roundCoord(offsetY + (maxLat - lat) * scale);
return `${x},${y}`;
});
return { d: `M ${segments.join(" L ")}` };
});
ポイントは bbox を「エリア内の全 polyline の全点」で取ること。これで同じエリアのランが全部同じ座標系に乗り、よく通る区間が自然に重なる。緯度は SVG の y 軸が下向きなので maxLat 基準で反転させている。座標は小数1桁に丸めて、出力する HTML / SVG のサイズを削っている。
この投影は Web ページのカード(TrailCard)と OG 画像(/api/og/heatmap)の両方が共有する。同じ関数を通すので、サイトで見える絵と SNS に流れる絵がコードレベルで一致する。
重なり密度を mix-blend-mode: screen で表現する
エリア内では、同じルートを何度も走るほど線が重なる。この「よく走るルートほど濃く見える」を、色分けではなく重なりの加算で出したかった。
Web 側は CSS の mix-blend-mode: screen を使った。signal 色(ネオンイエローグリーン)1色のまま、線が重なった部分だけが加算されて明るくなる。色のバリエーションを持たせるのではなく、1色の重なり方そのものを密度の表現にしている。これなら凡例を読む必要がない。明るいところがよく走っているところ、とそのまま見える。
OG画像は satori が screen 非対応 ― 多重ストロークでグローを擬似する
ここで詰まったのが OG 画像だ。
next/og の中身は satori(+ resvg)で、CSS の全機能をサポートしているわけではない。mix-blend-mode: screen は効かない。Web でうまくいった「重なりで明るくなる」表現が、そのままでは OG 画像に出せなかった。
そこで OG 側は、同じ投影座標を使いつつ描き方を変えた。1本の線を、太く半透明のストロークから細く明るいストロークへと多重に重ねて描き、blur を使わずにグローのにじみを擬似している。satori はフィルタ系も弱いので、「太い半透明を下に、細い本線を上に」という手描きのグローで代用した形だ。
screen で歪みやすい「その他」エリア(超広域)は、OG 画像からは外している。
SNS での反応は、正直まだほとんど無い。作ったばかりだし、こういう地味なページが急に伸びるとも思っていない。そこはこれから少しずつシェアしていくつもりだ。
それでも、自分でこの絵が最初にちゃんと表示されたときは、素直にうれしかった。地名も道路も無いのに、線の形を見ただけで「あ、これはいつもの川沿いだ」「これは遠征で行ったあの道だ」と分かる。毎朝積み重ねてきたものが、そのまま1枚の絵になっている。反応の数とは関係なく、自分のために作ってよかったと思えるページになった。
まとめ
線を描くだけのつもりで始めたら、時間のほとんどは投影をどう分けるかに溶けた。やったことを並べておく。
- Strava の polyline を自前デコードして、地図ライブラリなしで SVG に描く
- 全ランを単一 bbox に投影すると地元ランが点に潰れる、という地理由来の破綻があった
- 開始地点を8km閾値で greedy クラスタリングし、エリアごとに別 bbox で投影して解いた
- 重なり密度は Web では
mix-blend-mode: screen、OG 画像では多重ストロークのグロー擬似で表現 - 投影ロジックを Web カードと OG 画像で共有し、サイトと SNS の絵を一致させた
「全部を1枚に収める」と「毎朝の積み重ねをちゃんと見せる」は、単純にやると両立しない。その間を取るのに投影を分ける一手が要った、というのがこの実装の山場だった。出来上がった絵は、結局のところ自分が走ってきた場所そのものの形をしている。
シリーズナビ
この記事を書いた人

気に入ったら、次の記事もメールで。月1〜2通だけ。
[ Next Action / 次の動き ]
走行データは、こちらから。
読了ありがとうございます。気が向いたら、生データもどうぞ。