@mapwhit/tilerenderer 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/build/min/package.json +1 -1
  2. package/package.json +1 -1
  3. package/src/data/array_types.js +115 -64
  4. package/src/data/bucket/circle_bucket.js +42 -5
  5. package/src/data/bucket/fill_bucket.js +31 -13
  6. package/src/data/bucket/fill_extrusion_bucket.js +8 -6
  7. package/src/data/bucket/line_bucket.js +38 -14
  8. package/src/data/bucket/symbol_attributes.js +13 -5
  9. package/src/data/bucket/symbol_bucket.js +87 -33
  10. package/src/data/bucket/symbol_collision_buffers.js +1 -1
  11. package/src/data/bucket.js +3 -1
  12. package/src/data/feature_index.js +24 -11
  13. package/src/data/segment.js +15 -7
  14. package/src/render/draw_circle.js +45 -4
  15. package/src/render/draw_symbol.js +190 -22
  16. package/src/render/painter.js +1 -1
  17. package/src/source/geojson_source.js +118 -21
  18. package/src/source/geojson_source_diff.js +148 -0
  19. package/src/source/geojson_tiler.js +89 -0
  20. package/src/source/source.js +16 -5
  21. package/src/source/source_cache.js +6 -6
  22. package/src/source/source_state.js +4 -2
  23. package/src/source/tile.js +5 -3
  24. package/src/source/vector_tile_source.js +2 -0
  25. package/src/source/worker_tile.js +4 -2
  26. package/src/style/pauseable_placement.js +39 -7
  27. package/src/style/style.js +86 -34
  28. package/src/style/style_layer/circle_style_layer_properties.js +8 -1
  29. package/src/style/style_layer/fill_style_layer_properties.js +8 -1
  30. package/src/style/style_layer/line_style_layer_properties.js +4 -0
  31. package/src/style/style_layer/symbol_style_layer_properties.js +17 -2
  32. package/src/style-spec/reference/v8.json +161 -4
  33. package/src/symbol/one_em.js +4 -0
  34. package/src/symbol/placement.js +406 -173
  35. package/src/symbol/projection.js +3 -3
  36. package/src/symbol/quads.js +1 -6
  37. package/src/symbol/shaping.js +16 -27
  38. package/src/symbol/symbol_layout.js +243 -81
  39. package/src/util/vectortile_to_geojson.js +3 -4
  40. package/src/source/geojson_worker_source.js +0 -97
@@ -1,9 +1,12 @@
1
+ import { rotate } from '@mapwhit/point-geometry';
1
2
  import assert from 'assert';
2
3
  import EXTENT from '../data/extent.js';
3
4
  import pixelsToTileUnits from '../source/pixels_to_tile_units.js';
4
5
  import properties from '../style/style_layer/symbol_style_layer_properties.js';
5
6
  import CollisionIndex from './collision_index.js';
6
7
  import * as projection from './projection.js';
8
+ import { getAnchorAlignment } from './shaping.js';
9
+ import { evaluateRadialOffset, getAnchorJustification } from './symbol_layout.js';
7
10
  import * as symbolSize from './symbol_size.js';
8
11
 
9
12
  const symbolLayoutProperties = properties.layout;
@@ -81,19 +84,50 @@ class CollisionGroups {
81
84
  }
82
85
  }
83
86
 
87
+ function calculateVariableLayoutOffset(anchor, width, height, radialOffset, textBoxScale) {
88
+ const { horizontalAlign, verticalAlign } = getAnchorAlignment(anchor);
89
+ const shiftX = -(horizontalAlign - 0.5) * width;
90
+ const shiftY = -(verticalAlign - 0.5) * height;
91
+ const offset = evaluateRadialOffset(anchor, radialOffset);
92
+ return { x: shiftX + offset[0] * textBoxScale, y: shiftY + offset[1] * textBoxScale };
93
+ }
94
+
95
+ function shiftVariableCollisionBox(collisionBox, shiftX, shiftY, rotateWithMap, pitchWithMap, angle) {
96
+ const { x1, x2, y1, y2, anchorPointX, anchorPointY } = collisionBox;
97
+ let rotatedOffset = { x: shiftX, y: shiftY };
98
+ if (rotateWithMap) {
99
+ rotatedOffset = rotate(rotatedOffset, pitchWithMap ? angle : -angle);
100
+ }
101
+ return {
102
+ x1: x1 + rotatedOffset.x,
103
+ y1: y1 + rotatedOffset.y,
104
+ x2: x2 + rotatedOffset.x,
105
+ y2: y2 + rotatedOffset.y,
106
+ // symbol anchor point stays the same regardless of text-anchor
107
+ anchorPointX,
108
+ anchorPointY
109
+ };
110
+ }
111
+
84
112
  export class Placement {
85
- constructor(transform, fadeDuration, crossSourceCollisions) {
113
+ constructor(transform, fadeDuration, crossSourceCollisions, prevPlacement) {
86
114
  this.transform = transform.clone();
87
115
  this.collisionIndex = new CollisionIndex(this.transform);
88
116
  this.placements = {};
89
117
  this.opacities = {};
118
+ this.variableOffsets = {};
90
119
  this.stale = false;
91
120
  this.fadeDuration = fadeDuration;
92
121
  this.retainedQueryData = {};
93
122
  this.collisionGroups = new CollisionGroups(crossSourceCollisions);
123
+
124
+ this.prevPlacement = prevPlacement;
125
+ if (prevPlacement) {
126
+ prevPlacement.prevPlacement = undefined; // Only hold on to one placement back
127
+ }
94
128
  }
95
129
 
96
- placeLayerTile(styleLayer, tile, showCollisionBoxes, seenCrossTileIDs) {
130
+ getBucketParts(results, styleLayer, tile, sortAcrossTiles) {
97
131
  const symbolBucket = tile.getBucket(styleLayer);
98
132
  const bucketFeatureIndex = tile.latestFeatureIndex;
99
133
  if (!symbolBucket || !bucketFeatureIndex || styleLayer.id !== symbolBucket.layers[0].id) {
@@ -117,14 +151,6 @@ export class Placement {
117
151
  pixelsToTileUnits(tile, 1, this.transform.zoom)
118
152
  );
119
153
 
120
- const iconLabelPlaneMatrix = projection.getLabelPlaneMatrix(
121
- posMatrix,
122
- layout.get('icon-pitch-alignment') === 'map',
123
- layout.get('icon-rotation-alignment') === 'map',
124
- this.transform,
125
- pixelsToTileUnits(tile, 1, this.transform.zoom)
126
- );
127
-
128
154
  // As long as this placement lives, we have to hold onto this bucket's
129
155
  // matching FeatureIndex/data for querying purposes
130
156
  this.retainedQueryData[symbolBucket.bucketInstanceId] = new RetainedQueryData(
@@ -135,45 +161,110 @@ export class Placement {
135
161
  tile.tileID
136
162
  );
137
163
 
138
- this.placeLayerBucket(
139
- symbolBucket,
164
+ const parameters = {
165
+ bucket: symbolBucket,
166
+ layout,
140
167
  posMatrix,
141
168
  textLabelPlaneMatrix,
142
- iconLabelPlaneMatrix,
143
169
  scale,
144
170
  textPixelRatio,
145
- showCollisionBoxes,
146
- tile.holdingForFade(),
147
- seenCrossTileIDs,
148
- collisionBoxArray
149
- );
171
+ holdingForFade: tile.holdingForFade(),
172
+ collisionBoxArray,
173
+ partiallyEvaluatedTextSize: symbolSize.evaluateSizeForZoom(
174
+ symbolBucket.textSizeData,
175
+ this.transform.zoom,
176
+ symbolLayoutProperties.properties['text-size']
177
+ ),
178
+ collisionGroup: this.collisionGroups.get(symbolBucket.sourceID)
179
+ };
180
+
181
+ if (sortAcrossTiles) {
182
+ for (const range of symbolBucket.sortKeyRanges) {
183
+ const { sortKey, symbolInstanceStart, symbolInstanceEnd } = range;
184
+ results.push({ sortKey, symbolInstanceStart, symbolInstanceEnd, parameters });
185
+ }
186
+ } else {
187
+ results.push({
188
+ symbolInstanceStart: 0,
189
+ symbolInstanceEnd: symbolBucket.symbolInstances.length,
190
+ parameters
191
+ });
192
+ }
150
193
  }
151
194
 
152
- placeLayerBucket(
153
- bucket,
154
- posMatrix,
155
- textLabelPlaneMatrix,
156
- iconLabelPlaneMatrix,
157
- scale,
195
+ attemptAnchorPlacement(
196
+ anchor,
197
+ textBox,
198
+ width,
199
+ height,
200
+ radialTextOffset,
201
+ textBoxScale,
202
+ rotateWithMap,
203
+ pitchWithMap,
158
204
  textPixelRatio,
159
- showCollisionBoxes,
160
- holdingForFade,
161
- seenCrossTileIDs,
162
- collisionBoxArray
205
+ posMatrix,
206
+ collisionGroup,
207
+ textAllowOverlap,
208
+ symbolInstance,
209
+ bucket
163
210
  ) {
164
- const layout = bucket.layers[0]._layout;
211
+ const shift = calculateVariableLayoutOffset(anchor, width, height, radialTextOffset, textBoxScale);
165
212
 
166
- const partiallyEvaluatedTextSize = symbolSize.evaluateSizeForZoom(
167
- bucket.textSizeData,
168
- this.transform.zoom,
169
- symbolLayoutProperties.properties['text-size']
213
+ const placedGlyphBoxes = this.collisionIndex.placeCollisionBox(
214
+ shiftVariableCollisionBox(textBox, shift.x, shift.y, rotateWithMap, pitchWithMap, this.transform.angle),
215
+ textAllowOverlap,
216
+ textPixelRatio,
217
+ posMatrix,
218
+ collisionGroup.predicate
170
219
  );
171
220
 
221
+ if (placedGlyphBoxes.box.length > 0) {
222
+ let prevAnchor;
223
+ // If this label was placed in the previous placement, record the anchor position
224
+ // to allow us to animate the transition
225
+ if (
226
+ this.prevPlacement?.variableOffsets[symbolInstance.crossTileID] &&
227
+ this.prevPlacement?.placements[symbolInstance.crossTileID] &&
228
+ this.prevPlacement?.placements[symbolInstance.crossTileID].text
229
+ ) {
230
+ prevAnchor = this.prevPlacement.variableOffsets[symbolInstance.crossTileID].anchor;
231
+ }
232
+ assert(symbolInstance.crossTileID !== 0);
233
+ this.variableOffsets[symbolInstance.crossTileID] = {
234
+ radialOffset: radialTextOffset,
235
+ width,
236
+ height,
237
+ anchor,
238
+ textBoxScale,
239
+ prevAnchor
240
+ };
241
+ this.markUsedJustification(bucket, anchor, symbolInstance);
242
+ return placedGlyphBoxes;
243
+ }
244
+ }
245
+
246
+ placeLayerBucketPart(bucketPart, seenCrossTileIDs, showCollisionBoxes) {
247
+ const {
248
+ bucket,
249
+ layout,
250
+ posMatrix,
251
+ textLabelPlaneMatrix,
252
+ scale,
253
+ textPixelRatio,
254
+ holdingForFade,
255
+ collisionBoxArray,
256
+ partiallyEvaluatedTextSize,
257
+ collisionGroup
258
+ } = bucketPart.parameters;
259
+
172
260
  const textOptional = layout.get('text-optional');
173
261
  const iconOptional = layout.get('icon-optional');
174
-
175
262
  const textAllowOverlap = layout.get('text-allow-overlap');
176
263
  const iconAllowOverlap = layout.get('icon-allow-overlap');
264
+ const rotateWithMap = layout.get('text-rotation-alignment') === 'map';
265
+ const pitchWithMap = layout.get('text-pitch-alignment') === 'map';
266
+ const zOrderByViewportY = layout.get('symbol-z-order') === 'viewport-y';
267
+
177
268
  // This logic is similar to the "defaultOpacityState" logic below in updateBucketOpacities
178
269
  // If we know a symbol is always supposed to show, force it to be marked visible even if
179
270
  // it wasn't placed into the collision index (because some or all of it was outside the range
@@ -191,161 +282,246 @@ export class Placement {
191
282
  const alwaysShowText = textAllowOverlap && (iconAllowOverlap || !bucket.hasIconData() || iconOptional);
192
283
  const alwaysShowIcon = iconAllowOverlap && (textAllowOverlap || !bucket.hasTextData() || textOptional);
193
284
 
194
- const collisionGroup = this.collisionGroups.get(bucket.sourceID);
195
-
196
285
  if (!bucket.collisionArrays && collisionBoxArray) {
197
286
  bucket.deserializeCollisionBoxes(collisionBoxArray);
198
287
  }
199
288
 
200
- for (let i = 0; i < bucket.symbolInstances.length; i++) {
201
- const symbolInstance = bucket.symbolInstances.get(i);
202
- if (!seenCrossTileIDs[symbolInstance.crossTileID]) {
203
- if (holdingForFade) {
204
- // Mark all symbols from this tile as "not placed", but don't add to seenCrossTileIDs, because we don't
205
- // know yet if we have a duplicate in a parent tile that _should_ be placed.
206
- this.placements[symbolInstance.crossTileID] = new JointPlacement(false, false, false);
207
- continue;
208
- }
209
-
210
- let placeText = false;
211
- let placeIcon = false;
212
- let offscreen = true;
289
+ const placeSymbol = (symbolInstance, collisionArrays) => {
290
+ if (seenCrossTileIDs[symbolInstance.crossTileID]) {
291
+ return;
292
+ }
293
+ if (holdingForFade) {
294
+ // Mark all symbols from this tile as "not placed", but don't add to seenCrossTileIDs, because we don't
295
+ // know yet if we have a duplicate in a parent tile that _should_ be placed.
296
+ this.placements[symbolInstance.crossTileID] = new JointPlacement(false, false, false);
297
+ return;
298
+ }
213
299
 
214
- let placedGlyphBoxes = null;
215
- let placedGlyphCircles = null;
216
- let placedIconBoxes = null;
300
+ let placeText = false;
301
+ let placeIcon = false;
302
+ let offscreen = true;
217
303
 
218
- let textFeatureIndex = 0;
219
- let iconFeatureIndex = 0;
304
+ let placedGlyphBoxes = null;
305
+ let placedGlyphCircles = null;
306
+ let placedIconBoxes = null;
307
+ let textFeatureIndex = 0;
308
+ let iconFeatureIndex = 0;
220
309
 
221
- const collisionArrays = bucket.collisionArrays[i];
310
+ if (collisionArrays.textFeatureIndex) {
311
+ textFeatureIndex = collisionArrays.textFeatureIndex;
312
+ }
222
313
 
223
- if (collisionArrays.textFeatureIndex) {
224
- textFeatureIndex = collisionArrays.textFeatureIndex;
225
- }
226
- if (collisionArrays.textBox) {
314
+ const textBox = collisionArrays.textBox;
315
+ if (textBox) {
316
+ if (!layout.get('text-variable-anchor')) {
227
317
  placedGlyphBoxes = this.collisionIndex.placeCollisionBox(
228
- collisionArrays.textBox,
229
- layout.get('text-allow-overlap'),
318
+ textBox,
319
+ textAllowOverlap,
230
320
  textPixelRatio,
231
321
  posMatrix,
232
322
  collisionGroup.predicate
233
323
  );
234
324
  placeText = placedGlyphBoxes.box.length > 0;
235
- offscreen = offscreen && placedGlyphBoxes.offscreen;
236
- }
237
- const textCircles = collisionArrays.textCircles;
238
- if (textCircles) {
239
- const placedSymbol = bucket.text.placedSymbolArray.get(symbolInstance.horizontalPlacedTextSymbolIndex);
240
- const fontSize = symbolSize.evaluateSizeForFeature(
241
- bucket.textSizeData,
242
- partiallyEvaluatedTextSize,
243
- placedSymbol
244
- );
245
- placedGlyphCircles = this.collisionIndex.placeCollisionCircles(
246
- textCircles,
247
- layout.get('text-allow-overlap'),
248
- scale,
249
- textPixelRatio,
250
- placedSymbol,
251
- bucket.lineVertexArray,
252
- bucket.glyphOffsetArray,
253
- fontSize,
254
- posMatrix,
255
- textLabelPlaneMatrix,
256
- showCollisionBoxes,
257
- layout.get('text-pitch-alignment') === 'map',
258
- collisionGroup.predicate
259
- );
260
- // If text-allow-overlap is set, force "placedCircles" to true
261
- // In theory there should always be at least one circle placed
262
- // in this case, but for now quirks in text-anchor
263
- // and text-offset may prevent that from being true.
264
- placeText = layout.get('text-allow-overlap') || placedGlyphCircles.circles.length > 0;
265
- offscreen = offscreen && placedGlyphCircles.offscreen;
266
- }
325
+ } else {
326
+ const width = textBox.x2 - textBox.x1;
327
+ const height = textBox.y2 - textBox.y1;
328
+ const textBoxScale = symbolInstance.textBoxScale;
329
+ let anchors = layout.get('text-variable-anchor');
330
+
331
+ // If we this symbol was in the last placement, shift the previously used
332
+ // anchor to the front of the anchor list.
333
+ if (this.prevPlacement?.variableOffsets[symbolInstance.crossTileID]) {
334
+ const prevOffsets = this.prevPlacement.variableOffsets[symbolInstance.crossTileID];
335
+ if (anchors[0] !== prevOffsets.anchor) {
336
+ anchors = anchors.filter(anchor => anchor !== prevOffsets.anchor);
337
+ anchors.unshift(prevOffsets.anchor);
338
+ }
339
+ }
267
340
 
268
- if (collisionArrays.iconFeatureIndex) {
269
- iconFeatureIndex = collisionArrays.iconFeatureIndex;
270
- }
271
- if (collisionArrays.iconBox) {
272
- placedIconBoxes = this.collisionIndex.placeCollisionBox(
273
- collisionArrays.iconBox,
274
- layout.get('icon-allow-overlap'),
275
- textPixelRatio,
276
- posMatrix,
277
- collisionGroup.predicate
278
- );
279
- placeIcon = placedIconBoxes.box.length > 0;
280
- offscreen = offscreen && placedIconBoxes.offscreen;
281
- }
341
+ for (const anchor of anchors) {
342
+ placedGlyphBoxes = this.attemptAnchorPlacement(
343
+ anchor,
344
+ textBox,
345
+ width,
346
+ height,
347
+ symbolInstance.radialTextOffset,
348
+ textBoxScale,
349
+ rotateWithMap,
350
+ pitchWithMap,
351
+ textPixelRatio,
352
+ posMatrix,
353
+ collisionGroup,
354
+ textAllowOverlap,
355
+ symbolInstance,
356
+ bucket
357
+ );
358
+ if (placedGlyphBoxes) {
359
+ placeText = true;
360
+ break;
361
+ }
362
+ }
282
363
 
283
- const iconWithoutText =
284
- textOptional || (symbolInstance.numGlyphVertices === 0 && symbolInstance.numVerticalGlyphVertices === 0);
285
- const textWithoutIcon = iconOptional || symbolInstance.numIconVertices === 0;
286
-
287
- // Combine the scales for icons and text.
288
- if (!iconWithoutText && !textWithoutIcon) {
289
- placeIcon = placeText = placeIcon && placeText;
290
- } else if (!textWithoutIcon) {
291
- placeText = placeIcon && placeText;
292
- } else if (!iconWithoutText) {
293
- placeIcon = placeIcon && placeText;
364
+ // If we didn't get placed, we still need to copy our position from the last placement for
365
+ // fade animations
366
+ if (!this.variableOffsets[symbolInstance.crossTileID] && this.prevPlacement) {
367
+ const prevOffset = this.prevPlacement.variableOffsets[symbolInstance.crossTileID];
368
+ if (prevOffset) {
369
+ this.variableOffsets[symbolInstance.crossTileID] = prevOffset;
370
+ this.markUsedJustification(bucket, prevOffset.anchor, symbolInstance);
371
+ }
372
+ }
294
373
  }
374
+ }
295
375
 
296
- if (placeText && placedGlyphBoxes) {
297
- this.collisionIndex.insertCollisionBox(
298
- placedGlyphBoxes.box,
299
- layout.get('text-ignore-placement'),
300
- bucket.bucketInstanceId,
301
- textFeatureIndex,
302
- collisionGroup.ID
303
- );
304
- }
305
- if (placeIcon && placedIconBoxes) {
306
- this.collisionIndex.insertCollisionBox(
307
- placedIconBoxes.box,
308
- layout.get('icon-ignore-placement'),
309
- bucket.bucketInstanceId,
310
- iconFeatureIndex,
311
- collisionGroup.ID
312
- );
313
- }
314
- if (placeText && placedGlyphCircles) {
315
- this.collisionIndex.insertCollisionCircles(
316
- placedGlyphCircles.circles,
317
- layout.get('text-ignore-placement'),
318
- bucket.bucketInstanceId,
319
- textFeatureIndex,
320
- collisionGroup.ID
321
- );
322
- }
376
+ offscreen = placedGlyphBoxes?.offscreen;
377
+ const textCircles = collisionArrays.textCircles;
378
+ if (textCircles) {
379
+ const placedSymbol = bucket.text.placedSymbolArray.get(symbolInstance.centerJustifiedTextSymbolIndex);
380
+ const fontSize = symbolSize.evaluateSizeForFeature(
381
+ bucket.textSizeData,
382
+ partiallyEvaluatedTextSize,
383
+ placedSymbol
384
+ );
385
+ placedGlyphCircles = this.collisionIndex.placeCollisionCircles(
386
+ textCircles,
387
+ textAllowOverlap,
388
+ scale,
389
+ textPixelRatio,
390
+ placedSymbol,
391
+ bucket.lineVertexArray,
392
+ bucket.glyphOffsetArray,
393
+ fontSize,
394
+ posMatrix,
395
+ textLabelPlaneMatrix,
396
+ showCollisionBoxes,
397
+ pitchWithMap,
398
+ collisionGroup.predicate
399
+ );
400
+ // If text-allow-overlap is set, force "placedCircles" to true
401
+ // In theory there should always be at least one circle placed
402
+ // in this case, but for now quirks in text-anchor
403
+ // and text-offset may prevent that from being true.
404
+ placeText = textAllowOverlap || placedGlyphCircles.circles.length > 0;
405
+ offscreen = offscreen && placedGlyphCircles.offscreen;
406
+ }
407
+
408
+ if (collisionArrays.iconFeatureIndex) {
409
+ iconFeatureIndex = collisionArrays.iconFeatureIndex;
410
+ }
411
+ if (collisionArrays.iconBox) {
412
+ placedIconBoxes = this.collisionIndex.placeCollisionBox(
413
+ collisionArrays.iconBox,
414
+ iconAllowOverlap,
415
+ textPixelRatio,
416
+ posMatrix,
417
+ collisionGroup.predicate
418
+ );
419
+ placeIcon = placedIconBoxes.box.length > 0;
420
+ offscreen = offscreen && placedIconBoxes.offscreen;
421
+ }
323
422
 
324
- assert(symbolInstance.crossTileID !== 0);
325
- assert(bucket.bucketInstanceId !== 0);
423
+ const iconWithoutText =
424
+ textOptional ||
425
+ (symbolInstance.numHorizontalGlyphVertices === 0 && symbolInstance.numVerticalGlyphVertices === 0);
426
+ const textWithoutIcon = iconOptional || symbolInstance.numIconVertices === 0;
427
+
428
+ // Combine the scales for icons and text.
429
+ if (!iconWithoutText && !textWithoutIcon) {
430
+ placeIcon = placeText = placeIcon && placeText;
431
+ } else if (!textWithoutIcon) {
432
+ placeText = placeIcon && placeText;
433
+ } else if (!iconWithoutText) {
434
+ placeIcon = placeIcon && placeText;
435
+ }
326
436
 
327
- this.placements[symbolInstance.crossTileID] = new JointPlacement(
328
- placeText || (alwaysShowText && placedGlyphBoxes),
329
- placeIcon || alwaysShowIcon,
330
- offscreen || bucket.justReloaded
437
+ if (placeText && placedGlyphBoxes) {
438
+ this.collisionIndex.insertCollisionBox(
439
+ placedGlyphBoxes.box,
440
+ layout.get('text-ignore-placement'),
441
+ bucket.bucketInstanceId,
442
+ textFeatureIndex,
443
+ collisionGroup.ID
331
444
  );
332
- seenCrossTileIDs[symbolInstance.crossTileID] = true;
445
+ }
446
+ if (placeIcon && placedIconBoxes) {
447
+ this.collisionIndex.insertCollisionBox(
448
+ placedIconBoxes.box,
449
+ layout.get('icon-ignore-placement'),
450
+ bucket.bucketInstanceId,
451
+ iconFeatureIndex,
452
+ collisionGroup.ID
453
+ );
454
+ }
455
+ if (placeText && placedGlyphCircles) {
456
+ this.collisionIndex.insertCollisionCircles(
457
+ placedGlyphCircles.circles,
458
+ layout.get('text-ignore-placement'),
459
+ bucket.bucketInstanceId,
460
+ textFeatureIndex,
461
+ collisionGroup.ID
462
+ );
463
+ }
464
+
465
+ assert(symbolInstance.crossTileID !== 0);
466
+ assert(bucket.bucketInstanceId !== 0);
467
+
468
+ this.placements[symbolInstance.crossTileID] = new JointPlacement(
469
+ placeText || (alwaysShowText && placedGlyphBoxes),
470
+ placeIcon || alwaysShowIcon,
471
+ offscreen || bucket.justReloaded
472
+ );
473
+ seenCrossTileIDs[symbolInstance.crossTileID] = true;
474
+ };
475
+
476
+ if (zOrderByViewportY) {
477
+ assert(bucketPart.symbolInstanceStart === 0);
478
+ const symbolIndexes = bucket.getSortedSymbolIndexes(this.transform.angle);
479
+ for (let i = symbolIndexes.length - 1; i >= 0; --i) {
480
+ const symbolIndex = symbolIndexes[i];
481
+ placeSymbol(bucket.symbolInstances.get(symbolIndex), bucket.collisionArrays[symbolIndex]);
482
+ }
483
+ } else {
484
+ for (let i = bucketPart.symbolInstanceStart; i < bucketPart.symbolInstanceEnd; i++) {
485
+ placeSymbol(bucket.symbolInstances.get(i), bucket.collisionArrays[i]);
333
486
  }
334
487
  }
335
488
 
336
489
  bucket.justReloaded = false;
337
490
  }
338
491
 
339
- commit(prevPlacement, now) {
492
+ markUsedJustification(bucket, placedAnchor, symbolInstance) {
493
+ const justifications = {
494
+ left: symbolInstance.leftJustifiedTextSymbolIndex,
495
+ center: symbolInstance.centerJustifiedTextSymbolIndex,
496
+ right: symbolInstance.rightJustifiedTextSymbolIndex
497
+ };
498
+ const autoIndex = justifications[getAnchorJustification(placedAnchor)];
499
+
500
+ for (const justification in justifications) {
501
+ const index = justifications[justification];
502
+ if (index >= 0) {
503
+ if (autoIndex >= 0 && index !== autoIndex) {
504
+ // There are multiple justifications and this one isn't it: shift offscreen
505
+ bucket.text.placedSymbolArray.get(index).crossTileID = 0;
506
+ } else {
507
+ // Either this is the chosen justification or the justification is hardwired: use this one
508
+ bucket.text.placedSymbolArray.get(index).crossTileID = symbolInstance.crossTileID;
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ commit(now) {
340
515
  this.commitTime = now;
341
516
 
517
+ const prevPlacement = this.prevPlacement;
342
518
  let placementChanged = false;
343
519
 
344
520
  const increment =
345
521
  prevPlacement && this.fadeDuration !== 0 ? (this.commitTime - prevPlacement.commitTime) / this.fadeDuration : 1;
346
522
 
347
523
  const prevOpacities = prevPlacement ? prevPlacement.opacities : {};
348
-
524
+ const prevOffsets = prevPlacement ? prevPlacement.variableOffsets : {};
349
525
  // add the opacities from the current placement, and copy their current values from the previous placement
350
526
  for (const crossTileID in this.placements) {
351
527
  const jointPlacement = this.placements[crossTileID];
@@ -384,6 +560,15 @@ export class Placement {
384
560
  }
385
561
  }
386
562
  }
563
+ for (const crossTileID in prevOffsets) {
564
+ if (
565
+ !this.variableOffsets[crossTileID] &&
566
+ this.opacities[crossTileID] &&
567
+ !this.opacities[crossTileID].isHidden()
568
+ ) {
569
+ this.variableOffsets[crossTileID] = prevOffsets[crossTileID];
570
+ }
571
+ }
387
572
 
388
573
  // this.lastPlacementChangeTime is the time of the last commit() that
389
574
  // resulted in a placement change -- in other words, the start time of
@@ -398,7 +583,6 @@ export class Placement {
398
583
 
399
584
  updateLayerOpacities(styleLayer, tiles) {
400
585
  const seenCrossTileIDs = {};
401
-
402
586
  for (const tile of tiles) {
403
587
  const symbolBucket = tile.getBucket(styleLayer);
404
588
  if (symbolBucket && tile.latestFeatureIndex && styleLayer.id === symbolBucket.layers[0].id) {
@@ -425,6 +609,9 @@ export class Placement {
425
609
  const duplicateOpacityState = new JointOpacityState(null, 0, false, false, true);
426
610
  const textAllowOverlap = layout.get('text-allow-overlap');
427
611
  const iconAllowOverlap = layout.get('icon-allow-overlap');
612
+ const variablePlacement = layout.get('text-variable-anchor');
613
+ const rotateWithMap = layout.get('text-rotation-alignment') === 'map';
614
+ const pitchWithMap = layout.get('text-pitch-alignment') === 'map';
428
615
  // If allow-overlap is true, we can show symbols before placement runs on them
429
616
  // But we have to wait for placement if we potentially depend on a paired icon/text
430
617
  // with allow-overlap: false.
@@ -447,38 +634,51 @@ export class Placement {
447
634
 
448
635
  for (let s = 0; s < bucket.symbolInstances.length; s++) {
449
636
  const symbolInstance = bucket.symbolInstances.get(s);
450
- const isDuplicate = seenCrossTileIDs[symbolInstance.crossTileID];
637
+ const { numHorizontalGlyphVertices, numVerticalGlyphVertices, crossTileID } = symbolInstance;
638
+
639
+ const isDuplicate = seenCrossTileIDs[crossTileID];
451
640
 
452
- let opacityState = this.opacities[symbolInstance.crossTileID];
641
+ let opacityState = this.opacities[crossTileID];
453
642
  if (isDuplicate) {
454
643
  opacityState = duplicateOpacityState;
455
644
  } else if (!opacityState) {
456
645
  opacityState = defaultOpacityState;
457
646
  // store the state so that future placements use it as a starting point
458
- this.opacities[symbolInstance.crossTileID] = opacityState;
647
+ this.opacities[crossTileID] = opacityState;
459
648
  }
460
649
 
461
- seenCrossTileIDs[symbolInstance.crossTileID] = true;
650
+ seenCrossTileIDs[crossTileID] = true;
462
651
 
463
- const hasText = symbolInstance.numGlyphVertices > 0 || symbolInstance.numVerticalGlyphVertices > 0;
652
+ const hasText = numHorizontalGlyphVertices > 0 || numVerticalGlyphVertices > 0;
464
653
  const hasIcon = symbolInstance.numIconVertices > 0;
465
654
 
466
655
  if (hasText) {
467
656
  const packedOpacity = packOpacity(opacityState.text);
468
657
  // Vertical text fades in/out on collision the same way as corresponding
469
658
  // horizontal text. Switch between vertical/horizontal should be instantaneous
470
- const opacityEntryCount = (symbolInstance.numGlyphVertices + symbolInstance.numVerticalGlyphVertices) / 4;
659
+ const opacityEntryCount = (numHorizontalGlyphVertices + numVerticalGlyphVertices) / 4;
471
660
  for (let i = 0; i < opacityEntryCount; i++) {
472
661
  bucket.text.opacityVertexArray.emplaceBack(packedOpacity);
473
662
  }
474
663
  // If this label is completely faded, mark it so that we don't have to calculate
475
- // its position at render time
476
- bucket.text.placedSymbolArray.get(symbolInstance.horizontalPlacedTextSymbolIndex).hidden =
477
- opacityState.text.isHidden();
664
+ // its position at render time. If this layer has variable placement, shift the various
665
+ // symbol instances appropriately so that symbols from buckets that have yet to be placed
666
+ // offset appropriately.
667
+ const hidden = opacityState.text.isHidden() ? 1 : 0;
668
+ [
669
+ symbolInstance.rightJustifiedTextSymbolIndex,
670
+ symbolInstance.centerJustifiedTextSymbolIndex,
671
+ symbolInstance.leftJustifiedTextSymbolIndex,
672
+ symbolInstance.verticalPlacedTextSymbolIndex
673
+ ].forEach(index => {
674
+ if (index >= 0) {
675
+ bucket.text.placedSymbolArray.get(index).hidden = hidden;
676
+ }
677
+ });
478
678
 
479
- if (symbolInstance.verticalPlacedTextSymbolIndex >= 0) {
480
- bucket.text.placedSymbolArray.get(symbolInstance.verticalPlacedTextSymbolIndex).hidden =
481
- opacityState.text.isHidden();
679
+ const prevOffset = this.variableOffsets[symbolInstance.crossTileID];
680
+ if (prevOffset) {
681
+ this.markUsedJustification(bucket, prevOffset.anchor, symbolInstance);
482
682
  }
483
683
  }
484
684
 
@@ -494,7 +694,40 @@ export class Placement {
494
694
  const collisionArrays = bucket.collisionArrays[s];
495
695
  if (collisionArrays) {
496
696
  if (collisionArrays.textBox) {
497
- updateCollisionVertices(bucket.collisionBox.collisionVertexArray, opacityState.text.placed, false);
697
+ let shift = { x: 0, y: 0 };
698
+ let used = true;
699
+ if (variablePlacement) {
700
+ const variableOffset = this.variableOffsets[crossTileID];
701
+ if (variableOffset) {
702
+ // This will show either the currently placed position or the last
703
+ // successfully placed position (so you can visualize what collision
704
+ // just made the symbol disappear, and the most likely place for the
705
+ // symbol to come back)
706
+ shift = calculateVariableLayoutOffset(
707
+ variableOffset.anchor,
708
+ variableOffset.width,
709
+ variableOffset.height,
710
+ variableOffset.radialOffset,
711
+ variableOffset.textBoxScale
712
+ );
713
+ if (rotateWithMap) {
714
+ shift = rotate(shift, pitchWithMap ? this.transform.angle : -this.transform.angle);
715
+ }
716
+ } else {
717
+ // No offset -> this symbol hasn't been placed since coming on-screen
718
+ // No single box is particularly meaningful and all of them would be too noisy
719
+ // Use the center box just to show something's there, but mark it "not used"
720
+ used = false;
721
+ }
722
+ }
723
+
724
+ updateCollisionVertices(
725
+ bucket.collisionBox.collisionVertexArray,
726
+ opacityState.text.placed,
727
+ !used,
728
+ shift.x,
729
+ shift.y
730
+ );
498
731
  }
499
732
 
500
733
  if (collisionArrays.iconBox) {
@@ -551,11 +784,11 @@ export class Placement {
551
784
  }
552
785
  }
553
786
 
554
- function updateCollisionVertices(collisionVertexArray, placed, notUsed) {
555
- collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0);
556
- collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0);
557
- collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0);
558
- collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0);
787
+ function updateCollisionVertices(collisionVertexArray, placed, notUsed, shiftX, shiftY) {
788
+ collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0, shiftX || 0, shiftY || 0);
789
+ collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0, shiftX || 0, shiftY || 0);
790
+ collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0, shiftX || 0, shiftY || 0);
791
+ collisionVertexArray.emplaceBack(placed ? 1 : 0, notUsed ? 1 : 0, shiftX || 0, shiftY || 0);
559
792
  }
560
793
 
561
794
  // All four vertices for a glyph will have the same opacity state