Skip to content

Draw large polygon sets as raster tiles (PolygonTiler) - #33

Merged
wf9a5m75 merged 12 commits into
developfrom
feat/polygon-tiling
Nov 9, 2026
Merged

wf9a5m75 merged 12 commits into
developfrom
feat/polygon-tiling

Conversation

@wf9a5m75

@wf9a5m75 wf9a5m75 commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

何をするか

多数のポリゴン(既定: 2,000 個以上または頂点 50,000 以上)を、ネイティブの図形ではなくラスタータイルとして描きます(PolygonTilingOptions / PolygonTiler)。web・Android・iOS で同じ設計です。

  • タイルは SDK のタイルサーバー(web はサービスワーカー、ネイティブは LocalTileServer)で描きます。ズームごとに画素格子へ丸めて簡略化し、外周と穴の巻きを逆にそろえて nonzero で塗ります。
  • タップ判定は従来どおり PolygonManager が元の形で行います(InfoBubble などは今までどおり)。
  • タイルにできなくなった変更(geodesic にする、経度幅 180° 以上、3 点未満)が入ったポリゴンは、その場でネイティブ描画へ戻ります。
  • ボストン全域の区画(98,815 件・穴あり)と建物(105,599 件)で確認しています。

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

  • PolygonTilingOptions / PolygonTiler / タイル描画。PolygonController がタイル側のポリゴンを PolygonManager に残し、ネイティブの図形は作りません。タイルにできなくなった変更が入ったらネイティブ描画へ移します。
  • OverlayCollector / Map content: 同じリストの再同期を早く返す(10 万件規模での再評価のため)。
  • ポリゴンのタップ判定: 辺の上と見なすのを eps 以内に限定。
  • holesAreDisjoint(穴が離れていれば結合を飛ばす近道)を、develop の穴の結合(穴の結合で、近くにある穴の面積を失わないようにし、速くする #32)の EdgeGrid と ringBounds / meetPad に乗せ換え、独自の格子を消しました(衝突は PolygonUnion.swift の 1 か所)。

develop の取り込み

このブランチは push 済みだったので、rebase せず origin/develop を merge commit で取り込みました。

  • ios-sdk-core の xcodebuild test(シミュレータ): 406 件 pass(develop の穴の結合の fixture を含む)。scripts/api-surface.sh check: 変えたモジュールは全部 ok、ベースラインの更新は不要。
  • iPad 実機(overlay-on-added のサンプル、Release)でボストンの区画 98,815 件・建物 105,599 件を MapLibre / Google / Mapbox / MapTiler / Longdo で表示: 約 1 秒で全件が載り、街区(穴)が正しく抜ける。MapLibre / Google でタップ 40 件の判定がデータと一致(穴あり区画を含む)。頂点ドラッグの追従(Polygon ページ、MapLibre / Google)。

マージの順番

この機能は複数のリポジトリにまたがります。ios-sdk-core → 各 ios-for- → ios-sdk(親)* の順にマージが要ります。

この PR は大きな機能なので、マージするかはレビューのうえで判断してください。

pointOnSegment compared the dot product (p-a)·(p-b), a quantity in square
degrees, with eps = 1e-6, so any tap within about 0.001° (~100 m) of the
middle of a short edge counted as on that edge. With building footprints the
first nearby building in the list was returned instead of the tapped one.
Now it is the plain distance to the segment, within 1e-6° (~0.1 m).
Holes that neither cross, touch nor contain one another are their own union.
The check bins the edges into a grid, so it is linear; the union is quadratic
in the hole edges, which a street-network parcel with 80 holes and 8,000 hole
vertices turned into seconds. The rings still come back clockwise, as the
union returns them. js-sdk-core takes the same shortcut.
SwiftUI re-evaluates the map content on every camera frame and the host syncs
the same states back into the collector. Diffing them cost a set, a dictionary
and a listener per state: 260 ms a frame for Boston's 98,843 parcels on an
iPad Pro, so every pan ran at 5 fps. The same instances in the same order have
not changed membership and are already listened to.
The iOS counterpart of js-sdk-core's PolygonTiler / PolygonTileSet and the
polygon section of tile-sw.js, drawn the same way:

- PolygonTilingOptions: tile from 2,000 polygons or 50,000 vertices.
- PolygonTileRenderer (a TileProvider on the LocalTileServer): Web Mercator
  rings with the outline and holes wound opposite ways and filled nonzero; a
  grid index of bounding boxes (one z12 tile per cell); per-zoom simplified
  copies (snap to the pixel grid, drop repeats, Douglas-Peucker at 0.5 px, a
  dot under 1 px), four zooms kept; outline only, left out under 4 px; tiles
  cached without the version, and an in-place change drops only the tiles the
  polygon overlaps.
- PolygonTileSet: route, version in the URL, 16 ms coalescing of changes.
- PolygonController.tiler: tiled polygons stay in the PolygonManager for taps
  (original shapes) with no native polygon; the renderer never sees them.

PolygonManager.find tests only the polygons whose bounds hold the tap, so a
tap among 100,000 tiled parcels no longer reads every state.

Driver-facing types are @_spi(MapConductorDriver); PolygonTilingOptions is
public. Tests mirror PolygonTiling.test / RasterLayerCoexistence.test.
… state

Two costs that every camera frame paid for each overlay, measured on an iPad
Pro with Boston's 98,843 parcels:

- The same-list check compared states one by one through the generic, 14 ms
  a frame. The states are class references, so the two buffers are compared
  with memcmp.
- The listener captured each state weakly. A weak reference gives the object a
  side table, and from then on every retain and release of it takes the slow
  path; the map content holds each state again on every frame, so that was a
  third of a pan's main-thread time. The listener now finds the state by id
  and checks its identity instead.
…lement

SwiftUI rebuilds the map content on every camera frame. A single-expression
builder went through buildBlock, which merged it into a fresh content, and
Markers/Polygons/... built a list and then appended it: two copies of every
list, each a retain and a release per overlay. Merging into an empty list now
takes the other list's buffer, buildBlock returns a lone component as it is,
and the collection items assign into an empty list.

With 98,843 parcels in tiles, a pan went from about 30 to about 80 fps on the
iPad Pro (117 fps with 605 polygons).
… its state

The marker tiles check the server's port on every content update; the
polygon tiles now do the same (refreshIfServerMoved), since the server comes
back on another port after the app has been in the background.

route() runs wherever PolygonController.add runs, off the main thread, while
the content update calls refreshIfServerMoved() on the main thread, so the
tiled ids, states and the shown base URL are read and written under a lock.
The map hosts set it from their camera callbacks on the main thread, and
onCameraChanged, which the overlay registry runs in a task off the main
thread, sets it too. Two unguarded writes of a class reference release the
old camera twice. Once pans ran at 75 fps with 100,000 tiled parcels on
MapLibre, the app crashed a few pans in (EXC_BAD_ACCESS releasing the camera
captured by the regionIsChanging task); with the lock, none in four runs of
six pans each.
An internal initializer that skipped the fingerprint made the class lose its
complete set of public designated initializers, which changes the published
interface (@_hasMissingDesignatedInitializers). The fingerprint is computed
after the tiles have been handed to the map, so it only delays when taps
start resolving, not when the polygons show.
…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.
Conflict in PolygonUnion.swift: develop's unionHoleRings files edges in an EdgeGrid and skips rings by their Bounds; feat/polygon-tiling's holesAreDisjoint had its own grid and ringBox. holesAreDisjoint now uses EdgeGrid with meetPad and ringBounds, so there is one grid.
@wf9a5m75
wf9a5m75 merged commit 663976f into develop Nov 9, 2026
2 of 3 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