@trackunit/react-map-adapter-mapbox 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@trackunit/react-map-adapter-mapbox",
3
+ "version": "0.0.3",
4
+ "repository": "https://github.com/Trackunit/manager",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "engines": {
7
+ "node": ">=24.x"
8
+ },
9
+ "dependencies": {
10
+ "@trackunit/react-map-adapter-shared": "0.0.16",
11
+ "@trackunit/geo-json-utils": "1.14.35",
12
+ "mapbox-gl": "^3.18.1",
13
+ "supercluster": "^8.0.1",
14
+ "es-toolkit": "^1.39.10",
15
+ "react-device-detect": "^2.2.3",
16
+ "zod": "^3.25.76"
17
+ },
18
+ "peerDependencies": {
19
+ "react": "^19.0.0"
20
+ },
21
+ "module": "./index.esm.js",
22
+ "main": "./index.cjs.js",
23
+ "types": "./index.d.ts"
24
+ }
@@ -0,0 +1,75 @@
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 { MapboxLayerPort } from "./MapboxLayerPort";
3
+ /**
4
+ * Configuration for Mapbox adapter
5
+ */
6
+ export type MapboxConfig = Readonly<BaseAdapterConfig & {
7
+ /** Mapbox access token (required) */
8
+ accessToken: string;
9
+ }>;
10
+ /**
11
+ * Mapbox adapter instance implementation
12
+ * Manages the connection between our abstract interface and Mapbox GL API
13
+ */
14
+ export declare class MapboxAdapterInstance implements AdapterInstance<MapboxConfig> {
15
+ private readonly config;
16
+ /** Layer rendering port -- manages markers, shapes, routes, and overlays on the map */
17
+ readonly layers: MapboxLayerPort;
18
+ private map;
19
+ private state;
20
+ private cachedCameraState;
21
+ private cachedStatus;
22
+ private readonly cameraListeners;
23
+ private readonly statusListeners;
24
+ private readonly eventListeners;
25
+ private mapboxEventCleanups;
26
+ private isDestroyedFlag;
27
+ /** Unified appearance state — theme, mapType, and showRoads in one place */
28
+ private appearance;
29
+ constructor(config: MapboxConfig);
30
+ /**
31
+ * Connect to a Mapbox map instance - called by MapboxRenderer
32
+ */
33
+ connect(map: mapboxgl.Map): void;
34
+ getConfig(): MapboxConfig;
35
+ getCameraState(): CameraState;
36
+ getStatus(): MapStatus;
37
+ subscribeCamera(listener: () => void): () => void;
38
+ subscribeStatus(listener: () => void): () => void;
39
+ setCenter(center: GeoJsonPosition): Promise<void>;
40
+ setZoom(zoom: number): Promise<void>;
41
+ zoomBy(delta: number): Promise<void>;
42
+ fitBounds(bounds: GeoJsonBbox, options?: FitBoundsOptions): Promise<void>;
43
+ panTo(center: GeoJsonPosition): Promise<void>;
44
+ panBy(deltaX: number, deltaY: number): Promise<void>;
45
+ setMapType(type: MapType): Promise<void>;
46
+ setTheme(theme: MapTheme): Promise<void>;
47
+ setShowRoads(showRoads: boolean): Promise<void>;
48
+ on<TEventType extends MapEvent["type"]>(event: TEventType, handler: MapEventHandler<TEventType>): () => void;
49
+ notifyInitializationFailed(): void;
50
+ destroy(): void;
51
+ private setupEventListeners;
52
+ private updateState;
53
+ /**
54
+ * Update camera position fields (center, zoom, bounds) without touching isIdle.
55
+ * Called during `move` events so that isIdle=false set by `movestart` is preserved
56
+ * until the map reaches the `idle` state.
57
+ */
58
+ private updatePositionOnly;
59
+ /**
60
+ * Explicitly set isIdle and notify camera subscribers if it changed.
61
+ * Used by movestart (false) and the idle path already calls updateState (true).
62
+ */
63
+ private setIdleState;
64
+ /**
65
+ * Resolves the effective Mapbox style key: when the user asks for "satellite"
66
+ * with roads enabled, we use "hybrid" (satellite + road labels).
67
+ */
68
+ private effectiveMapType;
69
+ private updateCameraCache;
70
+ private updateStatusCache;
71
+ private notifyCameraListeners;
72
+ private notifyStatusListeners;
73
+ private emitEvent;
74
+ }
75
+ export declare const createMapboxInstance: (config: MapboxConfig) => MapboxAdapterInstance;
@@ -0,0 +1,236 @@
1
+ import { type DomPortalStore, type EntityInteractionHandler, type LayerPort, type LayerSnapshot, type MapTheme, type ShapeStyleDefaults } from "@trackunit/react-map-adapter-shared";
2
+ /**
3
+ * Mapbox implementation of the LayerPort interface.
4
+ *
5
+ * Manages Mapbox GL JS sources, layers, and Marker overlays to render map content.
6
+ * Each source type maps to appropriate Mapbox constructs:
7
+ * - Markers: `mapboxgl.Marker` DOM overlays (symbol: styled div, dom: portal container)
8
+ * - Shapes: GeoJSON source + fill/line layers
9
+ * - Routes: GeoJSON source + line layer (+ optional symbol layer for arrows)
10
+ * - Image overlays: Image source + raster layer
11
+ *
12
+ * Client-side clustering uses `supercluster` directly, managing marker
13
+ * visibility based on viewport and zoom level.
14
+ *
15
+ * Requires `connect(map)` to be called (by MapboxAdapterInstance) before
16
+ * any sources can be rendered. Sources set before connect are queued.
17
+ */
18
+ export declare class MapboxLayerPort implements LayerPort {
19
+ /**
20
+ * Marker portal subscription compatible with `useSyncExternalStore`.
21
+ * `<Layers>` routes descriptors from this store to `markerRender`. Includes
22
+ * static DOM markers and adaptive markers resolved to the DOM medium.
23
+ */
24
+ readonly markerPortals: DomPortalStore;
25
+ /**
26
+ * Cluster portal subscription compatible with `useSyncExternalStore`.
27
+ * `<Layers>` routes descriptors from this store to `clusterRender`. Includes
28
+ * client-cluster DOM containers and any other cluster-class portals.
29
+ */
30
+ readonly clusterPortals: DomPortalStore;
31
+ private map;
32
+ private readonly interactionHandlers;
33
+ private readonly backgroundClickHandlers;
34
+ private entityClickedInTick;
35
+ private mapClickHandler;
36
+ private readonly markerSources;
37
+ /** featureId → DOM marker, keyed by sourceId. Cleared when a source is removed. */
38
+ private readonly adaptiveMarkerIndex;
39
+ /** featureId → cluster DOM marker, keyed by sourceId. Cleared when a source is removed. */
40
+ private readonly clusterMarkerIndex;
41
+ private readonly shapeSources;
42
+ private readonly routeSources;
43
+ private readonly overlaySources;
44
+ private pendingSnapshot;
45
+ private lastNonEmptySnapshot;
46
+ private markerPortalDescriptors;
47
+ private clusterPortalDescriptors;
48
+ private readonly markerPortalSubscribers;
49
+ private readonly clusterPortalSubscribers;
50
+ private readonly clusterEntitiesByMarker;
51
+ private styleLoadHandler;
52
+ /**
53
+ * Tracks whether the Mapbox style has loaded at least once.
54
+ * `map.isStyleLoaded()` returns false inside `style.load` callbacks,
55
+ * so we maintain our own flag set synchronously in the handler.
56
+ */
57
+ private styleReady;
58
+ private arrowImageAdded;
59
+ private readonly shapeClickHandlers;
60
+ private readonly shapeDblClickHandlers;
61
+ private readonly shapeMouseEnterHandlers;
62
+ private readonly shapeMouseLeaveHandlers;
63
+ private readonly routeClickHandlers;
64
+ private readonly routeDblClickHandlers;
65
+ private readonly routeMouseEnterHandlers;
66
+ private readonly routeMouseLeaveHandlers;
67
+ private readonly markerClickHandlers;
68
+ private readonly markerDblClickHandlers;
69
+ private readonly markerMouseMoveHandlers;
70
+ private readonly markerMouseLeaveHandlers;
71
+ private lastHoveredMarkerFeatureId;
72
+ /** Shared state for the active document-level pointermove watcher across all DOM markers. */
73
+ private readonly markerHoverState;
74
+ private selectedShapeHandleId;
75
+ private selectedShapeFeatureId;
76
+ private hoveredShapeHandleId;
77
+ private hoveredShapeFeatureId;
78
+ private theme;
79
+ private readonly sourceReadyCallbacks;
80
+ private sourcedataHandler;
81
+ private _shapeStyleDefaults;
82
+ private get shapeStyleDefaults();
83
+ setShapeStyleDefaults(defaults: ShapeStyleDefaults): void;
84
+ /**
85
+ * Connect to a Mapbox map instance.
86
+ * Called by MapboxAdapterInstance.connect() when the map is ready.
87
+ * Flushes any sources that were set before the map was available.
88
+ */
89
+ connect(map: mapboxgl.Map): void;
90
+ setSnapshot(snapshot: LayerSnapshot): void;
91
+ /**
92
+ * Update the theme used for interaction style resolution (hover color shifts).
93
+ * Called by the adapter instance when the map theme changes.
94
+ */
95
+ setTheme(theme: MapTheme): void;
96
+ onEntityInteraction(handler: EntityInteractionHandler): () => void;
97
+ onBackgroundClick(handler: () => void): () => void;
98
+ onSourceReady(configId: string, callback: () => void): () => void;
99
+ /** @internal */
100
+ destroy(): void;
101
+ private syncMarkerSource;
102
+ private removeMarkerSourceInternal;
103
+ private syncShapeSource;
104
+ private removeShapeSourceInternal;
105
+ private applyShapeSelectionState;
106
+ private applyShapeHoverState;
107
+ private syncRouteSource;
108
+ private removeRouteSourceInternal;
109
+ private syncImageOverlay;
110
+ private removeImageOverlayInternal;
111
+ /**
112
+ * Wrapper around `map.addLayer` that starts the layer at opacity 0 and
113
+ * animates to the target opacity via Mapbox's native paint transition system.
114
+ * Layer types without a known opacity key (e.g. `symbol`) are added normally.
115
+ */
116
+ private addLayerWithFadeIn;
117
+ /**
118
+ * Process all queued source configurations that arrived before the style
119
+ * was ready. Idempotent -- clears each queue after processing.
120
+ */
121
+ private applySnapshot;
122
+ private flushPending;
123
+ /** Add a portal descriptor to the marker store and notify subscribers */
124
+ private addMarkerPortalDescriptor;
125
+ /** Add a portal descriptor to the cluster store and notify subscribers */
126
+ private addClusterPortalDescriptor;
127
+ /** Remove all portal descriptors for `sourceId` from both stores and notify */
128
+ private removePortalDescriptorsForSource;
129
+ private notifyMarkerPortalSubscribers;
130
+ private notifyClusterPortalSubscribers;
131
+ /**
132
+ * Patch existing DOM markers in place: update positions via `setLngLat` and
133
+ * refresh portal descriptors so React re-renders with the latest render function.
134
+ * Avoids the remove-then-add cycle that causes a brief flash at (0,0).
135
+ */
136
+ private patchDomMarkers;
137
+ /**
138
+ * Incrementally update adaptive DOM markers and the WebGL circle source
139
+ * when `adaptiveResolution` (or callbacks) changed but feature IDs are the same.
140
+ *
141
+ * Three phases:
142
+ * 1. DOM → symbol: remove DOM marker + portal for features leaving DOM mode.
143
+ * 2. Symbol → DOM: add new DOM marker + portal for features entering DOM mode.
144
+ * 3. DOM → DOM: update portal renderFn (so the consumer's latest closure runs).
145
+ * 4. Update WebGL circle source to reflect the new DOM/symbol split.
146
+ */
147
+ private patchAdaptiveMarkers;
148
+ /**
149
+ * Incrementally sync adaptive markers and server-side clusters when a viewport
150
+ * refetch changes which feature IDs are visible, without tearing down unchanged DOM markers.
151
+ */
152
+ private patchAdaptiveViewportData;
153
+ /**
154
+ * Remove adaptive DOM markers whose feature IDs are no longer in the incoming config.
155
+ */
156
+ private removeGoneAdaptiveAssetMarkers;
157
+ /**
158
+ * Remove server-side cluster markers whose feature IDs are no longer in the incoming config.
159
+ */
160
+ private removeGoneServerClusterMarkers;
161
+ /**
162
+ * Add new server-side cluster markers for feature IDs not yet tracked,
163
+ * and update existing ones with fresh portal data.
164
+ */
165
+ private syncServerClusterMarkers;
166
+ /**
167
+ * Update the Mapbox GeoJSON circle source for an adaptive marker layer,
168
+ * excluding features that are currently rendered as DOM markers.
169
+ */
170
+ private updateAdaptiveCircleSource;
171
+ private createMarkerForFeature;
172
+ private createClusterMarkerForFeature;
173
+ /**
174
+ * Create a mapboxgl.Marker with an empty div as content.
175
+ * Registers a portal descriptor in the target store (`marker` or `cluster`)
176
+ * so `<Layers>` renders React content into the container via
177
+ * `createPortal()` and routes to the matching render config.
178
+ */
179
+ private createDomMarker;
180
+ /**
181
+ * Adaptive mode: DOM overlays for features whose resolved mode is not `"symbol"`.
182
+ */
183
+ private createAdaptiveDomOverlays;
184
+ /**
185
+ * Set up client-side clustering using supercluster.
186
+ * Creates mapboxgl.Marker instances and manages their visibility
187
+ * based on zoom level and viewport.
188
+ */
189
+ private setupClientClustering;
190
+ /**
191
+ * Re-render cluster markers based on the current viewport.
192
+ * Called on initial setup and whenever the viewport changes.
193
+ */
194
+ private renderClusters;
195
+ /**
196
+ * Create a cluster marker for the current viewport (client-side clustering).
197
+ */
198
+ private createClusterMarkerForViewport;
199
+ /**
200
+ * Ensure the arrow icon image is registered on the map for route direction arrows.
201
+ * Uses a simple triangle rendered via canvas and converted to ImageData.
202
+ */
203
+ private ensureArrowImage;
204
+ private attachMarkerInteractionListeners;
205
+ private attachClusterInteractionListeners;
206
+ private updateClusterMarkerEntity;
207
+ private attachMarkerCircleLayerListeners;
208
+ private detachMarkerCircleLayerListeners;
209
+ private attachShapeLayerInteractionListeners;
210
+ private detachShapeLayerInteractionListeners;
211
+ private attachRouteLayerInteractionListeners;
212
+ private detachRouteLayerInteractionListeners;
213
+ /**
214
+ * Temporarily disable the map's built-in double-click zoom so that our
215
+ * custom dblclick handler can call fitBounds without the native zoom
216
+ * overriding it. Re-enables on the next tick.
217
+ */
218
+ private suppressNativeZoom;
219
+ /** Emit entity interaction event to all subscribed handlers */
220
+ private emitEntityInteraction;
221
+ /**
222
+ * Sets all interaction-sensitive paint properties for a shape source,
223
+ * building nested `case` expressions with selected > hovered > base priority.
224
+ *
225
+ * Called whenever hover, selection, or theme changes on the given handleId.
226
+ * When neither hover nor selection is active, all properties revert to base.
227
+ */
228
+ private applyShapeInteractionPaint;
229
+ /**
230
+ * Called when the map style reloads (e.g. after setTheme / setMapType).
231
+ * mapboxgl.Marker objects persist across style changes (they are DOM overlays),
232
+ * but all GeoJSON sources and layers are removed by the style change.
233
+ * We need to re-add them.
234
+ */
235
+ private onStyleLoad;
236
+ }
@@ -0,0 +1,8 @@
1
+ import { type AdapterRendererProps } from "@trackunit/react-map-adapter-shared";
2
+ import { type ReactElement } from "react";
3
+ /**
4
+ * Mapbox Renderer component — mounts GL JS and connects {@link MapboxAdapterInstance}.
5
+ * Full-map loading/error UX is owned by `@trackunit/react-map` (`createMapComponent`); this file only
6
+ * gates layer children on adapter readiness and reports init-time failures via the adapter.
7
+ */
8
+ export declare const MapboxRenderer: (props: AdapterRendererProps) => ReactElement;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Mapbox style URLs for different map types
3
+ */
4
+ export declare const MAPBOX_MAP_TYPE_STYLES: {
5
+ readonly roadmap: {
6
+ readonly light: "mapbox://styles/mapbox/streets-v12";
7
+ readonly dark: "mapbox://styles/mapbox/dark-v11";
8
+ };
9
+ readonly satellite: {
10
+ readonly light: "mapbox://styles/mapbox/satellite-v9";
11
+ readonly dark: "mapbox://styles/mapbox/satellite-v9";
12
+ };
13
+ readonly hybrid: {
14
+ readonly light: "mapbox://styles/mapbox/satellite-streets-v12";
15
+ readonly dark: "mapbox://styles/mapbox/satellite-streets-v12";
16
+ };
17
+ };
package/src/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { mapboxAdapter, type MapboxConfig } from "./mapboxAdapter";
2
+ export { MapboxAdapterInstance } from "./MapboxAdapterInstance";
3
+ export { MapboxRenderer } from "./MapboxRenderer";
@@ -0,0 +1,37 @@
1
+ import { type MapboxConfig } from "./MapboxAdapterInstance";
2
+ /**
3
+ * Mapbox adapter factory
4
+ *
5
+ * Creates an adapter configuration for Mapbox GL JS that can be passed to useMap.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * const map = useMap(mapboxAdapter({
10
+ * accessToken: process.env.MAPBOX_ACCESS_TOKEN,
11
+ * theme: "dark",
12
+ * language: "en",
13
+ * }));
14
+ *
15
+ * <map.Map className="h-full w-full">
16
+ * {children}
17
+ * </map.Map>
18
+ * ```
19
+ */
20
+ export declare const mapboxAdapter: (config: Readonly<Readonly<{
21
+ language?: string;
22
+ region?: string;
23
+ theme?: import("@trackunit/react-map-adapter-shared").MapTheme;
24
+ initialViewport?: import("@trackunit/react-map-adapter-shared").InitialViewport;
25
+ restrictBounds?: import("@trackunit/geo-json-utils").GeoJsonBbox | null;
26
+ }> & {
27
+ accessToken: string;
28
+ }>) => import("@trackunit/react-map-adapter-shared").AdapterConfig<Readonly<Readonly<{
29
+ language?: string;
30
+ region?: string;
31
+ theme?: import("@trackunit/react-map-adapter-shared").MapTheme;
32
+ initialViewport?: import("@trackunit/react-map-adapter-shared").InitialViewport;
33
+ restrictBounds?: import("@trackunit/geo-json-utils").GeoJsonBbox | null;
34
+ }> & {
35
+ accessToken: string;
36
+ }>>;
37
+ export type { MapboxConfig };
@@ -0,0 +1,26 @@
1
+ import type { GeoJsonBbox, GeoJsonPosition } from "@trackunit/react-map-adapter-shared";
2
+ /**
3
+ * Convert GeoJSON Position [lng, lat] to Mapbox LngLatLike
4
+ */
5
+ export declare const positionToLngLat: (position: GeoJsonPosition) => mapboxgl.LngLatLike;
6
+ /**
7
+ * Convert Mapbox LngLat to GeoJSON Position [lng, lat]
8
+ * Uses Zod to validate and extract lng/lat values safely
9
+ */
10
+ export declare const lngLatToPosition: (lngLat: mapboxgl.LngLat | mapboxgl.LngLatLike) => GeoJsonPosition;
11
+ /**
12
+ * Convert GeoJSON Bbox [minLng, minLat, maxLng, maxLat] to Mapbox LngLatBoundsLike.
13
+ *
14
+ * When the bbox crosses the antimeridian (RFC 7946: minLng > maxLng), the northeast
15
+ * longitude is unwrapped by +360° so both corners are in a continuous longitude range
16
+ * (west longitude less than east). Mapbox GL `fitBounds` / initial `bounds` use `cameraForBounds`,
17
+ * which builds an AABB from NW/SE in Mercator space; raw corners like (170, 10) and
18
+ * (-170, -10) span almost the whole world in x, while (170, 10) and (190, 10) span the
19
+ * intended Pacific strip.
20
+ */
21
+ export declare const bboxToLngLatBounds: (bbox: Readonly<GeoJsonBbox>) => mapboxgl.LngLatBoundsLike;
22
+ /**
23
+ * Convert Mapbox LngLatBounds to GeoJSON Bbox [minLng, minLat, maxLng, maxLat].
24
+ * Northeast longitudes above 180° (from unwrapped bounds) are converted back to [-180, 180].
25
+ */
26
+ export declare const lngLatBoundsToBbox: (bounds: mapboxgl.LngLatBounds) => GeoJsonBbox;