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 && tileID
anchorX
,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
として参照されます。