山手線3Dジオラマでは、電車を一定間隔で停車させるために「駅の位置」を線路LUT(Look Up Table)上の距離値にマッピングしておく必要があります。一見するとただの下ごしらえに過ぎない作業ですが、私はここで想像の3倍くらい時間を溶かしました。今回は、そのスナップ処理で遭遇した罠と、最終的に落ち着いた実装を振り返ってみたいと思います。

駅の位置を「距離値」に変換するという発想

このプロジェクトでは、電車の位置を緯度経度ではなく「山手線の起点(大崎駅付近)からの距離(メートル)」で管理しています。これは以前の記事でも触れたとおり、Turf.jsの along を使って線路を1m間隔のLUTに展開し、ランタイムでは距離値から座標をO(1)で引く設計にしているためです。

この設計なら、駅の位置も「起点からの距離」で保持しておけば、停車判定は Math.abs(headDist - stationDist) < 5 のような非常に単純な形に書けます。問題は、どうやって30駅分の緯度経度から正しい距離値を得るか、です。

最初に思いついた素朴な実装

まず試したのは、LUT全件を総当たりで走査して最近傍を取る方法でした。

// 素朴な最近傍探索(34,500エントリに対する総当たり)
function snapStationToLut(station, lut) {
    let minDist = Infinity;
    let hitIndex = -1;
    for (let i = 0; i < lut.length; i++) {
        const d = geolib.getDistance(
            { latitude: station.lat, longitude: station.lng },
            { latitude: lut[i].lat, longitude: lut[i].lng }
        );
        if (d < minDist) { minDist = d; hitIndex = i; }
    }
    return lut[hitIndex].dist;
}

30駅 × 34,500エントリなので全体でも100万回ちょっと。ビルド時の前処理なら十分動くと判断し、まずこの実装で全駅をスナップしました。ここまでは順調だったんです。

最初の異変:目黒駅と恵比寿駅で停車位置がズレる

デバッグ用に停車位置マーカーを地図上に描画してみると、ほとんどの駅は違和感なく乗っているのに、目黒駅と恵比寿駅だけが線路から40〜50mずれた位置で止まっているのに気づきました。画面上では電車がホームの真ん中ではなく、ホームの手前側の空中で止まっているように見えます。

最初は「LUTのインデックスがずれているのでは?」と疑って、LUT生成ロジックを3時間ほど読み返しました。結果、LUT側には問題なし。犯人は、OpenStreetMapから取得した駅ノードの緯度経度そのものでした。

駅ノードはホーム中心ではない

OpenStreetMapの railway=station ノードは、必ずしも「ホームの中心」を指しているわけではありません。駅舎の玄関付近に打たれていたり、改札口の位置だったり、複数ホームを持つ駅では代表点として「駅名ラベルを置きたい場所」に置かれていることもあります。目黒駅は改札口側、恵比寿駅は駅ビル側にノードが寄っており、そこから直線的に最寄りの線路にスナップすると、本来の山手線ホームからずれた線路(湘南新宿ラインや埼京線の線路)に吸い寄せられてしまっていたのです。

恵比寿に至っては、湘南新宿ラインの線路が山手線のすぐ隣を平行に走っているため、駅ノードから最も近い線路座標が山手線ではなく湘南新宿ライン側になっていました。当然、私のLUTは山手線しか持っていないので、そっちの線路上の一番近い点にスナップされた結果、全体として20mほど北寄りにズレていたわけです。このことに気づくまで半日が溶けました。

Turf.jsのnearestPointOnLineへの移行

LUTの離散点に対して最近傍を取るのではなく、もとの連続した線形ジオメトリに対して「最も近い点」を求めるほうが精度が出るはずだと考え直し、Turf.jsの nearestPointOnLine を使う方式に乗り換えました。この関数は、与えた点から線分までの垂線の足を求めてくれるので、離散化の誤差が原理的に出ません。

import nearestPointOnLine from '@turf/nearest-point-on-line';
import length from '@turf/length';

function snapStation(station, lineString) {
    const pt = turf.point([station.lng, station.lat]);
    const snapped = nearestPointOnLine(lineString, pt, { units: 'meters' });
    // snapped.properties.location に、線の先頭からの距離(m)が入っている
    return {
        dist: snapped.properties.location,
        lng: snapped.geometry.coordinates[0],
        lat: snapped.geometry.coordinates[1],
        residual: snapped.properties.dist // 元の点との距離(m)
    };
}

この関数の便利なところは、スナップ後の「線上の距離」が properties.location に入って返ってくる点です。これがそのままLUTの距離値として使えるので、ランタイムの停車判定コードを一切変えずに済みました。

精度は上がったが、別の駅でまた暴走した

「これで解決!」と喜んだのも束の間、今度は西日暮里駅の停車位置がなぜか1.2kmも先にスナップされるという現象に遭遇しました。シミュレーターを起動すると、電車が西日暮里を完全に素通りして田端の近くまで行ってから急に駅名表示が出るという、ホラーのような挙動です。

調べてみると、私が使っていた山手線のGeoJSONは、OpenStreetMapから取得した複数のLineStringを単純に結合したもので、途中でラインの向きが反転している区間があったのです。Turf.jsの nearestPointOnLine は、線の先頭からの距離を素直に計算して返すだけなので、逆向きに接続された区間では距離値が連続しません。西日暮里駅は運悪く、その反転区間の直後にあり、結果として「先頭から1.2km先」という全然違う距離値を返されていました。

LineStringの正規化で根本解決

このままパッチ当てで対応するのは危険だと判断し、スナップ処理の前段でLineStringを「大崎発・一周して大崎に戻る」向きに正規化する前処理を追加しました。OpenStreetMapのデータを取得したら、まず構成セグメントを端点の近さでつなぎ直し、向きも揃えます。

// 疑似コード: セグメントの接続と向き揃え
function normalizeRailLine(segments, startLngLat) {
    const ordered = [];
    let current = pickClosestSegment(segments, startLngLat);
    ordered.push(current);
    while (ordered.length < segments.length) {
        const tail = lastPointOf(current);
        const next = findAdjacent(segments, tail, ordered);
        if (!next) break;
        current = reverseIfNeeded(next, tail); // 端点が合うよう必要なら反転
        ordered.push(current);
    }
    return mergeCoords(ordered);
}

この前処理を挟むようになってから、30駅すべてがmオーダーの誤差に収まるようになりました。残差(元の駅座標とスナップ後の点との距離)も、多くの駅で5〜15mに収まり、感覚的にも「ホームの真ん中に止まっている」と言える水準になりました。

トンネル区間でスナップが暴れた話

精度が整ったと思ったところで、次の問題が発生します。池袋〜新宿〜渋谷の西側ではなく、田端〜駒込〜巣鴨あたりの一部区間で、LUT上の距離値が不自然にジャンプしていることに気づきました。具体的には、1m単位で連続しているはずの距離値が、ある地点で急に3〜4mスキップしたり、逆に0.3m刻みで密集したりしていたのです。

原因は、OpenStreetMapの該当区間のポリラインが、地下トンネル部を短い直線で大雑把につないでいたことでした。Turf.jsの along は、線分上の弧長パラメータに対して線形補間をかけるので、元データの座標粗密がそのまま速度ムラとして出てきます。電車を定速で走らせても、画面上では「トンネルに入ると急に加速して出ると減速する」という気持ちの悪い挙動になっていました。

スムージングのためのダグラス・ポイカー + リサンプリング

これについては、Turf.jsの simplify(Douglas-Peucker)で過剰な頂点を落としたあと、自前で1m刻みにリサンプリングするという二段構えで対処しました。simplify のtolerance値は試行錯誤で0.000015(度)に落ち着き、これで原データの無駄な揺れを吸収できるようになっています。

const simplified = turf.simplify(line, { tolerance: 0.000015, highQuality: true });
const total = turf.length(simplified, { units: 'meters' });
const lut = [];
for (let d = 0; d <= total; d += 1) {
    const p = turf.along(simplified, d, { units: 'meters' });
    lut.push({
        dist: d,
        lng: p.geometry.coordinates[0],
        lat: p.geometry.coordinates[1]
    });
}

simplifyのtoleranceを欲張って大きくしすぎると、今度はカーブの頂点が潰れて電車が線路外を走るようになります。大塚〜巣鴨間の緩いカーブでモデルが完全に道路にはみ出したのを見たときは「またやり直しか……」と机に突っ伏しました。

最後は手動オフセットで仕上げる

アルゴリズム側をどれだけ磨いても、最後の5〜10m単位のズレは「ホームの中心がどこか」という人間の感覚に依存する部分があり、自動では決めきれません。そこで、各駅に対して「アルゴリズムで求めた距離値」に加えて「手動で足す補正値(offsetM)」を設定できるようにしました。

// station-config.json
[
  { "name": "大崎",     "autoDist": 0,      "offsetM": 0 },
  { "name": "五反田",   "autoDist": 905,    "offsetM": -3 },
  { "name": "目黒",     "autoDist": 2077,   "offsetM": 8 },
  { "name": "恵比寿",   "autoDist": 3564,   "offsetM": 12 },
  { "name": "渋谷",     "autoDist": 5204,   "offsetM": -4 }
]

ランタイム側は stationDist = autoDist + offsetM を使うだけ。これで、アルゴリズムで9割方合わせて、残りを人間の目で1駅ずつ確認しながら追い込む運用に落ち着きました。地味ですが、これが一番安定します。

テスト自動化でデグレ防止

ここまで手こずると、当然「もう二度と同じ罠を踏みたくない」という気持ちになるので、軽いテストを書きました。各駅のスナップ残差が30mを超えたらCIを落とす、という単純なものです。

// snap.test.js
test('全30駅のスナップ残差は30m以内', () => {
    const lut = buildLut();
    stations.forEach(st => {
        const snapped = snapStation(st, railLine);
        expect(snapped.residual).toBeLessThan(30);
    });
});

このテストを入れておいたおかげで、後日OpenStreetMapのデータを更新したときに新宿駅の座標が微妙に動いていたのをすぐに検知できました。手動でデータを入れ替えるときほど、こういう「数値ベースの最低ラインテスト」が効きます。

まとめ

駅データのスナップ処理は、はじめは「ただの最近傍探索でしょ?」と甘く見ていましたが、実際にはデータの素性・方向・粗密・意味(ホーム中心かどうか)が全部絡み合う、想像以上に泥臭い領域でした。私の場合、目黒と恵比寿の誤差、西日暮里での逆向き区間、トンネル区間の速度ムラという3連続の失敗を経て、ようやく現在の「自動スナップ+手動オフセット+CIテスト」の構成にたどり着いています。

同じように地理データを扱う個人開発者の方がいたら、「最近傍探索の前にまずラインを正規化する」「残差は必ず可視化して目視確認する」「最後は手動オフセットに逃げてよい」、この3点だけでもお伝えしたいなと思います。完璧なアルゴリズムよりも、人間が直せる余地を残した設計のほうが、結果的に早く落ち着きました。