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