@trackunit/react-map-adapter-mapbox 0.0.149 → 0.0.152

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
@@ -2,6 +2,7 @@
2
2
 
3
3
  var reactMapAdapterShared = require('@trackunit/react-map-adapter-shared');
4
4
  var geoJsonUtils = require('@trackunit/geo-json-utils');
5
+ var reactMapDraw = require('@trackunit/react-map-draw');
5
6
  var esToolkit = require('es-toolkit');
6
7
  var mapboxgl = require('mapbox-gl');
7
8
  var Supercluster = require('supercluster');
@@ -27,6 +28,27 @@ const MAPBOX_MAP_TYPE_STYLES = {
27
28
  dark: "mapbox://styles/mapbox/satellite-streets-v12",
28
29
  },
29
30
  };
31
+ /**
32
+ * Style every Mapbox map is created with. The renderer passes it to `mapboxgl.Map`
33
+ * and the adapter instance records it as the applied style, so the two agree on
34
+ * what is on the map before the first `applyStyle`.
35
+ */
36
+ const getInitialStyleUrl = (theme) => MAPBOX_MAP_TYPE_STYLES.roadmap[theme];
37
+
38
+ /**
39
+ * Builds the Mapbox GL bridge for the shared drawing engine (ADR 0030).
40
+ *
41
+ * One loader per loaded style: `setStyle` (theme, map type, Roads) removes every
42
+ * custom source and layer, so `MapboxAdapterInstance` disconnects the controller
43
+ * before a style change and reconnects it with a fresh loader on `style.load`.
44
+ * Terra Draw and its Mapbox adapter are only downloaded when the loader runs.
45
+ */
46
+ const createMapboxDrawEngineLoader = (map) => async () => {
47
+ const { TerraDrawMapboxGLAdapter } = await import('terra-draw-mapbox-gl-adapter');
48
+ return reactMapDraw.loadTerraDrawEngine({
49
+ adapter: new TerraDrawMapboxGLAdapter({ map, coordinatePrecision: reactMapDraw.DRAW_COORDINATE_PRECISION }),
50
+ });
51
+ };
30
52
 
31
53
  // ============================================================================
32
54
  // Helpers
@@ -2440,15 +2462,29 @@ class MapboxAdapterInstance {
2440
2462
  initializationFailed: reactMapAdapterShared.INITIAL_MAP_STATE.initializationFailed,
2441
2463
  appearance: reactMapAdapterShared.INITIAL_MAP_STATE.appearance,
2442
2464
  tileSize: MAPBOX_TILE_SIZE,
2465
+ drawing: reactMapAdapterShared.INITIAL_MAP_STATE.drawing,
2443
2466
  };
2444
2467
  this.cameraListeners = new Set();
2445
2468
  this.statusListeners = new Set();
2446
2469
  this.eventListeners = new Map();
2447
2470
  this.mapboxEventCleanups = [];
2448
2471
  this.isDestroyedFlag = false;
2472
+ /**
2473
+ * The map whose style has loaded at least once. `map.isStyleLoaded()` is false
2474
+ * inside `style.load` and transiently while sources load, so the drawing bridge
2475
+ * keeps its own readiness (same reason as `MapboxLayerPort.styleReady`).
2476
+ */
2477
+ this.styleLoadedMap = null;
2478
+ /** Style URL currently applied to the map, so a same-URL change is skipped instead of reloaded. */
2479
+ this.appliedStyleUrl = null;
2449
2480
  this.appearance = { ...reactMapAdapterShared.DEFAULT_MAP_APPEARANCE, theme: config.theme ?? "light" };
2450
2481
  this.layers = new MapboxLayerPort();
2451
2482
  this.layers.setTheme(this.appearance.theme);
2483
+ // Engine load failures are not surfaced here (same as the deck.gl runtime): the
2484
+ // controller keeps mode and value and loads again on the next connect, setMode
2485
+ // or setValue that needs an engine.
2486
+ this.draw = new reactMapDraw.DrawController();
2487
+ this.draw.subscribe(() => this.syncDrawingStatus());
2452
2488
  this.state = { ...reactMapAdapterShared.computeInitialState(this.appearance, config.initialViewport), tileSize: MAPBOX_TILE_SIZE };
2453
2489
  this.cachedCameraState = {
2454
2490
  center: this.state.center,
@@ -2461,6 +2497,7 @@ class MapboxAdapterInstance {
2461
2497
  initializationFailed: this.state.initializationFailed,
2462
2498
  appearance: this.state.appearance,
2463
2499
  tileSize: this.state.tileSize,
2500
+ drawing: this.state.drawing,
2464
2501
  };
2465
2502
  }
2466
2503
  // ============================================================================
@@ -2483,8 +2520,18 @@ class MapboxAdapterInstance {
2483
2520
  this.setupEventListeners();
2484
2521
  // Connect the layer port so it can render on this map
2485
2522
  this.layers.connect(map);
2523
+ // Drawing lives in style-bound sources and layers, so the engine is (re)built on
2524
+ // every style load and torn down before setStyle (ADR 0030). Mode and value survive
2525
+ // in the controller; the engine is rebuilt from the committed value.
2526
+ const connectDraw = () => this.connectDraw(map);
2527
+ map.on("style.load", connectDraw);
2528
+ this.mapboxEventCleanups.push(() => map.off("style.load", connectDraw));
2529
+ if (this.styleLoadedMap === map || map.isStyleLoaded()) {
2530
+ connectDraw();
2531
+ }
2532
+ this.appliedStyleUrl = getInitialStyleUrl(this.appearance.theme);
2486
2533
  // Set isReady and notify status listeners before syncing camera state
2487
- this.state = { ...this.state, isReady: true, initializationFailed: false };
2534
+ this.state = { ...this.state, isReady: true, initializationFailed: false, drawing: this.draw.getMode() !== "idle" };
2488
2535
  this.updateStatusCache();
2489
2536
  this.notifyStatusListeners();
2490
2537
  // Sync initial map state
@@ -2603,12 +2650,7 @@ class MapboxAdapterInstance {
2603
2650
  const currentMap = this.map;
2604
2651
  if (currentMap === null)
2605
2652
  return;
2606
- const effectiveType = this.effectiveMapType();
2607
- const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2608
- return new Promise(resolve => {
2609
- currentMap.setStyle(styleUrl);
2610
- currentMap.once("idle", () => resolve());
2611
- });
2653
+ return this.applyStyle(currentMap);
2612
2654
  }
2613
2655
  async setTheme(theme) {
2614
2656
  if (this.appearance.theme === theme)
@@ -2621,12 +2663,7 @@ class MapboxAdapterInstance {
2621
2663
  const currentMap = this.map;
2622
2664
  if (currentMap === null)
2623
2665
  return;
2624
- const effectiveType = this.effectiveMapType();
2625
- const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2626
- return new Promise(resolve => {
2627
- currentMap.setStyle(styleUrl);
2628
- currentMap.once("idle", () => resolve());
2629
- });
2666
+ return this.applyStyle(currentMap);
2630
2667
  }
2631
2668
  async setShowRoads(showRoads) {
2632
2669
  if (this.appearance.showRoads === showRoads)
@@ -2640,12 +2677,7 @@ class MapboxAdapterInstance {
2640
2677
  if (currentMap === null)
2641
2678
  return;
2642
2679
  if (this.appearance.mapType === "satellite" || this.appearance.mapType === "hybrid") {
2643
- const effectiveType = this.effectiveMapType();
2644
- const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2645
- return new Promise(resolve => {
2646
- currentMap.setStyle(styleUrl);
2647
- currentMap.once("idle", () => resolve());
2648
- });
2680
+ return this.applyStyle(currentMap);
2649
2681
  }
2650
2682
  }
2651
2683
  on(event, handler) {
@@ -2679,7 +2711,8 @@ class MapboxAdapterInstance {
2679
2711
  }
2680
2712
  destroy() {
2681
2713
  this.isDestroyedFlag = true;
2682
- // Clean up layer port before removing event listeners
2714
+ // Clean up drawing and layer port before removing event listeners
2715
+ this.draw.disconnect();
2683
2716
  this.layers.destroy();
2684
2717
  // Remove all Mapbox event listeners
2685
2718
  this.mapboxEventCleanups.forEach(cleanup => cleanup());
@@ -2807,6 +2840,39 @@ class MapboxAdapterInstance {
2807
2840
  this.updateCameraCache();
2808
2841
  this.notifyCameraListeners();
2809
2842
  }
2843
+ connectDraw(map) {
2844
+ this.styleLoadedMap = map;
2845
+ this.draw.connect(createMapboxDrawEngineLoader(map));
2846
+ }
2847
+ /**
2848
+ * Applies the style for the current appearance.
2849
+ *
2850
+ * Satellite light and dark share one URL, and satellite with roads is the hybrid
2851
+ * URL, so a change can resolve to the style already on the map. Mapbox diffs such
2852
+ * a request as a no-op and never fires `style.load`; skipping it keeps the layers
2853
+ * and the drawing in place. For a real change the drawing is torn down first and
2854
+ * rebound on `style.load`, with `idle` as the safety net should the load event not
2855
+ * fire.
2856
+ */
2857
+ applyStyle(currentMap) {
2858
+ const styleUrl = MAPBOX_MAP_TYPE_STYLES[this.effectiveMapType()][this.appearance.theme];
2859
+ if (styleUrl === this.appliedStyleUrl)
2860
+ return Promise.resolve();
2861
+ this.appliedStyleUrl = styleUrl;
2862
+ return new Promise(resolve => {
2863
+ this.draw.disconnect();
2864
+ currentMap.setStyle(styleUrl);
2865
+ currentMap.once("idle", () => {
2866
+ // A late idle after destroy() or after a map swap must not attach Terra Draw
2867
+ // to a torn-down adapter or a dead map.
2868
+ const stillCurrent = this.map === currentMap && !this.isDestroyedFlag;
2869
+ if (stillCurrent && !this.draw.isConnected()) {
2870
+ this.connectDraw(currentMap);
2871
+ }
2872
+ resolve();
2873
+ });
2874
+ });
2875
+ }
2810
2876
  /**
2811
2877
  * Resolves the effective Mapbox style key: when the user asks for "satellite"
2812
2878
  * with roads enabled, we use "hybrid" (satellite + road labels).
@@ -2822,8 +2888,17 @@ class MapboxAdapterInstance {
2822
2888
  this.cachedCameraState = { center, zoom, bounds, isIdle };
2823
2889
  }
2824
2890
  updateStatusCache() {
2825
- const { isReady, initializationFailed, appearance, tileSize } = this.state;
2826
- this.cachedStatus = { isReady, initializationFailed, appearance, tileSize };
2891
+ const { isReady, initializationFailed, appearance, tileSize, drawing } = this.state;
2892
+ this.cachedStatus = { isReady, initializationFailed, appearance, tileSize, drawing };
2893
+ }
2894
+ /** Mirrors the draw controller's mode into `MapStatus.drawing` (ADR 0030). */
2895
+ syncDrawingStatus() {
2896
+ const drawing = this.draw.getMode() !== "idle";
2897
+ if (this.state.drawing === drawing)
2898
+ return;
2899
+ this.state = { ...this.state, drawing };
2900
+ this.updateStatusCache();
2901
+ this.notifyStatusListeners();
2827
2902
  }
2828
2903
  notifyCameraListeners() {
2829
2904
  this.cameraListeners.forEach(listener => listener());
@@ -2912,7 +2987,7 @@ const MapboxMapInternal = ({ adapterInstance, children, className, style, theme,
2912
2987
  const mapOptions = initialViewport?.type === "bounds"
2913
2988
  ? {
2914
2989
  container: el,
2915
- style: MAPBOX_MAP_TYPE_STYLES.roadmap[theme],
2990
+ style: getInitialStyleUrl(theme),
2916
2991
  bounds: bboxToLngLatBounds(initialViewport.bounds),
2917
2992
  fitBoundsOptions: initialViewport.padding !== undefined || initialViewport.maxZoom !== undefined
2918
2993
  ? {
@@ -2925,7 +3000,7 @@ const MapboxMapInternal = ({ adapterInstance, children, className, style, theme,
2925
3000
  }
2926
3001
  : {
2927
3002
  container: el,
2928
- style: MAPBOX_MAP_TYPE_STYLES.roadmap[theme],
3003
+ style: getInitialStyleUrl(theme),
2929
3004
  center: initialViewport?.type === "center"
2930
3005
  ? { lng: initialViewport.center[0], lat: initialViewport.center[1] }
2931
3006
  : { lng: reactMapAdapterShared.DEFAULT_CENTER.lng, lat: reactMapAdapterShared.DEFAULT_CENTER.lat },
package/index.esm.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { canPatchAdaptiveMarker, canPatchAdaptiveViewport, canPatchMarkerInPlace, getAdaptiveDomFeatureIds, buildAdaptiveSymbolStyleFn, extractPointCoordinates, resolveCircleSymbolDefaults, resolveSymbolDescriptor, mergeAntimeridianFeatures, densifyGeodesicFeatures, LAYER_FADE_DURATION_MS, patchPortalDescriptors, buildAdaptiveDomRenderFn, extractSourceData, collectFeatureIdSet, removeGoneIndexedMarkers, createSymbolDotElement, createDefaultClusterElement, createClusterPinElement, fadeInElement, buildAdaptiveDomEntries, attachSafeAreaHoverListeners, resolveSelectedStyle, resolveHoveredStyle, MAP_CURSORS, geometryTypeToShapeType, INITIAL_MAP_STATE, DEFAULT_MAP_APPEARANCE, computeInitialState, cameraStateEquals, isEventOfType, getEffectiveRestrictBounds, DEFAULT_ZOOM, DEFAULT_CENTER, defineAdapter } from '@trackunit/react-map-adapter-shared';
2
2
  export { mapboxMapCspDirectives } from '@trackunit/react-map-adapter-shared';
3
3
  import { validateBbox, validatePosition } from '@trackunit/geo-json-utils';
4
+ import { loadTerraDrawEngine, DRAW_COORDINATE_PRECISION, DrawController } from '@trackunit/react-map-draw';
4
5
  import { isEqual } from 'es-toolkit';
5
6
  import mapboxgl from 'mapbox-gl';
6
7
  import Supercluster from 'supercluster';
@@ -26,6 +27,27 @@ const MAPBOX_MAP_TYPE_STYLES = {
26
27
  dark: "mapbox://styles/mapbox/satellite-streets-v12",
27
28
  },
28
29
  };
30
+ /**
31
+ * Style every Mapbox map is created with. The renderer passes it to `mapboxgl.Map`
32
+ * and the adapter instance records it as the applied style, so the two agree on
33
+ * what is on the map before the first `applyStyle`.
34
+ */
35
+ const getInitialStyleUrl = (theme) => MAPBOX_MAP_TYPE_STYLES.roadmap[theme];
36
+
37
+ /**
38
+ * Builds the Mapbox GL bridge for the shared drawing engine (ADR 0030).
39
+ *
40
+ * One loader per loaded style: `setStyle` (theme, map type, Roads) removes every
41
+ * custom source and layer, so `MapboxAdapterInstance` disconnects the controller
42
+ * before a style change and reconnects it with a fresh loader on `style.load`.
43
+ * Terra Draw and its Mapbox adapter are only downloaded when the loader runs.
44
+ */
45
+ const createMapboxDrawEngineLoader = (map) => async () => {
46
+ const { TerraDrawMapboxGLAdapter } = await import('terra-draw-mapbox-gl-adapter');
47
+ return loadTerraDrawEngine({
48
+ adapter: new TerraDrawMapboxGLAdapter({ map, coordinatePrecision: DRAW_COORDINATE_PRECISION }),
49
+ });
50
+ };
29
51
 
30
52
  // ============================================================================
31
53
  // Helpers
@@ -2439,15 +2461,29 @@ class MapboxAdapterInstance {
2439
2461
  initializationFailed: INITIAL_MAP_STATE.initializationFailed,
2440
2462
  appearance: INITIAL_MAP_STATE.appearance,
2441
2463
  tileSize: MAPBOX_TILE_SIZE,
2464
+ drawing: INITIAL_MAP_STATE.drawing,
2442
2465
  };
2443
2466
  this.cameraListeners = new Set();
2444
2467
  this.statusListeners = new Set();
2445
2468
  this.eventListeners = new Map();
2446
2469
  this.mapboxEventCleanups = [];
2447
2470
  this.isDestroyedFlag = false;
2471
+ /**
2472
+ * The map whose style has loaded at least once. `map.isStyleLoaded()` is false
2473
+ * inside `style.load` and transiently while sources load, so the drawing bridge
2474
+ * keeps its own readiness (same reason as `MapboxLayerPort.styleReady`).
2475
+ */
2476
+ this.styleLoadedMap = null;
2477
+ /** Style URL currently applied to the map, so a same-URL change is skipped instead of reloaded. */
2478
+ this.appliedStyleUrl = null;
2448
2479
  this.appearance = { ...DEFAULT_MAP_APPEARANCE, theme: config.theme ?? "light" };
2449
2480
  this.layers = new MapboxLayerPort();
2450
2481
  this.layers.setTheme(this.appearance.theme);
2482
+ // Engine load failures are not surfaced here (same as the deck.gl runtime): the
2483
+ // controller keeps mode and value and loads again on the next connect, setMode
2484
+ // or setValue that needs an engine.
2485
+ this.draw = new DrawController();
2486
+ this.draw.subscribe(() => this.syncDrawingStatus());
2451
2487
  this.state = { ...computeInitialState(this.appearance, config.initialViewport), tileSize: MAPBOX_TILE_SIZE };
2452
2488
  this.cachedCameraState = {
2453
2489
  center: this.state.center,
@@ -2460,6 +2496,7 @@ class MapboxAdapterInstance {
2460
2496
  initializationFailed: this.state.initializationFailed,
2461
2497
  appearance: this.state.appearance,
2462
2498
  tileSize: this.state.tileSize,
2499
+ drawing: this.state.drawing,
2463
2500
  };
2464
2501
  }
2465
2502
  // ============================================================================
@@ -2482,8 +2519,18 @@ class MapboxAdapterInstance {
2482
2519
  this.setupEventListeners();
2483
2520
  // Connect the layer port so it can render on this map
2484
2521
  this.layers.connect(map);
2522
+ // Drawing lives in style-bound sources and layers, so the engine is (re)built on
2523
+ // every style load and torn down before setStyle (ADR 0030). Mode and value survive
2524
+ // in the controller; the engine is rebuilt from the committed value.
2525
+ const connectDraw = () => this.connectDraw(map);
2526
+ map.on("style.load", connectDraw);
2527
+ this.mapboxEventCleanups.push(() => map.off("style.load", connectDraw));
2528
+ if (this.styleLoadedMap === map || map.isStyleLoaded()) {
2529
+ connectDraw();
2530
+ }
2531
+ this.appliedStyleUrl = getInitialStyleUrl(this.appearance.theme);
2485
2532
  // Set isReady and notify status listeners before syncing camera state
2486
- this.state = { ...this.state, isReady: true, initializationFailed: false };
2533
+ this.state = { ...this.state, isReady: true, initializationFailed: false, drawing: this.draw.getMode() !== "idle" };
2487
2534
  this.updateStatusCache();
2488
2535
  this.notifyStatusListeners();
2489
2536
  // Sync initial map state
@@ -2602,12 +2649,7 @@ class MapboxAdapterInstance {
2602
2649
  const currentMap = this.map;
2603
2650
  if (currentMap === null)
2604
2651
  return;
2605
- const effectiveType = this.effectiveMapType();
2606
- const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2607
- return new Promise(resolve => {
2608
- currentMap.setStyle(styleUrl);
2609
- currentMap.once("idle", () => resolve());
2610
- });
2652
+ return this.applyStyle(currentMap);
2611
2653
  }
2612
2654
  async setTheme(theme) {
2613
2655
  if (this.appearance.theme === theme)
@@ -2620,12 +2662,7 @@ class MapboxAdapterInstance {
2620
2662
  const currentMap = this.map;
2621
2663
  if (currentMap === null)
2622
2664
  return;
2623
- const effectiveType = this.effectiveMapType();
2624
- const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2625
- return new Promise(resolve => {
2626
- currentMap.setStyle(styleUrl);
2627
- currentMap.once("idle", () => resolve());
2628
- });
2665
+ return this.applyStyle(currentMap);
2629
2666
  }
2630
2667
  async setShowRoads(showRoads) {
2631
2668
  if (this.appearance.showRoads === showRoads)
@@ -2639,12 +2676,7 @@ class MapboxAdapterInstance {
2639
2676
  if (currentMap === null)
2640
2677
  return;
2641
2678
  if (this.appearance.mapType === "satellite" || this.appearance.mapType === "hybrid") {
2642
- const effectiveType = this.effectiveMapType();
2643
- const styleUrl = MAPBOX_MAP_TYPE_STYLES[effectiveType][this.appearance.theme];
2644
- return new Promise(resolve => {
2645
- currentMap.setStyle(styleUrl);
2646
- currentMap.once("idle", () => resolve());
2647
- });
2679
+ return this.applyStyle(currentMap);
2648
2680
  }
2649
2681
  }
2650
2682
  on(event, handler) {
@@ -2678,7 +2710,8 @@ class MapboxAdapterInstance {
2678
2710
  }
2679
2711
  destroy() {
2680
2712
  this.isDestroyedFlag = true;
2681
- // Clean up layer port before removing event listeners
2713
+ // Clean up drawing and layer port before removing event listeners
2714
+ this.draw.disconnect();
2682
2715
  this.layers.destroy();
2683
2716
  // Remove all Mapbox event listeners
2684
2717
  this.mapboxEventCleanups.forEach(cleanup => cleanup());
@@ -2806,6 +2839,39 @@ class MapboxAdapterInstance {
2806
2839
  this.updateCameraCache();
2807
2840
  this.notifyCameraListeners();
2808
2841
  }
2842
+ connectDraw(map) {
2843
+ this.styleLoadedMap = map;
2844
+ this.draw.connect(createMapboxDrawEngineLoader(map));
2845
+ }
2846
+ /**
2847
+ * Applies the style for the current appearance.
2848
+ *
2849
+ * Satellite light and dark share one URL, and satellite with roads is the hybrid
2850
+ * URL, so a change can resolve to the style already on the map. Mapbox diffs such
2851
+ * a request as a no-op and never fires `style.load`; skipping it keeps the layers
2852
+ * and the drawing in place. For a real change the drawing is torn down first and
2853
+ * rebound on `style.load`, with `idle` as the safety net should the load event not
2854
+ * fire.
2855
+ */
2856
+ applyStyle(currentMap) {
2857
+ const styleUrl = MAPBOX_MAP_TYPE_STYLES[this.effectiveMapType()][this.appearance.theme];
2858
+ if (styleUrl === this.appliedStyleUrl)
2859
+ return Promise.resolve();
2860
+ this.appliedStyleUrl = styleUrl;
2861
+ return new Promise(resolve => {
2862
+ this.draw.disconnect();
2863
+ currentMap.setStyle(styleUrl);
2864
+ currentMap.once("idle", () => {
2865
+ // A late idle after destroy() or after a map swap must not attach Terra Draw
2866
+ // to a torn-down adapter or a dead map.
2867
+ const stillCurrent = this.map === currentMap && !this.isDestroyedFlag;
2868
+ if (stillCurrent && !this.draw.isConnected()) {
2869
+ this.connectDraw(currentMap);
2870
+ }
2871
+ resolve();
2872
+ });
2873
+ });
2874
+ }
2809
2875
  /**
2810
2876
  * Resolves the effective Mapbox style key: when the user asks for "satellite"
2811
2877
  * with roads enabled, we use "hybrid" (satellite + road labels).
@@ -2821,8 +2887,17 @@ class MapboxAdapterInstance {
2821
2887
  this.cachedCameraState = { center, zoom, bounds, isIdle };
2822
2888
  }
2823
2889
  updateStatusCache() {
2824
- const { isReady, initializationFailed, appearance, tileSize } = this.state;
2825
- this.cachedStatus = { isReady, initializationFailed, appearance, tileSize };
2890
+ const { isReady, initializationFailed, appearance, tileSize, drawing } = this.state;
2891
+ this.cachedStatus = { isReady, initializationFailed, appearance, tileSize, drawing };
2892
+ }
2893
+ /** Mirrors the draw controller's mode into `MapStatus.drawing` (ADR 0030). */
2894
+ syncDrawingStatus() {
2895
+ const drawing = this.draw.getMode() !== "idle";
2896
+ if (this.state.drawing === drawing)
2897
+ return;
2898
+ this.state = { ...this.state, drawing };
2899
+ this.updateStatusCache();
2900
+ this.notifyStatusListeners();
2826
2901
  }
2827
2902
  notifyCameraListeners() {
2828
2903
  this.cameraListeners.forEach(listener => listener());
@@ -2911,7 +2986,7 @@ const MapboxMapInternal = ({ adapterInstance, children, className, style, theme,
2911
2986
  const mapOptions = initialViewport?.type === "bounds"
2912
2987
  ? {
2913
2988
  container: el,
2914
- style: MAPBOX_MAP_TYPE_STYLES.roadmap[theme],
2989
+ style: getInitialStyleUrl(theme),
2915
2990
  bounds: bboxToLngLatBounds(initialViewport.bounds),
2916
2991
  fitBoundsOptions: initialViewport.padding !== undefined || initialViewport.maxZoom !== undefined
2917
2992
  ? {
@@ -2924,7 +2999,7 @@ const MapboxMapInternal = ({ adapterInstance, children, className, style, theme,
2924
2999
  }
2925
3000
  : {
2926
3001
  container: el,
2927
- style: MAPBOX_MAP_TYPE_STYLES.roadmap[theme],
3002
+ style: getInitialStyleUrl(theme),
2928
3003
  center: initialViewport?.type === "center"
2929
3004
  ? { lng: initialViewport.center[0], lat: initialViewport.center[1] }
2930
3005
  : { lng: DEFAULT_CENTER.lng, lat: DEFAULT_CENTER.lat },
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "@trackunit/react-map-adapter-mapbox",
3
- "version": "0.0.149",
3
+ "version": "0.0.152",
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.161",
11
- "@trackunit/geo-json-utils": "1.18.9",
10
+ "@trackunit/react-map-adapter-shared": "0.0.164",
11
+ "@trackunit/geo-json-utils": "1.19.0",
12
12
  "mapbox-gl": "^3.18.1",
13
13
  "supercluster": "^8.0.1",
14
14
  "es-toolkit": "^1.52.0",
15
15
  "react-device-detect": "^2.2.3",
16
- "zod": "^3.25.76"
16
+ "zod": "^3.25.76",
17
+ "@trackunit/react-map-draw": "0.0.2",
18
+ "terra-draw-mapbox-gl-adapter": "^1.4.0"
17
19
  },
18
20
  "peerDependencies": {
19
21
  "react": "^19.0.0"
@@ -1,4 +1,5 @@
1
1
  import { type AdapterInstance, type BaseAdapterConfig, type CameraState, type FitBoundsOptions, type GeoJsonBbox, type GeoJsonPosition, type MapEvent, type MapEventHandler, type MapStatus, type MapTheme, type MapType } from "@trackunit/react-map-adapter-shared";
2
+ import { DrawController } from "@trackunit/react-map-draw";
2
3
  import { MapboxLayerPort } from "./MapboxLayerPort";
3
4
  /**
4
5
  * Configuration for Mapbox adapter
@@ -15,6 +16,11 @@ export declare class MapboxAdapterInstance implements AdapterInstance<MapboxConf
15
16
  private readonly config;
16
17
  /** Layer rendering port -- manages markers, shapes, routes, and overlays on the map */
17
18
  readonly layers: MapboxLayerPort;
19
+ /**
20
+ * Drawing capability (ADR 0030). The controller keeps mode and value across style
21
+ * reloads; `connect()` rebinds it on every `style.load`.
22
+ */
23
+ readonly draw: DrawController;
18
24
  private map;
19
25
  private state;
20
26
  private cachedCameraState;
@@ -24,6 +30,14 @@ export declare class MapboxAdapterInstance implements AdapterInstance<MapboxConf
24
30
  private readonly eventListeners;
25
31
  private mapboxEventCleanups;
26
32
  private isDestroyedFlag;
33
+ /**
34
+ * The map whose style has loaded at least once. `map.isStyleLoaded()` is false
35
+ * inside `style.load` and transiently while sources load, so the drawing bridge
36
+ * keeps its own readiness (same reason as `MapboxLayerPort.styleReady`).
37
+ */
38
+ private styleLoadedMap;
39
+ /** Style URL currently applied to the map, so a same-URL change is skipped instead of reloaded. */
40
+ private appliedStyleUrl;
27
41
  /** Unified appearance state — theme, mapType, and showRoads in one place */
28
42
  private appearance;
29
43
  constructor(config: MapboxConfig);
@@ -61,6 +75,18 @@ export declare class MapboxAdapterInstance implements AdapterInstance<MapboxConf
61
75
  * Used by movestart (false) and the idle path already calls updateState (true).
62
76
  */
63
77
  private setIdleState;
78
+ private connectDraw;
79
+ /**
80
+ * Applies the style for the current appearance.
81
+ *
82
+ * Satellite light and dark share one URL, and satellite with roads is the hybrid
83
+ * URL, so a change can resolve to the style already on the map. Mapbox diffs such
84
+ * a request as a no-op and never fires `style.load`; skipping it keeps the layers
85
+ * and the drawing in place. For a real change the drawing is torn down first and
86
+ * rebound on `style.load`, with `idle` as the safety net should the load event not
87
+ * fire.
88
+ */
89
+ private applyStyle;
64
90
  /**
65
91
  * Resolves the effective Mapbox style key: when the user asks for "satellite"
66
92
  * with roads enabled, we use "hybrid" (satellite + road labels).
@@ -68,6 +94,8 @@ export declare class MapboxAdapterInstance implements AdapterInstance<MapboxConf
68
94
  private effectiveMapType;
69
95
  private updateCameraCache;
70
96
  private updateStatusCache;
97
+ /** Mirrors the draw controller's mode into `MapStatus.drawing` (ADR 0030). */
98
+ private syncDrawingStatus;
71
99
  private notifyCameraListeners;
72
100
  private notifyStatusListeners;
73
101
  private emitEvent;
@@ -1,3 +1,4 @@
1
+ import type { MapTheme } from "@trackunit/react-map-adapter-shared";
1
2
  /**
2
3
  * Mapbox style URLs for different map types
3
4
  */
@@ -15,3 +16,9 @@ export declare const MAPBOX_MAP_TYPE_STYLES: {
15
16
  readonly dark: "mapbox://styles/mapbox/satellite-streets-v12";
16
17
  };
17
18
  };
19
+ /**
20
+ * Style every Mapbox map is created with. The renderer passes it to `mapboxgl.Map`
21
+ * and the adapter instance records it as the applied style, so the two agree on
22
+ * what is on the map before the first `applyStyle`.
23
+ */
24
+ export declare const getInitialStyleUrl: (theme: MapTheme) => string;
@@ -0,0 +1,10 @@
1
+ import { type DrawEngineLoader } from "@trackunit/react-map-draw";
2
+ /**
3
+ * Builds the Mapbox GL bridge for the shared drawing engine (ADR 0030).
4
+ *
5
+ * One loader per loaded style: `setStyle` (theme, map type, Roads) removes every
6
+ * custom source and layer, so `MapboxAdapterInstance` disconnects the controller
7
+ * before a style change and reconnects it with a fresh loader on `style.load`.
8
+ * Terra Draw and its Mapbox adapter are only downloaded when the loader runs.
9
+ */
10
+ export declare const createMapboxDrawEngineLoader: (map: mapboxgl.Map) => DrawEngineLoader;