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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +34 -2
  2. package/THIRD-PARTY-NOTICES.md +205 -0
  3. package/dist/geojson-worker.js +2160 -0
  4. package/dist/geojson-worker.js.map +6 -0
  5. package/dist/index.cjs +11388 -0
  6. package/dist/index.cjs.map +6 -0
  7. package/dist/index.js +11369 -0
  8. package/dist/index.js.map +6 -0
  9. package/dist/map-style.cjs +445 -0
  10. package/dist/map-style.cjs.map +6 -0
  11. package/dist/map-style.js +431 -0
  12. package/dist/map-style.js.map +6 -0
  13. package/dist/types/arabic-shaping.d.ts +7 -0
  14. package/dist/types/atmosphere-layer.d.ts +10 -0
  15. package/dist/types/basemap-layer.d.ts +150 -0
  16. package/dist/types/bidi.d.ts +8 -0
  17. package/dist/types/circle-layer.d.ts +49 -0
  18. package/dist/types/fill-extrusion-lighting-extension.d.ts +49 -0
  19. package/dist/types/fill-outline-extension.d.ts +22 -0
  20. package/dist/types/fill-pattern.d.ts +99 -0
  21. package/dist/types/fill-style-extensions.d.ts +100 -0
  22. package/dist/types/geojson-source.d.ts +29 -0
  23. package/dist/types/geojson-tiler.d.ts +48 -0
  24. package/dist/types/geojson-worker.d.ts +40 -0
  25. package/dist/types/globe-horizon-extension.d.ts +61 -0
  26. package/dist/types/globe-layers.d.ts +239 -0
  27. package/dist/types/globe-line-angle-extension.d.ts +37 -0
  28. package/dist/types/glyph-font-renderer.d.ts +37 -0
  29. package/dist/types/glyph-pbf.d.ts +37 -0
  30. package/dist/types/glyph-store.d.ts +103 -0
  31. package/dist/types/grid-index.d.ts +47 -0
  32. package/dist/types/hillshade-layer.d.ts +154 -0
  33. package/dist/types/index.d.ts +9 -0
  34. package/dist/types/is-globe-viewport.d.ts +2 -0
  35. package/dist/types/label-font-renderer.d.ts +18 -0
  36. package/dist/types/label-placement.d.ts +247 -0
  37. package/dist/types/label-text-layer.d.ts +231 -0
  38. package/dist/types/line-blur-extension.d.ts +73 -0
  39. package/dist/types/line-glyphs.d.ts +60 -0
  40. package/dist/types/line-placement.d.ts +93 -0
  41. package/dist/types/map-style-loader.d.ts +48 -0
  42. package/dist/types/map-style-schema.d.ts +72 -0
  43. package/dist/types/map-style.d.ts +6 -0
  44. package/dist/types/mapbox-style.d.ts +40 -0
  45. package/dist/types/mvt-label-layer.d.ts +923 -0
  46. package/dist/types/polylabel.d.ts +17 -0
  47. package/dist/types/raster-color-extension.d.ts +146 -0
  48. package/dist/types/shared-texture.d.ts +34 -0
  49. package/dist/types/sort-key.d.ts +10 -0
  50. package/dist/types/sprite.d.ts +82 -0
  51. package/dist/types/style-accessor.d.ts +45 -0
  52. package/dist/types/style-expression.d.ts +83 -0
  53. package/dist/types/style-resolver.d.ts +94 -0
  54. package/dist/types/text-font.d.ts +36 -0
  55. package/dist/types/text-halo-blur-extension.d.ts +55 -0
  56. package/dist/types/text-layout.d.ts +71 -0
  57. package/dist/types/util.d.ts +22 -0
  58. package/dist/types/zoom-paint-extension.d.ts +72 -0
  59. package/package.json +80 -3
  60. package/src/arabic-shaping.ts +366 -0
  61. package/src/atmosphere-layer.ts +19 -0
  62. package/src/basemap-layer.ts +533 -0
  63. package/src/bidi.ts +199 -0
  64. package/src/circle-layer.ts +191 -0
  65. package/src/fill-extrusion-lighting-extension.ts +206 -0
  66. package/src/fill-outline-extension.ts +70 -0
  67. package/src/fill-pattern.ts +400 -0
  68. package/src/fill-style-extensions.ts +177 -0
  69. package/src/geojson-source.ts +318 -0
  70. package/src/geojson-tiler.ts +185 -0
  71. package/src/geojson-worker.ts +98 -0
  72. package/src/globe-horizon-extension.ts +64 -0
  73. package/src/globe-layers.ts +3487 -0
  74. package/src/globe-line-angle-extension.ts +131 -0
  75. package/src/glyph-font-renderer.ts +173 -0
  76. package/src/glyph-pbf.ts +164 -0
  77. package/src/glyph-store.ts +326 -0
  78. package/src/grid-index.ts +177 -0
  79. package/src/hillshade-layer.ts +775 -0
  80. package/src/index.ts +18 -0
  81. package/src/is-globe-viewport.ts +24 -0
  82. package/src/label-font-renderer.ts +159 -0
  83. package/src/label-placement.ts +727 -0
  84. package/src/label-text-layer.ts +913 -0
  85. package/src/line-blur-extension.ts +237 -0
  86. package/src/line-glyphs.ts +364 -0
  87. package/src/line-placement.ts +446 -0
  88. package/src/map-style-loader.ts +56 -0
  89. package/src/map-style-schema.ts +83 -0
  90. package/src/map-style.ts +23 -0
  91. package/src/mapbox-style.ts +168 -0
  92. package/src/mvt-label-layer.ts +3744 -0
  93. package/src/polylabel.ts +312 -0
  94. package/src/raster-color-extension.ts +245 -0
  95. package/src/shared-texture.ts +153 -0
  96. package/src/sort-key.ts +63 -0
  97. package/src/sprite.ts +244 -0
  98. package/src/style-accessor.ts +95 -0
  99. package/src/style-expression.ts +327 -0
  100. package/src/style-resolver.ts +301 -0
  101. package/src/text-font.ts +150 -0
  102. package/src/text-halo-blur-extension.ts +115 -0
  103. package/src/text-layout.ts +441 -0
  104. package/src/util.ts +47 -0
  105. package/src/zoom-paint-extension.ts +207 -0
@@ -0,0 +1,441 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ // deck.gl-community
6
+ // SPDX-License-Identifier: MIT
7
+ // Copyright (c) vis.gl contributors
8
+
9
+ // Line breaking follows MapLibre GL JS's src/symbol/shaping.ts (BSD-3-Clause).
10
+ import {applyArabicShaping} from './arabic-shaping';
11
+ import {containsRightToLeftText, processBidirectionalText} from './bidi';
12
+
13
+ const WHITESPACE = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20]);
14
+ const BREAKABLE = new Set([
15
+ 0x0a, 0x20, 0x26, 0x29, 0x2b, 0x2d, 0x2f, 0xad, 0xb7, 0x200b, 0x2010, 0x2013, 0x2027
16
+ ]);
17
+ const BREAKABLE_BEFORE = new Set([0x28]);
18
+
19
+ /** Builds a regexp of MapLibre's script codes, skipping scripts unsupported by the runtime. */
20
+ function createScriptRegExp(scriptCodes: string[]): RegExp {
21
+ const escapes = scriptCodes
22
+ .map(code => {
23
+ try {
24
+ return new RegExp(`\\p{sc=${code}}`, 'u').source;
25
+ } catch {
26
+ return null;
27
+ }
28
+ })
29
+ .filter(Boolean);
30
+ return new RegExp(escapes.join('|'), 'u');
31
+ }
32
+
33
+ const IDEOGRAPHIC_BREAKING_REGEXP = createScriptRegExp([
34
+ 'Bopo',
35
+ 'Hani',
36
+ 'Hira',
37
+ 'Kana',
38
+ 'Kits',
39
+ 'Nshu',
40
+ 'Tang',
41
+ 'Yiii'
42
+ ]);
43
+
44
+ /** Cursive scripts, whose letters join, take no letter spacing in MapLibre. */
45
+ const CURSIVE_SCRIPT_REGEXP = createScriptRegExp(['Arab', 'Dupl', 'Mong', 'Ougr', 'Syrc']);
46
+
47
+ /**
48
+ * Whether `text-letter-spacing` applies to `text`: as in MapLibre, text with any character of a
49
+ * cursive script (Arabic, Duployan, Mongolian, Old Uyghur, Syriac) is not letter-spaced.
50
+ */
51
+ export function allowsLetterSpacing(text: string): boolean {
52
+ return !CURSIVE_SCRIPT_REGEXP.test(text);
53
+ }
54
+
55
+ /** Tests the blocks and scripts where MapLibre permits ideographic line breaks. */
56
+ function charAllowsIdeographicBreaking(codePoint: number): boolean {
57
+ if (codePoint < 0x2e80) return false;
58
+ if (
59
+ (codePoint >= 0xfe30 && codePoint <= 0xfe4f) ||
60
+ (codePoint >= 0x3300 && codePoint <= 0x33ff) ||
61
+ (codePoint >= 0x31c0 && codePoint <= 0x31ef) ||
62
+ (codePoint >= 0x3000 && codePoint <= 0x303f) ||
63
+ (codePoint >= 0x3200 && codePoint <= 0x32ff) ||
64
+ (codePoint >= 0xff00 && codePoint <= 0xffef) ||
65
+ (codePoint >= 0x2ff0 && codePoint <= 0x2fff) ||
66
+ (codePoint >= 0xfe10 && codePoint <= 0xfe1f)
67
+ )
68
+ return true;
69
+ return IDEOGRAPHIC_BREAKING_REGEXP.test(String.fromCodePoint(codePoint));
70
+ }
71
+
72
+ /**
73
+ * `text-transform`, as MapLibre applies it: locale-aware upper or lower case, then Arabic shaping
74
+ * (see {@link applyArabicShaping}), so that the glyphs looked up, measured and drawn are those of
75
+ * the shapes the letters take.
76
+ */
77
+ export function transformText(text: string, transform: unknown): string {
78
+ if (transform === 'uppercase') text = text.toLocaleUpperCase();
79
+ else if (transform === 'lowercase') text = text.toLocaleLowerCase();
80
+ return applyArabicShaping(text);
81
+ }
82
+
83
+ /** Calculates the target width, including whitespace advances as MapLibre does. */
84
+ function determineAverageLineWidth(
85
+ characters: string[],
86
+ maxWidth: number,
87
+ measure: (character: string) => number
88
+ ): number {
89
+ let totalWidth = 0;
90
+ for (const character of characters) totalWidth += measure(character);
91
+ const lineCount = Math.max(1, Math.ceil(totalWidth / maxWidth));
92
+ return totalWidth / lineCount;
93
+ }
94
+
95
+ /** Scores raggedness, favoring a shorter final line and applying signed penalties. */
96
+ function calculateBadness(
97
+ lineWidth: number,
98
+ targetWidth: number,
99
+ penalty: number,
100
+ isLastBreak: boolean
101
+ ): number {
102
+ const raggedness = Math.pow(lineWidth - targetWidth, 2);
103
+ if (isLastBreak) return lineWidth < targetWidth ? raggedness / 2 : raggedness * 2;
104
+ return raggedness + Math.abs(penalty) * penalty;
105
+ }
106
+
107
+ /** Applies MapLibre's newline, suggested-break and parenthesis penalties. */
108
+ function calculatePenalty(
109
+ codePoint: number,
110
+ nextCodePoint: number,
111
+ penalizableIdeographicBreak: boolean
112
+ ): number {
113
+ let penalty = 0;
114
+ if (codePoint === 0x0a) penalty -= 10000;
115
+ if (penalizableIdeographicBreak) penalty += 150;
116
+ if (codePoint === 0x28 || codePoint === 0xff08) penalty += 50;
117
+ if (nextCodePoint === 0x29 || nextCodePoint === 0xff09) penalty += 50;
118
+ return penalty;
119
+ }
120
+
121
+ type LineBreak = {
122
+ index: number;
123
+ x: number;
124
+ priorBreak: LineBreak | null;
125
+ badness: number;
126
+ };
127
+
128
+ /** Finds the least costly prior break, allowing lines wider than the requested width. */
129
+ function evaluateBreak(
130
+ index: number,
131
+ x: number,
132
+ targetWidth: number,
133
+ potentialBreaks: LineBreak[],
134
+ penalty: number,
135
+ isLastBreak: boolean
136
+ ): LineBreak {
137
+ let priorBreak: LineBreak | null = null;
138
+ let badness = calculateBadness(x, targetWidth, penalty, isLastBreak);
139
+ for (const potentialBreak of potentialBreaks) {
140
+ const candidateBadness =
141
+ calculateBadness(x - potentialBreak.x, targetWidth, penalty, isLastBreak) +
142
+ potentialBreak.badness;
143
+ if (candidateBadness <= badness) {
144
+ priorBreak = potentialBreak;
145
+ badness = candidateBadness;
146
+ }
147
+ }
148
+ return {index, x, priorBreak, badness};
149
+ }
150
+
151
+ /** Recovers break indices iteratively so long labels do not exhaust the call stack. */
152
+ function leastBadBreaks(lastLineBreak: LineBreak | null): number[] {
153
+ const breaks: number[] = [];
154
+ for (let current = lastLineBreak; current; current = current.priorBreak) {
155
+ breaks.push(current.index);
156
+ }
157
+ return breaks.reverse();
158
+ }
159
+
160
+ /** Determines balanced line breaks using MapLibre's dynamic programming algorithm. */
161
+ function determineLineBreaks(
162
+ characters: string[],
163
+ maxWidth: number,
164
+ measure: (character: string) => number
165
+ ): number[] {
166
+ const potentialLineBreaks: LineBreak[] = [];
167
+ const targetWidth = determineAverageLineWidth(characters, maxWidth, measure);
168
+ const hasServerSuggestedBreakpoints = characters.includes('\u200b');
169
+ let currentX = 0;
170
+ for (let i = 0; i < characters.length; i++) {
171
+ const codePoint = characters[i].codePointAt(0)!;
172
+ if (!WHITESPACE.has(codePoint)) currentX += measure(characters[i]);
173
+ if (i < characters.length - 1) {
174
+ const nextCodePoint = characters[i + 1].codePointAt(0)!;
175
+ const ideographicBreak = charAllowsIdeographicBreaking(codePoint);
176
+ if (
177
+ BREAKABLE.has(codePoint) ||
178
+ ideographicBreak ||
179
+ (i !== characters.length - 2 && BREAKABLE_BEFORE.has(nextCodePoint))
180
+ ) {
181
+ potentialLineBreaks.push(
182
+ evaluateBreak(
183
+ i + 1,
184
+ currentX,
185
+ targetWidth,
186
+ potentialLineBreaks,
187
+ calculatePenalty(
188
+ codePoint,
189
+ nextCodePoint,
190
+ ideographicBreak && hasServerSuggestedBreakpoints
191
+ ),
192
+ false
193
+ )
194
+ );
195
+ }
196
+ }
197
+ }
198
+ return leastBadBreaks(
199
+ evaluateBreak(characters.length, currentX, targetWidth, potentialLineBreaks, 0, true)
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Longest label, in UTF-16 code units, that {@link wrapText} wraps. Line breaking is quadratic in
205
+ * the number of break opportunities, and tile data is untrusted, so longer text is returned
206
+ * unwrapped. Real labels are far shorter; at this length even ideographic text, where every
207
+ * character is a break opportunity, costs tens of thousands of steps.
208
+ */
209
+ export const MAX_WRAPPED_TEXT_LENGTH = 256;
210
+
211
+ /**
212
+ * Line-breaking work that {@link wrapText} may still spend, shared by the labels of one tile and
213
+ * style layer. Each wrap costs an upper bound of its steps: one per character plus one per pair of
214
+ * break opportunities. A label whose cost exceeds what is left is returned unwrapped.
215
+ */
216
+ export type WrapBudget = {steps: number};
217
+
218
+ /**
219
+ * Breaks `text` into lines no wider than `maxWidth` ems where it can, with MapLibre's balanced
220
+ * line breaking, and returns the lines joined by '\n', each in the order it is drawn (see
221
+ * {@link orderBidirectionalText}). Advances from `measure` are in ems; words without break
222
+ * opportunities stay intact, even when they exceed the requested width. Text longer than
223
+ * {@link MAX_WRAPPED_TEXT_LENGTH}, or whose cost exceeds `budget`, is not broken.
224
+ */
225
+ export function wrapText(
226
+ text: string,
227
+ maxWidth: number,
228
+ measure: (character: string) => number,
229
+ budget?: WrapBudget
230
+ ): string {
231
+ return layOutLines(text, getLineBreaks(text, maxWidth, measure, budget));
232
+ }
233
+
234
+ /**
235
+ * Puts text that is not wrapped, such as a label along a line, into the order it is drawn: right-
236
+ * to-left runs reversed, as MapLibre does. Text without right-to-left characters is unchanged.
237
+ */
238
+ export function orderBidirectionalText(text: string): string {
239
+ return layOutLines(text, null);
240
+ }
241
+
242
+ /**
243
+ * The code point offsets where `wrapText` ends the lines of `text`, the last being its length, or
244
+ * null when it is not broken.
245
+ */
246
+ function getLineBreaks(
247
+ text: string,
248
+ maxWidth: number,
249
+ measure: (character: string) => number,
250
+ budget?: WrapBudget
251
+ ): number[] | null {
252
+ if (text.length > MAX_WRAPPED_TEXT_LENGTH) {
253
+ return null;
254
+ }
255
+ const characters = Array.from(text);
256
+ let breakOpportunities = 0;
257
+ for (const character of characters) {
258
+ const codePoint = character.codePointAt(0)!;
259
+ if (
260
+ BREAKABLE.has(codePoint) ||
261
+ BREAKABLE_BEFORE.has(codePoint) ||
262
+ charAllowsIdeographicBreaking(codePoint)
263
+ ) {
264
+ breakOpportunities++;
265
+ }
266
+ }
267
+ if (!breakOpportunities) return null;
268
+ if (budget) {
269
+ const cost = characters.length + (breakOpportunities * (breakOpportunities + 1)) / 2;
270
+ if (cost > budget.steps) return null;
271
+ budget.steps -= cost;
272
+ }
273
+ return determineLineBreaks(characters, maxWidth, measure);
274
+ }
275
+
276
+ /** Trims whitespace from both ends of a line, given as code points. */
277
+ function trimLine(characters: string[]): string {
278
+ let first = 0;
279
+ let last = characters.length;
280
+ while (first < last && WHITESPACE.has(characters[first].codePointAt(0)!)) first++;
281
+ while (last > first && WHITESPACE.has(characters[last - 1].codePointAt(0)!)) last--;
282
+ return characters.slice(first, last).join('');
283
+ }
284
+
285
+ /**
286
+ * Breaks `text` at `breaks` (code point offsets, or null for none) into trimmed lines joined by
287
+ * '\n'. As MapLibre's shaping, text with right-to-left characters is broken in logical order and
288
+ * each line is then reordered by the Unicode Bidirectional Algorithm, with the embedding levels of
289
+ * the whole label (see {@link processBidirectionalText}).
290
+ */
291
+ function layOutLines(text: string, breaks: number[] | null): string {
292
+ const rightToLeft = containsRightToLeftText(text);
293
+ if (!breaks && !rightToLeft) {
294
+ return text;
295
+ }
296
+ const characters = Array.from(text);
297
+ if (rightToLeft) {
298
+ const codeUnitBreaks: number[] = [];
299
+ if (breaks) {
300
+ let codeUnit = 0;
301
+ let index = 0;
302
+ for (const end of breaks) {
303
+ for (; index < end; index++) codeUnit += characters[index].length;
304
+ codeUnitBreaks.push(codeUnit);
305
+ }
306
+ }
307
+ return processBidirectionalText(text, codeUnitBreaks)
308
+ .map(line => trimLine(Array.from(line)))
309
+ .join('\n');
310
+ }
311
+ const lines: string[] = [];
312
+ let start = 0;
313
+ for (const end of breaks!) {
314
+ lines.push(trimLine(characters.slice(start, end)));
315
+ start = end;
316
+ }
317
+ return lines.join('\n');
318
+ }
319
+
320
+ /**
321
+ * Whether text can be measured with a canvas here; otherwise {@link getCharacterWidthMeasurer}
322
+ * returns {@link FALLBACK_CHARACTER_WIDTH} for every character.
323
+ */
324
+ export function canMeasureText(): boolean {
325
+ return typeof OffscreenCanvas !== 'undefined' || typeof document !== 'undefined';
326
+ }
327
+
328
+ /** Advance in ems used when canvas measurement is unavailable, as for missing atlas glyphs. */
329
+ export const FALLBACK_CHARACTER_WIDTH = 0.5;
330
+ const FONT_SIZE = 64;
331
+ /**
332
+ * Bounds on the measurement caches: fonts and characters both come from untrusted style and tile
333
+ * data. A full cache is cleared rather than grown.
334
+ */
335
+ export const MAX_CACHED_FONTS = 32;
336
+ export const MAX_CACHED_CHARACTERS = 4096;
337
+ /** Bound on the fonts remembered as measured, which also come from untrusted style data. */
338
+ const MAX_MEASURED_FONTS = 4096;
339
+ const CHARACTER_WIDTH_MEASURERS = new Map<string, (character: string) => number>();
340
+
341
+ /** The advances each measurer has measured, so that a forgotten font can be compared. */
342
+ const MEASURED_WIDTHS = new WeakMap<(character: string) => number, Map<string, number>>();
343
+ /**
344
+ * Measurers that do not hold every advance measured in their font since it was last forgotten:
345
+ * their characters were cleared, or an earlier measurer of the font was evicted.
346
+ */
347
+ const INCOMPLETE_MEASURERS = new WeakSet<(character: string) => number>();
348
+ /** CSS fonts measured since they were last forgotten. */
349
+ const MEASURED_FONTS = new Set<string>();
350
+ /** Per CSS font, how many times its advances changed when forgotten. */
351
+ const CHARACTER_WIDTH_REVISIONS = new Map<string, number>();
352
+
353
+ /**
354
+ * Forgets the character advances measured for a CSS font, so they are measured again: a font that
355
+ * was still loading was measured with a fallback font. When the advances measured before differ
356
+ * from those measured now, or were not all kept, the font's revision changes.
357
+ */
358
+ export function forgetCharacterWidths(fontWeight: number | string, fontFamily: string): void {
359
+ const font = `${fontWeight} ${FONT_SIZE}px ${fontFamily}`;
360
+ const before = CHARACTER_WIDTH_MEASURERS.get(font);
361
+ CHARACTER_WIDTH_MEASURERS.delete(font);
362
+ MEASURED_FONTS.delete(font);
363
+ const widths = before && !INCOMPLETE_MEASURERS.has(before) && MEASURED_WIDTHS.get(before);
364
+ if (widths) {
365
+ const measure = getCharacterWidthMeasurer(fontWeight, fontFamily);
366
+ let changed = false;
367
+ for (const [character, width] of widths) {
368
+ if (measure(character) !== width) {
369
+ changed = true;
370
+ break;
371
+ }
372
+ }
373
+ if (!changed) {
374
+ return;
375
+ }
376
+ }
377
+ CHARACTER_WIDTH_REVISIONS.set(font, (CHARACTER_WIDTH_REVISIONS.get(font) ?? 0) + 1);
378
+ }
379
+
380
+ /**
381
+ * A number that changes whenever {@link forgetCharacterWidths} finds a font's advances changed, so
382
+ * that what was measured in it before can be measured again. Loading another font, or a measurer
383
+ * evicted from a full cache, measuring the same advances again, does not change it.
384
+ */
385
+ export function getCharacterWidthsRevision(
386
+ fontWeight: number | string,
387
+ fontFamily: string
388
+ ): number {
389
+ return CHARACTER_WIDTH_REVISIONS.get(`${fontWeight} ${FONT_SIZE}px ${fontFamily}`) ?? 0;
390
+ }
391
+
392
+ /**
393
+ * Returns a function giving a character's advance width in ems for a CSS font, measured the way
394
+ * deck.gl's TextLayer builds its font atlas: at 64px, then divided by 64. Measurers and character
395
+ * advances are cached, up to {@link MAX_CACHED_FONTS} and {@link MAX_CACHED_CHARACTERS}.
396
+ * Environments without a canvas 2D context use a half-em advance.
397
+ */
398
+ export function getCharacterWidthMeasurer(
399
+ fontWeight: number | string,
400
+ fontFamily: string
401
+ ): (character: string) => number {
402
+ const font = `${fontWeight} ${FONT_SIZE}px ${fontFamily}`;
403
+ const cached = CHARACTER_WIDTH_MEASURERS.get(font);
404
+ if (cached) return cached;
405
+ const context =
406
+ typeof OffscreenCanvas !== 'undefined'
407
+ ? new OffscreenCanvas(1, 1).getContext('2d')
408
+ : typeof document !== 'undefined'
409
+ ? document.createElement('canvas').getContext('2d')
410
+ : null;
411
+ if (context) context.font = font;
412
+ const widths = new Map<string, number>();
413
+ /** Measures each code point once for this font. */
414
+ const measure = (character: string): number => {
415
+ let width = widths.get(character);
416
+ if (width === undefined) {
417
+ width = context ? context.measureText(character).width / FONT_SIZE : FALLBACK_CHARACTER_WIDTH;
418
+ if (widths.size >= MAX_CACHED_CHARACTERS) {
419
+ widths.clear();
420
+ INCOMPLETE_MEASURERS.add(measure);
421
+ }
422
+ widths.set(character, width);
423
+ }
424
+ return width;
425
+ };
426
+ MEASURED_WIDTHS.set(measure, widths);
427
+ if (MEASURED_FONTS.has(font)) {
428
+ // An earlier measurer of the font was evicted, with advances this one does not hold.
429
+ INCOMPLETE_MEASURERS.add(measure);
430
+ } else {
431
+ if (MEASURED_FONTS.size >= MAX_MEASURED_FONTS) {
432
+ MEASURED_FONTS.clear();
433
+ }
434
+ MEASURED_FONTS.add(font);
435
+ }
436
+ if (CHARACTER_WIDTH_MEASURERS.size >= MAX_CACHED_FONTS) {
437
+ CHARACTER_WIDTH_MEASURERS.clear();
438
+ }
439
+ CHARACTER_WIDTH_MEASURERS.set(font, measure);
440
+ return measure;
441
+ }
package/src/util.ts ADDED
@@ -0,0 +1,47 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ type FeatureLike = {
6
+ properties?: Record<string, unknown>;
7
+ };
8
+
9
+ /**
10
+ * Options for {@link featuresArrayToObject}.
11
+ */
12
+ export type FeaturesArrayToObjectOptions = {
13
+ /** Features to group by the requested layer-name property. */
14
+ features?: FeatureLike[];
15
+ /** Feature property name that contains the source-layer identifier. */
16
+ layerName?: string;
17
+ /** Optional source name wrapper for the returned object. */
18
+ sourceName?: string | null;
19
+ };
20
+
21
+ /**
22
+ * Converts an array of GeoJSON-like features into an object keyed by layer
23
+ * name, optionally wrapped by a source identifier.
24
+ */
25
+ export function featuresArrayToObject(
26
+ options: FeaturesArrayToObjectOptions = {}
27
+ ): Record<string, FeatureLike[] | Record<string, FeatureLike[]>> {
28
+ const {features = [], layerName = 'layerName', sourceName = null} = options;
29
+
30
+ const featuresByLayer: Record<string, FeatureLike[]> = {};
31
+ for (const feature of features) {
32
+ const featureLayerName = String(feature.properties?.[layerName] ?? '');
33
+ featuresByLayer[featureLayerName] = featuresByLayer[featureLayerName] || [];
34
+ featuresByLayer[featureLayerName].push(feature);
35
+ }
36
+
37
+ if (!sourceName) {
38
+ return featuresByLayer;
39
+ }
40
+
41
+ return {[sourceName]: featuresByLayer};
42
+ }
43
+
44
+ /** Whether two lists hold the same items, by identity. */
45
+ export function isSameList(a: readonly unknown[] | undefined, b: readonly unknown[]): boolean {
46
+ return a !== undefined && a.length === b.length && a.every((item, index) => item === b[index]);
47
+ }
@@ -0,0 +1,207 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {LayerExtension} from '@deck.gl/core';
6
+ import type {Accessor, Layer, LayerContext} from '@deck.gl/core';
7
+ import type {Device, Texture} from '@luma.gl/core';
8
+
9
+ /** The most style layers whose paint one layer can take from `ZoomPaintExtension`. */
10
+ export const MAX_ZOOM_PAINT_PARTS = 64;
11
+
12
+ /** The rows of `ZoomPaintValues`. */
13
+ export const ZOOM_PAINT_WIDTH_ROW = 0;
14
+ export const ZOOM_PAINT_LINE_COLOR_ROW = 1;
15
+ export const ZOOM_PAINT_FILL_COLOR_ROW = 2;
16
+ const ZOOM_PAINT_ROWS = 3;
17
+
18
+ /**
19
+ * The paint of each style layer drawn by a layer, at one zoom: an RGBA texel per style layer
20
+ * (column) and value (row), `MAX_ZOOM_PAINT_PARTS` by 3. Column `i` is the style layer that
21
+ * `getStylePart` returns `i` for.
22
+ *
23
+ * - `ZOOM_PAINT_WIDTH_ROW`: in red, a factor of the layer's width attribute.
24
+ * - `ZOOM_PAINT_LINE_COLOR_ROW`, `ZOOM_PAINT_FILL_COLOR_ROW`: a color in 0-1, which replaces the
25
+ * layer's color attribute unless its alpha is negative: the attribute then holds the style
26
+ * layer's per-feature colors.
27
+ */
28
+ export type ZoomPaintValues = Float32Array;
29
+
30
+ /** Creates values with every width factor 1 and every color taken from the attributes. */
31
+ export function createZoomPaintValues(): ZoomPaintValues {
32
+ const values = new Float32Array(MAX_ZOOM_PAINT_PARTS * ZOOM_PAINT_ROWS * 4);
33
+ for (let part = 0; part < MAX_ZOOM_PAINT_PARTS; part++) {
34
+ values[getZoomPaintOffset(ZOOM_PAINT_WIDTH_ROW, part)] = 1;
35
+ values[getZoomPaintOffset(ZOOM_PAINT_LINE_COLOR_ROW, part) + 3] = -1;
36
+ values[getZoomPaintOffset(ZOOM_PAINT_FILL_COLOR_ROW, part) + 3] = -1;
37
+ }
38
+ return values;
39
+ }
40
+
41
+ /** The index of a style layer's texel in a row of `ZoomPaintValues`. */
42
+ export function getZoomPaintOffset(row: number, part: number): number {
43
+ return (row * MAX_ZOOM_PAINT_PARTS + part) * 4;
44
+ }
45
+
46
+ /**
47
+ * The paint of the style layers drawn by a layer, and the texture that hands it to the shaders,
48
+ * which `destroy` releases.
49
+ */
50
+ export class ZoomPaint {
51
+ private texture: Texture | null = null;
52
+ private written: ZoomPaintValues | null = null;
53
+
54
+ /** `getValues` returns the values at the current zoom, the same array while they are unchanged. */
55
+ constructor(readonly getValues: () => ZoomPaintValues) {}
56
+
57
+ /** The texture of the current values, created on first use. */
58
+ getTexture(device: Device): Texture {
59
+ if (this.texture?.device !== device) {
60
+ this.destroy();
61
+ this.texture = device.createTexture({
62
+ format: 'rgba32float',
63
+ width: MAX_ZOOM_PAINT_PARTS,
64
+ height: ZOOM_PAINT_ROWS,
65
+ mipLevels: 1,
66
+ // Float textures cannot be filtered without an extension; the shader reads exact texels.
67
+ sampler: {minFilter: 'nearest', magFilter: 'nearest'}
68
+ });
69
+ }
70
+ // Layers that share the texture share the values, so it is written once per change.
71
+ const values = this.getValues();
72
+ if (this.written !== values) {
73
+ this.texture.writeData(values);
74
+ this.written = values;
75
+ }
76
+ return this.texture;
77
+ }
78
+
79
+ destroy(): void {
80
+ this.texture?.destroy();
81
+ this.texture = null;
82
+ this.written = null;
83
+ }
84
+ }
85
+
86
+ export type ZoomPaintExtensionProps = {
87
+ /** The paint to apply; the layer draws its attributes as they are without it. */
88
+ zoomPaint?: ZoomPaint | null;
89
+ /** Which of `zoomPaint`'s style layers each object belongs to. */
90
+ getStylePart?: Accessor<any, number>;
91
+ };
92
+
93
+ const zoomPaintModule = {
94
+ name: 'zoomPaint',
95
+ vs: /* glsl */ `\
96
+ uniform highp sampler2D zoomPaintTexture;
97
+ `
98
+ } as const;
99
+
100
+ const DECLARATIONS = /* glsl */ `
101
+ in float instanceStyleParts;
102
+
103
+ vec4 zoomPaint_get(int row) {
104
+ return texelFetch(zoomPaintTexture, ivec2(int(instanceStyleParts + 0.5), row), 0);
105
+ }
106
+
107
+ vec4 zoomPaint_color(vec4 color, int row) {
108
+ vec4 partColor = zoomPaint_get(row);
109
+ return partColor.a < 0.0 ? color : vec4(partColor.rgb, partColor.a * layer.opacity);
110
+ }
111
+ `;
112
+
113
+ /** `PathLayer`: widths and line colors. */
114
+ const PATH_INJECTION = {
115
+ 'vs:#decl': DECLARATIONS,
116
+ 'vs:DECKGL_FILTER_SIZE': /* glsl */ `
117
+ size *= zoomPaint_get(${ZOOM_PAINT_WIDTH_ROW}).r;
118
+ `,
119
+ 'vs:DECKGL_FILTER_COLOR': /* glsl */ `
120
+ color = zoomPaint_color(color, ${ZOOM_PAINT_LINE_COLOR_ROW});
121
+ `
122
+ };
123
+
124
+ /** `SolidPolygonLayer`: fill colors. Not for extruded polygons, whose colors are lit. */
125
+ const POLYGON_INJECTION = {
126
+ 'vs:#decl': DECLARATIONS,
127
+ 'vs:DECKGL_FILTER_COLOR': /* glsl */ `
128
+ color = zoomPaint_color(color, ${ZOOM_PAINT_FILL_COLOR_ROW});
129
+ `
130
+ };
131
+
132
+ function getLayerType(layer: Layer): 'path' | 'polygon' | null {
133
+ const state = layer.state as Record<string, unknown> | undefined;
134
+ if (state && 'pathTesselator' in state) {
135
+ return 'path';
136
+ }
137
+ if (state && 'polygonTesselator' in state) {
138
+ return 'polygon';
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /** The texture last handed to each model's shaders. */
144
+ const boundTextures = new WeakMap<object, Texture>();
145
+
146
+ /**
147
+ * Takes the zoom-dependent paint of the style layers a `PathLayer` or flat `SolidPolygonLayer`
148
+ * draws from a texture instead of attributes: per style layer, its line width and, when they are
149
+ * the same for all of its features, its colors. The values are read when the layer draws, so a
150
+ * zoom step changes a small texture, shared by every layer with the same `zoomPaint`: the layers'
151
+ * props, and so their attributes, stay the same. Each object's style layer is an attribute, which
152
+ * changes only with the data.
153
+ *
154
+ * The width factor applies after `widthMinPixels` and `widthMaxPixels`, which must not clamp.
155
+ * The owner of a `ZoomPaint` destroys its texture. WebGL only.
156
+ */
157
+ export class ZoomPaintExtension extends LayerExtension {
158
+ static extensionName = 'ZoomPaintExtension';
159
+ static defaultProps = {
160
+ zoomPaint: {type: 'object', value: null},
161
+ getStylePart: {type: 'accessor', value: 0}
162
+ };
163
+
164
+ isEnabled(layer: Layer): boolean {
165
+ return getLayerType(layer) !== null;
166
+ }
167
+
168
+ getShaders(this: Layer<ZoomPaintExtensionProps>, extension: this): any {
169
+ const type = getLayerType(this);
170
+ if (!type) {
171
+ return null;
172
+ }
173
+ return {
174
+ modules: [zoomPaintModule],
175
+ inject: type === 'path' ? PATH_INJECTION : POLYGON_INJECTION
176
+ };
177
+ }
178
+
179
+ initializeState(this: Layer<ZoomPaintExtensionProps>, context: LayerContext, extension: this) {
180
+ if (!extension.isEnabled(this)) {
181
+ return;
182
+ }
183
+ this.getAttributeManager()?.add({
184
+ instanceStyleParts: {
185
+ size: 1,
186
+ stepMode: 'dynamic',
187
+ accessor: 'getStylePart',
188
+ defaultValue: 0
189
+ }
190
+ });
191
+ }
192
+
193
+ draw(this: Layer<ZoomPaintExtensionProps>, params: any, extension: this) {
194
+ const {zoomPaint} = this.props;
195
+ if (!zoomPaint || !extension.isEnabled(this)) {
196
+ return;
197
+ }
198
+ const texture = zoomPaint.getTexture(this.context.device);
199
+ const models = this.getModels();
200
+ if (models.some(model => boundTextures.get(model) !== texture)) {
201
+ this.setShaderModuleProps({zoomPaint: {zoomPaintTexture: texture}} as any);
202
+ for (const model of models) {
203
+ boundTextures.set(model, texture);
204
+ }
205
+ }
206
+ }
207
+ }