STGYの地図経路機能

本記事では、STGYの記事へ地図や経路を埋め込み、FITやGPXなどの経路データを解析し、地名と座標を相互変換する機能について説明する。中心となるのは、経路データの変換、分析、描画を担当する packages/track と、静的なジオコーディングと逆ジオコーディングを担当する packages/geocoder である。

地図表示だけならLeafletへ座標を渡せば実現できる。しかし、SNSの機能として経路データを扱うには、入力形式の違い、数万件のサンプル、センサー値、統計情報、プライバシー、保存容量、表示速度、地名検索などを一緒に考えなければならない。そこで、入力形式に依存する処理と、表示や保存に使う共通形式を分離し、地名処理も独立したパッケージにした。

全体構成

地図経路機能に関わる主な構成要素は以下の通りである。

FIT / GPX / TrackJSON
  ↓
packages/track
  ├─ 入力形式の解析
  ├─ TrackActivityへの正規化
  ├─ 統計・分析値の計算
  ├─ TrackJSONへの変換
  └─ Leafletによる描画
  ↓
packages/geocoder
  ├─ 地名から代表座標を検索
  └─ 座標から市区町村名を検索
  ↓
backend / frontend / packages/markdown
  ├─ 経路データのアップロードとプレビュー生成
  ├─ 記事内の地図記法のHTML化
  ├─ 経路一覧と元データ表示
  └─ 地図入力補助とPOIへの地名付与

packages/track は地図タイルの描画だけを行うパッケージではない。FITやGPXのような入力データを共通の中間形式へ変換し、距離、時間、標高、心拍数、ケイデンス、パワーなどを整理して、Web表示に適したTrackJSONを作るところまでを担当する。

一方の packages/geocoder は経路データの内容を知らない。地名を受け取って代表座標を返すか、座標を受け取って最寄りの行政区域を返すだけである。packages/track が生成した始点、終点、中心点、最遠点へ、後から packages/geocoder で地名を付ける。この分離により、経路変換はジオコーダーが利用できない環境でも動作し、ジオコーダーは経路機能以外の地図入力補助にも再利用できる。

TrackActivityによる入力形式の統一

FIT、GPX、GeoJSONは、同じGPS経路を表せる形式ではあるが、内部構造は大きく異なる。FITはバイナリ形式で、GPS座標に加えて、心拍数、ケイデンス、パワー、機器情報、スポーツ種別などを記録できる。GPXはXML形式で、経路やウェイポイントの交換に広く使われる。GeoJSONは地理形状をJSONで表す汎用形式だが、センサー値の標準的な格納方法は定められていない。

入力形式ごとに表示処理を作ると、FIT用、GPX用、TrackJSON用で、距離計算、ダウンサンプリング、グラフ描画、統計表示を重複実装することになる。そこで packages/track では、入力データを最初に TrackActivity へ変換する。

export type TrackActivity = {
  schemaVersion: 1;
  metadata: TrackActivityMetadata;
  points: TrackPoint[];
  pins?: TrackActivityPin[];
  warnings: TrackWarning[];
};

各サンプルは TrackPoint として表す。

export type TrackPoint = {
  time?: number;
  lat?: number;
  lon?: number;
  distanceM?: number;
  altitudeM?: number;
  heartRateBpm?: number;
  cadenceRpm?: number;
  powerW?: number;
  speedMps?: number;
  temperatureC?: number;
  metrics?: Record<string, number>;
};

値を持たない項目は undefined のままにする。GPS座標だけのGPXも、各種センサーを記録したFITも、同じ配列として扱える。機器固有の数値系列は metrics に保持できる。

FITの解析にはGarmin FIT SDKを利用するが、その出力をそのまま画面へ渡すわけではない。メッセージやフィールドを読み取り、単位を揃え、時刻や距離やセンサー値を TrackActivity へ正規化する。GPXも同じ中間形式へ変換する。その後のダウンサンプリング、統計計算、TrackJSON生成では、元の入力形式を意識しなくてよい。

TrackJSON

STGYの表示用経路形式はTrackJSONである。TrackJSONはGeoJSONの FeatureCollection を基礎として、経路ごとの表示属性、サンプル系列、アクティビティのメタデータを追加した形式である。非圧縮形式の拡張子は .trj、gzip圧縮形式の拡張子は .trjgz とする。

簡略化した例を以下に示す。

{
  "type": "FeatureCollection",
  "bbox": [138.29, 36.35, 138.34, 36.38],
  "poi": [
    {
      "role": "start",
      "coordinates": [138.2933, 36.37194],
      "label": "日本長野県東御市"
    },
    {
      "role": "end",
      "coordinates": [138.33058, 36.35977],
      "label": "日本長野県東御市"
    }
  ],
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [138.2933, 36.37194],
          [138.2941, 36.3715]
        ]
      },
      "properties": {
        "title": "Cycling",
        "coordinateProperties": {
          "times": [1780643064, 1780643065],
          "distances": [0, 83.4],
          "altitudes": [632.1, 630.8],
          "heartRates": [121, 123],
          "cadences": [78, 81],
          "powers": [148, 165],
          "speeds": [23.4, 24.1]
        }
      }
    }
  ]
}

座標の順序はGeoJSONと同じく、緯度、経度ではなく [経度, 緯度] である。coordinateProperties の各配列は経路座標と同じ添字を使う。地図上のあるサンプルを指した時に、その位置の時刻、距離、標高、心拍数などを同時に取得できる。

TrackJSONのルートには、経路全体の外接範囲である bbox と、代表点である poi を置ける。現在の生成処理では、以下の4種類のPOIを作る。

  • start : 最初の座標
  • end : 最後の座標
  • midway : 中間点の座標
  • centroid : 経路長で重み付けした球面上の中心
  • furthest : 始点から球面上で最も遠い経路点

POIの座標は packages/track だけで計算できる。label は任意であり、ジオコーダーが利用できるバックエンドやフロントエンドが後から付与する。

経路の統計と分析

FITには記録機器が計算した統計値が含まれる場合があるが、入力形式や機器によって項目が異なる。packages/track では、元データを保持しつつ、共通の規則で計算できる値を追加する。

主なメタデータは以下の通りである。

  • 開始時刻と終了時刻
  • 経過時間とタイマー時間
  • 総距離
  • 獲得標高と下降量
  • 速度、ケイデンス、心拍数、パワー、温度の平均、中央値、最大値
  • Normalized Power、総仕事量、消費カロリー
  • 速度、ケイデンス、心拍数、パワーのヒストグラム
  • パワーカーブ
  • ペダリング中の時間と各種平均値
  • 左右バランス、トルク効率、ペダルスムーズネス
  • 記録機器とソフトウェアの情報

移動中かどうかは、現在と直前の両サンプルが速度3km/h以上である区間として判定する。ペダリング中の集計では、速度3km/h以上、ケイデンス10rpm以上、パワー20W以上という条件を用いる。停止中の心拍数や信号待ちのゼロパワーを、走行中の平均へ無条件に混ぜないためである。

分析画面では、速度、ケイデンス、心拍数、パワー、トルク、勾配、標高などの組み合わせで散布図を作れる。標準では30秒の移動平均を使い、点数を最大1000件に制限し、極端な外れ値を除いて表示範囲を決める。パワーを心拍数で割ったCardio efficiency、左右値を平均したトルク効率やペダルスムーズネスも対象にできる。

これらの分析値は表示用の経路点数とは分けて保持する。地図表示のために数万点の経路を3000点へ縮約しても、元データから計算した総距離や平均値を失わないようにする。

ダウンサンプリングと数値の圧縮

GPS機器が1秒間隔で記録すると、数時間のアクティビティで数万点になる。全点を記事の地図へ送ると、転送量だけでなく、JSON解析、Leafletの経路描画、グラフ描画の負荷も大きくなる。

packages/track には、経路点を間引く二つの方式がある。

  • uniform : 元のサンプルを均等な間隔で選ぶ
  • aggregate : 区間ごとに代表点を作り、センサー値を平均する

preserveEndpoints を有効にすると、始点と終点を必ず元のサンプルのまま残す。STGYのプレビューデータでは、経路形状を不用意に変えない単純な uniform 方式を使い、最大3000点へ縮約する。

TrackJSONを保存する際には、座標や測定値の小数桁も用途に応じて丸める。位置情報に必要以上の桁数を残しても表示精度はほとんど変わらず、ファイルサイズだけが増えるからである。最後にgzip圧縮して .trjgz とすれば、記事表示用の転送量をさらに減らせる。

プライバシー保護

サイクリングやランニングのログには、自宅や勤務先が始点または終点として記録されやすい。そこで、アップロード画面には経路の始点側と終点側を難読化する機能を用意している。

既定値は始点側と終点側のそれぞれ1000mである。ただし、短い経路全体が潰れないように、入力できる距離には経路全長に応じた上限を設ける。FITではバイナリ内の座標を書き換えてCRCを更新し、GPXやTrackJSONでは共通データへ変換してから座標を加工する。

難読化はアップロード前にブラウザ上で行う。利用者がこの機能を有効にした場合、未加工の座標を含む元データを一度サーバへ送ってから隠すのではない。プライバシー保護は保存後の表示設定ではなく、保存するデータ自体への加工として実施する。

Leafletによる地図描画

TrackJSONの描画にはLeafletを使う。StgyTrackRenderer は、HTML内の figure.stgy-track-map を見つけて、経路、ピン、背景地図、グラフ、測定値表示を初期化する。

背景地図は次のレイヤーから選べる。

  • 国土地理院の淡色地図
  • 国土地理院の標準地図
  • 国土地理院の写真地図
  • CyclOSM
  • OpenStreetMap
  • OpenTopoMap

初期レイヤーが指定されていない場合、表示範囲が日本なら国土地理院の淡色地図を使い、日本以外ならCyclOSMを使う。特定の外部地図サービスだけへ依存せず、用途に応じて切り替えられるようにしている。

経路にサンプル系列があれば、地図の下にグラフを表示する。横軸は距離、時間、サンプル番号から選べる。縦軸は標高、心拍数、ケイデンス、パワー、速度、トルク効率、ペダルスムーズネス、その他の数値系列から選べる。地図上の経路とグラフ上の位置は連動し、ポインタ位置の測定値を確認できる。

地図をコンテキストクリックすると、その位置の座標を次の形式で表示する。

138.40028E, 36.40591N

ポップアップ内の文字列を選択してコピーでき、地図の別の場所をクリックすると閉じる。記事内の地図やピンを手作業で調整する際に使う。

Markdownからの地図埋め込み

STGYの記事では、画像記法の先頭を ! から @ へ変えた形式で地図を埋め込む。

@[サイクリング記録](/data/demo-toumi.trj)

経路ファイルを指定すると、packages/markdown が figure.stgy-track-map を含むHTMLへ変換し、フロントエンドが StgyTrackRenderer を初期化する。

表示オプションもMarkdown内へ指定できる。

@[サイクリング記録](/data/demo-toumi.trj){base=photo,graph=true,size=large}

主なオプションは以下の通りである。

  • base : 背景地図
  • graph : グラフの表示
  • overlay : 測定値オーバーレイの表示
  • controls : 拡大縮小やレイヤー切り替えの表示
  • float : 左右への回り込み
  • size : 地図の大きさ
  • grid : 連続する地図のグリッド配置

投稿フォームでは、カーソルが地図記法の行にある時だけ、地図用のツールバーを表示する。通常の画像やYouTube埋め込みと判定を分け、背景地図、グラフ、サイズ、配置を編集できるようにしている。

map://による簡易地図

経路ファイルがなくても、中心座標とズームを指定して地図を作れる。

@[東京都世田谷区赤堤](map://139.6444794,35.6595519,15)

書式は次の通りである。

map://経度,緯度,ズーム

ズームを省略した場合は13になる。座標には正負の数だけでなく、N、S、E、W を接尾させた表記も使える。

中心指定の後ろへ | で区切って、任意の数のピンを追加できる。

@[散歩メモ](map://139.6444794,35.6595519,15|139.6444772,35.6595498;パン屋;美味い|139.6460,35.6600;公園;休憩)

各ピンには、タイトル、説明、リンク、アップロード済み画像を付けられる。経路ファイルのURLの後ろへ同じピン記法を追加し、記録した経路上に写真やメモを置くこともできる。

map:// は人間が短い地図メモを書くための簡易形式である。大量の経路座標やセンサー値はTrackJSONへ格納し、記事からファイルを参照する方がよい。

静的ジオコーダー

地名を入力して地図を作るには、地名を代表座標へ変換する必要がある。また、経路の始点や終点を人間が読める名前で表示するには、座標から地名を引く必要がある。そのために packages/geocoder を作った。

このパッケージは、実行時にGoogle Mapsなどの外部APIへ問い合わせない。起動時にNDJSON形式の地名データを読み込み、以後はメモリ上の索引だけで検索する。

import { GeoCoder } from "stgy-geocoder";

const geoCoder = new GeoCoder([
  "packages/geocoder/data/geo-japan.ndjson",
]);

console.log(geoCoder.encode("埼玉県所沢市", "ja-JP"));
console.log(geoCoder.decode(139.46, 35.80, "ja-JP"));

Web APIとしては、次の二つを公開する。

GET /geo/encode?query=埼玉県所沢市&locale=ja-JP
X-STGY-QueryHash: <sha1>

GET /geo/decode?longitude=139.46&latitude=35.80&locale=ja-JP
X-STGY-QueryHash: <sha1>

encode は地名から代表座標を返し、decode は座標に最も近い行政区域とその親階層を返す。有効なログインセッションがある場合、バックエンドは従来通りログインユーザとして処理し、query-hashヘッダは必須ではない。フロントエンドの共通Geoクライアントはログイン状態に関係なく常に X-STGY-QueryHash を送るので、同じ呼び出しコードをログイン画面と匿名画面の両方で使える。

匿名アクセスとX-STGY-QueryHash

ログインユーザを取得できない場合は匿名アクセスへフォールバックする。セッションCookieが無い場合だけでなく、期限切れなどでCookieから有効なユーザを復元できない場合も同じである。匿名アクセスでは X-STGY-QueryHash を必須とし、不正または欠落していればGeoCoderを呼ぶ前に 403 invalid queryhash を返す。

正規化規則は外部公開投稿検索と共通である。全クエリパラメータをURLデコードした後、key/value を encodeURIComponent と同じ規則で再エンコードして key=value にする。重複 key は残し、要素全体を辞書順でソートして & で連結する。そのUTF-8文字列のSHA-1を小文字16進表記にし、URLではなく X-STGY-QueryHash ヘッダへ入れる。バックエンドも全クエリパラメータから期待値を計算する。

?c=111&a=222&b=444&b=333
  ↓
a=222&b=333&b=444&c=111
  ↓
X-STGY-QueryHash: d5dd857313b1b13cdda08e5d9ed98fef264884d7

秘密情報を使わないため、アルゴリズムを理解したbotは同じヘッダを作れ、認証や直接攻撃への防御にはならない。ただしカスタムヘッダに置くことで、別originのブラウザJavaScriptからは単純なcross-origin requestとして送れず、先にCORS preflightを通す必要がある。STGYバックエンドは許可originを設定済みのfrontendに限定しているため、無関係なサイト上のJavaScriptから、このヘッダを伴うGeo APIリクエストを直接送る経路を塞げる。また、発見したURLをそのまま反復する単純なクローラも安価に弾ける。

Geo APIの処理時間は既存の DailyTimerThrottleService("geo") で制限する。ログインユーザはユーザごとの枠を使い、匿名アクセスは固定のダミーユーザへフォールバックするため全匿名利用者で一つの枠を共有する。STGY_DAILY_GEO_TIMER_LIMIT_MS のデフォルトは120秒/日である。管理者はこの制限を受けない。query-hashヘッダはこのタイマースロットリングより前で検証するので、正しいヘッダを持たないリクエストはGeoCoderの処理時間枠を消費しない。本番Caddyではこれとは独立して /backend/geo/encode と /backend/geo/decode を合わせて接続元IPごと60回/1時間に制限し、さらにSTGY全体の通常制限としてデフォルト120回/5分/IPも残す。

地理階層と多言語住所

地名レコードの level は国を第1階層とする深さであり、kind は行政単位の意味である。

levelkind日本語の例英語の例
1country日本Japan
2prefecture日本神奈川県Kanagawa, Japan
3municipality日本神奈川県横浜市Yokohama, Kanagawa, Japan
3special-ward日本東京都世田谷区Setagaya, Tokyo, Japan
4designated-city-ward日本神奈川県横浜市鶴見区Tsurumi, Yokohama, Kanagawa, Japan

日本の国レコードのIDにはISO 3166-1 numericの392を使い、代表点には東京都港区麻布台の日本経緯度原点を使う。

各住所は国から対象地点までの完全な elements を持つ。

{
  "locale": "ja",
  "label": "日本神奈川県横浜市鶴見区",
  "elements": ["日本", "神奈川県", "横浜市", "鶴見区"],
  "aliases": ["鶴見"]
}
{
  "locale": "en",
  "label": "Tsurumi, Yokohama, Kanagawa, Japan",
  "elements": ["Japan", "Kanagawa", "Yokohama", "Tsurumi"],
  "aliases": [
    "Tsurumi ward",
    "Yokohama Tsurumi",
    "Yokohama Tsurumi ward"
  ]
}

検索索引を作る際には、完全な住所だけでなく、先頭階層を順に省略した住所も自動的に登録する。したがって、次のいずれでも同じ行政区を検索できる。

日本神奈川県横浜市鶴見区
神奈川県横浜市鶴見区
横浜市鶴見区
鶴見区
鶴見

階層の省略だけで得られる 鶴見区 や 横浜市鶴見区 は、aliases に重複して保存しない。aliases には 鶴見 や Tsurumi ward のように、住所要素の単純な連結からは作れない表記だけを入れる。

英語の検索キーは小文字へ正規化するので、大文字と小文字を区別しない。

Tsurumi, Yokohama
tsurumi, yokohama
TSURUMI, YOKOHAMA

これらは同じ検索になる。ただし部分一致ではなく、登録済みの検索キーまたはaliasとの完全一致である。

ロケールの解決

packages/geocoder は、データのロード時に全住所を調べ、一つ以上存在するロケールをサポート対象とする。現在の日本データでは en と ja が得られる。

デフォルトロケールは Config.DEFAULT_LOCALE であり、現在は en である。指定されたロケールは、ja_JP を ja-JP にするなどの正規化を行った後、次の順に解決する。

1. ja-JP のような完全なロケール
2. ja のような基本言語
3. Config.DEFAULT_LOCALE

したがって、現在のデータで ja-JP を指定すると ja が使われる。未知のロケールを指定した場合や、ロケールを省略した場合は英語になる。

英語以外のロケールが解決された場合、encode はそのロケールとデフォルトの英語の両方の検索空間を調べる。日本語環境でも英語の地名を入力できるようにするためである。

返却される各地名レコードには、デフォルトの英語住所を含める。日本語が指定され、そのレコードに日本語住所が存在する場合は、英語住所に続けて日本語住所も返す。それ以外のロケールは結果から除く。

日本の地名データ生成

日本の地名データは、国土交通省の国土数値情報N03行政区域データと、デジタル庁のアドレス・ベース・レジストリから生成する。

N03行政区域ポリゴン
  ├─ 都道府県
  ├─ 市町村
  ├─ 東京都特別区
  └─ 政令指定都市区

アドレス・ベース・レジストリ
  └─ 都道府県・市区町村・行政区の英語名

生成コマンドは以下である。

npm run generate:japan --workspace packages/geocoder

N03から行政区域と地方公共団体コードを読み、ABRの日本語名称と対応付けて英語住所を付与する。ABRの公開データ取得にはAPIキーを必要としない。英語名を対応付けられなかったレコードは日本語住所だけを残し、生成時に警告を出す。

市町村や行政区の代表点には、最大の構成面の内部に確実に入る点を使う。単純な幾何学的重心は、湾曲した区域や複数の島からなる区域ではポリゴン外へ出る可能性があるからである。

逆ジオコーディングの補助点

市役所などの代表点だけで最近傍探索をすると、面積の広い市町村や複雑な境界で誤判定が増える。かといって、実行時に全国の行政区域ポリゴンを読み込み、毎回包含判定を行うと、メモリと処理量が大きくなる。

そこで、生成時に行政区域内へ2km間隔の補助点を置く。経度緯度へ一定値を加えるのではなく、行政区域をメートル単位の投影座標へ変換して、全国共通の格子を作る。格子点がポリゴン内にある場合だけ、その市区町村または行政区のIDと一緒に保存する。

小さな離島や細長い飛び地には2km格子の点が入らないことがある。面積0.25km²以上で、代表点も格子点もない構成面には、内部の補完点を1個追加する。

NDJSON内の補助点は以下のような単純なレコードである。

{"longitude":139.450742,"latitude":35.794495,"belongTo":11208}

ロード後は補助点をJavaScriptオブジェクトとして保持せず、経度と緯度を Float32Array、所属先IDを Uint16Array または Uint32Array へ格納する。ポリゴンや大量の文字列を持たないので、数万件の補助点を小さいメモリで保持できる。

代表点と補助点は緯度順に並べる。decode では指定座標から南北10kmの緯度範囲だけを二分探索で切り出し、その候補に対して球面距離を計算する。最近傍点が10kmより遠い場合は該当なしとする。

国と都道府県を別々に最近傍探索することはしない。最も具体的な市町村、特別区、政令指定都市区のいずれかを決め、そのレコードの住所要素から都道府県と国を復元する。これにより、市区町村と都道府県が異なる階層から選ばれる不整合を防ぐ。

経路POIへの地名付与

packages/track がTrackJSONを生成した時点では、POIは座標だけでも有効である。バックエンドでプレビューを作る場合は、その座標を GeoCoder.decode へ渡し、最も具体的な地名のlabelをPOIへ設定する。

TrackJSONのPOI座標
  ↓
GeoCoder.decode
  ↓
最も具体的な地名レコード
  ↓
POIのlabelへ設定

バックエンドの経路プレビュー生成では日本語を明示しているため、保存されるlabelは次のようになる。

"poi": [
  {
    "role": "start",
    "coordinates": [138.2933, 36.37194],
    "label": "日本長野県東御市"
  },
  {
    "role": "furthest",
    "coordinates": [138.33407, 36.35316],
    "label": "日本長野県上田市"
  }
]

Local Track Studio はログインを前提にしないため、ブラウザの navigator.languages または navigator.language を使う。英語環境なら Tomi, Nagano, Japan のようなlabelになる。POIの逆ジオコーディングはフロントエンド共通の decodeGeo を通り、そこで全クエリパラメータからハッシュを計算して X-STGY-QueryHash ヘッダに入れてから /geo/decode を呼ぶ。そのため匿名の Local Track Studio も、上記のquery-hashヘッダフィルタと匿名共有タイマースロットリングを通る。

この処理は packages/track の内部へ直接組み込まない。applyTrackJsonPoiLabels は、座標とlabelの対応を受け取り、座標が一致するPOIへlabelを設定するだけである。地名データを持たない単独のTrackJSON変換ツールでも packages/track を利用できる。

地名による地図入力補助

投稿フォームの「@」ボタンで開く検索ダイアログでは、入力の先頭が % の場合に地名検索を行う。

%埼玉県所沢市

フロントエンドはログインユーザのロケールを /geo/encode へ渡す。共通の encodeGeo クライアントはこの場合にも X-STGY-QueryHash を送るが、有効なログインセッションがあるためバックエンド側ではヘッダを必須条件にはしない。検索結果から代表座標を取得し、次のような map:// 記法を本文へ挿入する。

@[日本埼玉県所沢市](map://139.461129,35.803146)

挿入された地図をプレビューし、目的地点まで拡大してコンテキストクリックすれば、より細かい座標を調べられる。市区町村精度のジオコーダーと、地図上の座標取得を組み合わせることで、町名や施設レベルのデータを持たなくても地図入力を補助できる。

経路ストレージとの関係

経路データの保存にはS3または互換ストレージを使う。マスターとしてFITまたはTRJGZを保存し、記事表示用には最大3000点へ縮約したTRJGZプレビューを別に作る。GPXと非圧縮TRJは、ブラウザ上でTRJGZへ正規化してからアップロードする。

ストレージのキー設計、presigned-POST、月間クォータ、finalize時の検査、マスターとプレビューの対応については、STGYのメディアストレージで説明している。本記事では、保存前後のデータ解析と表示を packages/track が担当し、プレビューのPOIへ packages/geocoder が地名を付けるという関係だけ押さえておけばよい。

開発用の画面とコマンド

packages/track には、FITやTrackJSONをブラウザで読み込んで変換結果を確認する fit-demo.html がある。フロントエンドには、同じ変換機能を使った /local-track-studio がある。ファイルを選ぶと、経路、メタデータ、グラフ、分析結果、生成されたTrackJSONを確認できる。

パッケージ単体の主なコマンドは以下である。

npm run build --workspace packages/track
npm run test --workspace packages/track
npm run check --workspace packages/track
npm run lint --workspace packages/track
npm run bundle --workspace packages/track

ジオコーダーはコマンドラインからも確認できる。

npm run encode --workspace packages/geocoder -- "埼玉県所沢市" ja-JP
npm run decode --workspace packages/geocoder -- 139.46 35.80 ja-JP

日本データを再生成して検証する場合は以下を使う。

npm run generate:japan --workspace packages/geocoder
npm run validate:data --workspace packages/geocoder

通常の利用者はリポジトリに含まれる生成済みNDJSONを使うので、N03やABRから自分で再生成する必要はない。

現在の制限

地図経路機能には以下の制限がある。

  • TrackJSONはGeoJSONを基礎とする独自拡張であり、全てのGISソフトがセンサー系列やメタデータを解釈できるわけではない。
  • FITの機器固有メッセージを全てTrackJSONへ写すわけではない。再利用のため、FITのマスターは原則としてFITのまま保存する。
  • 地図表示用のプレビューは最大3000点に縮約するため、細かい蛇行や短い変化が省略される場合がある。
  • 逆ジオコーディングは行政区域ポリゴンの厳密な包含判定ではなく、代表点と補助点による最近傍探索である。
  • ジオコーダーが扱うのは国、都道府県、市区町村、特別区、政令指定都市区までであり、町名、番地、建物は扱わない。
  • 地理階層には国レコードを導入したが、現在の生成処理と階層復元は日本データを前提としている。
  • map:// は簡潔さを優先した人間向け記法であり、複雑なGeoJSONの代替にはならない。
  • 地図タイルは外部配信サービスを利用するため、タイル配信自体を完全に自前化しているわけではない。

これらは、SNSの記事へ経路を貼り付け、日常的な地図メモやアクティビティの閲覧を行うという用途に合わせた割り切りである。町名や建物まで含む厳密なジオコーディングや、大規模なGIS解析を行う場合には、専用のデータや外部サービスを併用すべきである。

まとめ

STGYの地図経路機能では、packages/track が入力形式の解析、共通形式への正規化、統計計算、TrackJSON生成、Leaflet描画を担当する。FIT、GPX、TRJ、TRJGZを同じ TrackActivity へ変換することで、入力形式ごとの処理を表示層から分離した。

表示用のTrackJSONはGeoJSONを基礎とし、経路座標に時刻、距離、標高、心拍数、ケイデンス、パワー、速度などの系列を対応付ける。経路全体のメタデータと、始点、終点、中心点、最遠点のPOIも保持する。ブラウザではLeafletの地図と測定値グラフを連動させ、Markdownの @[caption](URL) 記法や map:// 記法から記事へ埋め込む。

packages/geocoder は、N03とアドレス・ベース・レジストリから生成した静的NDJSONを起動時に読み込み、地名検索と逆ジオコーディングをメモリ上で行う。日本語と英語の住所、ロケールフォールバック、大文字小文字を無視した検索、国名を省略した階層検索に対応する。逆ジオコーディングでは、2km格子の補助点をTypedArrayへ格納し、緯度範囲を二分探索で絞って最近傍を求める。Web APIはログインユーザには従来の認証とユーザ別タイマースロットリングを使い、匿名時には X-STGY-QueryHash を確認して共有ダミーユーザのタイマースロットリングへフォールバックする。

両者は直接依存させず、TrackJSONのPOI座標へ後処理で地名を付ける。この分離により、経路変換、地図描画、地名検索をそれぞれ単独で利用できる一方、STGY上では記事入力、経路アップロード、一覧表示、地名付きメタデータとして一体化できる。

地図や経路はSNSの中心機能ではないが、文章と画像だけでは表現しにくい「どこで何をしたか」を記録するには有用である。汎用GISを組み込むのではなく、必要な入力形式、分析項目、行政精度に限定し、静的データとブラウザ描画を組み合わせることで、単一ホストでも運用しやすい機能にしている。

Next: STGYの記事外部公開機能