@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.
- package/README.md +34 -2
- package/THIRD-PARTY-NOTICES.md +205 -0
- package/dist/geojson-worker.js +2160 -0
- package/dist/geojson-worker.js.map +6 -0
- package/dist/index.cjs +11388 -0
- package/dist/index.cjs.map +6 -0
- package/dist/index.js +11369 -0
- package/dist/index.js.map +6 -0
- package/dist/map-style.cjs +445 -0
- package/dist/map-style.cjs.map +6 -0
- package/dist/map-style.js +431 -0
- package/dist/map-style.js.map +6 -0
- package/dist/types/arabic-shaping.d.ts +7 -0
- package/dist/types/atmosphere-layer.d.ts +10 -0
- package/dist/types/basemap-layer.d.ts +150 -0
- package/dist/types/bidi.d.ts +8 -0
- package/dist/types/circle-layer.d.ts +49 -0
- package/dist/types/fill-extrusion-lighting-extension.d.ts +49 -0
- package/dist/types/fill-outline-extension.d.ts +22 -0
- package/dist/types/fill-pattern.d.ts +99 -0
- package/dist/types/fill-style-extensions.d.ts +100 -0
- package/dist/types/geojson-source.d.ts +29 -0
- package/dist/types/geojson-tiler.d.ts +48 -0
- package/dist/types/geojson-worker.d.ts +40 -0
- package/dist/types/globe-horizon-extension.d.ts +61 -0
- package/dist/types/globe-layers.d.ts +239 -0
- package/dist/types/globe-line-angle-extension.d.ts +37 -0
- package/dist/types/glyph-font-renderer.d.ts +37 -0
- package/dist/types/glyph-pbf.d.ts +37 -0
- package/dist/types/glyph-store.d.ts +103 -0
- package/dist/types/grid-index.d.ts +47 -0
- package/dist/types/hillshade-layer.d.ts +154 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/is-globe-viewport.d.ts +2 -0
- package/dist/types/label-font-renderer.d.ts +18 -0
- package/dist/types/label-placement.d.ts +247 -0
- package/dist/types/label-text-layer.d.ts +231 -0
- package/dist/types/line-blur-extension.d.ts +73 -0
- package/dist/types/line-glyphs.d.ts +60 -0
- package/dist/types/line-placement.d.ts +93 -0
- package/dist/types/map-style-loader.d.ts +48 -0
- package/dist/types/map-style-schema.d.ts +72 -0
- package/dist/types/map-style.d.ts +6 -0
- package/dist/types/mapbox-style.d.ts +40 -0
- package/dist/types/mvt-label-layer.d.ts +923 -0
- package/dist/types/polylabel.d.ts +17 -0
- package/dist/types/raster-color-extension.d.ts +146 -0
- package/dist/types/shared-texture.d.ts +34 -0
- package/dist/types/sort-key.d.ts +10 -0
- package/dist/types/sprite.d.ts +82 -0
- package/dist/types/style-accessor.d.ts +45 -0
- package/dist/types/style-expression.d.ts +83 -0
- package/dist/types/style-resolver.d.ts +94 -0
- package/dist/types/text-font.d.ts +36 -0
- package/dist/types/text-halo-blur-extension.d.ts +55 -0
- package/dist/types/text-layout.d.ts +71 -0
- package/dist/types/util.d.ts +22 -0
- package/dist/types/zoom-paint-extension.d.ts +72 -0
- package/package.json +80 -3
- package/src/arabic-shaping.ts +366 -0
- package/src/atmosphere-layer.ts +19 -0
- package/src/basemap-layer.ts +533 -0
- package/src/bidi.ts +199 -0
- package/src/circle-layer.ts +191 -0
- package/src/fill-extrusion-lighting-extension.ts +206 -0
- package/src/fill-outline-extension.ts +70 -0
- package/src/fill-pattern.ts +400 -0
- package/src/fill-style-extensions.ts +177 -0
- package/src/geojson-source.ts +318 -0
- package/src/geojson-tiler.ts +185 -0
- package/src/geojson-worker.ts +98 -0
- package/src/globe-horizon-extension.ts +64 -0
- package/src/globe-layers.ts +3487 -0
- package/src/globe-line-angle-extension.ts +131 -0
- package/src/glyph-font-renderer.ts +173 -0
- package/src/glyph-pbf.ts +164 -0
- package/src/glyph-store.ts +326 -0
- package/src/grid-index.ts +177 -0
- package/src/hillshade-layer.ts +775 -0
- package/src/index.ts +18 -0
- package/src/is-globe-viewport.ts +24 -0
- package/src/label-font-renderer.ts +159 -0
- package/src/label-placement.ts +727 -0
- package/src/label-text-layer.ts +913 -0
- package/src/line-blur-extension.ts +237 -0
- package/src/line-glyphs.ts +364 -0
- package/src/line-placement.ts +446 -0
- package/src/map-style-loader.ts +56 -0
- package/src/map-style-schema.ts +83 -0
- package/src/map-style.ts +23 -0
- package/src/mapbox-style.ts +168 -0
- package/src/mvt-label-layer.ts +3744 -0
- package/src/polylabel.ts +312 -0
- package/src/raster-color-extension.ts +245 -0
- package/src/shared-texture.ts +153 -0
- package/src/sort-key.ts +63 -0
- package/src/sprite.ts +244 -0
- package/src/style-accessor.ts +95 -0
- package/src/style-expression.ts +327 -0
- package/src/style-resolver.ts +301 -0
- package/src/text-font.ts +150 -0
- package/src/text-halo-blur-extension.ts +115 -0
- package/src/text-layout.ts +441 -0
- package/src/util.ts +47 -0
- 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
|
+
}
|