Skip to content

Draw large polyline sets as raster tiles (PolylineTiler) - #36

Merged
wf9a5m75 merged 9 commits into
developfrom
feat/polyline-tiling
Nov 10, 2026
Merged

wf9a5m75 merged 9 commits into
developfrom
feat/polyline-tiling

Conversation

@wf9a5m75

@wf9a5m75 wf9a5m75 commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

なぜ

ポリゴンと同じように、ポリラインも数万本を渡せるようにするためです。ボストン市内の道路(54,645 本・234,765 頂点)をネイティブの線で描くと、iOS の Google で全 onAdded まで 114 秒・1.5 GB、Android の Google は Java ヒープが尽きて落ちます。ポリゴンのタイル化(feat/polygon-tiling)と同じ仕組みで、多数のポリラインをラスタータイルとして描きます。

設計の要点(web・Android・iOS で同じ)

  • Polylines(states): Polygons と同じ形のまとめて渡す部品。そのインスタンスが足した id だけを足し引きするので、複数の Polylines / Polyline が同じ地図で共存できます(iOS は既存の Polylines(_:) のまま)。
  • PolylineTilingOptions / PolylineTileSet / PolylineTiler / PolylineController.tiler: 名前と既定値(2,000 本以上または頂点 50,000 以上)はポリゴンと 1 対 1。レイヤー id は mc-polyline-tiles。
  • ポリゴンのタイル化と内部を共通化: タイル集合(ルート・URL の版・16 ms のまとめ・削除)、振り分け、描画(格子索引・ズームごとの簡略化・LRU キャッシュ・無効化・スクラッチのプール・PNG・キャンセル)を内部の ShapeTileSet / ShapeTiler / ShapeTileRenderer に移し、Polygon* はその上の薄い層になりました。公開 API は追加のみです。
  • 描き方: 開いた経路として、ズームの画素格子に丸め → 重複を捨て → Douglas-Peucker 0.5 px(両端は残す)。round cap / round join。線幅は画面上の幅(dp / pt / CSS px)でズームに依らない。候補は線幅の半分だけタイルの外まで引き、はみ出す分もそのまま描くので、タイルの境目で線が切れません。1 px 未満に潰れた線は線幅の点で描きます。
  • タイルにできないもの: geodesic、2 点未満、経度幅 180° 以上。その場の変更でそうなった線はネイティブ描画へ移ります(onAdded は呼ばない)。破線・キャップの指定は API に無いので対象外です。
  • onAdded: ポリゴンと同じ規則(初めて地図に載ったときだけ。タイル⇔ネイティブの移動では呼ばない)。
  • 地図ごとの指定: web はプロバイダの props / config の polylineTilingOptions、Android は compose の PolylineTiling(options)、iOS は MapViewContent.polylineTilingOptions / PolylineTiling 要素。
  • 重なり順: ポリラインのタイルはポリラインの帯の最下段(サークルの上、ネイティブのポリラインとマーカーの下)。下から raster → GroundImage → polygon(+タイル) → circle → polyline(+タイル) → marker(+タイル)。

このリポジトリで変えたこと

  • PolylineTilingOptions / PolylineTileSet / PolylineTiler / POLYLINE_TILE_LAYER_ID / polylineTileRasterLayerState と PolylineController.tiler。タイルで描く線は PolylineManager に残り(タップ用)、entity の polyline は null。
  • ポリゴンと共通の内部部品 ShapeTileSet(ルート・URL の版・16 ms のまとめ・削除スロット)/ ShapeTiler / ShapeTileRenderer(格子索引・ズームごとの簡略化・LRU と線幅ぶんの無効化・Bitmap のプール・PNG・キャンセル)/ ShapeGridIndex。PolygonTileSet / PolygonTiler / PolygonTileRenderer はその上の薄い層。
  • 開いた経路を dp の線幅で、round cap/join、線幅の半分だけ外の候補を丸ごと描く。1 px 未満は点。
  • タップ: tapTolerance + strokeWidth / 2、最も近い線、登録時に作る外接矩形で先に絞る。
  • PolylineOverlay が地図ごとの PolylineTilingOptions の flow を受け取る(引数の追加。1 引数のコンストラクタは残る)。
  • feat/overlay-on-added を merge commit で取り込み(その中身は develop の API ベースラインの修正 Record the hole union's EdgeGrid and IntArrayList in the API baseline #34 のみ)。

検証(Android、追従後の 158d1ee2)

  • 単体テスト(testDebugUnitTest、--rerun): core 375・compose 10・arcgis 18・googlemaps 5・here 6・longdo 6・mapbox 1・mappls 4・maptiler 9・tomtom 3・example-app 13 すべて通過。openmobilemaps は 17 件中 1 件失敗(OpenMobileMapsDriverConformanceTest、develop の 5df7b09 で入った Log.isLoggable がモックされていないため。このブランチの差分は触れていない)。
  • apiCheck: 12 モジュールすべて通過。base との差分は公開 API の追加のみ(core の削除行は非 public の PolygonTileRenderer$Model / $Simplified と、PolylineOverlay の 1 引数コンストラクタの行の位置が動いただけ)。
  • assembleDebug 通過。親ビルドの ktlintFormat は回していません。
  • 実機: 追従前の build でタブレットにてボストンの道路のページ(MapLibre / Google、タイルで全件・タップ・重なり順・切り替えのメモリ)を確認済み。追従後の APK はタブレットが adb に見えず未確認です。

挙動の変わる所

  • タップの判定に線幅の半分を足しました(3 プラットフォームの core の PolylineManager.find、タイルでもネイティブでも同じ)。許容距離 = 既存の指の大きさ(tapTolerance)+ 線幅 / 2。太い線は縁から指の大きさ以内であたります。複数あたれば最も近い線。外接矩形で先に候補を絞るので、5 万本でも毎タップ全点をなめません。返す位置(線上の最近点)は今までどおりです。
  • iOS TomTom: 合成スタイルで、SDK のタイル層(ポリゴン・ポリライン・マーカーのタイル)をスタイルの最上段に置きます。TomTom のスタイルは最初の symbol レイヤーより後ろに道路があり、「最初のラベルの前」では道路のタイルが覆われていたためです。そのためポリゴンのタイルも TomTom の道路・ラベルの上に来ます。annotation(ネイティブのサークル・線・マーカー)はこれまでどおりスタイル全体の上です。
  • web Google: overlayMapTypes の中でマーカーのタイルを最上位にしました(ポリゴン < ポリライン < マーカー)。3D 地図ではタイル化せず、すべてネイティブで描きます。
  • web MapLibre 系(maplibre / mapbox / maptiler / mappls / tomtom / longdo): GeoJSON で描くネイティブのポリラインを zIndex 順に並べるようにしました(これまでは渡した順)。タイルとネイティブで重なり方がそろいます。
  • iOS MapLibre / MapTiler / Mappls: raster レンダラが SDK タイル層の重なりのキーを id から取るようにしました(OverlayStacking.rasterKey(styleLayerId:))。追加の順に関係なく、ポリラインのタイルがネイティブのポリラインの下に入ります。

SDK の制約で重なり順を守れないもの

  • web Google(2D): overlayMapTypes は図形・GroundOverlay より必ず下に描かれるので、サークルとネイティブのポリゴンが道路のタイルの上に出ます(ポリゴンのタイル < 道路のタイル < マーカーは守れています)。
  • web Cesium: imagery レイヤーは ground primitive より必ず下。アプリのラスターより上に置くところまで。
  • Android HERE / ArcGIS / TomTom: ポリラインのタイルが SDK のオーバーレイ層(HERE は map item、ArcGIS は GraphicsOverlay)より下になるので、ネイティブのポリゴン・サークルがタイルの上に来ます。ポリゴンのタイルより上、マーカー(とそのタイル)より下は守れています。
  • iOS TomTom: annotation がスタイル全体の上に描かれるので、サークルがポリラインのタイルの上に来ます。ポリゴンのタイルも道路の上(上記)。
  • iOS Longdo / HERE / ArcGIS: z16 の画像を目で見た範囲では、サークルが道路のタイルの上に見えます(画素では測っていない)。iOS OMM: ポリゴンのタイルがほとんど見えません(要調査)。

確認できていないもの

  • Android Google のネイティブ比較: 54,645 本をネイティブで描かせると、main スレッドが OutOfMemoryError で落ちます(GoogleMap.addPolyline → PolylineOptions.writeToParcel、Java ヒープが上限 512 MB に到達)。1 本ずつ Parcel に詰めて GMS に渡すためで、タイル化ありなら落ちません。このため Google のタイル化あり/なしの時間は並べられていません。
  • Android の追従後の実機確認: develop を取り込んだ後の APK は、タブレットが adb に見えず実機で確かめていません(単体テストと apiCheck は通過)。追従前の build では MapLibre / Google で確認済みです。
  • web MapKit: トークンが 401 で地図が出ず、描画と重なり順は確かめていません(タップ判定は 43/43)。
  • web ArcGIS の初回タップ: カメラが一度動くまで当たりません(地図を一度動かすと 43/43)。
  • web Mappls: 右端でタップの位置がずれます(36/43)。
  • iOS の Mac 側照合のずれ: タップ試験の Mac 側の照合(初期カメラを仮定して最寄りの道路を計算)が、Google 16/30・TomTom 18/30・ArcGIS 13/30・ArcGIS2D 10/30・MapKit 11/30 しか合いません。アプリが返した最近点はどれも実在の道路の線から 0.05 pt 以内なので、判定の誤りではなく、Mac 側が仮定するカメラ(ズームの定義・safe area・2D の傾き補正)が SDK と合っていないと見ていますが、確かめてはいません。
  • 既存の失敗(このブランチ由来ではない): Android OMM の OpenMobileMapsDriverConformanceTest 1 件(develop の Log.isLoggable がモックされていない)、iOS ArcGIS・TomTom の単体テスト各 1 件、iOS Google のテストターゲットのビルド(GoogleMaps が iOS 17 を要求)。

base について

ポリラインの差分だけをレビューできるように、base は feat/overlay-on-added(feat/polygon-tiling + onAdded + ボストンのサンプル。そのリポジトリに無い場合は develop)にしています。feat/polygon-tiling は develop にマージ済みなので、feat/overlay-on-added が develop に入ったら、この PR の base を develop に付け替えてください。
追従は rebase せず merge commit で行いました(origin/feat/polygon-tiling → feat/overlay-on-added → feat/polyline-tiling)。

マージの順番

各リポジトリの feat/overlay-on-added →(base を develop に付け替えて)android-sdk-core → android-sdk-compose → 各プロバイダ → 親。プロバイダは android-sdk-core の PolylineTiler / 重なり順を使うので core が先です。マージはレビューのうえで判断してください(この PR はまだマージしていません)。

親の PR: MapConductor/android-sdk#119

Marker, Circle, Polyline, Polygon, GroundImage and RasterLayer states take an
optional onAdded, called on the main thread each time the overlay goes from
not on the map to on it: after the provider made its native object, or, for
tiled overlays, after the tile template was handed to the map as a raster
layer (tile images may still be loading). In-place updates, moves between
native drawing and tiles, clear and destroy call nothing; remove then add
calls again. A throwing callback is logged and does not stop the others.

It is kept by each overlay (OverlayAddedTracker), which asks the map hasXxx
after compositionXxx returns, so every provider is covered, including those
that add without the core controllers. Old constructors and copy() stay as
hidden overloads for binary compatibility. Same as ios-sdk-core and
js-sdk-core.
…ge makes it untileable

Whether a polygon is tiled was decided only on add. Setting geodesic on a
tiled polygon in place, or giving it points that span half the world or fewer
than three, kept it in the tiles, drawn with straight edges. update() now
checks canTile: a polygon that fails it leaves the tiles (its slot in the
renderer is emptied, so only the tiles it was in are drawn again) and is
handed to the renderer. It stays on the map, so this is not an add. The
reverse is left alone: a native polygon that could now be tiled already looks
right. Same as js-sdk-core.
…dded where it is called

The move-to-native tests used exactly 2,000 parcels, so re-adding the list
dropped below the threshold. onAdded is called by PolygonOverlay, not by the
controller, so the no-onAdded-on-move test belongs in OverlayOnAddedDeviceTest.
# Conflicts:
#	api/android-sdk-core.api
Additions only (this branch had not run apiDump); in core, one member moved.
PolylineTilingOptions / PolylineTileSet / PolylineTiler / POLYLINE_TILE_LAYER_ID /
polylineTileRasterLayerState and PolylineController.tiler, the same shape as the
polygon tiling: tiled polylines stay in PolylineManager for taps and give the
renderer nothing (their entity's polyline is null).

The tiles share everything below the polygon ones: ShapeTileSet (route, version
in the URL, 16 ms coalescing, removal slots), ShapeTiler (routing), ShapeTileRenderer
(grid index, per-zoom simplification, LRU cache and stroke-wide invalidation,
pooled scratch bitmaps, PNG, cancellation) and ShapeGridIndex. PolygonTileSet,
PolygonTiler and PolygonTileRenderer are thin layers over them now.

A polyline tile is an open path: snapped to the zoom's pixel grid,
Douglas-Peucker at half a pixel with both ends kept, round caps and joins, the
stroke as wide in dp at every zoom, a dot as wide as the line when it collapses
to a point. Candidates are taken half the widest stroke beyond the tile and drawn
whole, so a line crossing a tile edge is the same on both sides.

Taps: tapTolerance + strokeWidth / 2, nearest line, with each line's bounding box
(made on registration) checked first.

PolylineOverlay takes an optional per-map PolylineTilingOptions flow, applied to
the map's tilers before every hand-over.
@wf9a5m75
wf9a5m75 changed the base branch from feat/overlay-on-added to develop November 10, 2026 01:54
@wf9a5m75

Copy link
Copy Markdown
Contributor Author

向け先を feat/overlay-on-added から develop に変えました。このため、この PR にはボストンのサンプル(feat/overlay-on-added:onAdded の数え方、MCPG v2 のデータ同梱など)の commit も含まれます。

@wf9a5m75
wf9a5m75 merged commit 8a42e39 into develop Nov 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant