Dealing with Mapbox hidden symbols (1. Collision box)
2022-08-20 (Updated on 2022-09-16)
I have been working on a utility library for Mapbox GL JS, that deals with symbols hidden by another symbol on the screen. This is the first blog post of the series that will walk you through the development of the library.
Background
Mapbox GL JS (mapbox-gl-js) is a JavaScript library to display Mapbox maps on a web browser.
mapbox-gl-js is a proprietary library of Mapbox, though it is open-sourced and we can still modify it as long as we stick to their Terms of Service.
We can show custom symbols at points on a map using symbol layers.
The following image is a screenshot from an app I am developing; icons of droppings are "symbols".

When symbols overlap on the screen, mapbox-gl-js shows only the first one and hides other overlapping ones.
As far as I know, there is no mapbox-gl-js API to get symbols hidden by a specific symbol on the screen*.
This is not convenient for my app because it wants to list all the symbols including hidden ones at a clicked point.
Although there is an option that makes mapbox-gl-js skip collision detection and show every single symbol on the screen, this will make the map too busy if there are many overlapping symbols.
So I decided to develop a library that can aggregate symbols overlapping with a specific symbol on a Mapbox map.
Edit (2022-08-29): The lbirary is available at https://github.com/codemonger-io/mapbox-collision-boxes.
* Map#queryRenderedFeatures returns only visible symbols (features) but no hidden features.
"click" event neither tells about hidden features.
Requirements for the library
With the library I am developing, we can query symbols intersecting with a given box on a specified layer whether symbols are visible or not.
On top of the above feature, we can query symbols hidden by a clicked symbol on a specified layer.
To simplify the problem, we only consider symbols without a label (text); i.e., icons only.
Collision detection
mapbox-gl-js does collision detection to determine which symbols it renders on the screen.
If you turn on the property Map#showCollisionBoxes, mapbox-gl-js visualizes collision boxes of all the visible and hidden symbols on the screen.
The following is an example of showing collision boxes (and circles) on a Mapbox map.

If we can obtain the positions and dimensions of these collision boxes along with symbol features from mapbox-gl-js, we can implement the library.
Getting collision boxes
Let's walk through the source code of mapbox-gl-js* to see if we can get collision boxes.
* I have analyzed the then latest version 2.9.2.
Collision boxes for debugging
Can we access the same information of collision boxes rendered when we turn on Map#showCollisionBoxes in a production environment where Map#showCollisionBoxes is false?
Unfortunately, the answer is no.
SymbolBucket#textCollisionBox and SymbolBucket#iconCollisionBox contain the information of all the rendered collision boxes.
SymbolBucket#updateCollisionBuffers updates SymbolBucket#textCollisionBox and SymbolBucket#iconCollisionBox, and Placement#placeLayerBucketPart calls it in symbol/placement.js#L435-L437:
if showCollisionBoxes && updateCollisionBoxIfNecessary
As you can see in the above code, the method is called only if showCollisionBoxes (= Map#showCollisionBoxes) is true.
Since Map#showCollisionBoxes must be false for production, we cannot rely on SymbolBucket#textCollisionBox and SymbolBucket#iconCollisionBox.
Even if we could utilize them, we still had to project them to the screen coordinate because they are in the tile coordinates.
Another source of collision boxes
Placement#placeLayerBucketPart is responsible for collision detection of symbols.
The internal function placeSymbol processes each symbol.
It calls its internal function placeIconFeature to test if the icon collides with other preceding symbols on the screen.
placeIconFeature calls CollisionIndex#placeCollisionBox in symbol/placement.js#L709-L710, and it does actual collision tests:
return bucket, iconScale, iconBox, shiftPoint,
iconAllowOverlap, textPixelRatio, posMatrix, collisionGroup.predicate;
If no collision is detected, placeSymbol remembers the collision box of the symbol with CollisionIndex#insertCollisionBox.
Unfortunately, it does not record the collision boxes of symbols that collide with other preceding collision boxes.
So we have to recalculate the collision boxes of those symbols.
Thus, let's look into CollisionIndex#placeCollisionBox in the next section to understand how it calculates a collision box in the screen coordinate.
Anatomy of CollisionIndex#placeCollisionBox
The following shows the brief workflow of CollisionIndex#placeCollisionBox,
-
In symbol/collision_index.js#L97-L99, obtain the position of the collision box in the tile space:
; ; ; -
In symbol/collision_index.js#L104-L111, deal with the elevation:
if elevation && tileIDAdjust
anchorX,anchorY, andanchorZby the elevation. -
In symbol/collision_index.js#L114, project the anchor point to the screen coordinate:
;CollisionIndex#projectAndGetPerspectiveRatio, in short, multipliesposMatrixto the point ([anchorX, anchorY, anchorZ]) and corrects the perspective. -
In symbol/collision_index.js#L116-L120, scale and translate the collision box to the
projectedPoint.point:; ; ; ; ; -
In symbol/collision_index.js#L125-L142, determine if the collision box (
[tlX, tlY, brX, brY]) collides with previously recorded collision boxes. This step is out of our scope.
To mock the above calculation (Steps 1 to 4), we have to prepare the following parameters,
According to my analysis, it is feasible to obtain the above parameters if Placement, Tiles, and SymbolBuckets are available.
Placement is available as Style#placement, and Style is as Map#style.
But we need further study to see how to obtain Tiles and SymbolBuckets.
The following subsections show you the details of my analysis.
Parameter: collisionBox
collisionBox is the iconBox argument given to placeIconFeature that placeSymbol calls at symbol/placement.js#L717 (and another line):
placedIconBoxes = collisionArrays.iconBox;
Then what is collisionArrays?
collisionArrays is an argument of placeSymbol that Placement#placeLayerBucketPart calls at symbol/placement.js#L795 (and another line):
i, i, bucket.collisionArrays;
So collisionArrays is the ith element of SymbolBucket#collisionArrays.
Please note that Placement#placeLayerBucketPart calls SymbolBucket#deserializeCollisionBoxes in symbol/placement.js#L431-L433 to initialize SymbolBucket#collisionArrays:
if !bucket.collisionArrays && collisionBoxArray
Placement#placeLayerBucketPart obtains collisionBoxArray from the first argument bucketPart of the method in symbol/placement.js#L388-L401:
;
collisionBoxArray of bucketPart.parameters comes from Tile#collisionBoxArray of the corresponding map tile.
Please refer to Placement#getBucketParts for more details.
We can use SymbolBucket#collisionArrays after symbol placement has finished.
Parameter: posMatrix
Placement#placeLayerBucketPart obtains posMatrix from the first argument bucketPart of the method in symbol/placement.js#L388-L401:
;
Placement#getBucketParts calculates posMatrix at symbol/placement.js#L249:
;
While getSymbolPlacementTileProjectionMatrix is not exported from mapbox-gl-js, it is not difficult to implement our version of the function.
Please refer to getSymbolPlacementTileProjectionMatrix for more details.
Parameter: elevation
CollisionIndex#placeCollisionBox obtains elevation at symbol/collision_index.js#L102:
;
placeIconFeature calls updateBoxData at symbol/placement.js#L704 to assign the elevation property to iconBox (=collisionBox):
iconBox;
updateBoxData calculates elevation in symbol/placement.js#L507-L509:
box.elevation = this.transform.elevation ?
this.retainedQueryData.tileID,
box.tileAnchorX, box.tileAnchorY : 0;
Because Placement#placeLayerBucketPart directly passes elements of SymbolBucket#collisionArrays to updateBoxData, iconBox of every SymbolBucket#collisionArrays element should remember elevation after symbol placement has finished.
Please see also,
Parameter: textPixelRatio
Placement#placeLayerBucketPart obtains textPixelRatio from the first argument bucketPart of the method in symbol/placement.js#L388-L401:
;
Placement#getBucketParts calculates textPixelRatio at symbol/placement.js#L244:
;
Please see also Tile#tileSize and EXTENT.
Calculation of this parameter is a piece of cake.
Parameter: scale
placeIconFeature calculates iconScale (=scale) at symbol/placement.js#L708:
;
Placement#placeLayerBucketPart obtains partiallyEvaluatedIconSize from the first argument bucketPart of the method in symbol/placement.js#L388-L401:
;
Placement#getBucketParts calculates partiallyEvaluatedIconSize at symbol/placement.js#L317:
partiallyEvaluatedIconSize: symbolBucket.iconSizeData, this.transform.zoom,
Please refer to SymbolBcuket#getSymbolInstanceIconSize for more details.
We can reproduce partiallyEvaluatedIconSize and calculate this parameter*.
* I found that the function symbolSize.evaluateSizeForZoom was not exported from mapbox-gl-js afterward.
I will cover this in an upcoming post.
Parameter: shift
placeIconFeature calculates shiftPoint (=shift) in symbol/placement.js#L705-L707:
new Point0, 0;
shift in the above code is set at src/symbol/placement.js#L609:
shift = result.shift;
hasIconFit is set at symbol/placement.js#L409:
;
We may assume this parameter is Point(0, 0) because we focus on icons without a label.
Wrap up
There is no handy way to obtain collision boxes of hidden symbols in the screen coordinate.
However, according to my analysis, we can recalculate the collision box of every single symbol if Placement, Tiles, and SymbolBuckets are available.
The following questions remain,
- How can we obtain
Tiles andSymbolBuckets? - How can we associate recalculated collision boxes with symbol features?
So, in an upcoming blog post, we will answer these questions.
Appendix
Glossary
This section briefly describes some words specific to mapbox-gl-js.
Feature
A vector tile or GeoJSON used as a layer data source is a collection of features. A feature has a shape (geometry) and optional properties.
Layer
mapbox-gl-js represents a map as a stack of layers.
There are several layer types, and a "symbol" layer is one of them.
Please refer to Layers|Style specification[1] for more details.
Map tile
mapbox-gl-js divides the world into grids of map tiles.
Tile coordinate (space)
Geometry in a map tile is encoded in its local coordinate (tile coordinate). Please refer to Vector tiles standards[2] for more details.
Source code reference
This section shows my supplemental comments on the source code of mapbox-gl-js.
Map
Definition: ui/map.js#L326-L3677
This is the top most class you instantiate to use mapbox-gl-js.
Map#style
Definition: ui/map.js#L327
style: Style;
This property manages all the map data.
Please see also Style.
Style
Definition: style/style.js#L135-L1860
Style#placement
Definition: style/style.js#L173
placement: Placement;
Please see also Placement.
Tile
Definition: source/tile.js#L95-L799
Tile#tileSize
Definition: src/source/tile.js#L99
tileSize: number;
Tile#collisionBoxArray
Definition: source/tile.js#L115
collisionBoxArray: ?CollisionBoxArray;
Placement
Definition: symbol/placement.js#L192-L1184
Style retains an instance of Placement as Style#placement.
Placement#retainedQueryData
Definition: symbol/placement.js#L205
retainedQueryData: ;
This property associates a SymbolBucket (bucket) with a RetainedQueryData.
Placement#getBucketParts assigns a new RetainedQueryData to a bucket in symbol/placement.js#L297-L303:
this.retainedQueryData = new RetainedQueryData
symbolBucket.bucketInstanceId,
bucketFeatureIndex,
symbolBucket.sourceLayerIndex,
symbolBucket.index,
tile.tileID
;
This property is necessary to obtain the FeatureIndex (bucketFeatureIndex) associated with a SymbolBucket.
Placement#getBucketParts
Definition: symbol/placement.js#L233-L333
This method calls getSymbolPlacementTileProjectionMatrix to calculate the projection matrix from the tile to screen coordinates.
projection_util.getSymbolPlacementTileProjectionMatrix
Definition: geo/projection/projection_util.js#L35-L41
Placement#placeLayerBucketPart
Definition: symbol/placement.js#L386-L808
This method is responsible for collision detection of symbols.
This method calls placeSymbol for each symbol in symbol/placement.js#L786-L797:
if zOrderByViewportY else
Placement#placeLayerBucketPart.placeSymbol
Definition: symbol/placement.js#L439-L784
This is an internal function of Placement#placeLayerBucketPart.
This function tests collision and places a given single symbol.
This function calls placeIconFeature to test collision of an icon.
Placement#placeLayerBucketPart.placeSymbol.placeIconFeature
Definition: symbol/placement.js#L703-L711
;
This is an internal function of placeSymbol.
This function projects iconBox from the tile coordinate to the screen coordinate and tests collision of the projected box.
Please also see,
Placement#placeLayerBucketPart.placeSymbol.updateBoxData
Definition: symbol/placement.js#L504-L510
;
This is an internal function of Placement#placeLayerBucketPart.
RetainedQueryData
Definition: symbol/placement.js#L87-L105
FeatureIndex
Definition: data/feature_index.js#L54-L312
FeatureIndex plays an important role in resolving the feature of a symbol.
SymbolBucket
Definition: data/bucket/symbol_bucket.js#L352-L1119
SymbolBucket#collisionArrays
Definition: data/bucket/symbol_bucket.js#L380
collisionArrays: Array<CollisionArrays>;
SymbolBucket#deserializeCollisionBoxes initializes this property.
Please also see CollisionArrays.
SymbolBucket#textCollisionBox
Definition: data/bucket/symbol_bucket.js#L398
textCollisionBox: CollisionBuffers;
This property stores collision boxes of all the text labels.
SymbolBucket#iconCollisionBox
Definition: data/bucket/symbol_bucket.js#L399
iconCollisionBox: CollisionBuffers;
This property stores collision boxes of all the icons.
SymbolBucket#deserializeCollisionBoxes
Definition: data/bucket/symbol_bucket.js#L979-L995
This method initializes SymbolBucket#collisionArrays.
SymbolBucket#getSymbolInstanceIconSize
Definition: data/bucket/symbol_bucket.js#L882-L887
iconSize: any, zoom: number, index: number: number
SymbolBucket#updateCollisionBuffers
Definition: data/bucket/symbol_bucket.js#L914-L939
Placement#placeLayerBucketPart calls this method only if Map#showCollisionBoxes is true.
CollisionArrays
Definition: data/bucket/symbol_bucket.js#L90-L99
;
CollisionIndex
Definition: symbol/collision_index.js#L64-L465
CollisionIndex#placeCollisionBox
Definition: symbol/collision_index.js#L94-L143
bucket: SymbolBucket, scale: number, collisionBox: SingleCollisionBox, shift: Point, allowOverlap: boolean, textPixelRatio: number, posMatrix: Mat4, collisionGroupPredicate?: any: PlacedCollisionBox
placeIconFeature calls this method.
CollisionIndex#insertCollisionBox
Definition: symbol/collision_index.js#L401-L406
collisionBox: Array<number>, ignorePlacement: boolean, bucketInstanceId: number, featureIndex: number, collisionGroupID: number
CollisionIndex#projectAndGetPerspectiveRatio
Definition: symbol/collision_index.js#L417-L445
Transform
Definition: geo/transform.js#L42-L2061
Transform#elevation
Definition: geo/transform.js#L220
Elevation
Definition: terrain/elevation.js#L31-L237
Elevation#getAtTileOffset
Definition: terrain/elevation.js#L110-L115
Constants
EXTENT
Definition: data/extent.js#L18
;
This value is referred to as EXTENT.