@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/LICENSE.txt +191 -0
- package/README.md +37 -0
- package/index.cjs.js +2914 -0
- package/index.d.ts +1 -0
- package/index.esm.js +2910 -0
- package/package.json +24 -0
- package/src/MapboxAdapterInstance.d.ts +75 -0
- package/src/MapboxLayerPort.d.ts +236 -0
- package/src/MapboxRenderer.d.ts +8 -0
- package/src/constants.d.ts +17 -0
- package/src/index.d.ts +3 -0
- package/src/mapboxAdapter.d.ts +37 -0
- package/src/mapboxTypeConverters.d.ts +26 -0
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;
|