@trackunit/react-map-adapter-google 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 +34 -0
- package/index.cjs.js +3015 -0
- package/index.d.ts +2 -0
- package/index.esm.js +3010 -0
- package/package.json +25 -0
- package/src/GoogleApiProvider.d.ts +29 -0
- package/src/GoogleMapsAdapterInstance.d.ts +84 -0
- package/src/GoogleMapsLayerPort.d.ts +255 -0
- package/src/GoogleMapsRenderer.d.ts +17 -0
- package/src/constants.d.ts +8 -0
- package/src/google-maps.d.ts +11 -0
- package/src/googleMapsAdapter.d.ts +39 -0
- package/src/googleTypeConverters.d.ts +22 -0
- package/src/index.d.ts +4 -0
package/index.esm.js
ADDED
|
@@ -0,0 +1,3010 @@
|
|
|
1
|
+
import { jsx } from 'react/jsx-runtime';
|
|
2
|
+
import { APIProvider, APIProviderContext, useMap, useApiLoadingStatus, APILoadingStatus, Map as Map$1 } from '@vis.gl/react-google-maps';
|
|
3
|
+
import { useMemo, useCallback, useSyncExternalStore, useId, useContext, useEffect } from 'react';
|
|
4
|
+
import { canPatchAdaptiveMarker, canPatchAdaptiveViewport, canPatchMarkerInPlace, extractPointCoordinates, mergeAntimeridianFeatures, extractPolygonPaths, extractLineCoordinates, patchPortalDescriptors, collectFeatureIdSet, removeGoneIndexedMarkers, getAdaptiveDomFeatureIds, extractSourceData, buildAdaptiveDomRenderFn, resolveCircleSymbolDefaults, resolveSymbolDescriptor, buildAdaptiveSymbolStyleFn, createSymbolDotElement, anchorFromBottomCenter, createDefaultClusterElement, createClusterPinElement, fadeInElement, buildAdaptiveDomEntries, MAP_CURSORS, resolveSelectedStyle, resolveHoveredStyle, colorWithOpacity, attachSafeAreaHoverListeners, INITIAL_MAP_STATE, DEFAULT_MAP_APPEARANCE, computeInitialState, getEffectiveRestrictBounds, cameraStateEquals, isEventOfType, DEFAULT_ZOOM, DEFAULT_CENTER, defineAdapter } from '@trackunit/react-map-adapter-shared';
|
|
5
|
+
import { validateBbox, validatePosition } from '@trackunit/geo-json-utils';
|
|
6
|
+
import { MarkerClusterer, SuperClusterAlgorithm } from '@googlemaps/markerclusterer';
|
|
7
|
+
import { isEqual } from 'es-toolkit';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { isDesktop } from 'react-device-detect';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shared Google Maps API context for multi-map layouts.
|
|
13
|
+
*
|
|
14
|
+
* Wrap multiple `<Map>` instances in a single `GoogleApiProvider` to load the
|
|
15
|
+
* Google Maps JS API once and prevent zombie `google.maps.Map` instances that
|
|
16
|
+
* occur when multiple `APIProvider`s initialize concurrently with `mapId`
|
|
17
|
+
* (vector/cloud-styled maps).
|
|
18
|
+
*
|
|
19
|
+
* Each map should have a unique `mapInstanceId` in its adapter config so the
|
|
20
|
+
* vis.gl registry can distinguish them.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <GoogleApiProvider apiKey={API_KEY}>
|
|
25
|
+
* <Map1 /> // googleMapsAdapter({ apiKey, mapInstanceId: "map-1" })
|
|
26
|
+
* <Map2 /> // googleMapsAdapter({ apiKey, mapInstanceId: "map-2" })
|
|
27
|
+
* </GoogleApiProvider>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
const GoogleApiProvider = ({ apiKey, language, children }) => {
|
|
31
|
+
const libraries = useMemo(() => ["marker"], []);
|
|
32
|
+
return (jsx(APIProvider, { apiKey: apiKey, language: language, libraries: libraries, children: children }));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Zod schema for Google Maps LatLngLiteral
|
|
37
|
+
* A plain object with lat and lng number properties
|
|
38
|
+
*/
|
|
39
|
+
const latLngLiteralSchema = z.object({
|
|
40
|
+
lat: z.number(),
|
|
41
|
+
lng: z.number(),
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* Zod schema for Google Maps LatLng class instance
|
|
45
|
+
* An object with lat() and lng() methods that return numbers
|
|
46
|
+
*/
|
|
47
|
+
const latLngClassSchema = z.object({
|
|
48
|
+
lat: z.function().returns(z.number()),
|
|
49
|
+
lng: z.function().returns(z.number()),
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Convert GeoJSON Position [lng, lat] to Google Maps LatLngLiteral
|
|
53
|
+
*/
|
|
54
|
+
const positionToLatLng = (position) => ({
|
|
55
|
+
lng: position[0],
|
|
56
|
+
lat: position[1],
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* Convert Google Maps LatLng to GeoJSON Position [lng, lat]
|
|
60
|
+
* Uses Zod to distinguish between LatLngLiteral (plain object) and LatLng (class instance)
|
|
61
|
+
*/
|
|
62
|
+
const latLngToPosition = (latLng) => {
|
|
63
|
+
const literalResult = latLngLiteralSchema.safeParse(latLng);
|
|
64
|
+
if (literalResult.success) {
|
|
65
|
+
// It's a LatLngLiteral - lat and lng are numbers
|
|
66
|
+
return [literalResult.data.lng, literalResult.data.lat];
|
|
67
|
+
}
|
|
68
|
+
const classResult = latLngClassSchema.safeParse(latLng);
|
|
69
|
+
if (classResult.success) {
|
|
70
|
+
// It's a LatLng class instance - lat and lng are methods
|
|
71
|
+
return [classResult.data.lng(), classResult.data.lat()];
|
|
72
|
+
}
|
|
73
|
+
// This should never happen if the input type is correct
|
|
74
|
+
throw new Error("Invalid LatLng value: expected either LatLngLiteral or LatLng class instance");
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Convert GeoJSON Bbox [minLon, minLat, maxLon, maxLat] to Google Maps LatLngBoundsLiteral
|
|
78
|
+
*/
|
|
79
|
+
const bboxToLatLngBounds = (bbox) => ({
|
|
80
|
+
west: bbox[0],
|
|
81
|
+
south: bbox[1],
|
|
82
|
+
east: bbox[2],
|
|
83
|
+
north: bbox[3],
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* Convert Google Maps LatLngBounds to GeoJSON Bbox [minLon, minLat, maxLon, maxLat]
|
|
87
|
+
*/
|
|
88
|
+
const latLngBoundsToBbox = (bounds) => {
|
|
89
|
+
const sw = bounds.getSouthWest();
|
|
90
|
+
const ne = bounds.getNorthEast();
|
|
91
|
+
return [sw.lng(), sw.lat(), ne.lng(), ne.lat()];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Google-specific helpers
|
|
96
|
+
// ============================================================================
|
|
97
|
+
const collectFeatureIdsFromCluster = (cluster) => {
|
|
98
|
+
const ids = [];
|
|
99
|
+
for (const m of cluster.markers) {
|
|
100
|
+
if ("dataset" in m) {
|
|
101
|
+
const featureId = m.dataset.featureId;
|
|
102
|
+
if (featureId !== undefined) {
|
|
103
|
+
ids.push(featureId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return ids;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Convert a GeoJSON bbox [minLng, minLat, maxLng, maxLat] to Google Maps LatLngBoundsLiteral.
|
|
111
|
+
*/
|
|
112
|
+
const bboxToGoogleBounds = (bbox) => ({
|
|
113
|
+
west: bbox[0],
|
|
114
|
+
south: bbox[1],
|
|
115
|
+
east: bbox[2],
|
|
116
|
+
north: bbox[3],
|
|
117
|
+
});
|
|
118
|
+
const CANVAS_VIEWPORT_PADDING = 50;
|
|
119
|
+
/** Outline polylines for tiled polygons must always render above any fill polygon,
|
|
120
|
+
* including hover-promoted fills (max fill z-index is HOVER_PROMOTION_Z_INDEX = 10_000
|
|
121
|
+
* in useShapeFillTiling). A value of 1_000_000 keeps strokes visually complete. */
|
|
122
|
+
const TILED_OUTLINE_Z_INDEX = 1000000;
|
|
123
|
+
// AdvancedMarkerElement zIndex for DOM-promoted adaptive markers. Must be high
|
|
124
|
+
// enough to render above the canvas overlay (which has no explicit zIndex).
|
|
125
|
+
const DOM_MARKER_Z_INDEX = 1000;
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// GoogleMapsLayerPort
|
|
128
|
+
// ============================================================================
|
|
129
|
+
/**
|
|
130
|
+
* Google Maps implementation of the LayerPort interface.
|
|
131
|
+
*
|
|
132
|
+
* Manages Google Maps objects to render map content.
|
|
133
|
+
* Each source type maps to appropriate Google Maps constructs:
|
|
134
|
+
* - Markers: Canvas OverlayView (symbol/adaptive mode), AdvancedMarkerElement (DOM mode)
|
|
135
|
+
* - Shapes: google.maps.Polygon / google.maps.Polyline
|
|
136
|
+
* - Routes: google.maps.Polyline with optional arrow symbols
|
|
137
|
+
* - Image overlays: google.maps.GroundOverlay
|
|
138
|
+
*
|
|
139
|
+
* Requires `connect(map)` to be called (by GoogleMapsAdapterInstance) before
|
|
140
|
+
* any sources can be rendered. Sources set before connect are queued.
|
|
141
|
+
*/
|
|
142
|
+
class GoogleMapsLayerPort {
|
|
143
|
+
constructor() {
|
|
144
|
+
/**
|
|
145
|
+
* Marker portal subscription compatible with `useSyncExternalStore`.
|
|
146
|
+
* `<Layers>` routes descriptors from this store to `markerRender`. Includes
|
|
147
|
+
* static DOM markers and adaptive markers resolved to the DOM medium.
|
|
148
|
+
*/
|
|
149
|
+
this.markerPortals = {
|
|
150
|
+
subscribe: (callback) => {
|
|
151
|
+
this.markerPortalSubscribers.add(callback);
|
|
152
|
+
return () => {
|
|
153
|
+
this.markerPortalSubscribers.delete(callback);
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
getSnapshot: () => {
|
|
157
|
+
return this.markerPortalDescriptors;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Cluster portal subscription compatible with `useSyncExternalStore`.
|
|
162
|
+
* `<Layers>` routes descriptors from this store to `clusterRender`. Includes
|
|
163
|
+
* server-cluster DOM containers and any other cluster-class portals.
|
|
164
|
+
*/
|
|
165
|
+
this.clusterPortals = {
|
|
166
|
+
subscribe: (callback) => {
|
|
167
|
+
this.clusterPortalSubscribers.add(callback);
|
|
168
|
+
return () => {
|
|
169
|
+
this.clusterPortalSubscribers.delete(callback);
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
getSnapshot: () => {
|
|
173
|
+
return this.clusterPortalDescriptors;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
this.map = null;
|
|
177
|
+
this.interactionHandlers = new Set();
|
|
178
|
+
this.backgroundClickHandlers = new Set();
|
|
179
|
+
this.entityClickedInTick = false;
|
|
180
|
+
this.mapClickListener = null;
|
|
181
|
+
// Tracked sources, keyed by source ID
|
|
182
|
+
this.markerSources = new Map();
|
|
183
|
+
/** featureId → element, keyed by sourceId. Cleared when a source is removed. */
|
|
184
|
+
this.adaptiveMarkerIndex = new Map();
|
|
185
|
+
/** cluster featureId → element, keyed by sourceId. Cleared when a source is removed. */
|
|
186
|
+
this.clusterMarkerIndex = new Map();
|
|
187
|
+
this.shapeSources = new Map();
|
|
188
|
+
this.routeSources = new Map();
|
|
189
|
+
this.overlaySources = new Map();
|
|
190
|
+
// Queue for snapshot set before the map is connected
|
|
191
|
+
this.pendingSnapshot = null;
|
|
192
|
+
// Last non-empty snapshot the consumer pushed. Preserved across `destroy()` so the React 18
|
|
193
|
+
// StrictMode dev cycle (cleanup pushes EMPTY → adapter destroy → remount → `useWatch` retains
|
|
194
|
+
// its prev value and does not re-fire) can replay the actual layer state on the next `connect`.
|
|
195
|
+
this.lastNonEmptySnapshot = null;
|
|
196
|
+
// ---- DOM portal registry ----
|
|
197
|
+
// Two typed stores so `<Layers>` derives render-config routing
|
|
198
|
+
// (markerRender vs clusterRender) structurally from store membership.
|
|
199
|
+
// Subscribers (the <Layers> component) are notified when the list changes.
|
|
200
|
+
this.markerPortalDescriptors = [];
|
|
201
|
+
this.clusterPortalDescriptors = [];
|
|
202
|
+
this.markerPortalSubscribers = new Set();
|
|
203
|
+
this.clusterPortalSubscribers = new Set();
|
|
204
|
+
this.clusterEntitiesByMarker = new WeakMap();
|
|
205
|
+
// ---- Adaptive marker pointermove watcher (cross-marker) ----
|
|
206
|
+
// Each adaptive DOM marker may install a pointermove watcher on document to detect
|
|
207
|
+
// genuine departure after a spurious mouseleave. These are per-marker closures, so
|
|
208
|
+
// moving from marker A to marker B would leave A's watcher alive — it fires hover-end
|
|
209
|
+
// and clears B's hover state. This class-level reference holds the cancel for whichever
|
|
210
|
+
// watcher is currently active, so any mouseenter on any adaptive marker can cancel it.
|
|
211
|
+
this.markerHoverState = { activeWatcherCancel: null, activeGuardCancel: null };
|
|
212
|
+
// ---- Shape interaction tracking ----
|
|
213
|
+
this.selectedShapeHandleId = null;
|
|
214
|
+
this.selectedShapeFeatureId = null;
|
|
215
|
+
this.hoveredShapeHandleId = null;
|
|
216
|
+
this.hoveredShapeFeatureId = null;
|
|
217
|
+
this.theme = "light";
|
|
218
|
+
// ---- Shape dblclick via DOM ----
|
|
219
|
+
// Google Maps overlays suppress both the second click AND dblclick events
|
|
220
|
+
// when disableDoubleClickZoom is true, and without it, native zoom moves
|
|
221
|
+
// the shape away before the second click. So we: (1) suppress native zoom
|
|
222
|
+
// on mousedown, and (2) catch the browser's own dblclick on the map
|
|
223
|
+
// container, matching it to the most recent shape click.
|
|
224
|
+
this.lastShapeClickTime = 0;
|
|
225
|
+
this.lastShapeClickEntity = null;
|
|
226
|
+
/** Forwards geographic pointer position to the adapter (fill tiling settle channel). */
|
|
227
|
+
this.pointerMoveReporter = null;
|
|
228
|
+
this.holdNativeZoomTimer = null;
|
|
229
|
+
this.mapDblClickListenerCleanup = null;
|
|
230
|
+
// Consumer-injected shape style defaults (set via setShapeStyleDefaults before first snapshot)
|
|
231
|
+
this._shapeStyleDefaults = undefined;
|
|
232
|
+
}
|
|
233
|
+
get shapeStyleDefaults() {
|
|
234
|
+
if (this._shapeStyleDefaults === undefined) {
|
|
235
|
+
throw new Error("shapeStyleDefaults not injected — call setShapeStyleDefaults before using GoogleMapsLayerPort");
|
|
236
|
+
}
|
|
237
|
+
return this._shapeStyleDefaults;
|
|
238
|
+
}
|
|
239
|
+
// ---- Connection ----
|
|
240
|
+
setShapeStyleDefaults(defaults) {
|
|
241
|
+
this._shapeStyleDefaults = defaults;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Connect to a Google Maps instance.
|
|
245
|
+
* Called by GoogleMapsAdapterInstance.connect() when the map is ready.
|
|
246
|
+
* Flushes any sources that were set before the map was available.
|
|
247
|
+
*/
|
|
248
|
+
connect(map) {
|
|
249
|
+
// If reconnecting to a new map instance (e.g. after theme change),
|
|
250
|
+
// re-render all existing sources on the new map
|
|
251
|
+
const isReconnection = this.map !== null && this.map !== map;
|
|
252
|
+
this.map = map;
|
|
253
|
+
this.setupMapContainerDblClickListener(map);
|
|
254
|
+
this.setupMapClickListener(map);
|
|
255
|
+
if (isReconnection) {
|
|
256
|
+
this.reconnectAllSources();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Flush queued snapshot
|
|
260
|
+
if (this.pendingSnapshot !== null) {
|
|
261
|
+
this.applySnapshot(this.pendingSnapshot);
|
|
262
|
+
this.pendingSnapshot = null;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Revival path: useLayerHandleSync's cleanup pushed EMPTY before destroy (React 18 StrictMode
|
|
266
|
+
// dev replay), and useWatch's persisted prev value won't re-fire on remount. Re-apply the
|
|
267
|
+
// last non-empty snapshot so markers/shapes return after the adapter is connected to a fresh map.
|
|
268
|
+
if (this.lastNonEmptySnapshot !== null) {
|
|
269
|
+
this.applySnapshot(this.lastNonEmptySnapshot);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ---- Declarative state ----
|
|
273
|
+
setSnapshot(snapshot) {
|
|
274
|
+
if (snapshot.layers.length > 0) {
|
|
275
|
+
this.lastNonEmptySnapshot = snapshot;
|
|
276
|
+
}
|
|
277
|
+
if (this.map === null) {
|
|
278
|
+
this.pendingSnapshot = snapshot;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this.applySnapshot(snapshot);
|
|
282
|
+
}
|
|
283
|
+
// ---- Pointer position (fill tiling) ----
|
|
284
|
+
/**
|
|
285
|
+
* Register a reporter for geographic pointer position. Google Maps does not
|
|
286
|
+
* emit map-level mousemove while the cursor is over clickable shape overlays;
|
|
287
|
+
* shape listeners call this so fill-tiling settle gets the correct position.
|
|
288
|
+
*/
|
|
289
|
+
setPointerMoveReporter(reporter) {
|
|
290
|
+
this.pointerMoveReporter = reporter;
|
|
291
|
+
}
|
|
292
|
+
// ---- Theme ----
|
|
293
|
+
/**
|
|
294
|
+
* Update the theme used for interaction style resolution (hover color shifts).
|
|
295
|
+
* Called by the adapter instance when the map theme changes.
|
|
296
|
+
*/
|
|
297
|
+
setTheme(theme) {
|
|
298
|
+
if (this.theme === theme)
|
|
299
|
+
return;
|
|
300
|
+
this.theme = theme;
|
|
301
|
+
if (this.hoveredShapeHandleId !== null) {
|
|
302
|
+
this.applyGoogleShapeInteraction(this.hoveredShapeHandleId);
|
|
303
|
+
}
|
|
304
|
+
if (this.selectedShapeHandleId !== null && this.selectedShapeHandleId !== this.hoveredShapeHandleId) {
|
|
305
|
+
this.applyGoogleShapeInteraction(this.selectedShapeHandleId);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// ---- Entity interaction ----
|
|
309
|
+
onEntityInteraction(handler) {
|
|
310
|
+
this.interactionHandlers.add(handler);
|
|
311
|
+
return () => {
|
|
312
|
+
this.interactionHandlers.delete(handler);
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
onBackgroundClick(handler) {
|
|
316
|
+
this.backgroundClickHandlers.add(handler);
|
|
317
|
+
return () => {
|
|
318
|
+
this.backgroundClickHandlers.delete(handler);
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// ---- Source readiness ----
|
|
322
|
+
onSourceReady(_sourceId, callback) {
|
|
323
|
+
let cancelled = false;
|
|
324
|
+
queueMicrotask(() => {
|
|
325
|
+
if (!cancelled)
|
|
326
|
+
callback();
|
|
327
|
+
});
|
|
328
|
+
return () => {
|
|
329
|
+
cancelled = true;
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// ---- Lifecycle ----
|
|
333
|
+
/** @internal */
|
|
334
|
+
destroy() {
|
|
335
|
+
this.markerHoverState.activeWatcherCancel?.();
|
|
336
|
+
this.markerHoverState.activeWatcherCancel = null;
|
|
337
|
+
this.markerHoverState.activeGuardCancel?.();
|
|
338
|
+
this.markerHoverState.activeGuardCancel = null;
|
|
339
|
+
// Remove all sources
|
|
340
|
+
for (const id of [...this.markerSources.keys()]) {
|
|
341
|
+
this.removeMarkerSourceInternal(id);
|
|
342
|
+
}
|
|
343
|
+
for (const id of [...this.shapeSources.keys()]) {
|
|
344
|
+
this.removeShapeSourceInternal(id);
|
|
345
|
+
}
|
|
346
|
+
for (const id of [...this.routeSources.keys()]) {
|
|
347
|
+
this.removeRouteSourceInternal(id);
|
|
348
|
+
}
|
|
349
|
+
for (const id of [...this.overlaySources.keys()]) {
|
|
350
|
+
this.removeImageOverlayInternal(id);
|
|
351
|
+
}
|
|
352
|
+
this.interactionHandlers.clear();
|
|
353
|
+
this.backgroundClickHandlers.clear();
|
|
354
|
+
this.markerPortalDescriptors = [];
|
|
355
|
+
this.clusterPortalDescriptors = [];
|
|
356
|
+
this.markerPortalSubscribers.clear();
|
|
357
|
+
this.clusterPortalSubscribers.clear();
|
|
358
|
+
this.pendingSnapshot = null;
|
|
359
|
+
this.selectedShapeHandleId = null;
|
|
360
|
+
this.selectedShapeFeatureId = null;
|
|
361
|
+
this.hoveredShapeHandleId = null;
|
|
362
|
+
this.hoveredShapeFeatureId = null;
|
|
363
|
+
this.mapDblClickListenerCleanup?.();
|
|
364
|
+
this.mapDblClickListenerCleanup = null;
|
|
365
|
+
if (this.mapClickListener !== null) {
|
|
366
|
+
google.maps.event.removeListener(this.mapClickListener);
|
|
367
|
+
this.mapClickListener = null;
|
|
368
|
+
}
|
|
369
|
+
this.map = null;
|
|
370
|
+
}
|
|
371
|
+
applySnapshot(snapshot) {
|
|
372
|
+
const markerIds = new Set(snapshot.layers.filter(l => l.layerType === "markers").map(l => l.id));
|
|
373
|
+
const shapeIds = new Set(snapshot.layers.filter(l => l.layerType === "shapes").map(l => l.id));
|
|
374
|
+
const routeIds = new Set(snapshot.layers.filter(l => l.layerType === "route").map(l => l.id));
|
|
375
|
+
const overlayIds = new Set(snapshot.layers.filter(l => l.layerType === "image-overlay").map(l => l.id));
|
|
376
|
+
for (const layer of snapshot.layers) {
|
|
377
|
+
switch (layer.layerType) {
|
|
378
|
+
case "markers":
|
|
379
|
+
this.syncMarkerSource(layer);
|
|
380
|
+
break;
|
|
381
|
+
case "shapes":
|
|
382
|
+
this.syncShapeSource(layer);
|
|
383
|
+
break;
|
|
384
|
+
case "route":
|
|
385
|
+
this.syncRouteSource(layer);
|
|
386
|
+
break;
|
|
387
|
+
case "image-overlay":
|
|
388
|
+
this.syncImageOverlay(layer);
|
|
389
|
+
break;
|
|
390
|
+
default: {
|
|
391
|
+
const exhaustiveCheck = layer;
|
|
392
|
+
throw new Error(`Unknown layer type: ${String(exhaustiveCheck)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (const id of [...this.markerSources.keys()]) {
|
|
397
|
+
if (!markerIds.has(id))
|
|
398
|
+
this.removeMarkerSourceInternal(id);
|
|
399
|
+
}
|
|
400
|
+
for (const id of [...this.shapeSources.keys()]) {
|
|
401
|
+
if (!shapeIds.has(id))
|
|
402
|
+
this.removeShapeSourceInternal(id);
|
|
403
|
+
}
|
|
404
|
+
for (const id of [...this.routeSources.keys()]) {
|
|
405
|
+
if (!routeIds.has(id))
|
|
406
|
+
this.removeRouteSourceInternal(id);
|
|
407
|
+
}
|
|
408
|
+
for (const id of [...this.overlaySources.keys()]) {
|
|
409
|
+
if (!overlayIds.has(id))
|
|
410
|
+
this.removeImageOverlayInternal(id);
|
|
411
|
+
}
|
|
412
|
+
this.applyShapeSelectionState(snapshot.shapeSelection.handleId, snapshot.shapeSelection.featureId);
|
|
413
|
+
this.applyShapeHoverState(snapshot.shapeHover.handleId, snapshot.shapeHover.featureId);
|
|
414
|
+
}
|
|
415
|
+
// ---- Marker sources ----
|
|
416
|
+
syncMarkerSource(config) {
|
|
417
|
+
if (this.map === null)
|
|
418
|
+
return;
|
|
419
|
+
// Skip full rebuild if config reference hasn't changed (same data, nothing to do)
|
|
420
|
+
const existing = this.markerSources.get(config.id);
|
|
421
|
+
if (existing !== undefined && existing.config === config)
|
|
422
|
+
return;
|
|
423
|
+
// Adaptive mode: incremental patch when only adaptiveResolution (or callbacks) changed
|
|
424
|
+
// but the feature set and structural config are the same. Avoids the
|
|
425
|
+
// remove-then-add cycle (and its cascading mouseleave → hover-clear → rebuild loop).
|
|
426
|
+
if (existing !== undefined && canPatchAdaptiveMarker(existing.config, config)) {
|
|
427
|
+
this.patchAdaptiveMarkers(existing, config);
|
|
428
|
+
existing.config = config;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// Adaptive mode: viewport refetch changed which markers/clusters are visible but
|
|
432
|
+
// structural config is unchanged. Diff by feature id instead of tearing everything down.
|
|
433
|
+
if (existing !== undefined && canPatchAdaptiveViewport(existing.config, config)) {
|
|
434
|
+
this.patchAdaptiveViewportData(existing, config);
|
|
435
|
+
existing.config = config;
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// Enhanced bail-out: skip full rebuild when only callback references changed
|
|
439
|
+
// but the actual marker data (feature IDs, render mode, anchor, clustering) is the same.
|
|
440
|
+
// This prevents visible blinking caused by unstable function references
|
|
441
|
+
// (e.g. inline style/render callbacks recreated on React re-renders).
|
|
442
|
+
if (existing !== undefined && canPatchMarkerInPlace(existing.config, config)) {
|
|
443
|
+
if (config.markerRender.mode === "dom") {
|
|
444
|
+
this.patchDomMarkers(existing, config);
|
|
445
|
+
}
|
|
446
|
+
const renderChanged = existing.config.markerRender.render !== config.markerRender.render;
|
|
447
|
+
existing.config = config;
|
|
448
|
+
if (config.markerRender.mode === "symbol" && renderChanged) {
|
|
449
|
+
this.updateSymbolCanvasMarkers(config, existing);
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// Remove existing markers for this source (update case)
|
|
454
|
+
this.removeMarkerSourceInternal(config.id);
|
|
455
|
+
const tracked = { markers: [], listeners: [], config };
|
|
456
|
+
const currentMap = this.map;
|
|
457
|
+
// ---- Client-side clustering path (interactive markers only) ----
|
|
458
|
+
if (config.kind === "marker" && config.clusterConfig !== null && config.clusterConfig.mode === "client") {
|
|
459
|
+
if (config.markerRender.mode === "adaptive") {
|
|
460
|
+
throw new Error("react-map: adaptive marker rendering is not supported with client-side clustering");
|
|
461
|
+
}
|
|
462
|
+
this.setupClientClustering(config, currentMap, tracked);
|
|
463
|
+
this.markerSources.set(config.id, tracked);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// ---- Standard (no clustering / server-side clustering) path ----
|
|
467
|
+
const { markerRender } = config;
|
|
468
|
+
const useCanvasOverlay = markerRender.mode === "symbol" || markerRender.mode === "adaptive";
|
|
469
|
+
if (useCanvasOverlay) {
|
|
470
|
+
this.setupCanvasSymbolOverlay(config, currentMap, tracked);
|
|
471
|
+
if (markerRender.mode === "adaptive") {
|
|
472
|
+
this.createAdaptiveDomOverlays(config, currentMap, tracked);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
// DOM marker path (mode === "dom")
|
|
477
|
+
for (const feature of config.features.features) {
|
|
478
|
+
const coords = extractPointCoordinates(feature);
|
|
479
|
+
if (coords === null)
|
|
480
|
+
continue;
|
|
481
|
+
const marker = this.createMarkerForFeature(feature, coords, config, currentMap);
|
|
482
|
+
tracked.markers.push(marker);
|
|
483
|
+
if (config.kind === "marker") {
|
|
484
|
+
const featureId = feature.id !== undefined ? String(feature.id) : undefined;
|
|
485
|
+
if (featureId !== undefined) {
|
|
486
|
+
this.attachMarkerInteractionListeners(marker, featureId, coords, tracked);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
else if (marker.content instanceof HTMLElement) {
|
|
490
|
+
marker.content.style.pointerEvents = "none";
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// Render server-side cluster features if present (interactive markers only)
|
|
495
|
+
if (config.kind === "marker" && config.clusterFeatures !== null) {
|
|
496
|
+
for (const feature of config.clusterFeatures.features) {
|
|
497
|
+
const coords = extractPointCoordinates(feature);
|
|
498
|
+
if (coords === null)
|
|
499
|
+
continue;
|
|
500
|
+
const marker = this.createClusterMarkerForFeature(feature, coords, config, currentMap);
|
|
501
|
+
tracked.markers.push(marker);
|
|
502
|
+
// Set up interaction listeners for cluster markers
|
|
503
|
+
const featureId = feature.id !== undefined ? String(feature.id) : undefined;
|
|
504
|
+
if (featureId !== undefined) {
|
|
505
|
+
const props = feature.properties;
|
|
506
|
+
const rawMarkerIds = props !== null ? props.markerIds : undefined;
|
|
507
|
+
const markerIds = Array.isArray(rawMarkerIds) ? rawMarkerIds : [];
|
|
508
|
+
const clusterBbox = props && props.bbox !== null ? validateBbox(props.bbox) : null;
|
|
509
|
+
this.attachClusterInteractionListeners(marker, featureId, { lng: coords.lng, lat: coords.lat }, markerIds, clusterBbox, tracked);
|
|
510
|
+
let clusterIndex = this.clusterMarkerIndex.get(config.id);
|
|
511
|
+
if (clusterIndex === undefined) {
|
|
512
|
+
clusterIndex = new Map();
|
|
513
|
+
this.clusterMarkerIndex.set(config.id, clusterIndex);
|
|
514
|
+
}
|
|
515
|
+
clusterIndex.set(featureId, marker);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
this.markerSources.set(config.id, tracked);
|
|
520
|
+
}
|
|
521
|
+
removeMarkerSourceInternal(id) {
|
|
522
|
+
const tracked = this.markerSources.get(id);
|
|
523
|
+
if (tracked === undefined)
|
|
524
|
+
return;
|
|
525
|
+
// Clean up MarkerClusterer if present (client-side clustering)
|
|
526
|
+
if (tracked.clusterer !== undefined) {
|
|
527
|
+
tracked.clusterer.setMap(null);
|
|
528
|
+
tracked.clusterer.clearMarkers();
|
|
529
|
+
}
|
|
530
|
+
// Clean up canvas overlay if present (symbol/adaptive rendering)
|
|
531
|
+
if (tracked.canvasOverlay !== undefined) {
|
|
532
|
+
tracked.canvasOverlay.setMap(null);
|
|
533
|
+
}
|
|
534
|
+
for (const listener of tracked.listeners) {
|
|
535
|
+
google.maps.event.removeListener(listener);
|
|
536
|
+
}
|
|
537
|
+
for (const marker of tracked.markers) {
|
|
538
|
+
marker.map = null;
|
|
539
|
+
}
|
|
540
|
+
// Clean up DOM portal descriptors for this source
|
|
541
|
+
this.removePortalDescriptorsForSource(id);
|
|
542
|
+
this.markerSources.delete(id);
|
|
543
|
+
this.adaptiveMarkerIndex.delete(id);
|
|
544
|
+
this.clusterMarkerIndex.delete(id);
|
|
545
|
+
}
|
|
546
|
+
// ---- Shape sources ----
|
|
547
|
+
syncShapeSource(config) {
|
|
548
|
+
if (this.map === null)
|
|
549
|
+
return;
|
|
550
|
+
const existingShape = this.shapeSources.get(config.id);
|
|
551
|
+
if (existingShape !== undefined) {
|
|
552
|
+
const prev = existingShape.config;
|
|
553
|
+
if (prev.features === config.features &&
|
|
554
|
+
prev.style === config.style &&
|
|
555
|
+
prev.featureStyles === config.featureStyles &&
|
|
556
|
+
prev.featureFillGeometries === config.featureFillGeometries &&
|
|
557
|
+
prev.featureZIndexOverrides === config.featureZIndexOverrides &&
|
|
558
|
+
prev.interactive === config.interactive) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const coreUnchanged = prev.features === config.features &&
|
|
562
|
+
prev.style === config.style &&
|
|
563
|
+
prev.featureStyles === config.featureStyles &&
|
|
564
|
+
prev.interactive === config.interactive;
|
|
565
|
+
// Path B promotion: only clipped-fill / z-index overrides changed — patch
|
|
566
|
+
// affected features in place instead of rebuilding the entire source (~180 sites).
|
|
567
|
+
if (coreUnchanged &&
|
|
568
|
+
(prev.featureFillGeometries !== config.featureFillGeometries ||
|
|
569
|
+
prev.featureZIndexOverrides !== config.featureZIndexOverrides)) {
|
|
570
|
+
this.syncShapeTilingOverrides(existingShape, prev, config);
|
|
571
|
+
existingShape.config = config;
|
|
572
|
+
if ((this.selectedShapeHandleId === config.id && this.selectedShapeFeatureId !== null) ||
|
|
573
|
+
(this.hoveredShapeHandleId === config.id && this.hoveredShapeFeatureId !== null)) {
|
|
574
|
+
this.applyGoogleShapeInteraction(config.id);
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// Remove existing shapes for this source (update case)
|
|
580
|
+
this.removeShapeSourceInternal(config.id);
|
|
581
|
+
const tracked = {
|
|
582
|
+
shapes: [],
|
|
583
|
+
hitPolylines: [],
|
|
584
|
+
pointMarkers: [],
|
|
585
|
+
listeners: [],
|
|
586
|
+
shapeListeners: new WeakMap(),
|
|
587
|
+
config,
|
|
588
|
+
featureIdToShapes: new Map(),
|
|
589
|
+
featureIdToPointMarkers: new Map(),
|
|
590
|
+
fillOnlyShapes: new Set(),
|
|
591
|
+
};
|
|
592
|
+
const currentMap = this.map;
|
|
593
|
+
const isStrokeOnly = config.interactive === "stroke";
|
|
594
|
+
const renderFeatures = mergeAntimeridianFeatures(config.features);
|
|
595
|
+
for (const feature of renderFeatures.features) {
|
|
596
|
+
const geometry = feature.geometry;
|
|
597
|
+
if (geometry === null)
|
|
598
|
+
continue;
|
|
599
|
+
const { type } = geometry;
|
|
600
|
+
const fid = feature.id !== undefined ? String(feature.id) : undefined;
|
|
601
|
+
const featureStyle = (fid !== undefined ? config.featureStyles?.get(fid) : undefined) ?? config.style;
|
|
602
|
+
// ADR-0021 fill tiling: when a clipped fill geometry is present, draw the
|
|
603
|
+
// outline from the full geometry and the fill from the clipped geometry.
|
|
604
|
+
const fillOverride = (type === "Polygon" || type === "MultiPolygon") && fid !== undefined
|
|
605
|
+
? config.featureFillGeometries?.get(fid)
|
|
606
|
+
: undefined;
|
|
607
|
+
if (fillOverride !== undefined && fid !== undefined) {
|
|
608
|
+
this.renderTiledPolygonFeature(geometry, fillOverride, featureStyle, fid, isStrokeOnly, config, tracked, currentMap);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (type === "Polygon") {
|
|
612
|
+
const paths = extractPolygonPaths(geometry.coordinates);
|
|
613
|
+
if (paths === null)
|
|
614
|
+
continue;
|
|
615
|
+
const fillVisible = (featureStyle.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity) > 0;
|
|
616
|
+
const polygonZIndex = fid !== undefined ? config.featureZIndexOverrides?.get(fid) : undefined;
|
|
617
|
+
const polygon = this.createPolygon(paths, featureStyle, currentMap, !isStrokeOnly && fillVisible, polygonZIndex);
|
|
618
|
+
tracked.shapes.push(polygon);
|
|
619
|
+
if (fid !== undefined) {
|
|
620
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, polygon);
|
|
621
|
+
}
|
|
622
|
+
if (config.interactive !== "none" && fid !== undefined) {
|
|
623
|
+
if (isStrokeOnly) {
|
|
624
|
+
const hitPls = this.createStrokeHitPolylines(paths, featureStyle.strokeWidth ?? this.shapeStyleDefaults.polygon.strokeWidth, currentMap);
|
|
625
|
+
for (const pl of hitPls) {
|
|
626
|
+
tracked.hitPolylines.push(pl);
|
|
627
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, pl);
|
|
628
|
+
this.attachShapeInteractionListeners(pl, fid, "polygon", tracked, config.id);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
this.attachShapeInteractionListeners(polygon, fid, "polygon", tracked, config.id);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
else if (type === "MultiPolygon") {
|
|
637
|
+
const multiCoords = geometry.coordinates;
|
|
638
|
+
if (!Array.isArray(multiCoords))
|
|
639
|
+
continue;
|
|
640
|
+
for (const polygonCoords of multiCoords) {
|
|
641
|
+
const paths = extractPolygonPaths(polygonCoords);
|
|
642
|
+
if (paths === null)
|
|
643
|
+
continue;
|
|
644
|
+
const fillVisible = (featureStyle.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity) > 0;
|
|
645
|
+
const polygonZIndex = fid !== undefined ? config.featureZIndexOverrides?.get(fid) : undefined;
|
|
646
|
+
const polygon = this.createPolygon(paths, featureStyle, currentMap, !isStrokeOnly && fillVisible, polygonZIndex);
|
|
647
|
+
tracked.shapes.push(polygon);
|
|
648
|
+
if (fid !== undefined) {
|
|
649
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, polygon);
|
|
650
|
+
}
|
|
651
|
+
if (config.interactive !== "none" && fid !== undefined) {
|
|
652
|
+
if (isStrokeOnly) {
|
|
653
|
+
const hitPls = this.createStrokeHitPolylines(paths, featureStyle.strokeWidth ?? this.shapeStyleDefaults.polygon.strokeWidth, currentMap);
|
|
654
|
+
for (const pl of hitPls) {
|
|
655
|
+
tracked.hitPolylines.push(pl);
|
|
656
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, pl);
|
|
657
|
+
this.attachShapeInteractionListeners(pl, fid, "polygon", tracked, config.id);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
this.attachShapeInteractionListeners(polygon, fid, "polygon", tracked, config.id);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
else if (type === "LineString") {
|
|
667
|
+
const path = extractLineCoordinates(feature);
|
|
668
|
+
if (path === null)
|
|
669
|
+
continue;
|
|
670
|
+
const polyline = this.createPolyline(path, featureStyle, currentMap);
|
|
671
|
+
tracked.shapes.push(polyline);
|
|
672
|
+
if (fid !== undefined) {
|
|
673
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, polyline);
|
|
674
|
+
}
|
|
675
|
+
if (config.interactive !== "none" && fid !== undefined) {
|
|
676
|
+
this.attachShapeInteractionListeners(polyline, fid, "line", tracked, config.id);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
else if (type === "MultiLineString") {
|
|
680
|
+
for (const lineCoords of geometry.coordinates) {
|
|
681
|
+
const path = lineCoords.map(([lng, lat]) => ({ lng, lat }));
|
|
682
|
+
if (path.length === 0)
|
|
683
|
+
continue;
|
|
684
|
+
const polyline = this.createPolyline(path, featureStyle, currentMap);
|
|
685
|
+
tracked.shapes.push(polyline);
|
|
686
|
+
if (fid !== undefined) {
|
|
687
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, polyline);
|
|
688
|
+
}
|
|
689
|
+
if (config.interactive !== "none" && fid !== undefined) {
|
|
690
|
+
this.attachShapeInteractionListeners(polyline, fid, "line", tracked, config.id);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else if (type === "Point") {
|
|
695
|
+
const [lng, lat] = geometry.coordinates;
|
|
696
|
+
const marker = this.createShapePointMarker({ lng, lat }, featureStyle, currentMap);
|
|
697
|
+
tracked.pointMarkers.push(marker);
|
|
698
|
+
if (fid !== undefined) {
|
|
699
|
+
this.pushToFeatureMap(tracked.featureIdToPointMarkers, fid, marker);
|
|
700
|
+
}
|
|
701
|
+
if (config.interactive !== "none" && fid !== undefined) {
|
|
702
|
+
this.attachShapePointInteractionListeners(marker, fid, "point", config.id);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else if (type === "MultiPoint") {
|
|
706
|
+
for (const coords of geometry.coordinates) {
|
|
707
|
+
const [lng, lat] = coords;
|
|
708
|
+
const marker = this.createShapePointMarker({ lng, lat }, featureStyle, currentMap);
|
|
709
|
+
tracked.pointMarkers.push(marker);
|
|
710
|
+
if (fid !== undefined) {
|
|
711
|
+
this.pushToFeatureMap(tracked.featureIdToPointMarkers, fid, marker);
|
|
712
|
+
}
|
|
713
|
+
if (config.interactive !== "none" && fid !== undefined) {
|
|
714
|
+
this.attachShapePointInteractionListeners(marker, fid, "point", config.id);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
this.shapeSources.set(config.id, tracked);
|
|
720
|
+
// Re-apply interaction styles if this source is currently hovered or selected
|
|
721
|
+
if ((this.selectedShapeHandleId === config.id && this.selectedShapeFeatureId !== null) ||
|
|
722
|
+
(this.hoveredShapeHandleId === config.id && this.hoveredShapeFeatureId !== null)) {
|
|
723
|
+
this.applyGoogleShapeInteraction(config.id);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** Deep equality for clipped fill overrides — map emissions create new Map refs every time. */
|
|
727
|
+
fillOverrideEqual(prev, next) {
|
|
728
|
+
if (prev === next)
|
|
729
|
+
return true;
|
|
730
|
+
if (prev === undefined || next === undefined)
|
|
731
|
+
return false;
|
|
732
|
+
return isEqual(prev, next);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Patch only features whose fill-override or z-index changed (ADR-0021 Path B).
|
|
736
|
+
* Returns the number of features that were updated or re-rendered.
|
|
737
|
+
*/
|
|
738
|
+
syncShapeTilingOverrides(tracked, prevConfig, config) {
|
|
739
|
+
if (this.map === null)
|
|
740
|
+
return 0;
|
|
741
|
+
const currentMap = this.map;
|
|
742
|
+
const isStrokeOnly = config.interactive === "stroke";
|
|
743
|
+
const renderFeatures = mergeAntimeridianFeatures(config.features);
|
|
744
|
+
let changedCount = 0;
|
|
745
|
+
for (const feature of renderFeatures.features) {
|
|
746
|
+
if (feature.id === undefined)
|
|
747
|
+
continue;
|
|
748
|
+
const fid = String(feature.id);
|
|
749
|
+
const geometry = feature.geometry;
|
|
750
|
+
if (geometry === null)
|
|
751
|
+
continue;
|
|
752
|
+
const { type } = geometry;
|
|
753
|
+
if (type !== "Polygon" && type !== "MultiPolygon")
|
|
754
|
+
continue;
|
|
755
|
+
const prevFill = prevConfig.featureFillGeometries?.get(fid);
|
|
756
|
+
const nextFill = config.featureFillGeometries?.get(fid);
|
|
757
|
+
const prevZ = prevConfig.featureZIndexOverrides?.get(fid);
|
|
758
|
+
const nextZ = config.featureZIndexOverrides?.get(fid);
|
|
759
|
+
const fillChanged = !this.fillOverrideEqual(prevFill, nextFill);
|
|
760
|
+
const zChanged = prevZ !== nextZ;
|
|
761
|
+
if (!fillChanged && !zChanged)
|
|
762
|
+
continue;
|
|
763
|
+
changedCount += 1;
|
|
764
|
+
if (!fillChanged) {
|
|
765
|
+
this.updateFeatureShapeZIndex(tracked, fid, nextZ);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
this.removeFeatureShapes(tracked, fid);
|
|
769
|
+
const featureStyle = config.featureStyles?.get(fid) ?? config.style;
|
|
770
|
+
const fillOverride = config.featureFillGeometries?.get(fid);
|
|
771
|
+
if (fillOverride !== undefined) {
|
|
772
|
+
this.renderTiledPolygonFeature(geometry, fillOverride, featureStyle, fid, isStrokeOnly, config, tracked, currentMap);
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (type === "Polygon") {
|
|
776
|
+
const paths = extractPolygonPaths(geometry.coordinates);
|
|
777
|
+
if (paths === null)
|
|
778
|
+
continue;
|
|
779
|
+
const fillVisible = (featureStyle.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity) > 0;
|
|
780
|
+
const polygon = this.createPolygon(paths, featureStyle, currentMap, !isStrokeOnly && fillVisible, nextZ);
|
|
781
|
+
tracked.shapes.push(polygon);
|
|
782
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, polygon);
|
|
783
|
+
if (config.interactive !== "none") {
|
|
784
|
+
if (isStrokeOnly) {
|
|
785
|
+
const hitPls = this.createStrokeHitPolylines(paths, featureStyle.strokeWidth ?? 1, currentMap);
|
|
786
|
+
for (const pl of hitPls) {
|
|
787
|
+
tracked.hitPolylines.push(pl);
|
|
788
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, pl);
|
|
789
|
+
this.attachShapeInteractionListeners(pl, fid, "polygon", tracked, config.id);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
this.attachShapeInteractionListeners(polygon, fid, "polygon", tracked, config.id);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
const multiCoords = geometry.coordinates;
|
|
799
|
+
if (!Array.isArray(multiCoords))
|
|
800
|
+
continue;
|
|
801
|
+
for (const polygonCoords of multiCoords) {
|
|
802
|
+
const paths = extractPolygonPaths(polygonCoords);
|
|
803
|
+
if (paths === null)
|
|
804
|
+
continue;
|
|
805
|
+
const fillVisible = (featureStyle.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity) > 0;
|
|
806
|
+
const polygon = this.createPolygon(paths, featureStyle, currentMap, !isStrokeOnly && fillVisible, nextZ);
|
|
807
|
+
tracked.shapes.push(polygon);
|
|
808
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, polygon);
|
|
809
|
+
if (config.interactive !== "none") {
|
|
810
|
+
if (isStrokeOnly) {
|
|
811
|
+
const hitPls = this.createStrokeHitPolylines(paths, featureStyle.strokeWidth ?? 1, currentMap);
|
|
812
|
+
for (const pl of hitPls) {
|
|
813
|
+
tracked.hitPolylines.push(pl);
|
|
814
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, pl);
|
|
815
|
+
this.attachShapeInteractionListeners(pl, fid, "polygon", tracked, config.id);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
this.attachShapeInteractionListeners(polygon, fid, "polygon", tracked, config.id);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return changedCount;
|
|
826
|
+
}
|
|
827
|
+
/** Remove all map shapes (and their listeners) for a single feature id. */
|
|
828
|
+
removeFeatureShapes(tracked, featureId) {
|
|
829
|
+
const shapes = tracked.featureIdToShapes.get(featureId);
|
|
830
|
+
if (shapes === undefined)
|
|
831
|
+
return;
|
|
832
|
+
for (const shape of shapes) {
|
|
833
|
+
const listeners = tracked.shapeListeners.get(shape) ?? [];
|
|
834
|
+
const listenersToRemove = new Set(listeners);
|
|
835
|
+
for (const listener of listeners) {
|
|
836
|
+
google.maps.event.removeListener(listener);
|
|
837
|
+
}
|
|
838
|
+
tracked.listeners = tracked.listeners.filter(listener => !listenersToRemove.has(listener));
|
|
839
|
+
tracked.shapeListeners.delete(shape);
|
|
840
|
+
google.maps.event.clearInstanceListeners(shape);
|
|
841
|
+
shape.setMap(null);
|
|
842
|
+
if ("getPaths" in shape) {
|
|
843
|
+
tracked.fillOnlyShapes.delete(shape);
|
|
844
|
+
}
|
|
845
|
+
const shapeIndex = tracked.shapes.indexOf(shape);
|
|
846
|
+
if (shapeIndex >= 0) {
|
|
847
|
+
tracked.shapes.splice(shapeIndex, 1);
|
|
848
|
+
}
|
|
849
|
+
const hitIndex = tracked.hitPolylines.indexOf(shape);
|
|
850
|
+
if (hitIndex >= 0) {
|
|
851
|
+
tracked.hitPolylines.splice(hitIndex, 1);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
tracked.featureIdToShapes.delete(featureId);
|
|
855
|
+
}
|
|
856
|
+
/** Update z-index on fill-bearing polygons for one feature without a geometry rebuild. */
|
|
857
|
+
updateFeatureShapeZIndex(tracked, featureId, zIndex) {
|
|
858
|
+
const shapes = tracked.featureIdToShapes.get(featureId);
|
|
859
|
+
if (shapes === undefined)
|
|
860
|
+
return;
|
|
861
|
+
for (const shape of shapes) {
|
|
862
|
+
if (!("getPaths" in shape))
|
|
863
|
+
continue;
|
|
864
|
+
const isFillOnly = tracked.fillOnlyShapes.has(shape);
|
|
865
|
+
if (isFillOnly || zIndex !== undefined) {
|
|
866
|
+
shape.setOptions(zIndex !== undefined ? { zIndex } : {});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
removeShapeSourceInternal(id) {
|
|
871
|
+
const tracked = this.shapeSources.get(id);
|
|
872
|
+
if (tracked === undefined)
|
|
873
|
+
return;
|
|
874
|
+
for (const listener of tracked.listeners) {
|
|
875
|
+
google.maps.event.removeListener(listener);
|
|
876
|
+
}
|
|
877
|
+
for (const shape of tracked.shapes) {
|
|
878
|
+
shape.setMap(null);
|
|
879
|
+
}
|
|
880
|
+
for (const pl of tracked.hitPolylines) {
|
|
881
|
+
pl.setMap(null);
|
|
882
|
+
}
|
|
883
|
+
for (const marker of tracked.pointMarkers) {
|
|
884
|
+
marker.map = null;
|
|
885
|
+
}
|
|
886
|
+
if (this.selectedShapeHandleId === id) {
|
|
887
|
+
this.selectedShapeHandleId = null;
|
|
888
|
+
this.selectedShapeFeatureId = null;
|
|
889
|
+
}
|
|
890
|
+
if (this.hoveredShapeHandleId === id) {
|
|
891
|
+
this.hoveredShapeHandleId = null;
|
|
892
|
+
this.hoveredShapeFeatureId = null;
|
|
893
|
+
}
|
|
894
|
+
this.shapeSources.delete(id);
|
|
895
|
+
}
|
|
896
|
+
// ---- Shape interaction ----
|
|
897
|
+
applyShapeSelectionState(handleId, featureId) {
|
|
898
|
+
if (this.selectedShapeHandleId === handleId && this.selectedShapeFeatureId === featureId)
|
|
899
|
+
return;
|
|
900
|
+
const prevHandleId = this.selectedShapeHandleId;
|
|
901
|
+
this.selectedShapeHandleId = handleId;
|
|
902
|
+
this.selectedShapeFeatureId = featureId;
|
|
903
|
+
if (prevHandleId !== null) {
|
|
904
|
+
this.applyGoogleShapeInteraction(prevHandleId);
|
|
905
|
+
}
|
|
906
|
+
if (handleId !== null && handleId !== prevHandleId) {
|
|
907
|
+
this.applyGoogleShapeInteraction(handleId);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
applyShapeHoverState(handleId, featureId) {
|
|
911
|
+
if (this.hoveredShapeHandleId === handleId && this.hoveredShapeFeatureId === featureId)
|
|
912
|
+
return;
|
|
913
|
+
const prevHandleId = this.hoveredShapeHandleId;
|
|
914
|
+
this.hoveredShapeHandleId = handleId;
|
|
915
|
+
this.hoveredShapeFeatureId = featureId;
|
|
916
|
+
if (prevHandleId !== null) {
|
|
917
|
+
this.applyGoogleShapeInteraction(prevHandleId);
|
|
918
|
+
}
|
|
919
|
+
if (handleId !== null && handleId !== prevHandleId) {
|
|
920
|
+
this.applyGoogleShapeInteraction(handleId);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// ---- Route sources ----
|
|
924
|
+
syncRouteSource(config) {
|
|
925
|
+
if (this.map === null)
|
|
926
|
+
return;
|
|
927
|
+
// Skip full rebuild if config is unchanged (new object reference, same values)
|
|
928
|
+
const existing = this.routeSources.get(config.id);
|
|
929
|
+
if (existing !== undefined && isEqual(existing.config, config)) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
// Remove existing route for this source (update case)
|
|
933
|
+
this.removeRouteSourceInternal(config.id);
|
|
934
|
+
const currentMap = this.map;
|
|
935
|
+
// Extract the first LineString feature
|
|
936
|
+
for (const feature of config.features.features) {
|
|
937
|
+
const path = extractLineCoordinates(feature);
|
|
938
|
+
if (path === null)
|
|
939
|
+
continue;
|
|
940
|
+
const polyline = this.createRoutePolyline(path, config.style, currentMap, config.interactive);
|
|
941
|
+
const tracked = { polyline, listeners: [], config };
|
|
942
|
+
if (config.interactive) {
|
|
943
|
+
const fid = feature.id !== undefined ? String(feature.id) : undefined;
|
|
944
|
+
if (fid !== undefined) {
|
|
945
|
+
this.attachRouteInteractionListeners(polyline, fid, feature, tracked);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
this.routeSources.set(config.id, tracked);
|
|
949
|
+
// Only use the first LineString feature for the route
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
removeRouteSourceInternal(id) {
|
|
954
|
+
const tracked = this.routeSources.get(id);
|
|
955
|
+
if (tracked === undefined)
|
|
956
|
+
return;
|
|
957
|
+
for (const listener of tracked.listeners) {
|
|
958
|
+
google.maps.event.removeListener(listener);
|
|
959
|
+
}
|
|
960
|
+
tracked.polyline.setMap(null);
|
|
961
|
+
this.routeSources.delete(id);
|
|
962
|
+
}
|
|
963
|
+
// ---- Image overlays ----
|
|
964
|
+
syncImageOverlay(config) {
|
|
965
|
+
if (this.map === null)
|
|
966
|
+
return;
|
|
967
|
+
// Skip full rebuild if config is unchanged (new object reference, same values)
|
|
968
|
+
const existing = this.overlaySources.get(config.id);
|
|
969
|
+
if (existing !== undefined && isEqual(existing.config, config)) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
// Remove existing overlay for this source (update case)
|
|
973
|
+
this.removeImageOverlayInternal(config.id);
|
|
974
|
+
const currentMap = this.map;
|
|
975
|
+
const bounds = bboxToGoogleBounds(config.imageBounds);
|
|
976
|
+
const overlay = new google.maps.GroundOverlay(config.url, bounds, {
|
|
977
|
+
opacity: config.opacity,
|
|
978
|
+
map: currentMap,
|
|
979
|
+
clickable: false,
|
|
980
|
+
});
|
|
981
|
+
this.overlaySources.set(config.id, { overlay, config });
|
|
982
|
+
}
|
|
983
|
+
removeImageOverlayInternal(id) {
|
|
984
|
+
const tracked = this.overlaySources.get(id);
|
|
985
|
+
if (tracked === undefined)
|
|
986
|
+
return;
|
|
987
|
+
tracked.overlay.setMap(null);
|
|
988
|
+
this.overlaySources.delete(id);
|
|
989
|
+
}
|
|
990
|
+
// ============================================================================
|
|
991
|
+
// Private: DOM portal management
|
|
992
|
+
// ============================================================================
|
|
993
|
+
/** Add a portal descriptor to the marker store and notify subscribers */
|
|
994
|
+
addMarkerPortalDescriptor(descriptor) {
|
|
995
|
+
this.markerPortalDescriptors = [...this.markerPortalDescriptors, descriptor];
|
|
996
|
+
this.notifyMarkerPortalSubscribers();
|
|
997
|
+
}
|
|
998
|
+
/** Add a portal descriptor to the cluster store and notify subscribers */
|
|
999
|
+
addClusterPortalDescriptor(descriptor) {
|
|
1000
|
+
this.clusterPortalDescriptors = [...this.clusterPortalDescriptors, descriptor];
|
|
1001
|
+
this.notifyClusterPortalSubscribers();
|
|
1002
|
+
}
|
|
1003
|
+
/** Remove all portal descriptors for `sourceId` from both stores and notify */
|
|
1004
|
+
removePortalDescriptorsForSource(sourceId) {
|
|
1005
|
+
const prefix = `${sourceId}:`;
|
|
1006
|
+
const filteredMarker = this.markerPortalDescriptors.filter(d => !d.key.startsWith(prefix));
|
|
1007
|
+
if (filteredMarker.length !== this.markerPortalDescriptors.length) {
|
|
1008
|
+
this.markerPortalDescriptors = filteredMarker;
|
|
1009
|
+
this.notifyMarkerPortalSubscribers();
|
|
1010
|
+
}
|
|
1011
|
+
const filteredCluster = this.clusterPortalDescriptors.filter(d => !d.key.startsWith(prefix));
|
|
1012
|
+
if (filteredCluster.length !== this.clusterPortalDescriptors.length) {
|
|
1013
|
+
this.clusterPortalDescriptors = filteredCluster;
|
|
1014
|
+
this.notifyClusterPortalSubscribers();
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
notifyMarkerPortalSubscribers() {
|
|
1018
|
+
for (const callback of this.markerPortalSubscribers) {
|
|
1019
|
+
callback();
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
notifyClusterPortalSubscribers() {
|
|
1023
|
+
for (const callback of this.clusterPortalSubscribers) {
|
|
1024
|
+
callback();
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Patch existing DOM markers in place: update positions and refresh portal
|
|
1029
|
+
* descriptors so React re-renders with the latest render function.
|
|
1030
|
+
* Avoids the remove-then-add cycle that causes a brief flash.
|
|
1031
|
+
*/
|
|
1032
|
+
patchDomMarkers(existing, config) {
|
|
1033
|
+
const oldFeatures = existing.config.features.features;
|
|
1034
|
+
const newFeatures = config.features.features;
|
|
1035
|
+
for (let i = 0; i < Math.min(existing.markers.length, newFeatures.length); i++) {
|
|
1036
|
+
const oldFeature = oldFeatures[i];
|
|
1037
|
+
const newFeature = newFeatures[i];
|
|
1038
|
+
const marker = existing.markers[i];
|
|
1039
|
+
if (newFeature === undefined || marker === undefined)
|
|
1040
|
+
continue;
|
|
1041
|
+
const oldCoords = oldFeature !== undefined ? extractPointCoordinates(oldFeature) : null;
|
|
1042
|
+
const newCoords = extractPointCoordinates(newFeature);
|
|
1043
|
+
if (newCoords !== null &&
|
|
1044
|
+
(oldCoords === null || oldCoords.lng !== newCoords.lng || oldCoords.lat !== newCoords.lat)) {
|
|
1045
|
+
marker.position = { lng: newCoords.lng, lat: newCoords.lat };
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (config.markerRender.mode === "dom") {
|
|
1049
|
+
const result = patchPortalDescriptors(this.markerPortalDescriptors, config.id, newFeatures, config.markerRender.render);
|
|
1050
|
+
if (result.changed) {
|
|
1051
|
+
this.markerPortalDescriptors = result.descriptors;
|
|
1052
|
+
this.notifyMarkerPortalSubscribers();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Incrementally sync adaptive markers and server-side clusters when a viewport
|
|
1058
|
+
* refetch changes which feature ids are visible, without tearing down unchanged DOM markers.
|
|
1059
|
+
*/
|
|
1060
|
+
patchAdaptiveViewportData(existing, config) {
|
|
1061
|
+
if (this.map === null)
|
|
1062
|
+
return;
|
|
1063
|
+
if (config.markerRender.mode !== "adaptive" || config.kind !== "marker")
|
|
1064
|
+
return;
|
|
1065
|
+
this.removeGoneAdaptiveAssetMarkers(existing, config);
|
|
1066
|
+
this.removeGoneServerClusterMarkers(existing, config);
|
|
1067
|
+
this.patchAdaptiveMarkers(existing, config);
|
|
1068
|
+
this.syncServerClusterMarkers(existing, config);
|
|
1069
|
+
if (existing.canvasMarkers !== undefined && existing.canvasOverlay !== undefined) {
|
|
1070
|
+
this.updateAdaptiveCanvasMarkers(config, existing);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
removeGoneAdaptiveAssetMarkers(existing, config) {
|
|
1074
|
+
const incomingMarkerIds = collectFeatureIdSet(config.features);
|
|
1075
|
+
const adaptiveDomMarkers = this.adaptiveMarkerIndex.get(config.id) ?? new Map();
|
|
1076
|
+
const result = removeGoneIndexedMarkers({
|
|
1077
|
+
incomingFeatureIds: incomingMarkerIds,
|
|
1078
|
+
markerIndex: adaptiveDomMarkers,
|
|
1079
|
+
markers: existing.markers,
|
|
1080
|
+
portalDescriptors: this.markerPortalDescriptors,
|
|
1081
|
+
sourceId: config.id,
|
|
1082
|
+
detachMarker: marker => {
|
|
1083
|
+
marker.map = null;
|
|
1084
|
+
this.clusterEntitiesByMarker.delete(marker);
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
existing.markers = result.markers;
|
|
1088
|
+
if (result.portalDescriptorsChanged) {
|
|
1089
|
+
this.markerPortalDescriptors = result.portalDescriptors;
|
|
1090
|
+
this.notifyMarkerPortalSubscribers();
|
|
1091
|
+
}
|
|
1092
|
+
this.adaptiveMarkerIndex.set(config.id, result.markerIndex);
|
|
1093
|
+
}
|
|
1094
|
+
removeGoneServerClusterMarkers(existing, config) {
|
|
1095
|
+
if (config.kind !== "marker")
|
|
1096
|
+
return;
|
|
1097
|
+
const incomingClusterIds = collectFeatureIdSet(config.clusterFeatures);
|
|
1098
|
+
const clusterMarkers = this.clusterMarkerIndex.get(config.id) ?? new Map();
|
|
1099
|
+
const result = removeGoneIndexedMarkers({
|
|
1100
|
+
incomingFeatureIds: incomingClusterIds,
|
|
1101
|
+
markerIndex: clusterMarkers,
|
|
1102
|
+
markers: existing.markers,
|
|
1103
|
+
portalDescriptors: this.clusterPortalDescriptors,
|
|
1104
|
+
sourceId: config.id,
|
|
1105
|
+
detachMarker: marker => {
|
|
1106
|
+
marker.map = null;
|
|
1107
|
+
this.clusterEntitiesByMarker.delete(marker);
|
|
1108
|
+
},
|
|
1109
|
+
});
|
|
1110
|
+
existing.markers = result.markers;
|
|
1111
|
+
if (result.portalDescriptorsChanged) {
|
|
1112
|
+
this.clusterPortalDescriptors = result.portalDescriptors;
|
|
1113
|
+
this.notifyClusterPortalSubscribers();
|
|
1114
|
+
}
|
|
1115
|
+
this.clusterMarkerIndex.set(config.id, result.markerIndex);
|
|
1116
|
+
}
|
|
1117
|
+
syncServerClusterMarkers(existing, config) {
|
|
1118
|
+
if (this.map === null)
|
|
1119
|
+
return;
|
|
1120
|
+
if (config.kind !== "marker" || config.clusterFeatures === null)
|
|
1121
|
+
return;
|
|
1122
|
+
const clusterMarkers = this.clusterMarkerIndex.get(config.id) ?? new Map();
|
|
1123
|
+
const clusterRender = config.clusterRender;
|
|
1124
|
+
for (const feature of config.clusterFeatures.features) {
|
|
1125
|
+
if (feature.id === undefined)
|
|
1126
|
+
continue;
|
|
1127
|
+
const featureId = String(feature.id);
|
|
1128
|
+
const coords = extractPointCoordinates(feature);
|
|
1129
|
+
if (coords === null)
|
|
1130
|
+
continue;
|
|
1131
|
+
const props = feature.properties;
|
|
1132
|
+
const rawMarkerIds = props !== null ? props.markerIds : undefined;
|
|
1133
|
+
const markerIds = Array.isArray(rawMarkerIds) ? rawMarkerIds : [];
|
|
1134
|
+
const clusterBbox = props && props.bbox !== null ? validateBbox(props.bbox) : null;
|
|
1135
|
+
const existingMarker = clusterMarkers.get(featureId);
|
|
1136
|
+
if (existingMarker === undefined) {
|
|
1137
|
+
const marker = this.createClusterMarkerForFeature(feature, coords, config, this.map);
|
|
1138
|
+
existing.markers.push(marker);
|
|
1139
|
+
clusterMarkers.set(featureId, marker);
|
|
1140
|
+
this.attachClusterInteractionListeners(marker, featureId, { lng: coords.lng, lat: coords.lat }, markerIds, clusterBbox, existing);
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
existingMarker.position = { lng: coords.lng, lat: coords.lat };
|
|
1144
|
+
this.updateClusterMarkerEntity(existingMarker, featureId, coords, markerIds, clusterBbox);
|
|
1145
|
+
if (clusterRender !== null && clusterRender.mode === "dom") {
|
|
1146
|
+
const result = patchPortalDescriptors(this.clusterPortalDescriptors, config.id, [feature], clusterRender.render);
|
|
1147
|
+
if (result.changed) {
|
|
1148
|
+
this.clusterPortalDescriptors = result.descriptors;
|
|
1149
|
+
this.notifyClusterPortalSubscribers();
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
this.clusterMarkerIndex.set(config.id, clusterMarkers);
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Incrementally update adaptive DOM markers and the canvas symbol overlay
|
|
1157
|
+
* when `adaptiveResolution` (or callbacks) changed but feature IDs are the same.
|
|
1158
|
+
*
|
|
1159
|
+
* Three phases:
|
|
1160
|
+
* 1. DOM → symbol: detach DOM marker + remove portal for features leaving DOM mode.
|
|
1161
|
+
* 2. Symbol → DOM: add DOM marker + portal for features entering DOM mode.
|
|
1162
|
+
* 3. DOM → DOM: update portal renderFn (so the consumer's latest closure runs).
|
|
1163
|
+
* 4. Update canvas symbol overlay to reflect the new DOM/symbol split.
|
|
1164
|
+
*/
|
|
1165
|
+
patchAdaptiveMarkers(existing, config) {
|
|
1166
|
+
if (this.map === null)
|
|
1167
|
+
return;
|
|
1168
|
+
const { markerRender } = config;
|
|
1169
|
+
if (markerRender.mode !== "adaptive" || config.kind !== "marker")
|
|
1170
|
+
return;
|
|
1171
|
+
const existingDomIds = getAdaptiveDomFeatureIds(existing.config);
|
|
1172
|
+
const incomingDomIds = getAdaptiveDomFeatureIds(config);
|
|
1173
|
+
const incomingResolution = config.adaptiveResolution;
|
|
1174
|
+
const adaptiveDomMarkers = this.adaptiveMarkerIndex.get(config.id) ?? new Map();
|
|
1175
|
+
// Canvas only needs a repaint when the render function changed (e.g. activeState flip),
|
|
1176
|
+
// features data changed (new poll), or the DOM/symbol split changes (phases 1 or 2 below).
|
|
1177
|
+
// Skipping when only adaptiveResolution is a new-but-equal Map (pan frame) prevents
|
|
1178
|
+
// double-painting at 60 fps on top of Google Maps' own draw calls.
|
|
1179
|
+
let canvasNeedsUpdate = existing.config.markerRender.render !== config.markerRender.render ||
|
|
1180
|
+
existing.config.features !== config.features;
|
|
1181
|
+
// ---- Phase 1: DOM → symbol — detach DOM marker + remove portal ----
|
|
1182
|
+
for (const featureId of existingDomIds) {
|
|
1183
|
+
if (incomingDomIds.has(featureId))
|
|
1184
|
+
continue;
|
|
1185
|
+
canvasNeedsUpdate = true;
|
|
1186
|
+
const marker = adaptiveDomMarkers.get(featureId);
|
|
1187
|
+
if (marker !== undefined) {
|
|
1188
|
+
marker.map = null;
|
|
1189
|
+
adaptiveDomMarkers.delete(featureId);
|
|
1190
|
+
existing.markers = existing.markers.filter(m => m !== marker);
|
|
1191
|
+
}
|
|
1192
|
+
const key = `${config.id}:${featureId}`;
|
|
1193
|
+
const filtered = this.markerPortalDescriptors.filter(d => d.key !== key);
|
|
1194
|
+
if (filtered.length !== this.markerPortalDescriptors.length) {
|
|
1195
|
+
this.markerPortalDescriptors = filtered;
|
|
1196
|
+
this.notifyMarkerPortalSubscribers();
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
// ---- Phase 2: symbol → DOM — add DOM marker + portal ----
|
|
1200
|
+
const phase2DomCreated = new Set();
|
|
1201
|
+
for (const feature of config.features.features) {
|
|
1202
|
+
if (feature.id === undefined)
|
|
1203
|
+
continue;
|
|
1204
|
+
const featureId = String(feature.id);
|
|
1205
|
+
if (!incomingDomIds.has(featureId) || existingDomIds.has(featureId))
|
|
1206
|
+
continue;
|
|
1207
|
+
if (featureId !== "" && phase2DomCreated.has(featureId))
|
|
1208
|
+
continue;
|
|
1209
|
+
const mode = incomingResolution?.modesByFeatureId.get(featureId) ?? "symbol";
|
|
1210
|
+
if (mode !== "dom")
|
|
1211
|
+
continue;
|
|
1212
|
+
const coords = extractPointCoordinates(feature);
|
|
1213
|
+
if (coords === null)
|
|
1214
|
+
continue;
|
|
1215
|
+
const data = extractSourceData(feature.properties);
|
|
1216
|
+
const renderFn = buildAdaptiveDomRenderFn(markerRender.render);
|
|
1217
|
+
const domMarker = this.createDomMarker(featureId, coords, config.id, data, renderFn, this.map, "marker", markerRender.anchor, markerRender.pixelOffset, DOM_MARKER_Z_INDEX);
|
|
1218
|
+
domMarker.zIndex = DOM_MARKER_Z_INDEX;
|
|
1219
|
+
existing.markers.push(domMarker);
|
|
1220
|
+
adaptiveDomMarkers.set(featureId, domMarker);
|
|
1221
|
+
if (featureId !== "")
|
|
1222
|
+
phase2DomCreated.add(featureId);
|
|
1223
|
+
canvasNeedsUpdate = true;
|
|
1224
|
+
this.attachMarkerInteractionListeners(domMarker, featureId, coords, existing);
|
|
1225
|
+
}
|
|
1226
|
+
// ---- Phase 3: DOM → DOM — update portal renderFn ----
|
|
1227
|
+
let portalsChanged = false;
|
|
1228
|
+
const updatedPortals = Array.from(this.markerPortalDescriptors);
|
|
1229
|
+
for (const feature of config.features.features) {
|
|
1230
|
+
if (feature.id === undefined)
|
|
1231
|
+
continue;
|
|
1232
|
+
const featureId = String(feature.id);
|
|
1233
|
+
if (!incomingDomIds.has(featureId) || !existingDomIds.has(featureId))
|
|
1234
|
+
continue;
|
|
1235
|
+
const mode = incomingResolution?.modesByFeatureId.get(featureId) ?? "symbol";
|
|
1236
|
+
if (mode !== "dom")
|
|
1237
|
+
continue;
|
|
1238
|
+
// Update position if coordinates changed
|
|
1239
|
+
const marker = adaptiveDomMarkers.get(featureId);
|
|
1240
|
+
if (marker !== undefined) {
|
|
1241
|
+
const coords = extractPointCoordinates(feature);
|
|
1242
|
+
if (coords !== null) {
|
|
1243
|
+
const oldFeature = existing.config.features.features.find(f => f.id !== undefined && String(f.id) === featureId);
|
|
1244
|
+
const oldCoords = oldFeature !== undefined ? extractPointCoordinates(oldFeature) : null;
|
|
1245
|
+
if (oldCoords === null || oldCoords.lng !== coords.lng || oldCoords.lat !== coords.lat) {
|
|
1246
|
+
marker.position = { lng: coords.lng, lat: coords.lat };
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const renderFn = buildAdaptiveDomRenderFn(markerRender.render);
|
|
1251
|
+
const key = `${config.id}:${featureId}`;
|
|
1252
|
+
const idx = updatedPortals.findIndex(d => d.key === key);
|
|
1253
|
+
if (idx !== -1) {
|
|
1254
|
+
const desc = updatedPortals[idx];
|
|
1255
|
+
if (desc !== undefined) {
|
|
1256
|
+
updatedPortals[idx] = { ...desc, renderFn, sourceData: extractSourceData(feature.properties) };
|
|
1257
|
+
portalsChanged = true;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (portalsChanged) {
|
|
1262
|
+
this.markerPortalDescriptors = updatedPortals;
|
|
1263
|
+
this.notifyMarkerPortalSubscribers();
|
|
1264
|
+
}
|
|
1265
|
+
this.adaptiveMarkerIndex.set(config.id, adaptiveDomMarkers);
|
|
1266
|
+
// ---- Phase 4: Update canvas symbol overlay ----
|
|
1267
|
+
if (canvasNeedsUpdate) {
|
|
1268
|
+
this.updateAdaptiveCanvasMarkers(config, existing);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Repaint the canvas symbol overlay for a `mode: "symbol"` layer after the
|
|
1273
|
+
* render function changed (e.g. activeState flip). Rebuilds `canvasMarkers`
|
|
1274
|
+
* in-place so the draw closure reflects the new style, then triggers draw().
|
|
1275
|
+
*/
|
|
1276
|
+
updateSymbolCanvasMarkers(config, tracked) {
|
|
1277
|
+
const { markerRender } = config;
|
|
1278
|
+
if (markerRender.mode !== "symbol")
|
|
1279
|
+
return;
|
|
1280
|
+
if (tracked.canvasMarkers === undefined || tracked.canvasOverlay === undefined)
|
|
1281
|
+
return;
|
|
1282
|
+
const symbolState = {
|
|
1283
|
+
medium: "symbol",
|
|
1284
|
+
selected: false,
|
|
1285
|
+
hovered: false,
|
|
1286
|
+
theme: this.theme,
|
|
1287
|
+
};
|
|
1288
|
+
const styleFn = (item) => markerRender.render(item, symbolState);
|
|
1289
|
+
const canvasMarkers = tracked.canvasMarkers;
|
|
1290
|
+
canvasMarkers.length = 0;
|
|
1291
|
+
for (const feature of config.features.features) {
|
|
1292
|
+
const coords = extractPointCoordinates(feature);
|
|
1293
|
+
if (coords === null)
|
|
1294
|
+
continue;
|
|
1295
|
+
const featureId = feature.id !== undefined ? String(feature.id) : undefined;
|
|
1296
|
+
const resolved = resolveCircleSymbolDefaults(resolveSymbolDescriptor(styleFn, feature.properties));
|
|
1297
|
+
canvasMarkers.push({
|
|
1298
|
+
lat: coords.lat,
|
|
1299
|
+
lng: coords.lng,
|
|
1300
|
+
featureId: featureId ?? "",
|
|
1301
|
+
resolved,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
tracked.canvasOverlay.draw();
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Update the canvas symbol overlay for an adaptive marker layer by mutating
|
|
1308
|
+
* the `canvasMarkers` array in-place (the canvas `draw()` closure captures
|
|
1309
|
+
* the same array reference) and triggering an immediate redraw.
|
|
1310
|
+
*/
|
|
1311
|
+
updateAdaptiveCanvasMarkers(config, tracked) {
|
|
1312
|
+
const { markerRender } = config;
|
|
1313
|
+
if (markerRender.mode !== "adaptive")
|
|
1314
|
+
return;
|
|
1315
|
+
if (tracked.canvasMarkers === undefined || tracked.canvasOverlay === undefined)
|
|
1316
|
+
return;
|
|
1317
|
+
const promotedIds = getAdaptiveDomFeatureIds(config);
|
|
1318
|
+
const symbolState = {
|
|
1319
|
+
medium: "symbol",
|
|
1320
|
+
selected: false,
|
|
1321
|
+
hovered: false,
|
|
1322
|
+
theme: this.theme,
|
|
1323
|
+
};
|
|
1324
|
+
const styleFn = buildAdaptiveSymbolStyleFn(markerRender.render, symbolState);
|
|
1325
|
+
// Mutate in-place so the canvas closure's captured reference reflects the update
|
|
1326
|
+
const canvasMarkers = tracked.canvasMarkers;
|
|
1327
|
+
canvasMarkers.length = 0;
|
|
1328
|
+
for (const feature of config.features.features) {
|
|
1329
|
+
const coords = extractPointCoordinates(feature);
|
|
1330
|
+
if (coords === null)
|
|
1331
|
+
continue;
|
|
1332
|
+
const featureId = feature.id !== undefined ? String(feature.id) : undefined;
|
|
1333
|
+
if (featureId !== undefined && promotedIds.has(featureId))
|
|
1334
|
+
continue;
|
|
1335
|
+
const resolved = resolveCircleSymbolDefaults(resolveSymbolDescriptor(styleFn, feature.properties));
|
|
1336
|
+
canvasMarkers.push({
|
|
1337
|
+
lat: coords.lat,
|
|
1338
|
+
lng: coords.lng,
|
|
1339
|
+
featureId: featureId ?? "",
|
|
1340
|
+
resolved,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
tracked.canvasOverlay.draw();
|
|
1344
|
+
}
|
|
1345
|
+
// ============================================================================
|
|
1346
|
+
// Private: Marker creation
|
|
1347
|
+
// ============================================================================
|
|
1348
|
+
createMarkerForFeature(feature, coords, config, map) {
|
|
1349
|
+
const { markerRender } = config;
|
|
1350
|
+
const featureId = feature.id !== undefined ? String(feature.id) : "";
|
|
1351
|
+
switch (markerRender.mode) {
|
|
1352
|
+
case "symbol": {
|
|
1353
|
+
const symbolState = {
|
|
1354
|
+
medium: "symbol",
|
|
1355
|
+
selected: false,
|
|
1356
|
+
hovered: false,
|
|
1357
|
+
theme: this.theme,
|
|
1358
|
+
};
|
|
1359
|
+
const resolved = resolveCircleSymbolDefaults(resolveSymbolDescriptor(item => markerRender.render(item, symbolState), feature.properties));
|
|
1360
|
+
const el = createSymbolDotElement(resolved);
|
|
1361
|
+
el.style.transform = anchorFromBottomCenter("center");
|
|
1362
|
+
return new google.maps.marker.AdvancedMarkerElement({
|
|
1363
|
+
map,
|
|
1364
|
+
position: coords,
|
|
1365
|
+
content: el,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
case "dom": {
|
|
1369
|
+
return this.createDomMarker(featureId, coords, config.id, extractSourceData(feature.properties), markerRender.render, map, "marker", markerRender.anchor, markerRender.pixelOffset);
|
|
1370
|
+
}
|
|
1371
|
+
case "adaptive": {
|
|
1372
|
+
const symbolState = {
|
|
1373
|
+
medium: "symbol",
|
|
1374
|
+
selected: false,
|
|
1375
|
+
hovered: false,
|
|
1376
|
+
theme: this.theme,
|
|
1377
|
+
};
|
|
1378
|
+
const styleFn = buildAdaptiveSymbolStyleFn(markerRender.render, symbolState);
|
|
1379
|
+
const resolved = resolveCircleSymbolDefaults(resolveSymbolDescriptor(styleFn, feature.properties));
|
|
1380
|
+
const el = createSymbolDotElement(resolved);
|
|
1381
|
+
el.style.transform = anchorFromBottomCenter("center");
|
|
1382
|
+
return new google.maps.marker.AdvancedMarkerElement({
|
|
1383
|
+
map,
|
|
1384
|
+
position: coords,
|
|
1385
|
+
content: el,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
default: {
|
|
1389
|
+
throw new Error(`${markerRender} is not known`);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
createClusterMarkerForFeature(feature, coords, config, map) {
|
|
1394
|
+
const { clusterRender } = config;
|
|
1395
|
+
const featureId = feature.id !== undefined ? String(feature.id) : "";
|
|
1396
|
+
const clusterCount = feature.properties !== null ? feature.properties.count : undefined;
|
|
1397
|
+
if (clusterRender === null) {
|
|
1398
|
+
return new google.maps.marker.AdvancedMarkerElement({
|
|
1399
|
+
map,
|
|
1400
|
+
position: coords,
|
|
1401
|
+
content: createDefaultClusterElement(clusterCount),
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
switch (clusterRender.mode) {
|
|
1405
|
+
case "symbol": {
|
|
1406
|
+
const clusterState = {
|
|
1407
|
+
selected: false,
|
|
1408
|
+
hovered: false,
|
|
1409
|
+
theme: this.theme,
|
|
1410
|
+
memberItems: null,
|
|
1411
|
+
};
|
|
1412
|
+
const descriptor = resolveSymbolDescriptor(item => clusterRender.render(item, clusterState), feature.properties);
|
|
1413
|
+
return new google.maps.marker.AdvancedMarkerElement({
|
|
1414
|
+
map,
|
|
1415
|
+
position: coords,
|
|
1416
|
+
content: createClusterPinElement(descriptor, clusterCount),
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
case "dom": {
|
|
1420
|
+
return this.createDomMarker(featureId, coords, config.id, extractSourceData(feature.properties), clusterRender.render, map, "cluster", clusterRender.anchor, clusterRender.pixelOffset);
|
|
1421
|
+
}
|
|
1422
|
+
default: {
|
|
1423
|
+
throw new Error(`${clusterRender} is not known`);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Create an AdvancedMarkerElement with an empty div as content.
|
|
1429
|
+
* Registers a portal descriptor in the target store (`marker` or `cluster`)
|
|
1430
|
+
* so `<Layers>` renders React content into the container via
|
|
1431
|
+
* `createPortal()` and routes to the matching render config.
|
|
1432
|
+
*/
|
|
1433
|
+
createDomMarker(featureId, coords, sourceId, sourceData, renderFn, map, target, anchor, pixelOffset, nativeZIndexOffset = 0) {
|
|
1434
|
+
const container = document.createElement("div");
|
|
1435
|
+
container.style.pointerEvents = "auto";
|
|
1436
|
+
fadeInElement(container);
|
|
1437
|
+
const transforms = [anchorFromBottomCenter(anchor)];
|
|
1438
|
+
if (pixelOffset !== undefined)
|
|
1439
|
+
transforms.push(`translate(${pixelOffset.x}px, ${pixelOffset.y}px)`);
|
|
1440
|
+
container.style.transform = transforms.join(" ");
|
|
1441
|
+
const marker = new google.maps.marker.AdvancedMarkerElement({
|
|
1442
|
+
map,
|
|
1443
|
+
position: coords,
|
|
1444
|
+
content: container,
|
|
1445
|
+
});
|
|
1446
|
+
const descriptor = {
|
|
1447
|
+
key: `${sourceId}:${featureId}`,
|
|
1448
|
+
sourceId,
|
|
1449
|
+
container,
|
|
1450
|
+
setZIndex: zIndex => {
|
|
1451
|
+
marker.zIndex = nativeZIndexOffset + zIndex;
|
|
1452
|
+
},
|
|
1453
|
+
featureId,
|
|
1454
|
+
sourceData,
|
|
1455
|
+
renderFn,
|
|
1456
|
+
};
|
|
1457
|
+
if (target === "marker") {
|
|
1458
|
+
this.addMarkerPortalDescriptor(descriptor);
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
this.addClusterPortalDescriptor(descriptor);
|
|
1462
|
+
}
|
|
1463
|
+
return marker;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Adaptive mode: DOM overlays for features whose resolved mode is not `"symbol"`.
|
|
1467
|
+
*/
|
|
1468
|
+
createAdaptiveDomOverlays(config, map, tracked) {
|
|
1469
|
+
const adaptiveDomMarkers = new Map();
|
|
1470
|
+
const { markerRender } = config;
|
|
1471
|
+
if (markerRender.mode !== "adaptive")
|
|
1472
|
+
return;
|
|
1473
|
+
const adaptiveAnchor = markerRender.anchor;
|
|
1474
|
+
const adaptivePixelOffset = markerRender.pixelOffset;
|
|
1475
|
+
for (const { featureId, coords, domMarker } of buildAdaptiveDomEntries(config, (fId, c, sourceId, data, renderFn) => this.createDomMarker(fId, c, sourceId, data, renderFn, map, "marker", adaptiveAnchor, adaptivePixelOffset, DOM_MARKER_Z_INDEX))) {
|
|
1476
|
+
domMarker.zIndex = DOM_MARKER_Z_INDEX;
|
|
1477
|
+
tracked.markers.push(domMarker);
|
|
1478
|
+
if (featureId !== "") {
|
|
1479
|
+
this.attachMarkerInteractionListeners(domMarker, featureId, coords, tracked);
|
|
1480
|
+
adaptiveDomMarkers.set(featureId, domMarker);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
this.adaptiveMarkerIndex.set(config.id, adaptiveDomMarkers);
|
|
1484
|
+
}
|
|
1485
|
+
// ============================================================================
|
|
1486
|
+
// Private: Canvas overlay for symbol/adaptive rendering
|
|
1487
|
+
// ============================================================================
|
|
1488
|
+
/**
|
|
1489
|
+
* Set up a canvas-based OverlayView that draws all symbol markers in a
|
|
1490
|
+
* single pass, replacing the per-element Data layer approach.
|
|
1491
|
+
* For adaptive mode, DOM-rendered features are excluded (rendered as DOM overlays).
|
|
1492
|
+
*/
|
|
1493
|
+
setupCanvasSymbolOverlay(config, map, tracked) {
|
|
1494
|
+
const { markerRender } = config;
|
|
1495
|
+
if (markerRender.mode !== "symbol" && markerRender.mode !== "adaptive")
|
|
1496
|
+
return;
|
|
1497
|
+
const promotedIds = getAdaptiveDomFeatureIds(config);
|
|
1498
|
+
const symbolState = {
|
|
1499
|
+
medium: "symbol",
|
|
1500
|
+
selected: false,
|
|
1501
|
+
hovered: false,
|
|
1502
|
+
theme: this.theme,
|
|
1503
|
+
};
|
|
1504
|
+
const styleFn = markerRender.mode === "symbol"
|
|
1505
|
+
? item => markerRender.render(item, symbolState)
|
|
1506
|
+
: buildAdaptiveSymbolStyleFn(markerRender.render, symbolState);
|
|
1507
|
+
const canvasMarkers = [];
|
|
1508
|
+
for (const feature of config.features.features) {
|
|
1509
|
+
const coords = extractPointCoordinates(feature);
|
|
1510
|
+
if (coords === null)
|
|
1511
|
+
continue;
|
|
1512
|
+
const featureId = feature.id !== undefined ? String(feature.id) : undefined;
|
|
1513
|
+
if (featureId !== undefined && promotedIds.has(featureId))
|
|
1514
|
+
continue;
|
|
1515
|
+
const resolved = resolveCircleSymbolDefaults(resolveSymbolDescriptor(styleFn, feature.properties));
|
|
1516
|
+
canvasMarkers.push({
|
|
1517
|
+
lat: coords.lat,
|
|
1518
|
+
lng: coords.lng,
|
|
1519
|
+
featureId: featureId ?? "",
|
|
1520
|
+
resolved,
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
tracked.canvasMarkers = canvasMarkers;
|
|
1524
|
+
const layerPort = this;
|
|
1525
|
+
let lastHoveredFeatureId = null;
|
|
1526
|
+
let drawnMarkers = [];
|
|
1527
|
+
class CanvasSymbolOverlay extends google.maps.OverlayView {
|
|
1528
|
+
constructor() {
|
|
1529
|
+
super(...arguments);
|
|
1530
|
+
this.boundsDeferScheduled = false;
|
|
1531
|
+
this.canvas = null;
|
|
1532
|
+
this.handleClick = (event) => {
|
|
1533
|
+
const hit = this.findHit(event);
|
|
1534
|
+
if (hit === null)
|
|
1535
|
+
return;
|
|
1536
|
+
layerPort.emitEntityInteraction("click", {
|
|
1537
|
+
type: "marker",
|
|
1538
|
+
id: hit.featureId,
|
|
1539
|
+
position: [hit.lng, hit.lat],
|
|
1540
|
+
clusterId: null,
|
|
1541
|
+
});
|
|
1542
|
+
};
|
|
1543
|
+
this.handleDblClick = (event) => {
|
|
1544
|
+
event.stopPropagation();
|
|
1545
|
+
const hit = this.findHit(event);
|
|
1546
|
+
if (hit === null)
|
|
1547
|
+
return;
|
|
1548
|
+
layerPort.suppressNativeZoom();
|
|
1549
|
+
layerPort.emitEntityInteraction("dblclick", {
|
|
1550
|
+
type: "marker",
|
|
1551
|
+
id: hit.featureId,
|
|
1552
|
+
position: [hit.lng, hit.lat],
|
|
1553
|
+
clusterId: null,
|
|
1554
|
+
});
|
|
1555
|
+
};
|
|
1556
|
+
this.handleMouseMove = (event) => {
|
|
1557
|
+
const hit = this.findHit(event);
|
|
1558
|
+
const hitId = hit !== null ? hit.featureId : null;
|
|
1559
|
+
if (hitId === lastHoveredFeatureId)
|
|
1560
|
+
return;
|
|
1561
|
+
if (lastHoveredFeatureId !== null) {
|
|
1562
|
+
layerPort.emitEntityInteraction("hover-end", {
|
|
1563
|
+
type: "marker",
|
|
1564
|
+
id: lastHoveredFeatureId,
|
|
1565
|
+
position: [0, 0],
|
|
1566
|
+
clusterId: null,
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
lastHoveredFeatureId = hitId;
|
|
1570
|
+
if (hit !== null) {
|
|
1571
|
+
layerPort.emitEntityInteraction("hover-start", {
|
|
1572
|
+
type: "marker",
|
|
1573
|
+
id: hit.featureId,
|
|
1574
|
+
position: [hit.lng, hit.lat],
|
|
1575
|
+
clusterId: null,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
if (this.canvas !== null) {
|
|
1579
|
+
this.canvas.style.cursor = hit !== null ? MAP_CURSORS.interactive : MAP_CURSORS.default;
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
this.handleMouseLeave = () => {
|
|
1583
|
+
if (lastHoveredFeatureId !== null) {
|
|
1584
|
+
layerPort.emitEntityInteraction("hover-end", {
|
|
1585
|
+
type: "marker",
|
|
1586
|
+
id: lastHoveredFeatureId,
|
|
1587
|
+
position: [0, 0],
|
|
1588
|
+
clusterId: null,
|
|
1589
|
+
});
|
|
1590
|
+
lastHoveredFeatureId = null;
|
|
1591
|
+
}
|
|
1592
|
+
if (this.canvas !== null) {
|
|
1593
|
+
this.canvas.style.cursor = MAP_CURSORS.default;
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
onAdd() {
|
|
1598
|
+
const canvas = document.createElement("canvas");
|
|
1599
|
+
canvas.style.position = "absolute";
|
|
1600
|
+
canvas.style.pointerEvents = "auto";
|
|
1601
|
+
const panes = this.getPanes();
|
|
1602
|
+
if (panes !== null) {
|
|
1603
|
+
panes.overlayMouseTarget.appendChild(canvas);
|
|
1604
|
+
}
|
|
1605
|
+
this.canvas = canvas;
|
|
1606
|
+
canvas.addEventListener("click", this.handleClick);
|
|
1607
|
+
canvas.addEventListener("dblclick", this.handleDblClick);
|
|
1608
|
+
canvas.addEventListener("mousemove", this.handleMouseMove);
|
|
1609
|
+
canvas.addEventListener("mouseleave", this.handleMouseLeave);
|
|
1610
|
+
}
|
|
1611
|
+
draw() {
|
|
1612
|
+
const canvas = this.canvas;
|
|
1613
|
+
if (canvas === null)
|
|
1614
|
+
return;
|
|
1615
|
+
const projection = this.getProjection();
|
|
1616
|
+
const bounds = map.getBounds();
|
|
1617
|
+
const pad = CANVAS_VIEWPORT_PADDING;
|
|
1618
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1619
|
+
let originX;
|
|
1620
|
+
let originY;
|
|
1621
|
+
let width;
|
|
1622
|
+
let height;
|
|
1623
|
+
if (bounds) {
|
|
1624
|
+
const ne = projection.fromLatLngToDivPixel(bounds.getNorthEast());
|
|
1625
|
+
const sw = projection.fromLatLngToDivPixel(bounds.getSouthWest());
|
|
1626
|
+
if (ne !== null && sw !== null && ne.x - sw.x > 1 && sw.y - ne.y > 1) {
|
|
1627
|
+
width = ne.x - sw.x + pad * 2;
|
|
1628
|
+
height = sw.y - ne.y + pad * 2;
|
|
1629
|
+
originX = sw.x - pad;
|
|
1630
|
+
originY = ne.y - pad;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
// When multiple <Map> components coexist on one page with mapId
|
|
1634
|
+
// (vector maps), some google.maps.Map instances end up with a
|
|
1635
|
+
// detached getDiv() (0×0, no parentElement), causing getBounds()
|
|
1636
|
+
// to return undefined permanently. Fall back to sizing the canvas
|
|
1637
|
+
// from the actual marker positions so rendering still works.
|
|
1638
|
+
if (width === undefined) {
|
|
1639
|
+
let minX = Infinity;
|
|
1640
|
+
let minY = Infinity;
|
|
1641
|
+
let maxX = -Infinity;
|
|
1642
|
+
let maxY = -Infinity;
|
|
1643
|
+
for (const entry of canvasMarkers) {
|
|
1644
|
+
const pt = projection.fromLatLngToDivPixel(new google.maps.LatLng(entry.lat, entry.lng));
|
|
1645
|
+
if (pt === null)
|
|
1646
|
+
continue;
|
|
1647
|
+
if (pt.x < minX)
|
|
1648
|
+
minX = pt.x;
|
|
1649
|
+
if (pt.y < minY)
|
|
1650
|
+
minY = pt.y;
|
|
1651
|
+
if (pt.x > maxX)
|
|
1652
|
+
maxX = pt.x;
|
|
1653
|
+
if (pt.y > maxY)
|
|
1654
|
+
maxY = pt.y;
|
|
1655
|
+
}
|
|
1656
|
+
if (!isFinite(minX)) {
|
|
1657
|
+
if (!this.boundsDeferScheduled) {
|
|
1658
|
+
this.boundsDeferScheduled = true;
|
|
1659
|
+
requestAnimationFrame(() => {
|
|
1660
|
+
if (this.getMap() !== null)
|
|
1661
|
+
this.draw();
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
originX = minX - pad;
|
|
1667
|
+
originY = minY - pad;
|
|
1668
|
+
width = maxX - minX + pad * 2;
|
|
1669
|
+
height = maxY - minY + pad * 2;
|
|
1670
|
+
}
|
|
1671
|
+
if (originX === undefined || originY === undefined || height === undefined)
|
|
1672
|
+
return;
|
|
1673
|
+
this.boundsDeferScheduled = false;
|
|
1674
|
+
canvas.style.left = `${originX}px`;
|
|
1675
|
+
canvas.style.top = `${originY}px`;
|
|
1676
|
+
canvas.style.width = `${width}px`;
|
|
1677
|
+
canvas.style.height = `${height}px`;
|
|
1678
|
+
canvas.width = width * dpr;
|
|
1679
|
+
canvas.height = height * dpr;
|
|
1680
|
+
const ctx = canvas.getContext("2d");
|
|
1681
|
+
if (ctx === null)
|
|
1682
|
+
return;
|
|
1683
|
+
ctx.scale(dpr, dpr);
|
|
1684
|
+
ctx.clearRect(0, 0, width, height);
|
|
1685
|
+
const nextDrawnMarkers = [];
|
|
1686
|
+
for (const entry of canvasMarkers) {
|
|
1687
|
+
const point = projection.fromLatLngToDivPixel(new google.maps.LatLng(entry.lat, entry.lng));
|
|
1688
|
+
if (point === null)
|
|
1689
|
+
continue;
|
|
1690
|
+
const x = point.x - originX;
|
|
1691
|
+
const y = point.y - originY;
|
|
1692
|
+
const { resolved } = entry;
|
|
1693
|
+
const radius = resolved.radiusPx;
|
|
1694
|
+
ctx.globalAlpha = resolved.opacity;
|
|
1695
|
+
ctx.beginPath();
|
|
1696
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
1697
|
+
ctx.fillStyle = resolved.color;
|
|
1698
|
+
ctx.fill();
|
|
1699
|
+
if (resolved.hasBorder && resolved.borderColor !== null) {
|
|
1700
|
+
ctx.strokeStyle = resolved.borderColor;
|
|
1701
|
+
ctx.lineWidth = resolved.borderWidthPx;
|
|
1702
|
+
ctx.stroke();
|
|
1703
|
+
}
|
|
1704
|
+
nextDrawnMarkers.push({ x, y, radius, entry });
|
|
1705
|
+
}
|
|
1706
|
+
ctx.globalAlpha = 1;
|
|
1707
|
+
drawnMarkers = nextDrawnMarkers;
|
|
1708
|
+
}
|
|
1709
|
+
onRemove() {
|
|
1710
|
+
if (this.canvas !== null) {
|
|
1711
|
+
this.canvas.removeEventListener("click", this.handleClick);
|
|
1712
|
+
this.canvas.removeEventListener("dblclick", this.handleDblClick);
|
|
1713
|
+
this.canvas.removeEventListener("mousemove", this.handleMouseMove);
|
|
1714
|
+
this.canvas.removeEventListener("mouseleave", this.handleMouseLeave);
|
|
1715
|
+
this.canvas.remove();
|
|
1716
|
+
this.canvas = null;
|
|
1717
|
+
}
|
|
1718
|
+
drawnMarkers = [];
|
|
1719
|
+
}
|
|
1720
|
+
findHit(event) {
|
|
1721
|
+
const canvas = this.canvas;
|
|
1722
|
+
if (canvas === null)
|
|
1723
|
+
return null;
|
|
1724
|
+
const rect = canvas.getBoundingClientRect();
|
|
1725
|
+
const mouseX = event.clientX - rect.left;
|
|
1726
|
+
const mouseY = event.clientY - rect.top;
|
|
1727
|
+
let closestEntry = null;
|
|
1728
|
+
let closestDist = Infinity;
|
|
1729
|
+
for (const drawn of drawnMarkers) {
|
|
1730
|
+
const dx = mouseX - drawn.x;
|
|
1731
|
+
const dy = mouseY - drawn.y;
|
|
1732
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1733
|
+
if (dist <= drawn.radius && dist < closestDist) {
|
|
1734
|
+
closestDist = dist;
|
|
1735
|
+
closestEntry = drawn.entry;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
return closestEntry;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
const overlay = new CanvasSymbolOverlay();
|
|
1742
|
+
overlay.setMap(map);
|
|
1743
|
+
tracked.canvasOverlay = overlay;
|
|
1744
|
+
}
|
|
1745
|
+
// ============================================================================
|
|
1746
|
+
// Private: Client-side clustering
|
|
1747
|
+
// ============================================================================
|
|
1748
|
+
/**
|
|
1749
|
+
* Set up client-side clustering using @googlemaps/markerclusterer.
|
|
1750
|
+
* Creates AdvancedMarkerElement instances for all point features and
|
|
1751
|
+
* hands them to a MarkerClusterer which manages their visibility
|
|
1752
|
+
* based on zoom level.
|
|
1753
|
+
*/
|
|
1754
|
+
setupClientClustering(config, map, tracked) {
|
|
1755
|
+
const { clusterConfig, clusterRender } = config;
|
|
1756
|
+
if (clusterConfig === null || clusterConfig.mode !== "client")
|
|
1757
|
+
return;
|
|
1758
|
+
// Create individual markers (the clusterer manages adding/removing from map)
|
|
1759
|
+
const clusterableMarkers = [];
|
|
1760
|
+
for (const feature of config.features.features) {
|
|
1761
|
+
const coords = extractPointCoordinates(feature);
|
|
1762
|
+
if (coords === null)
|
|
1763
|
+
continue;
|
|
1764
|
+
const marker = this.createMarkerForFeature(feature, coords, config, map);
|
|
1765
|
+
// Store feature ID on the marker element for cluster interaction lookup
|
|
1766
|
+
const featureId = feature.id !== undefined ? String(feature.id) : undefined;
|
|
1767
|
+
if (featureId !== undefined) {
|
|
1768
|
+
marker.dataset.featureId = featureId;
|
|
1769
|
+
}
|
|
1770
|
+
clusterableMarkers.push(marker);
|
|
1771
|
+
tracked.markers.push(marker);
|
|
1772
|
+
// Interaction listeners for individual markers
|
|
1773
|
+
if (featureId !== undefined) {
|
|
1774
|
+
this.attachMarkerInteractionListeners(marker, featureId, coords, tracked);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
// Build the custom cluster renderer
|
|
1778
|
+
const layerPort = this;
|
|
1779
|
+
const sourceId = config.id;
|
|
1780
|
+
const renderer = {
|
|
1781
|
+
render(cluster, _stats, _map) {
|
|
1782
|
+
const count = cluster.count;
|
|
1783
|
+
const position = cluster.position;
|
|
1784
|
+
// Collect marker IDs from the cluster's markers
|
|
1785
|
+
const markerIds = collectFeatureIdsFromCluster(cluster);
|
|
1786
|
+
let content;
|
|
1787
|
+
if (clusterRender !== null && clusterRender.mode === "symbol") {
|
|
1788
|
+
const clusterState = {
|
|
1789
|
+
selected: false,
|
|
1790
|
+
hovered: false,
|
|
1791
|
+
theme: layerPort.theme,
|
|
1792
|
+
memberItems: null,
|
|
1793
|
+
};
|
|
1794
|
+
const style = clusterRender.render({ id: "", position: [position.lng(), position.lat()], markerIds, count, bbox: null }, clusterState);
|
|
1795
|
+
content = createClusterPinElement(style, count);
|
|
1796
|
+
}
|
|
1797
|
+
else if (clusterRender !== null) {
|
|
1798
|
+
// DOM cluster rendering via portals (mode === "dom" by elimination —
|
|
1799
|
+
// ClusterRenderConfig has no other variants per ADR-0016).
|
|
1800
|
+
const container = document.createElement("div");
|
|
1801
|
+
container.style.pointerEvents = "auto";
|
|
1802
|
+
const clusterId = `cluster-${position.lat().toFixed(6)}-${position.lng().toFixed(6)}`;
|
|
1803
|
+
layerPort.addClusterPortalDescriptor({
|
|
1804
|
+
key: `${sourceId}:${clusterId}`,
|
|
1805
|
+
sourceId,
|
|
1806
|
+
container,
|
|
1807
|
+
featureId: clusterId,
|
|
1808
|
+
sourceData: {
|
|
1809
|
+
id: clusterId,
|
|
1810
|
+
position: [position.lng(), position.lat()],
|
|
1811
|
+
markerIds,
|
|
1812
|
+
count,
|
|
1813
|
+
bbox: null,
|
|
1814
|
+
},
|
|
1815
|
+
renderFn: clusterRender.render,
|
|
1816
|
+
});
|
|
1817
|
+
content = container;
|
|
1818
|
+
}
|
|
1819
|
+
else {
|
|
1820
|
+
content = createDefaultClusterElement(count);
|
|
1821
|
+
}
|
|
1822
|
+
const clusterMarker = new google.maps.marker.AdvancedMarkerElement({
|
|
1823
|
+
position,
|
|
1824
|
+
content,
|
|
1825
|
+
zIndex: 1000 + count,
|
|
1826
|
+
});
|
|
1827
|
+
// Cluster interaction listeners
|
|
1828
|
+
const entity = {
|
|
1829
|
+
type: "cluster",
|
|
1830
|
+
id: `cluster-${position.lat().toFixed(6)}-${position.lng().toFixed(6)}`,
|
|
1831
|
+
position: [position.lng(), position.lat()],
|
|
1832
|
+
markerIds,
|
|
1833
|
+
bbox: null,
|
|
1834
|
+
};
|
|
1835
|
+
const hoverTarget = clusterMarker.element;
|
|
1836
|
+
hoverTarget.addEventListener("mouseenter", () => {
|
|
1837
|
+
layerPort.emitEntityInteraction("hover-start", entity);
|
|
1838
|
+
});
|
|
1839
|
+
hoverTarget.addEventListener("mouseleave", () => {
|
|
1840
|
+
layerPort.emitEntityInteraction("hover-end", entity);
|
|
1841
|
+
});
|
|
1842
|
+
return clusterMarker;
|
|
1843
|
+
},
|
|
1844
|
+
};
|
|
1845
|
+
// Handle cluster click at the MarkerClusterer level
|
|
1846
|
+
const onClusterClick = (_event, cluster, _clusterMap) => {
|
|
1847
|
+
const position = cluster.position;
|
|
1848
|
+
const markerIds = collectFeatureIdsFromCluster(cluster);
|
|
1849
|
+
this.emitEntityInteraction("click", {
|
|
1850
|
+
type: "cluster",
|
|
1851
|
+
id: `cluster-${position.lat().toFixed(6)}-${position.lng().toFixed(6)}`,
|
|
1852
|
+
position: [position.lng(), position.lat()],
|
|
1853
|
+
markerIds,
|
|
1854
|
+
bbox: null,
|
|
1855
|
+
});
|
|
1856
|
+
};
|
|
1857
|
+
const clusterer = new MarkerClusterer({
|
|
1858
|
+
map,
|
|
1859
|
+
markers: clusterableMarkers,
|
|
1860
|
+
algorithm: new SuperClusterAlgorithm({
|
|
1861
|
+
radius: clusterConfig.radius ?? 50,
|
|
1862
|
+
maxZoom: clusterConfig.maxZoom ?? 14,
|
|
1863
|
+
}),
|
|
1864
|
+
renderer,
|
|
1865
|
+
onClusterClick,
|
|
1866
|
+
});
|
|
1867
|
+
tracked.clusterer = clusterer;
|
|
1868
|
+
}
|
|
1869
|
+
// ============================================================================
|
|
1870
|
+
// Private: Feature-to-shape mapping
|
|
1871
|
+
// ============================================================================
|
|
1872
|
+
pushToFeatureMap(map, featureId, value) {
|
|
1873
|
+
const existing = map.get(featureId);
|
|
1874
|
+
if (existing !== undefined) {
|
|
1875
|
+
existing.push(value);
|
|
1876
|
+
}
|
|
1877
|
+
else {
|
|
1878
|
+
map.set(featureId, [value]);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
// ============================================================================
|
|
1882
|
+
// Private: Unified shape interaction styling
|
|
1883
|
+
// ============================================================================
|
|
1884
|
+
/**
|
|
1885
|
+
* Re-apply all interaction-sensitive styles for every feature in the given
|
|
1886
|
+
* shape source. Priority: selected > hovered > base.
|
|
1887
|
+
*
|
|
1888
|
+
* Called whenever hover, selection, or theme changes on the given handleId.
|
|
1889
|
+
* When neither hover nor selection targets a feature, it reverts to its base style.
|
|
1890
|
+
*/
|
|
1891
|
+
applyGoogleShapeInteraction(handleId) {
|
|
1892
|
+
const tracked = this.shapeSources.get(handleId);
|
|
1893
|
+
if (tracked === undefined)
|
|
1894
|
+
return;
|
|
1895
|
+
const { style, featureStyles } = tracked.config;
|
|
1896
|
+
const selId = this.selectedShapeHandleId === handleId ? this.selectedShapeFeatureId : null;
|
|
1897
|
+
const hovId = this.hoveredShapeHandleId === handleId ? this.hoveredShapeFeatureId : null;
|
|
1898
|
+
let selectedOverrides = null;
|
|
1899
|
+
if (selId !== null) {
|
|
1900
|
+
const fs = featureStyles?.get(selId) ?? style;
|
|
1901
|
+
selectedOverrides = resolveSelectedStyle(fs, "polygon", this.theme);
|
|
1902
|
+
}
|
|
1903
|
+
let hoveredOverrides = null;
|
|
1904
|
+
if (hovId !== null) {
|
|
1905
|
+
const fs = featureStyles?.get(hovId) ?? style;
|
|
1906
|
+
hoveredOverrides = resolveHoveredStyle(fs, "polygon", this.theme);
|
|
1907
|
+
}
|
|
1908
|
+
const resolveForFeature = (featureId) => {
|
|
1909
|
+
if (featureId === selId)
|
|
1910
|
+
return selectedOverrides;
|
|
1911
|
+
if (featureId === hovId)
|
|
1912
|
+
return hoveredOverrides;
|
|
1913
|
+
return null;
|
|
1914
|
+
};
|
|
1915
|
+
for (const [featureId, shapes] of tracked.featureIdToShapes) {
|
|
1916
|
+
const overrides = resolveForFeature(featureId);
|
|
1917
|
+
const base = featureStyles?.get(featureId) ?? style;
|
|
1918
|
+
for (const shape of shapes) {
|
|
1919
|
+
if ("getPaths" in shape) {
|
|
1920
|
+
// Fill-only tiled polygons (ADR-0021) carry no stroke — their outline is a
|
|
1921
|
+
// separate polyline. Restyle only the fill so hover/select never paints a
|
|
1922
|
+
// stroke back onto them.
|
|
1923
|
+
if (tracked.fillOnlyShapes.has(shape)) {
|
|
1924
|
+
shape.setOptions({
|
|
1925
|
+
fillColor: overrides?.fill ?? base.fill ?? "rgba(0,0,0,0.1)",
|
|
1926
|
+
fillOpacity: overrides?.fillOpacity ?? base.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity,
|
|
1927
|
+
});
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
shape.setOptions({
|
|
1931
|
+
fillColor: overrides?.fill ?? base.fill ?? "rgba(0,0,0,0.1)",
|
|
1932
|
+
fillOpacity: overrides?.fillOpacity ?? base.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity,
|
|
1933
|
+
strokeColor: overrides?.stroke ?? base.stroke ?? "#000000",
|
|
1934
|
+
strokeWeight: overrides?.strokeWidth ?? base.strokeWidth ?? this.shapeStyleDefaults.polygon.strokeWidth,
|
|
1935
|
+
strokeOpacity: overrides?.strokeOpacity ?? base.strokeOpacity ?? this.shapeStyleDefaults.polygon.strokeOpacity,
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
else {
|
|
1939
|
+
shape.setOptions({
|
|
1940
|
+
strokeColor: overrides?.stroke ?? base.stroke ?? "#000000",
|
|
1941
|
+
strokeWeight: overrides?.strokeWidth ?? base.strokeWidth ?? this.shapeStyleDefaults.line.strokeWidth,
|
|
1942
|
+
strokeOpacity: overrides?.strokeOpacity ?? base.strokeOpacity ?? this.shapeStyleDefaults.line.strokeOpacity,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
for (const [featureId, pointMarkers] of tracked.featureIdToPointMarkers) {
|
|
1948
|
+
const overrides = resolveForFeature(featureId);
|
|
1949
|
+
const base = featureStyles?.get(featureId) ?? style;
|
|
1950
|
+
for (const marker of pointMarkers) {
|
|
1951
|
+
const el = marker.content;
|
|
1952
|
+
if (!(el instanceof HTMLElement))
|
|
1953
|
+
continue;
|
|
1954
|
+
const fillOpacity = overrides?.fillOpacity ?? base.fillOpacity ?? this.shapeStyleDefaults.point.fillOpacity;
|
|
1955
|
+
const strokeOpacity = overrides?.strokeOpacity ?? base.strokeOpacity ?? this.shapeStyleDefaults.point.strokeOpacity;
|
|
1956
|
+
el.style.backgroundColor = colorWithOpacity(overrides?.fill ?? base.fill ?? "rgba(0,0,0,0.1)", fillOpacity);
|
|
1957
|
+
el.style.borderColor = colorWithOpacity(overrides?.stroke ?? base.stroke ?? "#000", strokeOpacity);
|
|
1958
|
+
el.style.borderWidth = `${overrides?.strokeWidth ?? base.strokeWidth ?? this.shapeStyleDefaults.point.strokeWidth}px`;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
// ============================================================================
|
|
1963
|
+
// Private: Shape creation
|
|
1964
|
+
// ============================================================================
|
|
1965
|
+
createPolygon(paths, style, map, clickable = true, zIndex) {
|
|
1966
|
+
return new google.maps.Polygon({
|
|
1967
|
+
paths,
|
|
1968
|
+
map,
|
|
1969
|
+
fillColor: style.fill ?? "rgba(0,0,0,0.1)",
|
|
1970
|
+
fillOpacity: style.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity,
|
|
1971
|
+
strokeColor: style.stroke ?? "#000000",
|
|
1972
|
+
strokeWeight: style.strokeWidth ?? this.shapeStyleDefaults.polygon.strokeWidth,
|
|
1973
|
+
strokeOpacity: style.strokeOpacity ?? this.shapeStyleDefaults.polygon.strokeOpacity,
|
|
1974
|
+
clickable,
|
|
1975
|
+
...(zIndex !== undefined ? { zIndex } : {}),
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
createPolyline(path, style, map, zIndex) {
|
|
1979
|
+
return new google.maps.Polyline({
|
|
1980
|
+
path,
|
|
1981
|
+
map,
|
|
1982
|
+
strokeColor: style.stroke ?? "#000000",
|
|
1983
|
+
strokeWeight: style.strokeWidth ?? this.shapeStyleDefaults.line.strokeWidth,
|
|
1984
|
+
strokeOpacity: style.strokeOpacity ?? this.shapeStyleDefaults.line.strokeOpacity,
|
|
1985
|
+
clickable: true,
|
|
1986
|
+
...(zIndex !== undefined ? { zIndex } : {}),
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Fill-only polygon (no stroke) for ADR-0021 fill tiling: the fill comes from
|
|
1991
|
+
* clipped geometry while the full outline is drawn separately as polylines.
|
|
1992
|
+
* An optional `zIndex` controls the Google Maps rendering order for hover promotion.
|
|
1993
|
+
*/
|
|
1994
|
+
createFillOnlyPolygon(paths, style, map, clickable, zIndex) {
|
|
1995
|
+
return new google.maps.Polygon({
|
|
1996
|
+
paths,
|
|
1997
|
+
map,
|
|
1998
|
+
fillColor: style.fill ?? "rgba(0,0,0,0.1)",
|
|
1999
|
+
fillOpacity: style.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity,
|
|
2000
|
+
strokeWeight: 0,
|
|
2001
|
+
strokeOpacity: 0,
|
|
2002
|
+
clickable,
|
|
2003
|
+
...(zIndex !== undefined ? { zIndex } : {}),
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Render a polygonal feature whose fill is tiled (ADR-0021): the visible
|
|
2008
|
+
* outline is drawn from the feature's full geometry as polylines (so it stays
|
|
2009
|
+
* complete through covering neighbours), and the fill is drawn from the
|
|
2010
|
+
* clipped geometry as fill-only polygons. A fully covered loser yields no fill
|
|
2011
|
+
* polygon — just its outline.
|
|
2012
|
+
*/
|
|
2013
|
+
renderTiledPolygonFeature(fullGeometry, fillGeometry, style, fid, isStrokeOnly, config, tracked, map) {
|
|
2014
|
+
const polygonParts = (geometry) => {
|
|
2015
|
+
if (geometry.type === "Polygon")
|
|
2016
|
+
return [geometry.coordinates];
|
|
2017
|
+
if (geometry.type === "MultiPolygon")
|
|
2018
|
+
return geometry.coordinates;
|
|
2019
|
+
return [];
|
|
2020
|
+
};
|
|
2021
|
+
// Outline polylines from the full geometry — one per ring, closed.
|
|
2022
|
+
for (const partCoords of polygonParts(fullGeometry)) {
|
|
2023
|
+
const paths = extractPolygonPaths(partCoords);
|
|
2024
|
+
if (paths === null)
|
|
2025
|
+
continue;
|
|
2026
|
+
for (const ring of paths) {
|
|
2027
|
+
if (ring.length < 2)
|
|
2028
|
+
continue;
|
|
2029
|
+
const first = ring[0];
|
|
2030
|
+
if (first === undefined)
|
|
2031
|
+
continue;
|
|
2032
|
+
const outline = this.createPolyline([...ring, first], style, map, TILED_OUTLINE_Z_INDEX);
|
|
2033
|
+
tracked.shapes.push(outline);
|
|
2034
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, outline);
|
|
2035
|
+
if (config.interactive !== "none") {
|
|
2036
|
+
this.attachShapeInteractionListeners(outline, fid, "polygon", tracked, config.id);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
// Fill-only polygons from the clipped geometry.
|
|
2041
|
+
const fillVisible = (style.fillOpacity ?? this.shapeStyleDefaults.polygon.fillOpacity) > 0;
|
|
2042
|
+
const fillClickable = !isStrokeOnly && fillVisible && config.interactive !== "none";
|
|
2043
|
+
const fillZIndex = config.featureZIndexOverrides?.get(fid);
|
|
2044
|
+
for (const partCoords of polygonParts(fillGeometry)) {
|
|
2045
|
+
const paths = extractPolygonPaths(partCoords);
|
|
2046
|
+
if (paths === null)
|
|
2047
|
+
continue;
|
|
2048
|
+
const fillPolygon = this.createFillOnlyPolygon(paths, style, map, fillClickable, fillZIndex);
|
|
2049
|
+
tracked.shapes.push(fillPolygon);
|
|
2050
|
+
tracked.fillOnlyShapes.add(fillPolygon);
|
|
2051
|
+
this.pushToFeatureMap(tracked.featureIdToShapes, fid, fillPolygon);
|
|
2052
|
+
if (fillClickable) {
|
|
2053
|
+
this.attachShapeInteractionListeners(fillPolygon, fid, "polygon", tracked, config.id);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Create invisible polylines tracing each ring of a polygon's paths.
|
|
2059
|
+
* Used as hit targets for stroke-only interaction mode — Google natively
|
|
2060
|
+
* shows pointer cursor on clickable polylines, giving correct cursor
|
|
2061
|
+
* behaviour without manual hit-testing.
|
|
2062
|
+
*
|
|
2063
|
+
* `geodesic` must match the parent polygon so hit areas align with the
|
|
2064
|
+
* visible stroke.
|
|
2065
|
+
*/
|
|
2066
|
+
createStrokeHitPolylines(paths, strokeWeight, map, geodesic = true) {
|
|
2067
|
+
const hitWeight = Math.max(strokeWeight, 3);
|
|
2068
|
+
const polylines = [];
|
|
2069
|
+
for (const ring of paths) {
|
|
2070
|
+
if (ring.length < 2)
|
|
2071
|
+
continue;
|
|
2072
|
+
const first = ring[0];
|
|
2073
|
+
if (first === undefined)
|
|
2074
|
+
continue;
|
|
2075
|
+
const closedPath = [...ring, first];
|
|
2076
|
+
polylines.push(new google.maps.Polyline({
|
|
2077
|
+
path: closedPath,
|
|
2078
|
+
map,
|
|
2079
|
+
strokeOpacity: 0,
|
|
2080
|
+
strokeWeight: hitWeight,
|
|
2081
|
+
geodesic,
|
|
2082
|
+
clickable: true,
|
|
2083
|
+
}));
|
|
2084
|
+
}
|
|
2085
|
+
return polylines;
|
|
2086
|
+
}
|
|
2087
|
+
createShapePointMarker(position, style, map) {
|
|
2088
|
+
const diameter = (style.pointRadius ?? this.shapeStyleDefaults.point.pointRadius) * 2;
|
|
2089
|
+
const el = document.createElement("div");
|
|
2090
|
+
el.style.boxSizing = "content-box";
|
|
2091
|
+
el.style.pointerEvents = "auto";
|
|
2092
|
+
el.style.width = `${diameter}px`;
|
|
2093
|
+
el.style.height = `${diameter}px`;
|
|
2094
|
+
el.style.borderRadius = "50%";
|
|
2095
|
+
const fillOpacity = style.fillOpacity ?? this.shapeStyleDefaults.point.fillOpacity;
|
|
2096
|
+
const strokeOpacity = style.strokeOpacity ?? this.shapeStyleDefaults.point.strokeOpacity;
|
|
2097
|
+
el.style.backgroundColor = colorWithOpacity(style.fill ?? "rgba(0,0,0,0.1)", fillOpacity);
|
|
2098
|
+
el.style.borderWidth = `${style.strokeWidth ?? this.shapeStyleDefaults.point.strokeWidth}px`;
|
|
2099
|
+
el.style.borderStyle = "solid";
|
|
2100
|
+
el.style.borderColor = colorWithOpacity(style.stroke ?? "#000", strokeOpacity);
|
|
2101
|
+
el.style.transform = anchorFromBottomCenter("center");
|
|
2102
|
+
return new google.maps.marker.AdvancedMarkerElement({ map, position, content: el });
|
|
2103
|
+
}
|
|
2104
|
+
// ============================================================================
|
|
2105
|
+
// Private: Route creation
|
|
2106
|
+
// ============================================================================
|
|
2107
|
+
createRoutePolyline(path, style, map, interactive = false) {
|
|
2108
|
+
const icons = [];
|
|
2109
|
+
// Direction arrows
|
|
2110
|
+
if (style.showDirectionArrows === true) {
|
|
2111
|
+
icons.push({
|
|
2112
|
+
icon: {
|
|
2113
|
+
path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW,
|
|
2114
|
+
scale: 3,
|
|
2115
|
+
strokeColor: style.color ?? "#000000",
|
|
2116
|
+
strokeWeight: 2,
|
|
2117
|
+
fillColor: style.color ?? "#000000",
|
|
2118
|
+
fillOpacity: 1,
|
|
2119
|
+
},
|
|
2120
|
+
offset: "0",
|
|
2121
|
+
repeat: "100px",
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
// Dash pattern via icon sequence
|
|
2125
|
+
const { dashArray } = style;
|
|
2126
|
+
const hasDash = dashArray !== undefined && dashArray.length > 0;
|
|
2127
|
+
if (hasDash) {
|
|
2128
|
+
const dashOn = dashArray[0] ?? 10;
|
|
2129
|
+
const dashOff = dashArray[1] ?? 10;
|
|
2130
|
+
icons.push({
|
|
2131
|
+
icon: {
|
|
2132
|
+
path: "M 0,-1 0,1",
|
|
2133
|
+
strokeOpacity: style.opacity ?? 1,
|
|
2134
|
+
strokeColor: style.color ?? "#000000",
|
|
2135
|
+
strokeWeight: style.width ?? 3,
|
|
2136
|
+
scale: 1,
|
|
2137
|
+
},
|
|
2138
|
+
offset: "0",
|
|
2139
|
+
repeat: `${dashOn + dashOff}px`,
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
return new google.maps.Polyline({
|
|
2143
|
+
path,
|
|
2144
|
+
map,
|
|
2145
|
+
strokeColor: style.color ?? "#000000",
|
|
2146
|
+
strokeWeight: style.width ?? 3,
|
|
2147
|
+
strokeOpacity: hasDash ? 0 : (style.opacity ?? 1),
|
|
2148
|
+
icons: icons.length > 0 ? icons : undefined,
|
|
2149
|
+
clickable: interactive,
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
// ============================================================================
|
|
2153
|
+
// Private: Interaction listeners
|
|
2154
|
+
// ============================================================================
|
|
2155
|
+
attachMarkerInteractionListeners(marker, featureId, coords, tracked) {
|
|
2156
|
+
const entity = {
|
|
2157
|
+
type: "marker",
|
|
2158
|
+
id: featureId,
|
|
2159
|
+
position: [coords.lng, coords.lat],
|
|
2160
|
+
clusterId: null,
|
|
2161
|
+
};
|
|
2162
|
+
// Click
|
|
2163
|
+
const clickListener = marker.addListener("gmp-click", () => {
|
|
2164
|
+
this.emitEntityInteraction("click", entity);
|
|
2165
|
+
});
|
|
2166
|
+
tracked.listeners.push(clickListener);
|
|
2167
|
+
// Hover and dblclick via DOM events on the marker's outer element.
|
|
2168
|
+
// marker.element is the outer <gmp-advanced-marker> wrapper that
|
|
2169
|
+
// supports standard DOM events.
|
|
2170
|
+
const hoverTarget = marker.element;
|
|
2171
|
+
hoverTarget.addEventListener("dblclick", (e) => {
|
|
2172
|
+
e.stopPropagation();
|
|
2173
|
+
this.suppressNativeZoom();
|
|
2174
|
+
this.emitEntityInteraction("dblclick", entity);
|
|
2175
|
+
});
|
|
2176
|
+
// Hover — spurious-mouseleave-safe implementation via attachSafeAreaHoverListeners.
|
|
2177
|
+
// See layerPortHelpers.ts for the full contract.
|
|
2178
|
+
attachSafeAreaHoverListeners(hoverTarget, () => this.emitEntityInteraction("hover-start", entity), () => this.emitEntityInteraction("hover-end", entity), this.markerHoverState);
|
|
2179
|
+
}
|
|
2180
|
+
attachClusterInteractionListeners(marker, featureId, coords, markerIds, clusterBbox, tracked) {
|
|
2181
|
+
this.updateClusterMarkerEntity(marker, featureId, coords, markerIds, clusterBbox);
|
|
2182
|
+
const emitClusterInteraction = (type) => {
|
|
2183
|
+
const entity = this.clusterEntitiesByMarker.get(marker);
|
|
2184
|
+
if (entity !== undefined) {
|
|
2185
|
+
this.emitEntityInteraction(type, entity);
|
|
2186
|
+
}
|
|
2187
|
+
};
|
|
2188
|
+
const clickListener = marker.addListener("gmp-click", () => {
|
|
2189
|
+
emitClusterInteraction("click");
|
|
2190
|
+
});
|
|
2191
|
+
tracked.listeners.push(clickListener);
|
|
2192
|
+
const clusterHoverTarget = marker.element;
|
|
2193
|
+
clusterHoverTarget.addEventListener("dblclick", (e) => {
|
|
2194
|
+
e.stopPropagation();
|
|
2195
|
+
this.suppressNativeZoom();
|
|
2196
|
+
emitClusterInteraction("dblclick");
|
|
2197
|
+
});
|
|
2198
|
+
clusterHoverTarget.addEventListener("mouseenter", () => {
|
|
2199
|
+
emitClusterInteraction("hover-start");
|
|
2200
|
+
});
|
|
2201
|
+
clusterHoverTarget.addEventListener("mouseleave", () => {
|
|
2202
|
+
emitClusterInteraction("hover-end");
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
updateClusterMarkerEntity(marker, featureId, coords, markerIds, clusterBbox) {
|
|
2206
|
+
const entity = {
|
|
2207
|
+
type: "cluster",
|
|
2208
|
+
id: featureId,
|
|
2209
|
+
position: [coords.lng, coords.lat],
|
|
2210
|
+
markerIds,
|
|
2211
|
+
bbox: clusterBbox,
|
|
2212
|
+
};
|
|
2213
|
+
this.clusterEntitiesByMarker.set(marker, entity);
|
|
2214
|
+
}
|
|
2215
|
+
attachShapeInteractionListeners(shape, featureId, shapeType, tracked, handleId) {
|
|
2216
|
+
const entity = {
|
|
2217
|
+
type: "shape",
|
|
2218
|
+
id: featureId,
|
|
2219
|
+
shapeType,
|
|
2220
|
+
handleId,
|
|
2221
|
+
};
|
|
2222
|
+
const listeners = [
|
|
2223
|
+
shape.addListener("mousedown", () => {
|
|
2224
|
+
this.holdNativeZoomForDblClick();
|
|
2225
|
+
}),
|
|
2226
|
+
shape.addListener("click", () => {
|
|
2227
|
+
this.lastShapeClickTime = Date.now();
|
|
2228
|
+
this.lastShapeClickEntity = entity;
|
|
2229
|
+
this.emitEntityInteraction("click", entity);
|
|
2230
|
+
}),
|
|
2231
|
+
shape.addListener("mouseover", () => {
|
|
2232
|
+
this.emitEntityInteraction("hover-start", entity);
|
|
2233
|
+
}),
|
|
2234
|
+
shape.addListener("mousemove", (e) => {
|
|
2235
|
+
const latLng = e.latLng;
|
|
2236
|
+
if (latLng === null)
|
|
2237
|
+
return;
|
|
2238
|
+
this.pointerMoveReporter?.(latLngToPosition(latLng));
|
|
2239
|
+
}),
|
|
2240
|
+
shape.addListener("mouseout", () => {
|
|
2241
|
+
this.emitEntityInteraction("hover-end", entity);
|
|
2242
|
+
}),
|
|
2243
|
+
];
|
|
2244
|
+
tracked.listeners.push(...listeners);
|
|
2245
|
+
tracked.shapeListeners.set(shape, listeners);
|
|
2246
|
+
}
|
|
2247
|
+
attachShapePointInteractionListeners(marker, featureId, shapeType, handleId) {
|
|
2248
|
+
const entity = {
|
|
2249
|
+
type: "shape",
|
|
2250
|
+
id: featureId,
|
|
2251
|
+
shapeType,
|
|
2252
|
+
handleId,
|
|
2253
|
+
};
|
|
2254
|
+
const content = marker.content;
|
|
2255
|
+
if (!(content instanceof HTMLElement))
|
|
2256
|
+
return;
|
|
2257
|
+
content.style.cursor = MAP_CURSORS.interactive;
|
|
2258
|
+
content.addEventListener("click", () => {
|
|
2259
|
+
this.emitEntityInteraction("click", entity);
|
|
2260
|
+
});
|
|
2261
|
+
content.addEventListener("dblclick", (e) => {
|
|
2262
|
+
e.stopPropagation();
|
|
2263
|
+
this.suppressNativeZoom();
|
|
2264
|
+
this.emitEntityInteraction("dblclick", entity);
|
|
2265
|
+
});
|
|
2266
|
+
content.addEventListener("mouseenter", () => {
|
|
2267
|
+
this.emitEntityInteraction("hover-start", entity);
|
|
2268
|
+
});
|
|
2269
|
+
content.addEventListener("mouseleave", () => {
|
|
2270
|
+
this.emitEntityInteraction("hover-end", entity);
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
attachRouteInteractionListeners(polyline, featureId, feature, tracked) {
|
|
2274
|
+
const waypoints = feature.geometry !== null && feature.geometry.type === "LineString"
|
|
2275
|
+
? feature.geometry.coordinates.map(c => [c[0], c[1]])
|
|
2276
|
+
: [];
|
|
2277
|
+
const entity = { type: "route", id: featureId, waypoints };
|
|
2278
|
+
tracked.listeners.push(polyline.addListener("click", () => {
|
|
2279
|
+
this.emitEntityInteraction("click", entity);
|
|
2280
|
+
}));
|
|
2281
|
+
tracked.listeners.push(polyline.addListener("dblclick", () => {
|
|
2282
|
+
this.suppressNativeZoom();
|
|
2283
|
+
this.emitEntityInteraction("dblclick", entity);
|
|
2284
|
+
}));
|
|
2285
|
+
tracked.listeners.push(polyline.addListener("mouseover", () => {
|
|
2286
|
+
this.emitEntityInteraction("hover-start", entity);
|
|
2287
|
+
}));
|
|
2288
|
+
tracked.listeners.push(polyline.addListener("mouseout", () => {
|
|
2289
|
+
this.emitEntityInteraction("hover-end", entity);
|
|
2290
|
+
}));
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Attach a DOM-level dblclick listener on the map container.
|
|
2294
|
+
* Google Maps overlay events suppress the second click and dblclick when
|
|
2295
|
+
* disableDoubleClickZoom is true, so we rely on the browser's own dblclick
|
|
2296
|
+
* on the container instead.
|
|
2297
|
+
*/
|
|
2298
|
+
setupMapClickListener(map) {
|
|
2299
|
+
if (this.mapClickListener !== null) {
|
|
2300
|
+
google.maps.event.removeListener(this.mapClickListener);
|
|
2301
|
+
}
|
|
2302
|
+
this.mapClickListener = map.addListener("click", () => {
|
|
2303
|
+
if (!this.entityClickedInTick) {
|
|
2304
|
+
this.backgroundClickHandlers.forEach(handler => handler());
|
|
2305
|
+
}
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
setupMapContainerDblClickListener(map) {
|
|
2309
|
+
this.mapDblClickListenerCleanup?.();
|
|
2310
|
+
const container = map.getDiv();
|
|
2311
|
+
const handler = () => {
|
|
2312
|
+
const elapsed = Date.now() - this.lastShapeClickTime;
|
|
2313
|
+
const entity = this.lastShapeClickEntity;
|
|
2314
|
+
if (entity !== null && elapsed < GoogleMapsLayerPort.DBLCLICK_THRESHOLD_MS) {
|
|
2315
|
+
if (this.holdNativeZoomTimer !== null) {
|
|
2316
|
+
clearTimeout(this.holdNativeZoomTimer);
|
|
2317
|
+
this.holdNativeZoomTimer = null;
|
|
2318
|
+
}
|
|
2319
|
+
this.suppressNativeZoom();
|
|
2320
|
+
this.emitEntityInteraction("dblclick", entity);
|
|
2321
|
+
this.lastShapeClickEntity = null;
|
|
2322
|
+
}
|
|
2323
|
+
};
|
|
2324
|
+
container.addEventListener("dblclick", handler, true);
|
|
2325
|
+
this.mapDblClickListenerCleanup = () => {
|
|
2326
|
+
container.removeEventListener("dblclick", handler, true);
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* On shape mousedown, temporarily hold native zoom disabled so the browser
|
|
2331
|
+
* can complete its dblclick detection without the map moving the shape.
|
|
2332
|
+
* Re-enables after the dblclick threshold elapses.
|
|
2333
|
+
*/
|
|
2334
|
+
holdNativeZoomForDblClick() {
|
|
2335
|
+
if (this.map === null)
|
|
2336
|
+
return;
|
|
2337
|
+
const m = this.map;
|
|
2338
|
+
m.set("disableDoubleClickZoom", true);
|
|
2339
|
+
if (this.holdNativeZoomTimer !== null) {
|
|
2340
|
+
clearTimeout(this.holdNativeZoomTimer);
|
|
2341
|
+
}
|
|
2342
|
+
this.holdNativeZoomTimer = setTimeout(() => {
|
|
2343
|
+
m.set("disableDoubleClickZoom", false);
|
|
2344
|
+
this.holdNativeZoomTimer = null;
|
|
2345
|
+
}, GoogleMapsLayerPort.DBLCLICK_THRESHOLD_MS);
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Temporarily disable the map's built-in double-click zoom so that our
|
|
2349
|
+
* custom dblclick handler can call fitBounds without the native zoom
|
|
2350
|
+
* overriding it. Re-enables on the next tick.
|
|
2351
|
+
*/
|
|
2352
|
+
suppressNativeZoom() {
|
|
2353
|
+
if (this.map === null)
|
|
2354
|
+
return;
|
|
2355
|
+
const m = this.map;
|
|
2356
|
+
m.set("disableDoubleClickZoom", true);
|
|
2357
|
+
setTimeout(() => {
|
|
2358
|
+
m.set("disableDoubleClickZoom", false);
|
|
2359
|
+
}, 0);
|
|
2360
|
+
}
|
|
2361
|
+
/** Emit entity interaction event to all subscribed handlers */
|
|
2362
|
+
emitEntityInteraction(type, entity) {
|
|
2363
|
+
if (type === "click") {
|
|
2364
|
+
this.entityClickedInTick = true;
|
|
2365
|
+
setTimeout(() => {
|
|
2366
|
+
this.entityClickedInTick = false;
|
|
2367
|
+
}, 0);
|
|
2368
|
+
}
|
|
2369
|
+
const event = { type, entity };
|
|
2370
|
+
this.interactionHandlers.forEach(handler => handler(event));
|
|
2371
|
+
}
|
|
2372
|
+
// ============================================================================
|
|
2373
|
+
// Private: Reconnection (theme change creates a new map instance)
|
|
2374
|
+
// ============================================================================
|
|
2375
|
+
/**
|
|
2376
|
+
* When the Google Maps instance is recreated (e.g. theme change), all
|
|
2377
|
+
* existing Google Maps objects lose their reference to the old map.
|
|
2378
|
+
* We need to re-create them on the new map.
|
|
2379
|
+
*
|
|
2380
|
+
* We collect the current source configs from tracked state and re-apply
|
|
2381
|
+
* them. Since set*Source calls removeSource first, this cleanly replaces.
|
|
2382
|
+
*/
|
|
2383
|
+
reconnectAllSources() {
|
|
2384
|
+
// Re-assign existing markers to the new map
|
|
2385
|
+
for (const tracked of this.markerSources.values()) {
|
|
2386
|
+
// If there's a clusterer, re-assign its map reference
|
|
2387
|
+
if (tracked.clusterer !== undefined && this.map !== null) {
|
|
2388
|
+
tracked.clusterer.setMap(this.map);
|
|
2389
|
+
}
|
|
2390
|
+
// Re-assign canvas overlay to the new map
|
|
2391
|
+
if (tracked.canvasOverlay !== undefined) {
|
|
2392
|
+
tracked.canvasOverlay.setMap(this.map);
|
|
2393
|
+
}
|
|
2394
|
+
for (const marker of tracked.markers) {
|
|
2395
|
+
marker.map = this.map;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
// Re-assign existing shapes and hit polylines
|
|
2399
|
+
for (const tracked of this.shapeSources.values()) {
|
|
2400
|
+
for (const shape of tracked.shapes) {
|
|
2401
|
+
shape.setMap(this.map);
|
|
2402
|
+
}
|
|
2403
|
+
for (const pl of tracked.hitPolylines) {
|
|
2404
|
+
pl.setMap(this.map);
|
|
2405
|
+
}
|
|
2406
|
+
for (const marker of tracked.pointMarkers) {
|
|
2407
|
+
marker.map = this.map;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
// Re-assign existing routes
|
|
2411
|
+
for (const tracked of this.routeSources.values()) {
|
|
2412
|
+
tracked.polyline.setMap(this.map);
|
|
2413
|
+
}
|
|
2414
|
+
// Re-assign existing overlays
|
|
2415
|
+
for (const tracked of this.overlaySources.values()) {
|
|
2416
|
+
tracked.overlay.setMap(this.map);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
GoogleMapsLayerPort.DBLCLICK_THRESHOLD_MS = 400;
|
|
2421
|
+
|
|
2422
|
+
/**
|
|
2423
|
+
* Google Maps adapter instance implementation
|
|
2424
|
+
* Manages the connection between our abstract interface and Google Maps API
|
|
2425
|
+
*/
|
|
2426
|
+
class GoogleMapsAdapterInstance {
|
|
2427
|
+
constructor(config) {
|
|
2428
|
+
this.config = config;
|
|
2429
|
+
this.map = null;
|
|
2430
|
+
this.isDestroyedFlag = false;
|
|
2431
|
+
this.state = INITIAL_MAP_STATE;
|
|
2432
|
+
this.cachedCameraState = {
|
|
2433
|
+
center: INITIAL_MAP_STATE.center,
|
|
2434
|
+
zoom: INITIAL_MAP_STATE.zoom,
|
|
2435
|
+
bounds: INITIAL_MAP_STATE.bounds,
|
|
2436
|
+
isIdle: INITIAL_MAP_STATE.isIdle,
|
|
2437
|
+
};
|
|
2438
|
+
this.cachedStatus = {
|
|
2439
|
+
isReady: INITIAL_MAP_STATE.isReady,
|
|
2440
|
+
initializationFailed: INITIAL_MAP_STATE.initializationFailed,
|
|
2441
|
+
appearance: INITIAL_MAP_STATE.appearance,
|
|
2442
|
+
tileSize: INITIAL_MAP_STATE.tileSize,
|
|
2443
|
+
};
|
|
2444
|
+
this.cameraListeners = new Set();
|
|
2445
|
+
this.statusListeners = new Set();
|
|
2446
|
+
this.eventListeners = new Map();
|
|
2447
|
+
this.googleEventListeners = [];
|
|
2448
|
+
/** Throttled pointermove channel shared by map mousemove and shape overlay mousemove. */
|
|
2449
|
+
this.pointerMoveRaf = null;
|
|
2450
|
+
this.lastPointerPosition = null;
|
|
2451
|
+
this.appearance = { ...DEFAULT_MAP_APPEARANCE, theme: config.theme ?? "light" };
|
|
2452
|
+
this.layers = new GoogleMapsLayerPort();
|
|
2453
|
+
this.layers.setTheme(this.appearance.theme);
|
|
2454
|
+
this.state = computeInitialState(this.appearance, config.initialViewport);
|
|
2455
|
+
this.cachedCameraState = {
|
|
2456
|
+
center: this.state.center,
|
|
2457
|
+
zoom: this.state.zoom,
|
|
2458
|
+
bounds: this.state.bounds,
|
|
2459
|
+
isIdle: this.state.isIdle,
|
|
2460
|
+
};
|
|
2461
|
+
this.cachedStatus = {
|
|
2462
|
+
isReady: this.state.isReady,
|
|
2463
|
+
initializationFailed: this.state.initializationFailed,
|
|
2464
|
+
appearance: this.state.appearance,
|
|
2465
|
+
tileSize: this.state.tileSize,
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
// ============================================================================
|
|
2469
|
+
// Public AdapterInstance implementation
|
|
2470
|
+
// ============================================================================
|
|
2471
|
+
/**
|
|
2472
|
+
* Connect to a Google Maps instance - called by GoogleMapsRenderer.
|
|
2473
|
+
*
|
|
2474
|
+
* Handles both first connection and reconnection (e.g. after a theme change
|
|
2475
|
+
* causes vis.gl to create a new google.maps.Map instance for the new mapId).
|
|
2476
|
+
* On reconnection the previous center, zoom, and map type are restored so
|
|
2477
|
+
* the user doesn't see the viewport jump.
|
|
2478
|
+
*/
|
|
2479
|
+
connect(map) {
|
|
2480
|
+
// React 18 StrictMode (dev) runs effect cleanups between the first and second mount, so
|
|
2481
|
+
// `useMap`'s destroy cleanup fires before the real connect. Allow `connect` to revive a
|
|
2482
|
+
// destroyed adapter so the dev cycle self-heals; production unmounts never re-mount.
|
|
2483
|
+
if (this.isDestroyedFlag) {
|
|
2484
|
+
this.isDestroyedFlag = false;
|
|
2485
|
+
}
|
|
2486
|
+
// Already connected to this exact instance — nothing to do
|
|
2487
|
+
if (this.map === map)
|
|
2488
|
+
return;
|
|
2489
|
+
const isReconnection = this.map !== null;
|
|
2490
|
+
// Save viewport state before switching so we can restore it on the new map
|
|
2491
|
+
const previousState = isReconnection ? { ...this.state } : null;
|
|
2492
|
+
// Tear down listeners on the old map (if any)
|
|
2493
|
+
if (isReconnection) {
|
|
2494
|
+
this.removeGoogleEventListeners();
|
|
2495
|
+
}
|
|
2496
|
+
this.map = map;
|
|
2497
|
+
this.layers.connect(map);
|
|
2498
|
+
this.setupEventListeners();
|
|
2499
|
+
this.layers.setPointerMoveReporter(position => {
|
|
2500
|
+
this.reportPointerMove(position);
|
|
2501
|
+
});
|
|
2502
|
+
// Restore previous viewport and map type after map recreation
|
|
2503
|
+
if (previousState !== null) {
|
|
2504
|
+
map.setCenter(positionToLatLng(previousState.center));
|
|
2505
|
+
map.setZoom(previousState.zoom);
|
|
2506
|
+
map.setMapTypeId(this.mapTypeToGoogleMapTypeId(this.appearance.mapType, this.appearance.showRoads));
|
|
2507
|
+
}
|
|
2508
|
+
if (!this.state.isReady || this.state.initializationFailed) {
|
|
2509
|
+
// Successful connect clears a prior init failure so recovery is possible (e.g. API load failed then
|
|
2510
|
+
// a later script retry / new Map instance from vis.gl). Idempotent when already ready and not failed.
|
|
2511
|
+
this.state = { ...this.state, isReady: true, initializationFailed: false };
|
|
2512
|
+
this.updateStatusCache();
|
|
2513
|
+
this.notifyStatusListeners();
|
|
2514
|
+
}
|
|
2515
|
+
// Sync adapter state from the (possibly new) map
|
|
2516
|
+
this.updateState(true);
|
|
2517
|
+
}
|
|
2518
|
+
getConfig() {
|
|
2519
|
+
return this.config;
|
|
2520
|
+
}
|
|
2521
|
+
getCameraState() {
|
|
2522
|
+
return this.cachedCameraState;
|
|
2523
|
+
}
|
|
2524
|
+
getStatus() {
|
|
2525
|
+
return this.cachedStatus;
|
|
2526
|
+
}
|
|
2527
|
+
subscribeCamera(listener) {
|
|
2528
|
+
this.cameraListeners.add(listener);
|
|
2529
|
+
return () => {
|
|
2530
|
+
this.cameraListeners.delete(listener);
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
subscribeStatus(listener) {
|
|
2534
|
+
this.statusListeners.add(listener);
|
|
2535
|
+
return () => {
|
|
2536
|
+
this.statusListeners.delete(listener);
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Returns the current theme.
|
|
2541
|
+
* Used by GoogleMapsRenderer (via useSyncExternalStore) to reactively
|
|
2542
|
+
* update the mapId prop on the <GoogleMap> component.
|
|
2543
|
+
*/
|
|
2544
|
+
getTheme() {
|
|
2545
|
+
return this.appearance.theme;
|
|
2546
|
+
}
|
|
2547
|
+
async setCenter(center) {
|
|
2548
|
+
const validCenter = validatePosition(center, "[setCenter]");
|
|
2549
|
+
if (validCenter === null)
|
|
2550
|
+
return;
|
|
2551
|
+
const currentMap = this.map;
|
|
2552
|
+
if (currentMap === null)
|
|
2553
|
+
return;
|
|
2554
|
+
return new Promise(resolve => {
|
|
2555
|
+
currentMap.setCenter(positionToLatLng(validCenter));
|
|
2556
|
+
google.maps.event.addListenerOnce(currentMap, "idle", () => resolve());
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
async setZoom(zoomLevel) {
|
|
2560
|
+
const currentMap = this.map;
|
|
2561
|
+
if (currentMap === null)
|
|
2562
|
+
return;
|
|
2563
|
+
return new Promise(resolve => {
|
|
2564
|
+
currentMap.setZoom(zoomLevel);
|
|
2565
|
+
google.maps.event.addListenerOnce(currentMap, "idle", () => resolve());
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
async zoomBy(delta) {
|
|
2569
|
+
const currentMap = this.map;
|
|
2570
|
+
if (currentMap === null)
|
|
2571
|
+
return;
|
|
2572
|
+
const currentZoom = currentMap.getZoom();
|
|
2573
|
+
if (currentZoom === undefined)
|
|
2574
|
+
return;
|
|
2575
|
+
return this.setZoom(currentZoom + delta);
|
|
2576
|
+
}
|
|
2577
|
+
async fitBounds(bounds, options) {
|
|
2578
|
+
const validBounds = validateBbox(bounds, "[fitBounds]");
|
|
2579
|
+
if (validBounds === null)
|
|
2580
|
+
return;
|
|
2581
|
+
const currentMap = this.map;
|
|
2582
|
+
if (currentMap === null)
|
|
2583
|
+
return;
|
|
2584
|
+
const effectiveBounds = getEffectiveRestrictBounds(this.config.restrictBounds);
|
|
2585
|
+
return new Promise(resolve => {
|
|
2586
|
+
if (effectiveBounds !== null) {
|
|
2587
|
+
// Temporarily remove restriction to allow fitBounds to work fully
|
|
2588
|
+
currentMap.setOptions({ restriction: undefined });
|
|
2589
|
+
}
|
|
2590
|
+
currentMap.fitBounds(bboxToLatLngBounds(validBounds), options?.padding ?? 0);
|
|
2591
|
+
const maxZoom = options?.maxZoom;
|
|
2592
|
+
if (maxZoom !== undefined) {
|
|
2593
|
+
const currentZoom = currentMap.getZoom();
|
|
2594
|
+
if (currentZoom !== undefined && currentZoom > maxZoom) {
|
|
2595
|
+
currentMap.setZoom(maxZoom);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
google.maps.event.addListenerOnce(currentMap, "idle", () => {
|
|
2599
|
+
if (effectiveBounds !== null) {
|
|
2600
|
+
currentMap.setOptions({
|
|
2601
|
+
restriction: { latLngBounds: bboxToLatLngBounds(effectiveBounds), strictBounds: true },
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
resolve();
|
|
2605
|
+
});
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
async panTo(center) {
|
|
2609
|
+
const validCenter = validatePosition(center, "[panTo]");
|
|
2610
|
+
if (validCenter === null)
|
|
2611
|
+
return;
|
|
2612
|
+
const currentMap = this.map;
|
|
2613
|
+
if (currentMap === null)
|
|
2614
|
+
return;
|
|
2615
|
+
return new Promise(resolve => {
|
|
2616
|
+
currentMap.panTo(positionToLatLng(validCenter));
|
|
2617
|
+
google.maps.event.addListenerOnce(currentMap, "idle", () => resolve());
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
async panBy(deltaX, deltaY) {
|
|
2621
|
+
const currentMap = this.map;
|
|
2622
|
+
if (currentMap === null)
|
|
2623
|
+
return;
|
|
2624
|
+
currentMap.panBy(deltaX, deltaY);
|
|
2625
|
+
}
|
|
2626
|
+
async setMapType(type) {
|
|
2627
|
+
if (this.appearance.mapType === type)
|
|
2628
|
+
return;
|
|
2629
|
+
this.appearance = { ...this.appearance, mapType: type };
|
|
2630
|
+
this.state = { ...this.state, appearance: this.appearance };
|
|
2631
|
+
this.updateStatusCache();
|
|
2632
|
+
this.notifyStatusListeners();
|
|
2633
|
+
const currentMap = this.map;
|
|
2634
|
+
if (currentMap === null)
|
|
2635
|
+
return;
|
|
2636
|
+
currentMap.setMapTypeId(this.mapTypeToGoogleMapTypeId(type, this.appearance.showRoads));
|
|
2637
|
+
}
|
|
2638
|
+
async setTheme(theme) {
|
|
2639
|
+
if (this.appearance.theme === theme)
|
|
2640
|
+
return;
|
|
2641
|
+
// Google Maps does NOT support changing mapId on an existing map instance.
|
|
2642
|
+
// Instead we update our tracked theme and notify subscribers so the
|
|
2643
|
+
// GoogleMapsRenderer re-renders with the new mapId prop, which causes
|
|
2644
|
+
// @vis.gl/react-google-maps to create a fresh map instance.
|
|
2645
|
+
this.appearance = { ...this.appearance, theme };
|
|
2646
|
+
this.state = { ...this.state, appearance: this.appearance };
|
|
2647
|
+
this.layers.setTheme(theme);
|
|
2648
|
+
this.updateStatusCache();
|
|
2649
|
+
this.notifyStatusListeners();
|
|
2650
|
+
}
|
|
2651
|
+
async setShowRoads(showRoads) {
|
|
2652
|
+
if (this.appearance.showRoads === showRoads)
|
|
2653
|
+
return;
|
|
2654
|
+
this.appearance = { ...this.appearance, showRoads };
|
|
2655
|
+
this.state = { ...this.state, appearance: this.appearance };
|
|
2656
|
+
this.updateStatusCache();
|
|
2657
|
+
this.notifyStatusListeners();
|
|
2658
|
+
// Re-apply the effective map type (satellite vs hybrid) if on satellite/hybrid
|
|
2659
|
+
const currentMap = this.map;
|
|
2660
|
+
if (currentMap === null)
|
|
2661
|
+
return;
|
|
2662
|
+
if (this.appearance.mapType === "satellite" || this.appearance.mapType === "hybrid") {
|
|
2663
|
+
currentMap.setMapTypeId(this.mapTypeToGoogleMapTypeId(this.appearance.mapType, showRoads));
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
on(event, handler) {
|
|
2667
|
+
let listeners = this.eventListeners.get(event);
|
|
2668
|
+
if (listeners === undefined) {
|
|
2669
|
+
listeners = new Set();
|
|
2670
|
+
this.eventListeners.set(event, listeners);
|
|
2671
|
+
}
|
|
2672
|
+
// Store handler as generic event handler
|
|
2673
|
+
const storedHandler = mapEvent => {
|
|
2674
|
+
// Only call handler if event type matches (runtime check + type narrowing)
|
|
2675
|
+
if (isEventOfType(mapEvent, event)) {
|
|
2676
|
+
handler(mapEvent);
|
|
2677
|
+
}
|
|
2678
|
+
};
|
|
2679
|
+
listeners.add(storedHandler);
|
|
2680
|
+
return () => {
|
|
2681
|
+
listeners.delete(storedHandler);
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
notifyInitializationFailed() {
|
|
2685
|
+
if (this.isDestroyedFlag) {
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
if (this.state.initializationFailed) {
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
this.state = { ...this.state, isReady: false, initializationFailed: true };
|
|
2692
|
+
this.updateStatusCache();
|
|
2693
|
+
this.notifyStatusListeners();
|
|
2694
|
+
}
|
|
2695
|
+
destroy() {
|
|
2696
|
+
if (this.isDestroyedFlag) {
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
this.isDestroyedFlag = true;
|
|
2700
|
+
this.removeGoogleEventListeners();
|
|
2701
|
+
this.layers.setPointerMoveReporter(null);
|
|
2702
|
+
this.layers.destroy();
|
|
2703
|
+
// Clear our listeners
|
|
2704
|
+
this.cameraListeners.clear();
|
|
2705
|
+
this.statusListeners.clear();
|
|
2706
|
+
this.eventListeners.clear();
|
|
2707
|
+
this.map = null;
|
|
2708
|
+
// Set isReady: false in state (listeners already cleared, no need to notify)
|
|
2709
|
+
this.state = { ...INITIAL_MAP_STATE, isReady: false };
|
|
2710
|
+
this.updateCameraCache();
|
|
2711
|
+
this.updateStatusCache();
|
|
2712
|
+
}
|
|
2713
|
+
// ============================================================================
|
|
2714
|
+
// Private methods
|
|
2715
|
+
// ============================================================================
|
|
2716
|
+
/** Detach all Google Maps native event listeners without clearing our own subscribers */
|
|
2717
|
+
removeGoogleEventListeners() {
|
|
2718
|
+
this.googleEventListeners.forEach(listener => {
|
|
2719
|
+
google.maps.event.removeListener(listener);
|
|
2720
|
+
});
|
|
2721
|
+
this.googleEventListeners = [];
|
|
2722
|
+
if (this.pointerMoveRaf !== null) {
|
|
2723
|
+
cancelAnimationFrame(this.pointerMoveRaf);
|
|
2724
|
+
this.pointerMoveRaf = null;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
/** Emit a throttled pointermove event (map canvas + shape overlays). */
|
|
2728
|
+
reportPointerMove(position) {
|
|
2729
|
+
this.lastPointerPosition = position;
|
|
2730
|
+
if (this.pointerMoveRaf !== null)
|
|
2731
|
+
return;
|
|
2732
|
+
this.pointerMoveRaf = requestAnimationFrame(() => {
|
|
2733
|
+
this.pointerMoveRaf = null;
|
|
2734
|
+
if (this.lastPointerPosition !== null) {
|
|
2735
|
+
this.emitEvent({ type: "pointermove", position: this.lastPointerPosition });
|
|
2736
|
+
}
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
setupEventListeners() {
|
|
2740
|
+
const currentMap = this.map;
|
|
2741
|
+
if (currentMap === null)
|
|
2742
|
+
return;
|
|
2743
|
+
// Idle event — camera has settled after pan/zoom
|
|
2744
|
+
this.googleEventListeners.push(currentMap.addListener("idle", () => {
|
|
2745
|
+
this.updateState(true);
|
|
2746
|
+
this.emitEvent({ type: "idle" });
|
|
2747
|
+
}));
|
|
2748
|
+
const markCameraActive = () => {
|
|
2749
|
+
if (this.state.isIdle) {
|
|
2750
|
+
this.state = { ...this.state, isIdle: false };
|
|
2751
|
+
this.updateCameraCache();
|
|
2752
|
+
this.notifyCameraListeners();
|
|
2753
|
+
}
|
|
2754
|
+
};
|
|
2755
|
+
// Move start
|
|
2756
|
+
this.googleEventListeners.push(currentMap.addListener("dragstart", () => {
|
|
2757
|
+
markCameraActive();
|
|
2758
|
+
this.emitEvent({ type: "movestart" });
|
|
2759
|
+
}));
|
|
2760
|
+
// Wheel / control zoom also fires bounds_changed but not dragstart
|
|
2761
|
+
this.googleEventListeners.push(currentMap.addListener("zoom_changed", () => {
|
|
2762
|
+
markCameraActive();
|
|
2763
|
+
}));
|
|
2764
|
+
// Bounds changed (during move) — keep isIdle false until the idle event
|
|
2765
|
+
this.googleEventListeners.push(currentMap.addListener("bounds_changed", () => {
|
|
2766
|
+
this.updateState(false);
|
|
2767
|
+
}));
|
|
2768
|
+
// Click event
|
|
2769
|
+
this.googleEventListeners.push(currentMap.addListener("click", (e) => {
|
|
2770
|
+
const latLng = e.latLng;
|
|
2771
|
+
if (latLng === null)
|
|
2772
|
+
return;
|
|
2773
|
+
this.emitEvent({
|
|
2774
|
+
type: "click",
|
|
2775
|
+
position: latLngToPosition(latLng),
|
|
2776
|
+
originalEvent: e.domEvent instanceof MouseEvent ? e.domEvent : new MouseEvent("click"),
|
|
2777
|
+
});
|
|
2778
|
+
}));
|
|
2779
|
+
// Pointer move (ADR-0021 fill tiling) — throttled to one emit per frame.
|
|
2780
|
+
// Shape overlays also report via setPointerMoveReporter; map mousemove alone
|
|
2781
|
+
// does not fire while the cursor is over clickable polygons/polylines.
|
|
2782
|
+
this.googleEventListeners.push(currentMap.addListener("mousemove", (e) => {
|
|
2783
|
+
const latLng = e.latLng;
|
|
2784
|
+
if (latLng === null)
|
|
2785
|
+
return;
|
|
2786
|
+
this.reportPointerMove(latLngToPosition(latLng));
|
|
2787
|
+
}));
|
|
2788
|
+
}
|
|
2789
|
+
updateState(isIdle) {
|
|
2790
|
+
const currentMap = this.map;
|
|
2791
|
+
if (currentMap === null)
|
|
2792
|
+
return;
|
|
2793
|
+
const center = currentMap.getCenter();
|
|
2794
|
+
const zoom = currentMap.getZoom();
|
|
2795
|
+
if (center === undefined || zoom === undefined)
|
|
2796
|
+
return;
|
|
2797
|
+
const bounds = currentMap.getBounds();
|
|
2798
|
+
const boundsValue = bounds ? latLngBoundsToBbox(bounds) : null;
|
|
2799
|
+
const newCamera = {
|
|
2800
|
+
center: latLngToPosition(center),
|
|
2801
|
+
zoom,
|
|
2802
|
+
bounds: boundsValue,
|
|
2803
|
+
isIdle,
|
|
2804
|
+
};
|
|
2805
|
+
// Only notify camera subscribers if camera fields actually changed
|
|
2806
|
+
if (!cameraStateEquals(this.state, newCamera)) {
|
|
2807
|
+
this.state = { ...this.state, ...newCamera };
|
|
2808
|
+
this.updateCameraCache();
|
|
2809
|
+
this.notifyCameraListeners();
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
mapTypeToGoogleMapTypeId(type, showRoads) {
|
|
2813
|
+
switch (type) {
|
|
2814
|
+
case "roadmap":
|
|
2815
|
+
return google.maps.MapTypeId.ROADMAP;
|
|
2816
|
+
case "satellite":
|
|
2817
|
+
// If showRoads is true, use HYBRID to show roads over satellite imagery
|
|
2818
|
+
return showRoads === true ? google.maps.MapTypeId.HYBRID : google.maps.MapTypeId.SATELLITE;
|
|
2819
|
+
case "hybrid":
|
|
2820
|
+
return google.maps.MapTypeId.HYBRID;
|
|
2821
|
+
default: {
|
|
2822
|
+
const exhaustiveCheck = type;
|
|
2823
|
+
throw new Error(`Unknown map type: ${exhaustiveCheck}`);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
updateCameraCache() {
|
|
2828
|
+
const { center, zoom, bounds, isIdle } = this.state;
|
|
2829
|
+
this.cachedCameraState = { center, zoom, bounds, isIdle };
|
|
2830
|
+
}
|
|
2831
|
+
updateStatusCache() {
|
|
2832
|
+
const { isReady, initializationFailed, appearance, tileSize } = this.state;
|
|
2833
|
+
this.cachedStatus = { isReady, initializationFailed, appearance, tileSize };
|
|
2834
|
+
}
|
|
2835
|
+
notifyCameraListeners() {
|
|
2836
|
+
this.cameraListeners.forEach(listener => listener());
|
|
2837
|
+
}
|
|
2838
|
+
notifyStatusListeners() {
|
|
2839
|
+
this.statusListeners.forEach(listener => listener());
|
|
2840
|
+
}
|
|
2841
|
+
emitEvent(event) {
|
|
2842
|
+
const listeners = this.eventListeners.get(event.type);
|
|
2843
|
+
if (listeners !== undefined) {
|
|
2844
|
+
listeners.forEach(handler => {
|
|
2845
|
+
handler(event);
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
// Also emit moveend after idle if it was a move
|
|
2849
|
+
if (event.type === "idle") {
|
|
2850
|
+
const moveEndListeners = this.eventListeners.get("moveend");
|
|
2851
|
+
if (moveEndListeners !== undefined) {
|
|
2852
|
+
const moveEndEvent = { type: "moveend", state: this.state };
|
|
2853
|
+
moveEndListeners.forEach(handler => {
|
|
2854
|
+
handler(moveEndEvent);
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
const createGoogleMapsInstance = (config) => {
|
|
2861
|
+
return new GoogleMapsAdapterInstance(config);
|
|
2862
|
+
};
|
|
2863
|
+
|
|
2864
|
+
/**
|
|
2865
|
+
* Google Cloud Map IDs for different themes
|
|
2866
|
+
* These are configured in Google Cloud Console for custom styling
|
|
2867
|
+
*/
|
|
2868
|
+
const GOOGLE_MAP_IDS = {
|
|
2869
|
+
light: "462dc96a2b3709ee",
|
|
2870
|
+
dark: "8676e1fb4a115a8a",
|
|
2871
|
+
};
|
|
2872
|
+
|
|
2873
|
+
/**
|
|
2874
|
+
* Internal component that renders the actual Google Map
|
|
2875
|
+
* Must be inside APIProvider
|
|
2876
|
+
*/
|
|
2877
|
+
const GoogleMapInternal = ({ adapterInstance, children, className, style, theme, mapInstanceId, initialViewport, restrictBounds, }) => {
|
|
2878
|
+
const map = useMap(mapInstanceId);
|
|
2879
|
+
const loadingStatus = useApiLoadingStatus();
|
|
2880
|
+
const restrictionBbox = useMemo(() => getEffectiveRestrictBounds(restrictBounds), [restrictBounds]);
|
|
2881
|
+
const mapProps = useMemo(() => {
|
|
2882
|
+
return initialViewport?.type === "bounds"
|
|
2883
|
+
? {
|
|
2884
|
+
defaultBounds: {
|
|
2885
|
+
...bboxToLatLngBounds(initialViewport.bounds),
|
|
2886
|
+
padding: initialViewport.padding ?? 0,
|
|
2887
|
+
},
|
|
2888
|
+
}
|
|
2889
|
+
: {
|
|
2890
|
+
defaultCenter: initialViewport?.type === "center"
|
|
2891
|
+
? { lat: initialViewport.center[1], lng: initialViewport.center[0] }
|
|
2892
|
+
: DEFAULT_CENTER,
|
|
2893
|
+
defaultZoom: initialViewport?.type === "center" && initialViewport.zoom !== undefined
|
|
2894
|
+
? initialViewport.zoom
|
|
2895
|
+
: DEFAULT_ZOOM,
|
|
2896
|
+
};
|
|
2897
|
+
}, [initialViewport]);
|
|
2898
|
+
// Connect only after the Maps JS API reports loaded — avoids marking the adapter ready while
|
|
2899
|
+
// vis.gl is still in LOADING (if `useMap` ever yields a handle early).
|
|
2900
|
+
useEffect(() => {
|
|
2901
|
+
if (loadingStatus !== APILoadingStatus.LOADED) {
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
if (map !== null && adapterInstance instanceof GoogleMapsAdapterInstance) {
|
|
2905
|
+
adapterInstance.connect(map);
|
|
2906
|
+
}
|
|
2907
|
+
}, [loadingStatus, map, adapterInstance]);
|
|
2908
|
+
useEffect(() => {
|
|
2909
|
+
if (loadingStatus !== APILoadingStatus.FAILED) {
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
if (adapterInstance instanceof GoogleMapsAdapterInstance) {
|
|
2913
|
+
adapterInstance.notifyInitializationFailed();
|
|
2914
|
+
}
|
|
2915
|
+
}, [loadingStatus, adapterInstance]);
|
|
2916
|
+
/** Adapter readiness overlay is owned by `createMapComponent` (@trackunit/react-map). */
|
|
2917
|
+
if (loadingStatus === APILoadingStatus.LOADING || loadingStatus === APILoadingStatus.FAILED) {
|
|
2918
|
+
return null;
|
|
2919
|
+
}
|
|
2920
|
+
const restrictionProps = restrictionBbox !== null
|
|
2921
|
+
? {
|
|
2922
|
+
restriction: {
|
|
2923
|
+
latLngBounds: bboxToLatLngBounds(restrictionBbox),
|
|
2924
|
+
strictBounds: true,
|
|
2925
|
+
},
|
|
2926
|
+
}
|
|
2927
|
+
: {};
|
|
2928
|
+
return (jsx(Map$1, { className: className, clickableIcons: false, disableDefaultUI: true, draggableCursor: MAP_CURSORS.default, gestureHandling: isDesktop ? "greedy" : "cooperative", id: mapInstanceId, isFractionalZoomEnabled: true, mapId: GOOGLE_MAP_IDS[theme], style: { width: "100%", height: "100%", ...style }, ...restrictionProps, ...mapProps, children: children }));
|
|
2929
|
+
};
|
|
2930
|
+
/**
|
|
2931
|
+
* Google Maps Renderer component
|
|
2932
|
+
* Wraps the map with APIProvider. Loading/error UI is owned by `@trackunit/react-map` (`createMapComponent`);
|
|
2933
|
+
* this module reports API load failure on the adapter and returns `null` until the script is loaded.
|
|
2934
|
+
*
|
|
2935
|
+
* Subscribes to the adapter's current theme via `useSyncExternalStore` so
|
|
2936
|
+
* that when `setTheme()` is called, the component re-renders with the new
|
|
2937
|
+
* `mapId` prop, causing `@vis.gl/react-google-maps` to create a fresh
|
|
2938
|
+
* `google.maps.Map` instance styled with the correct cloud-based map style.
|
|
2939
|
+
*
|
|
2940
|
+
* When rendered inside an existing `APIProvider` (e.g. via `GoogleApiProvider`),
|
|
2941
|
+
* the renderer skips creating its own `APIProvider`, sharing the Google Maps
|
|
2942
|
+
* JS API context. Each map gets a unique `id` prop to avoid registry conflicts.
|
|
2943
|
+
*/
|
|
2944
|
+
const GoogleMapsRenderer = (props) => {
|
|
2945
|
+
const { adapterInstance } = props;
|
|
2946
|
+
// Narrow adapter instance to GoogleMapsAdapterInstance for config and theme access
|
|
2947
|
+
if (!(adapterInstance instanceof GoogleMapsAdapterInstance)) {
|
|
2948
|
+
throw new Error("GoogleMapsRenderer requires a GoogleMapsAdapterInstance");
|
|
2949
|
+
}
|
|
2950
|
+
const config = adapterInstance.getConfig();
|
|
2951
|
+
const { apiKey, language, region, initialViewport, restrictBounds } = config;
|
|
2952
|
+
// Subscribe to the adapter's current theme reactively.
|
|
2953
|
+
// When setTheme() updates currentTheme and notifies listeners, this
|
|
2954
|
+
// causes a re-render so the <GoogleMap> receives the updated mapId.
|
|
2955
|
+
const subscribeToTheme = useCallback((onStoreChange) => adapterInstance.subscribeStatus(onStoreChange), [adapterInstance]);
|
|
2956
|
+
const getThemeSnapshot = useCallback(() => adapterInstance.getTheme(), [adapterInstance]);
|
|
2957
|
+
const currentTheme = useSyncExternalStore(subscribeToTheme, getThemeSnapshot, getThemeSnapshot);
|
|
2958
|
+
// "marker" is required for AdvancedMarkerElement used by the layer port.
|
|
2959
|
+
// using satisfies to avoid type errors from the library while maintaining type safety
|
|
2960
|
+
const libraries = useMemo(() => ["marker"], []);
|
|
2961
|
+
// Unique instance ID so vis.gl's internal map registry can distinguish
|
|
2962
|
+
// multiple maps within the same APIProvider context.
|
|
2963
|
+
const autoId = useId();
|
|
2964
|
+
const mapInstanceId = config.mapInstanceId ?? autoId;
|
|
2965
|
+
// Detect if we're already inside an APIProvider (e.g. a shared GoogleApiProvider).
|
|
2966
|
+
// If so, skip creating our own — this prevents multiple concurrent API script
|
|
2967
|
+
// initializations that cause zombie google.maps.Map instances.
|
|
2968
|
+
const existingContext = useContext(APIProviderContext);
|
|
2969
|
+
const hasExternalProvider = existingContext !== null;
|
|
2970
|
+
const internalContent = (jsx(GoogleMapInternal, { ...props, initialViewport: initialViewport, mapInstanceId: mapInstanceId, restrictBounds: restrictBounds, theme: currentTheme }));
|
|
2971
|
+
if (hasExternalProvider) {
|
|
2972
|
+
return internalContent;
|
|
2973
|
+
}
|
|
2974
|
+
return (jsx(APIProvider, { apiKey: apiKey, language: language, libraries: libraries, region: region, children: internalContent }));
|
|
2975
|
+
};
|
|
2976
|
+
|
|
2977
|
+
/** Stable reference — inline objects break `useMap` Map memo equality and recreate the map every render. */
|
|
2978
|
+
const GOOGLE_MAPS_ADAPTER_SAFE_AREA_INSETS = {
|
|
2979
|
+
top: 0,
|
|
2980
|
+
right: 0,
|
|
2981
|
+
bottom: 24,
|
|
2982
|
+
left: 0,
|
|
2983
|
+
};
|
|
2984
|
+
/**
|
|
2985
|
+
* Google Maps adapter factory
|
|
2986
|
+
*
|
|
2987
|
+
* Creates an adapter configuration for Google Maps that can be passed to useMap.
|
|
2988
|
+
*
|
|
2989
|
+
* @example
|
|
2990
|
+
* ```tsx
|
|
2991
|
+
* const map = useMap(googleMapsAdapter({
|
|
2992
|
+
* apiKey: process.env.GOOGLE_MAPS_API_KEY,
|
|
2993
|
+
* theme: "light",
|
|
2994
|
+
* language: "en",
|
|
2995
|
+
* }));
|
|
2996
|
+
*
|
|
2997
|
+
* <map.Map className="h-full w-full">
|
|
2998
|
+
* {children}
|
|
2999
|
+
* </map.Map>
|
|
3000
|
+
* ```
|
|
3001
|
+
*/
|
|
3002
|
+
const googleMapsAdapter = defineAdapter((config) => ({
|
|
3003
|
+
name: "google",
|
|
3004
|
+
config,
|
|
3005
|
+
safeAreaInsets: GOOGLE_MAPS_ADAPTER_SAFE_AREA_INSETS,
|
|
3006
|
+
createInstance: () => createGoogleMapsInstance(config),
|
|
3007
|
+
Renderer: GoogleMapsRenderer,
|
|
3008
|
+
}));
|
|
3009
|
+
|
|
3010
|
+
export { GoogleApiProvider, GoogleMapsAdapterInstance, GoogleMapsRenderer, googleMapsAdapter };
|