@trackunit/react-map-adapter-google 0.0.127 → 0.0.128

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 CHANGED
@@ -1652,8 +1652,16 @@ class GoogleMapsLayerPort {
1652
1652
  const canvas = this.canvas;
1653
1653
  if (canvas === null)
1654
1654
  return;
1655
+ // Read viewport state from the map this overlay is CURRENTLY attached to.
1656
+ // reconnectAllSources() re-parents the overlay onto recreated map instances
1657
+ // (theme or renderingType changes); the closed-over `map` parameter keeps
1658
+ // pointing at the old, detached instance, whose bounds no longer move —
1659
+ // which froze canvas dots on screen until a data refresh rebuilt the overlay.
1660
+ const attachedMap = this.getMap();
1661
+ if (attachedMap === null || !("getBounds" in attachedMap))
1662
+ return;
1655
1663
  const projection = this.getProjection();
1656
- const bounds = map.getBounds();
1664
+ const bounds = attachedMap.getBounds();
1657
1665
  const pad = CANVAS_VIEWPORT_PADDING;
1658
1666
  const dpr = window.devicePixelRatio || 1;
1659
1667
  let originX;
@@ -2570,6 +2578,17 @@ class GoogleMapsAdapterInstance {
2570
2578
  /** Throttled pointermove channel shared by map mousemove and shape overlay mousemove. */
2571
2579
  this.pointerMoveRaf = null;
2572
2580
  this.lastPointerPosition = null;
2581
+ /**
2582
+ * Traffic overlay, created lazily in syncTrafficLayer() because the google
2583
+ * global only exists once the Maps JS API has loaded.
2584
+ */
2585
+ this.trafficLayer = null;
2586
+ /**
2587
+ * Rendering type of the currently connected map, as declared by the renderer
2588
+ * at connect() time. The traffic layer only ever attaches to RASTER maps
2589
+ * (ADR 0028: attaching it to a vector map silently degrades the renderer).
2590
+ */
2591
+ this.connectedRenderingType = "VECTOR";
2573
2592
  this.appearance = { ...reactMapAdapterShared.DEFAULT_MAP_APPEARANCE, theme: config.theme ?? "light" };
2574
2593
  this.layers = new GoogleMapsLayerPort();
2575
2594
  this.layers.setTheme(this.appearance.theme);
@@ -2598,7 +2617,7 @@ class GoogleMapsAdapterInstance {
2598
2617
  * On reconnection the previous center, zoom, and map type are restored so
2599
2618
  * the user doesn't see the viewport jump.
2600
2619
  */
2601
- connect(map) {
2620
+ connect(map, renderingType = "VECTOR") {
2602
2621
  // React 18 StrictMode (dev) runs effect cleanups between the first and second mount, so
2603
2622
  // `useMap`'s destroy cleanup fires before the real connect. Allow `connect` to revive a
2604
2623
  // destroyed adapter so the dev cycle self-heals; production unmounts never re-mount.
@@ -2616,7 +2635,16 @@ class GoogleMapsAdapterInstance {
2616
2635
  this.removeGoogleEventListeners();
2617
2636
  }
2618
2637
  this.map = map;
2638
+ this.connectedRenderingType = renderingType;
2639
+ // Re-derive the status appearance from the tracked appearance: destroy() resets
2640
+ // `state` to defaults while `this.appearance` keeps the real values, so a
2641
+ // StrictMode destroy → revive would otherwise report stale defaults, and the
2642
+ // appearance controls' sync-back would overwrite persisted preferences with them.
2643
+ this.state = { ...this.state, appearance: this.appearance };
2644
+ this.updateStatusCache();
2619
2645
  this.layers.connect(map);
2646
+ // Theme changes recreate the google.maps.Map, so traffic must re-attach on every (re)connect
2647
+ this.syncTrafficLayer();
2620
2648
  this.setupEventListeners();
2621
2649
  this.layers.setPointerMoveReporter(position => {
2622
2650
  this.reportPointerMove(position);
@@ -2666,6 +2694,16 @@ class GoogleMapsAdapterInstance {
2666
2694
  getTheme() {
2667
2695
  return this.appearance.theme;
2668
2696
  }
2697
+ /**
2698
+ * Returns the current traffic preference.
2699
+ * Used by GoogleMapsRenderer (via useSyncExternalStore) to pick the map's
2700
+ * renderingType: TrafficLayer silently forces a vector map into raster
2701
+ * fallback and orphans OverlayViews, so the renderer instead recreates the
2702
+ * map with an explicit RASTER renderingType while traffic is on.
2703
+ */
2704
+ getShowTraffic() {
2705
+ return this.appearance.showTraffic === true;
2706
+ }
2669
2707
  async setCenter(center) {
2670
2708
  const validCenter = geoJsonUtils.validatePosition(center, "[setCenter]");
2671
2709
  if (validCenter === null)
@@ -2785,6 +2823,15 @@ class GoogleMapsAdapterInstance {
2785
2823
  currentMap.setMapTypeId(this.mapTypeToGoogleMapTypeId(this.appearance.mapType, showRoads));
2786
2824
  }
2787
2825
  }
2826
+ async setShowTraffic(showTraffic) {
2827
+ if (this.appearance.showTraffic === showTraffic)
2828
+ return;
2829
+ this.appearance = { ...this.appearance, showTraffic };
2830
+ this.state = { ...this.state, appearance: this.appearance };
2831
+ this.updateStatusCache();
2832
+ this.notifyStatusListeners();
2833
+ this.syncTrafficLayer();
2834
+ }
2788
2835
  on(event, handler) {
2789
2836
  let listeners = this.eventListeners.get(event);
2790
2837
  if (listeners === undefined) {
@@ -2820,6 +2867,8 @@ class GoogleMapsAdapterInstance {
2820
2867
  }
2821
2868
  this.isDestroyedFlag = true;
2822
2869
  this.removeGoogleEventListeners();
2870
+ this.trafficLayer?.setMap(null);
2871
+ this.trafficLayer = null;
2823
2872
  this.layers.setPointerMoveReporter(null);
2824
2873
  this.layers.destroy();
2825
2874
  // Clear our listeners
@@ -2835,6 +2884,30 @@ class GoogleMapsAdapterInstance {
2835
2884
  // ============================================================================
2836
2885
  // Private methods
2837
2886
  // ============================================================================
2887
+ /**
2888
+ * Attach or detach the traffic overlay so it matches the current preference
2889
+ * and zoom. Runs on setShowTraffic, on (re)connect (theme changes recreate
2890
+ * the google.maps.Map), and on zoom changes. Google natively suppresses
2891
+ * traffic on plain satellite, so no mapType check is needed here.
2892
+ */
2893
+ syncTrafficLayer() {
2894
+ const currentMap = this.map;
2895
+ if (currentMap === null)
2896
+ return;
2897
+ const zoom = currentMap.getZoom() ?? this.state.zoom;
2898
+ // TrafficLayer must never touch a vector map — it silently degrades the renderer
2899
+ // (ADR 0028). When the preference turns on, the attached map is briefly still the
2900
+ // vector instance the renderer is about to replace; skip it, and the connect()
2901
+ // that follows the raster recreation attaches on the right map.
2902
+ const isRasterMap = this.connectedRenderingType === "RASTER";
2903
+ const targetMap = isRasterMap && this.appearance.showTraffic === true && zoom >= reactMapAdapterShared.TRAFFIC_MIN_ZOOM ? currentMap : null;
2904
+ if (this.trafficLayer === null && targetMap === null)
2905
+ return;
2906
+ this.trafficLayer ?? (this.trafficLayer = new google.maps.TrafficLayer());
2907
+ if (this.trafficLayer.getMap() !== targetMap) {
2908
+ this.trafficLayer.setMap(targetMap);
2909
+ }
2910
+ }
2838
2911
  /** Detach all Google Maps native event listeners without clearing our own subscribers */
2839
2912
  removeGoogleEventListeners() {
2840
2913
  this.googleEventListeners.forEach(listener => {
@@ -2882,6 +2955,8 @@ class GoogleMapsAdapterInstance {
2882
2955
  // Wheel / control zoom also fires bounds_changed but not dragstart
2883
2956
  this.googleEventListeners.push(currentMap.addListener("zoom_changed", () => {
2884
2957
  markCameraActive();
2958
+ // Traffic hides below TRAFFIC_MIN_ZOOM; cheap no-op unless the threshold is crossed
2959
+ this.syncTrafficLayer();
2885
2960
  }));
2886
2961
  // Bounds changed (during move) — keep isIdle false until the idle event
2887
2962
  this.googleEventListeners.push(currentMap.addListener("bounds_changed", () => {
@@ -2987,7 +3062,7 @@ const createGoogleMapsInstance = (config) => {
2987
3062
  * Internal component that renders the actual Google Map
2988
3063
  * Must be inside APIProvider
2989
3064
  */
2990
- const GoogleMapInternal = ({ adapterInstance, children, className, style, theme, mapInstanceId, initialViewport, restrictBounds, }) => {
3065
+ const GoogleMapInternal = ({ adapterInstance, children, className, style, theme, renderingType, mapInstanceId, initialViewport, restrictBounds, }) => {
2991
3066
  const map = reactGoogleMaps.useMap(mapInstanceId);
2992
3067
  const loadingStatus = reactGoogleMaps.useApiLoadingStatus();
2993
3068
  const restrictionBbox = react.useMemo(() => reactMapAdapterShared.getEffectiveRestrictBounds(restrictBounds), [restrictBounds]);
@@ -3015,9 +3090,11 @@ const GoogleMapInternal = ({ adapterInstance, children, className, style, theme,
3015
3090
  return;
3016
3091
  }
3017
3092
  if (map !== null && adapterInstance instanceof GoogleMapsAdapterInstance) {
3018
- adapterInstance.connect(map);
3093
+ // Declare the rendering type so the adapter only ever attaches the traffic
3094
+ // layer to RASTER maps (ADR 0028)
3095
+ adapterInstance.connect(map, renderingType === reactGoogleMaps.RenderingType.RASTER ? "RASTER" : "VECTOR");
3019
3096
  }
3020
- }, [loadingStatus, map, adapterInstance]);
3097
+ }, [loadingStatus, map, adapterInstance, renderingType]);
3021
3098
  react.useEffect(() => {
3022
3099
  if (loadingStatus !== reactGoogleMaps.APILoadingStatus.FAILED) {
3023
3100
  return;
@@ -3038,7 +3115,9 @@ const GoogleMapInternal = ({ adapterInstance, children, className, style, theme,
3038
3115
  },
3039
3116
  }
3040
3117
  : {};
3041
- 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 }));
3118
+ return (jsxRuntime.jsx(reactGoogleMaps.Map, { className: className, clickableIcons: false, disableDefaultUI: true, draggableCursor: reactMapAdapterShared.MAP_CURSORS.default, gestureHandling: reactDeviceDetect.isDesktop ? "greedy" : "cooperative", id: mapInstanceId,
3119
+ // Fractional zoom is a vector-renderer feature; raster snaps to integer zooms
3120
+ isFractionalZoomEnabled: renderingType === reactGoogleMaps.RenderingType.VECTOR, mapId: GOOGLE_MAP_IDS[theme], renderingType: renderingType, style: { width: "100%", height: "100%", ...style }, ...restrictionProps, ...mapProps, children: children }));
3042
3121
  };
3043
3122
  /**
3044
3123
  * Google Maps Renderer component
@@ -3065,9 +3144,19 @@ const GoogleMapsRenderer = (props) => {
3065
3144
  // Subscribe to the adapter's current theme reactively.
3066
3145
  // When setTheme() updates currentTheme and notifies listeners, this
3067
3146
  // causes a re-render so the <GoogleMap> receives the updated mapId.
3068
- const subscribeToTheme = react.useCallback((onStoreChange) => adapterInstance.subscribeStatus(onStoreChange), [adapterInstance]);
3147
+ const subscribeToStatus = react.useCallback((onStoreChange) => adapterInstance.subscribeStatus(onStoreChange), [adapterInstance]);
3069
3148
  const getThemeSnapshot = react.useCallback(() => adapterInstance.getTheme(), [adapterInstance]);
3070
- const currentTheme = react.useSyncExternalStore(subscribeToTheme, getThemeSnapshot, getThemeSnapshot);
3149
+ const currentTheme = react.useSyncExternalStore(subscribeToStatus, getThemeSnapshot, getThemeSnapshot);
3150
+ // Subscribe to the traffic preference the same way. TrafficLayer is not
3151
+ // supported by the vector renderer: attaching it silently drops the map into
3152
+ // raster fallback and orphans mounted OverlayViews (frozen markers). Instead
3153
+ // of that implicit half-state, the map is recreated with an explicit
3154
+ // renderingType — RASTER while traffic is on, VECTOR when it is off. vis.gl
3155
+ // treats renderingType like mapId: a change creates a fresh google.maps.Map
3156
+ // (camera preserved), and connect() re-attaches layers and traffic.
3157
+ const getShowTrafficSnapshot = react.useCallback(() => adapterInstance.getShowTraffic(), [adapterInstance]);
3158
+ const showTraffic = react.useSyncExternalStore(subscribeToStatus, getShowTrafficSnapshot, getShowTrafficSnapshot);
3159
+ const renderingType = showTraffic ? reactGoogleMaps.RenderingType.RASTER : reactGoogleMaps.RenderingType.VECTOR;
3071
3160
  // "marker" is required for AdvancedMarkerElement used by the layer port.
3072
3161
  // using satisfies to avoid type errors from the library while maintaining type safety
3073
3162
  const libraries = react.useMemo(() => ["marker"], []);
@@ -3080,7 +3169,7 @@ const GoogleMapsRenderer = (props) => {
3080
3169
  // initializations that cause zombie google.maps.Map instances.
3081
3170
  const existingContext = react.useContext(reactGoogleMaps.APIProviderContext);
3082
3171
  const hasExternalProvider = existingContext !== null;
3083
- const internalContent = (jsxRuntime.jsx(GoogleMapInternal, { ...props, initialViewport: initialViewport, mapInstanceId: mapInstanceId, restrictBounds: restrictBounds, theme: currentTheme }));
3172
+ const internalContent = (jsxRuntime.jsx(GoogleMapInternal, { ...props, initialViewport: initialViewport, mapInstanceId: mapInstanceId, renderingType: renderingType, restrictBounds: restrictBounds, theme: currentTheme }));
3084
3173
  if (hasExternalProvider) {
3085
3174
  return internalContent;
3086
3175
  }
package/index.esm.js CHANGED
@@ -1,7 +1,7 @@
1
- import { canPatchAdaptiveMarker, canPatchAdaptiveViewport, canPatchMarkerInPlace, extractPointCoordinates, mergeAntimeridianFeatures, extractPolygonPaths, extractLineCoordinates, patchPortalDescriptors, collectFeatureIdSet, removeGoneIndexedMarkers, getAdaptiveDomFeatureIds, extractSourceData, buildAdaptiveDomRenderFn, resolveCircleSymbolDefaults, resolveSymbolDescriptor, buildAdaptiveSymbolStyleFn, createSymbolDotElement, anchorFromBottomCenter, createDefaultClusterElement, createClusterPinElement, fadeInElement, buildAdaptiveDomEntries, MAP_CURSORS, resolveSelectedStyle, resolveHoveredStyle, attachSafeAreaHoverListeners, INITIAL_MAP_STATE, DEFAULT_MAP_APPEARANCE, computeInitialState, getEffectiveRestrictBounds, cameraStateEquals, isEventOfType, DEFAULT_ZOOM, DEFAULT_CENTER, defineAdapter } from '@trackunit/react-map-adapter-shared';
1
+ import { canPatchAdaptiveMarker, canPatchAdaptiveViewport, canPatchMarkerInPlace, extractPointCoordinates, mergeAntimeridianFeatures, extractPolygonPaths, extractLineCoordinates, patchPortalDescriptors, collectFeatureIdSet, removeGoneIndexedMarkers, getAdaptiveDomFeatureIds, extractSourceData, buildAdaptiveDomRenderFn, resolveCircleSymbolDefaults, resolveSymbolDescriptor, buildAdaptiveSymbolStyleFn, createSymbolDotElement, anchorFromBottomCenter, createDefaultClusterElement, createClusterPinElement, fadeInElement, buildAdaptiveDomEntries, MAP_CURSORS, resolveSelectedStyle, resolveHoveredStyle, attachSafeAreaHoverListeners, INITIAL_MAP_STATE, DEFAULT_MAP_APPEARANCE, computeInitialState, getEffectiveRestrictBounds, TRAFFIC_MIN_ZOOM, cameraStateEquals, isEventOfType, DEFAULT_ZOOM, DEFAULT_CENTER, defineAdapter } from '@trackunit/react-map-adapter-shared';
2
2
  export { googleMapCspDirectives } from '@trackunit/react-map-adapter-shared';
3
3
  import { jsx } from 'react/jsx-runtime';
4
- import { APIProvider, APIProviderContext, useMap, useApiLoadingStatus, APILoadingStatus, Map as Map$1 } from '@vis.gl/react-google-maps';
4
+ import { APIProvider, RenderingType, APIProviderContext, useMap, useApiLoadingStatus, APILoadingStatus, Map as Map$1 } from '@vis.gl/react-google-maps';
5
5
  import { useMemo, useCallback, useSyncExternalStore, useId, useContext, useEffect } from 'react';
6
6
  import { validateBbox, validatePosition } from '@trackunit/geo-json-utils';
7
7
  import { MarkerClusterer, SuperClusterAlgorithm } from '@googlemaps/markerclusterer';
@@ -1651,8 +1651,16 @@ class GoogleMapsLayerPort {
1651
1651
  const canvas = this.canvas;
1652
1652
  if (canvas === null)
1653
1653
  return;
1654
+ // Read viewport state from the map this overlay is CURRENTLY attached to.
1655
+ // reconnectAllSources() re-parents the overlay onto recreated map instances
1656
+ // (theme or renderingType changes); the closed-over `map` parameter keeps
1657
+ // pointing at the old, detached instance, whose bounds no longer move —
1658
+ // which froze canvas dots on screen until a data refresh rebuilt the overlay.
1659
+ const attachedMap = this.getMap();
1660
+ if (attachedMap === null || !("getBounds" in attachedMap))
1661
+ return;
1654
1662
  const projection = this.getProjection();
1655
- const bounds = map.getBounds();
1663
+ const bounds = attachedMap.getBounds();
1656
1664
  const pad = CANVAS_VIEWPORT_PADDING;
1657
1665
  const dpr = window.devicePixelRatio || 1;
1658
1666
  let originX;
@@ -2569,6 +2577,17 @@ class GoogleMapsAdapterInstance {
2569
2577
  /** Throttled pointermove channel shared by map mousemove and shape overlay mousemove. */
2570
2578
  this.pointerMoveRaf = null;
2571
2579
  this.lastPointerPosition = null;
2580
+ /**
2581
+ * Traffic overlay, created lazily in syncTrafficLayer() because the google
2582
+ * global only exists once the Maps JS API has loaded.
2583
+ */
2584
+ this.trafficLayer = null;
2585
+ /**
2586
+ * Rendering type of the currently connected map, as declared by the renderer
2587
+ * at connect() time. The traffic layer only ever attaches to RASTER maps
2588
+ * (ADR 0028: attaching it to a vector map silently degrades the renderer).
2589
+ */
2590
+ this.connectedRenderingType = "VECTOR";
2572
2591
  this.appearance = { ...DEFAULT_MAP_APPEARANCE, theme: config.theme ?? "light" };
2573
2592
  this.layers = new GoogleMapsLayerPort();
2574
2593
  this.layers.setTheme(this.appearance.theme);
@@ -2597,7 +2616,7 @@ class GoogleMapsAdapterInstance {
2597
2616
  * On reconnection the previous center, zoom, and map type are restored so
2598
2617
  * the user doesn't see the viewport jump.
2599
2618
  */
2600
- connect(map) {
2619
+ connect(map, renderingType = "VECTOR") {
2601
2620
  // React 18 StrictMode (dev) runs effect cleanups between the first and second mount, so
2602
2621
  // `useMap`'s destroy cleanup fires before the real connect. Allow `connect` to revive a
2603
2622
  // destroyed adapter so the dev cycle self-heals; production unmounts never re-mount.
@@ -2615,7 +2634,16 @@ class GoogleMapsAdapterInstance {
2615
2634
  this.removeGoogleEventListeners();
2616
2635
  }
2617
2636
  this.map = map;
2637
+ this.connectedRenderingType = renderingType;
2638
+ // Re-derive the status appearance from the tracked appearance: destroy() resets
2639
+ // `state` to defaults while `this.appearance` keeps the real values, so a
2640
+ // StrictMode destroy → revive would otherwise report stale defaults, and the
2641
+ // appearance controls' sync-back would overwrite persisted preferences with them.
2642
+ this.state = { ...this.state, appearance: this.appearance };
2643
+ this.updateStatusCache();
2618
2644
  this.layers.connect(map);
2645
+ // Theme changes recreate the google.maps.Map, so traffic must re-attach on every (re)connect
2646
+ this.syncTrafficLayer();
2619
2647
  this.setupEventListeners();
2620
2648
  this.layers.setPointerMoveReporter(position => {
2621
2649
  this.reportPointerMove(position);
@@ -2665,6 +2693,16 @@ class GoogleMapsAdapterInstance {
2665
2693
  getTheme() {
2666
2694
  return this.appearance.theme;
2667
2695
  }
2696
+ /**
2697
+ * Returns the current traffic preference.
2698
+ * Used by GoogleMapsRenderer (via useSyncExternalStore) to pick the map's
2699
+ * renderingType: TrafficLayer silently forces a vector map into raster
2700
+ * fallback and orphans OverlayViews, so the renderer instead recreates the
2701
+ * map with an explicit RASTER renderingType while traffic is on.
2702
+ */
2703
+ getShowTraffic() {
2704
+ return this.appearance.showTraffic === true;
2705
+ }
2668
2706
  async setCenter(center) {
2669
2707
  const validCenter = validatePosition(center, "[setCenter]");
2670
2708
  if (validCenter === null)
@@ -2784,6 +2822,15 @@ class GoogleMapsAdapterInstance {
2784
2822
  currentMap.setMapTypeId(this.mapTypeToGoogleMapTypeId(this.appearance.mapType, showRoads));
2785
2823
  }
2786
2824
  }
2825
+ async setShowTraffic(showTraffic) {
2826
+ if (this.appearance.showTraffic === showTraffic)
2827
+ return;
2828
+ this.appearance = { ...this.appearance, showTraffic };
2829
+ this.state = { ...this.state, appearance: this.appearance };
2830
+ this.updateStatusCache();
2831
+ this.notifyStatusListeners();
2832
+ this.syncTrafficLayer();
2833
+ }
2787
2834
  on(event, handler) {
2788
2835
  let listeners = this.eventListeners.get(event);
2789
2836
  if (listeners === undefined) {
@@ -2819,6 +2866,8 @@ class GoogleMapsAdapterInstance {
2819
2866
  }
2820
2867
  this.isDestroyedFlag = true;
2821
2868
  this.removeGoogleEventListeners();
2869
+ this.trafficLayer?.setMap(null);
2870
+ this.trafficLayer = null;
2822
2871
  this.layers.setPointerMoveReporter(null);
2823
2872
  this.layers.destroy();
2824
2873
  // Clear our listeners
@@ -2834,6 +2883,30 @@ class GoogleMapsAdapterInstance {
2834
2883
  // ============================================================================
2835
2884
  // Private methods
2836
2885
  // ============================================================================
2886
+ /**
2887
+ * Attach or detach the traffic overlay so it matches the current preference
2888
+ * and zoom. Runs on setShowTraffic, on (re)connect (theme changes recreate
2889
+ * the google.maps.Map), and on zoom changes. Google natively suppresses
2890
+ * traffic on plain satellite, so no mapType check is needed here.
2891
+ */
2892
+ syncTrafficLayer() {
2893
+ const currentMap = this.map;
2894
+ if (currentMap === null)
2895
+ return;
2896
+ const zoom = currentMap.getZoom() ?? this.state.zoom;
2897
+ // TrafficLayer must never touch a vector map — it silently degrades the renderer
2898
+ // (ADR 0028). When the preference turns on, the attached map is briefly still the
2899
+ // vector instance the renderer is about to replace; skip it, and the connect()
2900
+ // that follows the raster recreation attaches on the right map.
2901
+ const isRasterMap = this.connectedRenderingType === "RASTER";
2902
+ const targetMap = isRasterMap && this.appearance.showTraffic === true && zoom >= TRAFFIC_MIN_ZOOM ? currentMap : null;
2903
+ if (this.trafficLayer === null && targetMap === null)
2904
+ return;
2905
+ this.trafficLayer ?? (this.trafficLayer = new google.maps.TrafficLayer());
2906
+ if (this.trafficLayer.getMap() !== targetMap) {
2907
+ this.trafficLayer.setMap(targetMap);
2908
+ }
2909
+ }
2837
2910
  /** Detach all Google Maps native event listeners without clearing our own subscribers */
2838
2911
  removeGoogleEventListeners() {
2839
2912
  this.googleEventListeners.forEach(listener => {
@@ -2881,6 +2954,8 @@ class GoogleMapsAdapterInstance {
2881
2954
  // Wheel / control zoom also fires bounds_changed but not dragstart
2882
2955
  this.googleEventListeners.push(currentMap.addListener("zoom_changed", () => {
2883
2956
  markCameraActive();
2957
+ // Traffic hides below TRAFFIC_MIN_ZOOM; cheap no-op unless the threshold is crossed
2958
+ this.syncTrafficLayer();
2884
2959
  }));
2885
2960
  // Bounds changed (during move) — keep isIdle false until the idle event
2886
2961
  this.googleEventListeners.push(currentMap.addListener("bounds_changed", () => {
@@ -2986,7 +3061,7 @@ const createGoogleMapsInstance = (config) => {
2986
3061
  * Internal component that renders the actual Google Map
2987
3062
  * Must be inside APIProvider
2988
3063
  */
2989
- const GoogleMapInternal = ({ adapterInstance, children, className, style, theme, mapInstanceId, initialViewport, restrictBounds, }) => {
3064
+ const GoogleMapInternal = ({ adapterInstance, children, className, style, theme, renderingType, mapInstanceId, initialViewport, restrictBounds, }) => {
2990
3065
  const map = useMap(mapInstanceId);
2991
3066
  const loadingStatus = useApiLoadingStatus();
2992
3067
  const restrictionBbox = useMemo(() => getEffectiveRestrictBounds(restrictBounds), [restrictBounds]);
@@ -3014,9 +3089,11 @@ const GoogleMapInternal = ({ adapterInstance, children, className, style, theme,
3014
3089
  return;
3015
3090
  }
3016
3091
  if (map !== null && adapterInstance instanceof GoogleMapsAdapterInstance) {
3017
- adapterInstance.connect(map);
3092
+ // Declare the rendering type so the adapter only ever attaches the traffic
3093
+ // layer to RASTER maps (ADR 0028)
3094
+ adapterInstance.connect(map, renderingType === RenderingType.RASTER ? "RASTER" : "VECTOR");
3018
3095
  }
3019
- }, [loadingStatus, map, adapterInstance]);
3096
+ }, [loadingStatus, map, adapterInstance, renderingType]);
3020
3097
  useEffect(() => {
3021
3098
  if (loadingStatus !== APILoadingStatus.FAILED) {
3022
3099
  return;
@@ -3037,7 +3114,9 @@ const GoogleMapInternal = ({ adapterInstance, children, className, style, theme,
3037
3114
  },
3038
3115
  }
3039
3116
  : {};
3040
- return (jsx(Map$1, { className: className, clickableIcons: false, disableDefaultUI: true, draggableCursor: MAP_CURSORS.default, gestureHandling: isDesktop ? "greedy" : "cooperative", id: mapInstanceId, isFractionalZoomEnabled: true, mapId: GOOGLE_MAP_IDS[theme], style: { width: "100%", height: "100%", ...style }, ...restrictionProps, ...mapProps, children: children }));
3117
+ return (jsx(Map$1, { className: className, clickableIcons: false, disableDefaultUI: true, draggableCursor: MAP_CURSORS.default, gestureHandling: isDesktop ? "greedy" : "cooperative", id: mapInstanceId,
3118
+ // Fractional zoom is a vector-renderer feature; raster snaps to integer zooms
3119
+ isFractionalZoomEnabled: renderingType === RenderingType.VECTOR, mapId: GOOGLE_MAP_IDS[theme], renderingType: renderingType, style: { width: "100%", height: "100%", ...style }, ...restrictionProps, ...mapProps, children: children }));
3041
3120
  };
3042
3121
  /**
3043
3122
  * Google Maps Renderer component
@@ -3064,9 +3143,19 @@ const GoogleMapsRenderer = (props) => {
3064
3143
  // Subscribe to the adapter's current theme reactively.
3065
3144
  // When setTheme() updates currentTheme and notifies listeners, this
3066
3145
  // causes a re-render so the <GoogleMap> receives the updated mapId.
3067
- const subscribeToTheme = useCallback((onStoreChange) => adapterInstance.subscribeStatus(onStoreChange), [adapterInstance]);
3146
+ const subscribeToStatus = useCallback((onStoreChange) => adapterInstance.subscribeStatus(onStoreChange), [adapterInstance]);
3068
3147
  const getThemeSnapshot = useCallback(() => adapterInstance.getTheme(), [adapterInstance]);
3069
- const currentTheme = useSyncExternalStore(subscribeToTheme, getThemeSnapshot, getThemeSnapshot);
3148
+ const currentTheme = useSyncExternalStore(subscribeToStatus, getThemeSnapshot, getThemeSnapshot);
3149
+ // Subscribe to the traffic preference the same way. TrafficLayer is not
3150
+ // supported by the vector renderer: attaching it silently drops the map into
3151
+ // raster fallback and orphans mounted OverlayViews (frozen markers). Instead
3152
+ // of that implicit half-state, the map is recreated with an explicit
3153
+ // renderingType — RASTER while traffic is on, VECTOR when it is off. vis.gl
3154
+ // treats renderingType like mapId: a change creates a fresh google.maps.Map
3155
+ // (camera preserved), and connect() re-attaches layers and traffic.
3156
+ const getShowTrafficSnapshot = useCallback(() => adapterInstance.getShowTraffic(), [adapterInstance]);
3157
+ const showTraffic = useSyncExternalStore(subscribeToStatus, getShowTrafficSnapshot, getShowTrafficSnapshot);
3158
+ const renderingType = showTraffic ? RenderingType.RASTER : RenderingType.VECTOR;
3070
3159
  // "marker" is required for AdvancedMarkerElement used by the layer port.
3071
3160
  // using satisfies to avoid type errors from the library while maintaining type safety
3072
3161
  const libraries = useMemo(() => ["marker"], []);
@@ -3079,7 +3168,7 @@ const GoogleMapsRenderer = (props) => {
3079
3168
  // initializations that cause zombie google.maps.Map instances.
3080
3169
  const existingContext = useContext(APIProviderContext);
3081
3170
  const hasExternalProvider = existingContext !== null;
3082
- const internalContent = (jsx(GoogleMapInternal, { ...props, initialViewport: initialViewport, mapInstanceId: mapInstanceId, restrictBounds: restrictBounds, theme: currentTheme }));
3171
+ const internalContent = (jsx(GoogleMapInternal, { ...props, initialViewport: initialViewport, mapInstanceId: mapInstanceId, renderingType: renderingType, restrictBounds: restrictBounds, theme: currentTheme }));
3083
3172
  if (hasExternalProvider) {
3084
3173
  return internalContent;
3085
3174
  }
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@trackunit/react-map-adapter-google",
3
- "version": "0.0.127",
3
+ "version": "0.0.128",
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.138",
11
- "@trackunit/react-map-color-utils": "0.0.113",
12
- "@trackunit/geo-json-utils": "1.15.53",
10
+ "@trackunit/react-map-adapter-shared": "0.0.139",
11
+ "@trackunit/react-map-color-utils": "0.0.114",
12
+ "@trackunit/geo-json-utils": "1.15.54",
13
13
  "@googlemaps/markerclusterer": "^2.6.2",
14
14
  "@vis.gl/react-google-maps": "^1.7.1",
15
15
  "es-toolkit": "^1.39.10",
@@ -35,6 +35,17 @@ export declare class GoogleMapsAdapterInstance implements AdapterInstance {
35
35
  private lastPointerPosition;
36
36
  /** Unified appearance state — theme, mapType, and showRoads in one place */
37
37
  private appearance;
38
+ /**
39
+ * Traffic overlay, created lazily in syncTrafficLayer() because the google
40
+ * global only exists once the Maps JS API has loaded.
41
+ */
42
+ private trafficLayer;
43
+ /**
44
+ * Rendering type of the currently connected map, as declared by the renderer
45
+ * at connect() time. The traffic layer only ever attaches to RASTER maps
46
+ * (ADR 0028: attaching it to a vector map silently degrades the renderer).
47
+ */
48
+ private connectedRenderingType;
38
49
  constructor(config: GoogleMapsConfig);
39
50
  /**
40
51
  * Connect to a Google Maps instance - called by GoogleMapsRenderer.
@@ -44,7 +55,7 @@ export declare class GoogleMapsAdapterInstance implements AdapterInstance {
44
55
  * On reconnection the previous center, zoom, and map type are restored so
45
56
  * the user doesn't see the viewport jump.
46
57
  */
47
- connect(map: google.maps.Map): void;
58
+ connect(map: google.maps.Map, renderingType?: "RASTER" | "VECTOR"): void;
48
59
  getConfig(): GoogleMapsConfig;
49
60
  getCameraState(): CameraState;
50
61
  getStatus(): MapStatus;
@@ -56,6 +67,14 @@ export declare class GoogleMapsAdapterInstance implements AdapterInstance {
56
67
  * update the mapId prop on the <GoogleMap> component.
57
68
  */
58
69
  getTheme(): MapTheme;
70
+ /**
71
+ * Returns the current traffic preference.
72
+ * Used by GoogleMapsRenderer (via useSyncExternalStore) to pick the map's
73
+ * renderingType: TrafficLayer silently forces a vector map into raster
74
+ * fallback and orphans OverlayViews, so the renderer instead recreates the
75
+ * map with an explicit RASTER renderingType while traffic is on.
76
+ */
77
+ getShowTraffic(): boolean;
59
78
  setCenter(center: GeoJsonPosition): Promise<void>;
60
79
  setZoom(zoomLevel: number): Promise<void>;
61
80
  zoomBy(delta: number): Promise<void>;
@@ -65,9 +84,17 @@ export declare class GoogleMapsAdapterInstance implements AdapterInstance {
65
84
  setMapType(type: MapType): Promise<void>;
66
85
  setTheme(theme: MapTheme): Promise<void>;
67
86
  setShowRoads(showRoads: boolean): Promise<void>;
87
+ setShowTraffic(showTraffic: boolean): Promise<void>;
68
88
  on<TEventType extends MapEvent["type"]>(event: TEventType, handler: MapEventHandler<TEventType>): () => void;
69
89
  notifyInitializationFailed(): void;
70
90
  destroy(): void;
91
+ /**
92
+ * Attach or detach the traffic overlay so it matches the current preference
93
+ * and zoom. Runs on setShowTraffic, on (re)connect (theme changes recreate
94
+ * the google.maps.Map), and on zoom changes. Google natively suppresses
95
+ * traffic on plain satellite, so no mapType check is needed here.
96
+ */
97
+ private syncTrafficLayer;
71
98
  /** Detach all Google Maps native event listeners without clearing our own subscribers */
72
99
  private removeGoogleEventListeners;
73
100
  /** Emit a throttled pointermove event (map canvas + shape overlays). */