Mapboxの非表示シンボルを扱う (1. 衝突ボックス編)
2022-08-20 (2022-09-16 更新)
Mapbox GL JSの画面上で別のシンボルに隠されたシンボルを扱うユーティリティライブラリを開発中です。 これはライブラリの開発過程を紹介するシリーズの最初のブログ投稿です。
背景
Mapbox GL JS (mapbox-gl-js)はMapboxのマップをウェブブラウザ上に表示するためのJavaScriptライブラリです。
mapbox-gl-jsはMapboxのプロプライエタリなライブラリですが、オープンソース化されており彼らのサービス規約に沿う限り改変することができます。
Symbol Layerを使用するとマップ上の点に任意のシンボルを表示することができます。
以下の画像は私が開発中のアプリのスクリーンショットで、落とし物のアイコンが「シンボル」です。

シンボルが画面上で重なると、mapbox-gl-jsは最初のものだけ表示し他の重なっているものは非表示にします。
私の知っている限り、画面上で特定のシンボルに隠されたシンボルを取得するAPIはmapbox-gl-jsにありません*。
私のアプリではクリックされた場所のシンボルを非表示のものも含めてすべてリストしたいので、これでは不都合です。
mapbox-gl-jsに衝突検出をスキップさせてシンボルをもれなく画面上に表示するオプションはありますが、重なるシンボルが多い場合はマップがごちゃごちゃし過ぎてしまうでしょう。
ということでMapboxのマップ上で特定のシンボルと重なるシンボルを集約することのできるライブラリを開発することにしました。
2022-08-29追記: ライブラリはhttps://github.com/codemonger-io/mapbox-collision-boxesで手に入ります。
* Map#queryRenderedFeaturesは表示されているシンボル(Feature)だけを返し非表示のものは返しません。
"click"イベントも非表示のシンボルについては教えてくれません。
ライブラリに対する要件
私が開発中のライブラリを使うと、特定のLayerで指定したボックスと交差するシンボルを表示の有無にかかわらず問い合わせることができます。
上記機能に基づき、特定のLayerでクリックされたシンボルに隠されているシンボルを問い合わせることができます。
問題を単純化するため、ラベル(Text)のないシンボルのみ(アイコンのみ)を考えることにします。
衝突検出
mapbox-gl-jsはどのシンボルを画面に描画するか決めるために衝突検出を行います。
Map#showCollisionBoxesプロパティを有効にすると、mapbox-gl-jsは画面上に表示・非表示のシンボルすべての衝突ボックスを可視化します。
以下は衝突ボックス(とサークル)をMapboxのマップ上に表示する例です。

これらの衝突ボックスの位置と寸法をシンボルのFeatureと一緒に取得できれば、ライブラリを実装できます。
衝突ボックスを取得する
衝突ボックスを取得できるかどうか調べるためにmapbox-gl-jsのソースコード*を眺めていきましょう。
* 当時の最新バージョン2.9.2を分析しました。
デバッグのための衝突ボックス
Map#showCollisionBoxesを有効にしたときに描画される衝突ボックスの情報にMap#showCollisionBoxesをfalseにした製品版環境でもアクセスできるでしょうか?
残念ながら、答えはノーです。
SymbolBucket#textCollisionBoxとSymbolBucket#iconCollisionBoxがすべての描画された衝突ボックスの情報を含んでいます。
SymbolBucket#updateCollisionBuffersがSymbolBucket#textCollisionBoxとSymbolBucket#iconCollisionBoxを更新しており、Placement#placeLayerBucketPartがsymbol/placement.js#L435-L437でそれを呼び出しています:
if showCollisionBoxes && updateCollisionBoxIfNecessary
上記コードから分かるとおり、当該メソッドはshowCollisionBoxes(= Map#showCollisionBoxes)がtrueの場合のみ呼び出されています。
製品版ではMap#showCollisionBoxesはfalseのはずなので、SymbolBucket#textCollisionBoxとSymbolBucket#iconCollisionBoxに頼ることはできません。
もしこれらを使うことができたとしても、タイル座標系で表されているので画面座標系に射影しなければなりません。
衝突ボックスの別の入手元
Placement#placeLayerBucketPartはシンボルの衝突検出を担っています。
その内部関数placeSymbolが各シンボルを処理しています。
さらにその内部関数のplaceIconFeatureを呼び出してアイコンが画面上で優先するシンボルと衝突しているかどうか判定しています。
placeIconFeatureはsymbol/placement.js#L709-L710でCollisionIndex#placeCollisionBoxを呼び出しており、それが実際の衝突判定を行っています:
return bucket, iconScale, iconBox, shiftPoint,
iconAllowOverlap, textPixelRatio, posMatrix, collisionGroup.predicate;
衝突が検出されなければ、placeSymbolは当該シンボルの衝突ボックスをCollisionIndex#insertCollisionBoxで記憶します。
残念ながら、他の優先する衝突ボックスと衝突するシンボルの衝突ボックスは記録しません。
なのでそれらのシンボルについて衝突ボックスを再計算しなければなりません。
ということで、次の節では画面座標系で衝突ボックスをどのように計算しているか理解するためにCollisionIndex#placeCollisionBoxを覗いてみましょう。
CollisionIndex#placeCollisionBoxの解剖
以下はCollisionIndex#placeCollisionBoxの大まかな流れを示しています。
-
symbol/collision_index.js#L97-L99で、衝突ボックスの位置をタイル空間で取得:
; ; ; -
symbol/collision_index.js#L104-L111で、標高(Elevation)の処理:
if elevation && tileIDanchorX,anchorY,anchorZを標高で調整。 -
symbol/collision_index.js#L114で、アンカーを画面座標系に射影:
;概説すると、
CollisionIndex#projectAndGetPerspectiveRatioはposMatrixを点([anchorX, anchorY, anchorZ])にかけてパースを修正します。 -
symbol/collision_index.js#L116-L120で、衝突ボックスをスケールして
projectedPoint.pointに移動:; ; ; ; ; -
symbol/collision_index.js#L125-L142で、衝突ボックス(
[tlX, tlY, brX, brY])が以前に記録した衝突ボックスと衝突するかどうかを判定。 このステップはスコープ外です。
上記の計算(ステップ1から4)を模倣するためには、以下のパラメータを用意しなければなりません。
私の分析では、Placement, Tile, SymbolBucketが利用できれば、上記パラメータを取得することは可能です。
PlacementはStyle#placementとして手に入り、StyleはMap#styleとして手に入ります。
しかし TileとSymbolBucketの取得については更に調査が必要です。
以下の節では分析の詳細を示します。
パラメータ: collisionBox
collisionBoxはplaceIconFeatureに与えられるiconBox引数でplaceSymbolはsymbol/placement.js#L717(と別の行)でそれを呼び出します:
placedIconBoxes = collisionArrays.iconBox;
ではcollisionArraysとは何でしょうか?
collisionArraysはplaceSymbolの引数でPlacement#placeLayerBucketPartはsymbol/placement.js#L795(と別の行)でそれを呼び出します:
i, i, bucket.collisionArrays;
したがってcollisionArraysはSymbolBucket#collisionArraysのi番目の要素です。
Placement#placeLayerBucketPartはSymbolBucket#deserializeCollisionBoxesをsymbol/placement.js#L431-L433で呼び出してSymbolBucket#collisionArraysを初期化していることに留意ください:
if !bucket.collisionArrays && collisionBoxArray
Placement#placeLayerBucketPartはcollisionBoxArrayをメソッドの最初の引数であるbucketPartからsymbol/placement.js#L388-L401で取得しています:
;
bucketPart.parametersのcollisionBoxArrayは対応するマップタイルのTile#collisionBoxArrayから来ています。
詳しくはPlacement#getBucketPartsを参照ください。
シンボルの配置が終了した後は SymbolBucket#collisionArraysを使うことができます。
パラメータ: posMatrix
Placement#placeLayerBucketPartはposMatrixをメソッドの最初の引数であるbucketPartからsymbol/placement.js#L388-L401で取得しています:
;
Placement#getBucketPartsはposMatrixをsymbol/placement.js#L249で計算しています:
;
getSymbolPlacementTileProjectionMatrixはmapbox-gl-jsからエクスポートされていませんが、我々のバージョンを実装するのは難しくありません。
詳しくはgetSymbolPlacementTileProjectionMatrixを参照ください。
パラメータ: elevation
CollisionIndex#placeCollisionBoxはelevationをsymbol/collision_index.js#L102で取得しています:
;
placeIconFeatureはupdateBoxDataをsymbol/placement.js#L704で呼び出しelevationプロパティをiconBox(=collisionBox)に追加しています:
iconBox;
updateBoxDataはelevationをsymbol/placement.js#L507-L509で計算しています:
box.elevation = this.transform.elevation ?
this.retainedQueryData.tileID,
box.tileAnchorX, box.tileAnchorY : 0;
Placement#placeLayerBucketPartはSymbolBucket#collisionArraysの要素を直接updateBoxDataに渡しているので、シンボルの配置が終わった後は SymbolBucket#collisionArraysの各要素のiconBoxがelevationを記憶しているはずです。
以下もご覧ください。
パラメータ: textPixelRatio
Placement#placeLayerBucketPartはtextPixelRatioをメソッドの最初の引数であるbucketPartからsymbol/placement.js#L388-L401で取得しています:
;
Placement#getBucketPartsはtextPixelRatioをsymbol/placement.js#L244で計算しています:
;
Tile#tileSizeとEXTENTもご覧ください。
このパラメータの計算は朝飯前です。
パラメータ: scale
placeIconFeatureはiconScale(=scale)をsymbol/placement.js#L708で計算しています:
;
Placement#placeLayerBucketPartはpartiallyEvaluatedIconSizeをメソッドの最初の引数であるbucketPartからsymbol/placement.js#L388-L401で取得しています:
;
Placement#getBucketPartsはpartiallyEvaluatedIconSizeをsymbol/placement.js#L317で計算しています:
partiallyEvaluatedIconSize: symbolBucket.iconSizeData, this.transform.zoom,
詳しくはSymbolBcuket#getSymbolInstanceIconSizeを参照ください。.
partiallyEvaluatedIconSizeは再現することができ、このパラメータを計算することができます*。
* symbolSize.evaluateSizeForZoom関数がmapbox-gl-jsからエクスポートされていないことに後から気づきました。
この問題は次回の投稿でカバーします。
パラメータ: shift
placeIconFeatureはshiftPoint(=shift)をsymbol/placement.js#L705-L707で計算しています:
new Point0, 0;
上記コードのshiftは insrc/symbol/placement.js#L609で設定されています:
shift = result.shift;
hasIconFitはsymbol/placement.js#L409で設定されています:
;
ラベルのないアイコンにフォーカスするのでこのパラメータはPoint(0, 0)であると想定できます。
まとめ
非表示シンボルの衝突ボックスを画面座標系で取得するお手軽な方法はありません。
しかし分析によると、Placement, Tile, SymbolBucketが手に入ればそれぞれのシンボルの衝突ボックスを再計算することはできます。
以下の疑問が残っています。
TileとSymbolBucketはどうやって取得するのか?- 再計算した衝突ボックスとシンボルのFeatureをどうやって対応づけるのか?
ということで、次のブログ投稿ではこれらの疑問に答えます。
補足
用語
この節ではmapbox-gl-js特有の用語を簡単に解説します。
Feature
Layerのデータソースとして利用するVector TileとGeoJSONはFeatureの集合です。 Featureは形状(ジオメトリ)とオプションのプロパティを持ちます。
Layer
mapbox-gl-jsはマップをLayerのスタックとして表します。
Layerのタイプはいくつかあり、"Symbol" Layerはそのひとつです。
詳しくは「Layers|Style specification」[1]を参照ください。
マップタイル
mapbox-gl-jsは世界をマップタイルのグリッドに分ます。
タイル座標系(空間)
map tileのジオメトリはローカルな座標系(タイル座標系)で表されます。 詳しくは「Vector tiles standards」[2]を参照ください。
ソースコードレファレンス
この節ではmapbox-gl-jsのソースコードに関する私の補足コメントを紹介します。
Map
mapbox-gl-jsを使うためにインスタンス化する最初のクラスです。
Map#style
定義: ui/map.js#L327
style: Style;
このプロパティはすべてのマップデータを管理します。
Styleもご覧ください。
Style
Style#placement
placement: Placement;
Placementもご覧ください。
Tile
Tile#tileSize
tileSize: number;
Tile#collisionBoxArray
collisionBoxArray: ?CollisionBoxArray;
Placement
定義: symbol/placement.js#L192-L1184
StyleはPlacementのインスタンスをStyle#placementとして保持します。
Placement#retainedQueryData
retainedQueryData: ;
このプロパティはSymbolBucket(Bucket)をRetainedQueryDataと対応づけます。
Placement#getBucketPartsは新しいRetainedQueryDataをBucketにsymbol/placement.js#L297-L303で割り当てます:
this.retainedQueryData = new RetainedQueryData
symbolBucket.bucketInstanceId,
bucketFeatureIndex,
symbolBucket.sourceLayerIndex,
symbolBucket.index,
tile.tileID
;
このプロパティはSymbolBucketに対応するFeatureIndex(bucketFeatureIndex)を取得するのに必要です。
Placement#getBucketParts
定義: symbol/placement.js#L233-L333
このメソッドはgetSymbolPlacementTileProjectionMatrixを呼び出しタイルから画面座標系への射影マトリクスを計算します。
projection_util.getSymbolPlacementTileProjectionMatrix
定義: geo/projection/projection_util.js#L35-L41
Placement#placeLayerBucketPart
定義: symbol/placement.js#L386-L808
このメソッドはシンボルの衝突検出を担います。
このメソッドはsymbol/placement.js#L786-L797でplaceSymbolを各シンボルについて呼び出します:
if zOrderByViewportY else
Placement#placeLayerBucketPart.placeSymbol
定義: symbol/placement.js#L439-L784
これはPlacement#placeLayerBucketPartの内部関数です。
この関数は与えられたシンボルについて衝突判定と配置を行います。
この関数はアイコンの衝突判定のためにplaceIconFeatureを呼び出します。
Placement#placeLayerBucketPart.placeSymbol.placeIconFeature
定義: symbol/placement.js#L703-L711
;
これはplaceSymbolの内部関数です。
この関数はiconBoxをタイル座標系から画面座標系に射影し、その射影したボックスについて衝突判定を行います。
以下もご覧ください。
Placement#placeLayerBucketPart.placeSymbol.updateBoxData
定義: symbol/placement.js#L504-L510
;
これはPlacement#placeLayerBucketPartの内部関数です。
RetainedQueryData
定義: symbol/placement.js#L87-L105
FeatureIndex
定義: data/feature_index.js#L54-L312
FeatureIndexはシンボルのFeatureを解決する上で重要な役割を果たします。
SymbolBucket
定義: data/bucket/symbol_bucket.js#L352-L1119
SymbolBucket#collisionArrays
定義: data/bucket/symbol_bucket.js#L380
collisionArrays: Array<CollisionArrays>;
SymbolBucket#deserializeCollisionBoxesがこのプロパティを初期化します。
CollisionArraysもご覧ください。
SymbolBucket#textCollisionBox
定義: data/bucket/symbol_bucket.js#L398
textCollisionBox: CollisionBuffers;
このプロパティはすべてのテキストラベルの衝突ボックスを格納します。
SymbolBucket#iconCollisionBox
定義: data/bucket/symbol_bucket.js#L399
iconCollisionBox: CollisionBuffers;
このプロパティはすべてのアイコンの衝突ボックスを格納します。
SymbolBucket#deserializeCollisionBoxes
定義: data/bucket/symbol_bucket.js#L979-L995
このメソッドはSymbolBucket#collisionArraysを初期化します。
SymbolBucket#getSymbolInstanceIconSize
Definition: data/bucket/symbol_bucket.js#L882-L887
iconSize: any, zoom: number, index: number: number
SymbolBucket#updateCollisionBuffers
定義: data/bucket/symbol_bucket.js#L914-L939
Placement#placeLayerBucketPartはMap#showCollisionBoxesがtrueの場合のみこのメソッドを呼び出します。
CollisionArrays
定義: data/bucket/symbol_bucket.js#L90-L99
;
CollisionIndex
定義: symbol/collision_index.js#L64-L465
CollisionIndex#placeCollisionBox
定義: symbol/collision_index.js#L94-L143
bucket: SymbolBucket, scale: number, collisionBox: SingleCollisionBox, shift: Point, allowOverlap: boolean, textPixelRatio: number, posMatrix: Mat4, collisionGroupPredicate?: any: PlacedCollisionBox
placeIconFeatureはこのメソッドを呼び出します。
CollisionIndex#insertCollisionBox
定義: symbol/collision_index.js#L401-L406
collisionBox: Array<number>, ignorePlacement: boolean, bucketInstanceId: number, featureIndex: number, collisionGroupID: number
CollisionIndex#projectAndGetPerspectiveRatio
定義: symbol/collision_index.js#L417-L445
Transform
定義: geo/transform.js#L42-L2061
Transform#elevation
Elevation
定義: terrain/elevation.js#L31-L237
Elevation#getAtTileOffset
定義: terrain/elevation.js#L110-L115
定数
EXTENT
;
この値はEXTENTとして参照されます。