@geolibre/core 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1822 +0,0 @@
1
- import { Color, createExpression } from "@maplibre/maplibre-gl-style-spec";
2
- //#region src/ellipsoids.ts
3
- /**
4
- * Built-in ellipsoids. Earth is WGS 84; every other body uses IAU-adopted
5
- * figures. Only Earth and Mars are appreciably oblate; the rest are modelled as
6
- * spheres (`inverseFlattening: 0`), which is how the IAU/USGS simple-cylindrical
7
- * products treat them and is well within the accuracy MapLibre's unit-sphere
8
- * rendering can represent anyway.
9
- */
10
- const ELLIPSOIDS = [
11
- {
12
- id: "earth",
13
- name: "Earth (WGS 84)",
14
- semiMajorAxisMeters: 6378137,
15
- inverseFlattening: 298.257223563
16
- },
17
- {
18
- id: "moon",
19
- name: "Moon",
20
- semiMajorAxisMeters: 1737400,
21
- inverseFlattening: 0
22
- },
23
- {
24
- id: "mars",
25
- name: "Mars (IAU 2000)",
26
- semiMajorAxisMeters: 3396190,
27
- inverseFlattening: 169.894447
28
- },
29
- {
30
- id: "mercury",
31
- name: "Mercury",
32
- semiMajorAxisMeters: 2439400,
33
- inverseFlattening: 0
34
- },
35
- {
36
- id: "venus",
37
- name: "Venus",
38
- semiMajorAxisMeters: 6051800,
39
- inverseFlattening: 0
40
- },
41
- {
42
- id: "io",
43
- name: "Io",
44
- semiMajorAxisMeters: 1821600,
45
- inverseFlattening: 0
46
- },
47
- {
48
- id: "europa",
49
- name: "Europa",
50
- semiMajorAxisMeters: 1560800,
51
- inverseFlattening: 0
52
- },
53
- {
54
- id: "ganymede",
55
- name: "Ganymede",
56
- semiMajorAxisMeters: 2631200,
57
- inverseFlattening: 0
58
- },
59
- {
60
- id: "callisto",
61
- name: "Callisto",
62
- semiMajorAxisMeters: 2410300,
63
- inverseFlattening: 0
64
- },
65
- {
66
- id: "titan",
67
- name: "Titan",
68
- semiMajorAxisMeters: 2574730,
69
- inverseFlattening: 0
70
- },
71
- {
72
- id: "pluto",
73
- name: "Pluto",
74
- semiMajorAxisMeters: 1188300,
75
- inverseFlattening: 0
76
- },
77
- {
78
- id: "charon",
79
- name: "Charon",
80
- semiMajorAxisMeters: 606e3,
81
- inverseFlattening: 0
82
- }
83
- ];
84
- const DEFAULT_ELLIPSOID_ID = "earth";
85
- /** Look an ellipsoid up by id, falling back to Earth for unknown ids. */
86
- function getEllipsoid(id) {
87
- return ELLIPSOIDS.find((e) => e.id === id) ?? ELLIPSOIDS.find((e) => e.id === "earth");
88
- }
89
- /**
90
- * Mean radius `R = (2a + b) / 3` in metres, where `b` is the polar radius
91
- * derived from the inverse flattening. This is the radius used for spherical
92
- * (haversine) distance and area math.
93
- */
94
- function meanRadiusMeters(ellipsoid) {
95
- const a = ellipsoid.semiMajorAxisMeters;
96
- if (!ellipsoid.inverseFlattening) return a;
97
- const b = a * (1 - 1 / ellipsoid.inverseFlattening);
98
- return (2 * a + b) / 3;
99
- }
100
- /** Earth's mean radius in metres — the radius Turf.js and MapLibre bake in. */
101
- const EARTH_MEAN_RADIUS_METERS = meanRadiusMeters(ELLIPSOIDS.find((e) => e.id === "earth"));
102
- let activeEllipsoidId = DEFAULT_ELLIPSOID_ID;
103
- /**
104
- * Point the measurement helpers at a body. Unknown ids fall back to Earth so a
105
- * malformed project can never break measurements. Safe to call on every
106
- * preferences change; it is a cheap assignment.
107
- */
108
- function setActiveEllipsoidId(id) {
109
- activeEllipsoidId = getEllipsoid(id).id;
110
- }
111
- function getActiveEllipsoid() {
112
- return getEllipsoid(activeEllipsoidId);
113
- }
114
- /** Mean radius (metres) of the active body — for haversine distance/area. */
115
- function getActiveMeanRadiusMeters() {
116
- return meanRadiusMeters(getActiveEllipsoid());
117
- }
118
- /** Semi-major axis (metres) of the active body — for its Web-Mercator scale. */
119
- function getActiveSemiMajorAxisMeters() {
120
- return getActiveEllipsoid().semiMajorAxisMeters;
121
- }
122
- /**
123
- * The active body's mean radius as a fraction of Earth's — `R_body / R_earth`,
124
- * and exactly `1` on Earth.
125
- *
126
- * Everything GeoLibre measures ultimately comes from lon/lat *angles*, and the
127
- * only thing that turns an angle into a ground distance is the radius it is
128
- * multiplied by. Turf.js hardcodes Earth's, with no per-call override (that is
129
- * the "Turf is Earth-locked" half of GeoLibre#1128), so rather than patch or
130
- * replace Turf we post-scale what it returns and pre-scale what we hand it. The
131
- * result is exact for a sphere, which is how every non-Earth body GeoLibre
132
- * offers is modelled anyway, and within Mars' 0.6% flattening at worst.
133
- *
134
- * Suggested by @thareUSGS (USGS Astrogeology) on GeoLibre#1128 as the practical
135
- * workaround planetary web maps have long used on Earth-locked engines.
136
- */
137
- function getActiveBodyRadiusRatio() {
138
- return getActiveMeanRadiusMeters() / EARTH_MEAN_RADIUS_METERS;
139
- }
140
- /**
141
- * Convert a length Turf.js measured on Earth into the active body's true ground
142
- * length. A no-op on Earth.
143
- *
144
- * Use on anything Turf *returns* — a distance, a length, a perimeter.
145
- *
146
- * The correction is a dimensionless ratio, so it is unit-agnostic: pass the
147
- * length in whatever unit Turf gave it back (metres, kilometres, miles) and the
148
- * result is in that same unit. There is no hidden unit conversion here.
149
- */
150
- function earthLengthToBody(length) {
151
- return length * getActiveBodyRadiusRatio();
152
- }
153
- /**
154
- * Convert a true ground length on the active body into the Earth-equivalent
155
- * length to hand Turf.js. A no-op on Earth.
156
- *
157
- * Use on anything passed *into* Turf as a distance — a buffer width, a circle
158
- * or sector radius, a search threshold — so the resulting geometry spans that
159
- * distance on this body rather than on Earth.
160
- *
161
- * Unit-agnostic like {@link earthLengthToBody}: pass the length in whatever
162
- * unit you are about to hand Turf alongside it, and the result is in that unit.
163
- */
164
- function bodyLengthToEarth(length) {
165
- return length / getActiveBodyRadiusRatio();
166
- }
167
- /**
168
- * Convert an area Turf.js measured on Earth into the active body's true ground
169
- * area. A no-op on Earth.
170
- *
171
- * Areas scale with the *square* of the radius ratio, so this is not the same
172
- * correction as {@link earthLengthToBody}. Unit-agnostic in the same way, for
173
- * whatever squared unit the area is expressed in.
174
- */
175
- function earthAreaToBody(area) {
176
- const ratio = getActiveBodyRadiusRatio();
177
- return area * ratio * ratio;
178
- }
179
- const PLANETARY_BASEMAP_SENTINEL_PREFIX = "geolibre://basemap/";
180
- const OPM_ATTRIBUTION = "<a href=\"https://www.openplanetary.org/opm\">OpenPlanetaryMap</a>";
181
- /** Data-source credit joined with the OpenPlanetaryMap attribution. */
182
- const opmCredit = (source) => `${source} · ${OPM_ATTRIBUTION}`;
183
- const TILE_PROXY_BASE = "https://tiles.geolibre.app/opm";
184
- const WMS_PROXY_BASE = "https://tiles.geolibre.app/wms";
185
- const USGS_ASTRO_ATTRIBUTION = "<a href=\"https://astrogeology.usgs.gov/\">USGS Astrogeology</a>";
186
- /** Data-source credit joined with the USGS Astrogeology attribution. */
187
- const usgsCredit = (source) => `${source} · ${USGS_ASTRO_ATTRIBUTION}`;
188
- const PLANETARY_BASEMAPS = [
189
- {
190
- id: "mars-colour-mola-elevation",
191
- name: "Colour MOLA Elevation",
192
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}mars-colour-mola-elevation`,
193
- tileUrl: `${TILE_PROXY_BASE}/mars-mola-color-noshade/{z}/{x}/{y}.png`,
194
- scheme: "tms",
195
- maxZoom: 6,
196
- attribution: opmCredit("NASA / MOLA"),
197
- ellipsoidId: "mars"
198
- },
199
- {
200
- id: "mars-viking-mdim21",
201
- name: "Viking MDIM2.1",
202
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}mars-viking-mdim21`,
203
- tileUrl: `${TILE_PROXY_BASE}/mars-viking-mdim21/{z}/{x}/{y}.png`,
204
- scheme: "tms",
205
- maxZoom: 7,
206
- attribution: opmCredit("NASA / Viking / USGS"),
207
- ellipsoidId: "mars"
208
- },
209
- {
210
- id: "mars-hillshade",
211
- name: "Hillshade",
212
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}mars-hillshade`,
213
- tileUrl: `${TILE_PROXY_BASE}/mars-hillshade/{z}/{x}/{y}.png`,
214
- scheme: "tms",
215
- maxZoom: 6,
216
- attribution: opmCredit("NASA / MOLA"),
217
- ellipsoidId: "mars"
218
- },
219
- {
220
- id: "mars-basemap-v0-2",
221
- name: "OPM Basemap v0.2",
222
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}mars-basemap-v0-2`,
223
- tileUrl: "https://cartocdn-gusc.global.ssl.fastly.net/opmbuilder/api/v1/map/named/opm-mars-basemap-v0-2/all/{z}/{x}/{y}.png",
224
- maxZoom: 6,
225
- attribution: OPM_ATTRIBUTION,
226
- ellipsoidId: "mars"
227
- },
228
- {
229
- id: "mars-shaded-colour-mola-elevation",
230
- name: "Shaded Colour MOLA Elevation",
231
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}mars-shaded-colour-mola-elevation`,
232
- tileUrl: `${TILE_PROXY_BASE}/mars-mola-color/{z}/{x}/{y}.png`,
233
- scheme: "tms",
234
- maxZoom: 6,
235
- attribution: opmCredit("NASA / MOLA"),
236
- ellipsoidId: "mars"
237
- },
238
- {
239
- id: "mars-shaded-grayscale-mola-elevation",
240
- name: "Shaded Grayscale MOLA Elevation",
241
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}mars-shaded-grayscale-mola-elevation`,
242
- tileUrl: `${TILE_PROXY_BASE}/mars-mola-gray/{z}/{x}/{y}.png`,
243
- scheme: "tms",
244
- maxZoom: 9,
245
- attribution: opmCredit("NASA / MOLA"),
246
- ellipsoidId: "mars"
247
- },
248
- {
249
- id: "moon-hillshaded-albedo",
250
- name: "Hillshaded Albedo",
251
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}moon-hillshaded-albedo`,
252
- tileUrl: `${TILE_PROXY_BASE}/moon-hillshaded-albedo/{z}/{x}/{y}.png`,
253
- scheme: "tms",
254
- maxZoom: 6,
255
- attribution: opmCredit("NASA / LOLA / USGS"),
256
- ellipsoidId: "moon"
257
- },
258
- {
259
- id: "moon-basemap-v0-1",
260
- name: "OPM Basemap v0.1",
261
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}moon-basemap-v0-1`,
262
- tileUrl: "https://cartocdn-gusc.global.ssl.fastly.net/opmbuilder/api/v1/map/named/opm-moon-basemap-v0-1/all/{z}/{x}/{y}.png",
263
- maxZoom: 6,
264
- attribution: OPM_ATTRIBUTION,
265
- ellipsoidId: "moon"
266
- },
267
- {
268
- id: "mercury-messenger-color",
269
- name: "MESSENGER Colour Mosaic",
270
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}mercury-messenger-color`,
271
- tileUrl: `${WMS_PROXY_BASE}/mercury-messenger-color/{z}/{x}/{y}.png`,
272
- maxZoom: 7,
273
- attribution: usgsCredit("NASA / JHU APL / CIW"),
274
- ellipsoidId: "mercury"
275
- },
276
- {
277
- id: "mercury-messenger",
278
- name: "MESSENGER Basemap",
279
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}mercury-messenger`,
280
- tileUrl: `${WMS_PROXY_BASE}/mercury-messenger/{z}/{x}/{y}.png`,
281
- maxZoom: 7,
282
- attribution: usgsCredit("NASA / JHU APL"),
283
- ellipsoidId: "mercury"
284
- },
285
- {
286
- id: "venus-magellan",
287
- name: "Magellan FMAP",
288
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}venus-magellan`,
289
- tileUrl: `${WMS_PROXY_BASE}/venus-magellan/{z}/{x}/{y}.png`,
290
- maxZoom: 6,
291
- attribution: usgsCredit("NASA / JPL"),
292
- ellipsoidId: "venus"
293
- },
294
- {
295
- id: "venus-magellan-color",
296
- name: "Magellan C3-MDIR Colour",
297
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}venus-magellan-color`,
298
- tileUrl: `${WMS_PROXY_BASE}/venus-magellan-color/{z}/{x}/{y}.png`,
299
- maxZoom: 6,
300
- attribution: usgsCredit("NASA / JPL"),
301
- ellipsoidId: "venus"
302
- },
303
- {
304
- id: "io-galileo-color",
305
- name: "Galileo SSI Colour",
306
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}io-galileo-color`,
307
- tileUrl: `${WMS_PROXY_BASE}/io-galileo-color/{z}/{x}/{y}.png`,
308
- maxZoom: 6,
309
- attribution: usgsCredit("NASA / JPL"),
310
- ellipsoidId: "io"
311
- },
312
- {
313
- id: "europa-galileo-voyager",
314
- name: "Galileo / Voyager",
315
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}europa-galileo-voyager`,
316
- tileUrl: `${WMS_PROXY_BASE}/europa-galileo-voyager/{z}/{x}/{y}.png`,
317
- maxZoom: 6,
318
- attribution: usgsCredit("NASA / JPL"),
319
- ellipsoidId: "europa"
320
- },
321
- {
322
- id: "ganymede-galileo-voyager",
323
- name: "Galileo / Voyager",
324
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}ganymede-galileo-voyager`,
325
- tileUrl: `${WMS_PROXY_BASE}/ganymede-galileo-voyager/{z}/{x}/{y}.png`,
326
- maxZoom: 6,
327
- attribution: usgsCredit("NASA / JPL"),
328
- ellipsoidId: "ganymede"
329
- },
330
- {
331
- id: "callisto-galileo-voyager",
332
- name: "Galileo / Voyager",
333
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}callisto-galileo-voyager`,
334
- tileUrl: `${WMS_PROXY_BASE}/callisto-galileo-voyager/{z}/{x}/{y}.png`,
335
- maxZoom: 6,
336
- attribution: usgsCredit("NASA / JPL"),
337
- ellipsoidId: "callisto"
338
- },
339
- {
340
- id: "titan-cassini",
341
- name: "Cassini ISS Mosaic",
342
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}titan-cassini`,
343
- tileUrl: `${WMS_PROXY_BASE}/titan-cassini/{z}/{x}/{y}.png`,
344
- maxZoom: 6,
345
- attribution: usgsCredit("NASA / JPL / SSI"),
346
- ellipsoidId: "titan"
347
- },
348
- {
349
- id: "titan-hisar",
350
- name: "Cassini HiSAR Mosaic",
351
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}titan-hisar`,
352
- tileUrl: `${WMS_PROXY_BASE}/titan-hisar/{z}/{x}/{y}.png`,
353
- maxZoom: 6,
354
- attribution: usgsCredit("NASA / JPL / USGS"),
355
- ellipsoidId: "titan"
356
- },
357
- {
358
- id: "pluto-mosaic",
359
- name: "New Horizons Mosaic",
360
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}pluto-mosaic`,
361
- tileUrl: `${WMS_PROXY_BASE}/pluto-mosaic/{z}/{x}/{y}.png`,
362
- maxZoom: 7,
363
- attribution: usgsCredit("NASA / JHU APL / SwRI"),
364
- ellipsoidId: "pluto"
365
- },
366
- {
367
- id: "pluto-color",
368
- name: "New Horizons Colour Shaded Relief",
369
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}pluto-color`,
370
- tileUrl: `${WMS_PROXY_BASE}/pluto-color/{z}/{x}/{y}.png`,
371
- maxZoom: 7,
372
- attribution: usgsCredit("NASA / JHU APL / SwRI"),
373
- ellipsoidId: "pluto"
374
- },
375
- {
376
- id: "charon-mosaic",
377
- name: "New Horizons Mosaic",
378
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}charon-mosaic`,
379
- tileUrl: `${WMS_PROXY_BASE}/charon-mosaic/{z}/{x}/{y}.png`,
380
- maxZoom: 6,
381
- attribution: usgsCredit("NASA / JHU APL / SwRI"),
382
- ellipsoidId: "charon"
383
- },
384
- {
385
- id: "earth-usgs-imagery",
386
- name: "USGS Imagery",
387
- styleUrl: `${PLANETARY_BASEMAP_SENTINEL_PREFIX}earth-usgs-imagery`,
388
- tileUrl: "https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}",
389
- maxZoom: 16,
390
- attribution: "<a href=\"https://www.usgs.gov/programs/national-geospatial-program/national-map\">USGS The National Map</a>",
391
- ellipsoidId: "earth"
392
- }
393
- ];
394
- const DEDICATED_SECTION_BODIES = ["moon", "mars"];
395
- /**
396
- * {@link PLANETARY_BASEMAPS} grouped for the pickers: one section each for the
397
- * Moon and Mars, then a single "Other celestial bodies" section for the rest,
398
- * in {@link PLANETARY_BASEMAPS} order (grouped by body). Empty sections are
399
- * dropped.
400
- */
401
- const PLANETARY_BASEMAP_GROUPS = [
402
- {
403
- id: "moon",
404
- ellipsoidId: "moon"
405
- },
406
- {
407
- id: "mars",
408
- ellipsoidId: "mars"
409
- },
410
- {
411
- id: "other",
412
- ellipsoidId: null
413
- }
414
- ].map(({ id, ellipsoidId }) => {
415
- const basemaps = PLANETARY_BASEMAPS.filter((b) => ellipsoidId ? b.ellipsoidId === ellipsoidId : b.ellipsoidId !== "earth" && !DEDICATED_SECTION_BODIES.includes(b.ellipsoidId));
416
- return {
417
- id,
418
- basemaps: id === "other" ? [...basemaps].sort((a, b) => getEllipsoid(a.ellipsoidId).name.localeCompare(getEllipsoid(b.ellipsoidId).name)) : basemaps
419
- };
420
- }).filter((group) => group.basemaps.length > 0);
421
- const LEGACY_PLANETARY_BASEMAP_IDS = {
422
- "mars-opm": "mars-basemap-v0-2",
423
- "moon-opm": "moon-basemap-v0-1",
424
- "mars-viking": "mars-viking-mdim21",
425
- "moon-lroc": "moon-basemap-v0-1"
426
- };
427
- /** Resolve a `geolibre://basemap/<id>` sentinel to its planetary basemap. */
428
- function getPlanetaryBasemapByStyleUrl(styleUrl) {
429
- if (!styleUrl?.startsWith("geolibre://basemap/")) return void 0;
430
- const id = styleUrl.slice(19);
431
- const resolvedId = Object.prototype.hasOwnProperty.call(LEGACY_PLANETARY_BASEMAP_IDS, id) ? LEGACY_PLANETARY_BASEMAP_IDS[id] : id;
432
- return PLANETARY_BASEMAPS.find((b) => b.id === resolvedId);
433
- }
434
- /** Look a planetary basemap up by its stable id. */
435
- function getPlanetaryBasemapById(id) {
436
- return PLANETARY_BASEMAPS.find((b) => b.id === id);
437
- }
438
- /**
439
- * The celestial bodies offered by the Layers-panel planet switcher, in menu
440
- * order (like Google Earth's planet dropdown). Each body is paired with the
441
- * basemap the switcher applies for it — USGS satellite imagery for Earth, the
442
- * OpenPlanetaryMap Hillshaded Albedo mosaic for the Moon, and the OpenPlanetaryMap
443
- * Viking mosaic for Mars.
444
- */
445
- const PLANET_SWITCHER_OPTIONS = [
446
- {
447
- ellipsoidId: "earth",
448
- basemapId: "earth-usgs-imagery"
449
- },
450
- {
451
- ellipsoidId: "moon",
452
- basemapId: "moon-hillshaded-albedo"
453
- },
454
- {
455
- ellipsoidId: "mars",
456
- basemapId: "mars-viking-mdim21"
457
- },
458
- {
459
- ellipsoidId: "callisto",
460
- basemapId: "callisto-galileo-voyager"
461
- },
462
- {
463
- ellipsoidId: "charon",
464
- basemapId: "charon-mosaic"
465
- },
466
- {
467
- ellipsoidId: "europa",
468
- basemapId: "europa-galileo-voyager"
469
- },
470
- {
471
- ellipsoidId: "ganymede",
472
- basemapId: "ganymede-galileo-voyager"
473
- },
474
- {
475
- ellipsoidId: "io",
476
- basemapId: "io-galileo-color"
477
- },
478
- {
479
- ellipsoidId: "mercury",
480
- basemapId: "mercury-messenger-color"
481
- },
482
- {
483
- ellipsoidId: "pluto",
484
- basemapId: "pluto-mosaic"
485
- },
486
- {
487
- ellipsoidId: "titan",
488
- basemapId: "titan-cassini"
489
- },
490
- {
491
- ellipsoidId: "venus",
492
- basemapId: "venus-magellan-color"
493
- }
494
- ];
495
- //#endregion
496
- //#region src/geojson-z.ts
497
- /**
498
- * Helpers for vector data whose coordinates carry a Z value (elevation), e.g.
499
- * GPX tracks with `<ele>` readings or LineStringZ/PointZ geometries. MapLibre's
500
- * 2D style layers ignore the third coordinate, so layers that want to render
501
- * their Z values use these helpers to detect and rescale elevations before
502
- * handing the data to a deck.gl overlay. The same data can carry its elevation
503
- * in a `bbox` member too, which `horizontalBbox` trims back to two dimensions.
504
- */
505
- /**
506
- * Reduces a bounding box to its horizontal extent, `[west, south, east, north]`.
507
- *
508
- * RFC 7946 §5 lets a `bbox` member carry elevation, in which case it holds six
509
- * values — `[west, south, minAltitude, east, north, maxAltitude]` — and
510
- * `@turf/bbox` returns a collection's own member verbatim rather than
511
- * recomputing it. A consumer reading such a box as four values takes the
512
- * altitude for a longitude and the east edge for a latitude; that is how a 3D
513
- * feed reached MapLibre as an out-of-range latitude (#2358).
514
- *
515
- * Returns null when there is no usable horizontal extent: a box of any other
516
- * length, or one carrying a non-finite value anywhere — an empty collection, or
517
- * one whose features all have a null geometry, bboxes to ±Infinity, and a box
518
- * whose altitudes are not finite is not one to trust the rest of.
519
- *
520
- * @param box - A GeoJSON or Turf bounding box, of four or six values.
521
- */
522
- function horizontalBbox(box) {
523
- if (!box) return null;
524
- if (box.length !== 4 && box.length !== 6) return null;
525
- if (!box.every((value) => Number.isFinite(value))) return null;
526
- return box.length === 6 ? [
527
- box[0],
528
- box[1],
529
- box[3],
530
- box[4]
531
- ] : [
532
- box[0],
533
- box[1],
534
- box[2],
535
- box[3]
536
- ];
537
- }
538
- const hasZCache = /* @__PURE__ */ new WeakMap();
539
- const hasFiniteZCache = /* @__PURE__ */ new WeakMap();
540
- /**
541
- * Returns true when any coordinate in the GeoJSON carries a finite, non-zero
542
- * Z value. All-zero Z arrays are treated as flat data because rendering them
543
- * "in 3D" would be indistinguishable from the 2D result. The result is
544
- * cached per GeoJSON object, so repeated calls on the same data are free.
545
- *
546
- * @param geojson - Any GeoJSON object (geometry, feature, or collection).
547
- * @param includeZero - Accept zero elevations when explicit absolute-height placement matters.
548
- */
549
- function geojsonHasZCoordinates(geojson, includeZero = false) {
550
- if (!geojson) return false;
551
- const cache = includeZero ? hasFiniteZCache : hasZCache;
552
- const cached = cache.get(geojson);
553
- if (cached !== void 0) return cached;
554
- const result = someGeometry(geojson, (geometry) => somePosition(geometry, (position) => {
555
- const z = position[2];
556
- return typeof z === "number" && Number.isFinite(z) && (includeZero || z !== 0);
557
- }));
558
- cache.set(geojson, result);
559
- return result;
560
- }
561
- /**
562
- * Returns a copy of the GeoJSON with every coordinate's Z value mapped to
563
- * `z * verticalScale + offset` (missing or non-finite Z treated as 0). Used
564
- * to apply vertical exaggeration and a constant altitude offset before 3D
565
- * rendering. The identity transform (`verticalScale === 1 && offset === 0`)
566
- * still produces a copy so the non-finite-Z sanitization applies on every
567
- * path — WebGL does not handle NaN positions gracefully.
568
- *
569
- * @param geojson - The feature collection to transform.
570
- * @param verticalScale - Multiplier applied to each Z value.
571
- * @param offset - Constant altitude in meters added after scaling.
572
- */
573
- function transformGeojsonElevation(geojson, verticalScale, offset) {
574
- const mapPosition = (position) => [
575
- position[0],
576
- position[1],
577
- (typeof position[2] === "number" && Number.isFinite(position[2]) ? position[2] : 0) * verticalScale + offset
578
- ];
579
- return {
580
- ...geojson,
581
- features: geojson.features.map((feature) => ({
582
- ...feature,
583
- geometry: mapGeometryPositions(feature.geometry, mapPosition)
584
- }))
585
- };
586
- }
587
- function someGeometry(geojson, predicate) {
588
- switch (geojson.type) {
589
- case "FeatureCollection": return geojson.features.some((feature) => feature.geometry ? someGeometry(feature.geometry, predicate) : false);
590
- case "Feature": return geojson.geometry ? someGeometry(geojson.geometry, predicate) : false;
591
- case "GeometryCollection": return geojson.geometries.some((geometry) => someGeometry(geometry, predicate));
592
- default: return predicate(geojson);
593
- }
594
- }
595
- function somePosition(geometry, predicate) {
596
- switch (geometry.type) {
597
- case "Point": return predicate(geometry.coordinates);
598
- case "MultiPoint":
599
- case "LineString": return geometry.coordinates.some(predicate);
600
- case "MultiLineString":
601
- case "Polygon": return geometry.coordinates.some((ring) => ring.some(predicate));
602
- case "MultiPolygon": return geometry.coordinates.some((polygon) => polygon.some((ring) => ring.some(predicate)));
603
- case "GeometryCollection": return geometry.geometries.some((child) => somePosition(child, predicate));
604
- default: return false;
605
- }
606
- }
607
- function mapGeometryPositions(geometry, mapPosition) {
608
- if (!geometry) return geometry;
609
- switch (geometry.type) {
610
- case "Point": return {
611
- ...geometry,
612
- coordinates: mapPosition(geometry.coordinates)
613
- };
614
- case "MultiPoint":
615
- case "LineString": return {
616
- ...geometry,
617
- coordinates: geometry.coordinates.map(mapPosition)
618
- };
619
- case "MultiLineString":
620
- case "Polygon": return {
621
- ...geometry,
622
- coordinates: geometry.coordinates.map((ring) => ring.map(mapPosition))
623
- };
624
- case "MultiPolygon": return {
625
- ...geometry,
626
- coordinates: geometry.coordinates.map((polygon) => polygon.map((ring) => ring.map(mapPosition)))
627
- };
628
- case "GeometryCollection": return {
629
- ...geometry,
630
- geometries: geometry.geometries.map((child) => mapGeometryPositions(child, mapPosition))
631
- };
632
- default: return geometry;
633
- }
634
- }
635
- //#endregion
636
- //#region src/polyline.ts
637
- /**
638
- * Pure TypeScript Google Encoded Polyline algorithm codec.
639
- *
640
- * Supports arbitrary precision:
641
- * - precision = 5: Google Maps standard, OSRM, HERE (factor 1e5, ~1.1m precision)
642
- * - precision = 6: Valhalla, Mapbox, Open Source Routing Machine 6 (factor 1e6, ~0.11m precision)
643
- *
644
- * Coordinates are represented as `[lon, lat]` pairs in GeoJSON format.
645
- */
646
- /**
647
- * Unescapes backslashes in a polyline string.
648
- *
649
- * When polyline strings are copied from JSON payloads, log files, or source code,
650
- * backslashes are frequently double-escaped as `\\` or formatted with escape sequences.
651
- *
652
- * @param str - Input string with potentially escaped backslashes.
653
- * @returns Cleaned polyline string.
654
- */
655
- function unescapePolyline(str) {
656
- if (!str || typeof str !== "string") return "";
657
- return str.replace(/\\\\/g, "\\").replace(/\\"/g, "\"").replace(/\\'/g, "'");
658
- }
659
- /**
660
- * Decodes an encoded polyline string into an array of `[lon, lat]` coordinates,
661
- * along with a `complete` flag indicating whether the entire string was consumed
662
- * as complete coordinate pairs without truncation or malformed characters.
663
- *
664
- * @param encoded - The ASCII-encoded polyline string.
665
- * @param precision - Number of decimal digits (default: 5).
666
- * @param unescape - Whether to unescape double-escaped backslashes before decoding (default: false).
667
- * @returns Object with decoded `coordinates` and `complete` boolean status.
668
- */
669
- function decodePolylineDetailed(encoded, precision = 5, unescape = false) {
670
- if (!encoded || typeof encoded !== "string") return {
671
- coordinates: [],
672
- complete: true
673
- };
674
- const cleanEncoded = unescape ? unescapePolyline(encoded) : encoded;
675
- const factor = 10 ** precision;
676
- const len = cleanEncoded.length;
677
- const coordinates = [];
678
- let index = 0;
679
- let lat = 0;
680
- let lon = 0;
681
- while (index < len) {
682
- let shift = 0;
683
- let result = 0;
684
- let byte;
685
- let latComplete = false;
686
- do {
687
- if (index >= len) return {
688
- coordinates,
689
- complete: false
690
- };
691
- byte = cleanEncoded.charCodeAt(index++) - 63;
692
- if (byte < 0 || byte > 63) return {
693
- coordinates,
694
- complete: false
695
- };
696
- if (shift > 30 || shift === 30 && (byte & 31) > 3) return {
697
- coordinates,
698
- complete: false
699
- };
700
- result |= (byte & 31) << shift;
701
- shift += 5;
702
- if (byte < 32) {
703
- latComplete = true;
704
- break;
705
- }
706
- } while (byte >= 32);
707
- if (!latComplete) return {
708
- coordinates,
709
- complete: false
710
- };
711
- lat += result & 1 ? ~(result >>> 1) : result >>> 1;
712
- shift = 0;
713
- result = 0;
714
- let lonComplete = false;
715
- do {
716
- if (index >= len) return {
717
- coordinates,
718
- complete: false
719
- };
720
- byte = cleanEncoded.charCodeAt(index++) - 63;
721
- if (byte < 0 || byte > 63) return {
722
- coordinates,
723
- complete: false
724
- };
725
- if (shift > 30 || shift === 30 && (byte & 31) > 3) return {
726
- coordinates,
727
- complete: false
728
- };
729
- result |= (byte & 31) << shift;
730
- shift += 5;
731
- if (byte < 32) {
732
- lonComplete = true;
733
- break;
734
- }
735
- } while (byte >= 32);
736
- if (!lonComplete) return {
737
- coordinates,
738
- complete: false
739
- };
740
- lon += result & 1 ? ~(result >>> 1) : result >>> 1;
741
- coordinates.push([lon / factor, lat / factor]);
742
- }
743
- return {
744
- coordinates,
745
- complete: true
746
- };
747
- }
748
- /**
749
- * Decodes an encoded polyline string into an array of `[lon, lat]` coordinates.
750
- *
751
- * @param encoded - The ASCII-encoded polyline string.
752
- * @param precision - Number of decimal digits (default: 5).
753
- * @param unescape - Whether to unescape double-escaped backslashes before decoding (default: false).
754
- * @returns Array of `[lon, lat]` coordinate pairs in GeoJSON order.
755
- */
756
- function decodePolyline(encoded, precision = 5, unescape = false) {
757
- return decodePolylineDetailed(encoded, precision, unescape).coordinates;
758
- }
759
- /**
760
- * Encodes a single integer delta value into polyline character chunks.
761
- */
762
- function encodeSignedNumber(num) {
763
- let sgnNum = num < 0 ? ~(num << 1) : num << 1;
764
- let out = "";
765
- while (sgnNum >= 32) {
766
- out += String.fromCharCode((32 | sgnNum & 31) + 63);
767
- sgnNum >>>= 5;
768
- }
769
- out += String.fromCharCode(sgnNum + 63);
770
- return out;
771
- }
772
- /**
773
- * Encodes an array of `[lon, lat]` coordinates into a Google Encoded Polyline string.
774
- *
775
- * @param coordinates - Array of `[lon, lat]` coordinate pairs.
776
- * @param precision - Number of decimal digits (default: 5).
777
- * @returns The encoded polyline string.
778
- */
779
- function encodePolyline(coordinates, precision = 5) {
780
- if (!Array.isArray(coordinates) || coordinates.length === 0) return "";
781
- const factor = 10 ** precision;
782
- let output = "";
783
- let prevLat = 0;
784
- let prevLon = 0;
785
- for (const [lon, lat] of coordinates) {
786
- if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
787
- const roundLat = Math.round(lat * factor);
788
- const roundLon = Math.round(lon * factor);
789
- const deltaLat = roundLat - prevLat;
790
- const deltaLon = roundLon - prevLon;
791
- output += encodeSignedNumber(deltaLat);
792
- output += encodeSignedNumber(deltaLon);
793
- prevLat = roundLat;
794
- prevLon = roundLon;
795
- }
796
- return output;
797
- }
798
- /**
799
- * Decodes an encoded polyline string into a GeoJSON `Feature<LineString>`.
800
- *
801
- * @param encoded - The polyline string.
802
- * @param precision - Coordinate precision (default: 5).
803
- * @param properties - Optional feature properties.
804
- * @returns A GeoJSON LineString Feature.
805
- */
806
- function polylineStrToGeoJSON(encoded, precision = 5, properties = {}) {
807
- return {
808
- type: "Feature",
809
- properties: { ...properties },
810
- geometry: {
811
- type: "LineString",
812
- coordinates: decodePolyline(encoded, precision)
813
- }
814
- };
815
- }
816
- /**
817
- * Encodes a GeoJSON LineString, MultiLineString, Feature, or FeatureCollection into encoded polyline string(s).
818
- *
819
- * @param geojson - The GeoJSON input geometry or feature.
820
- * @param precision - Coordinate precision (default: 5).
821
- * @returns An encoded polyline string for LineString, or an array of strings for MultiLineString / FeatureCollection.
822
- */
823
- function geoJSONToPolylineStr(geojson, precision = 5) {
824
- if (!geojson) return "";
825
- if (geojson.type === "Feature") return geoJSONToPolylineStr(geojson.geometry, precision);
826
- if (geojson.type === "FeatureCollection") {
827
- const results = [];
828
- for (const feature of geojson.features) {
829
- const res = geoJSONToPolylineStr(feature.geometry, precision);
830
- if (Array.isArray(res)) results.push(...res);
831
- else if (res) results.push(res);
832
- }
833
- return results;
834
- }
835
- if (geojson.type === "LineString") return encodePolyline(geojson.coordinates, precision);
836
- if (geojson.type === "MultiLineString") return geojson.coordinates.map((lineCoords) => encodePolyline(lineCoords, precision));
837
- return "";
838
- }
839
- /**
840
- * Parses and batch-decodes multiple polylines from a text block or file content.
841
- *
842
- * @param input - Multiline string or delimited text containing encoded polylines.
843
- * @param options - Decoding and parsing configuration.
844
- * @returns A FeatureCollection containing LineString (or MultiLineString) features.
845
- */
846
- function batchDecodePolylines(input, options = {}) {
847
- if (!input || typeof input !== "string") return {
848
- type: "FeatureCollection",
849
- features: []
850
- };
851
- const { precision = 5, unescape = true, delimiter = /\r?\n/, asMultiLine = false, baseProperties = {} } = options;
852
- const rawChunks = input.split(delimiter);
853
- const validLineCoords = [];
854
- const features = [];
855
- for (let i = 0; i < rawChunks.length; i++) {
856
- const raw = rawChunks[i].trim();
857
- if (!raw) continue;
858
- const cleaned = raw.replace(/^["']|["']$/g, "").trim();
859
- if (!cleaned) continue;
860
- const decodeResult = decodePolylineDetailed(cleaned, precision, unescape);
861
- if (decodeResult.complete && decodeResult.coordinates.length >= 2) {
862
- const coords = decodeResult.coordinates;
863
- validLineCoords.push(coords);
864
- features.push({
865
- type: "Feature",
866
- properties: {
867
- ...baseProperties,
868
- line_index: features.length + 1,
869
- point_count: coords.length
870
- },
871
- geometry: {
872
- type: "LineString",
873
- coordinates: coords
874
- }
875
- });
876
- }
877
- }
878
- if (asMultiLine && validLineCoords.length > 0) return {
879
- type: "FeatureCollection",
880
- features: [{
881
- type: "Feature",
882
- properties: {
883
- ...baseProperties,
884
- line_count: validLineCoords.length,
885
- point_count: validLineCoords.reduce((acc, c) => acc + c.length, 0)
886
- },
887
- geometry: {
888
- type: "MultiLineString",
889
- coordinates: validLineCoords
890
- }
891
- }]
892
- };
893
- return {
894
- type: "FeatureCollection",
895
- features
896
- };
897
- }
898
- //#endregion
899
- //#region src/selection.ts
900
- /** Selection modes in display order. */
901
- const SELECTION_MODES = [
902
- "new",
903
- "add",
904
- "remove",
905
- "intersect"
906
- ];
907
- /**
908
- * The id under which a feature participates in the selection model. Mirrors
909
- * the convention shared by the attribute table and the map highlight
910
- * (`String(feature.id ?? index)`): the feature's own `id` when present,
911
- * otherwise its index in the layer's feature array.
912
- */
913
- function featureSelectionId(feature, index) {
914
- return String(feature.id ?? index);
915
- }
916
- /**
917
- * Combines the current selection with a freshly matched id set according to
918
- * `mode`. The result is deduplicated and order-stable: "new" follows the
919
- * matched order, "add" appends unseen matches after the current ids, and
920
- * "remove"/"intersect" preserve the current selection's order.
921
- */
922
- function applySelectionMode(current, matched, mode) {
923
- const matchedSet = new Set(matched);
924
- switch (mode) {
925
- case "new": return Array.from(matchedSet);
926
- case "add": {
927
- const merged = new Set(current);
928
- for (const id of matched) merged.add(id);
929
- return Array.from(merged);
930
- }
931
- case "remove": return Array.from(new Set(current.filter((id) => !matchedSet.has(id))));
932
- case "intersect": return Array.from(new Set(current.filter((id) => matchedSet.has(id))));
933
- }
934
- }
935
- /**
936
- * The complement of `current` within `allIds` (QGIS "Invert selection"):
937
- * every feature id not currently selected, in layer order. With an empty
938
- * selection this selects everything.
939
- */
940
- function invertSelection(allIds, current) {
941
- const selected = new Set(current);
942
- return allIds.filter((id) => !selected.has(id));
943
- }
944
- //#endregion
945
- //#region src/expressions.ts
946
- const fn = (name, snippet, docKey) => ({
947
- name,
948
- snippet,
949
- docKey
950
- });
951
- /**
952
- * The curated function reference, grouped by category. Snippets are valid,
953
- * standalone-compilable MapLibre expressions (enforced by tests) so inserting
954
- * one into an empty editor always yields a working starting point.
955
- */
956
- const EXPRESSION_FUNCTION_CATEGORIES = [
957
- {
958
- key: "data",
959
- functions: [
960
- fn("get", "[\"get\", \"field\"]", "get"),
961
- fn("has", "[\"has\", \"field\"]", "has"),
962
- fn("id", "[\"id\"]", "id"),
963
- fn("properties", "[\"properties\"]", "properties"),
964
- fn("geometry-type", "[\"geometry-type\"]", "geometryType"),
965
- fn("at", "[\"at\", 0, [\"literal\", [1, 2, 3]]]", "at"),
966
- fn("in", "[\"in\", \"value\", [\"get\", \"field\"]]", "in"),
967
- fn("index-of", "[\"index-of\", \"value\", [\"get\", \"field\"]]", "indexOf"),
968
- fn("length", "[\"length\", [\"get\", \"field\"]]", "length"),
969
- fn("literal", "[\"literal\", [\"a\", \"b\"]]", "literal"),
970
- fn("typeof", "[\"typeof\", [\"get\", \"field\"]]", "typeofOp")
971
- ]
972
- },
973
- {
974
- key: "decision",
975
- functions: [
976
- fn("case", "[\"case\", [\"==\", [\"get\", \"field\"], \"value\"], \"#ff0000\", \"#cccccc\"]", "caseOp"),
977
- fn("match", "[\"match\", [\"get\", \"field\"], \"a\", \"#2563eb\", \"b\", \"#16a34a\", \"#94a3b8\"]", "match"),
978
- fn("coalesce", "[\"coalesce\", [\"get\", \"field\"], \"fallback\"]", "coalesce"),
979
- fn("==", "[\"==\", [\"get\", \"field\"], \"value\"]", "eq"),
980
- fn("!=", "[\"!=\", [\"get\", \"field\"], \"value\"]", "neq"),
981
- fn(">", "[\">\", [\"get\", \"field\"], 0]", "gt"),
982
- fn(">=", "[\">=\", [\"get\", \"field\"], 0]", "gte"),
983
- fn("<", "[\"<\", [\"get\", \"field\"], 0]", "lt"),
984
- fn("<=", "[\"<=\", [\"get\", \"field\"], 0]", "lte"),
985
- fn("all", "[\"all\", [\">\", [\"get\", \"field\"], 0], [\"<\", [\"get\", \"field\"], 100]]", "all"),
986
- fn("any", "[\"any\", [\"==\", [\"get\", \"field\"], \"a\"], [\"==\", [\"get\", \"field\"], \"b\"]]", "any"),
987
- fn("!", "[\"!\", [\"has\", \"field\"]]", "not")
988
- ]
989
- },
990
- {
991
- key: "math",
992
- functions: [
993
- fn("+", "[\"+\", [\"get\", \"field\"], 1]", "add"),
994
- fn("-", "[\"-\", [\"get\", \"field\"], 1]", "subtract"),
995
- fn("*", "[\"*\", [\"get\", \"field\"], 2]", "multiply"),
996
- fn("/", "[\"/\", [\"get\", \"field\"], 2]", "divide"),
997
- fn("%", "[\"%\", [\"get\", \"field\"], 2]", "modulo"),
998
- fn("^", "[\"^\", [\"get\", \"field\"], 2]", "power"),
999
- fn("abs", "[\"abs\", [\"get\", \"field\"]]", "abs"),
1000
- fn("ceil", "[\"ceil\", [\"get\", \"field\"]]", "ceil"),
1001
- fn("floor", "[\"floor\", [\"get\", \"field\"]]", "floor"),
1002
- fn("round", "[\"round\", [\"get\", \"field\"]]", "round"),
1003
- fn("sqrt", "[\"sqrt\", [\"get\", \"field\"]]", "sqrt"),
1004
- fn("ln", "[\"ln\", [\"get\", \"field\"]]", "ln"),
1005
- fn("log10", "[\"log10\", [\"get\", \"field\"]]", "log10"),
1006
- fn("min", "[\"min\", [\"get\", \"field\"], 0]", "min"),
1007
- fn("max", "[\"max\", [\"get\", \"field\"], 0]", "max")
1008
- ]
1009
- },
1010
- {
1011
- key: "string",
1012
- functions: [
1013
- fn("concat", "[\"concat\", [\"get\", \"field\"], \" suffix\"]", "concat"),
1014
- fn("upcase", "[\"upcase\", [\"get\", \"field\"]]", "upcase"),
1015
- fn("downcase", "[\"downcase\", [\"get\", \"field\"]]", "downcase"),
1016
- fn("slice", "[\"slice\", [\"get\", \"field\"], 0, 3]", "slice"),
1017
- fn("number-format", "[\"number-format\", [\"get\", \"field\"], {\"locale\": \"en-US\", \"max-fraction-digits\": 2}]", "numberFormat")
1018
- ]
1019
- },
1020
- {
1021
- key: "conversion",
1022
- functions: [
1023
- fn("to-number", "[\"to-number\", [\"get\", \"field\"]]", "toNumber"),
1024
- fn("to-string", "[\"to-string\", [\"get\", \"field\"]]", "toString"),
1025
- fn("to-boolean", "[\"to-boolean\", [\"get\", \"field\"]]", "toBoolean"),
1026
- fn("to-color", "[\"to-color\", \"#2563eb\"]", "toColor")
1027
- ]
1028
- },
1029
- {
1030
- key: "camera",
1031
- functions: [
1032
- fn("zoom", "[\"zoom\"]", "zoom"),
1033
- fn("interpolate", "[\"interpolate\", [\"linear\"], [\"zoom\"], 5, [\"to-color\", \"#2563eb\"], 12, [\"to-color\", \"#dc2626\"]]", "interpolate"),
1034
- fn("step", "[\"step\", [\"zoom\"], \"#2563eb\", 10, \"#dc2626\"]", "step"),
1035
- fn("rgb", "[\"rgb\", 37, 99, 235]", "rgb"),
1036
- fn("rgba", "[\"rgba\", 37, 99, 235, 0.5]", "rgba")
1037
- ]
1038
- }
1039
- ];
1040
- /**
1041
- * Strips commas that directly precede a closing bracket/brace outside of
1042
- * strings, so user-typed expressions with trailing commas still parse.
1043
- * Shared by {@link parseJsonExpression} (vector-color) and the builder.
1044
- */
1045
- function removeTrailingJsonCommas(value) {
1046
- let result = "";
1047
- let inString = false;
1048
- let escaped = false;
1049
- for (let index = 0; index < value.length; index += 1) {
1050
- const char = value[index];
1051
- if (inString) {
1052
- result += char;
1053
- if (escaped) escaped = false;
1054
- else if (char === "\\") escaped = true;
1055
- else if (char === "\"") inString = false;
1056
- continue;
1057
- }
1058
- if (char === "\"") {
1059
- inString = true;
1060
- result += char;
1061
- continue;
1062
- }
1063
- if (char === ",") {
1064
- const nextSignificant = value.slice(index + 1).match(/\S/)?.[0];
1065
- if (nextSignificant === "]" || nextSignificant === "}") continue;
1066
- }
1067
- result += char;
1068
- }
1069
- return result;
1070
- }
1071
- /**
1072
- * The `rootKey` `createExpression` (style-spec v26+) requires as its second
1073
- * argument — a location label it only surfaces when attaching a runtime
1074
- * warning to `console.warn` during evaluation. These expressions come from the
1075
- * Expression Builder dialog, not a style JSON layer, so a generic label is the
1076
- * honest value; it never appears in the parsing errors we report.
1077
- */
1078
- const EXPRESSION_ROOT_KEY = "expression";
1079
- /**
1080
- * Builds the minimal property spec that makes `createExpression` enforce a
1081
- * result type while still allowing zoom- and feature-dependent input.
1082
- *
1083
- * This mirrors the (unexported) `StylePropertySpecification` shape that
1084
- * `@maplibre/maplibre-gl-style-spec` expects — the cast below means the
1085
- * compiler cannot catch a change to that contract, so whenever the
1086
- * style-spec package is bumped (including Dependabot PRs) re-check that
1087
- * expected-type enforcement still works, and that the property spec is still
1088
- * `createExpression`'s third parameter (v26 inserted a `rootKey` at index 1,
1089
- * shifting it from `[1]` to `[2]`). The "enforces an expected result type"
1090
- * test in `tests/expressions.test.ts` fails if this shape stops being honored.
1091
- */
1092
- function propertySpecFor(expectedType) {
1093
- return {
1094
- type: expectedType,
1095
- "property-type": "data-driven",
1096
- expression: { parameters: ["zoom", "feature"] }
1097
- };
1098
- }
1099
- /**
1100
- * Validates a user-entered MapLibre expression: tolerant JSON parse, shape
1101
- * checks, then a real compile through the MapLibre style spec so operator
1102
- * misuse (wrong arity, unknown operator, type mismatch) is caught in the
1103
- * dialog instead of silently failing on the map. Variables are substituted
1104
- * before compiling (so tokens type-check as their values), and
1105
- * `expectedType` additionally enforces the destination's result type. The
1106
- * returned `parsed` array is the raw, unsubstituted parse. An empty/blank
1107
- * source is valid (surfaces treat it as "no expression").
1108
- */
1109
- function validateMapExpression(source, options = {}) {
1110
- return compileMapExpression(source, options).validation;
1111
- }
1112
- /**
1113
- * The single parse + substitute + compile pass shared by validation and
1114
- * preview evaluation, so a preview never compiles the same source twice.
1115
- * `expression` is set only when the validation succeeded on a non-empty
1116
- * source.
1117
- */
1118
- function compileMapExpression(source, options) {
1119
- const trimmed = source.trim();
1120
- if (!trimmed) return { validation: {
1121
- ok: true,
1122
- errors: []
1123
- } };
1124
- let parsed;
1125
- try {
1126
- parsed = JSON.parse(removeTrailingJsonCommas(trimmed));
1127
- } catch (error) {
1128
- return { validation: {
1129
- ok: false,
1130
- code: "not-json",
1131
- errors: [error instanceof Error ? error.message : "Invalid JSON"]
1132
- } };
1133
- }
1134
- if (!Array.isArray(parsed)) return { validation: {
1135
- ok: false,
1136
- code: "not-array",
1137
- errors: []
1138
- } };
1139
- if (typeof parsed[0] !== "string") return { validation: {
1140
- ok: false,
1141
- code: "not-operator",
1142
- errors: [],
1143
- parsed
1144
- } };
1145
- const substituted = substituteExpressionVariables(parsed, options.variables ?? []);
1146
- const compiled = createExpression(substituted, EXPRESSION_ROOT_KEY, options.expectedType ? propertySpecFor(options.expectedType) : void 0);
1147
- if (compiled.result === "error") return { validation: {
1148
- ok: false,
1149
- code: "compile",
1150
- errors: compiled.value.map((issue) => issue.key ? `${issue.key}: ${issue.message}` : issue.message),
1151
- parsed
1152
- } };
1153
- return {
1154
- validation: {
1155
- ok: true,
1156
- errors: [],
1157
- parsed
1158
- },
1159
- expression: compiled.value
1160
- };
1161
- }
1162
- /**
1163
- * Replaces every string node that exactly matches a known `@token` with that
1164
- * variable's value, returning a new structure (the input is not mutated).
1165
- * Unknown `@` strings are left untouched so field values that happen to start
1166
- * with `@` never get mangled.
1167
- */
1168
- function substituteExpressionVariables(node, variables) {
1169
- if (typeof node === "string" && node.startsWith("@")) {
1170
- const match = variables.find((variable) => variable.token === node);
1171
- return match ? match.value : node;
1172
- }
1173
- if (Array.isArray(node)) return node.map((entry) => substituteExpressionVariables(entry, variables));
1174
- if (node && typeof node === "object") return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, substituteExpressionVariables(value, variables)]));
1175
- return node;
1176
- }
1177
- /**
1178
- * Evaluates an expression source against a sample feature for the builder's
1179
- * live preview. Variables are substituted first, then the expression is
1180
- * compiled with the MapLibre style spec and evaluated with the given zoom.
1181
- * Runtime failures (e.g. `to-color` on garbage) come back as errors instead
1182
- * of throwing.
1183
- */
1184
- function evaluateMapExpression(source, options = {}) {
1185
- const trimmed = source.trim();
1186
- if (!trimmed) return { kind: "empty" };
1187
- const { validation, expression } = compileMapExpression(trimmed, {
1188
- variables: options.variables,
1189
- expectedType: options.expectedType
1190
- });
1191
- if (!validation.ok || !expression) return {
1192
- kind: "error",
1193
- errors: validation.errors
1194
- };
1195
- const feature = options.feature ?? null;
1196
- const geometryType = feature?.geometry?.type ?? "Unknown";
1197
- try {
1198
- return {
1199
- kind: "value",
1200
- value: expression.evaluateWithoutErrorHandling({ zoom: options.zoom ?? 0 }, {
1201
- type: geometryType,
1202
- properties: feature?.properties ?? {},
1203
- ...feature?.id !== void 0 ? { id: feature.id } : {},
1204
- geometry: feature?.geometry
1205
- })
1206
- };
1207
- } catch (error) {
1208
- return {
1209
- kind: "error",
1210
- errors: [error instanceof Error ? error.message : String(error)]
1211
- };
1212
- }
1213
- }
1214
- /**
1215
- * Compiles an expression source once for bulk per-feature evaluation (virtual
1216
- * fields, and any other caller that runs one expression over a whole layer).
1217
- * Unlike {@link evaluateMapExpression} the compile cost is paid once, not per
1218
- * feature. An empty source is reported as a failure with no errors — there is
1219
- * nothing to evaluate.
1220
- */
1221
- function compileFeatureExpression(source, options = {}) {
1222
- const trimmed = source.trim();
1223
- if (!trimmed) return {
1224
- ok: false,
1225
- errors: []
1226
- };
1227
- const { validation, expression } = compileMapExpression(trimmed, {
1228
- variables: options.variables,
1229
- expectedType: options.expectedType
1230
- });
1231
- if (!validation.ok || !expression) return {
1232
- ok: false,
1233
- errors: validation.errors
1234
- };
1235
- const compiledZoom = options.zoom ?? 0;
1236
- return {
1237
- ok: true,
1238
- errors: [],
1239
- evaluate: (feature, zoom = compiledZoom) => expression.evaluateWithoutErrorHandling({ zoom }, {
1240
- type: feature.geometry?.type ?? "Unknown",
1241
- properties: feature.properties ?? {},
1242
- ...feature.id !== void 0 ? { id: feature.id } : {},
1243
- geometry: feature.geometry
1244
- })
1245
- };
1246
- }
1247
- /**
1248
- * Evaluates a boolean expression against every feature and returns the
1249
- * selection ids of the matches (Select by Expression). Unlike
1250
- * {@link evaluateMapExpression} this compiles the source once and reuses the
1251
- * compiled expression across the whole array, so it stays fast on large
1252
- * layers. An empty source is reported as `ok: false` with no errors — there
1253
- * is nothing to select by.
1254
- */
1255
- function matchFeaturesByExpression(features, source, options = {}) {
1256
- const trimmed = source.trim();
1257
- if (!trimmed) return {
1258
- ok: false,
1259
- errors: [],
1260
- ids: [],
1261
- errorCount: 0
1262
- };
1263
- const { validation, expression } = compileMapExpression(trimmed, {
1264
- variables: options.variables,
1265
- expectedType: "boolean"
1266
- });
1267
- if (!validation.ok || !expression) return {
1268
- ok: false,
1269
- errors: validation.errors,
1270
- ids: [],
1271
- errorCount: 0
1272
- };
1273
- const ids = [];
1274
- let errorCount = 0;
1275
- features.forEach((feature, index) => {
1276
- try {
1277
- if (expression.evaluateWithoutErrorHandling({ zoom: options.zoom ?? 0 }, {
1278
- type: feature.geometry?.type ?? "Unknown",
1279
- properties: feature.properties ?? {},
1280
- ...feature.id !== void 0 ? { id: feature.id } : {},
1281
- geometry: feature.geometry
1282
- }) === true) ids.push(featureSelectionId(feature, index));
1283
- } catch {
1284
- errorCount += 1;
1285
- }
1286
- });
1287
- return {
1288
- ok: true,
1289
- errors: [],
1290
- ids,
1291
- errorCount
1292
- };
1293
- }
1294
- /**
1295
- * True when a preview value is a real style-spec `Color` instance (its
1296
- * evaluated color representation: premultiplied r/g/b/a floats). Shared by
1297
- * the preview formatter and the dialog's color-swatch rendering so the two
1298
- * checks cannot drift apart. An instanceof check (not duck typing) so user
1299
- * attribute data that happens to carry numeric r/g/b/a fields (e.g. via
1300
- * `["properties"]`) is never misclassified as a color.
1301
- */
1302
- function isStyleSpecColor(value) {
1303
- return value instanceof Color;
1304
- }
1305
- /**
1306
- * Renders a preview value as a short display string: style-spec Color
1307
- * instances become rgba() strings, plain values render as JSON, and
1308
- * `null`/`undefined` render as "null".
1309
- */
1310
- function formatExpressionPreviewValue(value) {
1311
- if (value === null || value === void 0) return "null";
1312
- if (isStyleSpecColor(value)) {
1313
- const color = value;
1314
- const alpha = color.a;
1315
- const channel = (component) => Math.round((alpha === 0 ? 0 : component / alpha) * 255);
1316
- return `rgba(${channel(color.r)}, ${channel(color.g)}, ${channel(color.b)}, ${Number(alpha.toFixed(3))})`;
1317
- }
1318
- try {
1319
- return JSON.stringify(value) ?? String(value);
1320
- } catch {
1321
- return String(value);
1322
- }
1323
- }
1324
- /**
1325
- * Infers a display type for each named field by scanning feature properties:
1326
- * a single consistent primitive type wins, disagreements become "mixed", and
1327
- * fields with no non-null values are "unknown".
1328
- */
1329
- function inferFieldTypes(features, names) {
1330
- const result = {};
1331
- for (const name of names) result[name] = "unknown";
1332
- for (const feature of features) {
1333
- const properties = feature.properties;
1334
- if (!properties) continue;
1335
- for (const name of names) {
1336
- const value = properties[name];
1337
- if (value === null || value === void 0) continue;
1338
- const valueType = typeof value;
1339
- const fieldType = valueType === "string" || valueType === "number" || valueType === "boolean" ? valueType : "mixed";
1340
- if (result[name] === "unknown") result[name] = fieldType;
1341
- else if (result[name] !== fieldType) result[name] = "mixed";
1342
- }
1343
- }
1344
- return result;
1345
- }
1346
- //#endregion
1347
- //#region src/virtual-fields.ts
1348
- /**
1349
- * Virtual fields (QGIS Field Calculator → "Create virtual field", issue
1350
- * #1321): expression-backed columns that recompute live instead of being
1351
- * written once as static values.
1352
- *
1353
- * The engine mirrors the persistent-joins engine (`joins.ts`): computed
1354
- * values are materialized into feature properties, so every consumer of
1355
- * attributes — the attribute table, Expression Builder, data-driven styling,
1356
- * labels, selection, export — sees the column with no further wiring.
1357
- * Idempotency uses the same bookkeeping: each applied field records the
1358
- * column it actually added (`addedField`), applying always strips those
1359
- * first, and an existing column always wins a name collision (the virtual
1360
- * field is skipped entirely), so stripping exactly restores the pre-apply
1361
- * properties.
1362
- *
1363
- * Expressions are declarative MapLibre expressions compiled through the
1364
- * style spec — never arbitrary code — so persisting them in `.geolibre.json`
1365
- * and re-evaluating them on project load is safe (unlike the attribute
1366
- * table's Field Calculator, whose JavaScript evaluator must never run
1367
- * persisted expressions). Fields apply in list order and each sees the
1368
- * columns materialized by earlier fields, so a virtual field can build on
1369
- * another. Zoom-dependent expressions evaluate at zoom 0 and `@` variables
1370
- * are not substituted: a virtual field is a property of the data, not of the
1371
- * current view.
1372
- *
1373
- * Known trade-off, shared with the joins engine (see `joins.ts`): when a
1374
- * layer's data is replaced wholesale, the replacement is stripped with the
1375
- * *previous* bookkeeping. That is required because a replacement can also be
1376
- * a write-back of the derived output (the attribute table round-trips
1377
- * `layer.geojson`), which must not freeze a stale computed value into base
1378
- * data — but it means a replacement dataset's own column whose name matches a
1379
- * previously-materialized virtual column is replaced by the expression's
1380
- * value for that column. Renaming or removing the virtual field first keeps
1381
- * such collisions from arising.
1382
- */
1383
- /**
1384
- * Remove every column previously added by `fields` (per their `addedField`
1385
- * bookkeeping) from a copy of `features`, restoring the pre-apply properties.
1386
- * Features without any tracked column are returned unchanged (same
1387
- * reference).
1388
- */
1389
- function stripVirtualFieldColumns(features, fields) {
1390
- const tracked = /* @__PURE__ */ new Set();
1391
- for (const field of fields ?? []) if (field.addedField) tracked.add(field.addedField);
1392
- if (tracked.size === 0) return features;
1393
- return features.map((feature) => {
1394
- const props = feature.properties;
1395
- if (!props) return feature;
1396
- let hasTracked = false;
1397
- for (const key of tracked) if (key in props) {
1398
- hasTracked = true;
1399
- break;
1400
- }
1401
- if (!hasTracked) return feature;
1402
- const next = {};
1403
- for (const [key, value] of Object.entries(props)) if (!tracked.has(key)) next[key] = value;
1404
- return {
1405
- ...feature,
1406
- properties: next
1407
- };
1408
- });
1409
- }
1410
- /**
1411
- * Normalize an evaluated expression value into a property value: `undefined`
1412
- * and non-finite numbers become null (a clean empty cell), style-spec Color
1413
- * instances become their rgba() string (a Color object would neither
1414
- * serialize nor display usefully), everything else passes through.
1415
- */
1416
- function normalizeComputedValue(value) {
1417
- if (value === void 0) return null;
1418
- if (typeof value === "number" && !Number.isFinite(value)) return null;
1419
- if (isStyleSpecColor(value)) return formatExpressionPreviewValue(value);
1420
- return value;
1421
- }
1422
- /**
1423
- * Apply `fields` in order to base (already-stripped) `features`, returning
1424
- * new feature objects with each computed column merged into their properties.
1425
- *
1426
- * Semantics per field: a disabled field, an empty/colliding output name, or
1427
- * an expression that fails to compile contributes nothing (with the compile
1428
- * error recorded on the field); a runtime evaluation failure nulls that one
1429
- * feature's cell and increments the field's `errorCount` instead of aborting
1430
- * the column. Fields evaluate sequentially, so a later field's expression
1431
- * can `["get", ...]` an earlier field's column.
1432
- */
1433
- function applyLayerVirtualFields(features, fields) {
1434
- const fieldList = fields ?? [];
1435
- const clearBookkeeping = (field) => ({
1436
- ...field,
1437
- addedField: void 0,
1438
- error: void 0,
1439
- errorCount: void 0
1440
- });
1441
- if (fieldList.filter((field) => field.enabled !== false).length === 0) return {
1442
- features,
1443
- fields: fieldList.map(clearBookkeeping)
1444
- };
1445
- const usedNames = /* @__PURE__ */ new Set();
1446
- for (const feature of features) for (const key of Object.keys(feature.properties ?? {})) usedNames.add(key);
1447
- const out = features.map((feature) => ({
1448
- ...feature,
1449
- properties: { ...feature.properties ?? {} }
1450
- }));
1451
- return {
1452
- features: out,
1453
- fields: fieldList.map((field) => {
1454
- if (field.enabled === false) return clearBookkeeping(field);
1455
- const name = field.name.trim();
1456
- if (!name || usedNames.has(name)) return clearBookkeeping(field);
1457
- const compiled = compileFeatureExpression(field.expression);
1458
- if (!compiled.ok || !compiled.evaluate) return {
1459
- ...clearBookkeeping(field),
1460
- error: compiled.errors[0] ?? "Invalid expression"
1461
- };
1462
- usedNames.add(name);
1463
- let errorCount = 0;
1464
- for (const feature of out) {
1465
- let value = null;
1466
- try {
1467
- value = normalizeComputedValue(compiled.evaluate(feature));
1468
- } catch {
1469
- errorCount += 1;
1470
- }
1471
- feature.properties[name] = value;
1472
- }
1473
- return {
1474
- ...clearBookkeeping(field),
1475
- addedField: name,
1476
- ...errorCount > 0 ? { errorCount } : {}
1477
- };
1478
- })
1479
- };
1480
- }
1481
- //#endregion
1482
- //#region src/joins.ts
1483
- /**
1484
- * Persistent attribute joins (QGIS Layer Properties → Joins), issue #1315.
1485
- *
1486
- * A layer's `joins` are live left joins that materialize columns from another
1487
- * layer's attribute table into this layer's feature properties. Materializing
1488
- * (rather than resolving lazily) means every consumer of attributes — the
1489
- * attribute table, Expression Builder, data-driven styling, labels, diagrams,
1490
- * export — sees the joined columns with no further wiring.
1491
- *
1492
- * Idempotency without a duplicate base copy: each applied join records the
1493
- * output column names it added (`addedFields`), and applying joins always
1494
- * strips those first. Base columns win every name collision (a joined column
1495
- * whose output name already exists is skipped entirely), so stripping the
1496
- * added columns exactly restores the pre-join properties.
1497
- *
1498
- * Known trade-off of that bookkeeping: when a layer's data is replaced
1499
- * wholesale (file reload), the replacement is stripped with the *previous*
1500
- * `addedFields`. That is required because a replacement can also be a
1501
- * write-back of the joined output (the attribute table round-trips
1502
- * `layer.geojson`), which must not freeze stale joined values into base data —
1503
- * but it means a reloaded base column whose name collides with a
1504
- * previously-joined output column is replaced by the join's value for that
1505
- * column. A field-name prefix (the QGIS convention the UI suggests) keeps
1506
- * such collisions from arising.
1507
- *
1508
- * The data is already in memory as JS feature objects, so the join is a plain
1509
- * hash join here rather than the DuckDB-WASM SQL statement sketched in the
1510
- * issue — the result is identical for equality keys and the engine stays
1511
- * synchronous and dependency-free. Key semantics deliberately mirror the
1512
- * Processing → Vector attribute join (and the sidecar's `_attribute_join_key`):
1513
- * empty values never match, and non-empty values compare stringified.
1514
- */
1515
- /** Canonical JSON for object values (sorted keys), matching the processing engine. */
1516
- function stableStringify(value) {
1517
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1518
- if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
1519
- return JSON.stringify(value) ?? "null";
1520
- }
1521
- function valueToString(value) {
1522
- if (typeof value === "boolean") return value ? "true" : "false";
1523
- if (value !== null && typeof value === "object") return stableStringify(value);
1524
- return String(value);
1525
- }
1526
- /**
1527
- * Match key for a persistent join: empty values (null/undefined/NaN/empty
1528
- * string) never match a row, mirroring a SQL/pandas NaN join key. Non-empty
1529
- * values are keyed by their string form, so a numeric `5` and the string `"5"`
1530
- * join while a zero-padded code like `"01001"` only matches another `"01001"`.
1531
- * Kept in sync with the Processing attribute join's `attributeJoinKey`.
1532
- */
1533
- function layerJoinKey(value) {
1534
- return value === null || value === void 0 || typeof value === "number" && Number.isNaN(value) || valueToString(value) === "" ? null : valueToString(value);
1535
- }
1536
- /** GeoJSON columns are own properties; inherited object members are not data. */
1537
- function fieldValue(properties, field) {
1538
- return properties && Object.hasOwn(properties, field) ? properties[field] : void 0;
1539
- }
1540
- /**
1541
- * Remove every column previously added by `joins` (per their `addedFields`
1542
- * bookkeeping) from a copy of `features`, restoring the pre-join properties.
1543
- * Features without any tracked column are returned unchanged (same reference).
1544
- */
1545
- function stripJoinFields(features, joins) {
1546
- const tracked = /* @__PURE__ */ new Set();
1547
- for (const join of joins ?? []) for (const field of join.addedFields ?? []) tracked.add(field);
1548
- if (tracked.size === 0) return features;
1549
- return features.map((feature) => {
1550
- const props = feature.properties;
1551
- if (!props) return feature;
1552
- let hasTracked = false;
1553
- for (const key of tracked) if (key in props) {
1554
- hasTracked = true;
1555
- break;
1556
- }
1557
- if (!hasTracked) return feature;
1558
- const next = {};
1559
- for (const [key, value] of Object.entries(props)) if (!tracked.has(key)) next[key] = value;
1560
- return {
1561
- ...feature,
1562
- properties: next
1563
- };
1564
- });
1565
- }
1566
- /**
1567
- * Apply `joins` in order to base (already-stripped) `features`, returning new
1568
- * feature objects with the joined columns merged into their properties.
1569
- *
1570
- * Semantics per join: left join, first matching join row wins, and joined
1571
- * columns are null-filled on unmatched features so the schema stays
1572
- * consistent. An output name (prefix + field) that collides with an existing
1573
- * column — from the base data or an earlier join — drops that one column;
1574
- * the join's other columns still apply. A disabled join, or one whose source
1575
- * cannot be resolved, contributes nothing and gets empty `addedFields` and no
1576
- * stats.
1577
- *
1578
- * @param features - The layer's base features (strip previous joins first).
1579
- * @param joins - Join definitions in application order.
1580
- * @param resolveSource - Maps a join's `joinLayerId` to that layer's current
1581
- * feature collection, or `undefined` when the layer is gone (or is the
1582
- * target itself; self-joins are refused by the caller).
1583
- */
1584
- function applyLayerJoins(features, joins, resolveSource) {
1585
- const joinList = joins ?? [];
1586
- const resolved = /* @__PURE__ */ new Map();
1587
- const resolveOnce = (joinLayerId) => {
1588
- if (!resolved.has(joinLayerId)) resolved.set(joinLayerId, resolveSource(joinLayerId));
1589
- return resolved.get(joinLayerId);
1590
- };
1591
- if (joinList.filter((join) => join.enabled !== false && resolveOnce(join.joinLayerId)).length === 0) return {
1592
- features,
1593
- joins: joinList.map((join) => ({
1594
- ...join,
1595
- addedFields: [],
1596
- stats: void 0
1597
- }))
1598
- };
1599
- const out = features.map((feature) => ({
1600
- ...feature,
1601
- properties: { ...feature.properties ?? {} }
1602
- }));
1603
- const usedNames = /* @__PURE__ */ new Set();
1604
- for (const feature of features) for (const key of Object.keys(feature.properties ?? {})) usedNames.add(key);
1605
- return {
1606
- features: out,
1607
- joins: joinList.map((join) => {
1608
- const source = join.enabled === false ? void 0 : resolveOnce(join.joinLayerId);
1609
- if (!source) return {
1610
- ...join,
1611
- addedFields: [],
1612
- stats: void 0
1613
- };
1614
- const joinFeatures = (source.features ?? []).filter(Boolean);
1615
- const joinKeysOrder = [];
1616
- const joinKeySet = /* @__PURE__ */ new Set();
1617
- for (const jf of joinFeatures) for (const key of Object.keys(jf.properties ?? {})) if (!joinKeySet.has(key)) {
1618
- joinKeySet.add(key);
1619
- joinKeysOrder.push(key);
1620
- }
1621
- const requested = join.fields !== void 0 ? join.fields.filter((field) => joinKeySet.has(field)) : joinKeysOrder.filter((key) => key !== join.joinField);
1622
- const prefix = join.prefix ?? "";
1623
- const fieldPairs = [];
1624
- for (const field of requested) {
1625
- const outputName = prefix + field;
1626
- if (usedNames.has(outputName)) continue;
1627
- usedNames.add(outputName);
1628
- fieldPairs.push([field, outputName]);
1629
- }
1630
- const lookup = /* @__PURE__ */ new Map();
1631
- for (const jf of joinFeatures) {
1632
- const key = layerJoinKey(fieldValue(jf.properties, join.joinField));
1633
- if (key === null || lookup.has(key)) continue;
1634
- lookup.set(key, jf.properties ?? {});
1635
- }
1636
- let matched = 0;
1637
- const targetKeys = /* @__PURE__ */ new Set();
1638
- for (const feature of out) {
1639
- const props = feature.properties;
1640
- const key = layerJoinKey(fieldValue(props, join.targetField));
1641
- if (key !== null) targetKeys.add(key);
1642
- const row = key === null ? void 0 : lookup.get(key);
1643
- if (row) {
1644
- matched += 1;
1645
- for (const [field, outputName] of fieldPairs) props[outputName] = fieldValue(row, field) ?? null;
1646
- } else for (const [, outputName] of fieldPairs) props[outputName] = null;
1647
- }
1648
- let unmatchedJoin = 0;
1649
- for (const jf of joinFeatures) {
1650
- const key = layerJoinKey(fieldValue(jf.properties, join.joinField));
1651
- if (key !== null && !targetKeys.has(key)) unmatchedJoin += 1;
1652
- }
1653
- return {
1654
- ...join,
1655
- addedFields: fieldPairs.map(([, outputName]) => outputName),
1656
- stats: {
1657
- matchedCount: matched,
1658
- unmatchedTargetCount: out.length - matched,
1659
- unmatchedJoinCount: unmatchedJoin
1660
- }
1661
- };
1662
- })
1663
- };
1664
- }
1665
- /**
1666
- * Strip-and-reapply this layer's derived columns — persistent joins, then
1667
- * virtual fields (so an expression can read joined columns) — resolving join
1668
- * sources from `allLayers`. Pass `nextJoins`/`nextVirtualFields` to replace
1669
- * the definitions (the layer's current bookkeeping still drives the strip, so
1670
- * removed definitions clean up after themselves); omit them to refresh the
1671
- * existing definitions against current data. Layers without a feature
1672
- * collection are returned unchanged.
1673
- */
1674
- function applyJoinsToLayer(layer, allLayers, nextJoins, nextVirtualFields) {
1675
- const geojson = layer.geojson;
1676
- const joins = nextJoins ?? layer.joins ?? [];
1677
- const virtualFields = nextVirtualFields ?? layer.virtualFields ?? [];
1678
- if (!geojson) return {
1679
- ...layer,
1680
- joins: joins.length > 0 ? joins : void 0,
1681
- virtualFields: virtualFields.length > 0 ? virtualFields : void 0
1682
- };
1683
- const applied = applyLayerJoins(stripJoinFields(stripVirtualFieldColumns(geojson.features ?? [], layer.virtualFields), layer.joins), joins, (joinLayerId) => {
1684
- if (joinLayerId === layer.id) return void 0;
1685
- return allLayers.find((candidate) => candidate.id === joinLayerId)?.geojson;
1686
- });
1687
- const withVirtual = applyLayerVirtualFields(applied.features, virtualFields);
1688
- return {
1689
- ...layer,
1690
- geojson: {
1691
- ...geojson,
1692
- features: withVirtual.features
1693
- },
1694
- joins: applied.joins.length > 0 ? applied.joins : void 0,
1695
- virtualFields: withVirtual.fields.length > 0 ? withVirtual.fields : void 0
1696
- };
1697
- }
1698
- /**
1699
- * Ids of every layer whose data (directly or transitively) feeds `layerId`'s
1700
- * joins, following `joinLayerId` edges through each source's own joins.
1701
- * Disabled joins count too: re-enabling one must not spring a cycle. The Joins
1702
- * UI uses this to keep a layer that already consumes the target — however
1703
- * indirectly — out of the join-source picker, so circular joins cannot be
1704
- * authored (the refresh cascade still breaks them defensively for hand-edited
1705
- * projects).
1706
- */
1707
- function collectTransitiveJoinSourceIds(layers, layerId) {
1708
- const byId = new Map(layers.map((layer) => [layer.id, layer]));
1709
- const visited = /* @__PURE__ */ new Set();
1710
- const queue = [layerId];
1711
- while (queue.length > 0) {
1712
- const current = byId.get(queue.shift());
1713
- for (const join of current?.joins ?? []) {
1714
- if (visited.has(join.joinLayerId)) continue;
1715
- visited.add(join.joinLayerId);
1716
- queue.push(join.joinLayerId);
1717
- }
1718
- }
1719
- return visited;
1720
- }
1721
- /**
1722
- * Order `candidates` (layers with joins) so every layer comes after the
1723
- * candidates its joins consume — Kahn's algorithm over "join source → join
1724
- * target" edges within the candidate set. When a cycle blocks progress
1725
- * (possible only in hand-edited projects; the UI refuses circular joins), an
1726
- * actual cycle member is forced through — found by walking unmet-source edges
1727
- * until a node repeats — so consumers *downstream* of the cycle still order
1728
- * after it rather than falling back ahead of it.
1729
- */
1730
- function topologicalJoinOrder(candidates) {
1731
- const candidateIds = new Set(candidates.map((layer) => layer.id));
1732
- const indegree = /* @__PURE__ */ new Map();
1733
- const dependents = /* @__PURE__ */ new Map();
1734
- const sourcesOf = /* @__PURE__ */ new Map();
1735
- for (const layer of candidates) indegree.set(layer.id, 0);
1736
- for (const layer of candidates) for (const join of layer.joins ?? []) {
1737
- if (join.enabled === false || join.joinLayerId === layer.id || !candidateIds.has(join.joinLayerId)) continue;
1738
- indegree.set(layer.id, (indegree.get(layer.id) ?? 0) + 1);
1739
- const list = dependents.get(join.joinLayerId) ?? [];
1740
- list.push(layer.id);
1741
- dependents.set(join.joinLayerId, list);
1742
- const sources = sourcesOf.get(layer.id) ?? [];
1743
- sources.push(join.joinLayerId);
1744
- sourcesOf.set(layer.id, sources);
1745
- }
1746
- const order = [];
1747
- const orderedSet = /* @__PURE__ */ new Set();
1748
- const queue = candidates.filter((layer) => indegree.get(layer.id) === 0).map((layer) => layer.id);
1749
- while (order.length < candidates.length) {
1750
- if (queue.length === 0) {
1751
- const start = candidates.find((layer) => !orderedSet.has(layer.id));
1752
- if (!start) break;
1753
- let cursor = start.id;
1754
- const walked = /* @__PURE__ */ new Set();
1755
- while (!walked.has(cursor)) {
1756
- walked.add(cursor);
1757
- const next = (sourcesOf.get(cursor) ?? []).find((source) => !orderedSet.has(source));
1758
- if (next === void 0) break;
1759
- cursor = next;
1760
- }
1761
- queue.push(cursor);
1762
- }
1763
- const id = queue.shift();
1764
- if (orderedSet.has(id)) continue;
1765
- orderedSet.add(id);
1766
- order.push(id);
1767
- for (const dependent of dependents.get(id) ?? []) {
1768
- const remaining = (indegree.get(dependent) ?? 1) - 1;
1769
- indegree.set(dependent, remaining);
1770
- if (remaining === 0 && !orderedSet.has(dependent)) queue.push(dependent);
1771
- }
1772
- }
1773
- return order;
1774
- }
1775
- /**
1776
- * Refresh every layer whose joins (directly or transitively) consume the
1777
- * layer identified by `seedId`, after that layer's data changed — or after it
1778
- * was removed (a gone source resolves to nothing, so its frozen columns strip
1779
- * away). The affected layers re-derive in topological dependency order, so a
1780
- * layer joining two other affected layers sees both of them fresh regardless
1781
- * of their positions in the layer panel. The seed itself is not touched.
1782
- */
1783
- function cascadeLayerJoinRefresh(layers, seedId) {
1784
- const affected = /* @__PURE__ */ new Set();
1785
- const queue = [seedId];
1786
- while (queue.length > 0) {
1787
- const sourceId = queue.shift();
1788
- for (const layer of layers) {
1789
- if (layer.id === seedId || affected.has(layer.id)) continue;
1790
- if (layer.joins?.some((join) => join.enabled !== false && join.joinLayerId === sourceId)) {
1791
- affected.add(layer.id);
1792
- queue.push(layer.id);
1793
- }
1794
- }
1795
- }
1796
- if (affected.size === 0) return layers;
1797
- let current = layers;
1798
- const order = topologicalJoinOrder(layers.filter((layer) => affected.has(layer.id)));
1799
- for (const id of order) current = current.map((layer) => layer.id === id ? applyJoinsToLayer(layer, current) : layer);
1800
- return current;
1801
- }
1802
- /**
1803
- * Re-resolve every layer's persistent joins against the freshly loaded layer
1804
- * set (project open). Layers re-derive in topological order of their join
1805
- * dependencies, so a chain (`C joins A, A joins B`) resolves A before C; a
1806
- * hand-edited cycle is broken by forcing one of its members through first
1807
- * (see {@link topologicalJoinOrder}), so consumers downstream of the cycle
1808
- * still re-derive after it. Layers with virtual fields but no joins re-derive
1809
- * too (their expressions run against the freshly loaded features). Layer sets
1810
- * without joins or virtual fields pass through by reference.
1811
- */
1812
- function reapplyLayerJoins(layers) {
1813
- const joined = layers.filter((layer) => layer.joins?.length || layer.virtualFields?.length);
1814
- if (joined.length === 0) return layers;
1815
- let current = layers;
1816
- for (const id of topologicalJoinOrder(joined)) current = current.map((layer) => layer.id === id ? applyJoinsToLayer(layer, current) : layer);
1817
- return current;
1818
- }
1819
- //#endregion
1820
- export { unescapePolyline as A, PLANET_SWITCHER_OPTIONS as B, invertSelection as C, encodePolyline as D, decodePolylineDetailed as E, EARTH_MEAN_RADIUS_METERS as F, getActiveEllipsoid as G, earthAreaToBody as H, ELLIPSOIDS as I, getEllipsoid as J, getActiveMeanRadiusMeters as K, PLANETARY_BASEMAPS as L, horizontalBbox as M, transformGeojsonElevation as N, geoJSONToPolylineStr as O, DEFAULT_ELLIPSOID_ID as P, setActiveEllipsoidId as Q, PLANETARY_BASEMAP_GROUPS as R, featureSelectionId as S, decodePolyline as T, earthLengthToBody as U, bodyLengthToEarth as V, getActiveBodyRadiusRatio as W, getPlanetaryBasemapByStyleUrl as X, getPlanetaryBasemapById as Y, meanRadiusMeters as Z, removeTrailingJsonCommas as _, layerJoinKey as a, SELECTION_MODES as b, applyLayerVirtualFields as c, compileFeatureExpression as d, evaluateMapExpression as f, matchFeaturesByExpression as g, isStyleSpecColor as h, collectTransitiveJoinSourceIds as i, geojsonHasZCoordinates as j, polylineStrToGeoJSON as k, stripVirtualFieldColumns as l, inferFieldTypes as m, applyLayerJoins as n, reapplyLayerJoins as o, formatExpressionPreviewValue as p, getActiveSemiMajorAxisMeters as q, cascadeLayerJoinRefresh as r, stripJoinFields as s, applyJoinsToLayer as t, EXPRESSION_FUNCTION_CATEGORIES as u, substituteExpressionVariables as v, batchDecodePolylines as w, applySelectionMode as x, validateMapExpression as y, PLANETARY_BASEMAP_SENTINEL_PREFIX as z };
1821
-
1822
- //# sourceMappingURL=joins-CGcRoTpc.mjs.map