@trackunit/react-map-adapter-google 0.0.132 → 0.0.134

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/README.md CHANGED
@@ -58,9 +58,15 @@ const irisAppManifest = {
58
58
 
59
59
  Use the exported directives instead of copying host lists or using the Manager-only `defaultCspConfig()`. This keeps the CSP requirement versioned with the adapter.
60
60
 
61
+ ## Heatmaps
62
+
63
+ - **Renderer:** lazy `@deck.gl/google-maps` overlay + `HeatmapLayer` (Google's own Heatmap Layer is decommissioned - see https://developers.google.com/maps/deprecations#heatmap-layer-js-deprecation).
64
+ - **Integration:** Heatmap layers from `@trackunit/react-map` (`layerType: "heatmap"` on the adapter snapshot) trigger lazy deck.gl inside this adapter — no deck.gl types or APIs are exported here.
65
+ - **Contract:** shared `HeatmapSourceConfig` from `@trackunit/react-map-adapter-shared`.
66
+
61
67
  ## Dependencies
62
68
 
63
- Depends on `@trackunit/react-map-adapter-shared` and Google Maps–related packages (e.g. `@vis.gl/react-google-maps`). It does **not** import `@trackunit/react-map` (adapters are consumed *by* the map package, not the reverse).
69
+ Depends on `@trackunit/react-map-adapter-shared`, `@trackunit/react-map-color-utils`, `@trackunit/geo-json-utils`, Google Maps packages (e.g. `@vis.gl/react-google-maps`, `@googlemaps/markerclusterer`), and `@deck.gl/*` (heatmap on Google — **dynamic import** at runtime when a heatmap is active; still declared npm dependencies). It does **not** import `@trackunit/react-map` (adapters are consumed *by* the map package, not the reverse).
64
70
 
65
71
  ## Part of the `@trackunit/react-map` family
66
72
 
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ var aggregationLayers = require('@deck.gl/aggregation-layers');
4
+
5
+ /**
6
+ * Maps {@link HeatmapSourceConfig} → deck.gl `HeatmapLayer` on Google Maps.
7
+ *
8
+ * Invoked from {@link DeckGlGoogleMapsLifecycle} after dynamic import.
9
+ */
10
+ const buildDeckGlHeatmapLayer = (config) => {
11
+ return new aggregationLayers.HeatmapLayer({
12
+ id: `heatmap-${config.id}`,
13
+ data: config.features.features,
14
+ getPosition: feature => {
15
+ if (feature.geometry?.type !== "Point")
16
+ return [0, 0];
17
+ return [feature.geometry.coordinates[0], feature.geometry.coordinates[1]];
18
+ },
19
+ getWeight: feature => {
20
+ const weight = feature.properties?.weight;
21
+ return typeof weight === "number" ? weight : 1;
22
+ },
23
+ aggregation: "SUM",
24
+ radiusPixels: config.style.radiusPixels,
25
+ intensity: config.style.intensity,
26
+ opacity: config.style.opacity,
27
+ });
28
+ };
29
+
30
+ exports.buildDeckGlHeatmapLayer = buildDeckGlHeatmapLayer;
@@ -0,0 +1,28 @@
1
+ import { HeatmapLayer } from '@deck.gl/aggregation-layers';
2
+
3
+ /**
4
+ * Maps {@link HeatmapSourceConfig} → deck.gl `HeatmapLayer` on Google Maps.
5
+ *
6
+ * Invoked from {@link DeckGlGoogleMapsLifecycle} after dynamic import.
7
+ */
8
+ const buildDeckGlHeatmapLayer = (config) => {
9
+ return new HeatmapLayer({
10
+ id: `heatmap-${config.id}`,
11
+ data: config.features.features,
12
+ getPosition: feature => {
13
+ if (feature.geometry?.type !== "Point")
14
+ return [0, 0];
15
+ return [feature.geometry.coordinates[0], feature.geometry.coordinates[1]];
16
+ },
17
+ getWeight: feature => {
18
+ const weight = feature.properties?.weight;
19
+ return typeof weight === "number" ? weight : 1;
20
+ },
21
+ aggregation: "SUM",
22
+ radiusPixels: config.style.radiusPixels,
23
+ intensity: config.style.intensity,
24
+ opacity: config.style.opacity,
25
+ });
26
+ };
27
+
28
+ export { buildDeckGlHeatmapLayer };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ var googleMaps = require('@deck.gl/google-maps');
4
+
5
+ /**
6
+ * Creates one `GoogleMapsOverlay` per map.
7
+ *
8
+ * See https://deck.gl/docs/api-reference/google-maps/google-maps-overlay
9
+ */
10
+ const createDeckGlGoogleMapsOverlayRuntime = () => ({
11
+ createOverlay: map => {
12
+ const overlay = new googleMaps.GoogleMapsOverlay({
13
+ layers: [],
14
+ });
15
+ overlay.setMap(map);
16
+ return {
17
+ setMap: nextMap => overlay.setMap(nextMap),
18
+ setLayers: layers => {
19
+ overlay.setProps({ layers: [...layers] });
20
+ },
21
+ finalize: () => overlay.finalize(),
22
+ };
23
+ },
24
+ });
25
+
26
+ exports.createDeckGlGoogleMapsOverlayRuntime = createDeckGlGoogleMapsOverlayRuntime;
@@ -0,0 +1,24 @@
1
+ import { GoogleMapsOverlay } from '@deck.gl/google-maps';
2
+
3
+ /**
4
+ * Creates one `GoogleMapsOverlay` per map.
5
+ *
6
+ * See https://deck.gl/docs/api-reference/google-maps/google-maps-overlay
7
+ */
8
+ const createDeckGlGoogleMapsOverlayRuntime = () => ({
9
+ createOverlay: map => {
10
+ const overlay = new GoogleMapsOverlay({
11
+ layers: [],
12
+ });
13
+ overlay.setMap(map);
14
+ return {
15
+ setMap: nextMap => overlay.setMap(nextMap),
16
+ setLayers: layers => {
17
+ overlay.setProps({ layers: [...layers] });
18
+ },
19
+ finalize: () => overlay.finalize(),
20
+ };
21
+ },
22
+ });
23
+
24
+ export { createDeckGlGoogleMapsOverlayRuntime };
package/index.cjs.js CHANGED
@@ -55,6 +55,196 @@ const GoogleApiProvider = ({ apiKey, language, children }) => {
55
55
  return (jsxRuntime.jsx(reactGoogleMaps.APIProvider, { apiKey: apiKey, language: language, libraries: libraries, version: GOOGLE_MAPS_API_VERSION, children: children }));
56
56
  };
57
57
 
58
+ let runtimePromise = null;
59
+ /**
60
+ * Lazily dynamic-imports {@link createDeckGlGoogleMapsOverlayRuntime} and caches the promise.
61
+ */
62
+ const loadDeckGlGoogleMapsOverlayRuntime = () => {
63
+ runtimePromise ?? (runtimePromise = Promise.resolve().then(function () { return require('./createDeckGlGoogleMapsOverlayRuntime.cjs.js'); })
64
+ .then(module => module.createDeckGlGoogleMapsOverlayRuntime())
65
+ .catch(error => {
66
+ runtimePromise = null;
67
+ throw error;
68
+ }));
69
+ return runtimePromise;
70
+ };
71
+
72
+ /**
73
+ * Owns a single deck.gl `GoogleMapsOverlay` on a Google map.
74
+ *
75
+ * Callers pass deck.gl `Layer` instances via {@link setLayers}; the coordinator merges them
76
+ * into one overlay. Additional adapter capabilities can contribute layers here later.
77
+ */
78
+ class DeckGlGoogleMapsOverlayCoordinator {
79
+ constructor(loadOverlayRuntime = loadDeckGlGoogleMapsOverlayRuntime) {
80
+ this.loadOverlayRuntime = loadOverlayRuntime;
81
+ this.map = null;
82
+ this.overlay = null;
83
+ this.pendingLayers = [];
84
+ this.runtime = null;
85
+ this.loading = null;
86
+ this.overlayCreation = null;
87
+ }
88
+ /** Associates the coordinator with a map and applies any pending layers. */
89
+ connect(map) {
90
+ const isReconnection = this.map !== null && this.map !== map;
91
+ this.map = map;
92
+ if (isReconnection) {
93
+ this.overlay?.setMap(map);
94
+ }
95
+ void this.syncOverlay();
96
+ }
97
+ /** Starts loading the deck.gl overlay runtime without creating an overlay. */
98
+ prepare() {
99
+ return this.loadRuntime().then(() => undefined);
100
+ }
101
+ /**
102
+ * Replaces all deck.gl layers on the shared overlay.
103
+ *
104
+ * An empty array tears down the overlay until layers are set again.
105
+ */
106
+ setLayers(layers) {
107
+ this.pendingLayers = layers;
108
+ if (layers.length === 0) {
109
+ this.overlay?.finalize();
110
+ this.overlay = null;
111
+ return;
112
+ }
113
+ void this.syncOverlay();
114
+ }
115
+ /** Finalizes the overlay and clears coordinator state. */
116
+ destroy() {
117
+ this.pendingLayers = [];
118
+ this.overlay?.finalize();
119
+ this.overlay = null;
120
+ this.map = null;
121
+ this.loading = null;
122
+ }
123
+ syncOverlay() {
124
+ if (this.map === null || this.pendingLayers.length === 0)
125
+ return;
126
+ if (this.overlay !== null) {
127
+ this.overlay.setLayers(this.pendingLayers);
128
+ return;
129
+ }
130
+ if (this.overlayCreation !== null)
131
+ return;
132
+ this.overlayCreation = this.loadRuntime()
133
+ .then(runtime => {
134
+ if (this.map === null || this.pendingLayers.length === 0)
135
+ return;
136
+ this.overlay = runtime.createOverlay(this.map);
137
+ this.overlay.setLayers(this.pendingLayers);
138
+ })
139
+ .catch(() => undefined)
140
+ .finally(() => {
141
+ this.overlayCreation = null;
142
+ });
143
+ }
144
+ loadRuntime() {
145
+ if (this.runtime !== null)
146
+ return Promise.resolve(this.runtime);
147
+ if (this.loading !== null)
148
+ return this.loading;
149
+ this.loading = this.loadOverlayRuntime().then(runtime => {
150
+ this.runtime = runtime;
151
+ this.loading = null;
152
+ return runtime;
153
+ }, error => {
154
+ this.loading = null;
155
+ throw error;
156
+ });
157
+ return this.loading;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Internal deck.gl lifecycle for the Google adapter (not a {@link LayerPort}).
163
+ *
164
+ * Reads deck.gl-relevant layers from a {@link LayerSnapshot}, builds deck.gl `Layer`
165
+ * instances, and pushes them through {@link DeckGlGoogleMapsOverlayCoordinator}.
166
+ * Heatmap is supported today; additional layer types can extend `buildDeckGlLayers`.
167
+ */
168
+ class DeckGlGoogleMapsLifecycle {
169
+ constructor(overlayCoordinator = new DeckGlGoogleMapsOverlayCoordinator(), loadHeatmapLayerBuilder = () => Promise.resolve().then(function () { return require('./buildDeckGlHeatmapLayer.cjs.js'); })) {
170
+ this.overlayCoordinator = overlayCoordinator;
171
+ this.loadHeatmapLayerBuilder = loadHeatmapLayerBuilder;
172
+ this.map = null;
173
+ this.heatmapConfigs = [];
174
+ this.heatmapLayers = new Map();
175
+ this.heatmapLayerBuilderModule = null;
176
+ this.syncing = null;
177
+ }
178
+ /** Binds to the map and syncs if snapshot state is already held. */
179
+ connect(map) {
180
+ this.map = map;
181
+ this.overlayCoordinator.connect(map);
182
+ void this.sync();
183
+ }
184
+ /**
185
+ * Applies deck.gl layers derived from the snapshot.
186
+ *
187
+ * Only layers rendered via deck.gl are considered; native Google layers stay on
188
+ * {@link GoogleMapsLayerPort}.
189
+ */
190
+ applySnapshot(snapshot) {
191
+ this.heatmapConfigs = snapshot.layers.filter(layer => layer.layerType === "heatmap");
192
+ void this.sync();
193
+ }
194
+ /** Detaches from the map and releases the shared deck.gl overlay. */
195
+ destroy() {
196
+ this.map = null;
197
+ this.heatmapConfigs = [];
198
+ this.heatmapLayers.clear();
199
+ this.syncing = null;
200
+ this.overlayCoordinator.destroy();
201
+ }
202
+ sync() {
203
+ if (this.map === null)
204
+ return;
205
+ if (this.heatmapConfigs.length === 0) {
206
+ this.heatmapLayers.clear();
207
+ this.overlayCoordinator.setLayers([]);
208
+ return;
209
+ }
210
+ if (this.heatmapLayerBuilderModule !== null) {
211
+ this.applyCurrentLayers(this.heatmapLayerBuilderModule);
212
+ return;
213
+ }
214
+ if (this.syncing !== null)
215
+ return;
216
+ this.syncing = Promise.all([this.loadHeatmapLayerBuilder(), this.overlayCoordinator.prepare()])
217
+ .then(([module]) => {
218
+ this.heatmapLayerBuilderModule = module;
219
+ this.syncing = null;
220
+ if (this.map === null)
221
+ return;
222
+ this.applyCurrentLayers(module);
223
+ })
224
+ .catch(() => {
225
+ this.syncing = null;
226
+ });
227
+ }
228
+ applyCurrentLayers(module) {
229
+ const currentIds = new Set(this.heatmapConfigs.map(config => config.id));
230
+ const layers = this.heatmapConfigs.map(config => {
231
+ const tracked = this.heatmapLayers.get(config.id);
232
+ if (tracked !== undefined && esToolkit.isEqual(tracked.config, config)) {
233
+ return tracked.layer;
234
+ }
235
+ const layer = module.buildDeckGlHeatmapLayer(config);
236
+ this.heatmapLayers.set(config.id, { config, layer });
237
+ return layer;
238
+ });
239
+ for (const id of this.heatmapLayers.keys()) {
240
+ if (!currentIds.has(id)) {
241
+ this.heatmapLayers.delete(id);
242
+ }
243
+ }
244
+ this.overlayCoordinator.setLayers(layers);
245
+ }
246
+ }
247
+
58
248
  /**
59
249
  * Zod schema for Google Maps LatLngLiteral
60
250
  * A plain object with lat and lng number properties
@@ -163,7 +353,7 @@ const DOM_MARKER_Z_INDEX = 1000;
163
353
  * any sources can be rendered. Sources set before connect are queued.
164
354
  */
165
355
  class GoogleMapsLayerPort {
166
- constructor() {
356
+ constructor(deckGlGoogleMapsLifecycle = new DeckGlGoogleMapsLifecycle()) {
167
357
  /**
168
358
  * Marker portal subscription compatible with `useSyncExternalStore`.
169
359
  * `<Layers>` routes descriptors from this store to `markerRender`. Includes
@@ -269,6 +459,7 @@ class GoogleMapsLayerPort {
269
459
  this.mapDblClickListenerCleanup = null;
270
460
  // Consumer-injected shape style defaults (set via setShapeStyleDefaults before first snapshot)
271
461
  this._shapeStyleDefaults = undefined;
462
+ this.deckGlGoogleMapsLifecycle = deckGlGoogleMapsLifecycle;
272
463
  }
273
464
  get shapeStyleDefaults() {
274
465
  if (this._shapeStyleDefaults === undefined) {
@@ -290,6 +481,7 @@ class GoogleMapsLayerPort {
290
481
  // re-render all existing sources on the new map
291
482
  const isReconnection = this.map !== null && this.map !== map;
292
483
  this.map = map;
484
+ this.deckGlGoogleMapsLifecycle.connect(map);
293
485
  this.setupMapContainerDblClickListener(map);
294
486
  this.setupMapClickListener(map);
295
487
  if (isReconnection) {
@@ -389,6 +581,7 @@ class GoogleMapsLayerPort {
389
581
  for (const id of [...this.overlaySources.keys()]) {
390
582
  this.removeImageOverlayInternal(id);
391
583
  }
584
+ this.deckGlGoogleMapsLifecycle.destroy();
392
585
  this.interactionHandlers.clear();
393
586
  this.backgroundClickHandlers.clear();
394
587
  this.markerPortalDescriptors = [];
@@ -413,6 +606,7 @@ class GoogleMapsLayerPort {
413
606
  const shapeIds = new Set(snapshot.layers.filter(l => l.layerType === "shapes").map(l => l.id));
414
607
  const routeIds = new Set(snapshot.layers.filter(l => l.layerType === "route").map(l => l.id));
415
608
  const overlayIds = new Set(snapshot.layers.filter(l => l.layerType === "image-overlay").map(l => l.id));
609
+ this.deckGlGoogleMapsLifecycle.applySnapshot(snapshot);
416
610
  for (const layer of snapshot.layers) {
417
611
  switch (layer.layerType) {
418
612
  case "markers":
@@ -427,6 +621,9 @@ class GoogleMapsLayerPort {
427
621
  case "image-overlay":
428
622
  this.syncImageOverlay(layer);
429
623
  break;
624
+ case "heatmap":
625
+ // Rendered by DeckGlGoogleMapsLifecycle (applySnapshot above); ADR-0029.
626
+ break;
430
627
  default: {
431
628
  const exhaustiveCheck = layer;
432
629
  throw new Error(`Unknown layer type: ${String(exhaustiveCheck)}`);
@@ -2589,7 +2786,18 @@ class GoogleMapsAdapterInstance {
2589
2786
  * (ADR 0028: attaching it to a vector map silently degrades the renderer).
2590
2787
  */
2591
2788
  this.connectedRenderingType = "VECTOR";
2592
- this.appearance = { ...reactMapAdapterShared.DEFAULT_MAP_APPEARANCE, theme: config.theme ?? "light" };
2789
+ // Seed the full appearance from config so a persisted preference (via
2790
+ // usePersistedAppearanceSeed in @trackunit/react-map) shapes the FIRST map:
2791
+ // the renderer reads theme and showTraffic from this initial state to pick
2792
+ // mapId and renderingType, so seeding here avoids the construct-then-recreate
2793
+ // blink (and double Dynamic Maps load) on mount.
2794
+ this.appearance = {
2795
+ theme: config.theme ?? reactMapAdapterShared.DEFAULT_MAP_APPEARANCE.theme,
2796
+ mapType: config.mapType ?? reactMapAdapterShared.DEFAULT_MAP_APPEARANCE.mapType,
2797
+ showRoads: config.showRoads ?? reactMapAdapterShared.DEFAULT_MAP_APPEARANCE.showRoads,
2798
+ // Absent = off; the core default keeps the field optional for external compatibility
2799
+ showTraffic: config.showTraffic ?? false,
2800
+ };
2593
2801
  this.layers = new GoogleMapsLayerPort();
2594
2802
  this.layers.setTheme(this.appearance.theme);
2595
2803
  this.state = reactMapAdapterShared.computeInitialState(this.appearance, config.initialViewport);
@@ -2649,11 +2857,17 @@ class GoogleMapsAdapterInstance {
2649
2857
  this.layers.setPointerMoveReporter(position => {
2650
2858
  this.reportPointerMove(position);
2651
2859
  });
2652
- // Restore previous viewport and map type after map recreation
2860
+ // Restore previous viewport after map recreation
2653
2861
  if (previousState !== null) {
2654
2862
  map.setCenter(positionToLatLng(previousState.center));
2655
2863
  map.setZoom(previousState.zoom);
2656
- map.setMapTypeId(this.mapTypeToGoogleMapTypeId(this.appearance.mapType, this.appearance.showRoads));
2864
+ }
2865
+ // Apply the effective map type on every connect: vis.gl creates each map with
2866
+ // Google's default (roadmap), so a seeded satellite/hybrid appearance must be
2867
+ // applied on first connect too, not only restored after recreations.
2868
+ const effectiveMapTypeId = this.mapTypeToGoogleMapTypeId(this.appearance.mapType, this.appearance.showRoads);
2869
+ if (map.getMapTypeId() !== effectiveMapTypeId) {
2870
+ map.setMapTypeId(effectiveMapTypeId);
2657
2871
  }
2658
2872
  if (!this.state.isReady || this.state.initializationFailed) {
2659
2873
  // Successful connect clears a prior init failure so recovery is possible (e.g. API load failed then
package/index.esm.js CHANGED
@@ -54,6 +54,196 @@ const GoogleApiProvider = ({ apiKey, language, children }) => {
54
54
  return (jsx(APIProvider, { apiKey: apiKey, language: language, libraries: libraries, version: GOOGLE_MAPS_API_VERSION, children: children }));
55
55
  };
56
56
 
57
+ let runtimePromise = null;
58
+ /**
59
+ * Lazily dynamic-imports {@link createDeckGlGoogleMapsOverlayRuntime} and caches the promise.
60
+ */
61
+ const loadDeckGlGoogleMapsOverlayRuntime = () => {
62
+ runtimePromise ?? (runtimePromise = import('./createDeckGlGoogleMapsOverlayRuntime.esm.js')
63
+ .then(module => module.createDeckGlGoogleMapsOverlayRuntime())
64
+ .catch(error => {
65
+ runtimePromise = null;
66
+ throw error;
67
+ }));
68
+ return runtimePromise;
69
+ };
70
+
71
+ /**
72
+ * Owns a single deck.gl `GoogleMapsOverlay` on a Google map.
73
+ *
74
+ * Callers pass deck.gl `Layer` instances via {@link setLayers}; the coordinator merges them
75
+ * into one overlay. Additional adapter capabilities can contribute layers here later.
76
+ */
77
+ class DeckGlGoogleMapsOverlayCoordinator {
78
+ constructor(loadOverlayRuntime = loadDeckGlGoogleMapsOverlayRuntime) {
79
+ this.loadOverlayRuntime = loadOverlayRuntime;
80
+ this.map = null;
81
+ this.overlay = null;
82
+ this.pendingLayers = [];
83
+ this.runtime = null;
84
+ this.loading = null;
85
+ this.overlayCreation = null;
86
+ }
87
+ /** Associates the coordinator with a map and applies any pending layers. */
88
+ connect(map) {
89
+ const isReconnection = this.map !== null && this.map !== map;
90
+ this.map = map;
91
+ if (isReconnection) {
92
+ this.overlay?.setMap(map);
93
+ }
94
+ void this.syncOverlay();
95
+ }
96
+ /** Starts loading the deck.gl overlay runtime without creating an overlay. */
97
+ prepare() {
98
+ return this.loadRuntime().then(() => undefined);
99
+ }
100
+ /**
101
+ * Replaces all deck.gl layers on the shared overlay.
102
+ *
103
+ * An empty array tears down the overlay until layers are set again.
104
+ */
105
+ setLayers(layers) {
106
+ this.pendingLayers = layers;
107
+ if (layers.length === 0) {
108
+ this.overlay?.finalize();
109
+ this.overlay = null;
110
+ return;
111
+ }
112
+ void this.syncOverlay();
113
+ }
114
+ /** Finalizes the overlay and clears coordinator state. */
115
+ destroy() {
116
+ this.pendingLayers = [];
117
+ this.overlay?.finalize();
118
+ this.overlay = null;
119
+ this.map = null;
120
+ this.loading = null;
121
+ }
122
+ syncOverlay() {
123
+ if (this.map === null || this.pendingLayers.length === 0)
124
+ return;
125
+ if (this.overlay !== null) {
126
+ this.overlay.setLayers(this.pendingLayers);
127
+ return;
128
+ }
129
+ if (this.overlayCreation !== null)
130
+ return;
131
+ this.overlayCreation = this.loadRuntime()
132
+ .then(runtime => {
133
+ if (this.map === null || this.pendingLayers.length === 0)
134
+ return;
135
+ this.overlay = runtime.createOverlay(this.map);
136
+ this.overlay.setLayers(this.pendingLayers);
137
+ })
138
+ .catch(() => undefined)
139
+ .finally(() => {
140
+ this.overlayCreation = null;
141
+ });
142
+ }
143
+ loadRuntime() {
144
+ if (this.runtime !== null)
145
+ return Promise.resolve(this.runtime);
146
+ if (this.loading !== null)
147
+ return this.loading;
148
+ this.loading = this.loadOverlayRuntime().then(runtime => {
149
+ this.runtime = runtime;
150
+ this.loading = null;
151
+ return runtime;
152
+ }, error => {
153
+ this.loading = null;
154
+ throw error;
155
+ });
156
+ return this.loading;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Internal deck.gl lifecycle for the Google adapter (not a {@link LayerPort}).
162
+ *
163
+ * Reads deck.gl-relevant layers from a {@link LayerSnapshot}, builds deck.gl `Layer`
164
+ * instances, and pushes them through {@link DeckGlGoogleMapsOverlayCoordinator}.
165
+ * Heatmap is supported today; additional layer types can extend `buildDeckGlLayers`.
166
+ */
167
+ class DeckGlGoogleMapsLifecycle {
168
+ constructor(overlayCoordinator = new DeckGlGoogleMapsOverlayCoordinator(), loadHeatmapLayerBuilder = () => import('./buildDeckGlHeatmapLayer.esm.js')) {
169
+ this.overlayCoordinator = overlayCoordinator;
170
+ this.loadHeatmapLayerBuilder = loadHeatmapLayerBuilder;
171
+ this.map = null;
172
+ this.heatmapConfigs = [];
173
+ this.heatmapLayers = new Map();
174
+ this.heatmapLayerBuilderModule = null;
175
+ this.syncing = null;
176
+ }
177
+ /** Binds to the map and syncs if snapshot state is already held. */
178
+ connect(map) {
179
+ this.map = map;
180
+ this.overlayCoordinator.connect(map);
181
+ void this.sync();
182
+ }
183
+ /**
184
+ * Applies deck.gl layers derived from the snapshot.
185
+ *
186
+ * Only layers rendered via deck.gl are considered; native Google layers stay on
187
+ * {@link GoogleMapsLayerPort}.
188
+ */
189
+ applySnapshot(snapshot) {
190
+ this.heatmapConfigs = snapshot.layers.filter(layer => layer.layerType === "heatmap");
191
+ void this.sync();
192
+ }
193
+ /** Detaches from the map and releases the shared deck.gl overlay. */
194
+ destroy() {
195
+ this.map = null;
196
+ this.heatmapConfigs = [];
197
+ this.heatmapLayers.clear();
198
+ this.syncing = null;
199
+ this.overlayCoordinator.destroy();
200
+ }
201
+ sync() {
202
+ if (this.map === null)
203
+ return;
204
+ if (this.heatmapConfigs.length === 0) {
205
+ this.heatmapLayers.clear();
206
+ this.overlayCoordinator.setLayers([]);
207
+ return;
208
+ }
209
+ if (this.heatmapLayerBuilderModule !== null) {
210
+ this.applyCurrentLayers(this.heatmapLayerBuilderModule);
211
+ return;
212
+ }
213
+ if (this.syncing !== null)
214
+ return;
215
+ this.syncing = Promise.all([this.loadHeatmapLayerBuilder(), this.overlayCoordinator.prepare()])
216
+ .then(([module]) => {
217
+ this.heatmapLayerBuilderModule = module;
218
+ this.syncing = null;
219
+ if (this.map === null)
220
+ return;
221
+ this.applyCurrentLayers(module);
222
+ })
223
+ .catch(() => {
224
+ this.syncing = null;
225
+ });
226
+ }
227
+ applyCurrentLayers(module) {
228
+ const currentIds = new Set(this.heatmapConfigs.map(config => config.id));
229
+ const layers = this.heatmapConfigs.map(config => {
230
+ const tracked = this.heatmapLayers.get(config.id);
231
+ if (tracked !== undefined && isEqual(tracked.config, config)) {
232
+ return tracked.layer;
233
+ }
234
+ const layer = module.buildDeckGlHeatmapLayer(config);
235
+ this.heatmapLayers.set(config.id, { config, layer });
236
+ return layer;
237
+ });
238
+ for (const id of this.heatmapLayers.keys()) {
239
+ if (!currentIds.has(id)) {
240
+ this.heatmapLayers.delete(id);
241
+ }
242
+ }
243
+ this.overlayCoordinator.setLayers(layers);
244
+ }
245
+ }
246
+
57
247
  /**
58
248
  * Zod schema for Google Maps LatLngLiteral
59
249
  * A plain object with lat and lng number properties
@@ -162,7 +352,7 @@ const DOM_MARKER_Z_INDEX = 1000;
162
352
  * any sources can be rendered. Sources set before connect are queued.
163
353
  */
164
354
  class GoogleMapsLayerPort {
165
- constructor() {
355
+ constructor(deckGlGoogleMapsLifecycle = new DeckGlGoogleMapsLifecycle()) {
166
356
  /**
167
357
  * Marker portal subscription compatible with `useSyncExternalStore`.
168
358
  * `<Layers>` routes descriptors from this store to `markerRender`. Includes
@@ -268,6 +458,7 @@ class GoogleMapsLayerPort {
268
458
  this.mapDblClickListenerCleanup = null;
269
459
  // Consumer-injected shape style defaults (set via setShapeStyleDefaults before first snapshot)
270
460
  this._shapeStyleDefaults = undefined;
461
+ this.deckGlGoogleMapsLifecycle = deckGlGoogleMapsLifecycle;
271
462
  }
272
463
  get shapeStyleDefaults() {
273
464
  if (this._shapeStyleDefaults === undefined) {
@@ -289,6 +480,7 @@ class GoogleMapsLayerPort {
289
480
  // re-render all existing sources on the new map
290
481
  const isReconnection = this.map !== null && this.map !== map;
291
482
  this.map = map;
483
+ this.deckGlGoogleMapsLifecycle.connect(map);
292
484
  this.setupMapContainerDblClickListener(map);
293
485
  this.setupMapClickListener(map);
294
486
  if (isReconnection) {
@@ -388,6 +580,7 @@ class GoogleMapsLayerPort {
388
580
  for (const id of [...this.overlaySources.keys()]) {
389
581
  this.removeImageOverlayInternal(id);
390
582
  }
583
+ this.deckGlGoogleMapsLifecycle.destroy();
391
584
  this.interactionHandlers.clear();
392
585
  this.backgroundClickHandlers.clear();
393
586
  this.markerPortalDescriptors = [];
@@ -412,6 +605,7 @@ class GoogleMapsLayerPort {
412
605
  const shapeIds = new Set(snapshot.layers.filter(l => l.layerType === "shapes").map(l => l.id));
413
606
  const routeIds = new Set(snapshot.layers.filter(l => l.layerType === "route").map(l => l.id));
414
607
  const overlayIds = new Set(snapshot.layers.filter(l => l.layerType === "image-overlay").map(l => l.id));
608
+ this.deckGlGoogleMapsLifecycle.applySnapshot(snapshot);
415
609
  for (const layer of snapshot.layers) {
416
610
  switch (layer.layerType) {
417
611
  case "markers":
@@ -426,6 +620,9 @@ class GoogleMapsLayerPort {
426
620
  case "image-overlay":
427
621
  this.syncImageOverlay(layer);
428
622
  break;
623
+ case "heatmap":
624
+ // Rendered by DeckGlGoogleMapsLifecycle (applySnapshot above); ADR-0029.
625
+ break;
429
626
  default: {
430
627
  const exhaustiveCheck = layer;
431
628
  throw new Error(`Unknown layer type: ${String(exhaustiveCheck)}`);
@@ -2588,7 +2785,18 @@ class GoogleMapsAdapterInstance {
2588
2785
  * (ADR 0028: attaching it to a vector map silently degrades the renderer).
2589
2786
  */
2590
2787
  this.connectedRenderingType = "VECTOR";
2591
- this.appearance = { ...DEFAULT_MAP_APPEARANCE, theme: config.theme ?? "light" };
2788
+ // Seed the full appearance from config so a persisted preference (via
2789
+ // usePersistedAppearanceSeed in @trackunit/react-map) shapes the FIRST map:
2790
+ // the renderer reads theme and showTraffic from this initial state to pick
2791
+ // mapId and renderingType, so seeding here avoids the construct-then-recreate
2792
+ // blink (and double Dynamic Maps load) on mount.
2793
+ this.appearance = {
2794
+ theme: config.theme ?? DEFAULT_MAP_APPEARANCE.theme,
2795
+ mapType: config.mapType ?? DEFAULT_MAP_APPEARANCE.mapType,
2796
+ showRoads: config.showRoads ?? DEFAULT_MAP_APPEARANCE.showRoads,
2797
+ // Absent = off; the core default keeps the field optional for external compatibility
2798
+ showTraffic: config.showTraffic ?? false,
2799
+ };
2592
2800
  this.layers = new GoogleMapsLayerPort();
2593
2801
  this.layers.setTheme(this.appearance.theme);
2594
2802
  this.state = computeInitialState(this.appearance, config.initialViewport);
@@ -2648,11 +2856,17 @@ class GoogleMapsAdapterInstance {
2648
2856
  this.layers.setPointerMoveReporter(position => {
2649
2857
  this.reportPointerMove(position);
2650
2858
  });
2651
- // Restore previous viewport and map type after map recreation
2859
+ // Restore previous viewport after map recreation
2652
2860
  if (previousState !== null) {
2653
2861
  map.setCenter(positionToLatLng(previousState.center));
2654
2862
  map.setZoom(previousState.zoom);
2655
- map.setMapTypeId(this.mapTypeToGoogleMapTypeId(this.appearance.mapType, this.appearance.showRoads));
2863
+ }
2864
+ // Apply the effective map type on every connect: vis.gl creates each map with
2865
+ // Google's default (roadmap), so a seeded satellite/hybrid appearance must be
2866
+ // applied on first connect too, not only restored after recreations.
2867
+ const effectiveMapTypeId = this.mapTypeToGoogleMapTypeId(this.appearance.mapType, this.appearance.showRoads);
2868
+ if (map.getMapTypeId() !== effectiveMapTypeId) {
2869
+ map.setMapTypeId(effectiveMapTypeId);
2656
2870
  }
2657
2871
  if (!this.state.isReady || this.state.initializationFailed) {
2658
2872
  // Successful connect clears a prior init failure so recovery is possible (e.g. API load failed then
package/package.json CHANGED
@@ -1,15 +1,22 @@
1
1
  {
2
2
  "name": "@trackunit/react-map-adapter-google",
3
- "version": "0.0.132",
3
+ "version": "0.0.134",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "@trackunit/react-map-adapter-shared": "0.0.143",
11
- "@trackunit/react-map-color-utils": "0.0.118",
12
- "@trackunit/geo-json-utils": "1.16.2",
10
+ "@deck.gl/aggregation-layers": "^9.4.0",
11
+ "@deck.gl/core": "^9.4.0",
12
+ "@deck.gl/google-maps": "^9.4.0",
13
+ "@deck.gl/layers": "^9.4.0",
14
+ "@loaders.gl/core": "~4.5.2",
15
+ "@luma.gl/core": "^9.4.1",
16
+ "@luma.gl/engine": "^9.4.1",
17
+ "@trackunit/react-map-adapter-shared": "0.0.145",
18
+ "@trackunit/react-map-color-utils": "0.0.120",
19
+ "@trackunit/geo-json-utils": "1.16.4",
13
20
  "@googlemaps/markerclusterer": "^2.6.2",
14
21
  "@vis.gl/react-google-maps": "^1.7.1",
15
22
  "es-toolkit": "^1.39.10",
@@ -1,5 +1,6 @@
1
1
  import { type GeoJsonPosition } from "@trackunit/geo-json-utils";
2
2
  import { type DomPortalStore, type EntityInteractionHandler, type LayerPort, type LayerSnapshot, type MapTheme, type ShapeStyleDefaults } from "@trackunit/react-map-adapter-shared";
3
+ import { DeckGlGoogleMapsLifecycle } from "./deckGl/DeckGlGoogleMapsLifecycle";
3
4
  /**
4
5
  * Google Maps implementation of the LayerPort interface.
5
6
  *
@@ -77,6 +78,8 @@ export declare class GoogleMapsLayerPort implements LayerPort {
77
78
  private holdNativeZoomTimer;
78
79
  private mapDblClickListenerCleanup;
79
80
  private _shapeStyleDefaults;
81
+ private readonly deckGlGoogleMapsLifecycle;
82
+ constructor(deckGlGoogleMapsLifecycle?: DeckGlGoogleMapsLifecycle);
80
83
  private get shapeStyleDefaults();
81
84
  setShapeStyleDefaults(defaults: ShapeStyleDefaults): void;
82
85
  /**
@@ -0,0 +1,38 @@
1
+ import type { Layer } from "@deck.gl/core";
2
+ import type { HeatmapSourceConfig, LayerSnapshot } from "@trackunit/react-map-adapter-shared";
3
+ import { DeckGlGoogleMapsOverlayCoordinator } from "./DeckGlGoogleMapsOverlayCoordinator";
4
+ type HeatmapLayerBuilderModule = Readonly<{
5
+ buildDeckGlHeatmapLayer: (config: HeatmapSourceConfig) => Layer;
6
+ }>;
7
+ type LoadHeatmapLayerBuilder = () => Promise<HeatmapLayerBuilderModule>;
8
+ /**
9
+ * Internal deck.gl lifecycle for the Google adapter (not a {@link LayerPort}).
10
+ *
11
+ * Reads deck.gl-relevant layers from a {@link LayerSnapshot}, builds deck.gl `Layer`
12
+ * instances, and pushes them through {@link DeckGlGoogleMapsOverlayCoordinator}.
13
+ * Heatmap is supported today; additional layer types can extend `buildDeckGlLayers`.
14
+ */
15
+ export declare class DeckGlGoogleMapsLifecycle {
16
+ private readonly overlayCoordinator;
17
+ private readonly loadHeatmapLayerBuilder;
18
+ private map;
19
+ private heatmapConfigs;
20
+ private readonly heatmapLayers;
21
+ private heatmapLayerBuilderModule;
22
+ private syncing;
23
+ constructor(overlayCoordinator?: DeckGlGoogleMapsOverlayCoordinator, loadHeatmapLayerBuilder?: LoadHeatmapLayerBuilder);
24
+ /** Binds to the map and syncs if snapshot state is already held. */
25
+ connect(map: google.maps.Map): void;
26
+ /**
27
+ * Applies deck.gl layers derived from the snapshot.
28
+ *
29
+ * Only layers rendered via deck.gl are considered; native Google layers stay on
30
+ * {@link GoogleMapsLayerPort}.
31
+ */
32
+ applySnapshot(snapshot: LayerSnapshot): void;
33
+ /** Detaches from the map and releases the shared deck.gl overlay. */
34
+ destroy(): void;
35
+ private sync;
36
+ private applyCurrentLayers;
37
+ }
38
+ export {};
@@ -0,0 +1,33 @@
1
+ import type { Layer } from "@deck.gl/core";
2
+ import type { DeckGlGoogleMapsOverlayRuntime } from "./createDeckGlGoogleMapsOverlayRuntime";
3
+ export type LoadDeckGlGoogleMapsOverlayRuntime = () => Promise<DeckGlGoogleMapsOverlayRuntime>;
4
+ /**
5
+ * Owns a single deck.gl `GoogleMapsOverlay` on a Google map.
6
+ *
7
+ * Callers pass deck.gl `Layer` instances via {@link setLayers}; the coordinator merges them
8
+ * into one overlay. Additional adapter capabilities can contribute layers here later.
9
+ */
10
+ export declare class DeckGlGoogleMapsOverlayCoordinator {
11
+ private readonly loadOverlayRuntime;
12
+ private map;
13
+ private overlay;
14
+ private pendingLayers;
15
+ private runtime;
16
+ private loading;
17
+ private overlayCreation;
18
+ constructor(loadOverlayRuntime?: LoadDeckGlGoogleMapsOverlayRuntime);
19
+ /** Associates the coordinator with a map and applies any pending layers. */
20
+ connect(map: google.maps.Map): void;
21
+ /** Starts loading the deck.gl overlay runtime without creating an overlay. */
22
+ prepare(): Promise<void>;
23
+ /**
24
+ * Replaces all deck.gl layers on the shared overlay.
25
+ *
26
+ * An empty array tears down the overlay until layers are set again.
27
+ */
28
+ setLayers(layers: ReadonlyArray<Layer>): void;
29
+ /** Finalizes the overlay and clears coordinator state. */
30
+ destroy(): void;
31
+ private syncOverlay;
32
+ private loadRuntime;
33
+ }
@@ -0,0 +1,17 @@
1
+ import type { Layer } from "@deck.gl/core";
2
+ /** Mutable deck.gl overlay bound to one {@link google.maps.Map}. */
3
+ export type DeckGlGoogleMapsOverlayHandle = Readonly<{
4
+ setMap: (map: google.maps.Map) => void;
5
+ setLayers: (layers: ReadonlyArray<Layer>) => void;
6
+ finalize: () => void;
7
+ }>;
8
+ /** Factory after lazy-loading `@deck.gl/google-maps`. */
9
+ export type DeckGlGoogleMapsOverlayRuntime = Readonly<{
10
+ createOverlay: (map: google.maps.Map) => DeckGlGoogleMapsOverlayHandle;
11
+ }>;
12
+ /**
13
+ * Creates one `GoogleMapsOverlay` per map.
14
+ *
15
+ * See https://deck.gl/docs/api-reference/google-maps/google-maps-overlay
16
+ */
17
+ export declare const createDeckGlGoogleMapsOverlayRuntime: () => DeckGlGoogleMapsOverlayRuntime;
@@ -0,0 +1,5 @@
1
+ import type { DeckGlGoogleMapsOverlayRuntime } from "./createDeckGlGoogleMapsOverlayRuntime";
2
+ /**
3
+ * Lazily dynamic-imports {@link createDeckGlGoogleMapsOverlayRuntime} and caches the promise.
4
+ */
5
+ export declare const loadDeckGlGoogleMapsOverlayRuntime: () => Promise<DeckGlGoogleMapsOverlayRuntime>;
@@ -21,6 +21,9 @@ export declare const googleMapsAdapter: (config: Readonly<Readonly<{
21
21
  language?: string;
22
22
  region?: string;
23
23
  theme?: import("@trackunit/react-map-adapter-shared").MapTheme;
24
+ mapType?: import("@trackunit/react-map-adapter-shared").MapType;
25
+ showRoads?: boolean;
26
+ showTraffic?: boolean;
24
27
  initialViewport?: import("@trackunit/react-map-adapter-shared").InitialViewport;
25
28
  restrictBounds?: import("@trackunit/geo-json-utils").GeoJsonBbox | null;
26
29
  }> & {
@@ -30,6 +33,9 @@ export declare const googleMapsAdapter: (config: Readonly<Readonly<{
30
33
  language?: string;
31
34
  region?: string;
32
35
  theme?: import("@trackunit/react-map-adapter-shared").MapTheme;
36
+ mapType?: import("@trackunit/react-map-adapter-shared").MapType;
37
+ showRoads?: boolean;
38
+ showTraffic?: boolean;
33
39
  initialViewport?: import("@trackunit/react-map-adapter-shared").InitialViewport;
34
40
  restrictBounds?: import("@trackunit/geo-json-utils").GeoJsonBbox | null;
35
41
  }> & {
@@ -0,0 +1,8 @@
1
+ import { HeatmapLayer } from "@deck.gl/aggregation-layers";
2
+ import type { GeoJsonFeature, HeatmapSourceConfig } from "@trackunit/react-map-adapter-shared";
3
+ /**
4
+ * Maps {@link HeatmapSourceConfig} → deck.gl `HeatmapLayer` on Google Maps.
5
+ *
6
+ * Invoked from {@link DeckGlGoogleMapsLifecycle} after dynamic import.
7
+ */
8
+ export declare const buildDeckGlHeatmapLayer: (config: HeatmapSourceConfig) => HeatmapLayer<GeoJsonFeature>;