@trackunit/react-map-adapter-mapbox 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js ADDED
@@ -0,0 +1,2914 @@
1
+ 'use strict';
2
+
3
+ var reactMapAdapterShared = require('@trackunit/react-map-adapter-shared');
4
+ var geoJsonUtils = require('@trackunit/geo-json-utils');
5
+ var esToolkit = require('es-toolkit');
6
+ var mapboxgl = require('mapbox-gl');
7
+ var Supercluster = require('supercluster');
8
+ var zod = require('zod');
9
+ var jsxRuntime = require('react/jsx-runtime');
10
+ var react = require('react');
11
+ var reactDeviceDetect = require('react-device-detect');
12
+
13
+ /**
14
+ * Mapbox style URLs for different map types
15
+ */
16
+ const MAPBOX_MAP_TYPE_STYLES = {
17
+ roadmap: {
18
+ light: "mapbox://styles/mapbox/streets-v12",
19
+ dark: "mapbox://styles/mapbox/dark-v11",
20
+ },
21
+ satellite: {
22
+ light: "mapbox://styles/mapbox/satellite-v9",
23
+ dark: "mapbox://styles/mapbox/satellite-v9",
24
+ },
25
+ hybrid: {
26
+ light: "mapbox://styles/mapbox/satellite-streets-v12",
27
+ dark: "mapbox://styles/mapbox/satellite-streets-v12",
28
+ },
29
+ };
30
+
31
+ // ============================================================================
32
+ // Helpers
33
+ // ============================================================================
34
+ /**
35
+ * Convert our internal readonly GeoJSON FeatureCollection to a mutable
36
+ * Mapbox-compatible GeoJSON object. Mapbox GL JS expects mutable arrays,
37
+ * while our internal types use `readonly`.
38
+ */
39
+ const toMapboxGeoJson = (collection) => JSON.parse(JSON.stringify(collection));
40
+ /**
41
+ * Convert a GeoJSON FeatureCollection for shape sources, injecting each
42
+ * feature's top-level `id` into `properties.__featureId`.
43
+ *
44
+ * Mapbox GL JS only exposes **numeric** feature IDs via `feature.id` in
45
+ * query results. Our features use string IDs, so we store the original
46
+ * ID as a property and read it from `properties.__featureId` in event
47
+ * handlers and paint expressions.
48
+ */
49
+ const toMapboxShapeGeoJson = (collection, featureStyles, featureZIndexOverrides) => {
50
+ const raw = JSON.parse(JSON.stringify(collection));
51
+ for (const feature of raw.features) {
52
+ const featureId = feature.id !== undefined ? String(feature.id) : "";
53
+ const perFeature = featureStyles?.get(featureId);
54
+ const zIndex = featureZIndexOverrides?.get(featureId);
55
+ feature.properties = {
56
+ ...feature.properties,
57
+ __featureId: featureId,
58
+ ...(zIndex !== undefined && { _zIndex: zIndex }),
59
+ ...(perFeature !== undefined && {
60
+ __fill: perFeature.fill,
61
+ __fillOpacity: perFeature.fillOpacity,
62
+ __stroke: perFeature.stroke,
63
+ __strokeOpacity: perFeature.strokeOpacity,
64
+ __strokeWidth: perFeature.strokeWidth,
65
+ __pointRadius: perFeature.pointRadius,
66
+ }),
67
+ };
68
+ }
69
+ return raw;
70
+ };
71
+ /**
72
+ * Returns a copy of the collection with each feature's geometry replaced by its
73
+ * clipped fill geometry (ADR-0021 fill tiling), when present. Features without
74
+ * an override keep their full geometry. Used to build the dedicated fill source
75
+ * so polygon fills are clipped while the line source keeps full outlines.
76
+ */
77
+ const applyFillGeometries = (collection, featureFillGeometries) => {
78
+ if (featureFillGeometries === undefined || featureFillGeometries.size === 0)
79
+ return collection;
80
+ return {
81
+ ...collection,
82
+ features: collection.features.map(feature => {
83
+ const featureId = feature.id !== undefined ? String(feature.id) : "";
84
+ const override = featureFillGeometries.get(featureId);
85
+ return override === undefined ? feature : { ...feature, geometry: override };
86
+ }),
87
+ };
88
+ };
89
+ /**
90
+ * Build a Mapbox paint value that is either a static fallback or a data-driven
91
+ * expression reading from an injected feature property.
92
+ */
93
+ const shapePaint = (featureStyles, prop, fallback) => (featureStyles !== undefined ? ["coalesce", ["get", prop], fallback] : fallback);
94
+ /**
95
+ * Filter layer IDs based on the interactive mode, keeping only the layers
96
+ * that should respond to interaction events.
97
+ *
98
+ * Convention: `shape-fill-{id}` = polygon fill, `shape-line-{id}` = stroke,
99
+ * `shape-circle-{id}` = point circles (always interactive).
100
+ */
101
+ const filterLayerIdsByMode = (mode, layerIds, configId) => {
102
+ const lineId = `shape-line-${configId}`;
103
+ const circleId = `shape-circle-${configId}`;
104
+ switch (mode) {
105
+ case "stroke":
106
+ return layerIds.filter(id => id === lineId || id === circleId);
107
+ case "stroke-and-fill":
108
+ return layerIds;
109
+ default:
110
+ throw new Error(`${mode} is not known`);
111
+ }
112
+ };
113
+ /**
114
+ * Convert a GeoJSON bbox [minLng, minLat, maxLng, maxLat] to the corner
115
+ * coordinate format required by Mapbox image sources:
116
+ * [[topLeft], [topRight], [bottomRight], [bottomLeft]]
117
+ */
118
+ const bboxToImageCoordinates = (bbox) => {
119
+ const [minLng, minLat, maxLng, maxLat] = bbox;
120
+ return [
121
+ [minLng, maxLat], // top-left
122
+ [maxLng, maxLat], // top-right
123
+ [maxLng, minLat], // bottom-right
124
+ [minLng, minLat], // bottom-left
125
+ ];
126
+ };
127
+ /**
128
+ * Safely check if a source exists on the map.
129
+ */
130
+ const hasSource = (map, sourceId) => {
131
+ try {
132
+ return map.getSource(sourceId) !== undefined;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ };
138
+ /**
139
+ * Safely check if a layer exists on the map.
140
+ */
141
+ const hasLayer = (map, layerId) => {
142
+ try {
143
+ return map.getLayer(layerId) !== undefined;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ };
149
+ /**
150
+ * Safely remove a layer then its source from the map.
151
+ */
152
+ const safeRemoveLayersAndSource = (map, layerIds, sourceId) => {
153
+ for (const layerId of layerIds) {
154
+ if (hasLayer(map, layerId)) {
155
+ map.removeLayer(layerId);
156
+ }
157
+ }
158
+ if (hasSource(map, sourceId)) {
159
+ map.removeSource(sourceId);
160
+ }
161
+ };
162
+ // ============================================================================
163
+ // MapboxLayerPort
164
+ // ============================================================================
165
+ /**
166
+ * Mapbox implementation of the LayerPort interface.
167
+ *
168
+ * Manages Mapbox GL JS sources, layers, and Marker overlays to render map content.
169
+ * Each source type maps to appropriate Mapbox constructs:
170
+ * - Markers: `mapboxgl.Marker` DOM overlays (symbol: styled div, dom: portal container)
171
+ * - Shapes: GeoJSON source + fill/line layers
172
+ * - Routes: GeoJSON source + line layer (+ optional symbol layer for arrows)
173
+ * - Image overlays: Image source + raster layer
174
+ *
175
+ * Client-side clustering uses `supercluster` directly, managing marker
176
+ * visibility based on viewport and zoom level.
177
+ *
178
+ * Requires `connect(map)` to be called (by MapboxAdapterInstance) before
179
+ * any sources can be rendered. Sources set before connect are queued.
180
+ */
181
+ class MapboxLayerPort {
182
+ constructor() {
183
+ /**
184
+ * Marker portal subscription compatible with `useSyncExternalStore`.
185
+ * `<Layers>` routes descriptors from this store to `markerRender`. Includes
186
+ * static DOM markers and adaptive markers resolved to the DOM medium.
187
+ */
188
+ this.markerPortals = {
189
+ subscribe: (callback) => {
190
+ this.markerPortalSubscribers.add(callback);
191
+ return () => {
192
+ this.markerPortalSubscribers.delete(callback);
193
+ };
194
+ },
195
+ getSnapshot: () => {
196
+ return this.markerPortalDescriptors;
197
+ },
198
+ };
199
+ /**
200
+ * Cluster portal subscription compatible with `useSyncExternalStore`.
201
+ * `<Layers>` routes descriptors from this store to `clusterRender`. Includes
202
+ * client-cluster DOM containers and any other cluster-class portals.
203
+ */
204
+ this.clusterPortals = {
205
+ subscribe: (callback) => {
206
+ this.clusterPortalSubscribers.add(callback);
207
+ return () => {
208
+ this.clusterPortalSubscribers.delete(callback);
209
+ };
210
+ },
211
+ getSnapshot: () => {
212
+ return this.clusterPortalDescriptors;
213
+ },
214
+ };
215
+ this.map = null;
216
+ this.interactionHandlers = new Set();
217
+ this.backgroundClickHandlers = new Set();
218
+ this.entityClickedInTick = false;
219
+ this.mapClickHandler = null;
220
+ // Tracked sources, keyed by source ID
221
+ this.markerSources = new Map();
222
+ /** featureId → DOM marker, keyed by sourceId. Cleared when a source is removed. */
223
+ this.adaptiveMarkerIndex = new Map();
224
+ /** featureId → cluster DOM marker, keyed by sourceId. Cleared when a source is removed. */
225
+ this.clusterMarkerIndex = new Map();
226
+ this.shapeSources = new Map();
227
+ this.routeSources = new Map();
228
+ this.overlaySources = new Map();
229
+ // Snapshot queued before the map is connected / style is ready
230
+ this.pendingSnapshot = null;
231
+ // Last non-empty snapshot the consumer pushed. Preserved across `destroy()` so the React 18
232
+ // StrictMode dev cycle (cleanup pushes EMPTY → adapter destroy → remount → `useWatch` retains
233
+ // its prev value and does not re-fire) can replay the actual layer state on the next `connect`.
234
+ this.lastNonEmptySnapshot = null;
235
+ // ---- DOM portal registry ----
236
+ // Two typed stores so `<Layers>` derives render-config routing
237
+ // (markerRender vs clusterRender) structurally from store membership.
238
+ this.markerPortalDescriptors = [];
239
+ this.clusterPortalDescriptors = [];
240
+ this.markerPortalSubscribers = new Set();
241
+ this.clusterPortalSubscribers = new Set();
242
+ this.clusterEntitiesByMarker = new WeakMap();
243
+ // ---- Style change reconnection ----
244
+ this.styleLoadHandler = null;
245
+ /**
246
+ * Tracks whether the Mapbox style has loaded at least once.
247
+ * `map.isStyleLoaded()` returns false inside `style.load` callbacks,
248
+ * so we maintain our own flag set synchronously in the handler.
249
+ */
250
+ this.styleReady = false;
251
+ // ---- Direction arrow image tracking ----
252
+ this.arrowImageAdded = false;
253
+ // ---- Shape layer interaction handler storage ----
254
+ this.shapeClickHandlers = new Map();
255
+ this.shapeDblClickHandlers = new Map();
256
+ this.shapeMouseEnterHandlers = new Map();
257
+ this.shapeMouseLeaveHandlers = new Map();
258
+ // ---- Route layer interaction handler storage ----
259
+ this.routeClickHandlers = new Map();
260
+ this.routeDblClickHandlers = new Map();
261
+ this.routeMouseEnterHandlers = new Map();
262
+ this.routeMouseLeaveHandlers = new Map();
263
+ // ---- Marker circle layer interaction handler storage ----
264
+ this.markerClickHandlers = new Map();
265
+ this.markerDblClickHandlers = new Map();
266
+ this.markerMouseMoveHandlers = new Map();
267
+ this.markerMouseLeaveHandlers = new Map();
268
+ this.lastHoveredMarkerFeatureId = null;
269
+ // ---- Adaptive hover watcher (shared across all DOM markers) ----
270
+ /** Shared state for the active document-level pointermove watcher across all DOM markers. */
271
+ this.markerHoverState = { activeWatcherCancel: null, activeGuardCancel: null };
272
+ // ---- Shape interaction tracking ----
273
+ this.selectedShapeHandleId = null;
274
+ this.selectedShapeFeatureId = null;
275
+ this.hoveredShapeHandleId = null;
276
+ this.hoveredShapeFeatureId = null;
277
+ this.theme = "light";
278
+ // ---- Source readiness tracking ----
279
+ this.sourceReadyCallbacks = new Map();
280
+ this.sourcedataHandler = null;
281
+ this._shapeStyleDefaults = undefined;
282
+ }
283
+ get shapeStyleDefaults() {
284
+ if (this._shapeStyleDefaults === undefined) {
285
+ throw new Error("shapeStyleDefaults not injected — call setShapeStyleDefaults before using MapboxLayerPort");
286
+ }
287
+ return this._shapeStyleDefaults;
288
+ }
289
+ // ---- Connection ----
290
+ setShapeStyleDefaults(defaults) {
291
+ this._shapeStyleDefaults = defaults;
292
+ }
293
+ /**
294
+ * Connect to a Mapbox map instance.
295
+ * Called by MapboxAdapterInstance.connect() when the map is ready.
296
+ * Flushes any sources that were set before the map was available.
297
+ */
298
+ connect(map) {
299
+ this.map = map;
300
+ // Persistent style.load listener: marks the style as ready, flushes any
301
+ // queued sources, and reconnects GL layers that were lost on style change.
302
+ this.styleLoadHandler = () => {
303
+ this.styleReady = true;
304
+ this.onStyleLoad();
305
+ this.flushPending();
306
+ };
307
+ map.on("style.load", this.styleLoadHandler);
308
+ this.sourcedataHandler = (e) => {
309
+ if (e.isSourceLoaded !== true || e.sourceId === undefined)
310
+ return;
311
+ const sourceId = e.sourceId;
312
+ const callbacks = this.sourceReadyCallbacks.get(sourceId);
313
+ if (callbacks === undefined)
314
+ return;
315
+ this.sourceReadyCallbacks.delete(sourceId);
316
+ callbacks.forEach(cb => cb());
317
+ };
318
+ map.on("sourcedata", this.sourcedataHandler);
319
+ // Background click detection
320
+ this.mapClickHandler = () => {
321
+ if (!this.entityClickedInTick) {
322
+ this.backgroundClickHandlers.forEach(handler => handler());
323
+ }
324
+ };
325
+ map.on("click", this.mapClickHandler);
326
+ // Reset arrow image flag (new map/style = need to re-add)
327
+ this.arrowImageAdded = false;
328
+ // If style is already loaded (reconnection), flush immediately.
329
+ if (this.styleReady) {
330
+ this.flushPending();
331
+ }
332
+ }
333
+ // ---- Declarative state ----
334
+ setSnapshot(snapshot) {
335
+ if (snapshot.layers.length > 0) {
336
+ this.lastNonEmptySnapshot = snapshot;
337
+ }
338
+ if (this.map === null || !this.styleReady) {
339
+ this.pendingSnapshot = snapshot;
340
+ return;
341
+ }
342
+ this.applySnapshot(snapshot);
343
+ }
344
+ // ---- Theme ----
345
+ /**
346
+ * Update the theme used for interaction style resolution (hover color shifts).
347
+ * Called by the adapter instance when the map theme changes.
348
+ */
349
+ setTheme(theme) {
350
+ if (this.theme === theme)
351
+ return;
352
+ this.theme = theme;
353
+ if (this.map === null)
354
+ return;
355
+ if (this.hoveredShapeHandleId !== null) {
356
+ this.applyShapeInteractionPaint(this.hoveredShapeHandleId);
357
+ }
358
+ if (this.selectedShapeHandleId !== null && this.selectedShapeHandleId !== this.hoveredShapeHandleId) {
359
+ this.applyShapeInteractionPaint(this.selectedShapeHandleId);
360
+ }
361
+ }
362
+ // ---- Entity interaction ----
363
+ onEntityInteraction(handler) {
364
+ this.interactionHandlers.add(handler);
365
+ return () => {
366
+ this.interactionHandlers.delete(handler);
367
+ };
368
+ }
369
+ onBackgroundClick(handler) {
370
+ this.backgroundClickHandlers.add(handler);
371
+ return () => {
372
+ this.backgroundClickHandlers.delete(handler);
373
+ };
374
+ }
375
+ // ---- Source readiness ----
376
+ onSourceReady(configId, callback) {
377
+ const internalId = `shape-source-${configId}`;
378
+ if (this.map !== null && hasSource(this.map, internalId) && this.map.isSourceLoaded(internalId)) {
379
+ queueMicrotask(callback);
380
+ return () => {
381
+ /* already fired */
382
+ };
383
+ }
384
+ let existing = this.sourceReadyCallbacks.get(internalId);
385
+ if (existing === undefined) {
386
+ existing = new Set();
387
+ this.sourceReadyCallbacks.set(internalId, existing);
388
+ }
389
+ existing.add(callback);
390
+ return () => {
391
+ existing.delete(callback);
392
+ if (existing.size === 0) {
393
+ this.sourceReadyCallbacks.delete(internalId);
394
+ }
395
+ };
396
+ }
397
+ // ---- Lifecycle ----
398
+ /** @internal */
399
+ destroy() {
400
+ this.markerHoverState.activeWatcherCancel?.();
401
+ this.markerHoverState.activeWatcherCancel = null;
402
+ this.markerHoverState.activeGuardCancel?.();
403
+ this.markerHoverState.activeGuardCancel = null;
404
+ // Remove all sources
405
+ for (const id of [...this.markerSources.keys()]) {
406
+ this.removeMarkerSourceInternal(id);
407
+ }
408
+ for (const id of [...this.shapeSources.keys()]) {
409
+ this.removeShapeSourceInternal(id);
410
+ }
411
+ for (const id of [...this.routeSources.keys()]) {
412
+ this.removeRouteSourceInternal(id);
413
+ }
414
+ for (const id of [...this.overlaySources.keys()]) {
415
+ this.removeImageOverlayInternal(id);
416
+ }
417
+ // Remove style.load listener
418
+ if (this.styleLoadHandler !== null && this.map !== null) {
419
+ this.map.off("style.load", this.styleLoadHandler);
420
+ this.styleLoadHandler = null;
421
+ }
422
+ // Remove sourcedata listener
423
+ if (this.sourcedataHandler !== null && this.map !== null) {
424
+ this.map.off("sourcedata", this.sourcedataHandler);
425
+ this.sourcedataHandler = null;
426
+ }
427
+ this.sourceReadyCallbacks.clear();
428
+ if (this.mapClickHandler !== null && this.map !== null) {
429
+ this.map.off("click", this.mapClickHandler);
430
+ this.mapClickHandler = null;
431
+ }
432
+ this.interactionHandlers.clear();
433
+ this.backgroundClickHandlers.clear();
434
+ this.markerPortalDescriptors = [];
435
+ this.clusterPortalDescriptors = [];
436
+ this.markerPortalSubscribers.clear();
437
+ this.clusterPortalSubscribers.clear();
438
+ this.pendingSnapshot = null;
439
+ this.arrowImageAdded = false;
440
+ this.styleReady = false;
441
+ this.lastHoveredMarkerFeatureId = null;
442
+ this.selectedShapeHandleId = null;
443
+ this.selectedShapeFeatureId = null;
444
+ this.hoveredShapeHandleId = null;
445
+ this.hoveredShapeFeatureId = null;
446
+ this.map = null;
447
+ }
448
+ // ---- Marker sources ----
449
+ syncMarkerSource(config) {
450
+ if (this.map === null)
451
+ return;
452
+ // Skip full rebuild if config reference hasn't changed (same data, nothing to do)
453
+ const existing = this.markerSources.get(config.id);
454
+ if (existing !== undefined && existing.config === config) {
455
+ return;
456
+ }
457
+ // Adaptive mode: incremental patch when only adaptiveResolution (or callbacks) changed
458
+ // but the feature set and structural config are the same. This avoids the
459
+ // remove-then-add cycle (and its cascading mouseleave → hover-clear → rebuild loop)
460
+ // that caused all markers to flash on every hover interaction.
461
+ if (existing !== undefined && reactMapAdapterShared.canPatchAdaptiveMarker(existing.config, config)) {
462
+ this.patchAdaptiveMarkers(existing, config);
463
+ existing.config = config;
464
+ return;
465
+ }
466
+ // Adaptive mode: viewport refetch changed which markers/clusters are visible but
467
+ // structural config is unchanged. Diff by feature id instead of tearing everything down.
468
+ if (existing !== undefined && reactMapAdapterShared.canPatchAdaptiveViewport(existing.config, config)) {
469
+ this.patchAdaptiveViewportData(existing, config);
470
+ existing.config = config;
471
+ return;
472
+ }
473
+ // Enhanced bail-out: skip full rebuild when only callback references changed
474
+ // but the actual marker data (feature IDs, render mode, anchor, clustering) is the same.
475
+ // This prevents visible blinking caused by unstable function references
476
+ // (e.g. inline style/render callbacks recreated on React re-renders).
477
+ if (existing !== undefined && reactMapAdapterShared.canPatchMarkerInPlace(existing.config, config)) {
478
+ if (config.markerRender.mode === "dom") {
479
+ this.patchDomMarkers(existing, config);
480
+ }
481
+ existing.config = config;
482
+ return;
483
+ }
484
+ // Remove existing markers for this source (update case)
485
+ this.removeMarkerSourceInternal(config.id);
486
+ const tracked = { markers: [], config };
487
+ const currentMap = this.map;
488
+ // ---- Client-side clustering path (interactive markers only) ----
489
+ if (config.kind === "marker" && config.clusterConfig?.mode === "client") {
490
+ if (config.markerRender.mode === "adaptive") {
491
+ throw new Error("react-map: adaptive marker rendering is not supported with client-side clustering");
492
+ }
493
+ this.setupClientClustering(config, currentMap, tracked);
494
+ this.markerSources.set(config.id, tracked);
495
+ return;
496
+ }
497
+ // ---- Standard (no clustering / server-side clustering) path ----
498
+ const { markerRender } = config;
499
+ const useCircleLayer = markerRender.mode === "symbol" || markerRender.mode === "adaptive";
500
+ if (useCircleLayer) {
501
+ // WebGL circle layer path for symbol/adaptive modes
502
+ const promotedIds = reactMapAdapterShared.getAdaptiveDomFeatureIds(config);
503
+ const symbolState = {
504
+ medium: "symbol",
505
+ selected: false,
506
+ hovered: false,
507
+ theme: this.theme,
508
+ };
509
+ const styleFn = markerRender.mode === "symbol"
510
+ ? item => markerRender.render(item, symbolState)
511
+ : reactMapAdapterShared.buildAdaptiveSymbolStyleFn(markerRender.render, symbolState);
512
+ // Build enriched GeoJSON with resolved style properties, filtering out promoted features
513
+ const enrichedFeatures = [];
514
+ for (const feature of config.features.features) {
515
+ const coords = reactMapAdapterShared.extractPointCoordinates(feature);
516
+ if (coords === null)
517
+ continue;
518
+ const featureId = feature.id !== undefined ? String(feature.id) : undefined;
519
+ if (featureId !== undefined && promotedIds.has(featureId))
520
+ continue;
521
+ const resolved = reactMapAdapterShared.resolveCircleSymbolDefaults(reactMapAdapterShared.resolveSymbolDescriptor(styleFn, feature.properties));
522
+ enrichedFeatures.push({
523
+ type: "Feature",
524
+ id: feature.id !== undefined ? Number(feature.id) || undefined : undefined,
525
+ geometry: { type: "Point", coordinates: [coords.lng, coords.lat] },
526
+ properties: {
527
+ __featureId: featureId ?? "",
528
+ __color: resolved.color,
529
+ __radius: resolved.radiusPx,
530
+ __opacity: resolved.opacity,
531
+ __strokeColor: resolved.borderColor ?? "#000000",
532
+ __strokeWidth: resolved.hasBorder ? resolved.borderWidthPx : 0,
533
+ },
534
+ });
535
+ }
536
+ const sourceId = `marker-source-${config.id}`;
537
+ const circleLayerId = `marker-circle-${config.id}`;
538
+ currentMap.addSource(sourceId, {
539
+ type: "geojson",
540
+ data: { type: "FeatureCollection", features: enrichedFeatures },
541
+ });
542
+ this.addLayerWithFadeIn(currentMap, {
543
+ id: circleLayerId,
544
+ type: "circle",
545
+ source: sourceId,
546
+ paint: {
547
+ "circle-radius": ["coalesce", ["get", "__radius"], 8],
548
+ "circle-color": ["coalesce", ["get", "__color"], "#4285F4"],
549
+ "circle-opacity": ["coalesce", ["get", "__opacity"], 1],
550
+ "circle-stroke-width": ["coalesce", ["get", "__strokeWidth"], 0],
551
+ "circle-stroke-color": ["coalesce", ["get", "__strokeColor"], "#000000"],
552
+ },
553
+ });
554
+ tracked.sourceId = sourceId;
555
+ tracked.layerIds = [circleLayerId];
556
+ if (config.kind === "marker") {
557
+ this.attachMarkerCircleLayerListeners(currentMap, circleLayerId);
558
+ }
559
+ if (markerRender.mode === "adaptive") {
560
+ this.createAdaptiveDomOverlays(config, currentMap, tracked);
561
+ }
562
+ }
563
+ else {
564
+ // DOM marker path (mode === "dom")
565
+ for (const feature of config.features.features) {
566
+ const coords = reactMapAdapterShared.extractPointCoordinates(feature);
567
+ if (coords === null)
568
+ continue;
569
+ const marker = this.createMarkerForFeature(feature, coords, config, currentMap);
570
+ tracked.markers.push(marker);
571
+ if (config.kind === "marker") {
572
+ const featureId = feature.id !== undefined ? String(feature.id) : undefined;
573
+ if (featureId !== undefined) {
574
+ this.attachMarkerInteractionListeners(marker, featureId, coords);
575
+ }
576
+ }
577
+ else if (marker.getElement() instanceof HTMLElement) {
578
+ marker.getElement().style.pointerEvents = "none";
579
+ }
580
+ }
581
+ }
582
+ // Render server-side cluster features if present (interactive markers only)
583
+ if (config.kind === "marker" && config.clusterFeatures !== null) {
584
+ const clusterIndex = new Map();
585
+ for (const feature of config.clusterFeatures.features) {
586
+ const coords = reactMapAdapterShared.extractPointCoordinates(feature);
587
+ if (coords === null)
588
+ continue;
589
+ const marker = this.createClusterMarkerForFeature(feature, coords, config, currentMap);
590
+ tracked.markers.push(marker);
591
+ const featureId = feature.id !== undefined ? String(feature.id) : undefined;
592
+ if (featureId !== undefined) {
593
+ clusterIndex.set(featureId, marker);
594
+ const props = feature.properties;
595
+ const rawMarkerIds = props !== null ? props.markerIds : undefined;
596
+ const markerIds = Array.isArray(rawMarkerIds) ? rawMarkerIds : [];
597
+ const clusterBbox = props && props.bbox !== null ? geoJsonUtils.validateBbox(props.bbox) : null;
598
+ this.attachClusterInteractionListeners(marker, featureId, coords, markerIds, clusterBbox);
599
+ }
600
+ }
601
+ this.clusterMarkerIndex.set(config.id, clusterIndex);
602
+ }
603
+ this.markerSources.set(config.id, tracked);
604
+ }
605
+ removeMarkerSourceInternal(id) {
606
+ const tracked = this.markerSources.get(id);
607
+ if (tracked === undefined)
608
+ return;
609
+ // Clean up viewport listener for client-side clustering
610
+ if (tracked.viewportListener !== undefined && this.map !== null) {
611
+ this.map.off("moveend", tracked.viewportListener);
612
+ }
613
+ // Remove cluster markers
614
+ if (tracked.clusterMarkers !== undefined) {
615
+ for (const marker of tracked.clusterMarkers) {
616
+ marker.remove();
617
+ }
618
+ }
619
+ // Remove all individual DOM markers
620
+ for (const marker of tracked.markers) {
621
+ marker.remove();
622
+ }
623
+ // Remove WebGL circle layer/source if present
624
+ if (tracked.sourceId !== undefined && tracked.layerIds !== undefined && this.map !== null) {
625
+ this.detachMarkerCircleLayerListeners(this.map, tracked.layerIds);
626
+ safeRemoveLayersAndSource(this.map, tracked.layerIds, tracked.sourceId);
627
+ }
628
+ // Clean up DOM portal descriptors for this source
629
+ this.removePortalDescriptorsForSource(id);
630
+ this.markerSources.delete(id);
631
+ this.adaptiveMarkerIndex.delete(id);
632
+ this.clusterMarkerIndex.delete(id);
633
+ }
634
+ // ---- Shape sources ----
635
+ syncShapeSource(config) {
636
+ if (this.map === null)
637
+ return;
638
+ const existing = this.shapeSources.get(config.id);
639
+ if (existing !== undefined) {
640
+ const prev = existing.config;
641
+ const featuresChanged = prev.features !== config.features;
642
+ const featureStylesChanged = prev.featureStyles !== config.featureStyles;
643
+ const fillGeometriesChanged = prev.featureFillGeometries !== config.featureFillGeometries;
644
+ const zIndexOverridesChanged = prev.featureZIndexOverrides !== config.featureZIndexOverrides;
645
+ const styleChanged = !esToolkit.isEqual(prev.style, config.style);
646
+ const interactiveChanged = prev.interactive !== config.interactive;
647
+ if (!featuresChanged &&
648
+ !styleChanged &&
649
+ !featureStylesChanged &&
650
+ !fillGeometriesChanged &&
651
+ !zIndexOverridesChanged &&
652
+ !interactiveChanged) {
653
+ return;
654
+ }
655
+ const existingSource = this.map.getSource(existing.sourceId);
656
+ const existingFillSource = this.map.getSource(existing.fillSourceId);
657
+ if (existingSource !== undefined && "setData" in existingSource) {
658
+ // Re-densify whenever features, per-feature styles, or the layer-level geodesic flag changes —
659
+ // geodesic is a coordinate-transform that lives in the source data, not just a paint property.
660
+ const geodesicChanged = prev.style.geodesic !== config.style.geodesic;
661
+ const interactionPaintActive = this.selectedShapeHandleId === config.id || this.hoveredShapeHandleId === config.id;
662
+ if ((featuresChanged || featureStylesChanged || geodesicChanged) && "setData" in existingSource) {
663
+ existingSource.setData(toMapboxShapeGeoJson(reactMapAdapterShared.mergeAntimeridianFeatures(reactMapAdapterShared.densifyGeodesicFeatures(config.features, config.style.geodesic ?? true, config.featureStyles)), config.featureStyles));
664
+ }
665
+ if ((featuresChanged ||
666
+ featureStylesChanged ||
667
+ geodesicChanged ||
668
+ fillGeometriesChanged ||
669
+ zIndexOverridesChanged) &&
670
+ existingFillSource !== undefined &&
671
+ "setData" in existingFillSource) {
672
+ existingFillSource.setData(toMapboxShapeGeoJson(reactMapAdapterShared.mergeAntimeridianFeatures(reactMapAdapterShared.densifyGeodesicFeatures(applyFillGeometries(config.features, config.featureFillGeometries), config.style.geodesic ?? true, config.featureStyles)), config.featureStyles, config.featureZIndexOverrides));
673
+ }
674
+ if (styleChanged || featureStylesChanged) {
675
+ const updatedFs = config.featureStyles;
676
+ this.map.setPaintProperty(`shape-fill-${config.id}`, "fill-color", shapePaint(updatedFs, "__fill", config.style.fill ?? "rgba(0,0,0,0.1)"));
677
+ this.map.setPaintProperty(`shape-fill-${config.id}`, "fill-opacity", shapePaint(updatedFs, "__fillOpacity", config.style.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity));
678
+ this.map.setPaintProperty(`shape-line-${config.id}`, "line-color", shapePaint(updatedFs, "__stroke", config.style.stroke ?? "#000000"));
679
+ this.map.setPaintProperty(`shape-line-${config.id}`, "line-width", shapePaint(updatedFs, "__strokeWidth", config.style.strokeWidth ?? this.shapeStyleDefaults.line.strokeWidth));
680
+ this.map.setPaintProperty(`shape-line-${config.id}`, "line-opacity", shapePaint(updatedFs, "__strokeOpacity", config.style.strokeOpacity ?? this.shapeStyleDefaults.line.strokeOpacity));
681
+ this.map.setPaintProperty(`shape-circle-${config.id}`, "circle-radius", shapePaint(updatedFs, "__pointRadius", config.style.pointRadius ?? this.shapeStyleDefaults.point.pointRadius));
682
+ this.map.setPaintProperty(`shape-circle-${config.id}`, "circle-color", shapePaint(updatedFs, "__fill", config.style.fill ?? "rgba(0,0,0,0.1)"));
683
+ this.map.setPaintProperty(`shape-circle-${config.id}`, "circle-stroke-color", shapePaint(updatedFs, "__stroke", config.style.stroke ?? "#000000"));
684
+ this.map.setPaintProperty(`shape-circle-${config.id}`, "circle-stroke-width", shapePaint(updatedFs, "__strokeWidth", config.style.strokeWidth ?? this.shapeStyleDefaults.point.strokeWidth));
685
+ this.map.setPaintProperty(`shape-circle-${config.id}`, "circle-opacity", shapePaint(updatedFs, "__fillOpacity", config.style.fillOpacity ?? this.shapeStyleDefaults.point.fillOpacity));
686
+ }
687
+ const shouldReapplyInteractionPaint = interactionPaintActive && (styleChanged || featureStylesChanged);
688
+ if (interactiveChanged) {
689
+ this.detachShapeLayerInteractionListeners(this.map, existing.layerIds);
690
+ if (config.interactive !== "none") {
691
+ const filtered = filterLayerIdsByMode(config.interactive, existing.layerIds, config.id);
692
+ this.attachShapeLayerInteractionListeners(this.map, filtered, config.id);
693
+ }
694
+ }
695
+ existing.config = config;
696
+ if (shouldReapplyInteractionPaint) {
697
+ this.applyShapeInteractionPaint(config.id);
698
+ }
699
+ return;
700
+ }
701
+ }
702
+ // Full add: source doesn't exist yet (first add or style reload)
703
+ this.removeShapeSourceInternal(config.id);
704
+ const currentMap = this.map;
705
+ const sourceId = `shape-source-${config.id}`;
706
+ const fillSourceId = `shape-fill-src-${config.id}`;
707
+ const fillLayerId = `shape-fill-${config.id}`;
708
+ const lineLayerId = `shape-line-${config.id}`;
709
+ const circleLayerId = `shape-circle-${config.id}`;
710
+ const fs = config.featureStyles;
711
+ // Geodesic densification: insert great-circle intermediate points so polygon/line
712
+ // edges curve with the Earth's surface. Mapbox GL has no native geodesic support —
713
+ // this pre-processes the GeoJSON before it reaches either shape source.
714
+ // TODO: remove densifyGeodesicFeatures once Mapbox GL ships native geodesic line/fill
715
+ // rendering and replace with a layer-level paint/layout property instead.
716
+ const geoJsonData = toMapboxShapeGeoJson(reactMapAdapterShared.mergeAntimeridianFeatures(reactMapAdapterShared.densifyGeodesicFeatures(config.features, config.style.geodesic ?? true, config.featureStyles)), fs);
717
+ // Fills read from a dedicated source carrying clipped geometry and z-index
718
+ // overrides; strokes and circles keep the original full-geometry source so
719
+ // outlines stay full. The _zIndex property is read by the fill-sort-key layout
720
+ // property for hover-promotion stacking (ADR-0021 Path B).
721
+ const fillGeoJsonData = toMapboxShapeGeoJson(reactMapAdapterShared.mergeAntimeridianFeatures(reactMapAdapterShared.densifyGeodesicFeatures(applyFillGeometries(config.features, config.featureFillGeometries), config.style.geodesic ?? true, config.featureStyles)), fs, config.featureZIndexOverrides);
722
+ currentMap.addSource(sourceId, {
723
+ type: "geojson",
724
+ data: geoJsonData,
725
+ });
726
+ currentMap.addSource(fillSourceId, {
727
+ type: "geojson",
728
+ data: fillGeoJsonData,
729
+ });
730
+ const layerIds = [];
731
+ this.addLayerWithFadeIn(currentMap, {
732
+ id: fillLayerId,
733
+ type: "fill",
734
+ source: fillSourceId,
735
+ filter: ["any", ["==", ["geometry-type"], "Polygon"], ["==", ["geometry-type"], "MultiPolygon"]],
736
+ layout: {
737
+ // _zIndex is injected per-feature by the fill tiling hook (ADR-0021 Path B).
738
+ // Features without _zIndex (non-overlapping) render at z-order 0.
739
+ "fill-sort-key": ["coalesce", ["get", "_zIndex"], 0],
740
+ },
741
+ paint: {
742
+ "fill-color": shapePaint(fs, "__fill", config.style.fill ?? "rgba(0,0,0,0.1)"),
743
+ "fill-opacity": shapePaint(fs, "__fillOpacity", config.style.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity),
744
+ },
745
+ });
746
+ layerIds.push(fillLayerId);
747
+ this.addLayerWithFadeIn(currentMap, {
748
+ id: lineLayerId,
749
+ type: "line",
750
+ source: sourceId,
751
+ paint: {
752
+ "line-color": shapePaint(fs, "__stroke", config.style.stroke ?? "#000000"),
753
+ "line-width": shapePaint(fs, "__strokeWidth", config.style.strokeWidth ?? this.shapeStyleDefaults.line.strokeWidth),
754
+ "line-opacity": shapePaint(fs, "__strokeOpacity", config.style.strokeOpacity ?? this.shapeStyleDefaults.line.strokeOpacity),
755
+ },
756
+ });
757
+ layerIds.push(lineLayerId);
758
+ this.addLayerWithFadeIn(currentMap, {
759
+ id: circleLayerId,
760
+ type: "circle",
761
+ source: sourceId,
762
+ filter: ["any", ["==", ["geometry-type"], "Point"], ["==", ["geometry-type"], "MultiPoint"]],
763
+ paint: {
764
+ "circle-radius": shapePaint(fs, "__pointRadius", config.style.pointRadius ?? this.shapeStyleDefaults.point.pointRadius),
765
+ "circle-color": shapePaint(fs, "__fill", config.style.fill ?? "rgba(0,0,0,0.1)"),
766
+ "circle-stroke-color": shapePaint(fs, "__stroke", config.style.stroke ?? "#000000"),
767
+ "circle-stroke-width": shapePaint(fs, "__strokeWidth", config.style.strokeWidth ?? this.shapeStyleDefaults.point.strokeWidth),
768
+ "circle-opacity": shapePaint(fs, "__fillOpacity", config.style.fillOpacity ?? this.shapeStyleDefaults.point.fillOpacity),
769
+ },
770
+ });
771
+ layerIds.push(circleLayerId);
772
+ if (config.interactive !== "none") {
773
+ const filtered = filterLayerIdsByMode(config.interactive, layerIds, config.id);
774
+ this.attachShapeLayerInteractionListeners(currentMap, filtered, config.id);
775
+ }
776
+ this.shapeSources.set(config.id, { sourceId, fillSourceId, layerIds, config });
777
+ }
778
+ removeShapeSourceInternal(id) {
779
+ const tracked = this.shapeSources.get(id);
780
+ if (tracked === undefined)
781
+ return;
782
+ if (this.map !== null) {
783
+ // Remove interaction listeners
784
+ this.detachShapeLayerInteractionListeners(this.map, tracked.layerIds);
785
+ safeRemoveLayersAndSource(this.map, tracked.layerIds, tracked.sourceId);
786
+ // The dedicated fill source (ADR-0021) has no remaining layers after the
787
+ // fill layer above was removed, so it is safe to drop.
788
+ if (hasSource(this.map, tracked.fillSourceId)) {
789
+ this.map.removeSource(tracked.fillSourceId);
790
+ }
791
+ }
792
+ if (this.selectedShapeHandleId === id) {
793
+ this.selectedShapeHandleId = null;
794
+ this.selectedShapeFeatureId = null;
795
+ }
796
+ if (this.hoveredShapeHandleId === id) {
797
+ this.hoveredShapeHandleId = null;
798
+ this.hoveredShapeFeatureId = null;
799
+ }
800
+ this.shapeSources.delete(id);
801
+ }
802
+ // ---- Shape interaction ----
803
+ applyShapeSelectionState(handleId, featureId) {
804
+ if (this.selectedShapeHandleId === handleId && this.selectedShapeFeatureId === featureId)
805
+ return;
806
+ const prevHandleId = this.selectedShapeHandleId;
807
+ this.selectedShapeHandleId = handleId;
808
+ this.selectedShapeFeatureId = featureId;
809
+ if (this.map === null)
810
+ return;
811
+ if (prevHandleId !== null) {
812
+ this.applyShapeInteractionPaint(prevHandleId);
813
+ }
814
+ if (handleId !== null && handleId !== prevHandleId) {
815
+ this.applyShapeInteractionPaint(handleId);
816
+ }
817
+ }
818
+ applyShapeHoverState(handleId, featureId) {
819
+ if (this.hoveredShapeHandleId === handleId && this.hoveredShapeFeatureId === featureId)
820
+ return;
821
+ const prevHandleId = this.hoveredShapeHandleId;
822
+ this.hoveredShapeHandleId = handleId;
823
+ this.hoveredShapeFeatureId = featureId;
824
+ if (this.map === null)
825
+ return;
826
+ if (prevHandleId !== null) {
827
+ this.applyShapeInteractionPaint(prevHandleId);
828
+ }
829
+ if (handleId !== null && handleId !== prevHandleId) {
830
+ this.applyShapeInteractionPaint(handleId);
831
+ }
832
+ }
833
+ // ---- Route sources ----
834
+ syncRouteSource(config) {
835
+ if (this.map === null)
836
+ return;
837
+ // Skip full rebuild if config is unchanged (new object reference, same values)
838
+ const existing = this.routeSources.get(config.id);
839
+ if (existing !== undefined && esToolkit.isEqual(existing.config, config)) {
840
+ return;
841
+ }
842
+ // Remove existing route for this source (update case)
843
+ this.removeRouteSourceInternal(config.id);
844
+ const currentMap = this.map;
845
+ const sourceId = `route-source-${config.id}`;
846
+ const lineLayerId = `route-line-${config.id}`;
847
+ const layerIds = [];
848
+ // Add GeoJSON source (convert readonly types to mutable for Mapbox)
849
+ currentMap.addSource(sourceId, {
850
+ type: "geojson",
851
+ data: toMapboxGeoJson(config.features),
852
+ });
853
+ // Build dash array if needed
854
+ const { dashArray } = config.style;
855
+ const hasDash = dashArray !== undefined && dashArray.length > 0;
856
+ this.addLayerWithFadeIn(currentMap, {
857
+ id: lineLayerId,
858
+ type: "line",
859
+ source: sourceId,
860
+ filter: ["==", ["geometry-type"], "LineString"],
861
+ layout: {
862
+ "line-cap": hasDash ? "butt" : "round",
863
+ "line-join": "round",
864
+ },
865
+ paint: {
866
+ "line-color": config.style.color ?? "#000000",
867
+ "line-width": config.style.width ?? 3,
868
+ "line-opacity": config.style.opacity ?? 1,
869
+ ...(hasDash ? { "line-dasharray": [...dashArray] } : {}),
870
+ },
871
+ });
872
+ layerIds.push(lineLayerId);
873
+ if (config.style.showDirectionArrows === true) {
874
+ const arrowLayerId = `route-arrows-${config.id}`;
875
+ this.ensureArrowImage(currentMap);
876
+ this.addLayerWithFadeIn(currentMap, {
877
+ id: arrowLayerId,
878
+ type: "symbol",
879
+ source: sourceId,
880
+ filter: ["==", ["geometry-type"], "LineString"],
881
+ layout: {
882
+ "symbol-placement": "line",
883
+ "symbol-spacing": 100,
884
+ "icon-image": "route-arrow",
885
+ "icon-size": 0.6,
886
+ "icon-rotation-alignment": "map",
887
+ "icon-allow-overlap": true,
888
+ "icon-ignore-placement": true,
889
+ },
890
+ });
891
+ layerIds.push(arrowLayerId);
892
+ }
893
+ if (config.interactive) {
894
+ this.attachRouteLayerInteractionListeners(currentMap, layerIds, config.id);
895
+ }
896
+ this.routeSources.set(config.id, { sourceId, layerIds, config });
897
+ }
898
+ removeRouteSourceInternal(id) {
899
+ const tracked = this.routeSources.get(id);
900
+ if (tracked === undefined)
901
+ return;
902
+ if (this.map !== null) {
903
+ this.detachRouteLayerInteractionListeners(this.map, tracked.layerIds);
904
+ safeRemoveLayersAndSource(this.map, tracked.layerIds, tracked.sourceId);
905
+ }
906
+ this.routeSources.delete(id);
907
+ }
908
+ // ---- Image overlays ----
909
+ syncImageOverlay(config) {
910
+ if (this.map === null)
911
+ return;
912
+ // Skip full rebuild if config is unchanged (new object reference, same values)
913
+ const existing = this.overlaySources.get(config.id);
914
+ if (existing !== undefined && esToolkit.isEqual(existing.config, config)) {
915
+ return;
916
+ }
917
+ // Remove existing overlay for this source (update case)
918
+ this.removeImageOverlayInternal(config.id);
919
+ const currentMap = this.map;
920
+ const sourceId = `image-source-${config.id}`;
921
+ const layerId = `image-layer-${config.id}`;
922
+ currentMap.addSource(sourceId, {
923
+ type: "image",
924
+ url: config.url,
925
+ coordinates: bboxToImageCoordinates(config.imageBounds),
926
+ });
927
+ this.addLayerWithFadeIn(currentMap, {
928
+ id: layerId,
929
+ type: "raster",
930
+ source: sourceId,
931
+ paint: {
932
+ "raster-opacity": config.opacity,
933
+ "raster-fade-duration": 0,
934
+ },
935
+ });
936
+ this.overlaySources.set(config.id, { sourceId, layerId, config });
937
+ }
938
+ removeImageOverlayInternal(id) {
939
+ const tracked = this.overlaySources.get(id);
940
+ if (tracked === undefined)
941
+ return;
942
+ if (this.map !== null) {
943
+ safeRemoveLayersAndSource(this.map, [tracked.layerId], tracked.sourceId);
944
+ }
945
+ this.overlaySources.delete(id);
946
+ }
947
+ // ============================================================================
948
+ // Private: Layer fade-in
949
+ // ============================================================================
950
+ /**
951
+ * Wrapper around `map.addLayer` that starts the layer at opacity 0 and
952
+ * animates to the target opacity via Mapbox's native paint transition system.
953
+ * Layer types without a known opacity key (e.g. `symbol`) are added normally.
954
+ */
955
+ addLayerWithFadeIn(map, spec) {
956
+ const fade = { duration: reactMapAdapterShared.LAYER_FADE_DURATION_MS, delay: 0 };
957
+ switch (spec.type) {
958
+ case "fill": {
959
+ const target = spec.paint?.["fill-opacity"] ?? 1;
960
+ map.addLayer({ ...spec, paint: { ...spec.paint, "fill-opacity": 0, "fill-opacity-transition": fade } });
961
+ map.setPaintProperty(spec.id, "fill-opacity", target);
962
+ return;
963
+ }
964
+ case "line": {
965
+ const target = spec.paint?.["line-opacity"] ?? 1;
966
+ map.addLayer({ ...spec, paint: { ...spec.paint, "line-opacity": 0, "line-opacity-transition": fade } });
967
+ map.setPaintProperty(spec.id, "line-opacity", target);
968
+ return;
969
+ }
970
+ case "circle": {
971
+ const target = spec.paint?.["circle-opacity"] ?? 1;
972
+ const strokeTarget = spec.paint?.["circle-stroke-opacity"] ?? 1;
973
+ map.addLayer({
974
+ ...spec,
975
+ paint: {
976
+ ...spec.paint,
977
+ "circle-opacity": 0,
978
+ "circle-opacity-transition": fade,
979
+ "circle-stroke-opacity": 0,
980
+ "circle-stroke-opacity-transition": fade,
981
+ },
982
+ });
983
+ map.setPaintProperty(spec.id, "circle-opacity", target);
984
+ map.setPaintProperty(spec.id, "circle-stroke-opacity", strokeTarget);
985
+ return;
986
+ }
987
+ case "raster": {
988
+ const target = spec.paint?.["raster-opacity"] ?? 1;
989
+ map.addLayer({ ...spec, paint: { ...spec.paint, "raster-opacity": 0, "raster-opacity-transition": fade } });
990
+ map.setPaintProperty(spec.id, "raster-opacity", target);
991
+ return;
992
+ }
993
+ default:
994
+ map.addLayer(spec);
995
+ }
996
+ }
997
+ // ============================================================================
998
+ // Private: Pending source flushing
999
+ // ============================================================================
1000
+ /**
1001
+ * Process all queued source configurations that arrived before the style
1002
+ * was ready. Idempotent -- clears each queue after processing.
1003
+ */
1004
+ applySnapshot(snapshot) {
1005
+ const markerIds = new Set(snapshot.layers.filter(l => l.layerType === "markers").map(l => l.id));
1006
+ const shapeIds = new Set(snapshot.layers.filter(l => l.layerType === "shapes").map(l => l.id));
1007
+ const routeIds = new Set(snapshot.layers.filter(l => l.layerType === "route").map(l => l.id));
1008
+ const overlayIds = new Set(snapshot.layers.filter(l => l.layerType === "image-overlay").map(l => l.id));
1009
+ for (const layer of snapshot.layers) {
1010
+ switch (layer.layerType) {
1011
+ case "markers":
1012
+ this.syncMarkerSource(layer);
1013
+ break;
1014
+ case "shapes":
1015
+ this.syncShapeSource(layer);
1016
+ break;
1017
+ case "route":
1018
+ this.syncRouteSource(layer);
1019
+ break;
1020
+ case "image-overlay":
1021
+ this.syncImageOverlay(layer);
1022
+ break;
1023
+ default: {
1024
+ const exhaustiveCheck = layer;
1025
+ throw new Error(`Unknown layer type: ${String(exhaustiveCheck)}`);
1026
+ }
1027
+ }
1028
+ }
1029
+ for (const id of [...this.markerSources.keys()]) {
1030
+ if (!markerIds.has(id))
1031
+ this.removeMarkerSourceInternal(id);
1032
+ }
1033
+ for (const id of [...this.shapeSources.keys()]) {
1034
+ if (!shapeIds.has(id))
1035
+ this.removeShapeSourceInternal(id);
1036
+ }
1037
+ for (const id of [...this.routeSources.keys()]) {
1038
+ if (!routeIds.has(id))
1039
+ this.removeRouteSourceInternal(id);
1040
+ }
1041
+ for (const id of [...this.overlaySources.keys()]) {
1042
+ if (!overlayIds.has(id))
1043
+ this.removeImageOverlayInternal(id);
1044
+ }
1045
+ this.applyShapeSelectionState(snapshot.shapeSelection.handleId, snapshot.shapeSelection.featureId);
1046
+ this.applyShapeHoverState(snapshot.shapeHover.handleId, snapshot.shapeHover.featureId);
1047
+ }
1048
+ flushPending() {
1049
+ if (this.pendingSnapshot !== null) {
1050
+ this.applySnapshot(this.pendingSnapshot);
1051
+ this.pendingSnapshot = null;
1052
+ return;
1053
+ }
1054
+ // Revival path: useLayerHandleSync's cleanup pushed EMPTY before destroy (React 18 StrictMode
1055
+ // dev replay), and useWatch's persisted prev value won't re-fire on remount. Re-apply the
1056
+ // last non-empty snapshot so markers/shapes return after the adapter is connected to a fresh map.
1057
+ if (this.lastNonEmptySnapshot !== null) {
1058
+ this.applySnapshot(this.lastNonEmptySnapshot);
1059
+ }
1060
+ }
1061
+ // ============================================================================
1062
+ // Private: DOM portal management
1063
+ // ============================================================================
1064
+ /** Add a portal descriptor to the marker store and notify subscribers */
1065
+ addMarkerPortalDescriptor(descriptor) {
1066
+ this.markerPortalDescriptors = [...this.markerPortalDescriptors, descriptor];
1067
+ this.notifyMarkerPortalSubscribers();
1068
+ }
1069
+ /** Add a portal descriptor to the cluster store and notify subscribers */
1070
+ addClusterPortalDescriptor(descriptor) {
1071
+ this.clusterPortalDescriptors = [...this.clusterPortalDescriptors, descriptor];
1072
+ this.notifyClusterPortalSubscribers();
1073
+ }
1074
+ /** Remove all portal descriptors for `sourceId` from both stores and notify */
1075
+ removePortalDescriptorsForSource(sourceId) {
1076
+ const prefix = `${sourceId}:`;
1077
+ const filteredMarker = this.markerPortalDescriptors.filter(d => !d.key.startsWith(prefix));
1078
+ if (filteredMarker.length !== this.markerPortalDescriptors.length) {
1079
+ this.markerPortalDescriptors = filteredMarker;
1080
+ this.notifyMarkerPortalSubscribers();
1081
+ }
1082
+ const filteredCluster = this.clusterPortalDescriptors.filter(d => !d.key.startsWith(prefix));
1083
+ if (filteredCluster.length !== this.clusterPortalDescriptors.length) {
1084
+ this.clusterPortalDescriptors = filteredCluster;
1085
+ this.notifyClusterPortalSubscribers();
1086
+ }
1087
+ }
1088
+ notifyMarkerPortalSubscribers() {
1089
+ for (const callback of this.markerPortalSubscribers) {
1090
+ callback();
1091
+ }
1092
+ }
1093
+ notifyClusterPortalSubscribers() {
1094
+ for (const callback of this.clusterPortalSubscribers) {
1095
+ callback();
1096
+ }
1097
+ }
1098
+ /**
1099
+ * Patch existing DOM markers in place: update positions via `setLngLat` and
1100
+ * refresh portal descriptors so React re-renders with the latest render function.
1101
+ * Avoids the remove-then-add cycle that causes a brief flash at (0,0).
1102
+ */
1103
+ patchDomMarkers(existing, config) {
1104
+ const oldFeatures = existing.config.features.features;
1105
+ const newFeatures = config.features.features;
1106
+ for (let i = 0; i < Math.min(existing.markers.length, newFeatures.length); i++) {
1107
+ const oldFeature = oldFeatures[i];
1108
+ const newFeature = newFeatures[i];
1109
+ const marker = existing.markers[i];
1110
+ if (newFeature === undefined || marker === undefined)
1111
+ continue;
1112
+ const oldCoords = oldFeature !== undefined ? reactMapAdapterShared.extractPointCoordinates(oldFeature) : null;
1113
+ const newCoords = reactMapAdapterShared.extractPointCoordinates(newFeature);
1114
+ if (newCoords !== null &&
1115
+ (oldCoords === null || oldCoords.lng !== newCoords.lng || oldCoords.lat !== newCoords.lat)) {
1116
+ marker.setLngLat(newCoords);
1117
+ }
1118
+ }
1119
+ if (config.markerRender.mode === "dom") {
1120
+ const result = reactMapAdapterShared.patchPortalDescriptors(this.markerPortalDescriptors, config.id, newFeatures, config.markerRender.render);
1121
+ if (result.changed) {
1122
+ this.markerPortalDescriptors = result.descriptors;
1123
+ this.notifyMarkerPortalSubscribers();
1124
+ }
1125
+ }
1126
+ }
1127
+ /**
1128
+ * Incrementally update adaptive DOM markers and the WebGL circle source
1129
+ * when `adaptiveResolution` (or callbacks) changed but feature IDs are the same.
1130
+ *
1131
+ * Three phases:
1132
+ * 1. DOM → symbol: remove DOM marker + portal for features leaving DOM mode.
1133
+ * 2. Symbol → DOM: add new DOM marker + portal for features entering DOM mode.
1134
+ * 3. DOM → DOM: update portal renderFn (so the consumer's latest closure runs).
1135
+ * 4. Update WebGL circle source to reflect the new DOM/symbol split.
1136
+ */
1137
+ patchAdaptiveMarkers(existing, config) {
1138
+ if (this.map === null)
1139
+ return;
1140
+ const { markerRender } = config;
1141
+ if (markerRender.mode !== "adaptive" || config.kind !== "marker")
1142
+ return;
1143
+ const existingDomIds = reactMapAdapterShared.getAdaptiveDomFeatureIds(existing.config);
1144
+ const incomingDomIds = reactMapAdapterShared.getAdaptiveDomFeatureIds(config);
1145
+ const incomingResolution = config.adaptiveResolution;
1146
+ const adaptiveDomMarkers = this.adaptiveMarkerIndex.get(config.id) ?? new Map();
1147
+ // ---- Phase 1: DOM → symbol — remove DOM marker + portal ----
1148
+ for (const featureId of existingDomIds) {
1149
+ if (incomingDomIds.has(featureId))
1150
+ continue;
1151
+ const marker = adaptiveDomMarkers.get(featureId);
1152
+ if (marker !== undefined) {
1153
+ marker.remove();
1154
+ adaptiveDomMarkers.delete(featureId);
1155
+ existing.markers = existing.markers.filter(m => m !== marker);
1156
+ }
1157
+ const key = `${config.id}:${featureId}`;
1158
+ const filtered = this.markerPortalDescriptors.filter(d => d.key !== key);
1159
+ if (filtered.length !== this.markerPortalDescriptors.length) {
1160
+ this.markerPortalDescriptors = filtered;
1161
+ this.notifyMarkerPortalSubscribers();
1162
+ }
1163
+ }
1164
+ // ---- Phase 2: Symbol → DOM — add DOM marker + portal ----
1165
+ const phase2DomCreated = new Set();
1166
+ for (const feature of config.features.features) {
1167
+ if (feature.id === undefined)
1168
+ continue;
1169
+ const featureId = String(feature.id);
1170
+ if (!incomingDomIds.has(featureId) || existingDomIds.has(featureId))
1171
+ continue;
1172
+ if (featureId !== "" && phase2DomCreated.has(featureId))
1173
+ continue;
1174
+ const mode = incomingResolution?.modesByFeatureId.get(featureId) ?? "symbol";
1175
+ if (mode !== "dom")
1176
+ continue;
1177
+ const coords = reactMapAdapterShared.extractPointCoordinates(feature);
1178
+ if (coords === null)
1179
+ continue;
1180
+ const renderFn = reactMapAdapterShared.buildAdaptiveDomRenderFn(markerRender.render);
1181
+ const data = reactMapAdapterShared.extractSourceData(feature.properties);
1182
+ const domMarker = this.createDomMarker(featureId, coords, config.id, data, renderFn, this.map, "marker", markerRender.anchor, markerRender.pixelOffset);
1183
+ existing.markers.push(domMarker);
1184
+ adaptiveDomMarkers.set(featureId, domMarker);
1185
+ if (featureId !== "")
1186
+ phase2DomCreated.add(featureId);
1187
+ this.attachMarkerInteractionListeners(domMarker, featureId, coords);
1188
+ }
1189
+ // ---- Phase 3: DOM → DOM — update portal renderFn + position ----
1190
+ let portalsChanged = false;
1191
+ const updatedPortals = Array.from(this.markerPortalDescriptors);
1192
+ for (const feature of config.features.features) {
1193
+ if (feature.id === undefined)
1194
+ continue;
1195
+ const featureId = String(feature.id);
1196
+ if (!incomingDomIds.has(featureId) || !existingDomIds.has(featureId))
1197
+ continue;
1198
+ const mode = incomingResolution?.modesByFeatureId.get(featureId) ?? "symbol";
1199
+ if (mode !== "dom")
1200
+ continue;
1201
+ // Update position if coordinates changed
1202
+ const marker = adaptiveDomMarkers.get(featureId);
1203
+ if (marker !== undefined) {
1204
+ const coords = reactMapAdapterShared.extractPointCoordinates(feature);
1205
+ if (coords !== null) {
1206
+ const oldFeature = existing.config.features.features.find(f => f.id !== undefined && String(f.id) === featureId);
1207
+ const oldCoords = oldFeature !== undefined ? reactMapAdapterShared.extractPointCoordinates(oldFeature) : null;
1208
+ if (oldCoords === null || oldCoords.lng !== coords.lng || oldCoords.lat !== coords.lat) {
1209
+ marker.setLngLat(coords);
1210
+ }
1211
+ }
1212
+ }
1213
+ const renderFn = reactMapAdapterShared.buildAdaptiveDomRenderFn(markerRender.render);
1214
+ const key = `${config.id}:${featureId}`;
1215
+ const idx = updatedPortals.findIndex(d => d.key === key);
1216
+ if (idx !== -1) {
1217
+ const desc = updatedPortals[idx];
1218
+ if (desc !== undefined) {
1219
+ updatedPortals[idx] = { ...desc, renderFn, sourceData: reactMapAdapterShared.extractSourceData(feature.properties) };
1220
+ portalsChanged = true;
1221
+ }
1222
+ }
1223
+ }
1224
+ if (portalsChanged) {
1225
+ this.markerPortalDescriptors = updatedPortals;
1226
+ this.notifyMarkerPortalSubscribers();
1227
+ }
1228
+ this.adaptiveMarkerIndex.set(config.id, adaptiveDomMarkers);
1229
+ // ---- Phase 4: Update WebGL circle source ----
1230
+ this.updateAdaptiveCircleSource(config, existing);
1231
+ }
1232
+ /**
1233
+ * Incrementally sync adaptive markers and server-side clusters when a viewport
1234
+ * refetch changes which feature IDs are visible, without tearing down unchanged DOM markers.
1235
+ */
1236
+ patchAdaptiveViewportData(existing, config) {
1237
+ if (this.map === null)
1238
+ return;
1239
+ if (config.markerRender.mode !== "adaptive" || config.kind !== "marker")
1240
+ return;
1241
+ this.removeGoneAdaptiveAssetMarkers(existing, config);
1242
+ this.removeGoneServerClusterMarkers(existing, config);
1243
+ this.patchAdaptiveMarkers(existing, config);
1244
+ this.syncServerClusterMarkers(existing, config);
1245
+ this.updateAdaptiveCircleSource(config, existing);
1246
+ }
1247
+ /**
1248
+ * Remove adaptive DOM markers whose feature IDs are no longer in the incoming config.
1249
+ */
1250
+ removeGoneAdaptiveAssetMarkers(existing, config) {
1251
+ const incomingIds = reactMapAdapterShared.collectFeatureIdSet(config.features);
1252
+ const adaptiveDomMarkers = this.adaptiveMarkerIndex.get(config.id) ?? new Map();
1253
+ const result = reactMapAdapterShared.removeGoneIndexedMarkers({
1254
+ incomingFeatureIds: incomingIds,
1255
+ markerIndex: adaptiveDomMarkers,
1256
+ markers: existing.markers,
1257
+ portalDescriptors: this.markerPortalDescriptors,
1258
+ sourceId: config.id,
1259
+ detachMarker: marker => {
1260
+ marker.remove();
1261
+ this.clusterEntitiesByMarker.delete(marker);
1262
+ },
1263
+ });
1264
+ existing.markers = result.markers;
1265
+ if (result.portalDescriptorsChanged) {
1266
+ this.markerPortalDescriptors = result.portalDescriptors;
1267
+ this.notifyMarkerPortalSubscribers();
1268
+ }
1269
+ this.adaptiveMarkerIndex.set(config.id, result.markerIndex);
1270
+ }
1271
+ /**
1272
+ * Remove server-side cluster markers whose feature IDs are no longer in the incoming config.
1273
+ */
1274
+ removeGoneServerClusterMarkers(existing, config) {
1275
+ if (config.kind !== "marker")
1276
+ return;
1277
+ const incomingIds = reactMapAdapterShared.collectFeatureIdSet(config.clusterFeatures);
1278
+ const clusterIndex = this.clusterMarkerIndex.get(config.id) ?? new Map();
1279
+ const result = reactMapAdapterShared.removeGoneIndexedMarkers({
1280
+ incomingFeatureIds: incomingIds,
1281
+ markerIndex: clusterIndex,
1282
+ markers: existing.markers,
1283
+ portalDescriptors: this.clusterPortalDescriptors,
1284
+ sourceId: config.id,
1285
+ detachMarker: marker => {
1286
+ marker.remove();
1287
+ this.clusterEntitiesByMarker.delete(marker);
1288
+ },
1289
+ });
1290
+ existing.markers = result.markers;
1291
+ if (result.portalDescriptorsChanged) {
1292
+ this.clusterPortalDescriptors = result.portalDescriptors;
1293
+ this.notifyClusterPortalSubscribers();
1294
+ }
1295
+ this.clusterMarkerIndex.set(config.id, result.markerIndex);
1296
+ }
1297
+ /**
1298
+ * Add new server-side cluster markers for feature IDs not yet tracked,
1299
+ * and update existing ones with fresh portal data.
1300
+ */
1301
+ syncServerClusterMarkers(existing, config) {
1302
+ if (this.map === null || config.kind !== "marker" || config.clusterFeatures === null)
1303
+ return;
1304
+ const currentMap = this.map;
1305
+ const clusterIndex = this.clusterMarkerIndex.get(config.id) ?? new Map();
1306
+ for (const feature of config.clusterFeatures.features) {
1307
+ const coords = reactMapAdapterShared.extractPointCoordinates(feature);
1308
+ if (coords === null)
1309
+ continue;
1310
+ const featureId = feature.id !== undefined ? String(feature.id) : undefined;
1311
+ if (featureId === undefined)
1312
+ continue;
1313
+ const props = feature.properties;
1314
+ const rawMarkerIds = props !== null ? props.markerIds : undefined;
1315
+ const markerIds = Array.isArray(rawMarkerIds) ? rawMarkerIds : [];
1316
+ const clusterBbox = props !== null && props.bbox !== null ? geoJsonUtils.validateBbox(props.bbox) : null;
1317
+ const existingMarker = clusterIndex.get(featureId);
1318
+ if (existingMarker !== undefined) {
1319
+ // Update position if moved
1320
+ const currentPos = existingMarker.getLngLat();
1321
+ if (currentPos.lng !== coords.lng || currentPos.lat !== coords.lat) {
1322
+ existingMarker.setLngLat(coords);
1323
+ }
1324
+ this.updateClusterMarkerEntity(existingMarker, featureId, coords, markerIds, clusterBbox);
1325
+ // Update portal descriptor if this cluster uses DOM render
1326
+ if (config.clusterRender !== null && config.clusterRender.mode === "dom") {
1327
+ const key = `${config.id}:${featureId}`;
1328
+ const idx = this.clusterPortalDescriptors.findIndex(d => d.key === key);
1329
+ if (idx !== -1) {
1330
+ const desc = this.clusterPortalDescriptors[idx];
1331
+ if (desc !== undefined) {
1332
+ const updated = [...this.clusterPortalDescriptors];
1333
+ updated[idx] = {
1334
+ ...desc,
1335
+ renderFn: config.clusterRender.render,
1336
+ sourceData: reactMapAdapterShared.extractSourceData(feature.properties),
1337
+ };
1338
+ this.clusterPortalDescriptors = updated;
1339
+ this.notifyClusterPortalSubscribers();
1340
+ }
1341
+ }
1342
+ }
1343
+ continue;
1344
+ }
1345
+ // New cluster — create and register
1346
+ const marker = this.createClusterMarkerForFeature(feature, coords, config, currentMap);
1347
+ existing.markers.push(marker);
1348
+ clusterIndex.set(featureId, marker);
1349
+ this.attachClusterInteractionListeners(marker, featureId, coords, markerIds, clusterBbox);
1350
+ }
1351
+ this.clusterMarkerIndex.set(config.id, clusterIndex);
1352
+ }
1353
+ /**
1354
+ * Update the Mapbox GeoJSON circle source for an adaptive marker layer,
1355
+ * excluding features that are currently rendered as DOM markers.
1356
+ */
1357
+ updateAdaptiveCircleSource(config, tracked) {
1358
+ if (this.map === null || tracked.sourceId === undefined)
1359
+ return;
1360
+ const { markerRender } = config;
1361
+ if (markerRender.mode !== "adaptive")
1362
+ return;
1363
+ const source = this.map.getSource(tracked.sourceId);
1364
+ if (source === undefined || !("setData" in source))
1365
+ return;
1366
+ const promotedIds = reactMapAdapterShared.getAdaptiveDomFeatureIds(config);
1367
+ const symbolState = {
1368
+ medium: "symbol",
1369
+ selected: false,
1370
+ hovered: false,
1371
+ theme: this.theme,
1372
+ };
1373
+ const styleFn = reactMapAdapterShared.buildAdaptiveSymbolStyleFn(markerRender.render, symbolState);
1374
+ const enrichedFeatures = [];
1375
+ for (const feature of config.features.features) {
1376
+ const coords = reactMapAdapterShared.extractPointCoordinates(feature);
1377
+ if (coords === null)
1378
+ continue;
1379
+ const featureId = feature.id !== undefined ? String(feature.id) : undefined;
1380
+ if (featureId !== undefined && promotedIds.has(featureId))
1381
+ continue;
1382
+ const resolved = reactMapAdapterShared.resolveCircleSymbolDefaults(reactMapAdapterShared.resolveSymbolDescriptor(styleFn, feature.properties));
1383
+ enrichedFeatures.push({
1384
+ type: "Feature",
1385
+ id: feature.id !== undefined ? Number(feature.id) || undefined : undefined,
1386
+ geometry: { type: "Point", coordinates: [coords.lng, coords.lat] },
1387
+ properties: {
1388
+ __featureId: featureId ?? "",
1389
+ __color: resolved.color,
1390
+ __radius: resolved.radiusPx,
1391
+ __opacity: resolved.opacity,
1392
+ __strokeColor: resolved.borderColor ?? "#000000",
1393
+ __strokeWidth: resolved.hasBorder ? resolved.borderWidthPx : 0,
1394
+ },
1395
+ });
1396
+ }
1397
+ source.setData({ type: "FeatureCollection", features: enrichedFeatures });
1398
+ }
1399
+ // ============================================================================
1400
+ // Private: Marker creation
1401
+ // ============================================================================
1402
+ createMarkerForFeature(feature, coords, config, map) {
1403
+ const { markerRender } = config;
1404
+ const featureId = feature.id !== undefined ? String(feature.id) : "";
1405
+ switch (markerRender.mode) {
1406
+ case "symbol": {
1407
+ const symbolState = {
1408
+ medium: "symbol",
1409
+ selected: false,
1410
+ hovered: false,
1411
+ theme: this.theme,
1412
+ };
1413
+ const resolved = reactMapAdapterShared.resolveCircleSymbolDefaults(reactMapAdapterShared.resolveSymbolDescriptor(item => markerRender.render(item, symbolState), feature.properties));
1414
+ const el = reactMapAdapterShared.createSymbolDotElement(resolved);
1415
+ return new mapboxgl.Marker({ element: el }).setLngLat(coords).addTo(map);
1416
+ }
1417
+ case "dom": {
1418
+ return this.createDomMarker(featureId, coords, config.id, reactMapAdapterShared.extractSourceData(feature.properties), markerRender.render, map, "marker", markerRender.anchor, markerRender.pixelOffset);
1419
+ }
1420
+ case "adaptive": {
1421
+ const symbolState = {
1422
+ medium: "symbol",
1423
+ selected: false,
1424
+ hovered: false,
1425
+ theme: this.theme,
1426
+ };
1427
+ const styleFn = reactMapAdapterShared.buildAdaptiveSymbolStyleFn(markerRender.render, symbolState);
1428
+ const resolved = reactMapAdapterShared.resolveCircleSymbolDefaults(reactMapAdapterShared.resolveSymbolDescriptor(styleFn, feature.properties));
1429
+ const el = reactMapAdapterShared.createSymbolDotElement(resolved);
1430
+ return new mapboxgl.Marker({ element: el }).setLngLat(coords).addTo(map);
1431
+ }
1432
+ default: {
1433
+ throw new Error(`${markerRender} is not known`);
1434
+ }
1435
+ }
1436
+ }
1437
+ createClusterMarkerForFeature(feature, coords, config, map) {
1438
+ const { clusterRender } = config;
1439
+ const featureId = feature.id !== undefined ? String(feature.id) : "";
1440
+ const clusterCount = feature.properties !== null ? feature.properties.count : undefined;
1441
+ if (clusterRender === null) {
1442
+ const el = reactMapAdapterShared.createDefaultClusterElement(clusterCount);
1443
+ return new mapboxgl.Marker({ element: el }).setLngLat(coords).addTo(map);
1444
+ }
1445
+ switch (clusterRender.mode) {
1446
+ case "symbol": {
1447
+ const clusterState = {
1448
+ selected: false,
1449
+ hovered: false,
1450
+ theme: this.theme,
1451
+ memberItems: null,
1452
+ };
1453
+ const descriptor = reactMapAdapterShared.resolveSymbolDescriptor(item => clusterRender.render(item, clusterState), feature.properties);
1454
+ const el = reactMapAdapterShared.createClusterPinElement(descriptor, clusterCount);
1455
+ return new mapboxgl.Marker({ element: el }).setLngLat(coords).addTo(map);
1456
+ }
1457
+ case "dom": {
1458
+ return this.createDomMarker(featureId, coords, config.id, reactMapAdapterShared.extractSourceData(feature.properties), clusterRender.render, map, "cluster", clusterRender.anchor, clusterRender.pixelOffset);
1459
+ }
1460
+ default: {
1461
+ throw new Error(`${clusterRender} is not known`);
1462
+ }
1463
+ }
1464
+ }
1465
+ /**
1466
+ * Create a mapboxgl.Marker with an empty div as content.
1467
+ * Registers a portal descriptor in the target store (`marker` or `cluster`)
1468
+ * so `<Layers>` renders React content into the container via
1469
+ * `createPortal()` and routes to the matching render config.
1470
+ */
1471
+ createDomMarker(featureId, coords, sourceId, sourceData, renderFn, map, target, anchor, pixelOffset) {
1472
+ const container = document.createElement("div");
1473
+ container.style.pointerEvents = "auto";
1474
+ reactMapAdapterShared.fadeInElement(container);
1475
+ const marker = new mapboxgl.Marker({
1476
+ element: container,
1477
+ anchor,
1478
+ ...(pixelOffset !== undefined ? { offset: [pixelOffset.x, pixelOffset.y] } : {}),
1479
+ })
1480
+ .setLngLat(coords)
1481
+ .addTo(map);
1482
+ const descriptor = {
1483
+ key: `${sourceId}:${featureId}`,
1484
+ sourceId,
1485
+ container,
1486
+ featureId,
1487
+ sourceData,
1488
+ renderFn,
1489
+ };
1490
+ if (target === "marker") {
1491
+ this.addMarkerPortalDescriptor(descriptor);
1492
+ }
1493
+ else {
1494
+ this.addClusterPortalDescriptor(descriptor);
1495
+ }
1496
+ return marker;
1497
+ }
1498
+ /**
1499
+ * Adaptive mode: DOM overlays for features whose resolved mode is not `"symbol"`.
1500
+ */
1501
+ createAdaptiveDomOverlays(config, map, tracked) {
1502
+ const adaptiveDomMarkers = new Map();
1503
+ const { markerRender } = config;
1504
+ if (markerRender.mode !== "adaptive")
1505
+ return;
1506
+ const adaptiveAnchor = markerRender.anchor;
1507
+ const adaptivePixelOffset = markerRender.pixelOffset;
1508
+ for (const { featureId, coords, domMarker } of reactMapAdapterShared.buildAdaptiveDomEntries(config, (fId, c, sourceId, data, renderFn) => this.createDomMarker(fId, c, sourceId, data, renderFn, map, "marker", adaptiveAnchor, adaptivePixelOffset))) {
1509
+ tracked.markers.push(domMarker);
1510
+ adaptiveDomMarkers.set(featureId, domMarker);
1511
+ if (featureId !== "") {
1512
+ this.attachMarkerInteractionListeners(domMarker, featureId, coords);
1513
+ }
1514
+ }
1515
+ this.adaptiveMarkerIndex.set(config.id, adaptiveDomMarkers);
1516
+ }
1517
+ // ============================================================================
1518
+ // Private: Client-side clustering
1519
+ // ============================================================================
1520
+ /**
1521
+ * Set up client-side clustering using supercluster.
1522
+ * Creates mapboxgl.Marker instances and manages their visibility
1523
+ * based on zoom level and viewport.
1524
+ */
1525
+ setupClientClustering(config, map, tracked) {
1526
+ const { clusterConfig } = config;
1527
+ if (clusterConfig === null || clusterConfig.mode !== "client")
1528
+ return;
1529
+ // Build supercluster index from point features
1530
+ const points = [];
1531
+ for (const feature of config.features.features) {
1532
+ const coords = reactMapAdapterShared.extractPointCoordinates(feature);
1533
+ if (coords === null)
1534
+ continue;
1535
+ const featureId = feature.id !== undefined ? String(feature.id) : "";
1536
+ points.push({
1537
+ type: "Feature",
1538
+ geometry: { type: "Point", coordinates: [coords.lng, coords.lat] },
1539
+ properties: { featureId, originalFeature: feature },
1540
+ });
1541
+ }
1542
+ const index = new Supercluster({
1543
+ radius: clusterConfig.radius ?? 50,
1544
+ maxZoom: clusterConfig.maxZoom ?? 14,
1545
+ });
1546
+ index.load(points);
1547
+ tracked.supercluster = index;
1548
+ tracked.clusterMarkers = [];
1549
+ // Render clusters for the current viewport
1550
+ const updateClusters = () => {
1551
+ this.renderClusters(config, map, tracked);
1552
+ };
1553
+ // Listen for viewport changes
1554
+ tracked.viewportListener = updateClusters;
1555
+ map.on("moveend", updateClusters);
1556
+ // Initial render
1557
+ updateClusters();
1558
+ }
1559
+ /**
1560
+ * Re-render cluster markers based on the current viewport.
1561
+ * Called on initial setup and whenever the viewport changes.
1562
+ */
1563
+ renderClusters(config, map, tracked) {
1564
+ const { supercluster, clusterMarkers } = tracked;
1565
+ if (supercluster === undefined || clusterMarkers === undefined)
1566
+ return;
1567
+ // Remove existing cluster and individual markers
1568
+ for (const marker of clusterMarkers) {
1569
+ marker.remove();
1570
+ }
1571
+ for (const marker of tracked.markers) {
1572
+ marker.remove();
1573
+ }
1574
+ tracked.clusterMarkers = [];
1575
+ tracked.markers = [];
1576
+ // Remove old portal descriptors for this source
1577
+ this.removePortalDescriptorsForSource(config.id);
1578
+ const zoom = Math.floor(map.getZoom());
1579
+ const bounds = map.getBounds();
1580
+ if (!bounds)
1581
+ return;
1582
+ const bbox = [
1583
+ bounds.getWest(),
1584
+ bounds.getSouth(),
1585
+ bounds.getEast(),
1586
+ bounds.getNorth(),
1587
+ ];
1588
+ const clusters = supercluster.getClusters(bbox, zoom);
1589
+ for (const cluster of clusters) {
1590
+ const clusterCoords = cluster.geometry.coordinates;
1591
+ const lng = clusterCoords[0];
1592
+ const lat = clusterCoords[1];
1593
+ if (lng === undefined || lat === undefined)
1594
+ continue;
1595
+ const coords = { lng, lat };
1596
+ const props = cluster.properties;
1597
+ const isCluster = "cluster" in props && "cluster_id" in props && "point_count" in props;
1598
+ if (isCluster) {
1599
+ // This is a cluster point -- narrow via property presence checks
1600
+ const numericClusterId = typeof props.cluster_id === "number" ? props.cluster_id : 0;
1601
+ const numericCount = typeof props.point_count === "number" ? props.point_count : 0;
1602
+ const leaves = supercluster.getLeaves(numericClusterId, Infinity);
1603
+ const markerIds = leaves.map(leaf => leaf.properties.featureId);
1604
+ const clusterMarker = this.createClusterMarkerForViewport(config, coords, numericCount, markerIds, String(numericClusterId), map);
1605
+ tracked.clusterMarkers.push(clusterMarker);
1606
+ // Cluster interaction
1607
+ const entity = {
1608
+ type: "cluster",
1609
+ id: `cluster-${lat.toFixed(6)}-${lng.toFixed(6)}`,
1610
+ position: [lng, lat],
1611
+ markerIds,
1612
+ bbox: null,
1613
+ };
1614
+ const clusterEl = clusterMarker.getElement();
1615
+ clusterEl.addEventListener("click", (e) => {
1616
+ // Mapbox fires a click for each of the two rapid clicks that precede a dblclick
1617
+ // (detail=1, then detail=2). Guard on detail >= 2 to suppress the spurious second
1618
+ // click so consumers don't see a toggle before the dblclick fires.
1619
+ // Google Maps adapter does not need this guard — it uses a separate DOM-level
1620
+ // dblclick listener on the map container and suppresses clicks via pointerdown,
1621
+ // so the double-click sequence never produces a stray click event.
1622
+ if (e.detail >= 2)
1623
+ return;
1624
+ this.emitEntityInteraction("click", entity);
1625
+ });
1626
+ clusterEl.addEventListener("dblclick", (e) => {
1627
+ e.stopPropagation();
1628
+ this.suppressNativeZoom();
1629
+ this.emitEntityInteraction("dblclick", entity);
1630
+ });
1631
+ clusterEl.addEventListener("mouseenter", () => {
1632
+ this.emitEntityInteraction("hover-start", entity);
1633
+ });
1634
+ clusterEl.addEventListener("mouseleave", () => {
1635
+ this.emitEntityInteraction("hover-end", entity);
1636
+ });
1637
+ }
1638
+ else {
1639
+ // This is an individual point
1640
+ const { originalFeature, featureId } = cluster.properties;
1641
+ const marker = this.createMarkerForFeature(originalFeature, coords, config, map);
1642
+ tracked.markers.push(marker);
1643
+ if (featureId !== "") {
1644
+ this.attachMarkerInteractionListeners(marker, featureId, coords);
1645
+ }
1646
+ }
1647
+ }
1648
+ }
1649
+ /**
1650
+ * Create a cluster marker for the current viewport (client-side clustering).
1651
+ */
1652
+ createClusterMarkerForViewport(config, coords, count, markerIds, clusterId, map) {
1653
+ const { clusterRender } = config;
1654
+ if (clusterRender !== null && clusterRender.mode === "symbol") {
1655
+ const clusterState = {
1656
+ selected: false,
1657
+ hovered: false,
1658
+ theme: this.theme,
1659
+ memberItems: null,
1660
+ };
1661
+ const style = clusterRender.render({ id: clusterId, position: [coords.lng, coords.lat], markerIds, count, bbox: null }, clusterState);
1662
+ const symbolEl = reactMapAdapterShared.createClusterPinElement(style, count);
1663
+ return new mapboxgl.Marker({ element: symbolEl }).setLngLat(coords).addTo(map);
1664
+ }
1665
+ if (clusterRender !== null) {
1666
+ // mode is "dom" by elimination (symbol handled above; ClusterRenderConfig
1667
+ // has no other variants per ADR-0016).
1668
+ const container = document.createElement("div");
1669
+ container.style.pointerEvents = "auto";
1670
+ const portalClusterId = `cluster-${coords.lat.toFixed(6)}-${coords.lng.toFixed(6)}`;
1671
+ this.addClusterPortalDescriptor({
1672
+ key: `${config.id}:${portalClusterId}`,
1673
+ sourceId: config.id,
1674
+ container,
1675
+ featureId: portalClusterId,
1676
+ sourceData: { id: portalClusterId, position: [coords.lng, coords.lat], markerIds, count, bbox: null },
1677
+ renderFn: clusterRender.render,
1678
+ });
1679
+ return new mapboxgl.Marker({ element: container }).setLngLat(coords).addTo(map);
1680
+ }
1681
+ const defaultEl = reactMapAdapterShared.createDefaultClusterElement(count);
1682
+ return new mapboxgl.Marker({ element: defaultEl }).setLngLat(coords).addTo(map);
1683
+ }
1684
+ // ============================================================================
1685
+ // Private: Direction arrow image
1686
+ // ============================================================================
1687
+ /**
1688
+ * Ensure the arrow icon image is registered on the map for route direction arrows.
1689
+ * Uses a simple triangle rendered via canvas and converted to ImageData.
1690
+ */
1691
+ ensureArrowImage(map) {
1692
+ if (this.arrowImageAdded)
1693
+ return;
1694
+ if (map.hasImage("route-arrow")) {
1695
+ this.arrowImageAdded = true;
1696
+ return;
1697
+ }
1698
+ // Create a simple arrow icon using canvas, then extract ImageData
1699
+ const size = 16;
1700
+ const canvas = document.createElement("canvas");
1701
+ canvas.width = size;
1702
+ canvas.height = size;
1703
+ const ctx = canvas.getContext("2d");
1704
+ if (ctx !== null) {
1705
+ ctx.fillStyle = "#000000";
1706
+ ctx.beginPath();
1707
+ ctx.moveTo(size / 2, 0);
1708
+ ctx.lineTo(size, size);
1709
+ ctx.lineTo(size / 2, size * 0.7);
1710
+ ctx.lineTo(0, size);
1711
+ ctx.closePath();
1712
+ ctx.fill();
1713
+ const imageData = ctx.getImageData(0, 0, size, size);
1714
+ map.addImage("route-arrow", imageData, { sdf: true });
1715
+ }
1716
+ this.arrowImageAdded = true;
1717
+ }
1718
+ // ============================================================================
1719
+ // Private: Interaction listeners
1720
+ // ============================================================================
1721
+ attachMarkerInteractionListeners(marker, featureId, coords) {
1722
+ const entity = {
1723
+ type: "marker",
1724
+ id: featureId,
1725
+ position: [coords.lng, coords.lat],
1726
+ clusterId: null,
1727
+ };
1728
+ const el = marker.getElement();
1729
+ el.addEventListener("click", (e) => {
1730
+ e.stopPropagation();
1731
+ // The browser fires click twice before dblclick (detail=1, then detail=2).
1732
+ // Suppress the second click so consumers don't see a spurious toggle.
1733
+ if (e.detail >= 2)
1734
+ return;
1735
+ this.emitEntityInteraction("click", entity);
1736
+ });
1737
+ el.addEventListener("dblclick", (e) => {
1738
+ e.stopPropagation();
1739
+ this.suppressNativeZoom();
1740
+ this.emitEntityInteraction("dblclick", entity);
1741
+ });
1742
+ // Hover — spurious-mouseleave-safe implementation via attachSafeAreaHoverListeners.
1743
+ // See safeArea/ for the full contract.
1744
+ reactMapAdapterShared.attachSafeAreaHoverListeners(el, () => this.emitEntityInteraction("hover-start", entity), () => this.emitEntityInteraction("hover-end", entity), this.markerHoverState);
1745
+ }
1746
+ attachClusterInteractionListeners(marker, featureId, coords, markerIds, clusterBbox) {
1747
+ this.updateClusterMarkerEntity(marker, featureId, coords, markerIds, clusterBbox);
1748
+ const emitClusterInteraction = (type) => {
1749
+ const entity = this.clusterEntitiesByMarker.get(marker);
1750
+ if (entity !== undefined) {
1751
+ this.emitEntityInteraction(type, entity);
1752
+ }
1753
+ };
1754
+ const el = marker.getElement();
1755
+ el.addEventListener("click", (e) => {
1756
+ e.stopPropagation();
1757
+ if (e.detail >= 2)
1758
+ return;
1759
+ emitClusterInteraction("click");
1760
+ });
1761
+ el.addEventListener("dblclick", (e) => {
1762
+ e.stopPropagation();
1763
+ this.suppressNativeZoom();
1764
+ emitClusterInteraction("dblclick");
1765
+ });
1766
+ el.addEventListener("mouseenter", () => {
1767
+ emitClusterInteraction("hover-start");
1768
+ });
1769
+ el.addEventListener("mouseleave", () => {
1770
+ emitClusterInteraction("hover-end");
1771
+ });
1772
+ }
1773
+ updateClusterMarkerEntity(marker, featureId, coords, markerIds, clusterBbox) {
1774
+ const entity = {
1775
+ type: "cluster",
1776
+ id: featureId,
1777
+ position: [coords.lng, coords.lat],
1778
+ markerIds,
1779
+ bbox: clusterBbox,
1780
+ };
1781
+ this.clusterEntitiesByMarker.set(marker, entity);
1782
+ }
1783
+ // ---- Marker circle layer interaction ----
1784
+ attachMarkerCircleLayerListeners(map, circleLayerId) {
1785
+ const clickHandler = (e) => {
1786
+ // Mapbox fires a click for each of the two rapid clicks that precede a dblclick.
1787
+ // Guard on detail >= 2 to suppress the spurious second click so consumers don't
1788
+ // see a toggle before the dblclick fires.
1789
+ if (e.originalEvent.detail >= 2)
1790
+ return;
1791
+ const feature = e.features?.[0];
1792
+ if (feature === undefined)
1793
+ return;
1794
+ const featureId = typeof feature.properties?.__featureId === "string" ? feature.properties.__featureId : undefined;
1795
+ if (featureId === undefined || featureId === "")
1796
+ return;
1797
+ const coords = e.lngLat;
1798
+ const entity = {
1799
+ type: "marker",
1800
+ id: featureId,
1801
+ position: [coords.lng, coords.lat],
1802
+ clusterId: null,
1803
+ };
1804
+ this.emitEntityInteraction("click", entity);
1805
+ };
1806
+ const dblClickHandler = (e) => {
1807
+ this.suppressNativeZoom();
1808
+ const feature = e.features?.[0];
1809
+ if (feature === undefined)
1810
+ return;
1811
+ const featureId = typeof feature.properties?.__featureId === "string" ? feature.properties.__featureId : undefined;
1812
+ if (featureId === undefined || featureId === "")
1813
+ return;
1814
+ const coords = e.lngLat;
1815
+ const entity = {
1816
+ type: "marker",
1817
+ id: featureId,
1818
+ position: [coords.lng, coords.lat],
1819
+ clusterId: null,
1820
+ };
1821
+ this.emitEntityInteraction("dblclick", entity);
1822
+ };
1823
+ const mouseMoveHandler = (e) => {
1824
+ const feature = e.features?.[0];
1825
+ if (feature === undefined)
1826
+ return;
1827
+ const featureId = typeof feature.properties?.__featureId === "string" ? feature.properties.__featureId : undefined;
1828
+ if (featureId === undefined || featureId === "")
1829
+ return;
1830
+ map.getCanvas().style.cursor = reactMapAdapterShared.MAP_CURSORS.interactive;
1831
+ map.getCanvasContainer().style.cursor = reactMapAdapterShared.MAP_CURSORS.interactive;
1832
+ if (this.lastHoveredMarkerFeatureId === featureId)
1833
+ return;
1834
+ // End hover on previous feature
1835
+ if (this.lastHoveredMarkerFeatureId !== null) {
1836
+ this.emitEntityInteraction("hover-end", {
1837
+ type: "marker",
1838
+ id: this.lastHoveredMarkerFeatureId,
1839
+ position: [e.lngLat.lng, e.lngLat.lat],
1840
+ clusterId: null,
1841
+ });
1842
+ }
1843
+ this.lastHoveredMarkerFeatureId = featureId;
1844
+ const coords = e.lngLat;
1845
+ this.emitEntityInteraction("hover-start", {
1846
+ type: "marker",
1847
+ id: featureId,
1848
+ position: [coords.lng, coords.lat],
1849
+ clusterId: null,
1850
+ });
1851
+ };
1852
+ const mouseLeaveHandler = () => {
1853
+ map.getCanvas().style.cursor = reactMapAdapterShared.MAP_CURSORS.default;
1854
+ map.getCanvasContainer().style.cursor = reactMapAdapterShared.MAP_CURSORS.default;
1855
+ if (this.lastHoveredMarkerFeatureId !== null) {
1856
+ this.emitEntityInteraction("hover-end", {
1857
+ type: "marker",
1858
+ id: this.lastHoveredMarkerFeatureId,
1859
+ position: [0, 0],
1860
+ clusterId: null,
1861
+ });
1862
+ this.lastHoveredMarkerFeatureId = null;
1863
+ }
1864
+ };
1865
+ map.on("click", circleLayerId, clickHandler);
1866
+ map.on("dblclick", circleLayerId, dblClickHandler);
1867
+ map.on("mousemove", circleLayerId, mouseMoveHandler);
1868
+ map.on("mouseleave", circleLayerId, mouseLeaveHandler);
1869
+ this.markerClickHandlers.set(circleLayerId, clickHandler);
1870
+ this.markerDblClickHandlers.set(circleLayerId, dblClickHandler);
1871
+ this.markerMouseMoveHandlers.set(circleLayerId, mouseMoveHandler);
1872
+ this.markerMouseLeaveHandlers.set(circleLayerId, mouseLeaveHandler);
1873
+ }
1874
+ detachMarkerCircleLayerListeners(map, layerIds) {
1875
+ for (const layerId of layerIds) {
1876
+ const clickHandler = this.markerClickHandlers.get(layerId);
1877
+ if (clickHandler !== undefined) {
1878
+ map.off("click", layerId, clickHandler);
1879
+ this.markerClickHandlers.delete(layerId);
1880
+ }
1881
+ const dblClickHandler = this.markerDblClickHandlers.get(layerId);
1882
+ if (dblClickHandler !== undefined) {
1883
+ map.off("dblclick", layerId, dblClickHandler);
1884
+ this.markerDblClickHandlers.delete(layerId);
1885
+ }
1886
+ const mouseMoveHandler = this.markerMouseMoveHandlers.get(layerId);
1887
+ if (mouseMoveHandler !== undefined) {
1888
+ map.off("mousemove", layerId, mouseMoveHandler);
1889
+ this.markerMouseMoveHandlers.delete(layerId);
1890
+ }
1891
+ const mouseLeaveHandler = this.markerMouseLeaveHandlers.get(layerId);
1892
+ if (mouseLeaveHandler !== undefined) {
1893
+ map.off("mouseleave", layerId, mouseLeaveHandler);
1894
+ this.markerMouseLeaveHandlers.delete(layerId);
1895
+ }
1896
+ }
1897
+ }
1898
+ // ---- Shape layer interaction ----
1899
+ attachShapeLayerInteractionListeners(map, layerIds, handleId) {
1900
+ for (const layerId of layerIds) {
1901
+ const isFillLayer = layerId === `shape-fill-${handleId}`;
1902
+ const clickHandler = (e) => {
1903
+ const feature = e.features?.[0];
1904
+ if (feature === undefined)
1905
+ return;
1906
+ const featureId = typeof feature.properties?.__featureId === "string" ? feature.properties.__featureId : undefined;
1907
+ if (featureId === undefined || featureId === "")
1908
+ return;
1909
+ if (feature.geometry.type === "GeometryCollection")
1910
+ return;
1911
+ if (isFillLayer && feature.properties?.__fillOpacity === 0)
1912
+ return;
1913
+ const entity = {
1914
+ type: "shape",
1915
+ id: featureId,
1916
+ shapeType: reactMapAdapterShared.geometryTypeToShapeType(feature.geometry.type),
1917
+ handleId,
1918
+ };
1919
+ this.emitEntityInteraction("click", entity);
1920
+ };
1921
+ const dblClickHandler = (e) => {
1922
+ this.suppressNativeZoom();
1923
+ const feature = e.features?.[0];
1924
+ if (feature === undefined)
1925
+ return;
1926
+ const featureId = typeof feature.properties?.__featureId === "string" ? feature.properties.__featureId : undefined;
1927
+ if (featureId === undefined || featureId === "")
1928
+ return;
1929
+ if (feature.geometry.type === "GeometryCollection")
1930
+ return;
1931
+ if (isFillLayer && feature.properties?.__fillOpacity === 0)
1932
+ return;
1933
+ const entity = {
1934
+ type: "shape",
1935
+ id: featureId,
1936
+ shapeType: reactMapAdapterShared.geometryTypeToShapeType(feature.geometry.type),
1937
+ handleId,
1938
+ };
1939
+ this.emitEntityInteraction("dblclick", entity);
1940
+ };
1941
+ const mouseEnterHandler = (e) => {
1942
+ const feature = e.features?.[0];
1943
+ if (feature === undefined)
1944
+ return;
1945
+ const featureId = typeof feature.properties?.__featureId === "string" ? feature.properties.__featureId : undefined;
1946
+ if (featureId === undefined || featureId === "")
1947
+ return;
1948
+ if (feature.geometry.type === "GeometryCollection")
1949
+ return;
1950
+ if (isFillLayer && feature.properties?.__fillOpacity === 0)
1951
+ return;
1952
+ const entity = {
1953
+ type: "shape",
1954
+ id: featureId,
1955
+ shapeType: reactMapAdapterShared.geometryTypeToShapeType(feature.geometry.type),
1956
+ handleId,
1957
+ };
1958
+ this.emitEntityInteraction("hover-start", entity);
1959
+ map.getCanvas().style.cursor = reactMapAdapterShared.MAP_CURSORS.interactive;
1960
+ map.getCanvasContainer().style.cursor = reactMapAdapterShared.MAP_CURSORS.interactive;
1961
+ };
1962
+ const mouseLeaveHandler = () => {
1963
+ this.emitEntityInteraction("hover-end", { type: "shape", id: "", shapeType: "polygon", handleId });
1964
+ map.getCanvas().style.cursor = reactMapAdapterShared.MAP_CURSORS.default;
1965
+ map.getCanvasContainer().style.cursor = reactMapAdapterShared.MAP_CURSORS.default;
1966
+ };
1967
+ map.on("click", layerId, clickHandler);
1968
+ map.on("dblclick", layerId, dblClickHandler);
1969
+ map.on("mouseenter", layerId, mouseEnterHandler);
1970
+ map.on("mouseleave", layerId, mouseLeaveHandler);
1971
+ this.shapeClickHandlers.set(layerId, clickHandler);
1972
+ this.shapeDblClickHandlers.set(layerId, dblClickHandler);
1973
+ this.shapeMouseEnterHandlers.set(layerId, mouseEnterHandler);
1974
+ this.shapeMouseLeaveHandlers.set(layerId, mouseLeaveHandler);
1975
+ }
1976
+ }
1977
+ detachShapeLayerInteractionListeners(map, layerIds) {
1978
+ for (const layerId of layerIds) {
1979
+ const clickHandler = this.shapeClickHandlers.get(layerId);
1980
+ if (clickHandler !== undefined) {
1981
+ map.off("click", layerId, clickHandler);
1982
+ this.shapeClickHandlers.delete(layerId);
1983
+ }
1984
+ const dblClickHandler = this.shapeDblClickHandlers.get(layerId);
1985
+ if (dblClickHandler !== undefined) {
1986
+ map.off("dblclick", layerId, dblClickHandler);
1987
+ this.shapeDblClickHandlers.delete(layerId);
1988
+ }
1989
+ const mouseEnterHandler = this.shapeMouseEnterHandlers.get(layerId);
1990
+ if (mouseEnterHandler !== undefined) {
1991
+ map.off("mouseenter", layerId, mouseEnterHandler);
1992
+ this.shapeMouseEnterHandlers.delete(layerId);
1993
+ }
1994
+ const mouseLeaveHandler = this.shapeMouseLeaveHandlers.get(layerId);
1995
+ if (mouseLeaveHandler !== undefined) {
1996
+ map.off("mouseleave", layerId, mouseLeaveHandler);
1997
+ this.shapeMouseLeaveHandlers.delete(layerId);
1998
+ }
1999
+ }
2000
+ }
2001
+ // ---- Route layer interaction ----
2002
+ attachRouteLayerInteractionListeners(map, layerIds, _handleId) {
2003
+ for (const layerId of layerIds) {
2004
+ const buildRouteEntity = (feature) => {
2005
+ const featureId = feature.id !== undefined ? String(feature.id) : undefined;
2006
+ if (featureId === undefined)
2007
+ return null;
2008
+ const waypoints = feature.geometry.type === "LineString" ? feature.geometry.coordinates.map(c => [c[0] ?? 0, c[1] ?? 0]) : [];
2009
+ return { type: "route", id: featureId, waypoints };
2010
+ };
2011
+ const clickHandler = (e) => {
2012
+ const feature = e.features?.[0];
2013
+ if (feature === undefined)
2014
+ return;
2015
+ const entity = buildRouteEntity(feature);
2016
+ if (entity !== null)
2017
+ this.emitEntityInteraction("click", entity);
2018
+ };
2019
+ const dblClickHandler = (e) => {
2020
+ this.suppressNativeZoom();
2021
+ const feature = e.features?.[0];
2022
+ if (feature === undefined)
2023
+ return;
2024
+ const entity = buildRouteEntity(feature);
2025
+ if (entity !== null)
2026
+ this.emitEntityInteraction("dblclick", entity);
2027
+ };
2028
+ const mouseEnterHandler = () => {
2029
+ map.getCanvas().style.cursor = reactMapAdapterShared.MAP_CURSORS.interactive;
2030
+ map.getCanvasContainer().style.cursor = reactMapAdapterShared.MAP_CURSORS.interactive;
2031
+ };
2032
+ const mouseLeaveHandler = () => {
2033
+ map.getCanvas().style.cursor = reactMapAdapterShared.MAP_CURSORS.default;
2034
+ map.getCanvasContainer().style.cursor = reactMapAdapterShared.MAP_CURSORS.default;
2035
+ };
2036
+ map.on("click", layerId, clickHandler);
2037
+ map.on("dblclick", layerId, dblClickHandler);
2038
+ map.on("mouseenter", layerId, mouseEnterHandler);
2039
+ map.on("mouseleave", layerId, mouseLeaveHandler);
2040
+ this.routeClickHandlers.set(layerId, clickHandler);
2041
+ this.routeDblClickHandlers.set(layerId, dblClickHandler);
2042
+ this.routeMouseEnterHandlers.set(layerId, mouseEnterHandler);
2043
+ this.routeMouseLeaveHandlers.set(layerId, mouseLeaveHandler);
2044
+ }
2045
+ }
2046
+ detachRouteLayerInteractionListeners(map, layerIds) {
2047
+ for (const layerId of layerIds) {
2048
+ const clickHandler = this.routeClickHandlers.get(layerId);
2049
+ if (clickHandler !== undefined) {
2050
+ map.off("click", layerId, clickHandler);
2051
+ this.routeClickHandlers.delete(layerId);
2052
+ }
2053
+ const dblClickHandler = this.routeDblClickHandlers.get(layerId);
2054
+ if (dblClickHandler !== undefined) {
2055
+ map.off("dblclick", layerId, dblClickHandler);
2056
+ this.routeDblClickHandlers.delete(layerId);
2057
+ }
2058
+ const mouseEnterHandler = this.routeMouseEnterHandlers.get(layerId);
2059
+ if (mouseEnterHandler !== undefined) {
2060
+ map.off("mouseenter", layerId, mouseEnterHandler);
2061
+ this.routeMouseEnterHandlers.delete(layerId);
2062
+ }
2063
+ const mouseLeaveHandler = this.routeMouseLeaveHandlers.get(layerId);
2064
+ if (mouseLeaveHandler !== undefined) {
2065
+ map.off("mouseleave", layerId, mouseLeaveHandler);
2066
+ this.routeMouseLeaveHandlers.delete(layerId);
2067
+ }
2068
+ }
2069
+ }
2070
+ /**
2071
+ * Temporarily disable the map's built-in double-click zoom so that our
2072
+ * custom dblclick handler can call fitBounds without the native zoom
2073
+ * overriding it. Re-enables on the next tick.
2074
+ */
2075
+ suppressNativeZoom() {
2076
+ if (this.map === null)
2077
+ return;
2078
+ const m = this.map;
2079
+ m.doubleClickZoom.disable();
2080
+ setTimeout(() => {
2081
+ m.doubleClickZoom.enable();
2082
+ }, 0);
2083
+ }
2084
+ /** Emit entity interaction event to all subscribed handlers */
2085
+ emitEntityInteraction(type, entity) {
2086
+ if (type === "click") {
2087
+ this.entityClickedInTick = true;
2088
+ setTimeout(() => {
2089
+ this.entityClickedInTick = false;
2090
+ }, 0);
2091
+ }
2092
+ const event = { type, entity };
2093
+ this.interactionHandlers.forEach(handler => handler(event));
2094
+ }
2095
+ // ============================================================================
2096
+ // Private: Unified shape interaction paint
2097
+ // ============================================================================
2098
+ /**
2099
+ * Sets all interaction-sensitive paint properties for a shape source,
2100
+ * building nested `case` expressions with selected > hovered > base priority.
2101
+ *
2102
+ * Called whenever hover, selection, or theme changes on the given handleId.
2103
+ * When neither hover nor selection is active, all properties revert to base.
2104
+ */
2105
+ applyShapeInteractionPaint(handleId) {
2106
+ const tracked = this.shapeSources.get(handleId);
2107
+ if (tracked === undefined || this.map === null)
2108
+ return;
2109
+ const { style, featureStyles: fs } = tracked.config;
2110
+ const hasSelection = this.selectedShapeHandleId === handleId && this.selectedShapeFeatureId !== null;
2111
+ const hasHover = this.hoveredShapeHandleId === handleId && this.hoveredShapeFeatureId !== null;
2112
+ let selectedOverrides = null;
2113
+ if (hasSelection && this.selectedShapeFeatureId !== null) {
2114
+ const featureStyle = fs?.get(this.selectedShapeFeatureId) ?? style;
2115
+ selectedOverrides = reactMapAdapterShared.resolveSelectedStyle(featureStyle, "polygon", this.theme);
2116
+ }
2117
+ let hoveredOverrides = null;
2118
+ if (hasHover && this.hoveredShapeFeatureId !== null) {
2119
+ const featureStyle = fs?.get(this.hoveredShapeFeatureId) ?? style;
2120
+ hoveredOverrides = reactMapAdapterShared.resolveHoveredStyle(featureStyle, "polygon", this.theme);
2121
+ }
2122
+ /**
2123
+ * Wraps a base paint value with case branches for the active interaction
2124
+ * states. Selected is checked first (higher priority), then hovered.
2125
+ * Returns the base paint unchanged when no interaction is active.
2126
+ */
2127
+ const withInteraction = (basePaint, selectedVal, hoveredVal, fallback) => {
2128
+ const selId = hasSelection ? this.selectedShapeFeatureId : null;
2129
+ const hovId = hasHover ? this.hoveredShapeFeatureId : null;
2130
+ if (selId !== null && hovId !== null) {
2131
+ return [
2132
+ "case",
2133
+ ["==", ["get", "__featureId"], selId],
2134
+ selectedVal ?? fallback,
2135
+ ["==", ["get", "__featureId"], hovId],
2136
+ hoveredVal ?? fallback,
2137
+ basePaint,
2138
+ ];
2139
+ }
2140
+ if (selId !== null) {
2141
+ return ["case", ["==", ["get", "__featureId"], selId], selectedVal ?? fallback, basePaint];
2142
+ }
2143
+ if (hovId !== null) {
2144
+ return ["case", ["==", ["get", "__featureId"], hovId], hoveredVal ?? fallback, basePaint];
2145
+ }
2146
+ return basePaint;
2147
+ };
2148
+ const defaultFill = style.fill ?? "rgba(0,0,0,0.1)";
2149
+ const defaultStroke = style.stroke ?? "#000000";
2150
+ const defaultStrokeWidth = style.strokeWidth ?? 1;
2151
+ const defaultStrokeOpacity = style.strokeOpacity ?? 1;
2152
+ const defaultPolyFillOpacity = style.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity;
2153
+ const defaultPointFillOpacity = style.fillOpacity ?? this.shapeStyleDefaults.point.fillOpacity;
2154
+ const baseFill = shapePaint(fs, "__fill", defaultFill);
2155
+ const baseStroke = shapePaint(fs, "__stroke", defaultStroke);
2156
+ const baseStrokeWidth = shapePaint(fs, "__strokeWidth", defaultStrokeWidth);
2157
+ const baseStrokeOpacity = shapePaint(fs, "__strokeOpacity", defaultStrokeOpacity);
2158
+ const basePolyFillOpacity = shapePaint(fs, "__fillOpacity", defaultPolyFillOpacity);
2159
+ const basePointFillOpacity = shapePaint(fs, "__fillOpacity", defaultPointFillOpacity);
2160
+ const fillLayerId = `shape-fill-${handleId}`;
2161
+ const lineLayerId = `shape-line-${handleId}`;
2162
+ const circleLayerId = `shape-circle-${handleId}`;
2163
+ this.map.setPaintProperty(fillLayerId, "fill-color", withInteraction(baseFill, selectedOverrides?.fill, hoveredOverrides?.fill, defaultFill));
2164
+ this.map.setPaintProperty(fillLayerId, "fill-opacity", withInteraction(basePolyFillOpacity, selectedOverrides?.fillOpacity, hoveredOverrides?.fillOpacity, defaultPolyFillOpacity));
2165
+ this.map.setPaintProperty(lineLayerId, "line-color", withInteraction(baseStroke, selectedOverrides?.stroke, hoveredOverrides?.stroke, defaultStroke));
2166
+ this.map.setPaintProperty(lineLayerId, "line-width", withInteraction(baseStrokeWidth, selectedOverrides?.strokeWidth, hoveredOverrides?.strokeWidth, defaultStrokeWidth));
2167
+ this.map.setPaintProperty(lineLayerId, "line-opacity", withInteraction(baseStrokeOpacity, selectedOverrides?.strokeOpacity, hoveredOverrides?.strokeOpacity, defaultStrokeOpacity));
2168
+ this.map.setPaintProperty(circleLayerId, "circle-color", withInteraction(baseFill, selectedOverrides?.fill, hoveredOverrides?.fill, defaultFill));
2169
+ this.map.setPaintProperty(circleLayerId, "circle-stroke-color", withInteraction(baseStroke, selectedOverrides?.stroke, hoveredOverrides?.stroke, defaultStroke));
2170
+ this.map.setPaintProperty(circleLayerId, "circle-stroke-width", withInteraction(baseStrokeWidth, selectedOverrides?.strokeWidth, hoveredOverrides?.strokeWidth, defaultStrokeWidth));
2171
+ this.map.setPaintProperty(circleLayerId, "circle-opacity", withInteraction(basePointFillOpacity, selectedOverrides?.fillOpacity, hoveredOverrides?.fillOpacity, defaultPointFillOpacity));
2172
+ }
2173
+ // ============================================================================
2174
+ // Private: Style change reconnection
2175
+ // ============================================================================
2176
+ /**
2177
+ * Called when the map style reloads (e.g. after setTheme / setMapType).
2178
+ * mapboxgl.Marker objects persist across style changes (they are DOM overlays),
2179
+ * but all GeoJSON sources and layers are removed by the style change.
2180
+ * We need to re-add them.
2181
+ */
2182
+ onStyleLoad() {
2183
+ const currentMap = this.map;
2184
+ if (currentMap === null)
2185
+ return;
2186
+ // Reset arrow image flag -- style change removes custom images
2187
+ this.arrowImageAdded = false;
2188
+ // Snapshot all source maps before iterating. Mutating a Map during
2189
+ // for...of iteration (delete + set with the same key) causes the
2190
+ // iterator to revisit the re-added entry, creating an infinite loop.
2191
+ const shapeEntries = Array.from(this.shapeSources.entries());
2192
+ const routeEntries = Array.from(this.routeSources.entries());
2193
+ const overlayEntries = Array.from(this.overlaySources.entries());
2194
+ const markerEntriesToRestore = Array.from(this.markerSources.entries()).filter(([, tracked]) => tracked.sourceId !== undefined);
2195
+ // Re-add shape sources/layers
2196
+ for (const [id, { config }] of shapeEntries) {
2197
+ this.shapeSources.delete(id);
2198
+ this.syncShapeSource(config);
2199
+ }
2200
+ // Re-apply interaction paint after shape sources are restored
2201
+ const interactionHandleIds = new Set();
2202
+ if (this.selectedShapeHandleId !== null)
2203
+ interactionHandleIds.add(this.selectedShapeHandleId);
2204
+ if (this.hoveredShapeHandleId !== null)
2205
+ interactionHandleIds.add(this.hoveredShapeHandleId);
2206
+ for (const id of interactionHandleIds) {
2207
+ this.applyShapeInteractionPaint(id);
2208
+ }
2209
+ // Re-add route sources/layers
2210
+ for (const [id, { config }] of routeEntries) {
2211
+ this.routeSources.delete(id);
2212
+ this.syncRouteSource(config);
2213
+ }
2214
+ // Re-add image overlay sources/layers
2215
+ for (const [id, { config }] of overlayEntries) {
2216
+ this.overlaySources.delete(id);
2217
+ this.syncImageOverlay(config);
2218
+ }
2219
+ // DOM markers (mapboxgl.Marker) persist across style changes, but
2220
+ // WebGL circle layers/sources are removed. Re-add marker sources
2221
+ // that used circle layers (from pre-snapshot).
2222
+ for (const [id, { config }] of markerEntriesToRestore) {
2223
+ this.markerSources.delete(id);
2224
+ this.syncMarkerSource(config);
2225
+ }
2226
+ }
2227
+ }
2228
+
2229
+ /**
2230
+ * Zod schema for Mapbox LngLatLike (plain object)
2231
+ * A plain object with lng and lat number properties
2232
+ */
2233
+ const lngLatLiteralSchema = zod.z.object({
2234
+ lng: zod.z.number(),
2235
+ lat: zod.z.number(),
2236
+ });
2237
+ /**
2238
+ * Zod schema for Mapbox LngLat class instance
2239
+ * An object with lng and lat properties that are numbers
2240
+ */
2241
+ const lngLatClassSchema = zod.z.object({
2242
+ lng: zod.z.number(),
2243
+ lat: zod.z.number(),
2244
+ });
2245
+ /**
2246
+ * Convert GeoJSON Position [lng, lat] to Mapbox LngLatLike
2247
+ */
2248
+ const positionToLngLat = (position) => ({
2249
+ lng: position[0],
2250
+ lat: position[1],
2251
+ });
2252
+ /**
2253
+ * Convert Mapbox LngLat to GeoJSON Position [lng, lat]
2254
+ * Uses Zod to validate and extract lng/lat values safely
2255
+ */
2256
+ const lngLatToPosition = (lngLat) => {
2257
+ const literalResult = lngLatLiteralSchema.safeParse(lngLat);
2258
+ if (literalResult.success) {
2259
+ // It's a LngLatLike - lng and lat are numbers
2260
+ return [literalResult.data.lng, literalResult.data.lat];
2261
+ }
2262
+ const classResult = lngLatClassSchema.safeParse(lngLat);
2263
+ if (classResult.success) {
2264
+ // It's a LngLat class instance - lng and lat are properties
2265
+ return [classResult.data.lng, classResult.data.lat];
2266
+ }
2267
+ // This should never happen if the input type is correct
2268
+ throw new Error("Invalid LngLat value: expected either LngLatLike or LngLat class instance");
2269
+ };
2270
+ /**
2271
+ * Convert GeoJSON Bbox [minLng, minLat, maxLng, maxLat] to Mapbox LngLatBoundsLike.
2272
+ *
2273
+ * When the bbox crosses the antimeridian (RFC 7946: minLng > maxLng), the northeast
2274
+ * longitude is unwrapped by +360° so both corners are in a continuous longitude range
2275
+ * (west longitude less than east). Mapbox GL `fitBounds` / initial `bounds` use `cameraForBounds`,
2276
+ * which builds an AABB from NW/SE in Mercator space; raw corners like (170, 10) and
2277
+ * (-170, -10) span almost the whole world in x, while (170, 10) and (190, 10) span the
2278
+ * intended Pacific strip.
2279
+ */
2280
+ const bboxToLngLatBounds = (bbox) => {
2281
+ const [minLng, minLat, maxLng, maxLat] = bbox;
2282
+ if (minLng > maxLng) {
2283
+ return [
2284
+ [minLng, minLat],
2285
+ [maxLng + 360, maxLat],
2286
+ ];
2287
+ }
2288
+ return [
2289
+ [minLng, minLat],
2290
+ [maxLng, maxLat],
2291
+ ];
2292
+ };
2293
+ /**
2294
+ * Convert Mapbox LngLatBounds to GeoJSON Bbox [minLng, minLat, maxLng, maxLat].
2295
+ * Northeast longitudes above 180° (from unwrapped bounds) are converted back to [-180, 180].
2296
+ */
2297
+ const lngLatBoundsToBbox = (bounds) => {
2298
+ const sw = bounds.getSouthWest();
2299
+ const ne = bounds.getNorthEast();
2300
+ let east = ne.lng;
2301
+ if (east > 180) {
2302
+ east -= 360;
2303
+ }
2304
+ return [sw.lng, sw.lat, east, ne.lat];
2305
+ };
2306
+
2307
+ const MAPBOX_TILE_SIZE = 512;
2308
+ /**
2309
+ * Mapbox adapter instance implementation
2310
+ * Manages the connection between our abstract interface and Mapbox GL API
2311
+ */
2312
+ class MapboxAdapterInstance {
2313
+ constructor(config) {
2314
+ this.config = config;
2315
+ this.map = null;
2316
+ this.state = { ...reactMapAdapterShared.INITIAL_MAP_STATE, tileSize: MAPBOX_TILE_SIZE };
2317
+ this.cachedCameraState = {
2318
+ center: reactMapAdapterShared.INITIAL_MAP_STATE.center,
2319
+ zoom: reactMapAdapterShared.INITIAL_MAP_STATE.zoom,
2320
+ bounds: reactMapAdapterShared.INITIAL_MAP_STATE.bounds,
2321
+ isIdle: reactMapAdapterShared.INITIAL_MAP_STATE.isIdle,
2322
+ };
2323
+ this.cachedStatus = {
2324
+ isReady: reactMapAdapterShared.INITIAL_MAP_STATE.isReady,
2325
+ initializationFailed: reactMapAdapterShared.INITIAL_MAP_STATE.initializationFailed,
2326
+ appearance: reactMapAdapterShared.INITIAL_MAP_STATE.appearance,
2327
+ tileSize: MAPBOX_TILE_SIZE,
2328
+ };
2329
+ this.cameraListeners = new Set();
2330
+ this.statusListeners = new Set();
2331
+ this.eventListeners = new Map();
2332
+ this.mapboxEventCleanups = [];
2333
+ this.isDestroyedFlag = false;
2334
+ this.appearance = { ...reactMapAdapterShared.DEFAULT_MAP_APPEARANCE, theme: config.theme ?? "light" };
2335
+ this.layers = new MapboxLayerPort();
2336
+ this.layers.setTheme(this.appearance.theme);
2337
+ this.state = { ...reactMapAdapterShared.computeInitialState(this.appearance, config.initialViewport), tileSize: MAPBOX_TILE_SIZE };
2338
+ this.cachedCameraState = {
2339
+ center: this.state.center,
2340
+ zoom: this.state.zoom,
2341
+ bounds: this.state.bounds,
2342
+ isIdle: this.state.isIdle,
2343
+ };
2344
+ this.cachedStatus = {
2345
+ isReady: this.state.isReady,
2346
+ initializationFailed: this.state.initializationFailed,
2347
+ appearance: this.state.appearance,
2348
+ tileSize: this.state.tileSize,
2349
+ };
2350
+ }
2351
+ // ============================================================================
2352
+ // Public AdapterInstance implementation
2353
+ // ============================================================================
2354
+ /**
2355
+ * Connect to a Mapbox map instance - called by MapboxRenderer
2356
+ */
2357
+ connect(map) {
2358
+ // React 18 StrictMode (dev) runs effect cleanups between the first and second mount, so
2359
+ // `useMap`'s destroy cleanup fires before the real connect. Allow `connect` to revive a
2360
+ // destroyed adapter so the dev cycle self-heals; production unmounts never re-mount.
2361
+ if (this.isDestroyedFlag) {
2362
+ this.isDestroyedFlag = false;
2363
+ }
2364
+ // Prevent reconnection if already connected
2365
+ if (this.state.isReady)
2366
+ return;
2367
+ this.map = map;
2368
+ this.setupEventListeners();
2369
+ // Connect the layer port so it can render on this map
2370
+ this.layers.connect(map);
2371
+ // Set isReady and notify status listeners before syncing camera state
2372
+ this.state = { ...this.state, isReady: true, initializationFailed: false };
2373
+ this.updateStatusCache();
2374
+ this.notifyStatusListeners();
2375
+ // Sync initial map state
2376
+ this.updateState();
2377
+ }
2378
+ getConfig() {
2379
+ return this.config;
2380
+ }
2381
+ getCameraState() {
2382
+ return this.cachedCameraState;
2383
+ }
2384
+ getStatus() {
2385
+ return this.cachedStatus;
2386
+ }
2387
+ subscribeCamera(listener) {
2388
+ this.cameraListeners.add(listener);
2389
+ return () => {
2390
+ this.cameraListeners.delete(listener);
2391
+ };
2392
+ }
2393
+ subscribeStatus(listener) {
2394
+ this.statusListeners.add(listener);
2395
+ return () => {
2396
+ this.statusListeners.delete(listener);
2397
+ };
2398
+ }
2399
+ async setCenter(center) {
2400
+ const validCenter = geoJsonUtils.validatePosition(center, "[setCenter]");
2401
+ if (validCenter === null)
2402
+ return;
2403
+ const currentMap = this.map;
2404
+ if (currentMap === null)
2405
+ return;
2406
+ return new Promise(resolve => {
2407
+ currentMap.setCenter(positionToLngLat(validCenter));
2408
+ currentMap.once("idle", () => resolve());
2409
+ });
2410
+ }
2411
+ async setZoom(zoom) {
2412
+ const currentMap = this.map;
2413
+ if (currentMap === null)
2414
+ return;
2415
+ return new Promise(resolve => {
2416
+ currentMap.setZoom(zoom);
2417
+ currentMap.once("idle", () => resolve());
2418
+ });
2419
+ }
2420
+ async zoomBy(delta) {
2421
+ const currentMap = this.map;
2422
+ if (currentMap === null)
2423
+ return;
2424
+ const currentZoom = currentMap.getZoom();
2425
+ return this.setZoom(currentZoom + delta);
2426
+ }
2427
+ async fitBounds(bounds, options) {
2428
+ const validBounds = geoJsonUtils.validateBbox(bounds, "[fitBounds]");
2429
+ if (validBounds === null)
2430
+ return;
2431
+ const currentMap = this.map;
2432
+ if (currentMap === null)
2433
+ return;
2434
+ return new Promise(resolve => {
2435
+ const mapboxOptions = {
2436
+ padding: options?.padding ?? 0,
2437
+ ...(options?.maxZoom !== undefined && { maxZoom: options.maxZoom }),
2438
+ animate: options?.animate ?? true,
2439
+ };
2440
+ if (!mapboxOptions.animate) {
2441
+ // animate:false = synchronous camera jump. The camera is already at the target
2442
+ // position when fitBounds() returns, so resolve immediately — no event needed.
2443
+ // Waiting for moveend/idle is wrong here: if the target equals the current
2444
+ // viewport (no-op), neither event fires and the promise hangs forever.
2445
+ currentMap.fitBounds(bboxToLngLatBounds(validBounds), mapboxOptions);
2446
+ resolve();
2447
+ return;
2448
+ }
2449
+ // Animated move: register moveend BEFORE calling fitBounds so we don't miss
2450
+ // a synchronous dispatch, then guard against no-ops (camera already at target).
2451
+ const handleMoveEnd = () => {
2452
+ resolve();
2453
+ };
2454
+ currentMap.once("moveend", handleMoveEnd);
2455
+ currentMap.fitBounds(bboxToLngLatBounds(validBounds), mapboxOptions);
2456
+ if (!currentMap.isMoving()) {
2457
+ // No animation started (no-op: camera already at target). moveend won't fire.
2458
+ currentMap.off("moveend", handleMoveEnd);
2459
+ resolve();
2460
+ }
2461
+ });
2462
+ }
2463
+ async panTo(center) {
2464
+ const validCenter = geoJsonUtils.validatePosition(center, "[panTo]");
2465
+ if (validCenter === null)
2466
+ return;
2467
+ const currentMap = this.map;
2468
+ if (currentMap === null)
2469
+ return;
2470
+ return new Promise(resolve => {
2471
+ currentMap.panTo(positionToLngLat(validCenter));
2472
+ currentMap.once("idle", () => resolve());
2473
+ });
2474
+ }
2475
+ async panBy(deltaX, deltaY) {
2476
+ const currentMap = this.map;
2477
+ if (currentMap === null)
2478
+ return;
2479
+ currentMap.panBy([deltaX, deltaY]);
2480
+ }
2481
+ async setMapType(type) {
2482
+ if (this.appearance.mapType === type)
2483
+ return;
2484
+ this.appearance = { ...this.appearance, mapType: type };
2485
+ this.state = { ...this.state, appearance: this.appearance };
2486
+ this.updateStatusCache();
2487
+ this.notifyStatusListeners();
2488
+ const currentMap = this.map;
2489
+ if (currentMap === null)
2490
+ return;
2491
+ const effectiveType = this.effectiveMapType();
2492
+ const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2493
+ return new Promise(resolve => {
2494
+ currentMap.setStyle(styleUrl);
2495
+ currentMap.once("idle", () => resolve());
2496
+ });
2497
+ }
2498
+ async setTheme(theme) {
2499
+ if (this.appearance.theme === theme)
2500
+ return;
2501
+ this.appearance = { ...this.appearance, theme };
2502
+ this.state = { ...this.state, appearance: this.appearance };
2503
+ this.layers.setTheme(theme);
2504
+ this.updateStatusCache();
2505
+ this.notifyStatusListeners();
2506
+ const currentMap = this.map;
2507
+ if (currentMap === null)
2508
+ return;
2509
+ const effectiveType = this.effectiveMapType();
2510
+ const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2511
+ return new Promise(resolve => {
2512
+ currentMap.setStyle(styleUrl);
2513
+ currentMap.once("idle", () => resolve());
2514
+ });
2515
+ }
2516
+ async setShowRoads(showRoads) {
2517
+ if (this.appearance.showRoads === showRoads)
2518
+ return;
2519
+ this.appearance = { ...this.appearance, showRoads };
2520
+ this.state = { ...this.state, appearance: this.appearance };
2521
+ this.updateStatusCache();
2522
+ this.notifyStatusListeners();
2523
+ // Re-apply style if on satellite (satellite vs hybrid depends on showRoads)
2524
+ const currentMap = this.map;
2525
+ if (currentMap === null)
2526
+ return;
2527
+ if (this.appearance.mapType === "satellite" || this.appearance.mapType === "hybrid") {
2528
+ const effectiveType = this.effectiveMapType();
2529
+ const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2530
+ return new Promise(resolve => {
2531
+ currentMap.setStyle(styleUrl);
2532
+ currentMap.once("idle", () => resolve());
2533
+ });
2534
+ }
2535
+ }
2536
+ on(event, handler) {
2537
+ let listeners = this.eventListeners.get(event);
2538
+ if (listeners === undefined) {
2539
+ listeners = new Set();
2540
+ this.eventListeners.set(event, listeners);
2541
+ }
2542
+ // Store handler as generic event handler
2543
+ const storedHandler = mapEvent => {
2544
+ // Only call handler if event type matches (runtime check + type narrowing)
2545
+ if (reactMapAdapterShared.isEventOfType(mapEvent, event)) {
2546
+ handler(mapEvent);
2547
+ }
2548
+ };
2549
+ listeners.add(storedHandler);
2550
+ return () => {
2551
+ listeners.delete(storedHandler);
2552
+ };
2553
+ }
2554
+ notifyInitializationFailed() {
2555
+ if (this.isDestroyedFlag) {
2556
+ return;
2557
+ }
2558
+ if (this.state.initializationFailed) {
2559
+ return;
2560
+ }
2561
+ this.state = { ...this.state, isReady: false, initializationFailed: true };
2562
+ this.updateStatusCache();
2563
+ this.notifyStatusListeners();
2564
+ }
2565
+ destroy() {
2566
+ this.isDestroyedFlag = true;
2567
+ // Clean up layer port before removing event listeners
2568
+ this.layers.destroy();
2569
+ // Remove all Mapbox event listeners
2570
+ this.mapboxEventCleanups.forEach(cleanup => cleanup());
2571
+ this.mapboxEventCleanups = [];
2572
+ // Clear our listeners
2573
+ this.cameraListeners.clear();
2574
+ this.statusListeners.clear();
2575
+ this.eventListeners.clear();
2576
+ this.map = null;
2577
+ // Set isReady: false in state (listeners already cleared, no need to notify)
2578
+ this.state = { ...reactMapAdapterShared.INITIAL_MAP_STATE, isReady: false, tileSize: MAPBOX_TILE_SIZE };
2579
+ this.updateCameraCache();
2580
+ this.updateStatusCache();
2581
+ }
2582
+ // ============================================================================
2583
+ // Private methods
2584
+ // ============================================================================
2585
+ setupEventListeners() {
2586
+ const currentMap = this.map;
2587
+ if (currentMap === null)
2588
+ return;
2589
+ // Idle event
2590
+ const idleListener = () => {
2591
+ this.updateState();
2592
+ this.emitEvent({ type: "idle" });
2593
+ };
2594
+ currentMap.on("idle", idleListener);
2595
+ this.mapboxEventCleanups.push(() => currentMap.off("idle", idleListener));
2596
+ // Move start — set isIdle=false so adaptive resolution freezes during camera motion
2597
+ const moveStartListener = () => {
2598
+ this.setIdleState(false);
2599
+ this.emitEvent({ type: "movestart" });
2600
+ };
2601
+ currentMap.on("movestart", moveStartListener);
2602
+ this.mapboxEventCleanups.push(() => currentMap.off("movestart", moveStartListener));
2603
+ // Move (during drag/zoom) — update position but preserve isIdle=false set by movestart
2604
+ const moveListener = () => {
2605
+ this.updatePositionOnly();
2606
+ };
2607
+ currentMap.on("move", moveListener);
2608
+ this.mapboxEventCleanups.push(() => currentMap.off("move", moveListener));
2609
+ // Click event
2610
+ const clickListener = (e) => {
2611
+ this.emitEvent({
2612
+ type: "click",
2613
+ position: lngLatToPosition(e.lngLat),
2614
+ originalEvent: e.originalEvent,
2615
+ });
2616
+ };
2617
+ currentMap.on("click", clickListener);
2618
+ this.mapboxEventCleanups.push(() => currentMap.off("click", clickListener));
2619
+ // Pointer move (ADR-0021 fill tiling) — throttled to one emit per frame.
2620
+ let pointerMoveRaf = null;
2621
+ let lastPointerPosition = null;
2622
+ const pointerMoveListener = (e) => {
2623
+ lastPointerPosition = lngLatToPosition(e.lngLat);
2624
+ if (pointerMoveRaf !== null)
2625
+ return;
2626
+ pointerMoveRaf = requestAnimationFrame(() => {
2627
+ pointerMoveRaf = null;
2628
+ if (lastPointerPosition !== null) {
2629
+ this.emitEvent({ type: "pointermove", position: lastPointerPosition });
2630
+ }
2631
+ });
2632
+ };
2633
+ currentMap.on("mousemove", pointerMoveListener);
2634
+ this.mapboxEventCleanups.push(() => {
2635
+ currentMap.off("mousemove", pointerMoveListener);
2636
+ if (pointerMoveRaf !== null)
2637
+ cancelAnimationFrame(pointerMoveRaf);
2638
+ });
2639
+ }
2640
+ updateState() {
2641
+ const currentMap = this.map;
2642
+ if (currentMap === null)
2643
+ return;
2644
+ const center = currentMap.getCenter();
2645
+ const zoom = currentMap.getZoom();
2646
+ const bounds = currentMap.getBounds();
2647
+ const newCamera = {
2648
+ center: lngLatToPosition(center),
2649
+ zoom,
2650
+ bounds: bounds ? lngLatBoundsToBbox(bounds) : null,
2651
+ isIdle: true,
2652
+ };
2653
+ // Only notify camera subscribers if camera fields actually changed
2654
+ if (!reactMapAdapterShared.cameraStateEquals(this.state, newCamera)) {
2655
+ this.state = { ...this.state, ...newCamera };
2656
+ this.updateCameraCache();
2657
+ this.notifyCameraListeners();
2658
+ }
2659
+ }
2660
+ /**
2661
+ * Update camera position fields (center, zoom, bounds) without touching isIdle.
2662
+ * Called during `move` events so that isIdle=false set by `movestart` is preserved
2663
+ * until the map reaches the `idle` state.
2664
+ */
2665
+ updatePositionOnly() {
2666
+ const currentMap = this.map;
2667
+ if (currentMap === null)
2668
+ return;
2669
+ const center = currentMap.getCenter();
2670
+ const zoom = currentMap.getZoom();
2671
+ const bounds = currentMap.getBounds();
2672
+ const newCamera = {
2673
+ center: lngLatToPosition(center),
2674
+ zoom,
2675
+ bounds: bounds ? lngLatBoundsToBbox(bounds) : null,
2676
+ isIdle: this.state.isIdle,
2677
+ };
2678
+ if (!reactMapAdapterShared.cameraStateEquals(this.state, newCamera)) {
2679
+ this.state = { ...this.state, ...newCamera };
2680
+ this.updateCameraCache();
2681
+ this.notifyCameraListeners();
2682
+ }
2683
+ }
2684
+ /**
2685
+ * Explicitly set isIdle and notify camera subscribers if it changed.
2686
+ * Used by movestart (false) and the idle path already calls updateState (true).
2687
+ */
2688
+ setIdleState(isIdle) {
2689
+ if (this.state.isIdle === isIdle)
2690
+ return;
2691
+ this.state = { ...this.state, isIdle };
2692
+ this.updateCameraCache();
2693
+ this.notifyCameraListeners();
2694
+ }
2695
+ /**
2696
+ * Resolves the effective Mapbox style key: when the user asks for "satellite"
2697
+ * with roads enabled, we use "hybrid" (satellite + road labels).
2698
+ */
2699
+ effectiveMapType() {
2700
+ if (this.appearance.mapType === "satellite" && this.appearance.showRoads) {
2701
+ return "hybrid";
2702
+ }
2703
+ return this.appearance.mapType;
2704
+ }
2705
+ updateCameraCache() {
2706
+ const { center, zoom, bounds, isIdle } = this.state;
2707
+ this.cachedCameraState = { center, zoom, bounds, isIdle };
2708
+ }
2709
+ updateStatusCache() {
2710
+ const { isReady, initializationFailed, appearance, tileSize } = this.state;
2711
+ this.cachedStatus = { isReady, initializationFailed, appearance, tileSize };
2712
+ }
2713
+ notifyCameraListeners() {
2714
+ this.cameraListeners.forEach(listener => listener());
2715
+ }
2716
+ notifyStatusListeners() {
2717
+ this.statusListeners.forEach(listener => listener());
2718
+ }
2719
+ emitEvent(event) {
2720
+ const listeners = this.eventListeners.get(event.type);
2721
+ if (listeners !== undefined) {
2722
+ listeners.forEach(handler => {
2723
+ handler(event);
2724
+ });
2725
+ }
2726
+ // Also emit moveend after idle if it was a move
2727
+ if (event.type === "idle") {
2728
+ const moveEndListeners = this.eventListeners.get("moveend");
2729
+ if (moveEndListeners !== undefined) {
2730
+ const moveEndEvent = { type: "moveend", state: this.state };
2731
+ moveEndListeners.forEach(handler => {
2732
+ handler(moveEndEvent);
2733
+ });
2734
+ }
2735
+ }
2736
+ }
2737
+ }
2738
+ const createMapboxInstance = (config) => {
2739
+ return new MapboxAdapterInstance(config);
2740
+ };
2741
+
2742
+ /**
2743
+ * Internal component that renders the actual Mapbox map
2744
+ */
2745
+ const MapboxMapInternal = ({ adapterInstance, children, className, style, theme, accessToken, region, initialViewport, restrictBounds, }) => {
2746
+ const containerRef = react.useRef(null);
2747
+ const mapRef = react.useRef(null);
2748
+ const subscribeAdapter = react.useCallback((onChange) => adapterInstance.subscribeStatus(onChange), [adapterInstance]);
2749
+ const getMapUsableSnapshot = react.useCallback(() => {
2750
+ const s = adapterInstance.getStatus();
2751
+ return s.isReady && !s.initializationFailed;
2752
+ }, [adapterInstance]);
2753
+ const mapUsable = react.useSyncExternalStore(subscribeAdapter, getMapUsableSnapshot, getMapUsableSnapshot);
2754
+ const restrictionBbox = react.useMemo(() => reactMapAdapterShared.getEffectiveRestrictBounds(restrictBounds), [restrictBounds]);
2755
+ // Use ref to capture latest adapter instance without causing re-renders
2756
+ const adapterInstanceRef = react.useRef(adapterInstance);
2757
+ react.useEffect(() => {
2758
+ adapterInstanceRef.current = adapterInstance;
2759
+ });
2760
+ // Initialize Mapbox map ONCE - wait for container to have dimensions before creating map
2761
+ // Mapbox's load event may not fire if container has zero height at init time
2762
+ react.useEffect(() => {
2763
+ const container = containerRef.current;
2764
+ if (container === null || mapRef.current !== null) {
2765
+ return;
2766
+ }
2767
+ let rafId;
2768
+ let map = null;
2769
+ let attempts = 0;
2770
+ const maxAttempts = 60; // ~1 second at 60fps
2771
+ const initMap = () => {
2772
+ if (mapRef.current !== null) {
2773
+ return;
2774
+ }
2775
+ const el = containerRef.current;
2776
+ if (el === null) {
2777
+ return;
2778
+ }
2779
+ const w = el.clientWidth;
2780
+ const h = el.clientHeight;
2781
+ if ((w === 0 || h === 0) && attempts < maxAttempts) {
2782
+ attempts++;
2783
+ rafId = requestAnimationFrame(initMap);
2784
+ return;
2785
+ }
2786
+ mapboxgl.accessToken = accessToken;
2787
+ const worldviewOption = region !== undefined ? { worldview: region } : {};
2788
+ const baseOptions = {
2789
+ interactive: true,
2790
+ projection: "mercator",
2791
+ ...worldviewOption,
2792
+ ...(reactDeviceDetect.isDesktop
2793
+ ? { dragPan: true, scrollZoom: true }
2794
+ : { dragPan: true, scrollZoom: true, touchZoomRotate: true, touchPitch: true }),
2795
+ };
2796
+ const maxBoundsOption = restrictionBbox !== null ? { maxBounds: bboxToLngLatBounds(restrictionBbox) } : {};
2797
+ const mapOptions = initialViewport?.type === "bounds"
2798
+ ? {
2799
+ container: el,
2800
+ style: MAPBOX_MAP_TYPE_STYLES.roadmap[theme],
2801
+ bounds: bboxToLngLatBounds(initialViewport.bounds),
2802
+ fitBoundsOptions: initialViewport.padding !== undefined || initialViewport.maxZoom !== undefined
2803
+ ? {
2804
+ ...(initialViewport.padding !== undefined && { padding: initialViewport.padding }),
2805
+ ...(initialViewport.maxZoom !== undefined && { maxZoom: initialViewport.maxZoom }),
2806
+ }
2807
+ : undefined,
2808
+ ...baseOptions,
2809
+ ...maxBoundsOption,
2810
+ }
2811
+ : {
2812
+ container: el,
2813
+ style: MAPBOX_MAP_TYPE_STYLES.roadmap[theme],
2814
+ center: initialViewport?.type === "center"
2815
+ ? { lng: initialViewport.center[0], lat: initialViewport.center[1] }
2816
+ : { lng: reactMapAdapterShared.DEFAULT_CENTER.lng, lat: reactMapAdapterShared.DEFAULT_CENTER.lat },
2817
+ zoom: initialViewport?.type === "center" && initialViewport.zoom !== undefined
2818
+ ? initialViewport.zoom
2819
+ : reactMapAdapterShared.DEFAULT_ZOOM,
2820
+ ...baseOptions,
2821
+ ...maxBoundsOption,
2822
+ };
2823
+ map = new mapboxgl.Map(mapOptions);
2824
+ map.getCanvas().style.cursor = reactMapAdapterShared.MAP_CURSORS.default;
2825
+ map.getCanvasContainer().style.cursor = reactMapAdapterShared.MAP_CURSORS.default;
2826
+ mapRef.current = map;
2827
+ adapterInstanceRef.current.connect(map);
2828
+ // Only treat errors as init failure until the first `load`. Tile/sprite errors after that
2829
+ // are non-fatal — the map is already interactive (parity with not permanently failing the adapter on transient network issues).
2830
+ let hasLoaded = false;
2831
+ map.once("load", () => {
2832
+ hasLoaded = true;
2833
+ });
2834
+ map.on("error", () => {
2835
+ if (!hasLoaded) {
2836
+ adapterInstanceRef.current.notifyInitializationFailed();
2837
+ }
2838
+ });
2839
+ };
2840
+ rafId = requestAnimationFrame(initMap);
2841
+ return () => {
2842
+ cancelAnimationFrame(rafId);
2843
+ if (mapRef.current !== null) {
2844
+ mapRef.current.remove();
2845
+ mapRef.current = null;
2846
+ }
2847
+ };
2848
+ // Intentionally empty deps: this effect runs once on mount to create the map.
2849
+ // accessToken, theme, initialViewport, restrictionBbox are used for initialization only.
2850
+ // They come from adapterInstance.getConfig(), which is immutable by design (set at adapter
2851
+ // construction). Theme changes are handled by the adapter via map.setStyle(), not by
2852
+ // recreating the map. Adding these to deps would require disconnect/reconnect support in
2853
+ // the adapter for a code path that never executes in practice. See MapboxRenderer docs.
2854
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2855
+ }, []);
2856
+ return (jsxRuntime.jsxs("div", { className: className, style: { width: "100%", height: "100%", position: "relative", ...style }, children: [jsxRuntime.jsx("div", { ref: containerRef, style: {
2857
+ width: "100%",
2858
+ height: "100%",
2859
+ minHeight: 200,
2860
+ } }), mapUsable ? jsxRuntime.jsx("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: children }) : null] }));
2861
+ };
2862
+ /**
2863
+ * Mapbox Renderer component — mounts GL JS and connects {@link MapboxAdapterInstance}.
2864
+ * Full-map loading/error UX is owned by `@trackunit/react-map` (`createMapComponent`); this file only
2865
+ * gates layer children on adapter readiness and reports init-time failures via the adapter.
2866
+ */
2867
+ const MapboxRenderer = (props) => {
2868
+ const { adapterInstance } = props;
2869
+ // Get config from adapter instance - MapboxRenderer only receives MapboxAdapterInstance
2870
+ if (!(adapterInstance instanceof MapboxAdapterInstance)) {
2871
+ throw new Error("MapboxRenderer requires MapboxAdapterInstance");
2872
+ }
2873
+ const mapboxInstance = adapterInstance;
2874
+ const config = mapboxInstance.getConfig();
2875
+ const { accessToken, theme = "light", region, initialViewport, restrictBounds } = config;
2876
+ return (jsxRuntime.jsx(MapboxMapInternal, { ...props, accessToken: accessToken, adapterInstance: mapboxInstance, initialViewport: initialViewport, region: region, restrictBounds: restrictBounds, theme: theme }));
2877
+ };
2878
+
2879
+ /** Stable reference — inline objects break `useMap` Map memo equality and recreate the map every render. */
2880
+ const MAPBOX_ADAPTER_SAFE_AREA_INSETS = {
2881
+ top: 0,
2882
+ right: 0,
2883
+ bottom: 30,
2884
+ left: 0,
2885
+ };
2886
+ /**
2887
+ * Mapbox adapter factory
2888
+ *
2889
+ * Creates an adapter configuration for Mapbox GL JS that can be passed to useMap.
2890
+ *
2891
+ * @example
2892
+ * ```tsx
2893
+ * const map = useMap(mapboxAdapter({
2894
+ * accessToken: process.env.MAPBOX_ACCESS_TOKEN,
2895
+ * theme: "dark",
2896
+ * language: "en",
2897
+ * }));
2898
+ *
2899
+ * <map.Map className="h-full w-full">
2900
+ * {children}
2901
+ * </map.Map>
2902
+ * ```
2903
+ */
2904
+ const mapboxAdapter = reactMapAdapterShared.defineAdapter((config) => ({
2905
+ name: "mapbox",
2906
+ config,
2907
+ safeAreaInsets: MAPBOX_ADAPTER_SAFE_AREA_INSETS,
2908
+ createInstance: () => createMapboxInstance(config),
2909
+ Renderer: MapboxRenderer,
2910
+ }));
2911
+
2912
+ exports.MapboxAdapterInstance = MapboxAdapterInstance;
2913
+ exports.MapboxRenderer = MapboxRenderer;
2914
+ exports.mapboxAdapter = mapboxAdapter;