@tangram.gl/basemap-layers 0.0.0-stage → 0.0.1

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 (105) hide show
  1. package/README.md +34 -2
  2. package/THIRD-PARTY-NOTICES.md +205 -0
  3. package/dist/geojson-worker.js +2160 -0
  4. package/dist/geojson-worker.js.map +6 -0
  5. package/dist/index.cjs +11388 -0
  6. package/dist/index.cjs.map +6 -0
  7. package/dist/index.js +11369 -0
  8. package/dist/index.js.map +6 -0
  9. package/dist/map-style.cjs +445 -0
  10. package/dist/map-style.cjs.map +6 -0
  11. package/dist/map-style.js +431 -0
  12. package/dist/map-style.js.map +6 -0
  13. package/dist/types/arabic-shaping.d.ts +7 -0
  14. package/dist/types/atmosphere-layer.d.ts +10 -0
  15. package/dist/types/basemap-layer.d.ts +150 -0
  16. package/dist/types/bidi.d.ts +8 -0
  17. package/dist/types/circle-layer.d.ts +49 -0
  18. package/dist/types/fill-extrusion-lighting-extension.d.ts +49 -0
  19. package/dist/types/fill-outline-extension.d.ts +22 -0
  20. package/dist/types/fill-pattern.d.ts +99 -0
  21. package/dist/types/fill-style-extensions.d.ts +100 -0
  22. package/dist/types/geojson-source.d.ts +29 -0
  23. package/dist/types/geojson-tiler.d.ts +48 -0
  24. package/dist/types/geojson-worker.d.ts +40 -0
  25. package/dist/types/globe-horizon-extension.d.ts +61 -0
  26. package/dist/types/globe-layers.d.ts +239 -0
  27. package/dist/types/globe-line-angle-extension.d.ts +37 -0
  28. package/dist/types/glyph-font-renderer.d.ts +37 -0
  29. package/dist/types/glyph-pbf.d.ts +37 -0
  30. package/dist/types/glyph-store.d.ts +103 -0
  31. package/dist/types/grid-index.d.ts +47 -0
  32. package/dist/types/hillshade-layer.d.ts +154 -0
  33. package/dist/types/index.d.ts +9 -0
  34. package/dist/types/is-globe-viewport.d.ts +2 -0
  35. package/dist/types/label-font-renderer.d.ts +18 -0
  36. package/dist/types/label-placement.d.ts +247 -0
  37. package/dist/types/label-text-layer.d.ts +231 -0
  38. package/dist/types/line-blur-extension.d.ts +73 -0
  39. package/dist/types/line-glyphs.d.ts +60 -0
  40. package/dist/types/line-placement.d.ts +93 -0
  41. package/dist/types/map-style-loader.d.ts +48 -0
  42. package/dist/types/map-style-schema.d.ts +72 -0
  43. package/dist/types/map-style.d.ts +6 -0
  44. package/dist/types/mapbox-style.d.ts +40 -0
  45. package/dist/types/mvt-label-layer.d.ts +923 -0
  46. package/dist/types/polylabel.d.ts +17 -0
  47. package/dist/types/raster-color-extension.d.ts +146 -0
  48. package/dist/types/shared-texture.d.ts +34 -0
  49. package/dist/types/sort-key.d.ts +10 -0
  50. package/dist/types/sprite.d.ts +82 -0
  51. package/dist/types/style-accessor.d.ts +45 -0
  52. package/dist/types/style-expression.d.ts +83 -0
  53. package/dist/types/style-resolver.d.ts +94 -0
  54. package/dist/types/text-font.d.ts +36 -0
  55. package/dist/types/text-halo-blur-extension.d.ts +55 -0
  56. package/dist/types/text-layout.d.ts +71 -0
  57. package/dist/types/util.d.ts +22 -0
  58. package/dist/types/zoom-paint-extension.d.ts +72 -0
  59. package/package.json +80 -3
  60. package/src/arabic-shaping.ts +366 -0
  61. package/src/atmosphere-layer.ts +19 -0
  62. package/src/basemap-layer.ts +533 -0
  63. package/src/bidi.ts +199 -0
  64. package/src/circle-layer.ts +191 -0
  65. package/src/fill-extrusion-lighting-extension.ts +206 -0
  66. package/src/fill-outline-extension.ts +70 -0
  67. package/src/fill-pattern.ts +400 -0
  68. package/src/fill-style-extensions.ts +177 -0
  69. package/src/geojson-source.ts +318 -0
  70. package/src/geojson-tiler.ts +185 -0
  71. package/src/geojson-worker.ts +98 -0
  72. package/src/globe-horizon-extension.ts +64 -0
  73. package/src/globe-layers.ts +3487 -0
  74. package/src/globe-line-angle-extension.ts +131 -0
  75. package/src/glyph-font-renderer.ts +173 -0
  76. package/src/glyph-pbf.ts +164 -0
  77. package/src/glyph-store.ts +326 -0
  78. package/src/grid-index.ts +177 -0
  79. package/src/hillshade-layer.ts +775 -0
  80. package/src/index.ts +18 -0
  81. package/src/is-globe-viewport.ts +24 -0
  82. package/src/label-font-renderer.ts +159 -0
  83. package/src/label-placement.ts +727 -0
  84. package/src/label-text-layer.ts +913 -0
  85. package/src/line-blur-extension.ts +237 -0
  86. package/src/line-glyphs.ts +364 -0
  87. package/src/line-placement.ts +446 -0
  88. package/src/map-style-loader.ts +56 -0
  89. package/src/map-style-schema.ts +83 -0
  90. package/src/map-style.ts +23 -0
  91. package/src/mapbox-style.ts +168 -0
  92. package/src/mvt-label-layer.ts +3744 -0
  93. package/src/polylabel.ts +312 -0
  94. package/src/raster-color-extension.ts +245 -0
  95. package/src/shared-texture.ts +153 -0
  96. package/src/sort-key.ts +63 -0
  97. package/src/sprite.ts +244 -0
  98. package/src/style-accessor.ts +95 -0
  99. package/src/style-expression.ts +327 -0
  100. package/src/style-resolver.ts +301 -0
  101. package/src/text-font.ts +150 -0
  102. package/src/text-halo-blur-extension.ts +115 -0
  103. package/src/text-layout.ts +441 -0
  104. package/src/util.ts +47 -0
  105. package/src/zoom-paint-extension.ts +207 -0
@@ -0,0 +1,3744 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {CompositeLayer} from '@deck.gl/core';
6
+ import {isGlobeViewport} from './is-globe-viewport';
7
+ import type {DefaultProps, Layer, LayerContext, UpdateParameters, Viewport} from '@deck.gl/core';
8
+ import {
9
+ acquireSharedLabelPlacement,
10
+ getSharedLabelPlacementKey,
11
+ LabelPlacement,
12
+ LabelPlacementExtension,
13
+ releaseSharedLabelPlacement,
14
+ type PlacementLayer,
15
+ type PlacementShape,
16
+ type PlacementSymbol,
17
+ PLACEMENT_VIEWPORT_PADDING,
18
+ type SymbolPlacementRules
19
+ } from './label-placement';
20
+ import type {OverlapMode} from './grid-index';
21
+ import {GeoJsonLayer, IconLayer} from '@deck.gl/layers';
22
+ import {LabelTextLayer} from './label-text-layer';
23
+ import type {LabelTextJustify} from './label-text-layer';
24
+ import {SharedTextureSet} from './shared-texture';
25
+ import {isSameList} from './util';
26
+ import {getZoomBucket, withOpacity} from './style-accessor';
27
+ import {
28
+ getCompiledStyleProperty,
29
+ getStylePropertyDefault,
30
+ type CompiledStyleProperty
31
+ } from './style-expression';
32
+ import {getSpriteImageNames, resolveSpriteIcon, warnMissingIcon} from './sprite';
33
+ import type {ResolvedSpriteIcon, SpriteAtlas} from './sprite';
34
+ import {GlobeHorizonExtension} from './globe-horizon-extension';
35
+ import {GlobeLineAngleExtension} from './globe-line-angle-extension';
36
+ import {DEFAULT_TEXT_FONT, getTextLayerFontWeight, resolveLabelFont} from './text-font';
37
+ import {getHaloBlurGamma, TextHaloBlurExtension} from './text-halo-blur-extension';
38
+ import {
39
+ GLYPH_FONT_SETTINGS,
40
+ getGlyphCharacterMeasurer,
41
+ getGlyphFontRenderer
42
+ } from './glyph-font-renderer';
43
+ import type {GlyphStore} from './glyph-store';
44
+ import {
45
+ getPathCircles,
46
+ needsFlip,
47
+ placeLineGlyphs,
48
+ type LabelLine,
49
+ type LineGlyphs
50
+ } from './line-glyphs';
51
+ import {
52
+ allowsLetterSpacing,
53
+ canMeasureText,
54
+ forgetCharacterWidths,
55
+ getCharacterWidthsRevision,
56
+ getCharacterWidthMeasurer,
57
+ orderBidirectionalText,
58
+ transformText,
59
+ wrapText
60
+ } from './text-layout';
61
+ import type {WrapBudget} from './text-layout';
62
+ import {
63
+ DEFAULT_POLE_SEARCH_SEGMENT_TESTS,
64
+ getPoleOfInaccessibility,
65
+ type PoleSearchBudget
66
+ } from './polylabel';
67
+ import type {LabelFont, LabelFontFamily} from './text-font';
68
+ import {
69
+ DEFAULT_LINE_ANCHOR_STEPS,
70
+ clipLine,
71
+ getLineAnchors,
72
+ getUprightAngle,
73
+ mergeLines,
74
+ type LineAnchorBudget
75
+ } from './line-placement';
76
+
77
+ type GeometryType =
78
+ | 'Point'
79
+ | 'MultiPoint'
80
+ | 'LineString'
81
+ | 'MultiLineString'
82
+ | 'Polygon'
83
+ | 'MultiPolygon'
84
+ | string;
85
+
86
+ type FeatureGeometry = {
87
+ type: GeometryType;
88
+ coordinates: any;
89
+ };
90
+
91
+ type FeatureLike = {
92
+ geometry: FeatureGeometry;
93
+ properties?: Record<string, any>;
94
+ };
95
+
96
+ type LabelRow = {
97
+ position: number[];
98
+ /** For labels along a line: the line's direction, in degrees counter-clockwise from east. */
99
+ angle?: number;
100
+ /** For labels along a line: the line, along which their glyphs are placed (see `getLineGlyphs`). */
101
+ line?: RowLine;
102
+ /**
103
+ * With `text-variable-anchor`, a point label has a row per anchor, in order, one after the other
104
+ * in `labelData`: the index of this row's anchor (0, the default, for the first row, which also
105
+ * draws the icon) and the number of anchors.
106
+ */
107
+ textAnchorIndex?: number;
108
+ textAnchorCount?: number;
109
+ };
110
+
111
+ /**
112
+ * The line a row's label is placed along: its vertices in pixels at the placement zoom (shared by
113
+ * every row on the line), where the anchor lies on it, and that zoom.
114
+ */
115
+ type RowLine = LabelLine & {zoom: number};
116
+
117
+ type StyleLayerLike = {
118
+ layout?: Record<string, any>;
119
+ paint?: Record<string, any>;
120
+ };
121
+
122
+ type LabelConfig = {
123
+ labels?: boolean;
124
+ };
125
+
126
+ /**
127
+ * Props accepted by {@link MVTLabelLayer}.
128
+ */
129
+ export type MVTLabelLayerProps = {
130
+ /** Decoded vector-tile features or a feature-collection-like object. */
131
+ data?: {features?: FeatureLike[]} | FeatureLike[];
132
+ /** Label rendering enablement for the current basemap mode. */
133
+ config: LabelConfig;
134
+ /** Style layer that contributes label rules. */
135
+ styleLayer?: StyleLayerLike;
136
+ /** Zoom level used to evaluate style expressions. */
137
+ zoom?: number;
138
+ /** Text fill color. */
139
+ textColor?: number[];
140
+ /**
141
+ * `[min, max)`: this layer's band in its style. `BasemapLayer` gives each symbol style layer its
142
+ * own band, rising with style order; labels are placed from the highest band down (see
143
+ * `LabelPlacement`).
144
+ */
145
+ collisionPriorityRange?: [number, number];
146
+ /** Text size units forwarded to `TextLayer`. */
147
+ labelSizeUnits?: 'pixels' | 'meters' | 'common';
148
+ /**
149
+ * Overrides the font derived from the style layer's `text-font`: a CSS family list, or a
150
+ * function from the `text-font` names to a family list or {@link LabelFont} fields.
151
+ */
152
+ fontFamily?: LabelFontFamily | null;
153
+ /**
154
+ * The glyphs of the style's `glyphs` URL, which labels are drawn with, as in MapLibre GL JS. A
155
+ * tile's labels are placed once the glyphs of their text have loaded. Without it, labels are
156
+ * drawn with the browser's fonts that `text-font` maps to.
157
+ */
158
+ glyphs?: GlyphStore | null;
159
+ /** Enables billboard rendering in the text sublayer. */
160
+ billboard?: boolean;
161
+ /** When `true`, renders the source geometries for debugging. */
162
+ renderGeometry?: boolean;
163
+ /** Additional extension instances passed through to the text sublayer. */
164
+ extensions?: any[];
165
+ /** Active basemap mode. */
166
+ mode?: 'map' | 'globe';
167
+ /**
168
+ * The placement shared with the other label layers of the map, which decides which labels and
169
+ * icons are drawn (see {@link LabelPlacement}). Label layers without one share one per deck.
170
+ */
171
+ labelPlacement?: LabelPlacement | null;
172
+ /**
173
+ * Sprites loaded for the style; `icon-image` names resolve against them. `null` while they are
174
+ * loading: no icons are drawn, and text is placed as if each symbol had its icon, so labels do
175
+ * not change placement rules when the sprites arrive.
176
+ */
177
+ spriteAtlases?: SpriteAtlas[] | null;
178
+ /** Load options for the sprite atlas images, so they use the same fetch as the style. */
179
+ iconLoadOptions?: Record<string, unknown> | null;
180
+ /**
181
+ * The tile's own bounding box, as deck.gl's `TileLayer` reports it. In longitude/latitude (globe
182
+ * tiles), polygon labels outside it are dropped, so a polygon in two tiles' buffers is labelled
183
+ * by one tile. Tile-local coordinates always use the tile's `[0, 1)` square.
184
+ */
185
+ tileBoundingBox?: {west: number; south: number; east: number; north: number} | null;
186
+ /** The tile's zoom level, at which labels along lines are spaced. Default: the integer `zoom`. */
187
+ tileZoom?: number | null;
188
+ /** The tile's size in pixels at its own zoom. Default: 512. */
189
+ tileSize?: number | null;
190
+ /**
191
+ * The label features of several tiles, labelled together in one layer instead of `data`: each
192
+ * tile's anchors are placed as for a layer of its own (`tileBoundingBox`, `tileZoom` and
193
+ * `tileSize` per tile), then drawn in longitude/latitude. Pass the same array while the tiles do
194
+ * not change; a tile keeps its anchors while its features, and the inputs they depend on, do not
195
+ * change. See {@link LabelTile}.
196
+ */
197
+ tiles?: LabelTile[] | null;
198
+ };
199
+
200
+ /** The label features of one tile, for {@link MVTLabelLayerProps.tiles}. */
201
+ export type LabelTile = {
202
+ /** The tile's index, which places tile-local coordinates in the world. */
203
+ index: {x: number; y: number; z: number};
204
+ /** The tile's features of the style layer. */
205
+ features: FeatureLike[];
206
+ tileBoundingBox?: {
207
+ west: number;
208
+ south: number;
209
+ east: number;
210
+ north: number;
211
+ } | null;
212
+ tileZoom?: number | null;
213
+ tileSize?: number | null;
214
+ /** Returned as `info.tile` when a label of the tile is picked. */
215
+ tile?: unknown;
216
+ };
217
+
218
+ /** The anchors of one tile of a {@link MVTLabelLayerProps.tiles} layer. */
219
+ type TileRows = {
220
+ features: FeatureLike[];
221
+ anchorKey: string;
222
+ /** The text and font the glyphs were loaded for (see `getGlyphKey`). */
223
+ glyphKey: string;
224
+ /** The tile the rows report when picked (see `LabelTile.tile`). */
225
+ tile: unknown;
226
+ rows: LabelRow[];
227
+ /** The font stacks of the rows' text, which the layer retains in its glyph store. */
228
+ fontStacks: ReadonlySet<string>;
229
+ /**
230
+ * The glyph store's `generation` when it refused to retain a font stack of the tile's text, whose
231
+ * labels are then left out of the rows; the rows are placed again once it has changed.
232
+ */
233
+ refusedGeneration?: number;
234
+ };
235
+
236
+ /** The tile-local `[0, 1)` square, as `ClipExtension` clips tile sublayers to it by anchor. */
237
+ const TILE_SQUARE = [0, 0, 1, 1];
238
+
239
+ /** `tileKey` of an index. */
240
+ function getTileKey(x: number, y: number, z: number): string {
241
+ return `${x},${y},${z}`;
242
+ }
243
+
244
+ /**
245
+ * The rows of every tile of `tiles`, from `tileRows`, without those of a tile whose anchor lies in
246
+ * a deeper tile of the set: while a tile's children load, the tile is drawn in their place, and
247
+ * where a child has loaded, the child's labels replace the parent's. `geographic` says whether the
248
+ * rows are in longitude/latitude; otherwise they are already converted, see `getTileRowsInWorld`.
249
+ */
250
+ export function combineTileRows(tiles: LabelTile[], tileRows: LabelRow[][]): LabelRow[] {
251
+ const keys = new Set(tiles.map(({index: {x, y, z}}) => getTileKey(x, y, z)));
252
+ const maxZ = Math.max(...tiles.map(tile => tile.index.z));
253
+ const combined: LabelRow[] = [];
254
+ tiles.forEach((tile, tileIndex) => {
255
+ const rows = tileRows[tileIndex];
256
+ const {z} = tile.index;
257
+ if (z >= maxZ) {
258
+ combined.push(...rows);
259
+ return;
260
+ }
261
+ for (const row of rows) {
262
+ // Tile-local anchors keep their exact world position: one converted to longitude/latitude
263
+ // and back can land on the wrong side of a tile edge.
264
+ const [wx, wy] = (row as {world?: number[]}).world ?? lngLatToWorld(row.position);
265
+ let covered = false;
266
+ for (let childZ = z + 1; childZ <= maxZ && !covered; childZ++) {
267
+ const scale = 2 ** childZ;
268
+ covered = keys.has(getTileKey(Math.floor(wx * scale), Math.floor(wy * scale), childZ));
269
+ }
270
+ if (!covered) {
271
+ combined.push(row);
272
+ }
273
+ }
274
+ });
275
+ return combined;
276
+ }
277
+
278
+ /**
279
+ * Draw parameters of the text, icon and anchor mark sublayers. Labels draw over the map without a depth test.
280
+ * Their screen-space quads wind clockwise, so `GlobeView`'s default `cullMode: 'back'` would cull
281
+ * every one of them; they are drawn with culling off, in every view.
282
+ */
283
+ const LABEL_PARAMETERS = {depthTest: false, cullMode: 'none'} as const;
284
+
285
+ /**
286
+ * Without a depth test, labels on the far side of a globe would draw through it. This extension
287
+ * hides those whose anchor is beyond the horizon (and `getPlacementSymbols` places none of them).
288
+ */
289
+ const GLOBE_HORIZON_EXTENSION = new GlobeHorizonExtension();
290
+
291
+ /** Draws each label sublayer's objects where the shared placement placed them. */
292
+ const LABEL_PLACEMENT_EXTENSION = new LabelPlacementExtension();
293
+
294
+ /** `text-overlap` / `icon-overlap`, or `*-allow-overlap` where that is unset, as MapLibre reads them. */
295
+ function getOverlapMode(overlap: unknown, allowOverlap: unknown): OverlapMode {
296
+ if (overlap === 'always' || overlap === 'never' || overlap === 'cooperative') {
297
+ return overlap;
298
+ }
299
+ return allowOverlap ? 'always' : 'never';
300
+ }
301
+
302
+ /**
303
+ * The box `[x1, y1, x2, y2]` around a box turned by `degrees` counter-clockwise about 0, 0, then
304
+ * taken to the screen by `toScreen` (see `getMapPlaneToScreen`) when it is drawn in the map plane.
305
+ */
306
+ function getTurnedBounds(box: number[], degrees: number, toScreen?: number[] | null): number[] {
307
+ if (!degrees && !toScreen) {
308
+ return box;
309
+ }
310
+ const radians = (degrees * Math.PI) / 180;
311
+ const cos = Math.cos(radians);
312
+ const sin = Math.sin(radians);
313
+ const [ax, ay, bx, by] = toScreen || [1, 0, 0, 1];
314
+ let [x1, y1, x2, y2] = [Infinity, Infinity, -Infinity, -Infinity];
315
+ for (const [x, y] of [
316
+ [box[0], box[1]],
317
+ [box[2], box[1]],
318
+ [box[0], box[3]],
319
+ [box[2], box[3]]
320
+ ]) {
321
+ // Screen y points down, so a counter-clockwise turn is negative there.
322
+ const mx = x * cos + y * sin;
323
+ const my = -x * sin + y * cos;
324
+ const tx = mx * ax + my * bx;
325
+ const ty = mx * ay + my * by;
326
+ x1 = Math.min(x1, tx);
327
+ y1 = Math.min(y1, ty);
328
+ x2 = Math.max(x2, tx);
329
+ y2 = Math.max(y2, ty);
330
+ }
331
+ return [x1, y1, x2, y2];
332
+ }
333
+
334
+ /**
335
+ * How a pixel offset in the map plane at common-space point `common` (on the screen at `sx, sy`)
336
+ * lands on the screen, as deck.gl draws a layer with `billboard: false`: the offset is taken to
337
+ * common space at the view's scale, with y up, and projected. Writes the screen step of a pixel to
338
+ * the right and of a pixel down in the map plane, `[ax, ay, bx, by]`, to `out`.
339
+ */
340
+ function getMapPlaneToScreen(
341
+ viewport: any,
342
+ common: number[],
343
+ sx: number,
344
+ sy: number,
345
+ out: number[]
346
+ ): void {
347
+ const m = viewport.pixelProjectionMatrix as number[];
348
+ const step = 1 / (viewport.scale || 1);
349
+ const project = (cx: number, cy: number, cz: number, index: number) => {
350
+ const w = m[3] * cx + m[7] * cy + m[11] * cz + m[15];
351
+ out[index] = (m[0] * cx + m[4] * cy + m[8] * cz + m[12]) / w - sx;
352
+ out[index + 1] = (m[1] * cx + m[5] * cy + m[9] * cz + m[13]) / w - sy;
353
+ };
354
+ project(common[0] + step, common[1], common[2], 0);
355
+ project(common[0], common[1] - step, common[2], 2);
356
+ }
357
+
358
+ /**
359
+ * The direction a globe view draws a direction on the map in, at `lngLat`: `radians`
360
+ * counter-clockwise from east there, to counter-clockwise on the screen (y up), as
361
+ * `GlobeLineAngleExtension` turns a billboard along a line to it. With `keepUpright`, a direction
362
+ * that points left is turned by another half turn, as the extension keeps labels upright.
363
+ */
364
+ function getGlobeDrawnAngle(
365
+ viewport: any,
366
+ lngLat: number[],
367
+ radians: number,
368
+ keepUpright: boolean
369
+ ): number {
370
+ const [lng, lat] = lngLat;
371
+ const step = 1e-4;
372
+ const cosLat = Math.max(Math.cos((lat * Math.PI) / 180), 1e-6);
373
+ const [ax, ay] = viewport.project([lng, lat]);
374
+ const [bx, by] = viewport.project([
375
+ lng + (step * Math.cos(radians)) / cosLat,
376
+ lat + step * Math.sin(radians)
377
+ ]);
378
+ const dx = bx - ax;
379
+ const angle = Math.atan2(ay - by, dx);
380
+ return keepUpright && dx < 0 ? angle + Math.PI : angle;
381
+ }
382
+
383
+ /** What placement needs of a label layer's rows that does not change with the view. */
384
+ type PlacementGeometry = {
385
+ key: unknown[];
386
+ rules: SymbolPlacementRules;
387
+ /**
388
+ * Indices into `labelData`, in placement order. A label with variable anchors is placed from its
389
+ * first row, and its other rows follow it (see `LabelRow.textAnchorIndex`).
390
+ */
391
+ order: number[];
392
+ textBoxes: Float64Array;
393
+ /** Each row's `text-offset` in pixels, `x, y`, which its text box includes. */
394
+ textOffsets: Float64Array;
395
+ iconBoxes: Float64Array;
396
+ textPadding: number;
397
+ /** See `getPlacementCommons`. */
398
+ commons?: {kind: string; positions: Float64Array};
399
+ };
400
+
401
+ /** A tile's place in MapLibre's placement order: deeper tiles first, then by x, then y. */
402
+ function compareTiles(a: any, b: any): number {
403
+ const ia = a?.index;
404
+ const ib = b?.index;
405
+ if (!ia || !ib) {
406
+ return 0;
407
+ }
408
+ return ib.z - ia.z || ia.x - ib.x || ia.y - ib.y;
409
+ }
410
+
411
+ /** Turn billboards along lines on a globe as the view draws the lines (`getLineAngleExtensions`). */
412
+ const GLOBE_LINE_ANGLE_EXTENSIONS = {
413
+ upright: new GlobeLineAngleExtension({keepUpright: true}),
414
+ free: new GlobeLineAngleExtension({keepUpright: false}),
415
+ // Text kept upright turns around without its `text-offset` (see `withLineDirection`).
416
+ uprightText: new GlobeLineAngleExtension({keepUpright: true, keepOffsetSide: true})
417
+ };
418
+
419
+ /** `text-anchor` / `icon-anchor` as a horizontal and vertical fraction from the center. */
420
+ const ANCHOR_FRACTIONS: Record<string, [number, number]> = {
421
+ center: [0, 0],
422
+ left: [-0.5, 0],
423
+ right: [0.5, 0],
424
+ top: [0, -0.5],
425
+ bottom: [0, 0.5],
426
+ 'top-left': [-0.5, -0.5],
427
+ 'top-right': [0.5, -0.5],
428
+ 'bottom-left': [-0.5, 0.5],
429
+ 'bottom-right': [0.5, 0.5]
430
+ };
431
+
432
+ /** `text-anchor` as deck.gl `TextLayer` text anchor and alignment baseline. */
433
+ function getTextAnchorProps(anchor: unknown): {
434
+ textAnchor: 'start' | 'middle' | 'end';
435
+ alignmentBaseline: 'top' | 'center' | 'bottom';
436
+ } {
437
+ const [x, y] = ANCHOR_FRACTIONS[String(anchor)] || ANCHOR_FRACTIONS.center;
438
+ return {
439
+ textAnchor: x < 0 ? 'start' : x > 0 ? 'end' : 'middle',
440
+ alignmentBaseline: y < 0 ? 'top' : y > 0 ? 'bottom' : 'center'
441
+ };
442
+ }
443
+
444
+ /**
445
+ * MapLibre GL JS's shift of the vertical offset of variable anchors, in ems: the offset is to the
446
+ * edge of the text box horizontally, but to the glyphs' baseline vertically, which MapLibre takes
447
+ * to be 7 of the 24 units of an em from the box's edge (`variable_text_anchor.ts`).
448
+ */
449
+ const VARIABLE_ANCHOR_BASELINE = 7 / 24;
450
+
451
+ /** A label anchor with variable anchors: the anchor, and its offset in ems of `text-size`. */
452
+ export type TextAnchorOffset = {anchor: string; offset: [number, number]};
453
+
454
+ /**
455
+ * The offset of text at `anchor`, in ems, from `text-radial-offset` (`radial`) or from the size of
456
+ * `text-offset`, as MapLibre GL JS's `evaluateVariableOffset`: away from the anchor point, in the
457
+ * anchor's direction, with the vertical baseline shift.
458
+ */
459
+ export function getVariableAnchorOffset(
460
+ anchor: string,
461
+ offset: number[],
462
+ radial: boolean
463
+ ): [number, number] {
464
+ const [ax, ay] = ANCHOR_FRACTIONS[anchor] || ANCHOR_FRACTIONS.center;
465
+ let x: number;
466
+ let y: number;
467
+ if (radial) {
468
+ const distance = Math.max(0, Number(offset[0]) || 0);
469
+ // A corner anchor is offset along the diagonal.
470
+ const along = ax && ay ? distance / Math.SQRT2 : distance;
471
+ x = ax ? along : 0;
472
+ y = ay ? along : 0;
473
+ } else {
474
+ x = ax ? Math.abs(Number(offset[0]) || 0) : 0;
475
+ y = ay ? Math.abs(Number(offset[1]) || 0) : 0;
476
+ }
477
+ // `left` (ax < 0) moves the text right of the point, `top` (ay < 0) below it.
478
+ return [
479
+ ax < 0 ? x : ax > 0 ? -x : 0,
480
+ ay < 0 ? y - VARIABLE_ANCHOR_BASELINE : ay > 0 ? -y + VARIABLE_ANCHOR_BASELINE : 0
481
+ ];
482
+ }
483
+
484
+ type MVTLabelLayerState = {
485
+ /** The placement this layer registered with, and its own when it has none from its props. */
486
+ placement?: LabelPlacement | null;
487
+ /**
488
+ * What the placement holds for this layer. deck.gl makes a new layer instance at each render
489
+ * and hands it the same state, so the placement holds this object, which points at the
490
+ * current instance.
491
+ */
492
+ placementHandle?: PlacementLayer & {layer: MVTLabelLayer};
493
+ /** The deck whose shared placement this layer uses, without a `labelPlacement` prop. */
494
+ sharedPlacementKey?: object | null;
495
+ /** The shared placement of `sharedPlacementKey`. */
496
+ sharedPlacement?: LabelPlacement | null;
497
+ /** The first of this layer's placement slots, one per row in `getRows` order. */
498
+ placementSlotBase?: number;
499
+ /** Changes with every update, so a placement made before it is made again. */
500
+ placementRevision?: number;
501
+ /**
502
+ * Changes whenever the rows change, and with them the slot of each symbol, so that the placement
503
+ * places them at once (see `PlacementLayer.getPlacementSlotRevision`).
504
+ */
505
+ placementSlotRevision?: number;
506
+ /** The rows `placementSlotRevision` was counted for. */
507
+ placementSlotRows?: LabelRow[];
508
+ /** See `getPlacementGeometry`. */
509
+ placementGeometry?: PlacementGeometry;
510
+ placementRowBoxes?: {key: unknown[]; boxes: WeakMap<LabelRow, Float64Array>};
511
+ placementCommons?: {kind: string; positions: WeakMap<LabelRow, number[]>};
512
+ /** The slot accessor of `getRows` from `placementSlotBase` (see `getPlacementSlotAccessor`). */
513
+ placementSlots?: {rows: LabelRow[]; base: number; accessor: (row: LabelRow) => number};
514
+ /** Flattened label rows generated from the current tile data. */
515
+ labelData?: LabelRow[];
516
+ /** `labelData` in reverse, the order every text and icon sublayer holds it in (see `getRows`). */
517
+ textRows?: {source: LabelRow[]; rows: LabelRow[]};
518
+ /** The non-data inputs `labelData` was computed with; a change recomputes it. */
519
+ anchorKey?: string;
520
+ /** The text and font `labelData`'s glyphs were loaded for (see `getGlyphKey`). */
521
+ glyphKey?: string;
522
+ /** The map bearing, in `BEARING_STEP` steps, that upright line labels were turned for. */
523
+ bearingStep?: number;
524
+ /** 1 while the layer waits for glyphs of the labels' text to load (see `hasLabelGlyphs`). */
525
+ glyphLoads?: number;
526
+ /** The glyph store the layer waits on while `glyphLoads` is 1. */
527
+ glyphLoadStore?: unknown;
528
+ /**
529
+ * The glyph store's `generation` when it refused a font stack of `labelData`'s text, for a
530
+ * layer of one tile's `data` (see `TileRows.refusedGeneration`).
531
+ */
532
+ refusedGeneration?: number;
533
+ /** The CSS font of labels along lines last seen loading; see `isLineTextFontLoading`. */
534
+ loadingFont?: string;
535
+ /**
536
+ * Each row's glyphs along its line, with what they were placed for, by the id of the view they
537
+ * were placed in, the first view placed in first (see `getLineGlyphs`).
538
+ */
539
+ lineGlyphs?: Map<string, WeakMap<LabelRow, {key: string; placements: RowGlyphPlacements | null}>>;
540
+ /** The halos of the label rows: one entry per distinct halo, and each row's entry index. */
541
+ halos?: LabelHalos;
542
+ /** The `labelData` and style inputs `halos` was computed with; a change recomputes it. */
543
+ haloKey?: [LabelRow[] | undefined, string];
544
+ /**
545
+ * Label text for the current text update trigger and label rows, per feature, and the wrap
546
+ * budget left for them. deck.gl reads each label's text several times per update; the cache
547
+ * keeps every read the same once the budget runs out, and wraps each label once.
548
+ */
549
+ labelText?: {
550
+ /** The text trigger, the rows and the font the text was wrapped for. */
551
+ key: [string, LabelRow[] | undefined, string];
552
+ budget: WrapBudget;
553
+ labels: WeakMap<FeatureLike, string | undefined>;
554
+ /** Per label row, whether it has text (see `getRowsWithText`). */
555
+ rowsWithText?: boolean[];
556
+ };
557
+ /** The sprite textures of the icon layers, shared with other label layers. */
558
+ iconTextures?: SharedTextureSet;
559
+ /**
560
+ * Style property values by compiled property, for the zoom step they were evaluated at, by
561
+ * feature (see `evaluateStyleProperty`).
562
+ */
563
+ styleValues?: WeakMap<
564
+ CompiledStyleProperty,
565
+ {zoom: number; values: WeakMap<FeatureLike, unknown>}
566
+ >;
567
+ /** In a `tiles` layer: each tile's anchors, by tile index, and the tiles they were combined for. */
568
+ tileRows?: Map<string, TileRows>;
569
+ rowTiles?: LabelTile[];
570
+ };
571
+
572
+ /** A label halo as deck.gl `TextLayer` draws it: an SDF outline color, width and blur. */
573
+ export type LabelHalo = {
574
+ /** Halo color, RGBA with alpha in 0-255. */
575
+ outlineColor: number[];
576
+ /** `TextLayer` `outlineWidth` (see {@link getOutlineWidth}). */
577
+ outlineWidth: number;
578
+ /** `TextHaloBlurExtension` `textHaloBlurGamma` (see {@link getHaloBlurGamma}). */
579
+ outlineBlur: number;
580
+ };
581
+
582
+ type LabelHalos = {
583
+ /** Distinct halos, `null` for labels without one. */
584
+ halos: (LabelHalo | null)[];
585
+ /** For each label row, its index in `halos`. */
586
+ rowHalos: number[];
587
+ };
588
+
589
+ /**
590
+ * Font atlas settings for label text. Glyphs are drawn as signed distance fields, so `TextLayer`
591
+ * can outline them for `text-halo-*`. The distance field reaches as far as the widest halo:
592
+ * MapLibre caps `text-halo-width` at a quarter of the text size, 16 pixels of a 64-pixel glyph.
593
+ * `TextLayer` outlines no further out than `(0.75 - smoothing) * radius` pixels, 15.6 here, and
594
+ * its halo edge fades over another `smoothing * radius` pixels, which the `buffer` keeps.
595
+ * `TextLayer` draws the glyph edge at a fixed distance value, so a `cutoff` below the default 0.25
596
+ * moves the drawn edge `(0.25 - cutoff) * radius` pixels outward, and the halo with it. At 0.24,
597
+ * small text matches the stroke weight of a plain bitmap atlas more closely.
598
+ */
599
+ const LABEL_FONT_SETTINGS = {
600
+ sdf: true,
601
+ fontSize: 64,
602
+ buffer: 18,
603
+ radius: 24,
604
+ cutoff: 0.24,
605
+ smoothing: 0.1
606
+ };
607
+
608
+ const GLYPH_FONT_RENDERER_FACTORIES = new WeakMap<GlyphStore, Map<string, () => unknown>>();
609
+
610
+ /** `TextLayer`'s `_getFontRenderer` for a font stack of a glyph store, the same each time. */
611
+ function getGlyphFontRendererFactory(glyphs: GlyphStore, fontStack: string): () => unknown {
612
+ let factories = GLYPH_FONT_RENDERER_FACTORIES.get(glyphs);
613
+ if (!factories) {
614
+ factories = new Map();
615
+ GLYPH_FONT_RENDERER_FACTORIES.set(glyphs, factories);
616
+ }
617
+ let factory = factories.get(fontStack);
618
+ if (!factory) {
619
+ factory = () => getGlyphFontRenderer(glyphs, fontStack);
620
+ factories.set(fontStack, factory);
621
+ }
622
+ return factory;
623
+ }
624
+
625
+ /** Shared by every text sublayer, so deck.gl does not see their extensions change. */
626
+ const TEXT_HALO_BLUR_EXTENSION = new TextHaloBlurExtension();
627
+
628
+ /**
629
+ * Most halos one label layer draws, each with its own text sublayer. Labels without a halo get
630
+ * one more sublayer, and with more than one sublayer, another places the labels. Each sublayer
631
+ * holds every label row (see `renderLayers`), so the cap bounds the work per tile when every
632
+ * feature has its own halo.
633
+ */
634
+ export const MAX_HALO_SUBLAYERS = 8;
635
+
636
+ /** `text-halo-width` is rounded to this step, in pixels, so that close widths share a sublayer. */
637
+ const HALO_WIDTH_STEP = 0.25;
638
+
639
+ /**
640
+ * `TextLayer` draws an outline of width `w` at `0.75 * w` atlas pixels outside the drawn glyph
641
+ * edge, whatever the `cutoff`.
642
+ */
643
+ const SDF_OUTLINE_SCALE = 0.75;
644
+
645
+ /**
646
+ * Converts a `text-halo-width`, in pixels, to `TextLayer`'s `outlineWidth` for text of `textSize`
647
+ * pixels, in an atlas of `fontSize` pixels per em. An atlas pixel is `textSize / fontSize` screen pixels, so the same halo needs a wider
648
+ * outline on smaller text. As in MapLibre, the halo is at most a quarter of the text size.
649
+ */
650
+ export function getOutlineWidth(
651
+ haloWidth: number,
652
+ textSize: number,
653
+ fontSize: number = LABEL_FONT_SETTINGS.fontSize
654
+ ): number {
655
+ const atlasPixels = (Math.min(haloWidth, textSize / 4) * fontSize) / textSize;
656
+ // Rounded so that labels whose halos differ imperceptibly share one text sublayer.
657
+ return Math.round((atlasPixels / SDF_OUTLINE_SCALE) * 100) / 100;
658
+ }
659
+
660
+ /**
661
+ * Glyphs along lines are placed again whenever the zoom crosses a step of `1 / LINE_GLYPH_ZOOM_STEPS`:
662
+ * between steps, the line is scaled by less than 0.6% under its text.
663
+ */
664
+ const LINE_GLYPH_ZOOM_STEPS = 128;
665
+
666
+ /**
667
+ * A row's glyphs along its line: `dx, dy, angle, mode` per character (see `LineGlyphPlacement`),
668
+ * and the path its collision circles follow, `x, y` pixels from the anchor, or null when the text
669
+ * does not fit on the line unturned. Text that must turn but does not fit turned keeps the unturned
670
+ * path, with every glyph hidden.
671
+ */
672
+ type RowLineGlyphs = {glyphs: Float32Array; path: number[] | null};
673
+
674
+ /**
675
+ * A row's glyphs along its line at one zoom step, both ways round: `layout` is the unturned
676
+ * placement (null when the text does not fit on the line), from which `getLineGlyphs` decides which
677
+ * way the label reads; `turned` is null when `text-keep-upright` never turns it.
678
+ */
679
+ type RowGlyphPlacements = {
680
+ count: number;
681
+ layout: LineGlyphs | null;
682
+ unturned: RowLineGlyphs;
683
+ turned: RowLineGlyphs | null;
684
+ };
685
+
686
+ /**
687
+ * Work done placing glyphs along lines, for benchmarks: rows placed, cached placements reused and
688
+ * collision paths taken to the screen.
689
+ */
690
+ export const LINE_GLYPH_STATS = {rows: 0, cacheHits: 0, placementPaths: 0};
691
+
692
+ /** The most views a label layer keeps the glyphs along lines of, as a main map and a minimap. */
693
+ const MAX_LINE_GLYPH_VIEWS = 4;
694
+
695
+ /**
696
+ * Bearing step, in degrees, at which labels along lines are re-checked for `text-keep-upright`
697
+ * while the map rotates. A label may read upside down by up to this much before it turns.
698
+ */
699
+ const BEARING_STEP = 5;
700
+
701
+ /**
702
+ * Average glyph advance, in ems, used to estimate a label's length when placing it along a line
703
+ * where the text cannot be measured: without a canvas, or while the label font loads.
704
+ */
705
+ const LINE_LABEL_EM_PER_CHARACTER = 0.6;
706
+
707
+ /** Advance, in ems, of a wide character (CJK, Hangul, fullwidth forms), which is about square. */
708
+ const LINE_LABEL_EM_PER_WIDE_CHARACTER = 1;
709
+
710
+ /** Code point ranges of wide characters: East Asian scripts and fullwidth forms. */
711
+ const WIDE_CHARACTER_RANGES: [number, number][] = [
712
+ [0x1100, 0x115f],
713
+ [0x2e80, 0xa4cf],
714
+ [0xac00, 0xd7a3],
715
+ [0xf900, 0xfaff],
716
+ [0xfe30, 0xfe4f],
717
+ [0xff00, 0xff60],
718
+ [0xffe0, 0xffe6],
719
+ [0x20000, 0x3fffd]
720
+ ];
721
+
722
+ /** The estimated length of `text` in ems: about square for wide characters, narrower for others. */
723
+ export function getTextLengthEms(text: string): number {
724
+ let ems = 0;
725
+ for (const character of text) {
726
+ const code = character.codePointAt(0)!;
727
+ const wide = WIDE_CHARACTER_RANGES.some(([start, end]) => code >= start && code <= end);
728
+ ems += wide ? LINE_LABEL_EM_PER_WIDE_CHARACTER : LINE_LABEL_EM_PER_CHARACTER;
729
+ }
730
+ return ems;
731
+ }
732
+
733
+ /**
734
+ * Turns `offset`, `[x, y]` in pixels with y pointing down, by `angle` degrees counter-clockwise,
735
+ * as deck.gl's `IconLayer` turns an icon by its `getAngle`.
736
+ */
737
+ export function rotatePixelOffset([x, y]: number[], angle: number): [number, number] {
738
+ if (!angle) {
739
+ return [x, y];
740
+ }
741
+ const radians = (angle * Math.PI) / 180;
742
+ const [cos, sin] = [Math.cos(radians), Math.sin(radians)];
743
+ return [cos * x + sin * y, cos * y - sin * x];
744
+ }
745
+
746
+ /**
747
+ * `getPixelOffset` for icons turned by `getAngle`. `IconLayer` turns an icon about its center and
748
+ * then adds its pixel offset; MapLibre turns the whole icon, `icon-anchor` and `icon-offset`
749
+ * included, about the anchor point. Turning the offset by the icon's angle does the same.
750
+ */
751
+ function withIconAngle(
752
+ getPixelOffset: (row: LabelRow, info: {index: number}) => [number, number],
753
+ getAngle: ((row: LabelRow) => number) | null
754
+ ): (row: LabelRow, info: {index: number}) => [number, number] {
755
+ return getAngle
756
+ ? (row, info) => rotatePixelOffset(getPixelOffset(row, info), getAngle(row))
757
+ : getPixelOffset;
758
+ }
759
+
760
+ /**
761
+ * `getPixelOffset` for text turned to the line it is placed along. MapLibre moves such text by
762
+ * `text-offset` along and across the line's own direction, so the offset stays on the same side
763
+ * of the line when `text-keep-upright` turns the text around. `TextLayer` adds the pixel offset
764
+ * after turning the text, so the offset is turned here, by the line's direction.
765
+ */
766
+ function withLineDirection(
767
+ getPixelOffset: (row: LabelRow, info: {index: number}) => [number, number],
768
+ turnsWithLine: boolean
769
+ ): (row: LabelRow, info: {index: number}) => [number, number] {
770
+ return turnsWithLine
771
+ ? (row, info) => rotatePixelOffset(getPixelOffset(row, info), row.angle ?? 0)
772
+ : getPixelOffset;
773
+ }
774
+
775
+ /**
776
+ * Whether a symbol's text or icon turns with the line it is placed along: with
777
+ * `*-rotation-alignment: map`, or `auto` (the default) under line placement, as in MapLibre.
778
+ */
779
+ function isAlignedToMap(alignment: unknown, placement: string): boolean {
780
+ return alignment === 'map' || ((alignment ?? 'auto') === 'auto' && isLinePlacement(placement));
781
+ }
782
+
783
+ /**
784
+ * Work, in point-to-segment distance tests, that polygon label searches may spend per tile and
785
+ * style layer. Tile geometry is untrusted; past this budget, remaining polygons get no label.
786
+ */
787
+ const TILE_POLE_SEARCH_SEGMENT_TESTS = 2e7;
788
+
789
+ /**
790
+ * Work, in steps along lines and vertices checked for bends, that labels along lines may spend
791
+ * per tile and style layer. Past this budget, remaining placements on the tile are skipped.
792
+ */
793
+ const TILE_LINE_ANCHOR_STEPS = 1e6;
794
+
795
+ /**
796
+ * Line-breaking steps (see `WrapBudget`) that label wrapping may spend per tile and style layer
797
+ * each time the labels' text is evaluated: about 60 labels of the longest wrapped length in
798
+ * ideographs, far more than real labels need. Past it, labels are drawn unwrapped.
799
+ */
800
+ const TILE_WRAP_BUDGET_STEPS = 2e6;
801
+
802
+ const geoJsonDefaultProps = {...GeoJsonLayer.defaultProps} as Omit<
803
+ typeof GeoJsonLayer.defaultProps,
804
+ 'data'
805
+ >;
806
+ delete (geoJsonDefaultProps as typeof GeoJsonLayer.defaultProps).data;
807
+
808
+ const functionIds = new WeakMap<object, number>();
809
+ let lastFunctionId = 0;
810
+
811
+ /** A stable number per function object, so a replaced function changes an update trigger. */
812
+ function getFunctionId(value: object): number {
813
+ let id = functionIds.get(value);
814
+ if (id === undefined) {
815
+ id = ++lastFunctionId;
816
+ functionIds.set(value, id);
817
+ }
818
+ return id;
819
+ }
820
+
821
+ /**
822
+ * Replaces style-spec token placeholders in a label template.
823
+ */
824
+ function resolveTokenString(template: unknown, properties?: Record<string, any>): string | null {
825
+ if (typeof template !== 'string') {
826
+ return null;
827
+ }
828
+
829
+ return template.replace(/\{([^}]+)\}/g, (_, token) => {
830
+ const value = properties?.[token];
831
+ return value === null || value === undefined ? '' : String(value);
832
+ });
833
+ }
834
+
835
+ /**
836
+ * Returns a midpoint for a line geometry.
837
+ */
838
+ function getLineMidpoint(coordinates: number[][]): number[] | null {
839
+ if (!Array.isArray(coordinates) || coordinates.length === 0) {
840
+ return null;
841
+ }
842
+
843
+ return coordinates[Math.floor(coordinates.length / 2)] || coordinates[0] || null;
844
+ }
845
+
846
+ /**
847
+ * MapLibre places a point label on a polygon at its pole of inaccessibility, found to within 16
848
+ * units of the tile's 8192-unit extent.
849
+ */
850
+ const POLE_PRECISION_TILE_FRACTION = 16 / 8192;
851
+
852
+ /**
853
+ * Returns one point-label anchor per polygon, at its pole of inaccessibility. Tile-local
854
+ * coordinates are planar already; longitude/latitude (globe tiles) are searched in Web Mercator
855
+ * world units, with the precision of a tile at `zoom`, and converted back. Each search draws on
856
+ * the shared `budget`; once it is spent, the remaining polygons get no anchor. Anchors outside
857
+ * `bounds` (`[minX, minY, maxX, maxY)`, the tile's own extent) are dropped, so a polygon that lies
858
+ * in the buffer of two tiles is labelled once.
859
+ */
860
+ function getPolygonAnchors(
861
+ polygons: number[][][][],
862
+ geographic: boolean,
863
+ zoom: number,
864
+ budget: PoleSearchBudget,
865
+ bounds: number[] | null
866
+ ): number[][] {
867
+ const precision = geographic
868
+ ? POLE_PRECISION_TILE_FRACTION / 2 ** Math.max(0, Math.floor(zoom))
869
+ : POLE_PRECISION_TILE_FRACTION;
870
+ const anchors: number[][] = [];
871
+ for (const polygon of polygons) {
872
+ if (!Array.isArray(polygon) || polygon.length === 0) {
873
+ continue;
874
+ }
875
+ const rings = geographic ? polygon.map(ring => ring.map(lngLatToWorld)) : polygon;
876
+ // One polygon may spend at most the single-search default, and never more than the tile has.
877
+ const polygonBudget = {
878
+ segmentTests: Math.min(DEFAULT_POLE_SEARCH_SEGMENT_TESTS, budget.segmentTests)
879
+ };
880
+ const start = polygonBudget.segmentTests;
881
+ const pole = getPoleOfInaccessibility(rings, precision, polygonBudget);
882
+ budget.segmentTests -= start - polygonBudget.segmentTests;
883
+ if (!pole) {
884
+ continue;
885
+ }
886
+ const anchor = geographic ? worldToLngLat(pole) : pole;
887
+ if (!bounds || isInsideBounds(anchor, bounds)) {
888
+ anchors.push(anchor);
889
+ }
890
+ }
891
+ return anchors;
892
+ }
893
+
894
+ /** Half-open bounds test, so a point on a shared tile edge belongs to exactly one tile. */
895
+ function isInsideBounds([x, y]: number[], [minX, minY, maxX, maxY]: number[]): boolean {
896
+ return x >= minX && x < maxX && y >= minY && y < maxY;
897
+ }
898
+
899
+ /** Whether a point lies strictly inside bounds, off their edges. */
900
+ function isStrictlyInsideBounds([x, y]: number[], [minX, minY, maxX, maxY]: number[]): boolean {
901
+ return x > minX && x < maxX && y > minY && y < maxY;
902
+ }
903
+
904
+ /** Longitude/latitude to Web Mercator world units, `[0, 1]` across the world. */
905
+ function lngLatToWorld([lng, lat]: number[]): number[] {
906
+ const latitude = Math.max(-85.051129, Math.min(85.051129, lat));
907
+ const y = Math.log(Math.tan(Math.PI / 4 + (latitude * Math.PI) / 360));
908
+ return [(lng + 180) / 360, 0.5 - y / (2 * Math.PI)];
909
+ }
910
+
911
+ function worldToLngLat([x, y]: number[]): number[] {
912
+ const latitude = (360 / Math.PI) * Math.atan(Math.exp((0.5 - y) * 2 * Math.PI)) - 90;
913
+ return [x * 360 - 180, latitude];
914
+ }
915
+
916
+ /**
917
+ * What the line placements of one tile and style layer share: how label text is measured, in ems,
918
+ * and the anchors placed so far per label text (see `isNearSameText`).
919
+ */
920
+ type LinePlacementContext = {
921
+ measureText: (text: string) => number;
922
+ placed: Map<string, number[][]>;
923
+ };
924
+
925
+ /**
926
+ * MapLibre's `anchorIsTooClose`: whether `point` lies within `distance` of an anchor already
927
+ * placed for `text`; if not, the point is recorded. Anchors are kept in a grid of `distance`
928
+ * cells, so each check reads a few cells, rather than every anchor of the same text.
929
+ */
930
+ function isNearSameText(
931
+ placed: Map<string, number[][]>,
932
+ text: string,
933
+ [x, y]: number[],
934
+ distance: number
935
+ ): boolean {
936
+ if (!(distance > 0) || !Number.isFinite(x / distance) || !Number.isFinite(y / distance)) {
937
+ return false;
938
+ }
939
+ const column = Math.floor(x / distance);
940
+ const row = Math.floor(y / distance);
941
+ for (let dx = -1; dx <= 1; dx++) {
942
+ for (let dy = -1; dy <= 1; dy++) {
943
+ for (const [px, py] of placed.get(`${column + dx},${row + dy},${text}`) || []) {
944
+ if (Math.hypot(px - x, py - y) < distance) {
945
+ return true;
946
+ }
947
+ }
948
+ }
949
+ }
950
+ const key = `${column},${row},${text}`;
951
+ const cell = placed.get(key);
952
+ if (cell) {
953
+ cell.push([x, y]);
954
+ } else {
955
+ placed.set(key, [[x, y]]);
956
+ }
957
+ return false;
958
+ }
959
+
960
+ function isLinePlacement(placement: string): boolean {
961
+ return placement === 'line' || placement === 'line-center';
962
+ }
963
+
964
+ function isLineGeometry(feature: FeatureLike): boolean {
965
+ const type = feature.geometry?.type;
966
+ return type === 'LineString' || type === 'MultiLineString';
967
+ }
968
+
969
+ /** The rows whose `part` (text or icon) is drawn by the same sublayers, with the same collision. */
970
+ type SymbolPartGroup = {
971
+ /** Appended to the ids of the group's sublayers. */
972
+ idSuffix: string;
973
+ /** Per row, whether the row is in the group; `null` when every row is. */
974
+ rows: boolean[] | null;
975
+ };
976
+
977
+ /** `accessor` for the rows in `rows` and `fallback` for the others; `accessor` when `rows` is null. */
978
+ function forRows<T>(
979
+ rows: boolean[] | null,
980
+ accessor: (row: LabelRow, info: {index: number}) => T,
981
+ fallback: T
982
+ ): (row: LabelRow, info: {index: number}) => T {
983
+ return rows ? (row, info) => (rows[info.index] ? accessor(row, info) : fallback) : accessor;
984
+ }
985
+
986
+ /**
987
+ * Renders label text for decoded vector-tile features, with optional geometry
988
+ * passthrough for debugging.
989
+ */
990
+ export class MVTLabelLayer extends CompositeLayer<MVTLabelLayerProps> {
991
+ /** Deck.gl layer name. */
992
+ static layerName = 'MVTLabelLayer';
993
+
994
+ /** Default props for {@link MVTLabelLayer}. */
995
+ static defaultProps: DefaultProps<MVTLabelLayerProps> = {
996
+ ...geoJsonDefaultProps,
997
+ billboard: true,
998
+ renderGeometry: false,
999
+ labelSizeUnits: 'pixels',
1000
+ fontFamily: null,
1001
+ glyphs: null
1002
+ };
1003
+
1004
+ /** Current label-row state. */
1005
+ state: MVTLabelLayerState = undefined!;
1006
+
1007
+ /** Not loaded while the glyphs of its labels load: their tiles are not labelled yet. */
1008
+ get isLoaded(): boolean {
1009
+ return super.isLoaded && !this.state?.glyphLoads;
1010
+ }
1011
+
1012
+ /** The tile whose anchors are being placed, in a `tiles` layer; see `getTileProps`. */
1013
+ private placingTile: LabelTile | null = null;
1014
+
1015
+ /** The tile props that anchors are placed with: the tile being placed, or this layer's. */
1016
+ getTileProps(): Pick<MVTLabelLayerProps, 'tileBoundingBox' | 'tileZoom' | 'tileSize'> {
1017
+ return this.placingTile ?? this.props;
1018
+ }
1019
+
1020
+ /** A picked label of a `tiles` layer reports its tile. */
1021
+ getPickingInfo(params: any): any {
1022
+ const row = params.info?.object;
1023
+ const info: any = super.getPickingInfo(params);
1024
+ if (row?.__tile) {
1025
+ info.tile = row.__tile;
1026
+ }
1027
+ return info;
1028
+ }
1029
+
1030
+ /**
1031
+ * The image name `icon-image` gives a feature, with its tokens resolved: empty for none. While the
1032
+ * sprites load, an `["image", ...]` expression still names its image, which is not available yet.
1033
+ */
1034
+ getIconImageName(feature: FeatureLike): string {
1035
+ const iconImage = this.getStyleProperty('icon-image');
1036
+ if (!iconImage) {
1037
+ return '';
1038
+ }
1039
+ const value = iconImage.evaluate(
1040
+ getZoomBucket(this.props.zoom || 0),
1041
+ feature,
1042
+ getSpriteImageNames(this.props.spriteAtlases)
1043
+ );
1044
+ const isExpression = Array.isArray(this.props.styleLayer?.layout?.['icon-image']);
1045
+ return value === null || value === undefined
1046
+ ? ''
1047
+ : isExpression || iconImage.isFeatureDependent
1048
+ ? String(value)
1049
+ : resolveTokenString(String(value), feature.properties) || '';
1050
+ }
1051
+
1052
+ /**
1053
+ * Resolves `icon-image` for a feature against the loaded sprites. Legacy `{token}` names are
1054
+ * resolved as in `text-field`. Returns `null`, and warns once per name, when no sprite has it.
1055
+ */
1056
+ getIcon(feature: FeatureLike): ResolvedSpriteIcon | null {
1057
+ const name = this.getIconImageName(feature);
1058
+ if (!name) {
1059
+ return null;
1060
+ }
1061
+ const icon = resolveSpriteIcon(this.props.spriteAtlases || undefined, name);
1062
+ if (!icon) {
1063
+ warnMissingIcon(name);
1064
+ }
1065
+ return icon;
1066
+ }
1067
+
1068
+ /** On-screen icon height in pixels: the image's CSS-pixel height scaled by `icon-size`. */
1069
+ getIconSize(feature: FeatureLike): number {
1070
+ const icon = this.getIcon(feature);
1071
+ if (!icon) {
1072
+ return 0;
1073
+ }
1074
+ const scale = Number(this.evaluateStyleProperty('icon-size', feature) ?? 1);
1075
+ return (icon.entry.height / icon.entry.pixelRatio) * scale;
1076
+ }
1077
+
1078
+ /**
1079
+ * Icon color: `icon-color` for SDF images, white otherwise (deck.gl draws unmasked icons in
1080
+ * their own colors), with `icon-opacity` as alpha.
1081
+ */
1082
+ getIconColor(feature: FeatureLike): number[] {
1083
+ const opacity = Number(this.evaluateStyleProperty('icon-opacity', feature) ?? 1);
1084
+ const icon = this.getIcon(feature);
1085
+ const color = icon?.entry.mask
1086
+ ? (this.evaluateStyleProperty('icon-color', feature) as number[] | undefined) || [0, 0, 0, 1]
1087
+ : [255, 255, 255, 1];
1088
+ return withOpacity(color, opacity);
1089
+ }
1090
+
1091
+ /**
1092
+ * Icon pixel offset: `icon-anchor` places that edge or corner of the icon on the point, and
1093
+ * `icon-offset` shifts it by pixels scaled with `icon-size`, as in MapLibre.
1094
+ */
1095
+ getIconPixelOffset(feature: FeatureLike): [number, number] {
1096
+ const icon = this.getIcon(feature);
1097
+ if (!icon) {
1098
+ return [0, 0];
1099
+ }
1100
+ const scale = Number(this.evaluateStyleProperty('icon-size', feature) ?? 1);
1101
+ const width = (icon.entry.width / icon.entry.pixelRatio) * scale;
1102
+ const height = (icon.entry.height / icon.entry.pixelRatio) * scale;
1103
+ const [ax, ay] =
1104
+ ANCHOR_FRACTIONS[String(this.evaluateStyleProperty('icon-anchor', feature) ?? 'center')] ||
1105
+ ANCHOR_FRACTIONS.center;
1106
+ const offset = (this.evaluateStyleProperty('icon-offset', feature) as number[] | undefined) || [
1107
+ 0, 0
1108
+ ];
1109
+ return [-ax * width + offset[0] * scale, -ay * height + offset[1] * scale];
1110
+ }
1111
+
1112
+ /**
1113
+ * Text pixel offset from `text-offset`, which is in ems of `text-size`, or from a non-zero
1114
+ * `text-radial-offset`, which MapLibre GL JS takes instead, in the direction of `text-anchor`.
1115
+ * A layer with variable anchors takes its offset from its anchor at placement, which MapLibre
1116
+ * does only for point labels, so its line labels have none.
1117
+ */
1118
+ getLabelPixelOffset(feature: FeatureLike): [number, number] {
1119
+ if (
1120
+ this.getStyleProperty('text-variable-anchor-offset') ||
1121
+ this.getStyleProperty('text-variable-anchor')
1122
+ ) {
1123
+ return [0, 0];
1124
+ }
1125
+ const size = this.getLabelSize(feature);
1126
+ const radial = Number(this.evaluateStyleProperty('text-radial-offset', feature)) || 0;
1127
+ if (radial) {
1128
+ const anchor = String(this.evaluateStyleProperty('text-anchor', feature) ?? 'center');
1129
+ const [x, y] = getVariableAnchorOffset(anchor, [radial], true);
1130
+ return [x * size, y * size];
1131
+ }
1132
+ const offset = this.evaluateStyleProperty('text-offset', feature) as number[] | undefined;
1133
+ if (!offset) {
1134
+ return [0, 0];
1135
+ }
1136
+ return [offset[0] * size, offset[1] * size];
1137
+ }
1138
+
1139
+ /**
1140
+ * The anchors a point label tries, in order, with their offsets in ems, from
1141
+ * `text-variable-anchor-offset`, or from `text-variable-anchor` with `text-radial-offset` (when
1142
+ * the layer sets it) or `text-offset`, as MapLibre GL JS's `getTextVariableAnchorOffset`; `null`
1143
+ * without variable anchors or along lines.
1144
+ */
1145
+ getTextAnchors(feature: FeatureLike, placement?: string): TextAnchorOffset[] | null {
1146
+ if (
1147
+ (!this.getStyleProperty('text-variable-anchor-offset') &&
1148
+ !this.getStyleProperty('text-variable-anchor')) ||
1149
+ isLinePlacement(placement ?? this.getSymbolPlacement())
1150
+ ) {
1151
+ return null;
1152
+ }
1153
+ const anchorOffsets = this.evaluateStyleProperty('text-variable-anchor-offset', feature);
1154
+ if (Array.isArray(anchorOffsets) && anchorOffsets.length >= 2) {
1155
+ const result: TextAnchorOffset[] = [];
1156
+ for (let i = 0; i + 1 < anchorOffsets.length; i += 2) {
1157
+ const anchor = String(anchorOffsets[i]);
1158
+ const [x = 0, y = 0] = (anchorOffsets[i + 1] as number[]) || [];
1159
+ const baseline = anchor.startsWith('top')
1160
+ ? -VARIABLE_ANCHOR_BASELINE
1161
+ : anchor.startsWith('bottom')
1162
+ ? VARIABLE_ANCHOR_BASELINE
1163
+ : 0;
1164
+ result.push({anchor, offset: [Number(x) || 0, (Number(y) || 0) + baseline]});
1165
+ }
1166
+ return result;
1167
+ }
1168
+ const anchors = this.evaluateStyleProperty('text-variable-anchor', feature);
1169
+ if (!Array.isArray(anchors) || !anchors.length) {
1170
+ return null;
1171
+ }
1172
+ // The style spec says not to set both offsets; MapLibre takes the radial one when it is set.
1173
+ const radial = Boolean(this.getStyleProperty('text-radial-offset'));
1174
+ const offset = radial
1175
+ ? [Number(this.evaluateStyleProperty('text-radial-offset', feature)) || 0]
1176
+ : ((this.evaluateStyleProperty('text-offset', feature) as number[] | undefined) ?? [0, 0]);
1177
+ return anchors.map(anchor => ({
1178
+ anchor: String(anchor),
1179
+ offset: getVariableAnchorOffset(String(anchor), offset, radial)
1180
+ }));
1181
+ }
1182
+
1183
+ /** The variable anchor a row's label is drawn at (see `LabelRow.textAnchorIndex`), or `null`. */
1184
+ getRowTextAnchor(row: LabelRow): TextAnchorOffset | null {
1185
+ const anchors = this.getTextAnchors((row as any).__source?.object ?? row);
1186
+ return anchors ? anchors[Math.min(row.textAnchorIndex ?? 0, anchors.length - 1)] : null;
1187
+ }
1188
+
1189
+ /** The `text-anchor` a row's label is drawn at: its variable anchor, or the feature's. */
1190
+ getRowAnchor(row: LabelRow): string {
1191
+ const variable = this.getRowTextAnchor(row);
1192
+ return variable
1193
+ ? variable.anchor
1194
+ : String(this.evaluateStyleProperty('text-anchor', (row as any).__source?.object ?? row));
1195
+ }
1196
+
1197
+ /** A row's text pixel offset: its variable anchor's, or the feature's (`getLabelPixelOffset`). */
1198
+ getRowPixelOffset(row: LabelRow): [number, number] {
1199
+ const feature = (row as any).__source?.object ?? row;
1200
+ const variable = this.getRowTextAnchor(row);
1201
+ if (!variable) {
1202
+ return this.getLabelPixelOffset(feature);
1203
+ }
1204
+ const size = this.getLabelSize(feature);
1205
+ // MapLibre GL JS shifts the text by half its padded box (`calculateVariableLayoutShift`), so
1206
+ // `text-padding` moves the text away from the point too.
1207
+ const [ax, ay] = ANCHOR_FRACTIONS[variable.anchor] || ANCHOR_FRACTIONS.center;
1208
+ const padding = 2 * this.getTextPadding();
1209
+ return [variable.offset[0] * size - ax * padding, variable.offset[1] * size - ay * padding];
1210
+ }
1211
+
1212
+ /** `text-padding` at the stepped zoom, in pixels. */
1213
+ getTextPadding(): number {
1214
+ const zoom = getZoomBucket(this.props.zoom || 0);
1215
+ const padding = Number(
1216
+ this.getStyleProperty('text-padding')?.evaluate(zoom) ??
1217
+ getStylePropertyDefault('text-padding')
1218
+ );
1219
+ return Math.max(0, padding) || 0;
1220
+ }
1221
+
1222
+ /**
1223
+ * Update trigger for accessors that read `propertyNames`: the properties' style values, plus the
1224
+ * stepped zoom when any of them depends on zoom. A value edited in place, or a new zoom step for a
1225
+ * zoom-dependent value, re-evaluates the accessor; other zoom changes do not.
1226
+ */
1227
+ getStyleUpdateTrigger(...propertyNames: string[]): string {
1228
+ const {styleLayer} = this.props;
1229
+ const values = propertyNames.map(name => {
1230
+ const value = styleLayer?.layout?.[name] ?? styleLayer?.paint?.[name];
1231
+ return value === undefined ? '' : JSON.stringify(value);
1232
+ });
1233
+ const isZoomDependent = propertyNames.some(
1234
+ name => this.getStyleProperty(name)?.isZoomDependent
1235
+ );
1236
+ return `${isZoomDependent ? getZoomBucket(this.props.zoom || 0) : ''}|${values.join('|')}`;
1237
+ }
1238
+
1239
+ /** Update triggers for the icon accessors (see `getStyleUpdateTrigger`). */
1240
+ getIconUpdateTriggers(): Record<string, string> {
1241
+ const getTrigger = (...propertyNames: string[]) => this.getStyleUpdateTrigger(...propertyNames);
1242
+ const angle = `${this.state?.bearingStep}|${getTrigger(
1243
+ 'icon-rotate',
1244
+ 'icon-keep-upright',
1245
+ 'icon-rotation-alignment'
1246
+ )}`;
1247
+ return {
1248
+ getIcon: getTrigger('icon-image'),
1249
+ getSize: getTrigger('icon-image', 'icon-size'),
1250
+ getColor: getTrigger('icon-image', 'icon-color', 'icon-opacity'),
1251
+ // Turned icons turn their offset too (see `withIconAngle`).
1252
+ getPixelOffset: `${angle}|${getTrigger('icon-image', 'icon-size', 'icon-anchor', 'icon-offset')}`,
1253
+ getAngle: angle
1254
+ };
1255
+ }
1256
+
1257
+ /**
1258
+ * One `IconLayer` per sprite that this layer's icons come from. Each holds every label row, so
1259
+ * an icon has the same row index, and so the same placement slot, as its own label. Rows whose
1260
+ * icon is in another sprite, or in none, draw nothing.
1261
+ *
1262
+ * `icon-image` is evaluated once per row. Each sprite's layer evaluates the other style
1263
+ * properties only for its own rows and gives every other row constants, so the style evaluation
1264
+ * across all layers is linear in the rows. The instance count is rows times the sprites a tile
1265
+ * uses (twice that when the rules differ), and only the style's `sprite` list adds sprites: tile
1266
+ * data cannot.
1267
+ */
1268
+ renderIconLayers(icons: (ResolvedSpriteIcon | null)[] = this.getRowIcons()): any[] {
1269
+ const {iconLoadOptions, billboard, spriteAtlases} = this.props;
1270
+ const labelRows = this.getRows();
1271
+ const atlases = new Set<SpriteAtlas>();
1272
+ for (const icon of icons) {
1273
+ if (icon) {
1274
+ atlases.add(icon.atlas);
1275
+ }
1276
+ }
1277
+ this.retainIconAtlases(atlases);
1278
+ if (!atlases.size) {
1279
+ return [];
1280
+ }
1281
+ const groups = this.getSymbolPartGroups('icon', () => this.getRowsWithText());
1282
+ const {props: angleProps, getAngle, extensions: angleExtensions} = this.getIconAngleProps();
1283
+ // Sprite ids are arbitrary strings, so `${id}${idSuffix}` (and a copy's `-overlap`) can repeat
1284
+ // another sprite's id; a repeated id gets a number.
1285
+ const ids = new Set<string>();
1286
+ const getUniqueId = (id: string) => {
1287
+ let unique = id;
1288
+ for (let n = 2; ids.has(unique) || ids.has(`${unique}-overlap`); n++) {
1289
+ unique = `${id}-${n}`;
1290
+ }
1291
+ ids.add(unique).add(`${unique}-overlap`);
1292
+ return unique;
1293
+ };
1294
+ return groups.flatMap(({idSuffix, rows}) =>
1295
+ [...atlases].flatMap(atlas => {
1296
+ const isOwnRow = (index: number) => icons[index]?.atlas === atlas && (!rows || rows[index]);
1297
+ // Accessors over every row: `fallback` for rows whose icon is not in this layer.
1298
+ const forOwnRows = <T>(accessor: (feature: FeatureLike) => T, fallback: T) => {
1299
+ const getValue = this.getSubLayerAccessor(accessor) as any;
1300
+ return (row: LabelRow, info: {index: number}) =>
1301
+ isOwnRow(info.index) ? getValue(row, info) : fallback;
1302
+ };
1303
+ const layer = new IconLayer({
1304
+ ...this.getSubLayerProps({id: getUniqueId(`icons-${atlas.id}${idSuffix}`)}),
1305
+ data: labelRows,
1306
+ iconAtlas: this.getIconAtlas(atlas) as any,
1307
+ iconMapping: atlas.mapping,
1308
+ loadOptions: iconLoadOptions || undefined,
1309
+ billboard,
1310
+ ...angleProps,
1311
+ ...(getAngle ? {getAngle} : {}),
1312
+ sizeUnits: 'pixels',
1313
+ parameters: LABEL_PARAMETERS,
1314
+ extensions: [
1315
+ ...(this.props.extensions || []),
1316
+ LABEL_PLACEMENT_EXTENSION,
1317
+ GLOBE_HORIZON_EXTENSION,
1318
+ ...angleExtensions
1319
+ ],
1320
+ ...this.getPlacementProps(1),
1321
+ getPosition: (d: LabelRow) => d.position,
1322
+ getIcon: ((_: LabelRow, {index}: {index: number}) =>
1323
+ isOwnRow(index) ? icons[index]!.name : null) as any,
1324
+ getSize: forOwnRows(feature => this.getIconSize(feature), 0) as any,
1325
+ getColor: forOwnRows(feature => this.getIconColor(feature), [0, 0, 0, 0]) as any,
1326
+ getPixelOffset: withIconAngle(
1327
+ forOwnRows(feature => this.getIconPixelOffset(feature), [0, 0] as [number, number]),
1328
+ getAngle
1329
+ ) as any,
1330
+ updateTriggers: {
1331
+ ...this.getIconUpdateTriggers(),
1332
+ getPlacementSlot: this.state.placementSlotBase,
1333
+ // The sprite list decides which rows `icon-image` resolves into this sprite, and which
1334
+ // rows have text decides the group's rows. The latter's change between split and
1335
+ // unsplit groups also refreshes the rows masked out before.
1336
+ all: [atlas, spriteAtlases, rows ? this.getLabelUpdateTriggers().getText : null]
1337
+ }
1338
+ });
1339
+ return [layer];
1340
+ })
1341
+ );
1342
+ }
1343
+
1344
+ /**
1345
+ * The label rows in the order every text and icon sublayer holds them: `labelData` in reverse.
1346
+ * A symbol's placement slot follows its index here, so an icon and its label share it; every
1347
+ * per-row array that these sublayers index (row icons, rows with text) is in this order.
1348
+ * Placement walks `labelData`, in feature order (see `getPlacementSymbols`).
1349
+ */
1350
+ getRows(): LabelRow[] {
1351
+ const {labelData, textRows} = this.state;
1352
+ if (!labelData) {
1353
+ return [];
1354
+ }
1355
+ if (textRows?.source !== labelData) {
1356
+ this.state.textRows = {source: labelData, rows: [...labelData].reverse()};
1357
+ }
1358
+ return this.state.textRows!.rows;
1359
+ }
1360
+
1361
+ /**
1362
+ * Per label row, whether it has text (see `getLabel`). The result is kept with the label text
1363
+ * cache, so it is evaluated once per text update trigger and label rows, not on every render.
1364
+ */
1365
+ getRowsWithText(): boolean[] {
1366
+ const rows = this.getRows();
1367
+ const labelText = this.state.labelText;
1368
+ if (labelText?.rowsWithText) {
1369
+ return labelText.rowsWithText;
1370
+ }
1371
+ const rowsWithText = rows.map(
1372
+ row => this.getLabel((row as any).__source?.object ?? row) !== undefined
1373
+ );
1374
+ if (labelText) {
1375
+ labelText.rowsWithText = rowsWithText;
1376
+ }
1377
+ return rowsWithText;
1378
+ }
1379
+
1380
+ /**
1381
+ * The texture of a sprite's image, shared by the icon layers of every label layer, or its URL
1382
+ * while it is not decoded. deck.gl would otherwise upload the image for each icon layer.
1383
+ */
1384
+ getIconAtlas(atlas: SpriteAtlas): unknown {
1385
+ const {device} = this.context;
1386
+ if (typeof atlas.image === 'string' || !device) {
1387
+ return atlas.image;
1388
+ }
1389
+ if (!this.state.iconTextures) {
1390
+ this.state.iconTextures = new SharedTextureSet(device);
1391
+ }
1392
+ return this.state.iconTextures.get(atlas.image);
1393
+ }
1394
+
1395
+ /** Releases the sprite textures that the icon layers no longer draw from. */
1396
+ retainIconAtlases(atlases: Set<SpriteAtlas>): void {
1397
+ this.state.iconTextures?.retainOnly([...atlases].map(atlas => atlas.image as object));
1398
+ }
1399
+
1400
+ /**
1401
+ * Each label row's icon, resolved against the loaded sprites (see `getIcon`), or all `null`
1402
+ * while the layer has no `icon-image` or no sprites.
1403
+ */
1404
+ getRowIcons(): (ResolvedSpriteIcon | null)[] {
1405
+ const rows = this.getRows();
1406
+ if (!this.props.spriteAtlases?.length || !this.getStyleProperty('icon-image')) {
1407
+ return rows.map(() => null);
1408
+ }
1409
+ // A label with variable anchors draws its icon from its first row only.
1410
+ return rows.map(row =>
1411
+ row.textAnchorIndex ? null : this.getIcon((row as any).__source?.object ?? row)
1412
+ );
1413
+ }
1414
+
1415
+ /**
1416
+ * The groups of rows each part is drawn by: one group of every row, since placement decides per
1417
+ * symbol which parts draw (see `LabelPlacement`).
1418
+ */
1419
+ getSymbolPartGroups(_part: 'text' | 'icon', _hasOtherPart: () => boolean[]): SymbolPartGroup[] {
1420
+ // Placement decides per symbol; every row is drawn by one sublayer per part.
1421
+ return [{idSuffix: '', rows: null}];
1422
+ }
1423
+
1424
+ /** Returns a compiled `layout` or `paint` property of the style layer, if it sets one. */
1425
+ private getStyleProperty(propertyName: string): CompiledStyleProperty | null {
1426
+ const {styleLayer} = this.props;
1427
+ return styleLayer ? getCompiledStyleProperty(styleLayer, propertyName) : null;
1428
+ }
1429
+
1430
+ /**
1431
+ * Evaluates a style property for a feature at the stepped zoom (`getZoomBucket`). The value is
1432
+ * kept for the feature until the zoom step or the property changes: a `tiles` layer makes its
1433
+ * rows again whenever a tile is shown or hidden, and the rows of the other tiles keep their
1434
+ * features, so their values are not evaluated again.
1435
+ */
1436
+ private evaluateStyleProperty(propertyName: string, feature: FeatureLike): unknown {
1437
+ const property = this.getStyleProperty(propertyName);
1438
+ if (!property) {
1439
+ return undefined;
1440
+ }
1441
+ const zoom = getZoomBucket(this.props.zoom || 0);
1442
+ if (!this.state || !feature || typeof feature !== 'object') {
1443
+ return property.evaluate(zoom, feature);
1444
+ }
1445
+ this.state.styleValues ||= new WeakMap();
1446
+ let cache = this.state.styleValues.get(property);
1447
+ if (!cache || cache.zoom !== zoom) {
1448
+ cache = {zoom, values: new WeakMap()};
1449
+ this.state.styleValues.set(property, cache);
1450
+ }
1451
+ if (cache.values.has(feature)) {
1452
+ return cache.values.get(feature);
1453
+ }
1454
+ const value = property.evaluate(zoom, feature);
1455
+ cache.values.set(feature, value);
1456
+ return value;
1457
+ }
1458
+
1459
+ /**
1460
+ * The CSS font of this layer's labels, from `text-font` at the stepped zoom, or the style
1461
+ * spec's default font when the layer does not set it, with the `fontFamily` override applied.
1462
+ * `TextLayer` takes one font per layer, so a data-driven `text-font` is evaluated for the first
1463
+ * label feature only.
1464
+ */
1465
+ getFont(): LabelFont {
1466
+ const textFont = this.getStyleProperty('text-font');
1467
+ const row = this.state?.labelData?.[0] as
1468
+ | (LabelRow & {__source?: {object: FeatureLike}})
1469
+ | undefined;
1470
+ const value = textFont?.evaluate(getZoomBucket(this.props.zoom || 0), row?.__source?.object);
1471
+ const fontStack = Array.isArray(value) && value.length ? value.map(String) : DEFAULT_TEXT_FONT;
1472
+ return resolveLabelFont(fontStack, this.props.fontFamily);
1473
+ }
1474
+
1475
+ /**
1476
+ * The `text-font` stack of a feature's label as glyph URLs name it, its fonts joined by commas:
1477
+ * of the first label row's feature when none is given, as `getFont`.
1478
+ */
1479
+ getFontStack(feature?: FeatureLike): string {
1480
+ const textFont = this.getStyleProperty('text-font');
1481
+ const row = this.state?.labelData?.[0] as
1482
+ | (LabelRow & {__source?: {object: FeatureLike}})
1483
+ | undefined;
1484
+ const value = textFont?.evaluate(
1485
+ getZoomBucket(this.props.zoom || 0),
1486
+ feature || row?.__source?.object
1487
+ );
1488
+ return (Array.isArray(value) && value.length ? value.map(String) : DEFAULT_TEXT_FONT).join(',');
1489
+ }
1490
+
1491
+ /** `TextLayer`'s font atlas settings: of the style's glyphs, or of the browser's fonts. */
1492
+ getFontSettings(): typeof LABEL_FONT_SETTINGS {
1493
+ return this.props.glyphs ? GLYPH_FONT_SETTINGS : LABEL_FONT_SETTINGS;
1494
+ }
1495
+
1496
+ /** The advance of a character in ems, in the font or glyphs the labels are drawn with. */
1497
+ getCharacterMeasurer(): (character: string) => number {
1498
+ const {glyphs} = this.props;
1499
+ if (glyphs) {
1500
+ return getGlyphCharacterMeasurer(glyphs, this.getFontStack());
1501
+ }
1502
+ const font = this.getFont();
1503
+ return getCharacterWidthMeasurer(getTextLayerFontWeight(font), font.fontFamily);
1504
+ }
1505
+
1506
+ /**
1507
+ * `TextLayer`'s font props: the style's glyphs of the layer's font stack, drawn by their own
1508
+ * font renderer into an atlas of their own, or the CSS font.
1509
+ */
1510
+ getFontProps(font: LabelFont = this.getFont()): Record<string, unknown> {
1511
+ const {glyphs} = this.props;
1512
+ if (glyphs) {
1513
+ const fontStack = this.getFontStack();
1514
+ return {
1515
+ // MapLibre GL JS samples its 24 px glyphs without mipmaps.
1516
+ atlasMipLevels: 1,
1517
+ // and smooths their edges over the same device pixels at every text size.
1518
+ edgeGammaCapped: false,
1519
+ fontSettings: GLYPH_FONT_SETTINGS,
1520
+ // The atlas's cache key: deck.gl keys font atlases by their font and settings, not by
1521
+ // `_getFontRenderer`. The store's id keeps a new store for the same URL from drawing with
1522
+ // the atlas of the old one; layers of one store share its atlases.
1523
+ fontFamily: `glyphs ${glyphs.id} ${glyphs.url} ${fontStack}`,
1524
+ fontWeight: 400,
1525
+ _getFontRenderer: getGlyphFontRendererFactory(glyphs, fontStack)
1526
+ };
1527
+ }
1528
+ return {
1529
+ fontSettings: LABEL_FONT_SETTINGS,
1530
+ fontFamily: font.fontFamily,
1531
+ fontWeight: getTextLayerFontWeight(font)
1532
+ };
1533
+ }
1534
+
1535
+ /**
1536
+ * What the label text is drawn with: the font stack of the style's glyph store, or the CSS font.
1537
+ * A new store for the same URL may hold other glyphs, so text is measured again with it.
1538
+ */
1539
+ getFontKey(): string {
1540
+ const {glyphs} = this.props;
1541
+ return glyphs
1542
+ ? `${glyphs.id}|${glyphs.url}|${this.getFontStack()}`
1543
+ : JSON.stringify(this.getFont());
1544
+ }
1545
+
1546
+ /**
1547
+ * The inputs that choose the glyphs of the label text: the glyph store, as a new one for the same
1548
+ * URL has none of the old one's glyphs loaded, the style's glyphs, and `text-field`,
1549
+ * `text-transform` and `text-font`, with the stepped zoom when any of them depends on zoom.
1550
+ * Point anchors do not depend on them, so rows kept for unchanged anchors check their glyphs
1551
+ * again when this changes: a zoom step can change the text to another glyph range or font stack.
1552
+ */
1553
+ getGlyphKey(): string {
1554
+ const {glyphs} = this.props;
1555
+ return glyphs
1556
+ ? `${glyphs.id}|${glyphs.url}|${this.getStyleUpdateTrigger('text-field', 'text-transform', 'text-font')}`
1557
+ : '';
1558
+ }
1559
+
1560
+ /**
1561
+ * `features` without those whose label text is in a font stack of `refused` (see
1562
+ * `hasLabelGlyphs`). Their rows are left out rather than drawn without glyphs: `TextLayer` would
1563
+ * draw their characters into the font atlas as empty cells, and keep drawing them so once the
1564
+ * store retains the font stack and its glyphs load.
1565
+ */
1566
+ withoutRefusedLabels(features: FeatureLike[], refused: ReadonlySet<string>): FeatureLike[] {
1567
+ return refused.size
1568
+ ? features.filter(
1569
+ feature =>
1570
+ !this.getUnwrappedLabelText(feature) || !refused.has(this.getFontStack(feature))
1571
+ )
1572
+ : features;
1573
+ }
1574
+
1575
+ /**
1576
+ * Whether the glyphs of the label text of `features` have loaded, with the style's glyphs. When
1577
+ * they have not, they are requested, and the layer updates once they have, as MapLibre GL JS
1578
+ * places a tile's symbols once their glyphs have loaded.
1579
+ *
1580
+ * The layer retains the font stack of each feature's text in the store, before it loads any,
1581
+ * and adds it to `fontStacks`: loading the glyphs of some font stacks then cannot drop those of
1582
+ * others that the layer draws or waits for. A feature whose font stack the store will not
1583
+ * retain (see `GlyphStore.retain`) is not waited for: its label is left undrawn, as that of a
1584
+ * glyph range that fails to load, so that the labels of the other features settle. Its font
1585
+ * stack is added to `refused`, and its row left out (see `withoutRefusedLabels`).
1586
+ */
1587
+ hasLabelGlyphs(
1588
+ features: FeatureLike[],
1589
+ fontStacks: Set<string>,
1590
+ refused: Set<string> = new Set()
1591
+ ): boolean {
1592
+ const {glyphs} = this.props;
1593
+ if (!glyphs || !this.getStyleProperty('text-field')) {
1594
+ return true;
1595
+ }
1596
+ const texts = new Map<string, string[]>();
1597
+ for (const feature of features) {
1598
+ const text = this.getUnwrappedLabelText(feature);
1599
+ if (text) {
1600
+ const fontStack = this.getFontStack(feature);
1601
+ if (!glyphs.retain(this.state, fontStack)) {
1602
+ refused.add(fontStack);
1603
+ continue;
1604
+ }
1605
+ fontStacks.add(fontStack);
1606
+ if (!glyphs.hasGlyphs(fontStack, text)) {
1607
+ texts.set(fontStack, [...(texts.get(fontStack) || []), text]);
1608
+ }
1609
+ }
1610
+ }
1611
+ if (!texts.size) {
1612
+ return true;
1613
+ }
1614
+ const loads: Promise<void>[] = [];
1615
+ for (const [fontStack, stackTexts] of texts) {
1616
+ const loading = glyphs.load(fontStack, stackTexts);
1617
+ if (loading) {
1618
+ loads.push(loading);
1619
+ }
1620
+ }
1621
+ // The layer waits for one load at a time: updates meanwhile request what they need, and the
1622
+ // update once it has loaded checks again. A new glyph store replaces the wait on the old one,
1623
+ // whose loads may never finish.
1624
+ const state = this.state;
1625
+ if (loads.length && !(state.glyphLoads && state.glyphLoadStore === glyphs)) {
1626
+ state.glyphLoads = 1;
1627
+ state.glyphLoadStore = glyphs;
1628
+ Promise.all(loads).then(() => {
1629
+ // Unless the layer was finalized, or given another store, meanwhile.
1630
+ if (state.glyphLoadStore !== glyphs) {
1631
+ return;
1632
+ }
1633
+ state.glyphLoads = 0;
1634
+ state.glyphLoadStore = undefined;
1635
+ if (this.state === state) {
1636
+ this.setNeedsUpdate();
1637
+ }
1638
+ });
1639
+ }
1640
+ return false;
1641
+ }
1642
+
1643
+ /**
1644
+ * Extracts the visible label text for a decoded feature. Legacy `{token}` placeholders are
1645
+ * resolved only in literal and zoom-function values, as in the style specification.
1646
+ * Applies `text-transform` and Arabic shaping, balanced `text-max-width` wrapping for point
1647
+ * labels, and the reordering of right-to-left text into the order it is drawn.
1648
+ */
1649
+ getLabel(feature: FeatureLike): string | undefined {
1650
+ // A `text-size` of 0 hides the label.
1651
+ if (!(this.getLabelSize(feature) > 0)) {
1652
+ return undefined;
1653
+ }
1654
+ return this.getLabelText(feature);
1655
+ }
1656
+
1657
+ /**
1658
+ * The label text of a feature as `getLabel` returns it, whatever its `text-size` at the map's
1659
+ * zoom. Labels along lines are placed at the tile's zoom, where the text may be visible.
1660
+ */
1661
+ getLabelText(feature: FeatureLike): string | undefined {
1662
+ let label = this.getUnwrappedLabelText(feature);
1663
+ if (label) {
1664
+ const labels = this.state?.labelText?.labels;
1665
+ const wrapped = labels?.get(feature);
1666
+ if (wrapped !== undefined) {
1667
+ return wrapped;
1668
+ }
1669
+ if (this.getSymbolPlacement() === 'point') {
1670
+ const maxWidth = Number(
1671
+ this.evaluateStyleProperty('text-max-width', feature) ??
1672
+ getStylePropertyDefault('text-max-width')
1673
+ );
1674
+ // As in MapLibre, each character's advance includes the letter spacing.
1675
+ const spacing = this.getLetterSpacing(feature, label);
1676
+ let measure: ((character: string) => number) | undefined;
1677
+ label = wrapText(
1678
+ label,
1679
+ maxWidth,
1680
+ character => {
1681
+ if (!measure) {
1682
+ measure = this.getCharacterMeasurer();
1683
+ }
1684
+ return measure(character) + spacing;
1685
+ },
1686
+ this.state?.labelText?.budget
1687
+ );
1688
+ } else {
1689
+ // As MapLibre, a label along a line is drawn along it in the order it is read.
1690
+ label = orderBidirectionalText(label);
1691
+ }
1692
+ labels?.set(feature, label);
1693
+ }
1694
+ return label || undefined;
1695
+ }
1696
+
1697
+ /**
1698
+ * A feature's label text, with `text-transform` and Arabic shaping applied, before wrapping and
1699
+ * in logical order: its code points are those whose glyphs are drawn.
1700
+ */
1701
+ getUnwrappedLabelText(feature: FeatureLike): string | undefined {
1702
+ const textField = this.getStyleProperty('text-field');
1703
+ if (!textField) {
1704
+ return undefined;
1705
+ }
1706
+ const value = this.evaluateStyleProperty('text-field', feature);
1707
+ const text = value === null || value === undefined ? '' : String(value);
1708
+ const isExpression = Array.isArray(this.props.styleLayer?.layout?.['text-field']);
1709
+ const label =
1710
+ isExpression || textField.isFeatureDependent
1711
+ ? text.trim()
1712
+ : resolveTokenString(text, feature.properties)?.trim();
1713
+ return label
1714
+ ? transformText(label, this.evaluateStyleProperty('text-transform', feature))
1715
+ : undefined;
1716
+ }
1717
+
1718
+ /**
1719
+ * `text-letter-spacing` of a feature's label `text`, in ems. As in MapLibre, text in a cursive
1720
+ * script is not letter-spaced.
1721
+ */
1722
+ getLetterSpacing(feature: FeatureLike, text: string): number {
1723
+ const spacing = Number(
1724
+ this.evaluateStyleProperty('text-letter-spacing', feature) ??
1725
+ getStylePropertyDefault('text-letter-spacing')
1726
+ );
1727
+ return Number.isFinite(spacing) && spacing && allowsLetterSpacing(text) ? spacing : 0;
1728
+ }
1729
+
1730
+ /**
1731
+ * `text-justify` of a feature's label. MapLibre centers the lines of labels along lines.
1732
+ */
1733
+ getTextJustify(feature: FeatureLike): LabelTextJustify {
1734
+ if (isLinePlacement(this.getSymbolPlacement())) {
1735
+ return 'center';
1736
+ }
1737
+ const justify = this.evaluateStyleProperty('text-justify', feature);
1738
+ return justify === 'auto' || justify === 'left' || justify === 'right' ? justify : 'center';
1739
+ }
1740
+
1741
+ /**
1742
+ * Returns the font size for a decoded feature label.
1743
+ */
1744
+ getLabelSize(feature: FeatureLike): number {
1745
+ const size = this.evaluateStyleProperty('text-size', feature);
1746
+ return Number(size ?? getStylePropertyDefault('text-size'));
1747
+ }
1748
+
1749
+ /**
1750
+ * The font size a label's collision box is laid out at, as MapLibre GL JS lays it out: `text-size`
1751
+ * at the zoom after the tile's, the integer zoom (its `layoutTextSize`). Where `text-size`
1752
+ * changes with zoom, the box is that size and not the one drawn: at z3.6, a size that grows with
1753
+ * zoom collides at its z4 size.
1754
+ */
1755
+ getCollisionLabelSize(feature: FeatureLike): number {
1756
+ const property = this.getStyleProperty('text-size');
1757
+ if (!property?.isZoomDependent) {
1758
+ return this.getLabelSize(feature);
1759
+ }
1760
+ const size = property.evaluate(Math.floor(this.props.zoom || 0) + 1, feature);
1761
+ return Number(size ?? getStylePropertyDefault('text-size'));
1762
+ }
1763
+
1764
+ /**
1765
+ * Returns the text color for a decoded feature label, from `text-color` and `text-opacity`.
1766
+ * Falls back to the `textColor` prop, then to the style spec's default black, when the style
1767
+ * layer does not set `text-color`.
1768
+ */
1769
+ getLabelColor(feature: FeatureLike): number[] {
1770
+ const textColor = this.evaluateStyleProperty('text-color', feature) as number[] | undefined;
1771
+ const opacity = this.evaluateStyleProperty('text-opacity', feature) as number | undefined;
1772
+ if (textColor) {
1773
+ return withOpacity(textColor, opacity ?? 1);
1774
+ }
1775
+
1776
+ // The style spec's default `text-color` is black.
1777
+ const fallbackColor = this.props.textColor || [0, 0, 0, 255];
1778
+ return opacity === undefined ? fallbackColor : withOpacity(fallbackColor, opacity);
1779
+ }
1780
+
1781
+ /**
1782
+ * Returns the halo of a decoded feature label from `text-halo-color`, `text-halo-width` and
1783
+ * `text-halo-blur`, or `null` when it has none: a width of 0 (the default) or a transparent color
1784
+ * (the default). The color is faded by the same `text-opacity` as the text, so a hidden or faded
1785
+ * label leaves no halo. Like the width, the blur is rounded to 0.25 px.
1786
+ */
1787
+ getLabelHalo(feature: FeatureLike): LabelHalo | null {
1788
+ const value = Number(this.evaluateStyleProperty('text-halo-width', feature) ?? 0);
1789
+ const size = this.getLabelSize(feature);
1790
+ if (!(value > 0) || !(size > 0)) {
1791
+ return null;
1792
+ }
1793
+ // Rounded to a step, but a positive width keeps at least one step.
1794
+ const width = Math.max(HALO_WIDTH_STEP, Math.round(value / HALO_WIDTH_STEP) * HALO_WIDTH_STEP);
1795
+ const color = this.evaluateStyleProperty('text-halo-color', feature) as number[] | undefined;
1796
+ const opacity = this.evaluateStyleProperty('text-opacity', feature) as number | undefined;
1797
+ const outlineColor = withOpacity(color, opacity ?? 1).map(Math.round);
1798
+ if (!(outlineColor[3] > 0)) {
1799
+ return null;
1800
+ }
1801
+ // WebGPU draws halos without blur (see `TextHaloBlurExtension`), so blur must not split halo
1802
+ // groups there: invisible differences would take places under the cap from other halos.
1803
+ const blur =
1804
+ this.context?.device?.type === 'webgpu'
1805
+ ? 0
1806
+ : Number(this.evaluateStyleProperty('text-halo-blur', feature) ?? 0);
1807
+ return {
1808
+ outlineColor,
1809
+ outlineWidth: getOutlineWidth(width, size, this.getFontSettings().fontSize),
1810
+ outlineBlur: getHaloBlurGamma(
1811
+ Math.round(blur / HALO_WIDTH_STEP) * HALO_WIDTH_STEP,
1812
+ size,
1813
+ this.getFontSettings()
1814
+ )
1815
+ };
1816
+ }
1817
+
1818
+ /**
1819
+ * Groups the label rows by halo. `TextLayer` takes one outline color, width and blur per layer,
1820
+ * so each distinct halo is drawn by its own text sublayer. At most `MAX_HALO_SUBLAYERS` halos are
1821
+ * kept, the most common ones; labels with any other halo are drawn with the most common halo.
1822
+ * Labels without a halo are not counted in the cap and never get one: a hidden label stays
1823
+ * hidden. Kept halos are ordered by their first label, so sublayer ids stay stable.
1824
+ */
1825
+ getLabelHalos(rows: LabelRow[]): LabelHalos {
1826
+ const groups = new Map<string, {halo: LabelHalo | null; count: number; first: number}>();
1827
+ const rowKeys = rows.map((row, index) => {
1828
+ const halo = this.getLabelHalo((row as any).__source?.object ?? row);
1829
+ const key = halo
1830
+ ? `${halo.outlineColor.join(',')}|${halo.outlineWidth}|${halo.outlineBlur}`
1831
+ : '';
1832
+ const group = groups.get(key);
1833
+ if (group) {
1834
+ group.count++;
1835
+ } else {
1836
+ groups.set(key, {halo, count: 1, first: index});
1837
+ }
1838
+ return key;
1839
+ });
1840
+ // A stable sort: equally common halos keep the order of their first label.
1841
+ const byCount = [...groups].filter(([key]) => key).sort(([, a], [, b]) => b.count - a.count);
1842
+ const kept = byCount.slice(0, MAX_HALO_SUBLAYERS);
1843
+ const none = groups.get('');
1844
+ if (none) {
1845
+ kept.push(['', none]);
1846
+ }
1847
+ kept.sort(([, a], [, b]) => a.first - b.first);
1848
+ const indices = new Map(kept.map(([key], index) => [key, index]));
1849
+ const fallback = byCount.length ? indices.get(byCount[0][0])! : 0;
1850
+ return {
1851
+ halos: kept.map(([, group]) => group.halo),
1852
+ rowHalos: rowKeys.map(key => indices.get(key) ?? fallback)
1853
+ };
1854
+ }
1855
+
1856
+ /**
1857
+ * What the label text takes from `text-size`: a size of 0 hides the label (see `getLabel`). A size
1858
+ * that depends on zoom but not on the feature re-evaluates the text only when it reaches or leaves
1859
+ * 0, not at every zoom step, since the text and its wrapping do not depend on the size otherwise.
1860
+ */
1861
+ getLabelSizeTextTrigger(): string {
1862
+ const size = this.getStyleProperty('text-size');
1863
+ if (!size?.isZoomDependent || size.isFeatureDependent) {
1864
+ return this.getStyleUpdateTrigger('text-size');
1865
+ }
1866
+ const value = size.evaluate(getZoomBucket(this.props.zoom || 0));
1867
+ const isVisible = Number(value ?? getStylePropertyDefault('text-size')) > 0;
1868
+ return `${JSON.stringify(this.props.styleLayer?.layout?.['text-size'])}|${isVisible}`;
1869
+ }
1870
+
1871
+ /** Update triggers for the text accessors (see `getStyleUpdateTrigger`). */
1872
+ getLabelUpdateTriggers(): Record<string, string> {
1873
+ const getTrigger = (...propertyNames: string[]) => this.getStyleUpdateTrigger(...propertyNames);
1874
+ const {fontFamily} = this.props;
1875
+ const fontFamilyKey =
1876
+ typeof fontFamily === 'function'
1877
+ ? `fn${getFunctionId(fontFamily)}`
1878
+ : JSON.stringify(fontFamily ?? null);
1879
+
1880
+ return {
1881
+ getText: `${getTrigger(
1882
+ 'text-field',
1883
+ 'text-transform',
1884
+ 'text-max-width',
1885
+ 'text-letter-spacing',
1886
+ 'symbol-placement',
1887
+ 'text-font'
1888
+ )}|${this.getLabelSizeTextTrigger()}|${fontFamilyKey}`,
1889
+ getSize: getTrigger('text-size'),
1890
+ getColor: getTrigger('text-color', 'text-opacity'),
1891
+ // A layer's slots move when it needs more of them.
1892
+ getPlacementSlot: String(this.state?.placementSlotBase ?? -1),
1893
+ // Text along a line turns its offset by the line (see `withLineDirection`).
1894
+ getPixelOffset: getTrigger(
1895
+ 'text-offset',
1896
+ 'text-radial-offset',
1897
+ 'text-anchor',
1898
+ 'text-variable-anchor',
1899
+ 'text-variable-anchor-offset',
1900
+ // Labels with variable anchors are moved by their padding too (see `getRowPixelOffset`).
1901
+ 'text-padding',
1902
+ 'text-size',
1903
+ 'text-rotation-alignment',
1904
+ 'symbol-placement'
1905
+ ),
1906
+ getAngle: `${this.state?.bearingStep}|${getTrigger('text-keep-upright')}`,
1907
+ // Also lays out the lines of each label (see `LabelTextLayer`).
1908
+ getTextAnchor: getTrigger(
1909
+ 'text-anchor',
1910
+ 'text-variable-anchor',
1911
+ 'text-variable-anchor-offset',
1912
+ 'text-justify',
1913
+ 'symbol-placement'
1914
+ ),
1915
+ getAlignmentBaseline: getTrigger(
1916
+ 'text-anchor',
1917
+ 'text-variable-anchor',
1918
+ 'text-variable-anchor-offset',
1919
+ 'symbol-placement'
1920
+ )
1921
+ };
1922
+ }
1923
+
1924
+ /**
1925
+ * The layer's `symbol-placement` at the stepped zoom. It is not data-driven, so it is evaluated
1926
+ * once per style layer rather than per feature.
1927
+ */
1928
+ getSymbolPlacement(): string {
1929
+ const placement = this.getStyleProperty('symbol-placement')?.evaluate(
1930
+ getZoomBucket(this.props.zoom || 0)
1931
+ );
1932
+ return typeof placement === 'string' ? placement : 'point';
1933
+ }
1934
+
1935
+ /**
1936
+ * Line height in ems. It is not data-driven in the spec, so one value per style layer and
1937
+ * zoom step is exact.
1938
+ */
1939
+ getLineHeight(): number {
1940
+ return Number(
1941
+ this.getStyleProperty('text-line-height')?.evaluate(getZoomBucket(this.props.zoom || 0)) ??
1942
+ getStylePropertyDefault('text-line-height')
1943
+ );
1944
+ }
1945
+
1946
+ /**
1947
+ * The tile's own extent in feature coordinates: the `[0, 1)` square for tile-local coordinates,
1948
+ * the tile's longitude/latitude box for globe tiles, or null when it is not known.
1949
+ */
1950
+ getTileBounds(geographic: boolean): number[] | null {
1951
+ if (!geographic) {
1952
+ return [0, 0, 1, 1];
1953
+ }
1954
+ const bbox = this.getTileProps().tileBoundingBox;
1955
+ return bbox && Number.isFinite(bbox.west)
1956
+ ? [bbox.west, bbox.south, bbox.east, bbox.north]
1957
+ : null;
1958
+ }
1959
+
1960
+ /**
1961
+ * Extracts candidate label anchor positions from a feature geometry. Points are labelled where
1962
+ * they are. Lines are labelled at the middle vertex of their first part here; with
1963
+ * `symbol-placement: line` or `line-center`, `getLabelData` places them along the line instead
1964
+ * (see `getLineLabelPlacements`). With `symbol-placement: point`, each polygon of a feature is
1965
+ * labelled at its pole of inaccessibility, as in MapLibre, if that point lies in this tile;
1966
+ * polygons are not labelled along their outline. `geographic` says whether coordinates are
1967
+ * longitude/latitude (globe tiles) or tile-local. `budget` bounds the polygon searches; pass one
1968
+ * budget for all features of a tile.
1969
+ */
1970
+ getLabelAnchors(
1971
+ feature: FeatureLike,
1972
+ geographic: boolean = isGlobeViewport(this.context?.viewport),
1973
+ placement: string = this.getSymbolPlacement(),
1974
+ budget: PoleSearchBudget = {segmentTests: TILE_POLE_SEARCH_SEGMENT_TESTS}
1975
+ ): number[][] {
1976
+ const {type, coordinates} = feature.geometry;
1977
+ const zoom = this.props.zoom || 0;
1978
+ switch (type) {
1979
+ case 'Point':
1980
+ return [coordinates];
1981
+ case 'MultiPoint':
1982
+ return coordinates;
1983
+ case 'LineString': {
1984
+ const midpoint = getLineMidpoint(coordinates);
1985
+ return midpoint ? [midpoint] : [];
1986
+ }
1987
+ case 'MultiLineString': {
1988
+ const midpoint = getLineMidpoint(coordinates[0]);
1989
+ return midpoint ? [midpoint] : [];
1990
+ }
1991
+ case 'Polygon':
1992
+ return placement === 'point'
1993
+ ? getPolygonAnchors(
1994
+ [coordinates],
1995
+ geographic,
1996
+ zoom,
1997
+ budget,
1998
+ this.getTileBounds(geographic)
1999
+ )
2000
+ : [];
2001
+ case 'MultiPolygon':
2002
+ return placement === 'point'
2003
+ ? getPolygonAnchors(coordinates, geographic, zoom, budget, this.getTileBounds(geographic))
2004
+ : [];
2005
+ default:
2006
+ return [];
2007
+ }
2008
+ }
2009
+
2010
+ /**
2011
+ * The zoom at which labels along lines are placed, and the tile's size in pixels there. As in
2012
+ * MapLibre, placement uses the tile's own zoom, so labels stay put while the map zooms within a
2013
+ * tile; an overzoomed tile is placed at the integer map zoom. A tile's own zoom is the map zoom
2014
+ * at which it is drawn `tileSize` pixels wide: one less than its tile zoom for 256 px tiles.
2015
+ */
2016
+ getLinePlacementScale(): {zoom: number; tileZoom: number; tileSize: number} {
2017
+ const mapZoom = Math.floor(this.props.zoom || 0);
2018
+ const tileProps = this.getTileProps();
2019
+ const size = tileProps.tileSize || 512;
2020
+ const tileZoom = tileProps.tileZoom ?? mapZoom + Math.log2(512 / size);
2021
+ const ownZoom = tileZoom - Math.log2(512 / size);
2022
+ const zoom = Math.max(ownZoom, mapZoom);
2023
+ return {zoom, tileZoom, tileSize: size * 2 ** (zoom - ownZoom)};
2024
+ }
2025
+
2026
+ /**
2027
+ * Measures the text of labels along lines, in ems, with the font `TextLayer` draws it in. Without
2028
+ * a canvas, or while that font is still loading (the browser would measure a fallback font), the
2029
+ * length is estimated from the character count instead (`getTextLengthEms`), and the layer
2030
+ * updates, placing the labels again, once the font has loaded.
2031
+ */
2032
+ getLineTextMeasurer(): (text: string) => number {
2033
+ if (!this.props.glyphs && (!canMeasureText() || this.isLineTextFontLoading())) {
2034
+ return getTextLengthEms;
2035
+ }
2036
+ const measure = this.getCharacterMeasurer();
2037
+ return text => {
2038
+ let ems = 0;
2039
+ for (const character of text) {
2040
+ ems += measure(character);
2041
+ }
2042
+ return ems;
2043
+ };
2044
+ }
2045
+
2046
+ /**
2047
+ * Whether the label font is still loading in the document. The first time it is seen loading,
2048
+ * a load is requested, and the layer updates when it is done.
2049
+ */
2050
+ isLineTextFontLoading(font: LabelFont = this.getFont()): boolean {
2051
+ const fonts = typeof document !== 'undefined' ? document.fonts : undefined;
2052
+ const fontWeight = getTextLayerFontWeight(font);
2053
+ const css = `${fontWeight} 16px ${font.fontFamily}`;
2054
+ if (!fonts || fonts.check(css)) {
2055
+ return false;
2056
+ }
2057
+ if (this.state && this.state.loadingFont !== css) {
2058
+ this.state.loadingFont = css;
2059
+ fonts.load(css).then(
2060
+ () => {
2061
+ // Widths measured meanwhile, as for wrapping, are of a fallback font.
2062
+ forgetCharacterWidths(fontWeight, font.fontFamily);
2063
+ // Unless the layer was finalized meanwhile (see `finalizeState`).
2064
+ if (this.state?.loadingFont === css) {
2065
+ this.setNeedsUpdate();
2066
+ }
2067
+ },
2068
+ () => {}
2069
+ );
2070
+ }
2071
+ return true;
2072
+ }
2073
+
2074
+ /**
2075
+ * Label anchors along the lines of a `LineString` or `MultiLineString` feature, for
2076
+ * `symbol-placement: line` or `line-center`, with the direction of the line at each. Lines are
2077
+ * measured in pixels at the tile's zoom (see `getLinePlacementScale`), where `symbol-spacing`,
2078
+ * `text-max-angle` and the label's `text-letter-spacing` are evaluated. As in MapLibre, the label's length, the window its bends
2079
+ * are summed in and the first label's offset come from `text-size` at zoom 18, the same for
2080
+ * every tile; where that size is 0, the size at the tile's zoom is used. The text is measured by
2081
+ * `context.measureText`. Anchors outside the tile's own extent are left to the neighbouring tile.
2082
+ * With `line` placement, lines are clipped to the tile's extent first, as in MapLibre, and a
2083
+ * label is dropped within half a `symbol-spacing` of an earlier label with the same text on the
2084
+ * tile (`context.placed`); `line-center` measures the whole line. Each feature draws on the
2085
+ * shared `budget`, at most `DEFAULT_LINE_ANCHOR_STEPS` of it.
2086
+ */
2087
+ getLineLabelPlacements(
2088
+ feature: FeatureLike,
2089
+ geographic: boolean,
2090
+ placement: string,
2091
+ budget: LineAnchorBudget = {steps: TILE_LINE_ANCHOR_STEPS},
2092
+ context: LinePlacementContext = this.getLinePlacementContext()
2093
+ ): {position: number[]; angle: number; line: RowLine}[] {
2094
+ const {type, coordinates} = feature.geometry;
2095
+ const lines: number[][][] =
2096
+ type === 'LineString' ? [coordinates] : type === 'MultiLineString' ? coordinates : [];
2097
+ if (!lines.length || !(budget.steps > 0)) {
2098
+ return [];
2099
+ }
2100
+ const {zoom, tileZoom, tileSize} = this.getLinePlacementScale();
2101
+ const evaluate = (name: string, at = zoom) =>
2102
+ Number(this.getStyleProperty(name)?.evaluate(at, feature) ?? getStylePropertyDefault(name));
2103
+ const maxTextSize = evaluate('text-size', 18);
2104
+ const textSize = maxTextSize > 0 ? maxTextSize : evaluate('text-size');
2105
+ const text = this.getLabelText(feature);
2106
+ // Icons are known once the sprites have loaded; the anchors are recomputed then.
2107
+ const icon = this.props.spriteAtlases?.length ? this.getIcon(feature) : null;
2108
+ // One feature may spend at most the single-feature default, and never more than the tile has.
2109
+ const featureBudget = {steps: Math.min(DEFAULT_LINE_ANCHOR_STEPS, budget.steps)};
2110
+ const start = featureBudget.steps;
2111
+ // Measuring the text costs a step per character.
2112
+ featureBudget.steps -= text?.length || 0;
2113
+ // As MapLibre's `getShapedLabelLength`: the longer of the text and the icon. A symbol with
2114
+ // neither has no length, and is not placed.
2115
+ // As MapLibre's shaping, the letter spacing follows every character but the last. MapLibre
2116
+ // lays symbols out at the tile's zoom, so the spacing is the one at the placement zoom, not
2117
+ // at the map's.
2118
+ const letterSpacing = text && allowsLetterSpacing(text) ? evaluate('text-letter-spacing') : 0;
2119
+ const textLength =
2120
+ text && featureBudget.steps > 0
2121
+ ? (context.measureText(text) +
2122
+ (Array.from(text).length - 1) * (Number.isFinite(letterSpacing) ? letterSpacing : 0)) *
2123
+ textSize
2124
+ : 0;
2125
+ const iconLength = icon
2126
+ ? (icon.entry.width / icon.entry.pixelRatio) * evaluate('icon-size')
2127
+ : 0;
2128
+ const spacing = evaluate('symbol-spacing');
2129
+ const options = {
2130
+ placement: placement === 'line-center' ? ('line-center' as const) : ('line' as const),
2131
+ spacing,
2132
+ maxAngle: evaluate('text-max-angle'),
2133
+ textSize,
2134
+ hasText: Boolean(text),
2135
+ labelLength: Math.max(textLength, iconLength)
2136
+ };
2137
+ // `tileSize` is the tile's size at the placement zoom, so the world is `2 ** tileZoom` tiles
2138
+ // across: globe coordinates, `[0, 1]` across the world, take the overzoom factor only once.
2139
+ const scale = geographic ? tileSize * 2 ** tileZoom : tileSize;
2140
+ const bounds = this.getTileBounds(geographic);
2141
+ const clipBounds = options.placement === 'line' ? bounds : null;
2142
+ // MapLibre's `anchorIsTooClose`: labels of the same text repeat no closer than this.
2143
+ const repeatDistance = options.placement === 'line' && text ? spacing / 2 : 0;
2144
+
2145
+ const placements = lines.flatMap(line => {
2146
+ if (!Array.isArray(line) || line.length < 2 || featureBudget.steps <= 0) {
2147
+ return [];
2148
+ }
2149
+ const parts = clipBounds ? clipLine(line, clipBounds) : [line];
2150
+ return parts.flatMap(part => {
2151
+ if (featureBudget.steps <= 0) {
2152
+ return [];
2153
+ }
2154
+ const pixels = part.map(point => {
2155
+ const [x, y] = geographic ? lngLatToWorld(point) : point;
2156
+ return [x * scale, y * scale];
2157
+ });
2158
+ const isContinued = Boolean(bounds) && !isStrictlyInsideBounds(part[0], bounds!);
2159
+ let points: Float64Array | null = null;
2160
+ return (
2161
+ getLineAnchors(pixels, {...options, isContinued, budget: featureBudget})
2162
+ .map(({segment, t, angle}) => {
2163
+ const [a, b] = [pixels[segment], pixels[segment + 1]];
2164
+ const pixel = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
2165
+ // Interpolated in Web Mercator, where the line was measured.
2166
+ const position = geographic
2167
+ ? worldToLngLat([pixel[0] / scale, pixel[1] / scale])
2168
+ : [pixel[0] / scale, pixel[1] / scale];
2169
+ // The line's vertices, shared by the rows on it.
2170
+ points ||= Float64Array.from(pixels.flat());
2171
+ return {position, angle, pixel, line: {points, segment, t, zoom}};
2172
+ })
2173
+ // Only anchors in the tile count as placed, as MapLibre's lie in the tile.
2174
+ .filter(({position}) => !bounds || isInsideBounds(position, bounds))
2175
+ .filter(
2176
+ ({pixel}) =>
2177
+ !repeatDistance || !isNearSameText(context.placed, text!, pixel, repeatDistance)
2178
+ )
2179
+ .map(({position, angle, line}) => ({position, angle, line}))
2180
+ );
2181
+ });
2182
+ });
2183
+ budget.steps -= start - Math.max(0, featureBudget.steps);
2184
+ return placements;
2185
+ }
2186
+
2187
+ /** What the line placements of one tile and style layer share; see `getLineLabelPlacements`. */
2188
+ getLinePlacementContext(): LinePlacementContext {
2189
+ return {measureText: this.getLineTextMeasurer(), placed: new Map()};
2190
+ }
2191
+
2192
+ /**
2193
+ * For `symbol-placement: line`, the line features with the same label text whose ends meet,
2194
+ * joined as MapLibre does (see `mergeLines`): per feature, the feature with the joined line to
2195
+ * place labels along, `null` for a feature joined into another, or `undefined` to place the
2196
+ * feature as it is. As in MapLibre, a `MultiLineString` joins by its first line only, and a
2197
+ * feature joined into another is dropped whole, with any other lines it has: MapLibre's
2198
+ * `mergeLines` takes each feature's `geometry[0]` and keeps the feature of the chain it joins.
2199
+ */
2200
+ getMergedLines(features: FeatureLike[]): (FeatureLike | null | undefined)[] {
2201
+ const lines = features.map(feature => {
2202
+ const {type, coordinates} = feature.geometry || {};
2203
+ const line =
2204
+ type === 'LineString' ? coordinates : type === 'MultiLineString' ? coordinates?.[0] : null;
2205
+ return line ? {text: this.getLabelText(feature), line} : {text: null, line: []};
2206
+ });
2207
+ return mergeLines(lines).map((line, index) => {
2208
+ if (line === null) {
2209
+ return null;
2210
+ }
2211
+ const feature = features[index];
2212
+ if (line === lines[index].line) {
2213
+ return undefined;
2214
+ }
2215
+ const {type, coordinates} = feature.geometry;
2216
+ return {
2217
+ ...feature,
2218
+ geometry: {
2219
+ ...feature.geometry,
2220
+ coordinates: type === 'MultiLineString' ? [line, ...coordinates.slice(1)] : line
2221
+ }
2222
+ };
2223
+ });
2224
+ }
2225
+
2226
+ /**
2227
+ * Label rows for every anchor of `features`: `symbol-placement` is evaluated once, and one
2228
+ * search budget is shared by all polygons of the tile. With `line` or `line-center` placement,
2229
+ * line features get rows along their lines, each with the line's direction.
2230
+ */
2231
+ getLabelData(features: FeatureLike[], geographic: boolean): LabelRow[] {
2232
+ const placement = this.getSymbolPlacement();
2233
+ const budget = {segmentTests: TILE_POLE_SEARCH_SEGMENT_TESTS};
2234
+ const lineBudget = {steps: TILE_LINE_ANCHOR_STEPS};
2235
+ const lineContext = isLinePlacement(placement) ? this.getLinePlacementContext() : undefined;
2236
+ const merged = placement === 'line' ? this.getMergedLines(features) : null;
2237
+ return features.flatMap((feature, index) =>
2238
+ isLinePlacement(placement) && isLineGeometry(feature)
2239
+ ? merged?.[index] === null
2240
+ ? []
2241
+ : this.getLineLabelPlacements(
2242
+ merged?.[index] || feature,
2243
+ geographic,
2244
+ placement,
2245
+ lineBudget,
2246
+ lineContext
2247
+ ).map(row => this.getSubLayerRow(row, feature, index))
2248
+ : this.getLabelAnchors(feature, geographic, placement, budget).flatMap(position => {
2249
+ // A row per variable anchor, so that each is laid out and drawn on its own, and
2250
+ // placement shows the one it places the label at (see `getPlacementSymbols`).
2251
+ const count = this.getTextAnchors(feature, placement)?.length ?? 0;
2252
+ if (count < 2) {
2253
+ return [this.getSubLayerRow({position}, feature, index)];
2254
+ }
2255
+ return Array.from({length: count}, (_, textAnchorIndex) =>
2256
+ this.getSubLayerRow(
2257
+ {position, textAnchorIndex, textAnchorCount: count} as LabelRow,
2258
+ feature,
2259
+ index
2260
+ )
2261
+ );
2262
+ })
2263
+ );
2264
+ }
2265
+
2266
+ /**
2267
+ * The inputs besides the tile data that the anchors depend on: `symbol-placement`, the
2268
+ * coordinate mode, the tile's extent and, for globe tiles, the search precision's zoom. Labels
2269
+ * along lines also depend on the placement scale and on the style values that size and space
2270
+ * them.
2271
+ */
2272
+ getAnchorKey(geographic: boolean): string {
2273
+ const placement = this.getSymbolPlacement();
2274
+ return JSON.stringify([
2275
+ placement,
2276
+ geographic,
2277
+ geographic ? Math.floor(this.props.zoom || 0) : null,
2278
+ this.getTileBounds(geographic),
2279
+ // The number of rows of a point label (see `getLabelData`).
2280
+ isLinePlacement(placement)
2281
+ ? null
2282
+ : this.getStyleUpdateTrigger('text-variable-anchor', 'text-variable-anchor-offset'),
2283
+ isLinePlacement(placement)
2284
+ ? [
2285
+ this.getLinePlacementScale(),
2286
+ this.getStyleUpdateTrigger('text-field', 'text-transform', 'icon-image'),
2287
+ (this.props.spriteAtlases || []).map(atlas => atlas.id),
2288
+ // With the stepped zoom when any of them depends on zoom: placements measured at one
2289
+ // step do not fit the next.
2290
+ this.getStyleUpdateTrigger(
2291
+ 'text-size',
2292
+ 'icon-size',
2293
+ 'symbol-spacing',
2294
+ 'text-max-angle',
2295
+ 'text-letter-spacing'
2296
+ ),
2297
+ // The text is measured in the label font, once it has loaded.
2298
+ ...(this.getStyleProperty('text-field')
2299
+ ? this.props.glyphs
2300
+ ? [this.getFontKey()]
2301
+ : [this.getFont(), this.isLineTextFontLoading()]
2302
+ : [])
2303
+ ]
2304
+ : null
2305
+ ]);
2306
+ }
2307
+
2308
+ /**
2309
+ * The map bearing in `BEARING_STEP` steps, against which upright line labels are turned. Globe
2310
+ * labels are billboards, drawn in screen space, so their bearing is 0.
2311
+ */
2312
+ getBearingStep(
2313
+ viewport: {bearing?: number; projectionMode?: number} | undefined = this.context?.viewport
2314
+ ): number {
2315
+ if (!viewport || isGlobeViewport(viewport)) {
2316
+ return 0;
2317
+ }
2318
+ return Math.round((viewport.bearing || 0) / BEARING_STEP) * BEARING_STEP;
2319
+ }
2320
+
2321
+ /**
2322
+ * Labels along lines turn upright as the map rotates (`text-keep-upright`), so a layer of them
2323
+ * updates when the bearing crosses a `BEARING_STEP`.
2324
+ */
2325
+ shouldUpdateState(params: UpdateParameters<this>): boolean {
2326
+ return (
2327
+ super.shouldUpdateState(params) ||
2328
+ (params.changeFlags.viewportChanged &&
2329
+ this.turnsWithLines() &&
2330
+ this.getBearingStep(params.context.viewport) !== this.state.bearingStep)
2331
+ );
2332
+ }
2333
+
2334
+ /**
2335
+ * Whether the text or icons of this layer turn with the lines they are placed along (see
2336
+ * `getLineLabelProps` and `getIconAngleProps`). Only then do they depend on the bearing. Text
2337
+ * needs a `text-field`, and icons an `icon-image` and loaded sprites.
2338
+ */
2339
+ turnsWithLines(): boolean {
2340
+ const placement = this.getSymbolPlacement();
2341
+ if (!isLinePlacement(placement)) {
2342
+ return false;
2343
+ }
2344
+ const zoom = getZoomBucket(this.props.zoom || 0);
2345
+ const turns = (name: string) =>
2346
+ isAlignedToMap(this.getStyleProperty(name)?.evaluate(zoom), placement);
2347
+ return (
2348
+ (Boolean(this.getStyleProperty('text-field')) && turns('text-rotation-alignment')) ||
2349
+ (Boolean(this.getStyleProperty('icon-image')) &&
2350
+ Boolean(this.props.spriteAtlases?.length) &&
2351
+ turns('icon-rotation-alignment'))
2352
+ );
2353
+ }
2354
+
2355
+ /**
2356
+ * Text layer props for labels along lines: each label is turned to its line, upright when
2357
+ * `text-keep-upright` is set (the default). On a flat map they are drawn in the map plane, as
2358
+ * MapLibre does with the `auto` `text-rotation-alignment` and `text-pitch-alignment` of line
2359
+ * placement; on a globe they stay billboards, turned in screen space.
2360
+ */
2361
+ getLineLabelProps(): Record<string, unknown> {
2362
+ const placement = this.getSymbolPlacement();
2363
+ const zoom = getZoomBucket(this.props.zoom || 0);
2364
+ const alignment = this.getStyleProperty('text-rotation-alignment')?.evaluate(zoom);
2365
+ // With `text-rotation-alignment: viewport` the text stays upright on screen, as a billboard.
2366
+ if (!isLinePlacement(placement) || !isAlignedToMap(alignment, placement)) {
2367
+ return {};
2368
+ }
2369
+ const bearingStep = this.getBearingStep();
2370
+ // Recorded without setState: this only tracks what the angles were computed for.
2371
+ this.state.bearingStep = bearingStep;
2372
+ const keepUpright =
2373
+ (this.getStyleProperty('text-keep-upright')?.evaluate(getZoomBucket(this.props.zoom || 0)) ??
2374
+ getStylePropertyDefault('text-keep-upright')) !== false;
2375
+ const geographic = isGlobeViewport(this.context?.viewport);
2376
+ return {
2377
+ billboard: geographic ? this.props.billboard : false,
2378
+ getAngle: (row: LabelRow) => getUprightAngle(row.angle ?? 0, bearingStep, keepUpright)
2379
+ };
2380
+ }
2381
+
2382
+ /**
2383
+ * Whether the glyphs of this layer's labels along lines are each placed on the line, as MapLibre
2384
+ * draws them: text turned to lines on a flat map (where it is drawn in the map plane), sized in
2385
+ * pixels, on WebGL. Otherwise labels are drawn straight, turned to their anchor's segment. On a
2386
+ * globe, labels are billboards turned on screen, and stay straight.
2387
+ */
2388
+ curvesLineText(viewport: any = this.context?.viewport): boolean {
2389
+ if (!viewport || isGlobeViewport(viewport) || this.context?.device?.type === 'webgpu') {
2390
+ return false;
2391
+ }
2392
+ if (this.props.labelSizeUnits !== 'pixels' || !this.getStyleProperty('text-field')) {
2393
+ return false;
2394
+ }
2395
+ const placement = this.getSymbolPlacement();
2396
+ const alignment = this.getStyleProperty('text-rotation-alignment')?.evaluate(
2397
+ getZoomBucket(this.props.zoom || 0)
2398
+ );
2399
+ return isLinePlacement(placement) && isAlignedToMap(alignment, placement);
2400
+ }
2401
+
2402
+ /** The zoom glyphs along lines are placed at: the viewport's, in `LINE_GLYPH_ZOOM_STEPS`. */
2403
+ getGlyphZoom(viewport: any = this.context?.viewport): number {
2404
+ return Math.floor((viewport?.zoom || 0) * LINE_GLYPH_ZOOM_STEPS) / LINE_GLYPH_ZOOM_STEPS;
2405
+ }
2406
+
2407
+ /**
2408
+ * The style inputs of glyphs along lines, besides the row's text and line: the style values,
2409
+ * and the revision of the label font's advances (`getCharacterWidthsRevision`).
2410
+ */
2411
+ getLineGlyphStyleKey(): string {
2412
+ return `${this.getLineGlyphStyleTrigger()}|${this.getLineGlyphWidthsRevision()()}`;
2413
+ }
2414
+
2415
+ /** The style values glyphs along lines are placed by. */
2416
+ getLineGlyphStyleTrigger(): string {
2417
+ const triggers = this.getLabelUpdateTriggers();
2418
+ return `${triggers.getText}|${triggers.getSize}|${triggers.getPixelOffset}|${triggers.getTextAnchor}|${this.getStyleUpdateTrigger('text-keep-upright')}`;
2419
+ }
2420
+
2421
+ /**
2422
+ * The revision of the label font's advances, read when called: it changes once the font has
2423
+ * loaded with other advances (`forgetCharacterWidths`), maybe as another layer found, without
2424
+ * this layer updating.
2425
+ */
2426
+ getLineGlyphWidthsRevision(): () => number {
2427
+ if (this.props.glyphs) {
2428
+ // A tile's labels are placed once their glyphs have loaded, which do not change.
2429
+ return () => 0;
2430
+ }
2431
+ const font = this.getFont();
2432
+ const fontWeight = getTextLayerFontWeight(font);
2433
+ return () => getCharacterWidthsRevision(fontWeight, font.fontFamily);
2434
+ }
2435
+
2436
+ /**
2437
+ * The text sublayers' `getLineGlyphs` and `getLineGlyphKey` (see `LabelTextLayer`), null when the
2438
+ * layer draws its text straight, and the update trigger of the glyphs: the style. The glyphs also
2439
+ * change with the view's zoom and bearing steps, and with the label font's advances, which the
2440
+ * character layers check when they draw: placement reads the advances' revision anew, so the
2441
+ * glyphs drawn follow the glyphs placed without this layer updating.
2442
+ */
2443
+ getLineGlyphProps(): {
2444
+ getLineGlyphs: ((row: LabelRow, viewport: any) => Float32Array | null) | null;
2445
+ getLineGlyphKey: ((viewport: any) => string) | null;
2446
+ trigger: string;
2447
+ } {
2448
+ if (!this.curvesLineText()) {
2449
+ return {getLineGlyphs: null, getLineGlyphKey: null, trigger: 'straight'};
2450
+ }
2451
+ const styleTrigger = this.getLineGlyphStyleTrigger();
2452
+ const widthsRevision = this.getLineGlyphWidthsRevision();
2453
+ return {
2454
+ getLineGlyphs: (row, viewport) =>
2455
+ this.getLineGlyphs(
2456
+ row,
2457
+ viewport,
2458
+ this.getBearingStep(viewport),
2459
+ `${styleTrigger}|${widthsRevision()}`
2460
+ )?.glyphs ?? null,
2461
+ getLineGlyphKey: viewport =>
2462
+ `${this.getGlyphZoom(viewport)}|${this.getBearingStep(viewport)}|${widthsRevision()}`,
2463
+ trigger: styleTrigger
2464
+ };
2465
+ }
2466
+
2467
+ /**
2468
+ * Where each glyph of `row`'s label is drawn along its line at `viewport`'s zoom, as MapLibre GL
2469
+ * JS's `placeGlyphsAlongLine`: each advance centre on the line, turned to the segment it lands on,
2470
+ * the whole label turned around to read upright at `bearingStep` (from its first and last glyph,
2471
+ * placed unturned). Null for a row drawn straight: not along a line, or multi-line text. The
2472
+ * placements are cached per row for the zoom step and style, both ways round, so that every text
2473
+ * sublayer and the placement share them, and a bearing step only checks which way each reads.
2474
+ * Each view keeps its own, so that views at different zooms, as a main map and a minimap, do not
2475
+ * place the glyphs again in turn.
2476
+ */
2477
+ getLineGlyphs(
2478
+ row: LabelRow,
2479
+ viewport: any,
2480
+ bearingStep: number,
2481
+ styleKey: string
2482
+ ): RowLineGlyphs | null {
2483
+ const {line} = row;
2484
+ if (!line) {
2485
+ return null;
2486
+ }
2487
+ const zoom = this.getGlyphZoom(viewport);
2488
+ const key = `${zoom}|${styleKey}`;
2489
+ if (!this.state.lineGlyphs) {
2490
+ this.state.lineGlyphs = new Map();
2491
+ }
2492
+ const views = this.state.lineGlyphs;
2493
+ const viewId = String(viewport?.id ?? '');
2494
+ let cache = views.get(viewId);
2495
+ if (!cache) {
2496
+ cache = new WeakMap();
2497
+ views.set(viewId, cache);
2498
+ // The view first placed in is forgotten.
2499
+ if (views.size > MAX_LINE_GLYPH_VIEWS) {
2500
+ views.delete(views.keys().next().value!);
2501
+ }
2502
+ }
2503
+ let entry = cache.get(row);
2504
+ if (entry && entry.key === key) {
2505
+ LINE_GLYPH_STATS.cacheHits++;
2506
+ } else {
2507
+ LINE_GLYPH_STATS.rows++;
2508
+ const feature = (row as any).__source?.object ?? row;
2509
+ const text = this.getLabel(feature);
2510
+ entry = {
2511
+ key,
2512
+ placements:
2513
+ text && !text.includes('\n') ? this.placeRowGlyphs(row, line, feature, text, zoom) : null
2514
+ };
2515
+ cache.set(row, entry);
2516
+ }
2517
+ const {placements} = entry;
2518
+ if (!placements) {
2519
+ return null;
2520
+ }
2521
+ if (!placements.unturned.path || !placements.turned) {
2522
+ return placements.unturned;
2523
+ }
2524
+ const flip =
2525
+ placements.count > 1
2526
+ ? needsFlip(placements.layout!, bearingStep)
2527
+ : // MapLibre turns a single glyph by its anchor's segment.
2528
+ getUprightAngle(row.angle ?? 0, bearingStep, true) !== (row.angle ?? 0);
2529
+ return flip ? placements.turned : placements.unturned;
2530
+ }
2531
+
2532
+ /**
2533
+ * `row`'s glyphs placed along its line at `zoom` (see `getLineGlyphs`): unturned, and turned to
2534
+ * read the other way when `text-keep-upright` may turn the label (null otherwise).
2535
+ */
2536
+ placeRowGlyphs(
2537
+ row: LabelRow,
2538
+ line: RowLine,
2539
+ feature: FeatureLike,
2540
+ text: string,
2541
+ zoom: number
2542
+ ): RowGlyphPlacements {
2543
+ const characters = Array.from(text);
2544
+ const count = characters.length;
2545
+ const size = this.getLabelSize(feature);
2546
+ const spacing = this.getLetterSpacing(feature, text) * size;
2547
+ const measure = this.getCharacterMeasurer();
2548
+ // Advance centres from the anchor, as MapLibre's shaping places them: `text-anchor` across the
2549
+ // text, the letter spacing after every character but the last.
2550
+ const widths = characters.map(character => measure(character) * size);
2551
+ const width = widths.reduce((sum, w) => sum + w, 0) + Math.max(0, count - 1) * spacing;
2552
+ const [ax] =
2553
+ ANCHOR_FRACTIONS[String(this.evaluateStyleProperty('text-anchor', feature))] ||
2554
+ ANCHOR_FRACTIONS.center;
2555
+ const offsets = new Float64Array(count);
2556
+ let x = -(ax + 0.5) * width;
2557
+ for (let i = 0; i < count; i++) {
2558
+ offsets[i] = x + widths[i] / 2;
2559
+ x += widths[i] + spacing;
2560
+ }
2561
+ const [lineOffsetX, lineOffsetY] = this.getLabelPixelOffset(feature);
2562
+ const options = {offsets, lineOffsetX, lineOffsetY, scale: 2 ** (zoom - line.zoom)};
2563
+ const hidden: RowLineGlyphs = {glyphs: new Float32Array(count * 4), path: null};
2564
+ for (let i = 0; i < count; i++) {
2565
+ hidden.glyphs[i * 4 + 3] = -1;
2566
+ }
2567
+ const layout = placeLineGlyphs(line, {...options, flip: false});
2568
+ if (!layout) {
2569
+ return {count, layout: null, unturned: hidden, turned: null};
2570
+ }
2571
+ const keepUpright =
2572
+ (this.getStyleProperty('text-keep-upright')?.evaluate(getZoomBucket(this.props.zoom || 0)) ??
2573
+ getStylePropertyDefault('text-keep-upright')) !== false;
2574
+ const turned = keepUpright ? placeLineGlyphs(line, {...options, flip: true}) : null;
2575
+ // MapLibre's collision circles follow the unturned placement, whichever way the label reads.
2576
+ // A label that must turn but does not fit turned is not drawn, yet still takes the room of its
2577
+ // unturned circles: MapLibre places it by those, and hides its glyphs only when drawing.
2578
+ const toRow = (placed: LineGlyphs | null): RowLineGlyphs => {
2579
+ if (!placed) {
2580
+ return {glyphs: hidden.glyphs, path: layout.path};
2581
+ }
2582
+ const glyphs = new Float32Array(count * 4);
2583
+ for (let i = 0; i < count; i++) {
2584
+ glyphs[i * 4] = placed.glyphs[i * 3];
2585
+ glyphs[i * 4 + 1] = placed.glyphs[i * 3 + 1];
2586
+ glyphs[i * 4 + 2] = placed.glyphs[i * 3 + 2];
2587
+ glyphs[i * 4 + 3] = 1;
2588
+ }
2589
+ return {glyphs, path: layout.path};
2590
+ };
2591
+ return {
2592
+ count,
2593
+ layout,
2594
+ unturned: toRow(layout),
2595
+ turned: keepUpright ? toRow(turned) : null
2596
+ };
2597
+ }
2598
+
2599
+ /**
2600
+ * The extensions that keep billboards along lines on their lines on a globe. A globe draws them
2601
+ * in screen space, at angles taken from north up, so the shader turns them by the direction the
2602
+ * view draws the line in, at every frame, and upright with `keepUpright`: there the bearing
2603
+ * alone does not tell which way they read. A flat map draws them in the map plane. The shader
2604
+ * keeps an angle upright only when it is the line's direction: icons turned by `icon-rotate` are
2605
+ * kept upright from north up, by their angles. Text keeps its `text-offset` on its side of the
2606
+ * line as it turns around.
2607
+ */
2608
+ getLineAngleExtensions(
2609
+ turnsWithLine: boolean,
2610
+ keepUpright: 'text-keep-upright' | 'icon-keep-upright',
2611
+ isLineDirection = true
2612
+ ): GlobeLineAngleExtension[] {
2613
+ const isGlobeBillboard = isGlobeViewport(this.context?.viewport) && this.props.billboard;
2614
+ if (!turnsWithLine || !isGlobeBillboard) {
2615
+ return [];
2616
+ }
2617
+ const upright =
2618
+ (this.getStyleProperty(keepUpright)?.evaluate(getZoomBucket(this.props.zoom || 0)) ??
2619
+ getStylePropertyDefault(keepUpright)) === true;
2620
+ if (!upright || !isLineDirection) {
2621
+ return [GLOBE_LINE_ANGLE_EXTENSIONS.free];
2622
+ }
2623
+ return [
2624
+ keepUpright === 'text-keep-upright'
2625
+ ? GLOBE_LINE_ANGLE_EXTENSIONS.uprightText
2626
+ : GLOBE_LINE_ANGLE_EXTENSIONS.upright
2627
+ ];
2628
+ }
2629
+
2630
+ /**
2631
+ * Icon layer props that turn icons, and the angle each row's icon is turned by, counter-clockwise
2632
+ * in degrees as deck.gl takes it (null when no icon turns). Icons along lines whose rotation is
2633
+ * aligned to the map (`icon-rotation-alignment: map`, or `auto`) are turned to the line's
2634
+ * direction, upright when `icon-keep-upright` is set (default false) as for text. Every icon then
2635
+ * adds `icon-rotate`, clockwise. Icons aligned to the map are drawn flat on a flat map, so they
2636
+ * turn with its bearing; the others are billboards, turned on screen. On a globe, icons stay
2637
+ * billboards.
2638
+ */
2639
+ getIconAngleProps(): {
2640
+ props: Record<string, unknown>;
2641
+ getAngle: ((row: LabelRow) => number) | null;
2642
+ extensions: GlobeLineAngleExtension[];
2643
+ } {
2644
+ const placement = this.getSymbolPlacement();
2645
+ const zoom = getZoomBucket(this.props.zoom || 0);
2646
+ const alignment = this.getStyleProperty('icon-rotation-alignment')?.evaluate(zoom);
2647
+ const isMapAligned = isAlignedToMap(alignment, placement);
2648
+ const turnsWithLine = isLinePlacement(placement) && isMapAligned;
2649
+ const iconRotate = this.getStyleProperty('icon-rotate');
2650
+ const geographic = isGlobeViewport(this.context?.viewport);
2651
+ const props = isMapAligned ? {billboard: geographic ? this.props.billboard : false} : {};
2652
+ const extensions = this.getLineAngleExtensions(turnsWithLine, 'icon-keep-upright', !iconRotate);
2653
+ if (!turnsWithLine && !iconRotate) {
2654
+ return {props, getAngle: null, extensions};
2655
+ }
2656
+ const bearingStep = this.getBearingStep();
2657
+ if (turnsWithLine) {
2658
+ this.state.bearingStep = bearingStep;
2659
+ }
2660
+ const keepUpright =
2661
+ (this.getStyleProperty('icon-keep-upright')?.evaluate(zoom) ??
2662
+ getStylePropertyDefault('icon-keep-upright')) === true;
2663
+ const getLineAngle = (row: LabelRow) =>
2664
+ turnsWithLine ? getUprightAngle(row.angle ?? 0, bearingStep, keepUpright) : 0;
2665
+ const getRotate = (row: LabelRow) =>
2666
+ iconRotate
2667
+ ? Number(
2668
+ this.evaluateStyleProperty('icon-rotate', (row as any).__source?.object ?? row) ?? 0
2669
+ )
2670
+ : 0;
2671
+ // `icon-rotate` is clockwise; deck.gl angles are counter-clockwise.
2672
+ return {props, getAngle: (row: LabelRow) => getLineAngle(row) - getRotate(row), extensions};
2673
+ }
2674
+
2675
+ /**
2676
+ * Forgets a font load in flight, so that its completion does not update a finalized layer, and
2677
+ * releases the shared sprite textures.
2678
+ */
2679
+ finalizeState(context: LayerContext): void {
2680
+ super.finalizeState(context);
2681
+ if (this.state) {
2682
+ this.state.loadingFont = undefined;
2683
+ this.state.iconTextures?.releaseAll();
2684
+ this.props.glyphs?.release(this.state);
2685
+ if (this.state.placementHandle) {
2686
+ this.state.placement?.unregister(this.state.placementHandle);
2687
+ }
2688
+ releaseSharedLabelPlacement(this.state.sharedPlacementKey);
2689
+ this.state.sharedPlacementKey = null;
2690
+ this.state.sharedPlacement = null;
2691
+ }
2692
+ }
2693
+
2694
+ /**
2695
+ * Recomputes label anchor rows when the source tile data, or an input the anchors depend on,
2696
+ * changes.
2697
+ */
2698
+ updateState({oldProps, changeFlags}: UpdateParameters<this>): void {
2699
+ if (oldProps?.glyphs && oldProps.glyphs !== this.props.glyphs) {
2700
+ oldProps.glyphs.release(this.state);
2701
+ }
2702
+ const {data, tiles} = this.props;
2703
+ if (!data && !tiles) {
2704
+ return;
2705
+ }
2706
+ const geographic = isGlobeViewport(this.context?.viewport);
2707
+ const textTrigger = this.getLabelUpdateTriggers().getText;
2708
+ if (textTrigger !== this.state.labelText?.key[0]) {
2709
+ // Labels along lines are placed by their text's length, so they must not read text cached
2710
+ // for the previous trigger.
2711
+ this.setState({labelText: undefined});
2712
+ }
2713
+ if (tiles) {
2714
+ this.updateTileRows(tiles, geographic);
2715
+ } else if (data) {
2716
+ this.updateDataRows(data, geographic, Boolean(changeFlags.dataChanged));
2717
+ }
2718
+ const haloKey: [LabelRow[] | undefined, string] = [
2719
+ this.state.labelData,
2720
+ this.getStyleUpdateTrigger(
2721
+ 'text-halo-color',
2722
+ 'text-halo-width',
2723
+ 'text-halo-blur',
2724
+ 'text-opacity',
2725
+ 'text-size'
2726
+ )
2727
+ ];
2728
+ if (haloKey[0] !== this.state.haloKey?.[0] || haloKey[1] !== this.state.haloKey?.[1]) {
2729
+ this.setState({halos: this.getLabelHalos(this.state.labelData || []), haloKey});
2730
+ }
2731
+ if (this.turnsWithLines()) {
2732
+ // The bearing the labels are turned for, so that `shouldUpdateState` updates the layer only
2733
+ // when it crosses a step, whichever of the text and icons turn.
2734
+ this.state.bearingStep = this.getBearingStep();
2735
+ }
2736
+ // deck.gl re-evaluates every label's text when the rows or the text trigger change; each
2737
+ // evaluation starts a new text cache with a fresh wrap budget.
2738
+ // The font, which a data-driven `text-font` takes from the first row: the rows of another
2739
+ // first tile can change it, and with it the text's wrapping.
2740
+ const font = this.getFontKey();
2741
+ const key: [string, LabelRow[] | undefined, string] = [textTrigger, this.state.labelData, font];
2742
+ const [lastTrigger, lastRows, lastFont] = this.state.labelText?.key || [];
2743
+ if (key[0] !== lastTrigger || key[1] !== lastRows || key[2] !== lastFont) {
2744
+ // A `tiles` layer wraps each tile's labels with a tile's budget. Its rows change whenever a
2745
+ // tile is added or removed; the text of features already wrapped is kept.
2746
+ const tileCount = tiles ? Math.max(tiles.length, 1) : 1;
2747
+ const labels =
2748
+ tiles && key[0] === lastTrigger && key[2] === lastFont && this.state.labelText
2749
+ ? this.state.labelText.labels
2750
+ : new WeakMap();
2751
+ this.setState({
2752
+ labelText: {
2753
+ key,
2754
+ budget: {steps: TILE_WRAP_BUDGET_STEPS * tileCount},
2755
+ labels
2756
+ }
2757
+ });
2758
+ }
2759
+ this.updatePlacement();
2760
+ }
2761
+
2762
+ /** The rows of a layer of one tile's `data`. */
2763
+ updateDataRows(
2764
+ data: NonNullable<MVTLabelLayerProps['data']>,
2765
+ geographic: boolean,
2766
+ dataChanged: boolean
2767
+ ): void {
2768
+ const anchorKey = this.getAnchorKey(geographic);
2769
+ const glyphKey = this.getGlyphKey();
2770
+ // Rows placed while the glyph store refused font stacks are placed again once it may not.
2771
+ const {refusedGeneration} = this.state;
2772
+ const anchorsChanged =
2773
+ dataChanged ||
2774
+ anchorKey !== this.state.anchorKey ||
2775
+ (refusedGeneration !== undefined && refusedGeneration !== this.props.glyphs?.generation);
2776
+ if (anchorsChanged || glyphKey !== this.state.glyphKey) {
2777
+ const features = Array.isArray(data) ? data : data.features || [];
2778
+ const fontStacks = new Set<string>();
2779
+ const refused = new Set<string>();
2780
+ const ready = this.hasLabelGlyphs(features, fontStacks, refused);
2781
+ this.state.refusedGeneration = refused.size ? this.props.glyphs?.generation : undefined;
2782
+ this.retainGlyphs(fontStacks, refused.size > 0);
2783
+ if (!ready) {
2784
+ // Placed once the glyphs have loaded, when the layer updates again. Rows kept meanwhile
2785
+ // would draw the new text without its glyphs.
2786
+ this.setState({labelData: [], anchorKey: undefined, glyphKey: undefined});
2787
+ return;
2788
+ }
2789
+ // Rows are placed again when a font stack is refused, to leave its labels out, and when
2790
+ // they were placed while one was: the new text may be in font stacks the store retains.
2791
+ if (anchorsChanged || refused.size || refusedGeneration !== undefined) {
2792
+ const labelData = this.getLabelData(
2793
+ this.withoutRefusedLabels(features, refused),
2794
+ geographic
2795
+ );
2796
+ this.setState({labelData, anchorKey, glyphKey});
2797
+ } else {
2798
+ this.setState({glyphKey});
2799
+ }
2800
+ }
2801
+ }
2802
+
2803
+ /**
2804
+ * The rows of a `tiles` layer: each tile's anchors, placed as in a layer of the tile alone and
2805
+ * kept while its features and anchor inputs do not change, in longitude/latitude, combined by
2806
+ * `combineTileRows`. Tile-local anchors outside the tile's square are left out, as the tile's
2807
+ * own layer clips them; on a globe, tiles keep the rows they have.
2808
+ */
2809
+ updateTileRows(tiles: LabelTile[], geographic: boolean): void {
2810
+ const previous = this.state.tileRows || new Map<string, TileRows>();
2811
+ let changed = tiles !== this.state.rowTiles;
2812
+ const next = new Map<string, TileRows>();
2813
+ // The font stacks of tiles that wait for their glyphs.
2814
+ const waiting = new Set<string>();
2815
+ // Whether the glyph store refused a font stack of a tile (see `TileRows.refusedGeneration`).
2816
+ let refusedAny = false;
2817
+ const generation = this.props.glyphs?.generation;
2818
+ const tileRows = tiles.map(tile => {
2819
+ const {x, y, z} = tile.index;
2820
+ const tileKey = getTileKey(x, y, z);
2821
+ this.placingTile = tile;
2822
+ try {
2823
+ const anchorKey = this.getAnchorKey(geographic);
2824
+ const glyphKey = this.getGlyphKey();
2825
+ const cached = previous.get(tileKey);
2826
+ if (
2827
+ cached &&
2828
+ cached.features === tile.features &&
2829
+ cached.anchorKey === anchorKey &&
2830
+ cached.tile === tile.tile &&
2831
+ // Rows placed while the store refused font stacks are placed again once it may not.
2832
+ (cached.refusedGeneration === undefined || cached.refusedGeneration === generation)
2833
+ ) {
2834
+ refusedAny ||= cached.refusedGeneration !== undefined;
2835
+ if (cached.glyphKey === glyphKey) {
2836
+ next.set(tileKey, cached);
2837
+ return cached.rows;
2838
+ }
2839
+ // The same anchors, with text or a font whose glyphs may not have loaded. The rows are
2840
+ // kept, but the tile draws none until they have: they would draw the new text without
2841
+ // its glyphs. The layer updates again once they have loaded.
2842
+ changed = true;
2843
+ const fontStacks = new Set<string>();
2844
+ const refused = new Set<string>();
2845
+ if (!this.hasLabelGlyphs(tile.features, fontStacks, refused)) {
2846
+ fontStacks.forEach(fontStack => waiting.add(fontStack));
2847
+ next.set(tileKey, cached);
2848
+ return [];
2849
+ }
2850
+ // Unless a font stack is refused, or was when the rows were placed: they are placed again,
2851
+ // to leave out the labels of a refused font stack, and to place those of the others.
2852
+ if (!refused.size && cached.refusedGeneration === undefined) {
2853
+ const entry = {...cached, glyphKey, fontStacks, refusedGeneration: undefined};
2854
+ next.set(tileKey, entry);
2855
+ return entry.rows;
2856
+ }
2857
+ }
2858
+ changed = true;
2859
+ const fontStacks = new Set<string>();
2860
+ const refused = new Set<string>();
2861
+ if (!this.hasLabelGlyphs(tile.features, fontStacks, refused)) {
2862
+ fontStacks.forEach(fontStack => waiting.add(fontStack));
2863
+ // Placed once the glyphs have loaded, when the layer updates again; until then the tile
2864
+ // keeps the rows it had, if any. It draws them only while their text is the same: rows
2865
+ // kept across a change of text or font would draw the new text without its glyphs.
2866
+ if (cached) {
2867
+ next.set(tileKey, cached);
2868
+ return cached.glyphKey === glyphKey ? cached.rows : [];
2869
+ }
2870
+ return [];
2871
+ }
2872
+ let rows = this.getLabelData(this.withoutRefusedLabels(tile.features, refused), geographic);
2873
+ if (!geographic) {
2874
+ const scale = 2 ** z;
2875
+ rows = rows
2876
+ .filter(row => isInsideBounds(row.position, TILE_SQUARE))
2877
+ .map(row => {
2878
+ const world = [(x + row.position[0]) / scale, (y + row.position[1]) / scale];
2879
+ return {...row, position: worldToLngLat(world), world};
2880
+ });
2881
+ }
2882
+ if (tile.tile) {
2883
+ for (const row of rows) {
2884
+ (row as any).__tile = tile.tile;
2885
+ }
2886
+ }
2887
+ const entry = {
2888
+ features: tile.features,
2889
+ anchorKey,
2890
+ glyphKey,
2891
+ tile: tile.tile,
2892
+ rows,
2893
+ fontStacks,
2894
+ refusedGeneration: refused.size ? generation : undefined
2895
+ };
2896
+ refusedAny ||= refused.size > 0;
2897
+ next.set(tileKey, entry);
2898
+ return rows;
2899
+ } finally {
2900
+ this.placingTile = null;
2901
+ }
2902
+ });
2903
+ // The anchors of tiles no longer drawn are kept, most recently drawn first, up to as many as
2904
+ // are drawn: a tile hidden and shown again, as at the view's edge while it moves, keeps its
2905
+ // rows, and the values the sublayers computed for them.
2906
+ let hidden = 0;
2907
+ for (const [tileKey, entry] of previous) {
2908
+ if (hidden >= tiles.length) {
2909
+ break;
2910
+ }
2911
+ if (!next.has(tileKey)) {
2912
+ next.set(tileKey, entry);
2913
+ hidden++;
2914
+ }
2915
+ }
2916
+ // The layer retains the font stacks of the rows it keeps and of the tiles that wait.
2917
+ for (const entry of next.values()) {
2918
+ entry.fontStacks.forEach(fontStack => waiting.add(fontStack));
2919
+ }
2920
+ this.retainGlyphs(waiting, refusedAny);
2921
+ if (changed) {
2922
+ const combined = combineTileRows(tiles, tileRows);
2923
+ // A tile shown or hidden without rows of this layer leaves the rows as they are: the same
2924
+ // array keeps the sublayers' data, which deck.gl would otherwise update for every row.
2925
+ const previousRows = this.state.labelData;
2926
+ const labelData = isSameList(previousRows, combined) ? previousRows! : combined;
2927
+ this.setState({
2928
+ labelData,
2929
+ tileRows: next,
2930
+ rowTiles: tiles,
2931
+ anchorKey: undefined
2932
+ });
2933
+ }
2934
+ }
2935
+
2936
+ /**
2937
+ * Gives back the font stacks the layer retains in its glyph store other than `fontStacks`, those
2938
+ * of the rows it keeps and of the tiles that wait for glyphs (see `GlyphStore.retain`).
2939
+ */
2940
+ retainGlyphs(fontStacks: ReadonlySet<string>, refused: boolean): void {
2941
+ const {glyphs} = this.props;
2942
+ if (!glyphs) {
2943
+ return;
2944
+ }
2945
+ // When the store refused font stacks, the layer updates again once stacks are given back, by
2946
+ // this layer or another, to place the rows of their labels again. Only then: an idle layer
2947
+ // does not update.
2948
+ const state = this.state;
2949
+ glyphs.onRetainedFreed(
2950
+ state,
2951
+ refused
2952
+ ? () => {
2953
+ Promise.resolve().then(() => {
2954
+ if (this.state === state && this.props.glyphs === glyphs) {
2955
+ this.setNeedsUpdate();
2956
+ }
2957
+ });
2958
+ }
2959
+ : null
2960
+ );
2961
+ glyphs.retainOnly(state, fontStacks);
2962
+ }
2963
+
2964
+ /**
2965
+ * Renders the optional debug geometry and the text labels.
2966
+ */
2967
+
2968
+ // --- Placement (see `LabelPlacement`) ---
2969
+
2970
+ /** The placement this layer's labels take part in: its prop's, or its deck's shared one. */
2971
+ getLabelPlacement(): LabelPlacement {
2972
+ if (this.props.labelPlacement) {
2973
+ releaseSharedLabelPlacement(this.state.sharedPlacementKey);
2974
+ this.state.sharedPlacementKey = null;
2975
+ this.state.sharedPlacement = null;
2976
+ return this.props.labelPlacement;
2977
+ }
2978
+ // Label layers made without a placement, as by `getBasemapLayers` alone, place together.
2979
+ if (!this.state.sharedPlacementKey) {
2980
+ const key = getSharedLabelPlacementKey(this);
2981
+ this.state.sharedPlacementKey = key;
2982
+ this.state.sharedPlacement = acquireSharedLabelPlacement(key);
2983
+ }
2984
+ return this.state.sharedPlacement!;
2985
+ }
2986
+
2987
+ /** Registers with the placement, for the current rows. */
2988
+ updatePlacement(): void {
2989
+ const placement = this.getLabelPlacement();
2990
+ let handle = this.state.placementHandle;
2991
+ if (!handle) {
2992
+ handle = {
2993
+ layer: this,
2994
+ getPlacementOrder: () => handle!.layer.getPlacementOrder(),
2995
+ getPlacementRevision: () => handle!.layer.getPlacementRevision(),
2996
+ getPlacementSlotRevision: () => handle!.layer.getPlacementSlotRevision(),
2997
+ getPlacementSlotCount: () => handle!.layer.getPlacementSlotCount(),
2998
+ isDrawnIn: viewport => handle!.layer.isDrawnIn(viewport),
2999
+ getPlacementSymbols: (viewport, base, out) =>
3000
+ handle!.layer.getPlacementSymbols(viewport, base, out),
3001
+ setNeedsRedraw: () => handle!.layer.setNeedsRedraw(),
3002
+ getPlacementRoot: () => handle!.layer.getPlacementRoot()
3003
+ };
3004
+ this.state.placementHandle = handle;
3005
+ }
3006
+ handle.layer = this;
3007
+ if (this.state.placement && this.state.placement !== placement) {
3008
+ this.state.placement.unregister(handle);
3009
+ }
3010
+ this.state.placement = placement;
3011
+ this.state.placementSlotBase = placement.register(handle);
3012
+ this.state.placementRevision = (this.state.placementRevision || 0) + 1;
3013
+ // Each row's slot is the slot base plus its index (see `getPlacementSlotAccessor`); the base
3014
+ // is the placement's to follow.
3015
+ if (this.state.labelData !== this.state.placementSlotRows) {
3016
+ this.state.placementSlotRows = this.state.labelData;
3017
+ this.state.placementSlotRevision = (this.state.placementSlotRevision || 0) + 1;
3018
+ }
3019
+ }
3020
+
3021
+ /** Sublayer props that draw a part (0: text, 1: icons) where placement placed it. */
3022
+ getPlacementProps(part: 0 | 1): Record<string, unknown> {
3023
+ return {
3024
+ labelPlacement: this.state.placement || null,
3025
+ placementPart: part,
3026
+ getPlacementSlot: this.getPlacementSlotAccessor()
3027
+ };
3028
+ }
3029
+
3030
+ /**
3031
+ * Each row's slot, by row: `TextLayer` does not hand its character layer the row's index in
3032
+ * `data`, so the slot is found from the row itself.
3033
+ */
3034
+ getPlacementSlotAccessor(): ((row: LabelRow) => number) | number {
3035
+ const base = this.state.placementSlotBase ?? -1;
3036
+ if (base < 0) {
3037
+ return -1;
3038
+ }
3039
+ const rows = this.getRows();
3040
+ const cached = this.state.placementSlots;
3041
+ if (cached?.rows !== rows || cached.base !== base) {
3042
+ const slots = new Map<LabelRow, number>();
3043
+ rows.forEach((row, index) => slots.set(row, base + index));
3044
+ this.state.placementSlots = {rows, base, accessor: (row: LabelRow) => slots.get(row) ?? -1};
3045
+ }
3046
+ return this.state.placementSlots!.accessor;
3047
+ }
3048
+
3049
+ getPlacementOrder(): number {
3050
+ return this.props.collisionPriorityRange?.[0] ?? 0;
3051
+ }
3052
+
3053
+ getPlacementRevision(): number {
3054
+ return this.state.placementRevision || 0;
3055
+ }
3056
+
3057
+ getPlacementSlotRevision(): number {
3058
+ return this.state.placementSlotRevision || 0;
3059
+ }
3060
+
3061
+ getPlacementSlotCount(): number {
3062
+ return this.getRows().length;
3063
+ }
3064
+
3065
+ /**
3066
+ * `PlacementLayer.isDrawnIn`: whether deck.gl draws this layer in `viewport`, as its layers pass
3067
+ * decides: the layer and its parents are visible, and each parent's `filterSubLayer` keeps it.
3068
+ */
3069
+ isDrawnIn(viewport: Viewport): boolean {
3070
+ if (!this.props.visible) {
3071
+ return false;
3072
+ }
3073
+ // As deck.gl's screen pass asks.
3074
+ const context = {layer: this as Layer, viewport, isPicking: false, renderPass: 'screen'};
3075
+ for (let parent = this.parent; parent; parent = parent.parent) {
3076
+ if (!parent.props.visible || !(parent as CompositeLayer).filterSubLayer(context)) {
3077
+ return false;
3078
+ }
3079
+ context.layer = parent;
3080
+ }
3081
+ // The deck's `layerFilter`, which deck.gl asks of the topmost parent.
3082
+ const layerFilter = (this.context as {deck?: {props?: {layerFilter?: unknown}}})?.deck?.props
3083
+ ?.layerFilter;
3084
+ return typeof layerFilter === 'function' ? Boolean(layerFilter(context)) : true;
3085
+ }
3086
+
3087
+ /**
3088
+ * `PlacementLayer.getPlacementRoot`: the deck layer this layer is drawn under, and the deck's
3089
+ * layers in draw order, so that the labels of a layer drawn over another (as a second
3090
+ * `BasemapLayer`) are placed first.
3091
+ */
3092
+ getPlacementRoot(): {root: Layer; layers: readonly Layer[]} | null {
3093
+ const layerManager = (this.context as {layerManager?: {getLayers?: () => Layer[]}})
3094
+ ?.layerManager;
3095
+ if (!layerManager?.getLayers) {
3096
+ return null;
3097
+ }
3098
+ let root: Layer = this;
3099
+ while (root.parent) {
3100
+ root = root.parent;
3101
+ }
3102
+ return {root, layers: layerManager.getLayers()};
3103
+ }
3104
+
3105
+ /**
3106
+ * What placement needs of each row that does not change with the view, kept until the rows, the
3107
+ * label text, the sprites, the style's layout values, the font's measured widths or the zoom
3108
+ * step change: the rules, the placement order, and per row (in `labelData` order) its text box
3109
+ * (`x1, y1, w, h` in pixels from the anchor, before padding; `w` NaN without text) and its icon
3110
+ * box (`x1, y1, x2, y2` padded, before any turn; `x1` NaN without an icon).
3111
+ */
3112
+ getPlacementGeometry(): PlacementGeometry | null {
3113
+ const {config, spriteAtlases, styleLayer, fontFamily} = this.props;
3114
+ const labelData = this.state.labelData;
3115
+ if (!config.labels || !labelData?.length) {
3116
+ return null;
3117
+ }
3118
+ const zoom = getZoomBucket(this.props.zoom || 0);
3119
+ // Measured again once the label font has loaded (`forgetCharacterWidths`).
3120
+ const widthsRevision = this.getLineGlyphWidthsRevision()();
3121
+ const fontKey = this.getFontKey();
3122
+ // The layout values, so that a style layer edited in place is measured and ruled again, as
3123
+ // `getCompiledStyleProperty` compiles an edited value again.
3124
+ const layout = styleLayer?.layout ? Object.entries(styleLayer.layout).flat() : [];
3125
+ const shared = [
3126
+ spriteAtlases,
3127
+ styleLayer,
3128
+ fontFamily,
3129
+ fontKey,
3130
+ widthsRevision,
3131
+ zoom,
3132
+ ...layout
3133
+ ];
3134
+ const key = [labelData, this.state.labelText, ...shared];
3135
+ const cached = this.state.placementGeometry;
3136
+ if (cached && isSameList(cached.key, key)) {
3137
+ return cached;
3138
+ }
3139
+ // Each row's boxes are kept while the text, the sprites, the style, the font and the zoom step
3140
+ // are the same: a `tiles` layer makes its rows again whenever a tile is shown or hidden, and
3141
+ // the rows of the other tiles keep their objects.
3142
+ const rowKey = [this.state.labelText?.key[0], this.state.labelText?.key[2], ...shared];
3143
+ let rowBoxes = this.state.placementRowBoxes;
3144
+ if (!rowBoxes || !isSameList(rowBoxes.key, rowKey)) {
3145
+ rowBoxes = {key: rowKey, boxes: new WeakMap()};
3146
+ this.state.placementRowBoxes = rowBoxes;
3147
+ }
3148
+ const evaluate = (name: string) =>
3149
+ this.getStyleProperty(name)?.evaluate(zoom) ?? getStylePropertyDefault(name);
3150
+ const hasTextData = Boolean(this.getStyleProperty('text-field'));
3151
+ // While the sprites load (`null`), each symbol is taken to have its icon, so that its rules do
3152
+ // not change when they arrive (see `spriteAtlases`).
3153
+ const hasIconData =
3154
+ Boolean(this.getStyleProperty('icon-image')) &&
3155
+ (spriteAtlases === null || Boolean(spriteAtlases?.length));
3156
+ const rules: SymbolPlacementRules = {
3157
+ textOverlap: getOverlapMode(evaluate('text-overlap'), evaluate('text-allow-overlap')),
3158
+ iconOverlap: getOverlapMode(evaluate('icon-overlap'), evaluate('icon-allow-overlap')),
3159
+ textIgnorePlacement: Boolean(evaluate('text-ignore-placement')),
3160
+ iconIgnorePlacement: Boolean(evaluate('icon-ignore-placement')),
3161
+ textOptional: Boolean(evaluate('text-optional')),
3162
+ iconOptional: Boolean(evaluate('icon-optional')),
3163
+ hasTextData,
3164
+ hasIconData
3165
+ };
3166
+ const count = labelData.length;
3167
+ const rowsWithText = hasTextData ? this.getRowsWithText() : null;
3168
+ const icons = hasIconData ? this.getRowIcons() : null;
3169
+ const measure = this.getCharacterMeasurer();
3170
+ const lineHeight = this.getLineHeight();
3171
+ const linePlacement = isLinePlacement(this.getSymbolPlacement());
3172
+ const textBoxes = new Float64Array(count * 4).fill(Number.NaN);
3173
+ const textOffsets = new Float64Array(count * 2);
3174
+ const iconBoxes = new Float64Array(count * 4).fill(Number.NaN);
3175
+ const sortKey = this.getStyleProperty('symbol-sort-key');
3176
+ const keys = sortKey ? new Float64Array(count) : null;
3177
+ for (let i = 0; i < count; i++) {
3178
+ const row = labelData[i];
3179
+ const rowIndex = count - 1 - i;
3180
+ const feature = (row as any).__source?.object ?? row;
3181
+ if (keys) {
3182
+ keys[i] = Number(this.evaluateStyleProperty('symbol-sort-key', feature)) || 0;
3183
+ }
3184
+ const kept = rowBoxes.boxes.get(row);
3185
+ if (kept) {
3186
+ textBoxes.set(kept.subarray(0, 4), i * 4);
3187
+ iconBoxes.set(kept.subarray(4, 8), i * 4);
3188
+ textOffsets.set(kept.subarray(8, 10), i * 2);
3189
+ continue;
3190
+ }
3191
+ if (rowsWithText?.[rowIndex]) {
3192
+ const label = this.getLabel(feature) || '';
3193
+ const size = this.getLabelSize(feature);
3194
+ const spacing = this.getLetterSpacing(feature, label);
3195
+ const lines = label.split('\n');
3196
+ let textWidth = 0;
3197
+ for (const line of lines) {
3198
+ const characters = Array.from(line);
3199
+ let lineWidth = 0;
3200
+ for (const character of characters) {
3201
+ lineWidth += measure(character);
3202
+ }
3203
+ lineWidth += Math.max(0, characters.length - 1) * spacing;
3204
+ textWidth = Math.max(textWidth, lineWidth);
3205
+ }
3206
+ const w = textWidth * size;
3207
+ const h = lines.length * lineHeight * size;
3208
+ const [ax, ay] = ANCHOR_FRACTIONS[this.getRowAnchor(row)] || ANCHOR_FRACTIONS.center;
3209
+ const [dx, dy] = this.getRowPixelOffset(row);
3210
+ // A point label's box, offset included, is laid out at its collision size; text along a
3211
+ // line collides by circles along the text as drawn.
3212
+ const scale = linePlacement || !(size > 0) ? 1 : this.getCollisionLabelSize(feature) / size;
3213
+ // Variable anchors include a shift by the padded box. Padding stays in pixels when the
3214
+ // text and its em offsets are scaled to the collision size.
3215
+ const padding = this.getRowTextAnchor(row) ? 2 * this.getTextPadding() : 0;
3216
+ textBoxes.set(
3217
+ [
3218
+ (-(ax + 0.5) * w + dx + ax * padding) * scale - ax * padding,
3219
+ (-(ay + 0.5) * h + dy + ay * padding) * scale - ay * padding,
3220
+ w * scale,
3221
+ h * scale
3222
+ ],
3223
+ i * 4
3224
+ );
3225
+ textOffsets.set([dx, dy], i * 2);
3226
+ }
3227
+ const resolved = icons?.[rowIndex];
3228
+ if (resolved) {
3229
+ const scale = Number(this.evaluateStyleProperty('icon-size', feature) ?? 1);
3230
+ const w = (resolved.entry.width / resolved.entry.pixelRatio) * scale;
3231
+ const h = (resolved.entry.height / resolved.entry.pixelRatio) * scale;
3232
+ const [ox, oy] = this.getIconPixelOffset(feature);
3233
+ const [top, right, bottom, left] = (
3234
+ (this.evaluateStyleProperty('icon-padding', feature) as number[] | undefined) ??
3235
+ (getStylePropertyDefault('icon-padding') as number[])
3236
+ ).map(value => Math.max(0, Number(value)) || 0);
3237
+ iconBoxes.set(
3238
+ [ox - w / 2 - left, oy - h / 2 - top, ox + w / 2 + right, oy + h / 2 + bottom],
3239
+ i * 4
3240
+ );
3241
+ }
3242
+ const boxes = new Float64Array(10);
3243
+ boxes.set(textBoxes.subarray(i * 4, i * 4 + 4), 0);
3244
+ boxes.set(iconBoxes.subarray(i * 4, i * 4 + 4), 4);
3245
+ boxes.set(textOffsets.subarray(i * 2, i * 2 + 2), 8);
3246
+ rowBoxes.boxes.set(row, boxes);
3247
+ }
3248
+ // `labelData` order is feature order within each tile. The other anchors of a label with
3249
+ // variable anchors are placed with its first.
3250
+ const order: number[] = [];
3251
+ for (let i = 0; i < count; i++) {
3252
+ if (!labelData[i].textAnchorIndex) {
3253
+ order.push(i);
3254
+ }
3255
+ }
3256
+ const tiles = labelData.map(row => (row as any).__tile);
3257
+ order.sort(
3258
+ (a, b) => (keys ? keys[a] - keys[b] : 0) || compareTiles(tiles[a], tiles[b]) || a - b
3259
+ );
3260
+ const geometry: PlacementGeometry = {
3261
+ key,
3262
+ rules,
3263
+ order,
3264
+ textBoxes,
3265
+ textOffsets,
3266
+ iconBoxes,
3267
+ textPadding: this.getTextPadding()
3268
+ };
3269
+ this.state.placementGeometry = geometry;
3270
+ return geometry;
3271
+ }
3272
+
3273
+ /** `PlacementLayer.getPlacementSymbols`: this layer's symbols, in MapLibre's order. */
3274
+ getPlacementSymbols(
3275
+ viewport: any,
3276
+ slotBase: number,
3277
+ out: PlacementSymbol[]
3278
+ ): SymbolPlacementRules | null {
3279
+ const geometry = this.getPlacementGeometry();
3280
+ if (!geometry) {
3281
+ return null;
3282
+ }
3283
+ const labelData = this.state.labelData!;
3284
+ const {rules, order, textBoxes, textOffsets, iconBoxes, textPadding} = geometry;
3285
+ const count = labelData.length;
3286
+ const linePlacement = isLinePlacement(this.getSymbolPlacement());
3287
+ const lineProps = (linePlacement ? this.getLineLabelProps() : {}) as {
3288
+ billboard?: boolean;
3289
+ getAngle?: (row: LabelRow) => number;
3290
+ };
3291
+ const iconAngleProps = rules.hasIconData ? this.getIconAngleProps() : null;
3292
+ const getIconAngle = iconAngleProps?.getAngle;
3293
+ const padding = PLACEMENT_VIEWPORT_PADDING;
3294
+ const {width, height} = viewport;
3295
+ const isGlobe = isGlobeViewport(viewport);
3296
+ // Text along lines and icons aligned to the map are drawn in the map plane on a flat map: their
3297
+ // pixel offsets turn with the bearing and are foreshortened by the pitch.
3298
+ const textInMapPlane = !isGlobe && Boolean(lineProps.getAngle) && lineProps.billboard === false;
3299
+ const iconInMapPlane = !isGlobe && iconAngleProps?.props.billboard === false;
3300
+ const toScreen = [1, 0, 0, 1];
3301
+ // Glyphs placed along lines, as drawn (see `getLineGlyphProps`).
3302
+ const curves = textInMapPlane && this.curvesLineText(viewport);
3303
+ const bearingStep = curves ? this.getBearingStep(viewport) : 0;
3304
+ const styleKey = curves ? this.getLineGlyphStyleKey() : '';
3305
+ const spritesLoading = rules.hasIconData && this.props.spriteAtlases === null;
3306
+ // On a globe, billboards along lines are turned to their lines as the view draws them (see
3307
+ // `getLineAngleExtensions`): they collide along that direction.
3308
+ const textGlobeTurn = isGlobe
3309
+ ? this.getLineAngleExtensions(Boolean(lineProps.getAngle), 'text-keep-upright')[0]
3310
+ : undefined;
3311
+ const iconGlobeTurn = isGlobe ? iconAngleProps?.extensions[0] : undefined;
3312
+
3313
+ // `tiles` rows are in longitude/latitude: their common-space positions, which do not change
3314
+ // with the view, are kept, and projected to the screen with the viewport's matrix.
3315
+ const commons = this.props.tiles ? this.getPlacementCommons(viewport, geometry) : null;
3316
+ const m = viewport.pixelProjectionMatrix as number[];
3317
+ const horizon = isGlobe ? this.getHorizonTest(viewport) : null;
3318
+ // The padded text box of row `j` at the grid point `x, y`.
3319
+ const getTextBox = (j: number, x: number, y: number): PlacementShape => {
3320
+ const x1 = textBoxes[j * 4];
3321
+ const y1 = textBoxes[j * 4 + 1];
3322
+ return {
3323
+ box: [
3324
+ x + x1 - textPadding,
3325
+ y + y1 - textPadding,
3326
+ x + x1 + textBoxes[j * 4 + 2] + textPadding,
3327
+ y + y1 + textBoxes[j * 4 + 3] + textPadding
3328
+ ]
3329
+ };
3330
+ };
3331
+ for (const i of order) {
3332
+ const row = labelData[i];
3333
+ let sx: number;
3334
+ let sy: number;
3335
+ let occluded = false;
3336
+ let common: number[] | null = null;
3337
+ if (commons) {
3338
+ const cx = commons[i * 3];
3339
+ const cy = commons[i * 3 + 1];
3340
+ const cz = commons[i * 3 + 2];
3341
+ const w = m[3] * cx + m[7] * cy + m[11] * cz + m[15];
3342
+ sx = (m[0] * cx + m[4] * cy + m[8] * cz + m[12]) / w;
3343
+ sy = (m[1] * cx + m[5] * cy + m[9] * cz + m[13]) / w;
3344
+ occluded = horizon ? horizon(cx, cy, cz) : false;
3345
+ common = [cx, cy, cz];
3346
+ } else {
3347
+ [sx, sy] = this.project(row.position as any) as number[];
3348
+ occluded = isGlobe && this.isBeyondHorizon(viewport, row.position);
3349
+ }
3350
+ if (!Number.isFinite(sx) || !Number.isFinite(sy)) {
3351
+ continue;
3352
+ }
3353
+ const x = sx + padding;
3354
+ const y = sy + padding;
3355
+ // Rows far outside the grid are not placed: MapLibre hides them until they come close.
3356
+ if (
3357
+ x < -width ||
3358
+ y < -height ||
3359
+ x > 3 * width + 2 * padding ||
3360
+ y > 3 * height + 2 * padding
3361
+ ) {
3362
+ continue;
3363
+ }
3364
+ const t = i * 4;
3365
+ const inMapPlane =
3366
+ (textInMapPlane && !Number.isNaN(textBoxes[t + 2])) ||
3367
+ (iconInMapPlane && !Number.isNaN(iconBoxes[t]));
3368
+ if (inMapPlane) {
3369
+ common ||= viewport.projectPosition(viewport.unproject([sx, sy])) as number[];
3370
+ getMapPlaneToScreen(viewport, common, sx, sy, toScreen);
3371
+ }
3372
+ let text: PlacementShape | null = null;
3373
+ let textAnchors: PlacementSymbol['textAnchors'];
3374
+ const w = textBoxes[t + 2];
3375
+ if (!Number.isNaN(w)) {
3376
+ const x1 = textBoxes[t];
3377
+ const y1 = textBoxes[t + 1];
3378
+ const h = textBoxes[t + 3];
3379
+ const curved = curves ? this.getLineGlyphs(row, viewport, bearingStep, styleKey) : null;
3380
+ if (curved && common) {
3381
+ // Circles along the line under the text, as MapLibre's: the path from its first glyph to
3382
+ // its last, taken to the screen vertex by vertex. As in MapLibre, they follow the line,
3383
+ // moved by `text-offset` only, whatever the `text-anchor`. Text that does not fit on the
3384
+ // line unturned has none, and is not drawn (see `LineGlyphPlacement`); text that fits
3385
+ // only unturned but must turn has its unturned circles, and is not drawn.
3386
+ const [ax, ay, bx, by] = toScreen;
3387
+ const screenRadius = (h / 2 + textPadding) * Math.sqrt(Math.abs(ax * by - ay * bx));
3388
+ const {path} = curved;
3389
+ if (!path) {
3390
+ text = {circles: []};
3391
+ } else {
3392
+ const step = 1 / (viewport.scale || 1);
3393
+ const screenPath: number[] = [];
3394
+ for (let k = 0; k < path.length; k += 2) {
3395
+ const cx = common[0] + path[k] * step;
3396
+ const cy = common[1] - path[k + 1] * step;
3397
+ const cz = common[2];
3398
+ const cw = m[3] * cx + m[7] * cy + m[11] * cz + m[15];
3399
+ screenPath.push(
3400
+ (m[0] * cx + m[4] * cy + m[8] * cz + m[12]) / cw + padding,
3401
+ (m[1] * cx + m[5] * cy + m[9] * cz + m[13]) / cw + padding
3402
+ );
3403
+ }
3404
+ LINE_GLYPH_STATS.placementPaths++;
3405
+ text = {circles: getPathCircles(screenPath, screenRadius)};
3406
+ }
3407
+ } else if (lineProps.getAngle) {
3408
+ // Circles along the turned label, as MapLibre's collision circles along a line, taken
3409
+ // to the screen when the text is drawn in the map plane.
3410
+ let angle = (lineProps.getAngle(row) * Math.PI) / 180;
3411
+ if (textGlobeTurn) {
3412
+ angle = getGlobeDrawnAngle(
3413
+ viewport,
3414
+ row.position as number[],
3415
+ angle,
3416
+ textGlobeTurn.opts.keepUpright
3417
+ );
3418
+ }
3419
+ const ux = Math.cos(angle);
3420
+ const uy = -Math.sin(angle);
3421
+ const radius = h / 2 + textPadding;
3422
+ // The text turns with its upright angle, and its offset with the line (see
3423
+ // `withLineDirection`), as drawn.
3424
+ const dx = textOffsets[i * 2];
3425
+ const dy = textOffsets[i * 2 + 1];
3426
+ // On a globe, along the line as the view draws it, never turned around.
3427
+ const [ex, ey] = rotatePixelOffset(
3428
+ [dx, dy],
3429
+ textGlobeTurn
3430
+ ? (getGlobeDrawnAngle(
3431
+ viewport,
3432
+ row.position as number[],
3433
+ ((row.angle ?? 0) * Math.PI) / 180,
3434
+ false
3435
+ ) *
3436
+ 180) /
3437
+ Math.PI
3438
+ : (row.angle ?? 0)
3439
+ );
3440
+ const cx = x1 + w / 2 - dx;
3441
+ const cy = y1 + h / 2 - dy;
3442
+ // A label without height has a single circle.
3443
+ const n =
3444
+ radius > 0
3445
+ ? Math.max(1, Math.ceil(Math.max(0, w - 2 * radius) / (radius * 2.5)) + 1)
3446
+ : 1;
3447
+ const [ax, ay, bx, by] = textInMapPlane ? toScreen : [1, 0, 0, 1];
3448
+ const screenRadius = radius * Math.sqrt(Math.abs(ax * by - ay * bx));
3449
+ const circles: number[] = [];
3450
+ for (let k = 0; k < n; k++) {
3451
+ const along = n === 1 ? 0 : -w / 2 + radius + ((w - 2 * radius) * k) / (n - 1);
3452
+ const ox = cx + along;
3453
+ const mx = ox * ux - cy * uy + ex;
3454
+ const my = ox * uy + cy * ux + ey;
3455
+ circles.push(x + mx * ax + my * bx, y + mx * ay + my * by, screenRadius);
3456
+ }
3457
+ text = {circles};
3458
+ } else {
3459
+ text = getTextBox(i, x, y);
3460
+ const textAnchorCount = row.textAnchorCount ?? 0;
3461
+ if (textAnchorCount > 1) {
3462
+ // The label's text at each of its anchors, from the rows that follow (see
3463
+ // `getLabelData`), and the slot each is drawn from.
3464
+ const shapes: PlacementShape[] = [text];
3465
+ const slots = [slotBase + count - 1 - i];
3466
+ for (let k = 1; k < textAnchorCount; k++) {
3467
+ const j = i + k;
3468
+ if (labelData[j]?.textAnchorIndex !== k || Number.isNaN(textBoxes[j * 4 + 2])) {
3469
+ break;
3470
+ }
3471
+ shapes.push(getTextBox(j, x, y));
3472
+ slots.push(slotBase + count - 1 - j);
3473
+ }
3474
+ // A tile's rows are kept while it is drawn, so the first row names the label across
3475
+ // placements.
3476
+ textAnchors = {shapes, slots, key: row};
3477
+ }
3478
+ }
3479
+ }
3480
+ let icon: PlacementShape | null = null;
3481
+ if (!Number.isNaN(iconBoxes[t])) {
3482
+ let iconAngle = getIconAngle ? getIconAngle(row) : 0;
3483
+ if (iconGlobeTurn) {
3484
+ iconAngle =
3485
+ (getGlobeDrawnAngle(
3486
+ viewport,
3487
+ row.position as number[],
3488
+ (iconAngle * Math.PI) / 180,
3489
+ iconGlobeTurn.opts.keepUpright
3490
+ ) *
3491
+ 180) /
3492
+ Math.PI;
3493
+ }
3494
+ const [bx1, by1, bx2, by2] = getTurnedBounds(
3495
+ [iconBoxes[t], iconBoxes[t + 1], iconBoxes[t + 2], iconBoxes[t + 3]],
3496
+ iconAngle,
3497
+ iconInMapPlane ? toScreen : null
3498
+ );
3499
+ icon = {box: [x + bx1, y + by1, x + bx2, y + by2]};
3500
+ }
3501
+ // While the sprites load, a row whose `icon-image` names an image waits for it.
3502
+ const iconPending =
3503
+ spritesLoading && Boolean(this.getIconImageName((row as any).__source?.object ?? row));
3504
+ if (text || icon || iconPending) {
3505
+ out.push({
3506
+ slot: slotBase + count - 1 - i,
3507
+ text,
3508
+ icon,
3509
+ occluded,
3510
+ iconPending,
3511
+ ...(textAnchors && {textAnchors})
3512
+ });
3513
+ }
3514
+ }
3515
+ return rules;
3516
+ }
3517
+
3518
+ /**
3519
+ * The common-space position of each `labelData` row (in longitude/latitude), for the kind of
3520
+ * viewport: a globe's or a map's, which do not change with the view. Kept per row.
3521
+ */
3522
+ getPlacementCommons(viewport: any, geometry: PlacementGeometry): Float64Array {
3523
+ const kind = isGlobeViewport(viewport) ? 'globe' : 'map';
3524
+ if (geometry.commons?.kind === kind) {
3525
+ return geometry.commons.positions;
3526
+ }
3527
+ const labelData = this.state.labelData!;
3528
+ let cache = this.state.placementCommons;
3529
+ if (cache?.kind !== kind) {
3530
+ cache = {kind, positions: new WeakMap()};
3531
+ this.state.placementCommons = cache;
3532
+ }
3533
+ const positions = new Float64Array(labelData.length * 3);
3534
+ labelData.forEach((row, i) => {
3535
+ let common = cache!.positions.get(row);
3536
+ if (!common) {
3537
+ common = viewport.projectPosition([row.position[0], row.position[1], 0]) as number[];
3538
+ cache!.positions.set(row, common);
3539
+ }
3540
+ positions[i * 3] = common[0];
3541
+ positions[i * 3 + 1] = common[1];
3542
+ positions[i * 3 + 2] = common[2];
3543
+ });
3544
+ geometry.commons = {kind, positions};
3545
+ return positions;
3546
+ }
3547
+
3548
+ /**
3549
+ * Whether a common-space point on a globe's surface is beyond its horizon in `viewport`, as
3550
+ * `GlobeHorizonExtension` tests it.
3551
+ */
3552
+ getHorizonTest(viewport: any): (x: number, y: number, z: number) => boolean {
3553
+ const m = viewport.viewProjectionMatrix as number[];
3554
+ if (m[3] === 0 && m[7] === 0 && m[11] === 0) {
3555
+ // Orthographic: hidden where the normal points away from the viewer (depth grows).
3556
+ return (x, y, z) => x * m[2] + y * m[6] + z * m[10] > 0;
3557
+ }
3558
+ const [cx, cy, cz] = viewport.cameraPosition as number[];
3559
+ // On the surface, a point's length is the globe's radius: dot(n, camera) < radius.
3560
+ return (x, y, z) => x * cx + y * cy + z * cz < x * x + y * y + z * z;
3561
+ }
3562
+
3563
+ /** Whether a longitude/latitude lies beyond the horizon of a globe viewport. */
3564
+ isBeyondHorizon(viewport: any, position: number[]): boolean {
3565
+ // As `GlobeHorizonExtension` tests it in the shader.
3566
+ const common = viewport.projectPosition([position[0], position[1], 0]);
3567
+ const length = Math.hypot(common[0], common[1], common[2]);
3568
+ const m = viewport.viewProjectionMatrix;
3569
+ if (!length || !m) {
3570
+ return false;
3571
+ }
3572
+ const n = [common[0] / length, common[1] / length, common[2] / length];
3573
+ if (m[3] === 0 && m[7] === 0 && m[11] === 0) {
3574
+ // Orthographic: hidden where the normal points away from the viewer (depth grows).
3575
+ return n[0] * m[2] + n[1] * m[6] + n[2] * m[10] > 0;
3576
+ }
3577
+ const camera = viewport.cameraPosition;
3578
+ // The surface point is the globe's radius from its centre, the common space origin.
3579
+ return n[0] * camera[0] + n[1] * camera[1] + n[2] * camera[2] < length;
3580
+ }
3581
+
3582
+ renderLayers(): any {
3583
+ const {config, labelSizeUnits, billboard, renderGeometry} = this.props;
3584
+ const layers: any[] = [];
3585
+
3586
+ if (renderGeometry) {
3587
+ layers.push(
3588
+ new GeoJsonLayer({
3589
+ ...this.props,
3590
+ ...this.getSubLayerProps({id: 'geojson'}),
3591
+ data: this.props.data
3592
+ })
3593
+ );
3594
+ }
3595
+
3596
+ if (!config.labels) {
3597
+ // No icon layers draw from the shared sprite textures.
3598
+ this.retainIconAtlases(new Set());
3599
+ }
3600
+ if (config.labels) {
3601
+ // Icons draw under their labels.
3602
+ const icons = this.getRowIcons();
3603
+ layers.push(...this.renderIconLayers(icons));
3604
+ const font = this.getFont();
3605
+ // While the sprites load, every row is taken to have its icon (see `spriteAtlases`).
3606
+ const spritesLoading = this.props.spriteAtlases === null;
3607
+ const groups = this.getSymbolPartGroups('text', () =>
3608
+ spritesLoading ? icons.map(() => true) : icons.map(Boolean)
3609
+ );
3610
+ const {halos = [null], rowHalos = []} = this.state.halos || {};
3611
+ const rowCount = rowHalos.length;
3612
+ for (const {idSuffix, rows} of groups) {
3613
+ // Accessors over every row: `fallback` for rows outside the group.
3614
+ const forGroupRows = <T>(accessor: (feature: FeatureLike) => T, fallback: T) =>
3615
+ forRows(rows, this.getSubLayerAccessor(accessor) as any, fallback) as any;
3616
+ const updateTriggers = this.getLabelUpdateTriggers();
3617
+ const getText = forGroupRows((feature: FeatureLike) => this.getLabel(feature), undefined);
3618
+ // Which rows have an icon decides the group's rows. `all` is always set, so that a change
3619
+ // between split and unsplit groups refreshes the rows masked out before.
3620
+ const groupTrigger = rows
3621
+ ? [this.getStyleUpdateTrigger('icon-image'), this.props.spriteAtlases]
3622
+ : null;
3623
+ // `TextLayer` refreshes its glyphs with the `getText` trigger only, not with `all`: the
3624
+ // group's rows decide which rows have text, so they are part of it.
3625
+ const getTextTrigger = [updateTriggers.getText, ...(groupTrigger || [])];
3626
+ const lineLabelProps = this.getLineLabelProps();
3627
+ const lineGlyphs = this.getLineGlyphProps();
3628
+ updateTriggers.getLineGlyphs = lineGlyphs.trigger;
3629
+ const textProps = {
3630
+ data: this.getRows(),
3631
+ extensions: [
3632
+ ...(this.props.extensions || []),
3633
+ LABEL_PLACEMENT_EXTENSION,
3634
+ GLOBE_HORIZON_EXTENSION,
3635
+ TEXT_HALO_BLUR_EXTENSION,
3636
+ ...this.getLineAngleExtensions('getAngle' in lineLabelProps, 'text-keep-upright')
3637
+ ],
3638
+ ...this.getPlacementProps(0),
3639
+ parameters: LABEL_PARAMETERS,
3640
+ billboard,
3641
+ ...lineLabelProps,
3642
+ characterSet: 'auto',
3643
+ // Glyphs are signed distance fields, which `TextLayer` outlines for halos.
3644
+ ...this.getFontProps(font),
3645
+ sizeUnits: labelSizeUnits,
3646
+ lineHeight: this.getLineHeight(),
3647
+ // A transparent background box makes the whole label pickable.
3648
+ background: true,
3649
+ getBackgroundColor: [0, 0, 0, 0],
3650
+ getPosition: (d: LabelRow) => d.position,
3651
+ getSize: forGroupRows((feature: FeatureLike) => this.getLabelSize(feature), 0),
3652
+ getColor: forGroupRows(
3653
+ (feature: FeatureLike) => this.getLabelColor(feature),
3654
+ [0, 0, 0, 0]
3655
+ ),
3656
+ getPixelOffset: withLineDirection(
3657
+ forRows(rows, (row: LabelRow) => this.getRowPixelOffset(row), [0, 0]) as any,
3658
+ Boolean(lineLabelProps.getAngle)
3659
+ ),
3660
+ getLineGlyphs: lineGlyphs.getLineGlyphs,
3661
+ getLineGlyphKey: lineGlyphs.getLineGlyphKey,
3662
+ // A row of a label with variable anchors is drawn at its own anchor.
3663
+ getTextAnchor: forRows(
3664
+ rows,
3665
+ (row: LabelRow) => getTextAnchorProps(this.getRowAnchor(row)).textAnchor,
3666
+ 'middle'
3667
+ ),
3668
+ getAlignmentBaseline: forRows(
3669
+ rows,
3670
+ (row: LabelRow) => getTextAnchorProps(this.getRowAnchor(row)).alignmentBaseline,
3671
+ 'center'
3672
+ ),
3673
+ // Constant where the style layer leaves the property at its default.
3674
+ getLetterSpacing: this.getStyleProperty('text-letter-spacing')
3675
+ ? forGroupRows(
3676
+ (feature: FeatureLike) =>
3677
+ this.getLetterSpacing(feature, this.getLabelText(feature) || ''),
3678
+ 0
3679
+ )
3680
+ : 0,
3681
+ getTextJustify: this.getStyleProperty('text-justify')
3682
+ ? forGroupRows((feature: FeatureLike) => this.getTextJustify(feature), 'center')
3683
+ : 'center'
3684
+ };
3685
+ if (halos.length <= 1) {
3686
+ // Every label has the same halo, or none: one sublayer draws them all.
3687
+ const halo = halos[0];
3688
+ layers.push(
3689
+ new LabelTextLayer({
3690
+ ...this.getSubLayerProps({id: `text${idSuffix}`}),
3691
+ ...textProps,
3692
+ outlineWidth: halo?.outlineWidth ?? 0,
3693
+ outlineColor: (halo?.outlineColor ?? [0, 0, 0, 0]) as any,
3694
+ textHaloBlurGamma: halo?.outlineBlur ?? 0,
3695
+ textHaloBlurCapped: !this.props.glyphs,
3696
+ getText,
3697
+ updateTriggers: {...updateTriggers, getText: getTextTrigger, all: groupTrigger}
3698
+ })
3699
+ );
3700
+ } else {
3701
+ // Several halos. `TextLayer` takes one outline per layer, so each halo is drawn by its
3702
+ // own sublayer. Every sublayer holds every row, in the order of `getRows`, so a row has
3703
+ // one placement slot. The invisible `text` sublayer draws the labels' picking boxes. The
3704
+ // number of halos is capped (see getLabelHalos), so the work stays linear in the rows.
3705
+ layers.push(
3706
+ new LabelTextLayer({
3707
+ ...this.getSubLayerProps({id: `text${idSuffix}`}),
3708
+ ...textProps,
3709
+ outlineWidth: 0,
3710
+ getColor: [0, 0, 0, 0],
3711
+ getText,
3712
+ updateTriggers: {...updateTriggers, getText: getTextTrigger, all: groupTrigger}
3713
+ }),
3714
+ ...halos.map(
3715
+ (halo, haloIndex) =>
3716
+ new LabelTextLayer({
3717
+ ...this.getSubLayerProps({id: `text${idSuffix}-${haloIndex + 1}`}),
3718
+ ...textProps,
3719
+ outlineWidth: halo?.outlineWidth ?? 0,
3720
+ outlineColor: (halo?.outlineColor ?? [0, 0, 0, 0]) as any,
3721
+ textHaloBlurGamma: halo?.outlineBlur ?? 0,
3722
+ textHaloBlurCapped: !this.props.glyphs,
3723
+ // The placing sublayer's background box is the label's picking box.
3724
+ background: false,
3725
+ // `rowHalos` is in `labelData` order, the reverse of `getRows`.
3726
+ getText: (row: LabelRow, info: {index: number}) =>
3727
+ rowHalos[rowCount - 1 - info.index] === haloIndex
3728
+ ? getText(row, info)
3729
+ : undefined,
3730
+ updateTriggers: {
3731
+ ...updateTriggers,
3732
+ getText: [...getTextTrigger, this.state.haloKey?.[1]],
3733
+ all: groupTrigger
3734
+ }
3735
+ })
3736
+ )
3737
+ );
3738
+ }
3739
+ }
3740
+ }
3741
+
3742
+ return layers;
3743
+ }
3744
+ }