Map store & getMap
Each mapId holds one MapLibre instance at a time (one shell / one live map). Reusing an id after removeMap is allowed via a new initMap; calling initMap while another live instance already owns that id throws MapInitializationError.
Who exports what
| Concern | Package / entry |
|---|---|
getMap, subscribeMapReady, registerMapAccessor, registerMapReadySubscriber, registerMapStoreCleanup, MAP_PLATFORM_HOST, listMapPlatformHosts, MAP_PLATFORM_REGISTRY_METHOD, MapStoreManager, MAP_STORE_KEY, hasMapInstance | @hungpvq/map-core |
| Domain features (basemap, crs, event, image, legend, measurement, menu, print, theme, toolbar) | @hungpvq/map-core/<domain> |
| In-worker helpers | @hungpvq/map-core/worker |
createMapScopedStore, destroyMapScopedStore, getStore, addStore, useMapContainer | @hungpvq/vue-map-core / @hungpvq/react-map-core |
Adapters do not re-export getMap or domain protocol. Apps import platform APIs from @hungpvq/map-core and feature APIs from the matching subpath (see Stable API).
Access the map
import { getMap, subscribeMapReady } from '@hungpvq/map-core';
import type { MapSimple } from '@hungpvq/map-core';
const map = getMap('map-1'); // MapSimple | undefined
getMap('map-1', (map: MapSimple) => {
// Runs now if ready, or when MapStoreManager emits READY
});
// Prefer when you need to cancel the wait (unmount / destroy):
const unsubscribe = subscribeMapReady('map-1', (map) => {
// …
});
unsubscribe();The Vue / React Map shell registers the accessor via registerMapAccessor / registerMapReadySubscriber and calls initMap / removeMap through useMapContainer.
Platform accessors are stored as UniversalRegistry global methods under reserved keys (MAP_PLATFORM_REGISTRY_METHOD.*, e.g. __platform.getMap). They live in the shared map:registry:global bag — clearMap / removeMap does not remove them — so duplicate package copies still share one wiring.
registerMapAccessor / registerMapReadySubscriber / registerMapStoreCleanupRegistrar: accept optional { hostId } (e.g. MAP_PLATFORM_HOST.VUE_MAP_CORE / REACT_MAP_CORE). Same hostId replaces that host only (version bump); different hosts coexist — composite getMap / READY fan out until a map is found. Returns { hostId, version, unregister }. Prefer one framework host per app; multi-host pages no longer silently last-writer-wins.
Scoped stores
Source of truth: per-mapId bags live under process key map:core (getMapCoreRootStore). Domain protocol state is created via registerMapDomainStoreFactory + ensureMap*Store (lazy on first use). Adapters must not addStore / invent a second factory for keys that already have a domain factory.
Use documented MAP_STORE_KEY values for map-core feature state:
| Key | Value | Access |
|---|---|---|
MITT | mitt | ensureMapMitt |
EVENT | event | ensureMapEventStore |
IMAGE | image | ensureMapImageStore |
TOOLBAR | toolbar | ensureMapToolbarStore |
LANG | lang | ensureMapLangStore / ensureMapLocaleApi |
CRS | crs | ensureMapCrsStore |
PRINT | print | ensureMapPrintStore |
BASEMAP | basemap | ensureMapBaseMapStore |
RESOLVER | resolver | per-map overrides via createMapCoreMetaRegistry |
CONTROLS | controls | UniversalRegistry.registerControl / getControl |
CONTROL_LAYOUT | control-layout | ensureControlLayout / setControlLayout / … |
CONTROL_AUTO_BUTTON | control-auto-button | registerControlAutoButton / getControlAutoButton |
REGISTRY_MAPS | registry-maps | UniversalRegistry.register*ForMap / getMethod / … |
Domain packages own additional keys on the same map:core[mapId] bag:
| Key | Owner | Constant / access |
|---|---|---|
'dataset' | @hungpvq/map-dataset | MAP_DATASET_STORE_KEY / ensureMapDatasetStore |
'draw' | @hungpvq/map-draw | MAP_DRAW_STORE_KEY / ensureMapDrawStore |
Dataset bag (MapDatasetStore) is a plain object (datasets, datasetIds: { value }, allLayerShow, version, listeners). Do not put Vue/React refs on it. After mutations call notifyMapDatasetStore(store); Vue/React useMapDataset exposes datasetVersion for UI — see useMapDataset.
import { ensureMapCrsStore } from '@hungpvq/map-core/crs';
import { ensureMapDatasetStore } from '@hungpvq/map-dataset';
import { ensureMapDrawStore } from '@hungpvq/map-draw';
const crs = ensureMapCrsStore(mapId);
const datasets = ensureMapDatasetStore(mapId);
const draw = ensureMapDrawStore(mapId);Vue/React adapters expose thin hooks (useMapBaseMapStore, useMapDrawStore, …) that call ensure*. Prefer those or the core ensure* APIs — not createMapScopedStore for protocol keys.
createMapScopedStore (adapters) remains for UI-only bags (e.g. dataset component portal state) and as a bridge: if a domain factory is registered for the key, it delegates to ensureMapDomainStore.
Cleanup: domain factories may supply cleanup; otherwise use registerMapStoreCleanup(mapId, key, fn). Do not call useMap*Store(mapId) from inside cleanup (circular inference / re-entrancy). Resolve with getStore / an already-captured reference, or rely on factory cleanup.
Changing a documented store key string value is a SemVer major.
Declare a new per-map domain store
Default for any new per-mapId protocol state: put it on map:core[mapId][key] via the domain-store helpers. Do not invent a process-wide getOrCreateStore('map:…').maps[mapId] bag.
Checklist
- Key — add to
MAP_STORE_KEYinlibs/map-core/core/src/types/constants.ts(map-core domains) or export a package constant (e.g.MAP_DATASET_STORE_KEY/MAP_DRAW_STORE_KEY) for domain packages. String value is Stable; renaming is major. - Factory —
registerMapDomainStoreFactory(key, { create, cleanup? })in aregister-domain-store.ts(or lazy insideensure*if the module participates in an import cycle withUniversalRegistry/map-platform-registry). - Accessors — export
ensureMap*Store(mapId)(create) and, when reads must not allocate,peekviapeekMapDomainStore. Clear withdeleteMapDomainStoreor factorycleanup/registerMapStoreCleanup. - Import side-effect — ensure the factory module is imported from the package entry (or called lazily on first
ensure) so the factory exists before first use. - Docs / lock — update this page’s key table,
stable-api.mdif Stable, andpublic-api.spec.tswhen exporting new helpers. - Adapters — Vue/React may add a thin
useMap*Storethat callsensure*. Do not usecreateMapScopedStore/addStorefor keys that already have a domain factory.
Minimal example
import { ensureMapDomainStore, peekMapDomainStore, registerMapDomainStoreFactory } from '@hungpvq/map-core';
/** Prefer a `MAP_STORE_KEY.*` or package constant — string value is Stable. */
const MY_FEATURE_STORE_KEY = 'my-feature';
registerMapDomainStoreFactory(MY_FEATURE_STORE_KEY, {
create: () => ({ items: [] as string[] }),
// cleanup: (mapId, store) => { … },
});
export function ensureMapMyFeatureStore(mapId: string) {
return ensureMapDomainStore<{ items: string[] }>(mapId, MY_FEATURE_STORE_KEY);
}
export function peekMapMyFeatureStore(mapId: string) {
return peekMapDomainStore<{ items: string[] }>(mapId, MY_FEATURE_STORE_KEY);
}Reference implementations: libs/map-core/core/src/basemap/register-domain-store.ts, libs/map-core/core/src/registry/controls-store.ts, libs/map-core/map-dataset/src/register-domain-store.ts.
Still process-wide (not per-map): map:registry:global, theme localStorage, map:core:meta, GIS worker URL — use @hungpvq/shared-store getOrCreateStore only for true process singletons.
Process-wide singletons
These keys live on @hungpvq/shared-store (globalThis.$_hungpv_store) unless noted. Duplicate package copies and Vue/React adapters must share them — do not invent parallel bags or class-static Maps.
| Key / export | Kind | Purpose |
|---|---|---|
__hungpvq_gis_worker__ | getOrCreateStore | GIS Web Worker URL override (configureGisWorker) |
map:registry:global | getOrCreateStore | UniversalRegistry global methods / components / menu handlers |
hungpvq.map-theme-mode (+ optional :<mapId>) | localStorage (MAP_THEME_STORAGE_KEY / getMapThemeStorageKey(mapId)) | Theme preference. Default process-global; ThemeControl scope="map" uses per-map key. |
map:core | getMapCoreRootStore / getOrCreateStore | Per-mapId map store entries (instance, scoped features, cleanups); resolver; registry-maps / controls / control-layout / control-auto-button under [mapId].* |
map:core:meta | getOrCreateStore | removedMapIds tombstones; errorCapture install slot; errorHandler singleton; registries process defaults (createMapCoreMetaRegistry); control-layout / auto-button listener sets |
map:debug | getMapDebugStore / getOrCreateStore | @hungpvq/map-core/devtools + @hungpvq/map-debug: logStoreOptions / logDataStore / logAdapter (Devtools Logs); dataset = Dataset Inspector API (installDatasetDebug). Console alias: window.__hungpvqDatasetDebug |
Related process pins outside this table: LoggerFactory on @hungpvq/shared-log’s own globalThis key.
Empty / deferred mapId
- Never create scoped stores under
mapId === ''—MapStoreManagerrejects empty ids and scrubs legacymap:core[""]. - Use Stable
isUsableMapId(mapId)beforecreateMapScopedStore/ dataset mutations. useMapDataset: apps may call the hook before the map exists, thensetMapId(map.id)on@mapLoaded/onMapLoaded.- Vue: does not allocate a dataset bag until
mapIdis usable. - React: uses an inert in-memory stand-in until
setMapId(never writesmap:core[""]).
- Vue: does not allocate a dataset bag until
Shell initOptions defaults
MapInitializer.createDefaultOptions is the single source for shell defaults (attributionControl: false, center/zoom, …). Vue/React Map should pass app overrides only — do not fork defaults in the adapters. MapLibre has no zoomControl option; use ZoomControl / NavigationControl for zoom chrome.
Multi-map DOM notes
| Surface | Scope |
|---|---|
Theme (applyMapTheme → html and/or map shell) | document (default) = process-global; map = per-mapId |
Layer search (/ shortcut) | Per mapId via [data-map-layer-search][data-map-id] |
| Devtools drag host | containerId and/or mapId — no first-match in document |
Multi-map caveats (apps with Map A + Map B)
- Per-map state is safe when keyed by
mapId(MapStoreManager, scoped stores,map:core[mapId]registry bags). - Theme: default
ThemeControl/bootstrapMapThemeusescope: 'document'(onehtmlchrome theme). For independent themes use<ThemeControl scope="map" />orapplyMapTheme(resolved, { scope: 'map', mapId })/bootstrapMapTheme(mode, { scope: 'map', mapId }). - Platform accessors are multi-host (
hostId); Vue/React register separately and compositegetMapresolves across hosts. - Prefer
subscribeMapReady(mapId, cb)over fire-and-forgetgetMap(id, cb)so each shell can unsubscribe on unmount without racing another map’s READY. - Consumer rule: call
bootstrapMapThemeonce for document chrome (or omit and let ThemeControl apply); usescope="map"when maps must differ.
Lifecycle (engine)
MapStoreManager (used by adapters):
initMap(mapId, map)— set the single instance and emitMAP_CORE_EVENT.READY(fails if a different live map already ownsmapId); clears the process-wide tombstone for that idremoveMap(mapId)— run registered cleanups, clear registry scope, delete the store entry, and tombstone the id sogetMap(id, cb)/subscribeMapReadydo not wait for READY or recreate an entrygetMap(mapId, cb?)—MapSimple | undefined; withcb, waits for READY only when the id is not tombstoned (internally shares wait logic withsubscribeMapReady)subscribeMapReady(mapId, cb)—() => voidunsubscribe; sync when live, waits when pending, no-op when tombstonedregisterCleanup/ publicregisterMapStoreCleanup(mapId, key, fn)— map-scoped teardown onremoveMap
Tombstones live in a process-wide @hungpvq/shared-store bag (map:core:meta.removedMapIds), so separate Vue and React MapStoreManager instances (or duplicate package copies) share the same removed-id set.
See also Stable API · Error handling · Minimal starter.