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