@trackunit/react-map-adapter-shared 0.0.4-alpha-9d327375fc1.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.
package/index.cjs.js ADDED
@@ -0,0 +1,2481 @@
1
+ 'use strict';
2
+
3
+ var geoJsonUtils = require('@trackunit/geo-json-utils');
4
+ var uiDesignTokens = require('@trackunit/ui-design-tokens');
5
+ var esToolkit = require('es-toolkit');
6
+ var zod = require('zod');
7
+
8
+ /**
9
+ * Helper to define an adapter factory with proper type inference.
10
+ *
11
+ * Automatically injects the `derive` method on every `AdapterConfig` it produces.
12
+ * Adapter implementations don't need to handle `derive` -- it's added transparently
13
+ * by closing over the original factory function.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * export const googleMapsAdapter = defineAdapter((config: GoogleMapsConfig) => ({
18
+ * name: "google",
19
+ * config,
20
+ * createInstance: () => createGoogleMapsInstance(config),
21
+ * Renderer: GoogleMapsRenderer,
22
+ * }));
23
+ *
24
+ * // derive is available automatically:
25
+ * const darkAdapter = googleMapsAdapter({ apiKey, theme: "light" }).derive({ theme: "dark" });
26
+ * ```
27
+ */
28
+ const defineAdapter = (factory) => {
29
+ const wrappedFactory = (config) => {
30
+ const base = factory(config);
31
+ return {
32
+ ...base,
33
+ derive: (overrides) => wrappedFactory({ ...config, ...overrides }),
34
+ };
35
+ };
36
+ return wrappedFactory;
37
+ };
38
+
39
+ // ============================================================================
40
+ // Constants
41
+ // ============================================================================
42
+ const ANTIMERIDIAN = 180;
43
+ /**
44
+ * Degrees tolerance for treating a longitude as lying on the antimeridian (±180°).
45
+ * Used when classifying vertices as on the eastern (+180°) or western (−180°) seam.
46
+ * Double-precision GeoJSON from RFC split tooling is expected near-exactly on ±180;
47
+ * widen only with a failing test from real data. Pairing seam latitudes uses {@link SEAM_LATITUDE_TOLERANCE}.
48
+ */
49
+ const ANTIMERIDIAN_LONGITUDE_TOLERANCE = 1e-9;
50
+ /** Tolerance for matching seam latitudes between east (+180°) and west (-180°) runs (clip output floats).
51
+ *
52
+ * SEAM_LATITUDE_TOLERANCE = 1e-5 means 0.00001° of latitude difference is treated as “the same” seam point.
53
+ * See https://docs.mapbox.com/help/dive-deeper/geojson-coordinate-precision/ for more details.
54
+ *
55
+ */
56
+ const SEAM_LATITUDE_TOLERANCE = 1e-5;
57
+ // ============================================================================
58
+ // Coordinate helpers
59
+ // ============================================================================
60
+ const isAtEasternAntimeridian = (lng) => Math.abs(lng - ANTIMERIDIAN) < ANTIMERIDIAN_LONGITUDE_TOLERANCE;
61
+ const isAtWesternAntimeridian = (lng) => Math.abs(lng + ANTIMERIDIAN) < ANTIMERIDIAN_LONGITUDE_TOLERANCE;
62
+ /**
63
+ * Shift a position's longitude by a given offset, preserving 2D/3D format.
64
+ */
65
+ const shiftPosition = (pos, offset) => {
66
+ if (pos.length === 3) {
67
+ return [pos[0] + offset, pos[1], pos[2]];
68
+ }
69
+ return [pos[0] + offset, pos[1]];
70
+ };
71
+ /**
72
+ * Shift all positions in a ring by a longitude offset.
73
+ */
74
+ const shiftRing = (ring, offset) => ring.map(pos => shiftPosition(pos, offset));
75
+ /**
76
+ * Find a maximal run of consecutive vertices at a given longitude (±180) in a ring.
77
+ * The ring's closing vertex (which duplicates the first) is excluded from indexing.
78
+ * Returns null if fewer than 2 vertices lie at the target longitude.
79
+ */
80
+ const findAntimeridianRun = (ring, isAtTarget) => {
81
+ const len = ring.length - 1;
82
+ if (len < 3)
83
+ return null;
84
+ let seedIdx = -1;
85
+ for (let i = 0; i < len; i++) {
86
+ const vertex = ring[i];
87
+ if (vertex !== undefined && isAtTarget(vertex[0])) {
88
+ seedIdx = i;
89
+ break;
90
+ }
91
+ }
92
+ if (seedIdx === -1)
93
+ return null;
94
+ // Walk backwards from seed to find the true start of the run (handles wrap-around)
95
+ let startIdx = seedIdx;
96
+ for (;;) {
97
+ const prev = (startIdx - 1 + len) % len;
98
+ if (prev === seedIdx)
99
+ break;
100
+ const prevVertex = ring[prev];
101
+ if (prevVertex === undefined || !isAtTarget(prevVertex[0]))
102
+ break;
103
+ startIdx = prev;
104
+ }
105
+ // Walk forward from start to collect the full run
106
+ const latitudes = [];
107
+ let current = startIdx;
108
+ for (let count = 0; count < len; count++) {
109
+ const vertex = ring[current];
110
+ if (vertex === undefined || !isAtTarget(vertex[0]))
111
+ break;
112
+ latitudes.push(vertex[1]);
113
+ current = (current + 1) % len;
114
+ }
115
+ if (latitudes.length < 2)
116
+ return null;
117
+ const endIdx = (startIdx + latitudes.length - 1) % len;
118
+ return { startIdx, endIdx, latitudes };
119
+ };
120
+ /**
121
+ * Maximal run of consecutive vertices on ±180° in an open polyline (no closing duplicate).
122
+ *
123
+ * Unlike {@link findAntimeridianRun} on closed rings, a **single** seam vertex is valid: RFC 7946
124
+ * splits at the crossing often yield one point at +180° on the east segment and one at −180° on
125
+ * the west segment (no edge *along* the meridian).
126
+ */
127
+ const findAntimeridianRunOnPolyline = (polyline, isAtTarget) => {
128
+ const len = polyline.length;
129
+ if (len < 2)
130
+ return null;
131
+ let seedIdx = -1;
132
+ for (let i = 0; i < len; i++) {
133
+ const vertex = polyline[i];
134
+ if (vertex !== undefined && isAtTarget(vertex[0])) {
135
+ seedIdx = i;
136
+ break;
137
+ }
138
+ }
139
+ if (seedIdx === -1)
140
+ return null;
141
+ let startIdx = seedIdx;
142
+ for (let i = seedIdx - 1; i >= 0; i--) {
143
+ const vertex = polyline[i];
144
+ if (vertex === undefined || !isAtTarget(vertex[0]))
145
+ break;
146
+ startIdx = i;
147
+ }
148
+ const latitudes = [];
149
+ let current = startIdx;
150
+ for (; current < len; current++) {
151
+ const vertex = polyline[current];
152
+ if (vertex === undefined || !isAtTarget(vertex[0]))
153
+ break;
154
+ latitudes.push(vertex[1]);
155
+ }
156
+ const endIdx = startIdx + latitudes.length - 1;
157
+ return { startIdx, endIdx, latitudes };
158
+ };
159
+ // ============================================================================
160
+ // Matching & stitching
161
+ // ============================================================================
162
+ /**
163
+ * Check whether two antimeridian runs have matching latitude values (reversed order,
164
+ * as expected for two adjacent CCW rings sharing an edge in opposite directions).
165
+ */
166
+ const runsMatch = (eastRun, westRun) => {
167
+ if (eastRun.latitudes.length !== westRun.latitudes.length)
168
+ return false;
169
+ const reversedWest = westRun.latitudes.toReversed();
170
+ return eastRun.latitudes.every((lat, i) => {
171
+ const westLat = reversedWest[i];
172
+ return westLat !== undefined && Math.abs(lat - westLat) < SEAM_LATITUDE_TOLERANCE;
173
+ });
174
+ };
175
+ /**
176
+ * Stitch an eastern ring (vertices at lng=+180) and a western ring (vertices at lng=-180)
177
+ * into a single ring with unwrapped coordinates. The western ring's longitudes are shifted
178
+ * by +360 so the merged ring has continuous coordinates across the antimeridian.
179
+ *
180
+ * The shared antimeridian edge is eliminated — the merged ring goes smoothly from the
181
+ * eastern side (lng near 180) through to the western side (lng near 190+).
182
+ */
183
+ const stitchRings = (eastRing, eastRun, westRing, westRun) => {
184
+ const eastLen = eastRing.length - 1;
185
+ const westLen = westRing.length - 1;
186
+ const result = [];
187
+ // Walk eastern ring from after the AM run end, through the non-AM portion,
188
+ // up to the AM run start (inclusive). This collects the entire eastern
189
+ // perimeter plus the connection vertex at lng=180.
190
+ const eastWalkStart = (eastRun.endIdx + 1) % eastLen;
191
+ let idx = eastWalkStart;
192
+ for (let count = 0; count < eastLen; count++) {
193
+ const vertex = eastRing[idx];
194
+ if (vertex === undefined)
195
+ break;
196
+ result.push(vertex);
197
+ if (idx === eastRun.startIdx)
198
+ break;
199
+ idx = (idx + 1) % eastLen;
200
+ }
201
+ // Walk western ring from after the AM run end, through the non-AM portion,
202
+ // shifted by +360. Skip the AM endpoints to avoid duplicating vertices that
203
+ // coincide with the eastern connection points.
204
+ const westWalkStart = (westRun.endIdx + 1) % westLen;
205
+ const westWalkEnd = (westRun.startIdx - 1 + westLen) % westLen;
206
+ idx = westWalkStart;
207
+ for (let count = 0; count < westLen; count++) {
208
+ const vertex = westRing[idx];
209
+ if (vertex === undefined)
210
+ break;
211
+ result.push(shiftPosition(vertex, 360));
212
+ if (idx === westWalkEnd)
213
+ break;
214
+ idx = (idx + 1) % westLen;
215
+ }
216
+ // Close the ring
217
+ const first = result[0];
218
+ if (first !== undefined) {
219
+ result.push(first);
220
+ }
221
+ return result;
222
+ };
223
+ /**
224
+ * Stitch east and west polylines that share a seam at ±180° into one path with unwrapped longitudes.
225
+ */
226
+ const stitchPolylines = (eastLine, eastRun, westLine, westRun) => {
227
+ const eastPrefix = eastLine.slice(0, eastRun.endIdx + 1);
228
+ const westTail = westLine.slice(westRun.endIdx + 1).map(pos => shiftPosition(pos, 360));
229
+ return [...eastPrefix, ...westTail];
230
+ };
231
+ /**
232
+ * Classify a sub-polygon as eastern (has AM run at +180) or western (at -180).
233
+ * Returns null if the exterior ring has no antimeridian run.
234
+ */
235
+ const classifySubPolygon = (rings) => {
236
+ const exterior = rings[0];
237
+ if (exterior === undefined)
238
+ return null;
239
+ const eastRun = findAntimeridianRun(exterior, isAtEasternAntimeridian);
240
+ if (eastRun !== null) {
241
+ return { rings, run: eastRun, side: "east" };
242
+ }
243
+ const westRun = findAntimeridianRun(exterior, isAtWesternAntimeridian);
244
+ if (westRun !== null) {
245
+ return { rings, run: westRun, side: "west" };
246
+ }
247
+ return null;
248
+ };
249
+ // ============================================================================
250
+ // MultiPolygon merging
251
+ // ============================================================================
252
+ /**
253
+ * When both halves of a hole sit on ±180° with matching seam latitudes, stitch them into one ring
254
+ * (same as the exterior). Otherwise return null so callers keep the legacy behaviour: east ring plus
255
+ * west ring shifted by +360° (holes that lie entirely on one side of the seam).
256
+ */
257
+ const tryMergeHolePair = (eastHole, westHoleOriginal) => {
258
+ const eastRun = findAntimeridianRun(eastHole, isAtEasternAntimeridian);
259
+ const westRun = findAntimeridianRun(westHoleOriginal, isAtWesternAntimeridian);
260
+ if (eastRun === null || westRun === null) {
261
+ return null;
262
+ }
263
+ if (!runsMatch(eastRun, westRun)) {
264
+ return null;
265
+ }
266
+ return stitchRings(eastHole, eastRun, westHoleOriginal, westRun);
267
+ };
268
+ /**
269
+ * Pair east/west interior rings: seam-split holes match by antimeridian run geometry;
270
+ * remaining rings pair in stable index order (legacy split tooling order).
271
+ */
272
+ const buildMergedInteriorRings = (eastHoles, westHolesOriginal) => {
273
+ const eastCount = eastHoles.length;
274
+ const westCount = westHolesOriginal.length;
275
+ const usedEast = new Set();
276
+ const usedWest = new Set();
277
+ const eastToWest = new Map();
278
+ for (let e = 0; e < eastCount; e++) {
279
+ const eastHole = eastHoles[e];
280
+ if (eastHole === undefined || usedEast.has(e))
281
+ continue;
282
+ for (let w = 0; w < westCount; w++) {
283
+ if (usedWest.has(w))
284
+ continue;
285
+ const westHole = westHolesOriginal[w];
286
+ if (westHole === undefined)
287
+ continue;
288
+ if (tryMergeHolePair(eastHole, westHole) !== null) {
289
+ eastToWest.set(e, w);
290
+ usedEast.add(e);
291
+ usedWest.add(w);
292
+ break;
293
+ }
294
+ }
295
+ }
296
+ const eastRemaining = [];
297
+ for (let e = 0; e < eastCount; e++) {
298
+ if (!usedEast.has(e)) {
299
+ eastRemaining.push(e);
300
+ }
301
+ }
302
+ const westRemaining = [];
303
+ for (let w = 0; w < westCount; w++) {
304
+ if (!usedWest.has(w)) {
305
+ westRemaining.push(w);
306
+ }
307
+ }
308
+ const remainderPairCount = Math.min(eastRemaining.length, westRemaining.length);
309
+ for (let k = 0; k < remainderPairCount; k++) {
310
+ const e = eastRemaining[k];
311
+ const w = westRemaining[k];
312
+ if (e === undefined || w === undefined)
313
+ continue;
314
+ eastToWest.set(e, w);
315
+ usedEast.add(e);
316
+ usedWest.add(w);
317
+ }
318
+ const mergedHoles = [];
319
+ for (let e = 0; e < eastCount; e++) {
320
+ const eastHole = eastHoles[e];
321
+ if (eastHole === undefined)
322
+ continue;
323
+ const w = eastToWest.get(e);
324
+ if (w !== undefined) {
325
+ const westHoleOriginal = westHolesOriginal[w];
326
+ if (westHoleOriginal !== undefined) {
327
+ const stitchedHole = tryMergeHolePair(eastHole, westHoleOriginal);
328
+ if (stitchedHole !== null) {
329
+ mergedHoles.push(stitchedHole);
330
+ }
331
+ else {
332
+ mergedHoles.push(eastHole, shiftRing(westHoleOriginal, 360));
333
+ }
334
+ }
335
+ }
336
+ else {
337
+ mergedHoles.push(eastHole);
338
+ }
339
+ }
340
+ for (let w = 0; w < westCount; w++) {
341
+ if (usedWest.has(w))
342
+ continue;
343
+ const westHoleOriginal = westHolesOriginal[w];
344
+ if (westHoleOriginal !== undefined) {
345
+ mergedHoles.push(shiftRing(westHoleOriginal, 360));
346
+ }
347
+ }
348
+ return mergedHoles;
349
+ };
350
+ /**
351
+ * Attempt to merge antimeridian-split sub-polygons within a MultiPolygon.
352
+ * Returns the merged coordinate arrays. Matching east/west pairs are stitched
353
+ * into single polygons; non-matching sub-polygons pass through unchanged.
354
+ */
355
+ const mergeMultiPolygonGeometry = (geometry) => {
356
+ const classified = geometry.coordinates.map(classifySubPolygon);
357
+ const used = new Set();
358
+ const mergedPolygons = [];
359
+ for (let i = 0; i < classified.length; i++) {
360
+ if (used.has(i))
361
+ continue;
362
+ const ci = classified[i] ?? null;
363
+ if (ci === null || ci.side !== "east") {
364
+ continue;
365
+ }
366
+ let matched = false;
367
+ for (let j = 0; j < classified.length; j++) {
368
+ if (i === j || used.has(j))
369
+ continue;
370
+ const cj = classified[j] ?? null;
371
+ if (cj === null || cj.side !== "west")
372
+ continue;
373
+ if (runsMatch(ci.run, cj.run)) {
374
+ used.add(i);
375
+ used.add(j);
376
+ const eastExterior = ci.rings[0];
377
+ const westExterior = cj.rings[0];
378
+ if (eastExterior === undefined || westExterior === undefined)
379
+ continue;
380
+ const mergedExterior = stitchRings(eastExterior, ci.run, westExterior, cj.run);
381
+ const eastHoles = ci.rings.slice(1);
382
+ const westHolesOriginal = cj.rings.slice(1);
383
+ const mergedHoles = buildMergedInteriorRings(eastHoles, westHolesOriginal);
384
+ mergedPolygons.push([mergedExterior, ...mergedHoles]);
385
+ matched = true;
386
+ break;
387
+ }
388
+ }
389
+ if (!matched) {
390
+ used.add(i);
391
+ mergedPolygons.push(ci.rings);
392
+ }
393
+ }
394
+ for (let i = 0; i < geometry.coordinates.length; i++) {
395
+ if (!used.has(i)) {
396
+ const coords = geometry.coordinates[i];
397
+ if (coords !== undefined) {
398
+ mergedPolygons.push(coords);
399
+ }
400
+ }
401
+ }
402
+ return mergedPolygons;
403
+ };
404
+ const classifySubLine = (line) => {
405
+ const eastRun = findAntimeridianRunOnPolyline(line, isAtEasternAntimeridian);
406
+ if (eastRun !== null) {
407
+ return { line, run: eastRun, side: "east" };
408
+ }
409
+ const westRun = findAntimeridianRunOnPolyline(line, isAtWesternAntimeridian);
410
+ if (westRun !== null) {
411
+ return { line, run: westRun, side: "west" };
412
+ }
413
+ return null;
414
+ };
415
+ /**
416
+ * Merge antimeridian-split sub-lines within a MultiLineString into one LineString when possible.
417
+ * Returns the input reference when no east/west pair qualifies.
418
+ */
419
+ const mergeMultiLineStringGeometry = (geometry) => {
420
+ const lines = geometry.coordinates.map(line => line.map((pos) => [...pos]));
421
+ let anyMerged = false;
422
+ for (;;) {
423
+ const length = lines.length;
424
+ const classified = lines.map(classifySubLine);
425
+ let pair = null;
426
+ outer: for (let i = 0; i < length; i++) {
427
+ const ci = classified[i] ?? null;
428
+ if (ci === null || ci.side !== "east")
429
+ continue;
430
+ for (let j = 0; j < length; j++) {
431
+ if (i === j)
432
+ continue;
433
+ const cj = classified[j] ?? null;
434
+ if (cj === null || cj.side !== "west")
435
+ continue;
436
+ if (runsMatch(ci.run, cj.run)) {
437
+ pair = { eastIndex: i, westIndex: j };
438
+ break outer;
439
+ }
440
+ }
441
+ }
442
+ if (pair === null) {
443
+ break;
444
+ }
445
+ const eastC = classified[pair.eastIndex];
446
+ const westC = classified[pair.westIndex];
447
+ if (eastC === undefined || westC === undefined || eastC === null || westC === null) {
448
+ break;
449
+ }
450
+ const stitched = stitchPolylines(eastC.line, eastC.run, westC.line, westC.run);
451
+ const i0 = Math.min(pair.eastIndex, pair.westIndex);
452
+ const j0 = Math.max(pair.eastIndex, pair.westIndex);
453
+ lines.splice(j0, 1);
454
+ lines.splice(i0, 1);
455
+ lines.splice(i0, 0, stitched);
456
+ anyMerged = true;
457
+ }
458
+ if (!anyMerged) {
459
+ return geometry;
460
+ }
461
+ if (lines.length === 1 && lines[0] !== undefined) {
462
+ return {
463
+ type: "LineString",
464
+ coordinates: lines[0],
465
+ };
466
+ }
467
+ return {
468
+ type: "MultiLineString",
469
+ coordinates: lines,
470
+ };
471
+ };
472
+ // ============================================================================
473
+ // Feature-level transformation
474
+ // ============================================================================
475
+ const mergeFeature = (feature) => {
476
+ if (feature.geometry === null) {
477
+ return feature;
478
+ }
479
+ switch (feature.geometry.type) {
480
+ case "MultiPolygon": {
481
+ const mergedCoordinates = mergeMultiPolygonGeometry(feature.geometry);
482
+ const firstMerged = mergedCoordinates[0];
483
+ if (mergedCoordinates.length === 1 && firstMerged !== undefined) {
484
+ return {
485
+ ...feature,
486
+ geometry: {
487
+ type: "Polygon",
488
+ coordinates: firstMerged,
489
+ },
490
+ };
491
+ }
492
+ if (mergedCoordinates.length === feature.geometry.coordinates.length) {
493
+ let changed = false;
494
+ for (let i = 0; i < mergedCoordinates.length; i++) {
495
+ if (mergedCoordinates[i] !== feature.geometry.coordinates[i]) {
496
+ changed = true;
497
+ break;
498
+ }
499
+ }
500
+ if (!changed)
501
+ return feature;
502
+ }
503
+ return {
504
+ ...feature,
505
+ geometry: {
506
+ type: "MultiPolygon",
507
+ coordinates: mergedCoordinates,
508
+ },
509
+ };
510
+ }
511
+ case "MultiLineString": {
512
+ const mergedGeometry = mergeMultiLineStringGeometry(feature.geometry);
513
+ if (mergedGeometry === feature.geometry) {
514
+ return feature;
515
+ }
516
+ return {
517
+ ...feature,
518
+ geometry: mergedGeometry,
519
+ };
520
+ }
521
+ default:
522
+ return feature;
523
+ }
524
+ };
525
+ // ============================================================================
526
+ // Exported entry point
527
+ // ============================================================================
528
+ /**
529
+ * Pre-process a GeoJSON FeatureCollection for antimeridian-aware rendering.
530
+ *
531
+ * - MultiPolygon features that represent a single polygon split at the antimeridian
532
+ * (per RFC 7946 Section 3.1.9) are merged back into single Polygons with
533
+ * unwrapped coordinates (lng values may exceed 180). Interior rings that are also
534
+ * split at ±180° with matching seam latitudes are stitched the same way as exteriors.
535
+ *
536
+ * - MultiLineString features that represent one route split at ±180° are merged
537
+ * into a single LineString with the same unwrapping. This eliminates visible strokes
538
+ * at the antimeridian and ensures hover/selection treats each shape as one entity.
539
+ *
540
+ * The input collection is never mutated. Features that don't need merging
541
+ * are returned by reference.
542
+ *
543
+ * **Important:** The output contains coordinates outside [-180, 180] and is
544
+ * intended only for rendering via Google Maps or Mapbox, which handle
545
+ * world-wrapped coordinates natively. Do not pass the output to GeoJSON
546
+ * validators or store it.
547
+ */
548
+ const mergeAntimeridianFeatures = (features) => {
549
+ const mergedFeatures = features.features.map(mergeFeature);
550
+ const anyChanged = mergedFeatures.some((merged, i) => merged !== features.features[i]);
551
+ if (!anyChanged)
552
+ return features;
553
+ return {
554
+ ...features,
555
+ features: mergedFeatures,
556
+ };
557
+ };
558
+
559
+ // TODO (next PR): colorUtils is a generic CSS utility with no adapter-specific knowledge.
560
+ // Extract to a standalone utility package so it isn't coupled to the adapter layer.
561
+ /**
562
+ * Browser-backed color mixing via CSS `color-mix()`.
563
+ *
564
+ * Uses a hidden DOM element + `getComputedStyle` to resolve expressions,
565
+ * cached so each unique expression is computed at most once.
566
+ */
567
+ const colorCache = new Map();
568
+ /**
569
+ * Modern browsers return CSS Color Level 4 `color(srgb r g b)` from
570
+ * `getComputedStyle`, but Mapbox GL JS only accepts classic formats
571
+ * (hex, rgb, rgba, hsl, hsla). Convert to `rgb()`/`rgba()`.
572
+ */
573
+ const normalizeComputedColor = (color) => {
574
+ const m = color.match(/^color\(srgb\s+([\d.e+-]+)\s+([\d.e+-]+)\s+([\d.e+-]+)(?:\s*\/\s*([\d.e+-]+))?\)$/);
575
+ if (!m)
576
+ return color;
577
+ const r = Math.round(parseFloat(m[1] ?? "0") * 255);
578
+ const g = Math.round(parseFloat(m[2] ?? "0") * 255);
579
+ const b = Math.round(parseFloat(m[3] ?? "0") * 255);
580
+ if (m[4] !== undefined) {
581
+ return `rgba(${r}, ${g}, ${b}, ${parseFloat(m[4])})`;
582
+ }
583
+ return `rgb(${r}, ${g}, ${b})`;
584
+ };
585
+ let probeElement = null;
586
+ const getProbeElement = () => {
587
+ if (probeElement) {
588
+ return probeElement;
589
+ }
590
+ if (typeof document === "undefined") {
591
+ return null;
592
+ }
593
+ const el = document.createElement("span");
594
+ el.style.display = "none";
595
+ document.body.appendChild(el);
596
+ probeElement = el;
597
+ return el;
598
+ };
599
+ const resolveColorMix = (expression, fallback) => {
600
+ const cached = colorCache.get(expression);
601
+ if (cached !== undefined) {
602
+ return cached;
603
+ }
604
+ const el = getProbeElement();
605
+ if (!el) {
606
+ return fallback;
607
+ }
608
+ el.style.color = "";
609
+ el.style.color = expression;
610
+ if (!el.style.color) {
611
+ return fallback;
612
+ }
613
+ const computed = getComputedStyle(el).color;
614
+ if (!computed) {
615
+ return fallback;
616
+ }
617
+ const normalized = normalizeComputedColor(computed);
618
+ colorCache.set(expression, normalized);
619
+ return normalized;
620
+ };
621
+ /**
622
+ * Mix two CSS colors using the browser's `color-mix(in srgb)` function.
623
+ *
624
+ * @param color1 - First color (any valid CSS color string)
625
+ * @param color2 - Second color (any valid CSS color string)
626
+ * @param percentage - Percentage of `color1` in the mix (0–100)
627
+ * @returns Resolved color as an `rgb()` string, or `color1` if resolution fails
628
+ */
629
+ const mixColor = (color1, color2, percentage) => {
630
+ const expression = `color-mix(in srgb, ${color1} ${percentage}%, ${color2})`;
631
+ return resolveColorMix(expression, color1);
632
+ };
633
+ /**
634
+ * Darken a CSS color by mixing it with black.
635
+ *
636
+ * @param color - Any valid CSS color string
637
+ * @param amount - Darkening intensity from 0 (no change) to 100 (pure black)
638
+ */
639
+ const darkenColor = (color, amount) => mixColor(color, "black", 100 - amount);
640
+ /**
641
+ * Lighten a CSS color by mixing it with white.
642
+ *
643
+ * @param color - Any valid CSS color string
644
+ * @param amount - Lightening intensity from 0 (no change) to 100 (pure white)
645
+ */
646
+ const lightenColor = (color, amount) => mixColor(color, "white", 100 - amount);
647
+ /**
648
+ * Resolve a CSS color and apply an opacity multiplier to its alpha channel.
649
+ *
650
+ * Unlike setting `element.style.opacity`, this only affects the individual
651
+ * color value — useful when fill and stroke need independent opacity.
652
+ *
653
+ * @param color - Any valid CSS color string
654
+ * @param opacity - Opacity multiplier from 0 to 1 (multiplied with existing alpha)
655
+ * @returns `rgba()` string with the combined alpha, or the original color if resolution fails
656
+ */
657
+ const colorWithOpacity = (color, opacity) => {
658
+ if (opacity >= 1)
659
+ return color;
660
+ const cacheKey = `${color}@${opacity}`;
661
+ const cached = colorCache.get(cacheKey);
662
+ if (cached !== undefined)
663
+ return cached;
664
+ const el = getProbeElement();
665
+ if (!el)
666
+ return color;
667
+ el.style.color = "";
668
+ el.style.color = color;
669
+ const computed = getComputedStyle(el).color;
670
+ if (!computed)
671
+ return color;
672
+ const normalized = normalizeComputedColor(computed);
673
+ const rgbaMatch = normalized.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/);
674
+ if (!rgbaMatch)
675
+ return color;
676
+ const existingAlpha = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
677
+ const result = `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, ${existingAlpha * opacity})`;
678
+ colorCache.set(cacheKey, result);
679
+ return result;
680
+ };
681
+ /**
682
+ * Clear the resolved-color cache and detach the probe element.
683
+ * Exposed for test teardown only — not part of the public API.
684
+ */
685
+ const resetColorUtilsForTesting = () => {
686
+ colorCache.clear();
687
+ if (probeElement) {
688
+ probeElement.remove();
689
+ probeElement = null;
690
+ }
691
+ };
692
+
693
+ /**
694
+ * Web Mercator max latitude in degrees: atan(sinh(π)).
695
+ * This is where Mercator tiles are cut off to form a square map (EPSG:3857).
696
+ */
697
+ const WEB_MERCATOR_MAX_LAT_DEG = (Math.atan(Math.sinh(Math.PI)) * 180) / Math.PI;
698
+ /**
699
+ * World bounds for Web Mercator projection.
700
+ * GeoJSON format: [minLng, minLat, maxLng, maxLat]
701
+ */
702
+ const WORLD_BBOX = [-180, -WEB_MERCATOR_MAX_LAT_DEG, 180, WEB_MERCATOR_MAX_LAT_DEG];
703
+ /**
704
+ * Default zoom level when not specified
705
+ */
706
+ const DEFAULT_ZOOM = 2;
707
+ /**
708
+ * Default center when not specified (Atlantic Ocean)
709
+ * Position format: { lat: number, lng: number }
710
+ */
711
+ const DEFAULT_CENTER = { lat: 0, lng: 0 };
712
+ /**
713
+ * Minimum zoom level supported across providers
714
+ * Both Mapbox and Google Maps support zoom level 0
715
+ */
716
+ const MIN_ZOOM = 0;
717
+ /**
718
+ * Maximum zoom level supported across providers
719
+ * Both Mapbox and Google Maps support zoom level 22
720
+ */
721
+ const MAX_ZOOM = 22;
722
+ /**
723
+ * Amount to pan the map when using arrow keys (in pixels)
724
+ */
725
+ const KEYBOARD_PAN_AMOUNT = 100;
726
+ /**
727
+ * Amount to zoom when using +/- keys
728
+ */
729
+ const KEYBOARD_ZOOM_AMOUNT = 1;
730
+ /**
731
+ * CSS cursor values used across map adapters.
732
+ * Centralised so both Mapbox and Google stay in sync.
733
+ */
734
+ const MAP_CURSORS = {
735
+ default: "default",
736
+ interactive: "pointer",
737
+ };
738
+ /**
739
+ * Estimate zoom level from a bounding box using Web Mercator projection.
740
+ * Both Google Maps and Mapbox use the same 256px tile / 2^zoom formula,
741
+ * so a simple `log2(360 / lonSpan)` gives a close approximation without
742
+ * needing the container size. Used by adapter constructors to provide
743
+ * a reasonable initial zoom before the native map instance is created.
744
+ */
745
+ const estimateZoomFromBounds = (bounds) => {
746
+ const lonSpan = bounds[2] - bounds[0];
747
+ if (lonSpan <= 0)
748
+ return DEFAULT_ZOOM;
749
+ return Math.log2(360 / lonSpan);
750
+ };
751
+ const mercatorY = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
752
+ /**
753
+ * Compute the visual center of a bounding box in Web Mercator projection.
754
+ * The arithmetic mean of lat/lng gives a wrong center because Mercator
755
+ * stretches latitudes non-linearly. This converts to Mercator Y, averages
756
+ * there, and converts back — matching what Google Maps and Mapbox render.
757
+ */
758
+ const mercatorCenterFromBounds = (bounds) => {
759
+ const [minLon, minLat, maxLon, maxLat] = bounds;
760
+ const centerLon = (minLon + maxLon) / 2;
761
+ const centerLat = ((Math.atan(Math.exp((mercatorY(minLat) + mercatorY(maxLat)) / 2)) - Math.PI / 4) * 360) / Math.PI;
762
+ return [centerLon, centerLat];
763
+ };
764
+
765
+ const isViewportLike = (value) => {
766
+ return typeof value === "object" && value !== null && "type" in value;
767
+ };
768
+ /**
769
+ * Validates InitialViewport and returns undefined for invalid values.
770
+ */
771
+ const validateInitialViewport = (value) => {
772
+ if (value === null || value === undefined) {
773
+ return undefined;
774
+ }
775
+ if (!isViewportLike(value)) {
776
+ // eslint-disable-next-line no-console -- Intentional: warn developers when initialViewport shape is invalid; fallback to undefined
777
+ console.warn("[initialViewport] Invalid: expected object with type 'center' or 'bounds'");
778
+ return undefined;
779
+ }
780
+ if (value.type === "center") {
781
+ const center = geoJsonUtils.validatePosition(value.center, "[initialViewport]");
782
+ if (center === null) {
783
+ return undefined;
784
+ }
785
+ return {
786
+ type: "center",
787
+ center,
788
+ zoom: typeof value.zoom === "number" ? value.zoom : undefined,
789
+ };
790
+ }
791
+ if (value.type === "bounds") {
792
+ const bounds = geoJsonUtils.validateBbox(value.bounds, "[initialViewport]");
793
+ if (bounds === null) {
794
+ return undefined;
795
+ }
796
+ return {
797
+ type: "bounds",
798
+ bounds,
799
+ padding: typeof value.padding === "number" ? value.padding : undefined,
800
+ maxZoom: typeof value.maxZoom === "number" ? value.maxZoom : undefined,
801
+ };
802
+ }
803
+ // eslint-disable-next-line no-console -- Intentional: warn developers when initialViewport type is unknown; fallback to undefined
804
+ console.warn("[initialViewport] Invalid: expected type 'center' or 'bounds'");
805
+ return undefined;
806
+ };
807
+
808
+ /**
809
+ * Maps a primitive GeoJSON geometry type to the ShapeType discriminant.
810
+ */
811
+ const geometryTypeToShapeType = (geometryType) => {
812
+ switch (geometryType) {
813
+ case "Polygon":
814
+ case "MultiPolygon":
815
+ return "polygon";
816
+ case "Point":
817
+ case "MultiPoint":
818
+ return "point";
819
+ case "LineString":
820
+ case "MultiLineString":
821
+ return "line";
822
+ default:
823
+ throw new Error(`${geometryType} is not a known primitive geometry type`);
824
+ }
825
+ };
826
+ /**
827
+ * Initial interaction state -- nothing selected or hovered.
828
+ */
829
+ const INITIAL_INTERACTION_STATE = {
830
+ selectedEntity: null,
831
+ hoveredEntity: null,
832
+ };
833
+
834
+ const GEOMETRY_WEIGHT = {
835
+ cluster: 0,
836
+ circle: 1,
837
+ pill: 2,
838
+ stick: 3,
839
+ };
840
+ const PHASE_WEIGHT = {
841
+ expanded: 0,
842
+ idle: 1,
843
+ hovered: 2,
844
+ selected: 3,
845
+ };
846
+ /** Room for new phases without overlapping the next geometry band. */
847
+ const GEOMETRY_STRIDE = 10;
848
+ /**
849
+ * Single source of truth for DOM marker portal `z-index` values.
850
+ *
851
+ * Higher geometry tiers always sort above lower tiers regardless of phase
852
+ * (e.g. any `pill` stacks above any `circle`). Within a tier, phase orders
853
+ * expanded → idle → hovered → selected.
854
+ */
855
+ const computeMarkerDomPortalZIndex = (geometry, phase) => {
856
+ return GEOMETRY_WEIGHT[geometry] * GEOMETRY_STRIDE + PHASE_WEIGHT[phase];
857
+ };
858
+
859
+ // ============================================================================
860
+ // GeoJSON extraction helpers
861
+ // ============================================================================
862
+ /**
863
+ * Extract {lng, lat} coordinates from a GeoJSON Point feature's geometry.
864
+ * Returns null if the geometry is null (unlocated feature) or not a Point.
865
+ */
866
+ const extractPointCoordinates = (feature) => {
867
+ if (feature.geometry === null || feature.geometry.type !== "Point")
868
+ return null;
869
+ const [lng, lat] = feature.geometry.coordinates;
870
+ return { lng, lat };
871
+ };
872
+ /**
873
+ * Extract coordinates array from a GeoJSON LineString feature.
874
+ * Returns an array of {lat, lng} objects, or null if empty.
875
+ * Returns null if the geometry is null (unlocated feature) or not a LineString.
876
+ */
877
+ const extractLineCoordinates = (feature) => {
878
+ if (feature.geometry === null || feature.geometry.type !== "LineString")
879
+ return null;
880
+ const path = feature.geometry.coordinates.map(([lng, lat]) => ({ lng, lat }));
881
+ return path.length > 0 ? path : null;
882
+ };
883
+ /**
884
+ * Extract coordinates from GeoJSON Polygon coordinates.
885
+ * Returns an array of rings (outer ring + holes), each an array of {lat, lng}.
886
+ */
887
+ const extractPolygonPaths = (coordinates) => {
888
+ if (!Array.isArray(coordinates))
889
+ return null;
890
+ const paths = [];
891
+ for (const ring of coordinates) {
892
+ if (!Array.isArray(ring))
893
+ continue;
894
+ const path = [];
895
+ for (const coord of ring) {
896
+ if (!Array.isArray(coord) || coord.length < 2)
897
+ continue;
898
+ const lng = coord[0];
899
+ const lat = coord[1];
900
+ if (typeof lng !== "number" || typeof lat !== "number")
901
+ continue;
902
+ path.push({ lat, lng });
903
+ }
904
+ if (path.length > 0) {
905
+ paths.push(path);
906
+ }
907
+ }
908
+ return paths.length > 0 ? paths : null;
909
+ };
910
+ // ============================================================================
911
+ // Feature comparison helpers
912
+ // ============================================================================
913
+ /**
914
+ * Check whether two GeoJSON feature collections contain the same features
915
+ * by comparing the count and individual feature IDs. Returns true if both
916
+ * are null, or if they have the same number of features with matching IDs
917
+ * in the same order.
918
+ *
919
+ * Used by adapters to skip unnecessary full marker rebuilds when a new config
920
+ * object is created but the underlying data hasn't changed (e.g. only callback
921
+ * references differ due to a React re-render).
922
+ */
923
+ const hasSameFeatureIds = (a, b) => {
924
+ if (a === b)
925
+ return true;
926
+ if (a === null || b === null)
927
+ return false;
928
+ const af = a.features, bf = b.features;
929
+ if (af.length !== bf.length)
930
+ return false;
931
+ for (let i = 0; i < af.length; i++) {
932
+ const featureA = af[i];
933
+ const featureB = bf[i];
934
+ if (featureA === undefined || featureB === undefined)
935
+ return false;
936
+ if (featureA.id !== featureB.id)
937
+ return false;
938
+ }
939
+ return true;
940
+ };
941
+ /**
942
+ * True when paired features (same length / ids per {@link hasSameFeatureIds}) share identical
943
+ * Point coordinates. Non-Point or missing geometries must match structurally (both null/absent).
944
+ */
945
+ const markerPointPositionsMatch = (existing, incoming) => {
946
+ const { features: af } = existing;
947
+ const { features: bf } = incoming;
948
+ if (af.length !== bf.length)
949
+ return false;
950
+ for (let i = 0; i < af.length; i++) {
951
+ const featureA = af[i];
952
+ const featureB = bf[i];
953
+ if (featureA === undefined || featureB === undefined)
954
+ return false;
955
+ const posA = extractPointCoordinates(featureA);
956
+ const posB = extractPointCoordinates(featureB);
957
+ if (posA === null && posB === null)
958
+ continue;
959
+ if (posA === null || posB === null)
960
+ return false;
961
+ if (posA.lng !== posB.lng || posA.lat !== posB.lat)
962
+ return false;
963
+ }
964
+ return true;
965
+ };
966
+ const markerRenderAnchor = (render) => render.mode === "dom" ? render.anchor : undefined;
967
+ const markerRenderPixelOffset = (render) => render.mode === "dom" ? render.pixelOffset : undefined;
968
+ const pixelOffsetEqual = (a, b) => a === b || (a !== undefined && b !== undefined && a.x === b.x && a.y === b.y);
969
+ /**
970
+ * Determines whether an existing marker source can be patched in place
971
+ * (position + portal update) instead of requiring a full teardown/rebuild.
972
+ *
973
+ * A full rebuild is required when the Mapbox/Google marker `anchor` or
974
+ * `pixelOffset` changes, because both are immutable after marker construction.
975
+ * Without this check the DOM marker keeps a stale anchor/offset while React
976
+ * renders with the new values.
977
+ */
978
+ const canPatchMarkerInPlace = (existing, incoming) => {
979
+ if (existing.markerRender.mode === "adaptive" || incoming.markerRender.mode === "adaptive") {
980
+ // Adaptive mode re-evaluates medium selection and can swap symbol/DOM backends;
981
+ // reuse the dedicated adaptive patch path instead of the static DOM/symbol patcher.
982
+ return false;
983
+ }
984
+ if (existing.kind !== incoming.kind)
985
+ return false;
986
+ const baseMatch = existing.markerRender.mode === incoming.markerRender.mode &&
987
+ markerRenderAnchor(existing.markerRender) === markerRenderAnchor(incoming.markerRender) &&
988
+ pixelOffsetEqual(markerRenderPixelOffset(existing.markerRender), markerRenderPixelOffset(incoming.markerRender)) &&
989
+ hasSameFeatureIds(existing.features, incoming.features);
990
+ if (!baseMatch)
991
+ return false;
992
+ // Canvas symbol overlays snapshot marker positions when built; the DOM patch path
993
+ // updates coordinates in place. If only Point coords move, force a rebuild so symbols redraw.
994
+ const renderMode = incoming.markerRender.mode;
995
+ if (renderMode === "symbol") {
996
+ if (!markerPointPositionsMatch(existing.features, incoming.features)) {
997
+ return false;
998
+ }
999
+ }
1000
+ if (existing.kind === "marker" && incoming.kind === "marker") {
1001
+ return (existing.clusterConfig?.mode === incoming.clusterConfig?.mode &&
1002
+ hasSameFeatureIds(existing.clusterFeatures, incoming.clusterFeatures));
1003
+ }
1004
+ return true;
1005
+ };
1006
+ // ============================================================================
1007
+ // Data extraction and style resolution
1008
+ // ============================================================================
1009
+ /**
1010
+ * Extract the original source data from GeoJSON feature properties.
1011
+ * useMarkers stores the original TItem / TCluster in properties.__data
1012
+ * so that style functions receive the full typed object, not raw GeoJSON properties.
1013
+ */
1014
+ const extractSourceData = (properties) => {
1015
+ if (properties !== null && "__data" in properties) {
1016
+ return properties.__data;
1017
+ }
1018
+ return properties;
1019
+ };
1020
+ const isPortalSourceComparableObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1021
+ const normalizePortalSourceComparable = (value) => {
1022
+ if (value === null) {
1023
+ return null;
1024
+ }
1025
+ if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1026
+ return value;
1027
+ }
1028
+ if (typeof value !== "object") {
1029
+ // Catches symbol, bigint, function, and undefined
1030
+ return String(value);
1031
+ }
1032
+ if (Array.isArray(value)) {
1033
+ return value.map(normalizePortalSourceComparable);
1034
+ }
1035
+ const keys = Object.keys(value).toSorted();
1036
+ const out = {};
1037
+ for (const k of keys) {
1038
+ out[k] = normalizePortalSourceComparable(Reflect.get(value, k));
1039
+ }
1040
+ return out;
1041
+ };
1042
+ /**
1043
+ * Stable JSON-like serialization (order-independent object keys) for comparing
1044
+ * {@link PortalSourceComparable} values.
1045
+ */
1046
+ const stableSerializePortalComparable = (value) => {
1047
+ if (value === null) {
1048
+ return "null";
1049
+ }
1050
+ if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1051
+ return JSON.stringify(value);
1052
+ }
1053
+ if (Array.isArray(value)) {
1054
+ return `[${value.map(stableSerializePortalComparable).join(",")}]`;
1055
+ }
1056
+ if (!isPortalSourceComparableObject(value)) {
1057
+ throw new Error("[layerPortHelpers] stableSerializePortalComparable: unreachable branch");
1058
+ }
1059
+ const keys = Object.keys(value).toSorted();
1060
+ return `{${keys
1061
+ .map(k => {
1062
+ const child = value[k];
1063
+ if (child === undefined) {
1064
+ throw new Error(`[layerPortHelpers] stableSerializePortalComparable: missing key ${k}`);
1065
+ }
1066
+ return `${JSON.stringify(k)}:${stableSerializePortalComparable(child)}`;
1067
+ })
1068
+ .join(",")}}`;
1069
+ };
1070
+ const portalSourceDataEqual = (a, b) => stableSerializePortalComparable(normalizePortalSourceComparable(a)) ===
1071
+ stableSerializePortalComparable(normalizePortalSourceComparable(b));
1072
+ /**
1073
+ * Patch portal descriptors for features whose data or render function changed,
1074
+ * preserving the existing container and key so React can update in-place
1075
+ * without a remove-then-add cycle (which causes visible flickering).
1076
+ *
1077
+ * Pure function: returns a new descriptors array and a `changed` flag.
1078
+ * Both adapters delegate to this from their `patchDomMarkers` method.
1079
+ */
1080
+ const patchPortalDescriptors = (descriptors, sourceId, features, render) => {
1081
+ const result = Array.from(descriptors);
1082
+ let changed = false;
1083
+ for (const feature of features) {
1084
+ const featureId = feature.id !== undefined ? String(feature.id) : "";
1085
+ const key = `${sourceId}:${featureId}`;
1086
+ const idx = result.findIndex(d => d.key === key);
1087
+ const existing = idx !== -1 ? result[idx] : undefined;
1088
+ if (existing !== undefined) {
1089
+ const nextSourceData = extractSourceData(feature.properties);
1090
+ const renderSame = existing.renderFn === render;
1091
+ const dataSame = portalSourceDataEqual(existing.sourceData, nextSourceData);
1092
+ if (renderSame && dataSame) {
1093
+ continue;
1094
+ }
1095
+ result[idx] = {
1096
+ ...existing,
1097
+ renderFn: render,
1098
+ sourceData: nextSourceData,
1099
+ };
1100
+ changed = true;
1101
+ }
1102
+ }
1103
+ return { descriptors: result, changed };
1104
+ };
1105
+ /**
1106
+ * Defaults for a {@link CircleSymbolDescriptor} (per ADR-0011).
1107
+ * - `diameterPx`: 16
1108
+ * - `opacity`: 1
1109
+ * - `borderColor`: null (no border)
1110
+ * - `borderWidthPx`: 0 (no border)
1111
+ */
1112
+ const CIRCLE_SYMBOL_DEFAULT_DIAMETER_PX = 16;
1113
+ const CIRCLE_SYMBOL_DEFAULT_OPACITY = 1;
1114
+ /**
1115
+ * Fill {@link CircleSymbolDescriptor} defaults and compute `radiusPx`
1116
+ * (`= diameterPx / 2`) and `hasBorder` (`= borderColor !== null && borderWidthPx > 0`).
1117
+ *
1118
+ * Pure function — adapters use this once per feature to derive the values they
1119
+ * pass to their style format (Mapbox paint expression or canvas `ctx.arc`).
1120
+ */
1121
+ const resolveCircleSymbolDefaults = (descriptor) => {
1122
+ const diameterPx = descriptor.diameterPx ?? CIRCLE_SYMBOL_DEFAULT_DIAMETER_PX;
1123
+ const opacity = descriptor.opacity ?? CIRCLE_SYMBOL_DEFAULT_OPACITY;
1124
+ const borderColor = descriptor.borderColor ?? null;
1125
+ const borderWidthPx = descriptor.borderWidthPx ?? 0;
1126
+ return {
1127
+ color: descriptor.color,
1128
+ diameterPx,
1129
+ radiusPx: diameterPx / 2,
1130
+ opacity,
1131
+ borderColor,
1132
+ borderWidthPx,
1133
+ hasBorder: borderColor !== null && borderWidthPx > 0,
1134
+ };
1135
+ };
1136
+ /**
1137
+ * Resolve a {@link SymbolDescriptor} by calling the style function with the
1138
+ * source data extracted from `properties.__data`.
1139
+ */
1140
+ const resolveSymbolDescriptor = (style, properties) => {
1141
+ return style(extractSourceData(properties));
1142
+ };
1143
+ /**
1144
+ * Structural runtime check for a {@link SymbolDescriptor}: a non-null,
1145
+ * non-array object with a `color: string` field.
1146
+ */
1147
+ const isSymbolDescriptor = (value) => {
1148
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1149
+ return false;
1150
+ }
1151
+ if (!("color" in value)) {
1152
+ return false;
1153
+ }
1154
+ return typeof value.color === "string";
1155
+ };
1156
+ /**
1157
+ * Discriminate the result of a unified `render(item, state)` callback into a
1158
+ * symbol descriptor or a DOM node (ADR-0009).
1159
+ *
1160
+ * Rules:
1161
+ * - Plain object with `color: string` → `{ kind: "symbol", symbol }`.
1162
+ * - Anything else (strings, numbers, `null`, fragments, iterables) → treated
1163
+ * as a DOM node so consumers can return primitives like text directly.
1164
+ *
1165
+ * When the optional `expectedMedium` argument is set (adaptive routing), a dev-only
1166
+ * `console.warn` runs if the discriminated `kind` disagrees with it — e.g.
1167
+ * the consumer returned a `ReactNode` while `state.medium` was `"symbol"`.
1168
+ * Omitted in production builds (`NODE_ENV === "production"`).
1169
+ */
1170
+ const discriminateRenderResult = (result, expectedMedium) => {
1171
+ let discriminated;
1172
+ if (isSymbolDescriptor(result)) {
1173
+ discriminated = { kind: "symbol", symbol: result };
1174
+ }
1175
+ else {
1176
+ discriminated = { kind: "dom", node: result };
1177
+ }
1178
+ if (expectedMedium !== undefined && discriminated.kind !== expectedMedium && process.env.NODE_ENV !== "production") {
1179
+ // eslint-disable-next-line no-console -- dev-only adaptive render contract check (ADR-0009)
1180
+ console.warn(`[react-map-adapter-shared] Adaptive render return type does not match state.medium: ` +
1181
+ `expected "${expectedMedium}" but the return value was discriminated as "${discriminated.kind}". ` +
1182
+ `Branch on state.medium and return a SymbolDescriptor when medium is "symbol", or a ReactNode when medium is "dom".`);
1183
+ }
1184
+ return discriminated;
1185
+ };
1186
+ /**
1187
+ * Fallback {@link SymbolDescriptor} used when an adaptive `render` returns a
1188
+ * DOM node while the symbol medium was requested. This is a programmer error
1189
+ * (the adaptive contract requires a `SymbolDescriptor` when `state.medium`
1190
+ * is `"symbol"`); a dev-only mismatch warning is emitted when
1191
+ * {@link discriminateRenderResult} is called with `expectedMedium: "symbol"`.
1192
+ */
1193
+ const adaptiveSymbolFallback = () => ({ color: uiDesignTokens.color("NEUTRAL", 400, "HEX") });
1194
+ /**
1195
+ * Build a `(item) → SymbolDescriptor` closure from an adaptive
1196
+ * {@link AdaptiveRenderConfig.render} callback (ADR-0009).
1197
+ *
1198
+ * Adapters that render a feature in symbol medium under `mode: "adaptive"`
1199
+ * call render with the supplied `SymbolRenderState`, then route the result
1200
+ * via {@link discriminateRenderResult}. When the discriminated kind is
1201
+ * `"symbol"`, the descriptor is returned; when it disagrees with the
1202
+ * requested medium (the consumer accidentally returned a `ReactNode`), a
1203
+ * neutral fallback descriptor is returned so rendering can proceed (with a
1204
+ * dev-only `console.warn` via {@link discriminateRenderResult}).
1205
+ */
1206
+ const buildAdaptiveSymbolStyleFn = (render, state) => {
1207
+ return (item) => {
1208
+ const result = render(item, state);
1209
+ const discriminated = discriminateRenderResult(result, "symbol");
1210
+ return discriminated.kind === "symbol" ? discriminated.symbol : adaptiveSymbolFallback();
1211
+ };
1212
+ };
1213
+ /**
1214
+ * Build a `DomPortalDescriptor["renderFn"]` from an adaptive
1215
+ * {@link AdaptiveRenderConfig.render} callback (ADR-0009).
1216
+ *
1217
+ * Adapters wrap the consumer's unified render with this when registering a
1218
+ * DOM portal for an adaptive feature: the closure forwards the
1219
+ * `DomRenderState` (a member of `AdaptiveRenderState`) into render, then
1220
+ * routes the result via {@link discriminateRenderResult}, returning the
1221
+ * `ReactNode` for `kind === "dom"` and `null` for the mismatch case where
1222
+ * the consumer accidentally returned a `SymbolDescriptor` (with a dev-only
1223
+ * `console.warn` via {@link discriminateRenderResult}).
1224
+ */
1225
+ const buildAdaptiveDomRenderFn = (render) => {
1226
+ return (item, state) => {
1227
+ // DomPortalDescriptor's renderFn accepts both marker (DomRenderState) and
1228
+ // cluster (ClusterRenderState) state shapes. Adapters never wire adaptive
1229
+ // markers' render into a cluster portal, so reaching this with a non-DOM
1230
+ // state is a programmer error — return null defensively rather than feed
1231
+ // an invalid state into the adaptive render.
1232
+ if (!("medium" in state))
1233
+ return null;
1234
+ const result = render(item, state);
1235
+ const discriminated = discriminateRenderResult(result, "dom");
1236
+ return discriminated.kind === "dom" ? discriminated.node : null;
1237
+ };
1238
+ };
1239
+ // ============================================================================
1240
+ // DOM element creation (provider-agnostic)
1241
+ // ============================================================================
1242
+ /**
1243
+ * Create a styled cluster pin DOM element.
1244
+ * Used by both Google Maps and Mapbox adapters for symbol-mode cluster rendering.
1245
+ *
1246
+ * Cluster pins keep their always-on white border (visual distinction from
1247
+ * individual markers); only `color` is read from the descriptor.
1248
+ */
1249
+ const createClusterPinElement = (descriptor, count) => {
1250
+ const size = Math.max(36, Math.min(56, 36 + (typeof count === "number" ? count : 0) * 2));
1251
+ const el = document.createElement("div");
1252
+ el.style.width = `${size}px`;
1253
+ el.style.height = `${size}px`;
1254
+ el.style.borderRadius = "50%";
1255
+ el.style.backgroundColor = descriptor.color;
1256
+ el.style.color = "white";
1257
+ el.style.display = "flex";
1258
+ el.style.alignItems = "center";
1259
+ el.style.justifyContent = "center";
1260
+ el.style.fontWeight = "bold";
1261
+ el.style.fontSize = "14px";
1262
+ el.style.boxShadow = "0 2px 6px rgba(0,0,0,0.3)";
1263
+ el.style.border = "2px solid white";
1264
+ el.style.cursor = "pointer";
1265
+ if (typeof count === "number") {
1266
+ el.textContent = String(count);
1267
+ }
1268
+ return el;
1269
+ };
1270
+ /**
1271
+ * Create a default cluster element with the default blue color.
1272
+ */
1273
+ const createDefaultClusterElement = (count) => {
1274
+ return createClusterPinElement({ color: "#4285F4" }, count);
1275
+ };
1276
+ /**
1277
+ * Create a styled dot element for symbol-mode markers (e.g. client-clustering
1278
+ * paths that emit per-marker DOM elements). Uses the resolved descriptor's
1279
+ * `diameterPx` for sizing and applies the border only when `hasBorder` is true.
1280
+ */
1281
+ const createSymbolDotElement = (resolved) => {
1282
+ const el = document.createElement("div");
1283
+ el.style.width = `${resolved.diameterPx}px`;
1284
+ el.style.height = `${resolved.diameterPx}px`;
1285
+ el.style.borderRadius = "50%";
1286
+ el.style.backgroundColor = resolved.color;
1287
+ el.style.border = resolved.hasBorder ? `${resolved.borderWidthPx}px solid ${resolved.borderColor}` : "none";
1288
+ el.style.boxShadow = "0 1px 4px rgba(0,0,0,0.3)";
1289
+ el.style.cursor = "pointer";
1290
+ el.style.opacity = String(resolved.opacity);
1291
+ return el;
1292
+ };
1293
+ // ============================================================================
1294
+ // Adaptive DOM helpers
1295
+ // ============================================================================
1296
+ /**
1297
+ * Feature IDs that render as DOM markers in adaptive mode (i.e. resolved
1298
+ * medium is `"dom"`).
1299
+ */
1300
+ const getAdaptiveDomFeatureIds = (config) => {
1301
+ const ids = new Set();
1302
+ const { markerRender } = config;
1303
+ if (markerRender.mode !== "adaptive")
1304
+ return ids;
1305
+ if (config.kind !== "marker")
1306
+ return ids;
1307
+ const adaptiveResolution = config.adaptiveResolution;
1308
+ if (adaptiveResolution === undefined)
1309
+ return ids;
1310
+ for (const feature of config.features.features) {
1311
+ if (feature.id === undefined)
1312
+ continue;
1313
+ const id = String(feature.id);
1314
+ const mode = adaptiveResolution.modesByFeatureId.get(id);
1315
+ if (mode === "dom") {
1316
+ ids.add(id);
1317
+ }
1318
+ }
1319
+ return ids;
1320
+ };
1321
+ // ============================================================================
1322
+ // Adaptive DOM overlay helpers
1323
+ // ============================================================================
1324
+ /**
1325
+ * Returns true when a render mode requires the WebGL/canvas circle layer
1326
+ * (symbol or adaptive). Use to branch between the canvas path and
1327
+ * the individual DOM-marker-per-feature path.
1328
+ */
1329
+ const isCanvasMarkerMode = (mode) => mode === "symbol" || mode === "adaptive";
1330
+ /**
1331
+ * Shared loop body for adaptive DOM overlay creation. Handles guards, feature
1332
+ * iteration, and renderFn construction, then delegates marker creation to the
1333
+ * adapter via `createMarker`.
1334
+ *
1335
+ * Returns one entry per DOM-rendered feature so each adapter can apply its own
1336
+ * post-creation work (zIndex, interaction listeners, tracked-marker bookkeeping).
1337
+ */
1338
+ const buildAdaptiveDomEntries = (config, createMarker) => {
1339
+ const { markerRender } = config;
1340
+ if (markerRender.mode !== "adaptive")
1341
+ return [];
1342
+ if (config.kind !== "marker")
1343
+ return [];
1344
+ const resolution = config.adaptiveResolution;
1345
+ if (resolution === undefined)
1346
+ return [];
1347
+ const entries = [];
1348
+ /** Non-empty feature ids already given a DOM marker — avoids duplicate portal keys when the collection repeats the same `id`. */
1349
+ const domPortalCreatedForId = new Set();
1350
+ for (const feature of config.features.features) {
1351
+ const coords = extractPointCoordinates(feature);
1352
+ if (coords === null)
1353
+ continue;
1354
+ const featureId = feature.id !== undefined ? String(feature.id) : "";
1355
+ const mode = resolution.modesByFeatureId.get(featureId) ?? "symbol";
1356
+ if (mode !== "dom")
1357
+ continue;
1358
+ if (featureId !== "" && domPortalCreatedForId.has(featureId))
1359
+ continue;
1360
+ const data = extractSourceData(feature.properties);
1361
+ const renderFn = buildAdaptiveDomRenderFn(markerRender.render);
1362
+ const domMarker = createMarker(featureId, coords, config.id, data, renderFn);
1363
+ entries.push({ featureId, coords, domMarker });
1364
+ if (featureId !== "")
1365
+ domPortalCreatedForId.add(featureId);
1366
+ }
1367
+ return entries;
1368
+ };
1369
+ // ============================================================================
1370
+ // Viewport-patch helpers (shared across adapters)
1371
+ // ============================================================================
1372
+ /**
1373
+ * Collect the set of non-empty feature IDs from a GeoJSON feature collection.
1374
+ * Returns an empty set when `collection` is null.
1375
+ *
1376
+ * Used by adapters to diff feature ID sets between config updates so they can
1377
+ * remove gone markers and add new ones without a full teardown/rebuild.
1378
+ */
1379
+ const collectFeatureIdSet = (collection) => {
1380
+ const ids = new Set();
1381
+ if (collection === null)
1382
+ return ids;
1383
+ for (const feature of collection.features) {
1384
+ if (feature.id !== undefined) {
1385
+ ids.add(String(feature.id));
1386
+ }
1387
+ }
1388
+ return ids;
1389
+ };
1390
+ /**
1391
+ * Returns true when an adaptive marker source can be patched incrementally
1392
+ * after a viewport refetch — i.e. structural config (render mode, anchor,
1393
+ * clustering mode, cluster render mode) matches between existing and incoming,
1394
+ * but marker/cluster feature IDs may differ.
1395
+ *
1396
+ * When this returns true, adapters should diff by feature ID (add new,
1397
+ * remove gone) instead of doing a full teardown/rebuild. This prevents
1398
+ * existing DOM markers from flickering when only the set of visible IDs changes.
1399
+ *
1400
+ * Callback references (render functions) are intentionally not compared:
1401
+ * they change on every React render and are always captured fresh during the patch.
1402
+ */
1403
+ const canPatchAdaptiveViewport = (existing, incoming) => {
1404
+ if (existing.kind !== "marker" || incoming.kind !== "marker")
1405
+ return false;
1406
+ if (existing.markerRender.mode !== "adaptive" || incoming.markerRender.mode !== "adaptive")
1407
+ return false;
1408
+ if (existing.markerRender.anchor !== incoming.markerRender.anchor)
1409
+ return false;
1410
+ if (!pixelOffsetEqual(existing.markerRender.pixelOffset, incoming.markerRender.pixelOffset))
1411
+ return false;
1412
+ if (existing.clusterConfig?.mode !== incoming.clusterConfig?.mode)
1413
+ return false;
1414
+ if (existing.clusterRender === null && incoming.clusterRender !== null)
1415
+ return false;
1416
+ if (existing.clusterRender !== null && incoming.clusterRender === null)
1417
+ return false;
1418
+ if (existing.clusterRender !== null && incoming.clusterRender !== null) {
1419
+ if (existing.clusterRender.mode !== incoming.clusterRender.mode)
1420
+ return false;
1421
+ if (existing.clusterRender.mode === "dom" && incoming.clusterRender.mode === "dom") {
1422
+ if (existing.clusterRender.anchor !== incoming.clusterRender.anchor)
1423
+ return false;
1424
+ if (!pixelOffsetEqual(existing.clusterRender.pixelOffset, incoming.clusterRender.pixelOffset))
1425
+ return false;
1426
+ }
1427
+ }
1428
+ return true;
1429
+ };
1430
+ /**
1431
+ * Returns true when an adaptive marker source can be patched incrementally for
1432
+ * mode transitions / portal updates where feature IDs and cluster data are stable.
1433
+ */
1434
+ const canPatchAdaptiveMarker = (existing, incoming) => {
1435
+ if (existing.markerRender.mode !== "adaptive" || incoming.markerRender.mode !== "adaptive")
1436
+ return false;
1437
+ if (existing.kind !== "marker" || incoming.kind !== "marker")
1438
+ return false;
1439
+ return (hasSameFeatureIds(existing.features, incoming.features) &&
1440
+ existing.markerRender.anchor === incoming.markerRender.anchor &&
1441
+ pixelOffsetEqual(existing.markerRender.pixelOffset, incoming.markerRender.pixelOffset) &&
1442
+ existing.clusterConfig?.mode === incoming.clusterConfig?.mode &&
1443
+ hasSameFeatureIds(existing.clusterFeatures, incoming.clusterFeatures) &&
1444
+ esToolkit.isEqual(existing.clusterFeatures, incoming.clusterFeatures));
1445
+ };
1446
+ /**
1447
+ * Removes indexed native markers whose feature IDs are no longer present and
1448
+ * removes the matching DOM portal descriptors. The marker index is mutated in
1449
+ * place because adapters keep it as their long-lived native marker registry.
1450
+ */
1451
+ const removeGoneIndexedMarkers = ({ incomingFeatureIds, markerIndex, markers, portalDescriptors, sourceId, detachMarker, }) => {
1452
+ const removedMarkers = new Set();
1453
+ const removedPortalKeys = new Set();
1454
+ for (const [featureId, marker] of markerIndex) {
1455
+ if (incomingFeatureIds.has(featureId))
1456
+ continue;
1457
+ detachMarker(marker);
1458
+ markerIndex.delete(featureId);
1459
+ removedMarkers.add(marker);
1460
+ removedPortalKeys.add(`${sourceId}:${featureId}`);
1461
+ }
1462
+ const nextMarkers = removedMarkers.size === 0 ? markers : markers.filter(marker => !removedMarkers.has(marker));
1463
+ const nextPortalDescriptors = removedPortalKeys.size === 0
1464
+ ? portalDescriptors
1465
+ : portalDescriptors.filter(descriptor => !removedPortalKeys.has(descriptor.key));
1466
+ return {
1467
+ markerIndex,
1468
+ markers: nextMarkers,
1469
+ portalDescriptors: nextPortalDescriptors,
1470
+ markersChanged: removedMarkers.size > 0,
1471
+ portalDescriptorsChanged: nextPortalDescriptors.length !== portalDescriptors.length,
1472
+ };
1473
+ };
1474
+ // ============================================================================
1475
+ // Anchor helpers
1476
+ // ============================================================================
1477
+ /**
1478
+ * Convert a MarkerAnchor value to a CSS transform string.
1479
+ * Assumes the element's **bottom-center** is at the coordinate by default.
1480
+ * Google Maps AdvancedMarkerElement positions custom DOM content at
1481
+ * bottom-center, so all Google DOM elements (symbol dots, shape point dots,
1482
+ * and DOM markers) need this to correct anchoring.
1483
+ */
1484
+ const anchorFromBottomCenter = (anchor) => {
1485
+ switch (anchor) {
1486
+ case "center":
1487
+ return "translate(0, 50%)";
1488
+ case "top":
1489
+ return "translate(0, 100%)";
1490
+ case "bottom":
1491
+ return "translate(0, 0)";
1492
+ case "left":
1493
+ return "translate(50%, 50%)";
1494
+ case "right":
1495
+ return "translate(-50%, 50%)";
1496
+ case "top-left":
1497
+ return "translate(50%, 100%)";
1498
+ case "top-right":
1499
+ return "translate(-50%, 100%)";
1500
+ case "bottom-left":
1501
+ return "translate(50%, 0)";
1502
+ case "bottom-right":
1503
+ return "translate(-50%, 0)";
1504
+ default: {
1505
+ throw new Error(`${anchor} is not known`);
1506
+ }
1507
+ }
1508
+ };
1509
+ const LAYER_FADE_DURATION_MS = 200;
1510
+ /**
1511
+ * Start an element at opacity 0 and animate it in via CSS transition.
1512
+ * Used for DOM-based markers in both Mapbox and Google adapters.
1513
+ */
1514
+ const fadeInElement = (el) => {
1515
+ el.style.opacity = "0";
1516
+ el.style.transition = `opacity ${LAYER_FADE_DURATION_MS}ms ease-out`;
1517
+ requestAnimationFrame(() => {
1518
+ el.style.opacity = "1";
1519
+ });
1520
+ };
1521
+
1522
+ /** Type predicate to narrow a MapEvent to a specific event type */
1523
+ const isEventOfType = (mapEvent, type) => mapEvent.type === type;
1524
+
1525
+ // ============================================================================
1526
+ // Appearance Schemas & Types (single source of truth)
1527
+ // ============================================================================
1528
+ const mapThemeSchema = zod.z.enum(["light", "dark"]);
1529
+ const mapTypeSchema = zod.z.enum(["roadmap", "satellite", "hybrid"]);
1530
+ const mapAppearanceSchema = zod.z.object({
1531
+ theme: mapThemeSchema,
1532
+ mapType: mapTypeSchema,
1533
+ showRoads: zod.z.boolean(),
1534
+ });
1535
+ /** Default appearance before any user preference is applied */
1536
+ const DEFAULT_MAP_APPEARANCE = {
1537
+ theme: "light",
1538
+ mapType: "roadmap",
1539
+ showRoads: false,
1540
+ };
1541
+ const INITIAL_CAMERA_STATE = {
1542
+ center: [0, 0],
1543
+ zoom: DEFAULT_ZOOM,
1544
+ bounds: null,
1545
+ isIdle: true,
1546
+ };
1547
+ const INITIAL_MAP_STATUS = {
1548
+ isReady: false,
1549
+ initializationFailed: false,
1550
+ appearance: DEFAULT_MAP_APPEARANCE,
1551
+ tileSize: 256,
1552
+ };
1553
+ /**
1554
+ * Initial map state - default values before map loads
1555
+ */
1556
+ const INITIAL_MAP_STATE = {
1557
+ ...INITIAL_CAMERA_STATE,
1558
+ ...INITIAL_MAP_STATUS,
1559
+ };
1560
+
1561
+ /**
1562
+ * Compute the initial MapState from a config's appearance and optional viewport.
1563
+ * Shared by both Google Maps and Mapbox adapters.
1564
+ */
1565
+ const computeInitialState = (appearance, initialViewport) => {
1566
+ const vp = validateInitialViewport(initialViewport);
1567
+ if (vp === undefined) {
1568
+ return { ...INITIAL_MAP_STATE, appearance };
1569
+ }
1570
+ if (vp.type === "center") {
1571
+ return {
1572
+ ...INITIAL_MAP_STATE,
1573
+ appearance,
1574
+ center: vp.center,
1575
+ zoom: vp.zoom ?? INITIAL_MAP_STATE.zoom,
1576
+ };
1577
+ }
1578
+ return {
1579
+ ...INITIAL_MAP_STATE,
1580
+ appearance,
1581
+ center: mercatorCenterFromBounds(vp.bounds),
1582
+ zoom: estimateZoomFromBounds(vp.bounds),
1583
+ bounds: vp.bounds,
1584
+ };
1585
+ };
1586
+ const bboxEquals = (a, b) => {
1587
+ if (a === b)
1588
+ return true;
1589
+ if (a === null || b === null)
1590
+ return false;
1591
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
1592
+ };
1593
+ /** Field-wise equality for camera state (center, zoom, bounds, isIdle). */
1594
+ const cameraStateEquals = (a, b) => a.center[0] === b.center[0] &&
1595
+ a.center[1] === b.center[1] &&
1596
+ a.zoom === b.zoom &&
1597
+ a.isIdle === b.isIdle &&
1598
+ bboxEquals(a.bounds, b.bounds);
1599
+ /** Field-wise equality for map status (isReady, initializationFailed, appearance, tileSize). */
1600
+ const statusEquals = (a, b) => a.isReady === b.isReady &&
1601
+ a.initializationFailed === b.initializationFailed &&
1602
+ a.appearance === b.appearance &&
1603
+ a.tileSize === b.tileSize;
1604
+ /** Field-wise equality for the full combined MapState. */
1605
+ const mapStateEquals = (a, b) => cameraStateEquals(a, b) && statusEquals(a, b);
1606
+
1607
+ /**
1608
+ * Resolve effective restrict bounds from config.
1609
+ * Returns null if no restriction; GeoJsonBbox otherwise.
1610
+ * Validates custom bbox with geoJsonBboxSchema; on failure, console.warn and fallback to null.
1611
+ *
1612
+ * @note - Why fallback to null and not WORLD_BBOX?
1613
+ * Different map providers handle full world bounds differently.
1614
+ * Eg. -/+180 degrees longitude is disregarded by Google Maps allowing for infinite horizontal scroll.
1615
+ * while Mapbox respects the full world bounds, leading to inconsistent behavior between adapters.
1616
+ */
1617
+ function getEffectiveRestrictBounds(configValue) {
1618
+ if (configValue === null || configValue === undefined)
1619
+ return null;
1620
+ const result = geoJsonUtils.geoJsonBboxSchema.safeParse(configValue);
1621
+ if (!result.success) {
1622
+ // eslint-disable-next-line no-console -- Intentional: warn devs when invalid restrictBounds bbox is passed; fallback to null
1623
+ console.warn("[restrictBounds]", result.error.message);
1624
+ return null;
1625
+ }
1626
+ return result.data;
1627
+ }
1628
+
1629
+ /**
1630
+ * The DOM-attribute contract that the safe-area hover system relies on. Any
1631
+ * marker (built-in or third-party) opts into the spurious-mouseleave-safe
1632
+ * hover by tagging two elements inside its DOM tree:
1633
+ *
1634
+ * - {@link HIT_SURFACE_SELECTOR} on the visible interactive surface (e.g.
1635
+ * the pill at the tip of a stick marker).
1636
+ * - {@link ANCHOR_SELECTOR} on the geographic-anchor element. Prefer the
1637
+ * visible anchor footprint when one exists (e.g. a stick marker's anchor
1638
+ * dot); a 0×0 coordinate node also works when no visible anchor exists.
1639
+ * The anchor is only required when the visible surface is *spatially
1640
+ * separated* from the anchor (the classic stick-marker case); plain
1641
+ * non-stick markers can omit it without consequence.
1642
+ */
1643
+ /** Stable attribute on the visible interactive surface element. */
1644
+ const HIT_SURFACE_SELECTOR = "[data-map-marker-hit-surface]";
1645
+ /** Stable attribute on the geographic-anchor element. */
1646
+ const ANCHOR_SELECTOR = "[data-map-marker-anchor]";
1647
+ /**
1648
+ * Outward buffer applied to each rect's corners when building the safe-area
1649
+ * polygon. Guards against OS pointer-jitter at the polygon boundary without
1650
+ * masking intentional outward movement.
1651
+ */
1652
+ const SAFE_AREA_DEFAULT_BUFFER_PX = 1;
1653
+ /**
1654
+ * Resolve the rect to use for hit-testing a DOM marker. Prefers the inner
1655
+ * hit-surface element (when present and non-empty); falls back to the host's
1656
+ * own rect; returns null when both are empty.
1657
+ */
1658
+ const getHitSurfaceRect = (host) => {
1659
+ const inner = host.querySelector(HIT_SURFACE_SELECTOR);
1660
+ if (inner !== null) {
1661
+ const innerRect = inner.getBoundingClientRect();
1662
+ if (innerRect.width > 0 && innerRect.height > 0)
1663
+ return innerRect;
1664
+ }
1665
+ const hostRect = host.getBoundingClientRect();
1666
+ return hostRect.width > 0 && hostRect.height > 0 ? hostRect : null;
1667
+ };
1668
+ /**
1669
+ * Return the page-space rect of the geographic anchor element, or null when
1670
+ * the host does not contain one (no `data-map-marker-anchor`). A visible
1671
+ * anchor dot gives the safe area its real footprint; a 0×0 coordinate node is
1672
+ * still valid when no visible anchor exists.
1673
+ */
1674
+ const getAnchorRect = (host) => {
1675
+ const el = host.querySelector(ANCHOR_SELECTOR);
1676
+ if (el === null)
1677
+ return null;
1678
+ return el.getBoundingClientRect();
1679
+ };
1680
+
1681
+ /**
1682
+ * Pure 2D polygon primitives used by the safe-area system. No DOM dependency,
1683
+ * no React dependency — just geometry.
1684
+ */
1685
+ /**
1686
+ * Return the four corners of `rect`, outward-buffered by `px` on every axis.
1687
+ * A 0×0 rect with a positive buffer still contributes a 4-point square (used
1688
+ * to give the geographic anchor a non-zero footprint in the safe area).
1689
+ */
1690
+ const bufferRectCorners = (rect, px) => {
1691
+ const left = rect.left - px;
1692
+ const right = rect.right + px;
1693
+ const top = rect.top - px;
1694
+ const bottom = rect.bottom + px;
1695
+ return [
1696
+ { x: left, y: top },
1697
+ { x: right, y: top },
1698
+ { x: right, y: bottom },
1699
+ { x: left, y: bottom },
1700
+ ];
1701
+ };
1702
+ /**
1703
+ * Andrew's monotone-chain convex hull. Returns hull points in counter-clockwise
1704
+ * order (in screen coordinates where y grows downward, this is visually
1705
+ * clockwise, but the orientation does not affect the even-odd point-in-polygon
1706
+ * test). The first point is **not** repeated at the end.
1707
+ *
1708
+ * Inputs are typically ≤ 12 (3 rects × 4 corners) so the O(n log n) sort is
1709
+ * negligible. Empty input returns an empty hull.
1710
+ */
1711
+ const convexHull = (points) => {
1712
+ if (points.length <= 1)
1713
+ return points.slice();
1714
+ const sorted = points.slice().sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x));
1715
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
1716
+ const lower = [];
1717
+ for (const p of sorted) {
1718
+ while (lower.length >= 2) {
1719
+ const a = lower[lower.length - 2];
1720
+ const b = lower[lower.length - 1];
1721
+ if (a === undefined || b === undefined)
1722
+ break;
1723
+ if (cross(a, b, p) <= 0) {
1724
+ lower.pop();
1725
+ }
1726
+ else {
1727
+ break;
1728
+ }
1729
+ }
1730
+ lower.push(p);
1731
+ }
1732
+ const upper = [];
1733
+ for (let i = sorted.length - 1; i >= 0; i--) {
1734
+ const p = sorted[i];
1735
+ if (p === undefined)
1736
+ continue;
1737
+ while (upper.length >= 2) {
1738
+ const a = upper[upper.length - 2];
1739
+ const b = upper[upper.length - 1];
1740
+ if (a === undefined || b === undefined)
1741
+ break;
1742
+ if (cross(a, b, p) <= 0) {
1743
+ upper.pop();
1744
+ }
1745
+ else {
1746
+ break;
1747
+ }
1748
+ }
1749
+ upper.push(p);
1750
+ }
1751
+ lower.pop();
1752
+ upper.pop();
1753
+ return lower.concat(upper);
1754
+ };
1755
+ /**
1756
+ * Even-odd ray-cast point-in-polygon test. The standard formulation: edges are
1757
+ * classified consistently (one side "inside", the other "outside") so a point
1758
+ * that lies exactly on a polygon boundary may go either way depending on which
1759
+ * edge was tested first — relying on edge classification is fragile. Callers
1760
+ * should use the outward buffer in {@link ./safePolygon#safePolygon} as the
1761
+ * tolerance margin and treat strictly-inside checks as the contract.
1762
+ */
1763
+ const pointInPolygon = (x, y, polygon) => {
1764
+ if (polygon.length < 3)
1765
+ return false;
1766
+ let inside = false;
1767
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
1768
+ const pi = polygon[i];
1769
+ const pj = polygon[j];
1770
+ if (pi === undefined || pj === undefined)
1771
+ continue;
1772
+ const intersects = pi.y > y !== pj.y > y && x < ((pj.x - pi.x) * (y - pi.y)) / (pj.y - pi.y) + pi.x;
1773
+ if (intersects)
1774
+ inside = !inside;
1775
+ }
1776
+ return inside;
1777
+ };
1778
+
1779
+ /**
1780
+ * Dev-only visual overlay for the safe-area polygon computed by
1781
+ * {@link ./safePolygon#safePolygon}. Lets you see the polygon the
1782
+ * leave-detection logic is actually testing against, so shape regressions
1783
+ * (e.g. "this doesn't look like a funnel at all") are debuggable from the
1784
+ * browser instead of via unit tests alone.
1785
+ *
1786
+ * Design:
1787
+ * - Off by default. Zero work when disabled.
1788
+ * - One singleton `<svg>` overlay attached to `document.body` on first enable.
1789
+ * - Per-area polygons keyed by string id; replace-or-clear by id.
1790
+ * - Toggle from any code path:
1791
+ * enableSafeAreaDebug(); // turn on
1792
+ * disableSafeAreaDebug(); // turn off
1793
+ */
1794
+ const COLORS = ["#ff3b30", "#34c759", "#5ac8fa", "#ff9500", "#af52de", "#ff2d55"];
1795
+ let enabled = false;
1796
+ let overlay = null;
1797
+ const entries = new Map();
1798
+ let nextColorIndex = 0;
1799
+ const ensureOverlay = () => {
1800
+ if (typeof document === "undefined")
1801
+ return null;
1802
+ if (overlay !== null && overlay.isConnected)
1803
+ return overlay;
1804
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1805
+ svg.setAttribute("data-safe-area-debug", "");
1806
+ svg.style.position = "fixed";
1807
+ svg.style.inset = "0";
1808
+ svg.style.width = "100vw";
1809
+ svg.style.height = "100vh";
1810
+ svg.style.pointerEvents = "none";
1811
+ svg.style.zIndex = "2147483600";
1812
+ document.body.appendChild(svg);
1813
+ overlay = svg;
1814
+ return svg;
1815
+ };
1816
+ const removeOverlay = () => {
1817
+ if (overlay !== null && overlay.isConnected) {
1818
+ overlay.remove();
1819
+ }
1820
+ overlay = null;
1821
+ };
1822
+ const polygonPath = (polygon) => {
1823
+ if (polygon.length === 0)
1824
+ return "";
1825
+ const head = polygon[0];
1826
+ if (head === undefined)
1827
+ return "";
1828
+ const segments = [`M ${head.x} ${head.y}`];
1829
+ for (let i = 1; i < polygon.length; i++) {
1830
+ const p = polygon[i];
1831
+ if (p === undefined)
1832
+ continue;
1833
+ segments.push(`L ${p.x} ${p.y}`);
1834
+ }
1835
+ segments.push("Z");
1836
+ return segments.join(" ");
1837
+ };
1838
+ const render = () => {
1839
+ const svg = ensureOverlay();
1840
+ if (svg === null)
1841
+ return;
1842
+ while (svg.firstChild !== null)
1843
+ svg.removeChild(svg.firstChild);
1844
+ for (const entry of entries.values()) {
1845
+ // Safe-area polygon: filled translucent + dashed stroke so the shape is
1846
+ // immediately visible against the underlying content.
1847
+ if (entry.polygon.length >= 3) {
1848
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1849
+ path.setAttribute("d", polygonPath(entry.polygon));
1850
+ path.setAttribute("fill", entry.color);
1851
+ path.setAttribute("fill-opacity", "0.15");
1852
+ path.setAttribute("stroke", entry.color);
1853
+ path.setAttribute("stroke-width", "1.5");
1854
+ path.setAttribute("stroke-dasharray", "4 3");
1855
+ svg.appendChild(path);
1856
+ }
1857
+ // Input rects: dotted outlines so you can see what we're hulling.
1858
+ for (const rect of entry.rects) {
1859
+ if (rect === null)
1860
+ continue;
1861
+ const r = document.createElementNS("http://www.w3.org/2000/svg", "rect");
1862
+ r.setAttribute("x", String(rect.left));
1863
+ r.setAttribute("y", String(rect.top));
1864
+ r.setAttribute("width", String(Math.max(rect.width, 1)));
1865
+ r.setAttribute("height", String(Math.max(rect.height, 1)));
1866
+ r.setAttribute("fill", "none");
1867
+ r.setAttribute("stroke", entry.color);
1868
+ r.setAttribute("stroke-width", "1");
1869
+ r.setAttribute("stroke-dasharray", "2 2");
1870
+ r.setAttribute("opacity", "0.9");
1871
+ svg.appendChild(r);
1872
+ }
1873
+ }
1874
+ };
1875
+ /**
1876
+ * Enable the debug overlay. Idempotent. Persists until {@link disableSafeAreaDebug}
1877
+ * is called or the page reloads.
1878
+ */
1879
+ const enableSafeAreaDebug = () => {
1880
+ if (enabled)
1881
+ return;
1882
+ enabled = true;
1883
+ ensureOverlay();
1884
+ render();
1885
+ };
1886
+ /**
1887
+ * Disable the overlay, remove the SVG element, and forget all registered
1888
+ * safe areas. Call when you're done debugging.
1889
+ */
1890
+ const disableSafeAreaDebug = () => {
1891
+ enabled = false;
1892
+ entries.clear();
1893
+ removeOverlay();
1894
+ };
1895
+ /** Read-only check used by the wiring hooks to skip work when off. */
1896
+ const isSafeAreaDebugEnabled = () => enabled;
1897
+ /**
1898
+ * Register or update a safe-area polygon. Use a stable id per active hover so
1899
+ * repeated calls during pointermove/animation update the same shape in place.
1900
+ */
1901
+ const renderSafeArea = (id, polygon, rects) => {
1902
+ if (!enabled)
1903
+ return;
1904
+ let entry = entries.get(id);
1905
+ if (entry === undefined) {
1906
+ const color = COLORS[nextColorIndex % COLORS.length] ?? "#ff3b30";
1907
+ nextColorIndex++;
1908
+ entry = { polygon, rects, color };
1909
+ }
1910
+ else {
1911
+ entry = { ...entry, polygon, rects };
1912
+ }
1913
+ entries.set(id, entry);
1914
+ render();
1915
+ };
1916
+ /** Remove a registered safe area by id. No-op if id is unknown. */
1917
+ const clearSafeArea = (id) => {
1918
+ if (!enabled)
1919
+ return;
1920
+ if (entries.delete(id))
1921
+ render();
1922
+ };
1923
+
1924
+ /**
1925
+ * Filter `rects` to those on the "cursor side" of an opposing rect, preventing
1926
+ * the safe-area polygon from extending into a corridor the cursor has clearly
1927
+ * turned away from.
1928
+ *
1929
+ * For every pair of rects (R, S) in the input where the cursor is NOT inside R:
1930
+ * if the cursor is on the opposite side of S from R (along the S→R axis,
1931
+ * tested via the sign of `dot(cursor − centerS, centerR − centerS)`), R is
1932
+ * dropped. Concretely: when cursor is at the entry-rect side of the marker and
1933
+ * moving away from the floating live-rect (e.g. stick-marker pill), the live
1934
+ * rect is dropped so the polygon does not include it. Rects the cursor is
1935
+ * inside are always kept.
1936
+ *
1937
+ * Pure function. Returns the filtered list in the original order.
1938
+ */
1939
+ const filterRectsByCursorDirection = (rects, cursorX, cursorY) => {
1940
+ const present = [];
1941
+ for (let i = 0; i < rects.length; i++) {
1942
+ const r = rects[i];
1943
+ if (r === undefined || r === null)
1944
+ continue;
1945
+ const cx = (r.left + r.right) / 2;
1946
+ const cy = (r.top + r.bottom) / 2;
1947
+ const insideCursor = cursorX >= r.left && cursorX <= r.right && cursorY >= r.top && cursorY <= r.bottom;
1948
+ present.push({ rect: r, index: i, cx, cy, insideCursor });
1949
+ }
1950
+ const dropped = new Set();
1951
+ for (const r of present) {
1952
+ if (r.insideCursor)
1953
+ continue;
1954
+ for (const s of present) {
1955
+ if (s.index === r.index)
1956
+ continue;
1957
+ if (s.insideCursor) {
1958
+ // Cursor is inside S: anything S→R-pointing past S still belongs to
1959
+ // the corridor; do not drop based on this S.
1960
+ continue;
1961
+ }
1962
+ const sToRx = r.cx - s.cx;
1963
+ const sToRy = r.cy - s.cy;
1964
+ const sToCursorX = cursorX - s.cx;
1965
+ const sToCursorY = cursorY - s.cy;
1966
+ const dot = sToRx * sToCursorX + sToRy * sToCursorY;
1967
+ if (dot < 0) {
1968
+ dropped.add(r.index);
1969
+ break;
1970
+ }
1971
+ }
1972
+ }
1973
+ return rects.map((r, i) => (dropped.has(i) ? null : r));
1974
+ };
1975
+ /**
1976
+ * Build a hover-safe convex-hull polygon spanning every input rect. Each
1977
+ * non-null rect contributes its four corners (outward-buffered by `bufferPx`)
1978
+ * and the result is the convex hull of all collected corners.
1979
+ *
1980
+ * Inspired by Floating UI's `safePolygon` middleware (used by `useHover` to
1981
+ * keep menus open while the cursor travels toward them). The difference: that
1982
+ * implementation builds a pointer-to-single-rect triangle for the standard
1983
+ * trigger ↔ floating-element relationship; this one accepts an arbitrary list
1984
+ * of rects so a single safe area can span N visible targets — e.g. a stick
1985
+ * marker's pill at the tip, an entry-time snapshot of the same pill (used as
1986
+ * an animation grace area), and the geographic anchor at the stick's base.
1987
+ *
1988
+ * The hull is angle-agnostic: it hugs the visible elements at any layout and
1989
+ * naturally excludes the dead corners that an axis-aligned bounding box would
1990
+ * include. Returns an empty polygon when no rects were provided; callers
1991
+ * should treat that as "no safe area" (any cursor position is outside).
1992
+ */
1993
+ const safePolygon = (rects, bufferPx) => {
1994
+ const points = [];
1995
+ for (const rect of rects) {
1996
+ if (rect === null)
1997
+ continue;
1998
+ points.push(...bufferRectCorners(rect, bufferPx));
1999
+ }
2000
+ return convexHull(points);
2001
+ };
2002
+
2003
+ let nextDebugId = 0;
2004
+ /**
2005
+ * Generate a unique id for one active hover instance. Exposed so callers that
2006
+ * orchestrate multiple watchers (e.g. {@link ./attachSafeAreaHoverListeners})
2007
+ * can pre-allocate one id and pass it in, so the debug overlay shows a single
2008
+ * polygon entry across the listener's lifecycle.
2009
+ */
2010
+ const allocSafeAreaDebugId = (label) => `${label}-${++nextDebugId}`;
2011
+ /**
2012
+ * Listen for genuine pointer departure from a DOM marker's safe area. The
2013
+ * watcher consults `getHitSurfaceRect` on every pointermove; when the inner
2014
+ * query returns nothing transiently (mid-mount, mid-collapse), it falls back
2015
+ * to the most recent non-empty rect seen.
2016
+ *
2017
+ * `entryRect` is the hit-surface rect captured at `mouseenter` time. It acts
2018
+ * as a second stable reference so that when the element animates away from a
2019
+ * stationary cursor (e.g. stick marker expanding from anchor to tip), the
2020
+ * watcher does not fire `onLeave` while the cursor is still inside the
2021
+ * original entry area.
2022
+ *
2023
+ * Returns a cancel function. Caller is responsible for invoking it on
2024
+ * hover-end and on cleanup.
2025
+ */
2026
+ const watchSafeAreaLeave = (host, onLeave, entryRect, debugId) => {
2027
+ let rememberedRect = getHitSurfaceRect(host);
2028
+ const fixedEntryRect = entryRect ?? null;
2029
+ const id = debugId ?? allocSafeAreaDebugId("watcher");
2030
+ const handler = (pe) => {
2031
+ const liveRect = getHitSurfaceRect(host);
2032
+ if (liveRect !== null) {
2033
+ rememberedRect = liveRect;
2034
+ }
2035
+ else if (!host.isConnected) {
2036
+ // Element removed from DOM — self-cancel without firing onLeave.
2037
+ document.removeEventListener("pointermove", handler);
2038
+ clearSafeArea(id);
2039
+ return;
2040
+ }
2041
+ const rect = liveRect ?? rememberedRect;
2042
+ if (rect === null)
2043
+ return;
2044
+ const anchorRect = getAnchorRect(host);
2045
+ const filtered = filterRectsByCursorDirection([rect, fixedEntryRect, anchorRect], pe.clientX, pe.clientY);
2046
+ const polygon = safePolygon(filtered, SAFE_AREA_DEFAULT_BUFFER_PX);
2047
+ if (isSafeAreaDebugEnabled()) {
2048
+ renderSafeArea(id, polygon, filtered);
2049
+ }
2050
+ const inside = pointInPolygon(pe.clientX, pe.clientY, polygon);
2051
+ if (!inside)
2052
+ onLeave();
2053
+ };
2054
+ const cancel = () => {
2055
+ document.removeEventListener("pointermove", handler);
2056
+ clearSafeArea(id);
2057
+ };
2058
+ document.addEventListener("pointermove", handler, { passive: true });
2059
+ return cancel;
2060
+ };
2061
+
2062
+ /**
2063
+ * Attach `mouseenter` and `mouseleave` listeners to a DOM marker element with
2064
+ * the full spurious-mouseleave-safe hover contract:
2065
+ *
2066
+ * - Records the hit-surface rect at entry time (`hoverEntryRect`).
2067
+ * - On `mouseleave`, computes the safe area: convex hull of the live hit
2068
+ * surface, the entry rect, and the anchor rect. If the pointer is still
2069
+ * inside, installs a {@link watchSafeAreaLeave} instead of firing onLeave
2070
+ * immediately.
2071
+ * - Cancels any watcher from a previous marker (via the shared `state` box)
2072
+ * when a new `mouseenter` arrives.
2073
+ *
2074
+ * Both `MapboxLayerPort` and `GoogleMapsLayerPort` share this implementation
2075
+ * so the safe-area logic has one source of truth.
2076
+ */
2077
+ const attachSafeAreaHoverListeners = (el, onEnter, onLeave, state) => {
2078
+ let cancelWatcher = null;
2079
+ let hoverEntryRect = null;
2080
+ // Timestamp of the most recent honoured `mouseenter`. When `mouseleave`
2081
+ // fires within the marker's morph window of this, the polygon at that
2082
+ // instant may not yet include the materialising pill rect (e.g. cursor
2083
+ // crossed the round-indicator boundary while the pill is still scaling up
2084
+ // from the anchor); fall back to installing the watcher so its polling
2085
+ // catches the pill once it renders, instead of firing onLeave on an
2086
+ // incomplete polygon.
2087
+ let lastEnterTime = 0;
2088
+ const MORPH_GRACE_MS = 250;
2089
+ // Set to true once the cursor has been observed inside the current live hit
2090
+ // surface during this hover session. While still false, `hoverEntryRect`
2091
+ // contributes to the safe-area polygon (grace area for marker morphs that
2092
+ // shrink under the cursor). Once true, the cursor has demonstrably acquired
2093
+ // the new (smaller) hit surface — the entry rect grace is dropped so leaving
2094
+ // the live surface closes the marker on the next move.
2095
+ let cursorAcquiredLiveSurface = false;
2096
+ // Re-entry guard: set to `true` when the close path fires `onLeave`. While
2097
+ // it's `true`, fresh `mouseenter` events on `el` are swallowed (the consumer
2098
+ // never sees a hover-start). The guard clears when a `pointermove` observes
2099
+ // the cursor outside the accumulated footprint envelope — i.e. the cursor
2100
+ // has visibly left the marker's footprint, so any future mouseenter is a
2101
+ // deliberate re-hover. Without this guard the stick → round
2102
+ // morph-back grows the hit surface around a near-stationary cursor and
2103
+ // fires a phantom mouseenter that re-arms the cycle.
2104
+ let pendingReentryGuard = false;
2105
+ let suppressedSinceEnter = false;
2106
+ // Footprint to test the cursor against while the guard is active. Built as
2107
+ // the union of every hit-surface rect ever observed for this `el` — the
2108
+ // morph-back round indicator falls inside this max-rect by definition, so
2109
+ // the guard correctly stays armed while the cursor is over any pixel the
2110
+ // marker has ever occupied. Cleared (and the union restarted) when the
2111
+ // guard clears, so the next hover session builds its own envelope.
2112
+ let guardFootprintRect = null;
2113
+ const expandFootprint = (rect) => {
2114
+ if (rect === null || rect.width === 0 || rect.height === 0)
2115
+ return;
2116
+ if (guardFootprintRect === null) {
2117
+ guardFootprintRect = rect;
2118
+ return;
2119
+ }
2120
+ const left = Math.min(guardFootprintRect.left, rect.left);
2121
+ const top = Math.min(guardFootprintRect.top, rect.top);
2122
+ const right = Math.max(guardFootprintRect.right, rect.right);
2123
+ const bottom = Math.max(guardFootprintRect.bottom, rect.bottom);
2124
+ guardFootprintRect = new DOMRect(left, top, right - left, bottom - top);
2125
+ };
2126
+ const debugId = allocSafeAreaDebugId("hover");
2127
+ // rAF loop that re-renders the debug overlay each frame while the marker is
2128
+ // hovered — the pill animates outward from the anchor, so a static snapshot
2129
+ // would not match the visible state. Stops on hover-end.
2130
+ let debugRafHandle = 0;
2131
+ const stopDebugLoop = () => {
2132
+ if (debugRafHandle !== 0) {
2133
+ cancelAnimationFrame(debugRafHandle);
2134
+ debugRafHandle = 0;
2135
+ }
2136
+ clearSafeArea(debugId);
2137
+ };
2138
+ const startDebugLoop = () => {
2139
+ if (!isSafeAreaDebugEnabled())
2140
+ return;
2141
+ const tick = () => {
2142
+ if (!isSafeAreaDebugEnabled()) {
2143
+ debugRafHandle = 0;
2144
+ return;
2145
+ }
2146
+ const liveRect = getHitSurfaceRect(el);
2147
+ const anchorRect = getAnchorRect(el);
2148
+ if (liveRect !== null) {
2149
+ // While `mouseenter` is active the cursor is over the marker; show the
2150
+ // unfiltered convex hull (the funnel-grace area) rather than running
2151
+ // the directional filter against a stale pointer.
2152
+ const polygon = safePolygon([liveRect, hoverEntryRect, anchorRect], SAFE_AREA_DEFAULT_BUFFER_PX);
2153
+ renderSafeArea(debugId, polygon, [liveRect, hoverEntryRect, anchorRect]);
2154
+ }
2155
+ debugRafHandle = requestAnimationFrame(tick);
2156
+ };
2157
+ debugRafHandle = requestAnimationFrame(tick);
2158
+ };
2159
+ // pointermove sampled while the cursor is over `el`. Two jobs: (1) detect
2160
+ // when the cursor enters a *shrunk* live hit surface so the entry-rect
2161
+ // grace can be dropped (see `cursorAcquiredLiveSurface`); (2) keep the
2162
+ // footprint envelope up to date so the re-entry guard has an accurate
2163
+ // bound when armed.
2164
+ const pointerMoveDuringHover = (pe) => {
2165
+ const live = getHitSurfaceRect(el);
2166
+ if (live === null && !el.isConnected) {
2167
+ // Element removed from DOM — remove the document listener.
2168
+ document.removeEventListener("pointermove", pointerMoveDuringHover);
2169
+ return;
2170
+ }
2171
+ expandFootprint(live);
2172
+ if (cursorAcquiredLiveSurface)
2173
+ return;
2174
+ if (live === null || hoverEntryRect === null)
2175
+ return;
2176
+ const liveArea = live.width * live.height;
2177
+ const entryArea = hoverEntryRect.width * hoverEntryRect.height;
2178
+ if (liveArea >= entryArea)
2179
+ return;
2180
+ if (pe.clientX < live.left || pe.clientX > live.right)
2181
+ return;
2182
+ if (pe.clientY < live.top || pe.clientY > live.bottom)
2183
+ return;
2184
+ cursorAcquiredLiveSurface = true;
2185
+ };
2186
+ const pointInRect = (x, y, r) => r !== null && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
2187
+ const removeGuardListener = () => {
2188
+ document.removeEventListener("pointermove", pointerMoveDuringGuard);
2189
+ if (state.activeGuardCancel === cancelReentryGuard) {
2190
+ state.activeGuardCancel = null;
2191
+ }
2192
+ };
2193
+ const cancelReentryGuard = () => {
2194
+ removeGuardListener();
2195
+ pendingReentryGuard = false;
2196
+ guardFootprintRect = null;
2197
+ hoverEntryRect = null;
2198
+ cursorAcquiredLiveSurface = false;
2199
+ };
2200
+ // pointermove sampled while the re-entry guard is active. Continues to
2201
+ // expand the footprint envelope so a still-growing morph-back stays
2202
+ // covered; clears the guard the moment the cursor is outside the
2203
+ // accumulated envelope.
2204
+ const pointerMoveDuringGuard = (pe) => {
2205
+ const live = getHitSurfaceRect(el);
2206
+ if (live === null && !el.isConnected) {
2207
+ cancelReentryGuard();
2208
+ return;
2209
+ }
2210
+ expandFootprint(live);
2211
+ if (!pointInRect(pe.clientX, pe.clientY, guardFootprintRect)) {
2212
+ cancelReentryGuard();
2213
+ }
2214
+ };
2215
+ const armReentryGuard = () => {
2216
+ removeGuardListener();
2217
+ pendingReentryGuard = true;
2218
+ // Seed the envelope with everything we know about the marker's geometry
2219
+ // — the current live rect, the hover entry rect (largest non-stick state
2220
+ // for this session), and the anchor point. The pointermove handler will
2221
+ // continue expanding the envelope as the morph-back materialises.
2222
+ expandFootprint(getHitSurfaceRect(el));
2223
+ expandFootprint(hoverEntryRect);
2224
+ expandFootprint(getAnchorRect(el));
2225
+ document.addEventListener("pointermove", pointerMoveDuringGuard, { passive: true });
2226
+ state.activeGuardCancel = cancelReentryGuard;
2227
+ };
2228
+ el.addEventListener("mouseenter", (e) => {
2229
+ // Suppress re-mouseenter if the re-entry guard is still armed — the
2230
+ // cursor has not yet visibly left the marker's footprint since the last
2231
+ // close, so this enter is the morph-back artefact, not an intentional
2232
+ // re-hover.
2233
+ if (pendingReentryGuard) {
2234
+ suppressedSinceEnter = true;
2235
+ return;
2236
+ }
2237
+ suppressedSinceEnter = false;
2238
+ lastEnterTime = Date.now();
2239
+ state.activeWatcherCancel?.();
2240
+ state.activeWatcherCancel = null;
2241
+ state.activeGuardCancel?.();
2242
+ state.activeGuardCancel = null;
2243
+ cancelWatcher = null;
2244
+ const previousEntryRect = hoverEntryRect;
2245
+ hoverEntryRect = getHitSurfaceRect(el);
2246
+ expandFootprint(hoverEntryRect);
2247
+ // Acquisition test: if the new entry rect is meaningfully smaller than the
2248
+ // previous one AND the cursor is inside it, the user has visibly acquired
2249
+ // the smaller hit surface — drop the entry-rect grace from now on.
2250
+ cursorAcquiredLiveSurface =
2251
+ hoverEntryRect !== null &&
2252
+ e.clientX >= hoverEntryRect.left &&
2253
+ e.clientX <= hoverEntryRect.right &&
2254
+ e.clientY >= hoverEntryRect.top &&
2255
+ e.clientY <= hoverEntryRect.bottom &&
2256
+ previousEntryRect !== null &&
2257
+ hoverEntryRect.width * hoverEntryRect.height < previousEntryRect.width * previousEntryRect.height;
2258
+ document.addEventListener("pointermove", pointerMoveDuringHover, { passive: true });
2259
+ onEnter();
2260
+ startDebugLoop();
2261
+ });
2262
+ el.addEventListener("mouseleave", (e) => {
2263
+ document.removeEventListener("pointermove", pointerMoveDuringHover);
2264
+ // If the matching `mouseenter` was suppressed, the consumer never observed
2265
+ // hover-start; skip onLeave too. The guard is already armed and tracking
2266
+ // the cursor — leave it in place so it covers consecutive morph cycles.
2267
+ if (suppressedSinceEnter) {
2268
+ return;
2269
+ }
2270
+ const rect = getHitSurfaceRect(el);
2271
+ const anchorRect = getAnchorRect(el);
2272
+ expandFootprint(rect);
2273
+ expandFootprint(anchorRect);
2274
+ // Once the cursor has acquired the (smaller) live hit surface, the entry
2275
+ // rect grace zone is stale — exclude it so leaving the live surface
2276
+ // closes the marker promptly.
2277
+ const entryRectForPolygon = cursorAcquiredLiveSurface ? null : hoverEntryRect;
2278
+ const pointerInsideSafeArea = (() => {
2279
+ if (rect === null)
2280
+ return false;
2281
+ const filtered = filterRectsByCursorDirection([rect, entryRectForPolygon, anchorRect], e.clientX, e.clientY);
2282
+ const polygon = safePolygon(filtered, SAFE_AREA_DEFAULT_BUFFER_PX);
2283
+ return pointInPolygon(e.clientX, e.clientY, polygon);
2284
+ })();
2285
+ // Morph grace: if mouseleave fires within the marker's morph-in window of
2286
+ // mouseenter, the polygon may not yet include the materialising stick pill
2287
+ // (cursor crossed the round-indicator boundary while the pill is still
2288
+ // scaling up from the anchor). Defer the close decision to the watcher
2289
+ // whose polling will catch the pill once it renders.
2290
+ const insideMorphWindow = lastEnterTime !== 0 && Date.now() - lastEnterTime < MORPH_GRACE_MS;
2291
+ // Spurious if: no usable rect, pointer is still within the safe-area
2292
+ // polygon (convex hull of live hit surface ∪ entry rect ∪ anchor), or
2293
+ // we're still within the morph-in window where the pill is materialising.
2294
+ if (rect === null || pointerInsideSafeArea || insideMorphWindow) {
2295
+ cancelWatcher?.();
2296
+ cancelWatcher = watchSafeAreaLeave(el, () => {
2297
+ onLeave();
2298
+ armReentryGuard();
2299
+ cancelWatcher?.();
2300
+ cancelWatcher = null;
2301
+ state.activeWatcherCancel = null;
2302
+ stopDebugLoop();
2303
+ }, entryRectForPolygon, debugId);
2304
+ state.activeWatcherCancel = cancelWatcher;
2305
+ }
2306
+ else {
2307
+ onLeave();
2308
+ armReentryGuard();
2309
+ cancelWatcher?.();
2310
+ cancelWatcher = null;
2311
+ state.activeWatcherCancel = null;
2312
+ stopDebugLoop();
2313
+ }
2314
+ });
2315
+ };
2316
+
2317
+ // TODO (next PR): shapeStyleDefaults computes visual defaults for shapes — a map consumer
2318
+ // concern, not an adapter concern. Make these injectable from react-map's createMapComponent
2319
+ // so adapters don't need to know about default visual styles.
2320
+ const SHAPE_STYLE_DEFAULTS = {
2321
+ polygon: {
2322
+ fillOpacity: 0.05,
2323
+ strokeWidth: 1,
2324
+ strokeOpacity: 1,
2325
+ },
2326
+ line: {
2327
+ strokeWidth: 1,
2328
+ strokeOpacity: 1,
2329
+ },
2330
+ point: {
2331
+ fillOpacity: 0.2,
2332
+ strokeWidth: 1,
2333
+ strokeOpacity: 1,
2334
+ pointRadius: 5,
2335
+ },
2336
+ };
2337
+ // ============================================================================
2338
+ // Interaction style constants
2339
+ // ============================================================================
2340
+ const HOVER_COLOR_SHIFT = 15;
2341
+ const SELECTED_COLOR_SHIFT = 25;
2342
+ // ============================================================================
2343
+ // Interaction style resolution
2344
+ // ============================================================================
2345
+ /**
2346
+ * Resolve the visual style for a hovered shape.
2347
+ *
2348
+ * User-provided `base.hovered` overrides take priority; missing properties
2349
+ * fall back to auto-computed defaults (darkened/lightened stroke based on theme).
2350
+ */
2351
+ const resolveHoveredStyle = (base, _shapeType, theme) => {
2352
+ const userOverrides = base.hovered;
2353
+ const baseStroke = base.stroke ?? "#000000";
2354
+ const autoStroke = theme === "dark" ? lightenColor(baseStroke, HOVER_COLOR_SHIFT) : darkenColor(baseStroke, HOVER_COLOR_SHIFT);
2355
+ return {
2356
+ fill: userOverrides?.fill ?? base.fill,
2357
+ fillOpacity: userOverrides?.fillOpacity ?? base.fillOpacity,
2358
+ stroke: userOverrides?.stroke ?? autoStroke,
2359
+ strokeOpacity: userOverrides?.strokeOpacity ?? base.strokeOpacity,
2360
+ strokeWidth: userOverrides?.strokeWidth ?? base.strokeWidth,
2361
+ };
2362
+ };
2363
+ /**
2364
+ * Resolve the visual style for a selected shape.
2365
+ *
2366
+ * User-provided `base.selected` overrides take priority; missing properties
2367
+ * fall back to auto-computed defaults (darkened/lightened stroke based on theme,
2368
+ * stronger shift than hover).
2369
+ */
2370
+ const resolveSelectedStyle = (base, _shapeType, theme) => {
2371
+ const userOverrides = base.selected;
2372
+ const baseStroke = base.stroke ?? "#000000";
2373
+ const autoStroke = theme === "dark" ? lightenColor(baseStroke, SELECTED_COLOR_SHIFT) : darkenColor(baseStroke, SELECTED_COLOR_SHIFT);
2374
+ return {
2375
+ fill: userOverrides?.fill ?? base.fill,
2376
+ fillOpacity: userOverrides?.fillOpacity ?? base.fillOpacity,
2377
+ stroke: userOverrides?.stroke ?? autoStroke,
2378
+ strokeOpacity: userOverrides?.strokeOpacity ?? base.strokeOpacity,
2379
+ strokeWidth: userOverrides?.strokeWidth ?? base.strokeWidth,
2380
+ };
2381
+ };
2382
+ // ============================================================================
2383
+ // Stroke color variants
2384
+ // ============================================================================
2385
+ /**
2386
+ * Returns the base, hovered, and selected stroke colors for a shape in one call.
2387
+ * Backed by the module-level color cache in colorUtils, so repeated calls with
2388
+ * the same inputs are cheap Map lookups.
2389
+ */
2390
+ const resolveStrokeColors = (style, shapeType, theme) => {
2391
+ const base = style.stroke ?? "#000";
2392
+ return {
2393
+ base,
2394
+ hovered: resolveHoveredStyle(style, shapeType, theme).stroke ?? base,
2395
+ selected: resolveSelectedStyle(style, shapeType, theme).stroke ?? base,
2396
+ };
2397
+ };
2398
+
2399
+ exports.ANCHOR_SELECTOR = ANCHOR_SELECTOR;
2400
+ exports.CIRCLE_SYMBOL_DEFAULT_DIAMETER_PX = CIRCLE_SYMBOL_DEFAULT_DIAMETER_PX;
2401
+ exports.CIRCLE_SYMBOL_DEFAULT_OPACITY = CIRCLE_SYMBOL_DEFAULT_OPACITY;
2402
+ exports.DEFAULT_CENTER = DEFAULT_CENTER;
2403
+ exports.DEFAULT_MAP_APPEARANCE = DEFAULT_MAP_APPEARANCE;
2404
+ exports.DEFAULT_ZOOM = DEFAULT_ZOOM;
2405
+ exports.HIT_SURFACE_SELECTOR = HIT_SURFACE_SELECTOR;
2406
+ exports.INITIAL_CAMERA_STATE = INITIAL_CAMERA_STATE;
2407
+ exports.INITIAL_INTERACTION_STATE = INITIAL_INTERACTION_STATE;
2408
+ exports.INITIAL_MAP_STATE = INITIAL_MAP_STATE;
2409
+ exports.INITIAL_MAP_STATUS = INITIAL_MAP_STATUS;
2410
+ exports.KEYBOARD_PAN_AMOUNT = KEYBOARD_PAN_AMOUNT;
2411
+ exports.KEYBOARD_ZOOM_AMOUNT = KEYBOARD_ZOOM_AMOUNT;
2412
+ exports.LAYER_FADE_DURATION_MS = LAYER_FADE_DURATION_MS;
2413
+ exports.MAP_CURSORS = MAP_CURSORS;
2414
+ exports.MAX_ZOOM = MAX_ZOOM;
2415
+ exports.MIN_ZOOM = MIN_ZOOM;
2416
+ exports.SAFE_AREA_DEFAULT_BUFFER_PX = SAFE_AREA_DEFAULT_BUFFER_PX;
2417
+ exports.SHAPE_STYLE_DEFAULTS = SHAPE_STYLE_DEFAULTS;
2418
+ exports.WORLD_BBOX = WORLD_BBOX;
2419
+ exports.allocSafeAreaDebugId = allocSafeAreaDebugId;
2420
+ exports.anchorFromBottomCenter = anchorFromBottomCenter;
2421
+ exports.attachSafeAreaHoverListeners = attachSafeAreaHoverListeners;
2422
+ exports.bboxEquals = bboxEquals;
2423
+ exports.bufferRectCorners = bufferRectCorners;
2424
+ exports.buildAdaptiveDomEntries = buildAdaptiveDomEntries;
2425
+ exports.buildAdaptiveDomRenderFn = buildAdaptiveDomRenderFn;
2426
+ exports.buildAdaptiveSymbolStyleFn = buildAdaptiveSymbolStyleFn;
2427
+ exports.cameraStateEquals = cameraStateEquals;
2428
+ exports.canPatchAdaptiveMarker = canPatchAdaptiveMarker;
2429
+ exports.canPatchAdaptiveViewport = canPatchAdaptiveViewport;
2430
+ exports.canPatchMarkerInPlace = canPatchMarkerInPlace;
2431
+ exports.clearSafeArea = clearSafeArea;
2432
+ exports.collectFeatureIdSet = collectFeatureIdSet;
2433
+ exports.colorWithOpacity = colorWithOpacity;
2434
+ exports.computeInitialState = computeInitialState;
2435
+ exports.computeMarkerDomPortalZIndex = computeMarkerDomPortalZIndex;
2436
+ exports.convexHull = convexHull;
2437
+ exports.createClusterPinElement = createClusterPinElement;
2438
+ exports.createDefaultClusterElement = createDefaultClusterElement;
2439
+ exports.createSymbolDotElement = createSymbolDotElement;
2440
+ exports.darkenColor = darkenColor;
2441
+ exports.defineAdapter = defineAdapter;
2442
+ exports.disableSafeAreaDebug = disableSafeAreaDebug;
2443
+ exports.discriminateRenderResult = discriminateRenderResult;
2444
+ exports.enableSafeAreaDebug = enableSafeAreaDebug;
2445
+ exports.estimateZoomFromBounds = estimateZoomFromBounds;
2446
+ exports.extractLineCoordinates = extractLineCoordinates;
2447
+ exports.extractPointCoordinates = extractPointCoordinates;
2448
+ exports.extractPolygonPaths = extractPolygonPaths;
2449
+ exports.extractSourceData = extractSourceData;
2450
+ exports.fadeInElement = fadeInElement;
2451
+ exports.filterRectsByCursorDirection = filterRectsByCursorDirection;
2452
+ exports.geometryTypeToShapeType = geometryTypeToShapeType;
2453
+ exports.getAdaptiveDomFeatureIds = getAdaptiveDomFeatureIds;
2454
+ exports.getAnchorRect = getAnchorRect;
2455
+ exports.getEffectiveRestrictBounds = getEffectiveRestrictBounds;
2456
+ exports.getHitSurfaceRect = getHitSurfaceRect;
2457
+ exports.hasSameFeatureIds = hasSameFeatureIds;
2458
+ exports.isCanvasMarkerMode = isCanvasMarkerMode;
2459
+ exports.isEventOfType = isEventOfType;
2460
+ exports.isSafeAreaDebugEnabled = isSafeAreaDebugEnabled;
2461
+ exports.lightenColor = lightenColor;
2462
+ exports.mapAppearanceSchema = mapAppearanceSchema;
2463
+ exports.mapStateEquals = mapStateEquals;
2464
+ exports.mapThemeSchema = mapThemeSchema;
2465
+ exports.mapTypeSchema = mapTypeSchema;
2466
+ exports.mercatorCenterFromBounds = mercatorCenterFromBounds;
2467
+ exports.mergeAntimeridianFeatures = mergeAntimeridianFeatures;
2468
+ exports.mixColor = mixColor;
2469
+ exports.patchPortalDescriptors = patchPortalDescriptors;
2470
+ exports.pointInPolygon = pointInPolygon;
2471
+ exports.removeGoneIndexedMarkers = removeGoneIndexedMarkers;
2472
+ exports.renderSafeArea = renderSafeArea;
2473
+ exports.resetColorUtilsForTesting = resetColorUtilsForTesting;
2474
+ exports.resolveCircleSymbolDefaults = resolveCircleSymbolDefaults;
2475
+ exports.resolveHoveredStyle = resolveHoveredStyle;
2476
+ exports.resolveSelectedStyle = resolveSelectedStyle;
2477
+ exports.resolveStrokeColors = resolveStrokeColors;
2478
+ exports.resolveSymbolDescriptor = resolveSymbolDescriptor;
2479
+ exports.safePolygon = safePolygon;
2480
+ exports.validateInitialViewport = validateInitialViewport;
2481
+ exports.watchSafeAreaLeave = watchSafeAreaLeave;