@base-works/mapbox (0.2.0)
Installation
@base-works:registry=https://git.muddlab.com/api/packages/ahmadsholehin/npm/npm install @base-works/[email protected]"@base-works/mapbox": "0.2.0"About this package
@base-works/mapbox
Shared BaseWorks Mapbox runtime and integration engine. Phases 1–11 establish the
map host, scoped runtime, independent viewport/status subscriptions,
deterministic layer IDs, runtime layer bookkeeping and observed style
generations, cleanup-safe imperative Mapbox effects, and an opt-in per-map
Three.js bridge, demand-driven frame scheduling, and map-scoped image/GLTF
asset management, centralized Mapbox/Three pointer interaction, and one
map-scoped transient popup manager, error isolation and hardened host/WebGL
lifecycle. Synthetic examples exercise the supported rendering modes together;
domain packages remain separate. Tokenless testMode checks establish logic
and lifecycle behavior, not pixels; the new graphics stories use an
environment-provided public token for actual rendering verification.
Start here
The package is published in the private BaseWorks Gitea npm registry and linked
from the workspace while developing the platform. A consuming package must
supply the React, react-map-gl, Mapbox GL and Three peer versions declared in
package.json.
Import mapbox-gl/dist/mapbox-gl.css, give the host container an explicit
height, and compose layers as children of BaseMap. The application owns the
style, token, camera state and domain state. Enable three only on hosts that
render Three content; v0 Three integration requires Mercator projection.
Choose a path by responsibility:
| Requirement | Preferred path |
|---|---|
| Ordinary reactive Mapbox source/layer | react-map-gl Source / Layer |
| Native Mapbox lifecycle or custom behavior | useMapboxEffect() |
| Mesh/model rendering | useThreeLayer() and optional useThreeFrame() |
| Mapbox hit target plus Three visual | Deliberate hybrid composition |
| Feature/object pointer behavior | useMapboxInteraction() / useThreeInteraction() |
| Transient anchored React content | useMapPopups() |
Detailed authoring guidance:
Intelligence catalogue
The package publishes dist/baseworks.catalogue.json through
package.json#baseWorks.catalogue. The mapbox.base-map record is an L2
map.host that provides the scoped runtime, style lifecycle, optional Three,
interaction, popup and asset capabilities. It accepts map.layer,
map.control and map.tool composition roles and records all supported
rendering backends.
Inside a derivative that installs this package, BaseWorks Intelligence can answer the contract without reading implementation source:
pnpm exec base-works search "map host three"
pnpm exec base-works describe mapbox.base-map
pnpm exec base-works describe mapbox.base-map --json
The JSON form is the compact machine-readable source for coding agents. A
future layer declares kind: "map.layer", the matching fits slot, its
rendering backend, and capabilities such as mapbox.runtime or mapbox.three
under rendering.requires. Generic browser fixtures are intentionally not
catalogued as reusable production components.
import "mapbox-gl/dist/mapbox-gl.css";
import { BaseMap, useMapRuntime, useNativeMap } from "@base-works/mapbox";
function RuntimeProbe() {
const runtime = useMapRuntime();
const map = useNativeMap(); // deliberate advanced Mapbox escape hatch
return <button onClick={() => runtime.invalidate()}>{map.getZoom()}</button>;
}
export function ApplicationMap() {
return (
<BaseMap
id="main-map"
initialViewState={{ longitude: 103.8, latitude: 1.35, zoom: 9 }}
mapStyle="/map/style.json"
>
<RuntimeProbe />
</BaseMap>
);
}
BaseMap preserves the react-map-gl/mapbox MapProps and forwards a
MapRef. If id is omitted it creates a stable local ID shared by the
react-map-gl host and BaseWorks runtime. Descendants use the nearest map; two
maps never share mutable bindings.
The general MapRuntime intentionally exposes only id and invalidate().
Native access remains explicit through useNativeMap(). Both hooks throw a
contextual developer error outside a mounted BaseMap. Applications still own
view state, map styles, tokens and domain state; this package provides no
geographic defaults or application store/event-bus integration.
Phase 2 hooks
useMapStatus()subscribes to map/style readiness and style generations.useMapViewport()subscribes to the complete camera snapshot;useMapViewport("zoom")subscribes to one scalar.useMapLayerIds()creates deterministic source and layer IDs for a component instance and diagnoses namespace collisions.useMapLayerRegistration()records renderer, semantic stage and native IDs in private map-scoped bookkeeping, then unregisters on unmount.
Style changes remain ordinary mapStyle prop updates. The lifecycle manager
observes their consequences and never calls native setStyle().
Imperative integration
Use declarative react-map-gl Source and Layer when they fit. For advanced
native integration, useMapboxEffect(effect, dependencies, options) waits for
style readiness by default, runs one cleanup for every installation, and can
restore after React changes mapStyle:
useMapboxEffect(
({ map }) => {
map.addSource(sourceId, source);
map.addLayer(layer);
return () => {
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
};
},
[sourceId, layerId],
{ componentId: "domain.component", restoreAfterStyleReload: true },
);
BaseMap.onRuntimeError receives structured install and cleanup failures. The
package contains a synthetic imperative source/layer proof for tests, but it is
deliberately not exported.
Three.js integration
Enable Three only on maps that need it:
function DomainLayer() {
const layer = useThreeLayer({ id: "domain.objects", stage: "world-3d" });
useEffect(() => {
const mesh = createDomainMesh();
layer.group.add(mesh);
layer.invalidate();
return () => {
layer.group.remove(mesh);
disposeDomainMesh(mesh);
};
}, [layer]);
return null;
}
<BaseMap three mapStyle="/map/style.json">
<DomainLayer />
</BaseMap>;
Each enabled BaseMap owns one private scene, camera, raycaster and renderer
using Mapbox's canvas/context. useThreeLayer() returns an owned group that is
attached and detached automatically; the layer remains responsible for the
geometry, material and texture resources it creates. The public
useThreeRuntime() facade exposes only ready and invalidate()—not the root
scene or rendering internals. The v0 bridge supports Mercator projection and
reports a structured runtime error rather than rendering incorrectly with an
unsupported projection. Style replacement restores the physical custom layer
without discarding logical layer groups.
Demand-driven Three frames
Static layers call layer.invalidate() only after mutating their owned objects.
That requests one Mapbox repaint and does not create a render loop. Animated
layers opt into continuous frames explicitly:
useThreeFrame(
({ camera, delta, elapsed }) => {
mesh.rotation.z += delta;
updateLevelOfDetail(mesh, camera, elapsed);
},
{ enabled: isAnimating, priority: 0 },
);
delta and elapsed are seconds. Lower priorities execute first and equal
priorities retain registration order. Callback changes are read through a ref,
so ordinary React rerenders do not tear down the subscription. The first active
subscriber starts repaint demand; every rendered frame requests the next one;
and disabling or unmounting the last subscriber stops the loop. Frame callback
errors are isolated and reported through BaseMap.onRuntimeError with the
three subsystem. The frame API deliberately omits the native map and root
scene: use useNativeMap() separately when native access is justified and
mutate objects owned by useThreeLayer().
Asset ownership
Mapbox images are runtime-owned desired state:
const assets = useMapAssets();
await assets.ensureImage("domain-alert", "/icons/alert.png", {
pixelRatio: 2,
sdf: true,
});
Concurrent requests for the same ID, source and options share one load. Decoded
URL data is retained so a style replacement can restore the image without
another network request. The image remains registered when a requesting layer
unmounts and is removed only when its BaseMap runtime is disposed. Reusing an
ID with different input is rejected rather than silently changing another
layer's asset.
GLTF assets require a Three-enabled map and use explicit handles:
const assets = useThreeAssets();
const instance = await assets.acquireGltf("/models/object.glb");
layer.group.add(instance.scene);
layer.invalidate();
// Cleanup: detach first, then release.
layer.group.remove(instance.scene);
instance.release();
Each URL is loaded once per map runtime. Instances use Three's skeleton-safe
clone utility, clone materials and animation clips for consumer mutation, and
share source geometry and textures. Treat shared geometry/textures as immutable;
clone them explicitly when a layer must mutate them. release() is idempotent,
disposes the instance-owned material clones, and does not invalidate another
consumer. GLTF source resources remain cached until the map runtime is disposed.
Cross-map caching is deliberately absent.
The ownership rule is therefore:
- runtime-owned: Mapbox image registrations and GLTF source caches;
- shared cached: decoded Mapbox image data and GLTF geometry/textures;
- layer-owned: Three groups, acquired instance transforms/materials, and any resources created or replaced by the layer.
Central interactions
Register rendered Mapbox features or objects below an owned Three group without installing native listeners in the domain layer:
useMapboxInteraction({
id: "domain.aircraft",
layerIds: [aircraftLayerId],
priority: 20,
mapFeature: (feature) => parseAircraft(feature),
getKey: (aircraft) => aircraft.id,
cursor: "pointer",
onClick: ({ data }) => onSelect(data.id),
});
useThreeInteraction({
id: "domain.models",
root: threeLayer.group,
priority: 30,
mapIntersection: (intersection) => mapModel(intersection.object),
getKey: (model) => model.id,
onClick: ({ data }) => onSelect(data.id),
});
The runtime owns one mousemove, click and mouseleave stream per map. It
performs at most one Mapbox feature query and one Three raycast per native event,
then passes the already-calculated results to consumer mappers. Higher priority
wins; equal priority retains registration order. Return "continue" from a
click callback to offer the same event to the next candidate. Cursor and hover
enter/move/leave are also centrally arbitrated. Use
useMapBackgroundInteraction() for a background fallback rather than attaching
another map click listener.
These hooks carry no aviation meaning. Domain layers supply mapped data and callbacks, and remain responsible for an accessible non-map path to critical workflows.
Transient popups
Mapbox and Three interaction callbacks share the nearest map's popup controller:
const popups = useMapPopups();
useMapboxInteraction({
// ...feature mapping...
onEnter: ({ data }) =>
popups.open({
id: `domain.${data.id}`,
position: data.position,
content: <DomainSummary data={data} />,
priority: 20,
hoverPolicy: "retain",
hideDelayMs: 200,
}),
onLeave: (data) => popups.close(`domain.${data.id}`),
});
One popup and one React root are owned per BaseMap. Opening the same ID updates
it in place; a different lower-priority popup cannot displace the current one.
close(id) ignores stale IDs and observes the configured delay, while
closeAll() closes immediately. Retained popup content cancels a pending close
while hovered. Cleanup removes timers, DOM listeners, React content and the
Mapbox popup instance. Persistent inspectors and panels remain application UI.
Error and lifecycle boundaries
Wrap the host when losing the whole map should produce application fallback UI, and wrap optional layers independently when the rest of the map can continue:
<MapErrorBoundary
onError={reportMapError}
fallback={({ reset }) => <MapFailure onRetry={reset} />}
>
<BaseMap three>{layers}</BaseMap>
</MapErrorBoundary>
<MapLayerErrorBoundary
layerId="domain.weather"
onError={reportLayerError}
fallback={<div role="alert">Weather layer unavailable</div>}
>
<WeatherLayer />
</MapLayerErrorBoundary>
The map-level default is a minimal unstyled alert with retry; the layer-level
default is null. resetKeys can deliberately retry after configuration or
data changes. React unmounts a failed layer subtree, so its hooks relinquish
runtime registrations normally. Callback/lifecycle failures outside React
rendering continue through BaseMap.onRuntimeError.
Mapbox remove disposes the entire scoped binding once. WebGL context loss
releases the stale Three renderer and requests reconstruction on restoration;
layers retain their owned groups and do not install context listeners.
Performance characteristics
The runtime carries internal lifetime diagnostics for validation without adding a public telemetry API. They count scheduled Three repaints and completed animated frames, active frame subscribers, owned Three groups/objects, shared Mapbox queries and Three raycasts, runtime layer registrations, and Mapbox/GLTF asset-cache hits and misses. These counters are read only by package tests and development internals; applications do not need to configure or ship a metrics service.
Phase 11 workload tests establish the intended invariants: static Three content does not request an endless repaint; many animated layers share one repaint chain; many interaction registrations share one query and one raycast per pointer event; repeated assets share one load; and separate maps retain separate registries and diagnostics. These are deterministic architectural checks, not machine-dependent timing benchmarks.
Reusable graphics layers
CurveRibbonLayer and InstancedSymbolLayer are experimental L2 map layers.
Both compose below <BaseMap three projection="mercator">. They own no domain
data or application time. Ribbons expand camera-facing in their vertex shader,
so they stay visibly wide at any camera pitch or bearing instead of collapsing
to a hairline at oblique/side-on angles; width remains physical metres.
Ribbons offer explicit smoothing, directional pulses, central picking and
shared map-owned glow; symbols update one instanced batch. Picking follows the
same camera-facing surface through the centralized Three interaction path (no
extra camera plumbing). The flight and shipping examples reuse the same
ribbon renderer.
See graphics composition
for contracts, default appearance, rendering limits, examples and verification.
Actual graphics stories require the existing local public token even when using
a synthetic basemap: Mapbox testMode tests logic but does not produce pixels.
Package commands
pnpm --filter @base-works/mapbox build
pnpm --filter @base-works/mapbox test
pnpm --filter @base-works/mapbox storybook
pnpm --filter @base-works/mapbox storybook:build
pnpm --filter @base-works/mapbox example
pnpm --filter @base-works/mapbox test:browser
Explore components in Storybook
storybook starts the primary interactive discovery surface at
http://localhost:6008. Open Mapbox / Capabilities to inspect the rendering
overview, interactions, Three motion, and runtime-lifecycle scenarios
individually. The stories use real BaseMap instances and the same internal
scenario implementations as the standalone gallery.
No token is required: Storybook defaults to a local style and synthetic data.
To review the examples over a hosted Mapbox basemap, copy
examples/browser/.env.example to examples/browser/.env.local and set
PUBLIC_MAPBOX_TOKEN, then restart Storybook. Both Storybook and the standalone
gallery read this ignored local file. The value is browser-visible by design:
use a public token with minimum scopes and URL restrictions, never a secret
token.
Run the integration gallery
example starts the standalone browser capability gallery at
http://127.0.0.1:4173. This is the stable host used by Playwright for
whole-runtime, WebGL, scenario-switching, and cleanup verification. It is not a
replacement for Storybook and is not normally needed merely to discover or
review a component.
Without a configured token—or with ?local=1—the gallery uses its local style
and synthetic data without requesting Mapbox-hosted resources. Its selectable
scenarios match the individual Storybook stories:
| Example | Direct URL | Demonstrates |
|---|---|---|
| Overview | ?example=overview |
Declarative and imperative Mapbox, Three, hybrid rendering and React popups |
| Interactions | ?example=interactions |
GeoJSON expressions, centralized hover/click, cursor and background handling |
| Three motion | ?example=three |
Owned groups, demand-driven frames, shared raycasting and Three popups |
| Runtime | ?example=lifecycle |
Effect installation/cleanup, viewport subscriptions and native camera access |
Append &local=1 to any direct URL for its tokenless form. All displayed data
is synthetic. Shared Phase 10 fixtures live under src/examples/; focused
gallery scenarios live under examples/browser/src/examples/. Neither set is
part of the package's public export surface.
example:build type-checks and builds the gallery. test:browser starts it in
tokenless mode and checks API readiness, the interactive popup, scenario
switching, Three lifecycle readiness and imperative-layer cleanup in Chromium.
These readiness checks are not evidence of actual Three drawing: Mapbox
testMode does not render pixels. Use test:graphics for the token-enabled
local-style visual checks described in graphics composition.
storybook:build verifies the component-discovery surface independently.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @base-works/contracts | ^0.2.0 |
Development Dependencies
| ID | Version |
|---|---|
| @playwright/test | ^1.58.0 |
| @storybook/addon-a11y | ^10.2.0 |
| @storybook/react-vite | ^10.2.0 |
| @testing-library/react | ^16.3.0 |
| @types/react | ^19.2.0 |
| @types/react-dom | ^19.2.0 |
| @types/three | ^0.185.4 |
| @vitejs/plugin-react | ^6.0.4 |
| jsdom | ^29.1.1 |
| mapbox-gl | ^3.27.0 |
| react | ^19.2.8 |
| react-dom | ^19.2.8 |
| react-map-gl | ^8.1.2 |
| storybook | ^10.2.0 |
| three | ^0.185.1 |
| vite | ^8.1.5 |
Peer Dependencies
| ID | Version |
|---|---|
| mapbox-gl | ^3.27.0 |
| react | ^19.2.8 |
| react-dom | ^19.2.8 |
| react-map-gl | ^8.1.2 |
| three | ^0.185.1 |