Appearance
MapLibre — live demo
This is the real @rozie-ui/maplibre-vue package running on this page (VitePress is itself a Vue app) — driving an actual WebGL MapLibre GL JS map. Pan it, scroll to zoom, or use the controls below — the [lng, lat] / zoom readout updates live because the camera is two-way bound. Everything below is driven by the same MapLibre.rozie source that compiles to all six frameworks. The map uses a network-free offline style (a solid background + a colored GeoJSON polygon), so it needs no tiles and no network.
The camera is two-way bound with v-model:center and v-model:zoom — the readout above updates live as you pan and zoom, and the Fly to buttons drive the imperative handle (flyTo), while Zoom in / out and Reset mutate the bound state directly. Because the binding is two-way, a flyTo() echoes back into center/zoom and the readout tracks it — the round-trip is the whole point. center is [lng, lat] — longitude first (MapLibre's convention). See the full API for the complete prop/event/handle surface.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
MapLibre.rozie — data-bound port of MapLibre GL JS v5 (maplibre/maplibre-gl-js).
MapLibre GL is the open-source (BSD-3) WebGL map engine — the community fork of
Mapbox GL JS v1. Per-framework wrappers are UNEVEN: react-map-gl (visgl) is deep,
Vue (@indoorequal/vue-maplibre-gl), Svelte (svelte-maplibre-gl) and Angular
(@maplibre/ngx-maplibre-gl) are solid, but Solid has only a stale/Mapbox-first
option and Lit/web-components is effectively ABSENT. ONE Rozie source ships six
idiomatic packages — and Solid + Lit get a category-leading wrapper for free.
Usage:
<MapLibre
r-model:center="$data.center" (center is [lng, lat] — lng FIRST)
r-model:zoom="$data.zoom"
:map-style="$data.style"
:markers="$data.points"
:controls="['navigation', 'scale']"
@click="onMapClick"
>
<template #marker="{ marker }"><MyPin :label="marker.label" /></template>
</MapLibre>
Consumers must import MapLibre's CSS themselves (the .rozie <style> is scoped, so
it can't ship the .maplibregl-* selectors — engine-rendered control/popup/marker
DOM never carries Rozie's [data-rozie-s-*] scope attribute; the `:root {}` block
below uses the Phase-34 engine-DOM escape hatch to reach it). Either:
- `import 'maplibre-gl/dist/maplibre-gl.css'` at the app entry, or
- <link> the CDN copy in index.html.
KEY MapLibre facts that drive this port:
- [lng, lat] ORDER everywhere (NOT Leaflet's [lat, lng]). center, Marker/Popup
.setLngLat, GeoJSON coords. getCenter() returns LngLat { lng, lat } — read
.lng/.lat, never index. This is the #1 porting bug — do NOT copy LeafletMap's
coordinate order.
- The prop is `mapStyle` (NOT `style`) — `style` is a reserved attribute across
Vue/Svelte/Angular and a special prop in React; react-map-gl calls it
`mapStyle` and vue-maplibre-gl uses `:map-style` for exactly this reason.
- Echo-guard via the `eventData` 2nd arg: camera methods take
`easeTo({...}, { rozieProgrammatic: true })` whose props merge onto the fired
moveend/zoomend/rotateend/pitchend — test `e.rozieProgrammatic` to ignore our
own programmatic moves. Cleaner than Leaflet's suppressViewSync boolean: it
survives batched/nested camera ops (no stale-flag race). $expose camera verbs
deliberately do NOT pass it, so an imperative flyTo() echoes back into $model
(and the prop $watch then no-ops because getCenter already matches).
- Sources/layers can only be added AFTER style load — gate on isStyleLoaded()
/ the `load` event.
- $refs.containerEl is read ONLY inside $onMount (ROZ123); the container needs
explicit dimensions (.rozie-maplibre sets them).
Slots — the marquee differentiator (singular slot / plural driving prop, the CM
gutter/gutterLines + decoration/decorations naming discipline to stay ROZ127-clean):
- `marker` — REACTIVE MULTI-INSTANCE portal driven by the `markers` prop: ONE
portal handle per marker; consumer fragment → new maplibregl.Marker({element}).
Solid/Lit get framework-native marker content they otherwise can't have.
- `popup` — REACTIVE MULTI-INSTANCE portal driven by the `popups` prop:
new maplibregl.Popup().setDOMContent(el).
- `control` — MOUNT-ONCE portal: custom control UI into a custom IControl host.
-->
<rozie name="MapLibre" inherit-attrs="false" inherit-listeners="false" adopt-document-styles>
<props>
{
// [lng, lat] — lng FIRST (NOT Leaflet's [lat, lng]).
center: {
type: Array,
default: () => [0, 0],
model: true,
docs: {
description:
"The map center as `[lng, lat]` — **longitude first** (MapLibre's convention, not Leaflet's `[lat, lng]`). Two-way: panning the map writes the new center back through the model path (echo-guarded), and a consumer write `easeTo`s the live map. The `moveend` echo reads `getCenter()` as `[lng, lat]`.",
example: '<MapLibre r-model:center="center" r-model:zoom="zoom" />',
},
},
zoom: {
type: Number,
default: 1,
model: true,
docs: {
description:
"The zoom level. Two-way: scroll / pinch writes the new zoom back, and a consumer write `easeTo`s the camera. Echo-guarded against the wrapper's own programmatic moves.",
},
},
bearing: {
type: Number,
default: 0,
model: true,
docs: {
description:
'The map rotation (bearing) in degrees. Two-way via the `rotateend` echo and the `easeTo` reconcile.',
},
},
pitch: {
type: Number,
default: 0,
model: true,
docs: {
description:
'The map tilt (pitch) in degrees. Two-way via the `pitchend` echo and the `easeTo` reconcile.',
},
},
// mapStyle (NOT `style` — reserved attr across targets). StyleSpecification
// object OR a style URL. No `type:` so it accepts string|object → emits as
// `unknown`; the default is therefore `undefined` (Vue's withDefaults rejects a
// raw string default on an `unknown`-typed prop) and the actual demo-tiles
// fallback (so the component "just works" with zero config) is applied in-script
// via DEFAULT_STYLE. Pass an offline style object for network-free rendering.
mapStyle: {
default: undefined,
docs: {
description:
"The map style — a `StyleSpecification` object **or** a style-URL string. Named `mapStyle` (not `style`) because `style` is a reserved attribute across the targets — `react-map-gl` and `vue-maplibre-gl` use the same name for the same reason. Defaults to MapLibre's official no-token demo tiles, so the component \"just works\" with zero config. Changing it calls `setStyle` and re-applies your `sources` / `layers` once the new style loads.",
},
},
// 0 / 22 are MapLibre's own min/maxZoom defaults — concrete (not `undefined`)
// so the strict-tsc props-merge stays `number` (a `default: undefined` on a
// typed prop emits a `number | undefined` that fails the framework-typecheck
// harness; passing the engine's own defaults is behavior-preserving).
minZoom: {
type: Number,
default: 0,
docs: {
description:
'Minimum zoom level. Applied at construction and via `setMinZoom` on change.',
},
},
maxZoom: {
type: Number,
default: 22,
docs: {
description:
'Maximum zoom level. Applied at construction and via `setMaxZoom` on change.',
},
},
// LngLatBoundsLike — no `type:` (array-of-arrays or LngLatBounds). Untyped →
// `unknown`, so `default: undefined` merges cleanly (no number-vs-undefined clash).
maxBounds: {
default: undefined,
docs: {
description:
'A `LngLatBoundsLike` the camera is constrained to. Applied via `setMaxBounds` on change (pass `undefined` to clear).',
},
},
// construction-only initial fit (overrides center/zoom when set).
bounds: {
default: undefined,
docs: {
description:
'**Construction-only** initial fit — a `LngLatBoundsLike` the map fits to on mount (overrides `center` / `zoom` when set). Pair with `fitBoundsOptions`.',
},
},
fitBoundsOptions: {
type: Object,
default: () => ({}),
docs: {
description:
'**Construction-only** options for the initial `bounds` fit (padding, max-zoom, etc.).',
},
},
// interaction toggles — applied at construction + reconciled at runtime via
// map.<handler>.enable()/disable().
dragPan: {
type: Boolean,
default: true,
docs: {
description:
"Toggle drag-to-pan. Applied at construction and reconciled live via the handler's `enable()` / `disable()`.",
},
},
dragRotate: {
type: Boolean,
default: true,
docs: {
description:
'Toggle right-drag / ctrl-drag rotation. Applied at construction and reconciled live.',
},
},
scrollZoom: {
type: Boolean,
default: true,
docs: {
description: 'Toggle scroll-wheel zoom. Applied at construction and reconciled live.',
},
},
doubleClickZoom: {
type: Boolean,
default: true,
docs: {
description: 'Toggle double-click zoom. Applied at construction and reconciled live.',
},
},
boxZoom: {
type: Boolean,
default: true,
docs: {
description: 'Toggle shift-drag box zoom. Applied at construction and reconciled live.',
},
},
keyboard: {
type: Boolean,
default: true,
docs: {
description: 'Toggle keyboard navigation. Applied at construction and reconciled live.',
},
},
touchZoomRotate: {
type: Boolean,
default: true,
docs: {
description:
'Toggle touch pinch-zoom + rotate. Applied at construction and reconciled live.',
},
},
touchPitch: {
type: Boolean,
default: true,
docs: {
description:
'Toggle two-finger touch pitch. Applied at construction and reconciled live.',
},
},
// markers/popups drive the REACTIVE MULTI-INSTANCE `marker`/`popup` portal
// slots — each entry { lng, lat, id?, anchor?, offset?, ... }.
markers: {
type: Array,
default: () => [],
docs: {
description:
'The marker data that drives the reactive multi-instance `marker` slot — one entry per marker (`{ lng, lat, id?, anchor?, offset?, draggable?, ... }`). One portal handle mounts per entry; changing the array reconciles markers keep / update / dispose with no remount. Only meaningful when the `marker` slot is filled.',
},
},
popups: {
type: Array,
default: () => [],
docs: {
description:
'The popup data that drives the reactive multi-instance `popup` slot — one entry per popup (`{ lng, lat, id?, anchor?, offset?, closeButton?, closeOnClick?, ... }`). One portal handle mounts per entry. Only meaningful when the `popup` slot is filled.',
},
},
// style-load-gated source/layer reconcile. sources: [{ id, spec }] (or a bare
// SourceSpecification with an `id`); layers: [LayerSpecification with id].
sources: {
type: Array,
default: () => [],
docs: {
description:
'Declarative GeoJSON / vector / raster sources — `[{ id, spec }]` (or a bare `SourceSpecification` carrying an `id`). Reconciled into the live style (add / `setData` / remove) once the style has loaded. The config-array authoring shape for sources; declarative `<Source>` / `<Layer>` children are the alternative shape (both feed the same registry).',
},
},
layers: {
type: Array,
default: () => [],
docs: {
description:
'Declarative layers — `LayerSpecification[]` (each with an `id`). Reconciled into the live style (add / `setPaintProperty` / `setLayoutProperty` / remove) once the style has loaded; `beforeId` controls draw order.',
},
},
// layer ids whose feature mouseenter/mouseleave fire (populates e.features).
interactiveLayerIds: {
type: Array,
default: () => [],
docs: {
description:
'Layer ids whose feature `mouseenter` / `mouseleave` fire the `@mouseenter` / `@mouseleave` events (populating `e.features`). Registered / unregistered per id on change.',
},
},
// standard-control config: 'navigation' | 'geolocate' | 'scale' | 'fullscreen'
// | 'attribution', or { type, position?, options? }.
controls: {
type: Array,
default: () => [],
docs: {
description:
"Standard map controls — strings (`'navigation'` / `'geolocate'` / `'scale'` / `'fullscreen'` / `'attribution'`) or `{ type, position?, options? }` objects. Reconciled (remove-all + re-add) on change.",
},
},
// passthrough — spread into the Map constructor BEFORE curated keys (explicit
// props win).
options: {
type: Object,
default: () => ({}),
docs: {
description:
"The raw `MapOptions` passthrough — spread into the `Map` constructor **before** the curated keys, so explicit props win. The MapLibre analog of an options bag for anything the curated surface doesn't special-case.",
},
},
}
</props>
<data>
{
// Reactive id→spec registries fed by the declarative <Source>/<Layer> children
// (Phase 37 — the dogfood of the Phase 36 $provide/$inject primitive). Each is a
// whole-object-replaced map: register/update/unregister REPLACE the reference
// (never mutate in place) so the parent $watch (reference equality) fires exactly
// once per mutation on ALL 6 targets (a bare in-place `$data.sourceReg[id] = spec`
// does NOT fire on React/Solid/Angular/Lit). The parent's existing style-load-
// gated applyLayers() reconcile consumes (registry ∪ the :sources/:layers config
// array) — the children add NO reconcile logic. An empty registry leaves the
// reconcile input byte-identical to today ((∅ ∪ props) === props), the dist-
// parity zero-drift guarantee (D-01/D37-03).
sourceReg: {},
layerReg: {}
}
</data>
<script>
import maplibregl from 'maplibre-gl'
let instance = null
// MapLibre's official no-token demo tiles — the zero-config `mapStyle` fallback
// (the prop default is `undefined`; see the prop note).
const DEFAULT_STYLE = 'https://demotiles.maplibre.org/style.json'
// The eventData merged onto programmatic camera ops so the camera-lifecycle echo
// handlers can ignore our own moves (the documented MapLibre echo-guard — robust
// across batched ops where Leaflet's single boolean would race).
const PROGRAMMATIC = { rozieProgrammatic: true }
// Live entry maps for the REACTIVE MULTI-INSTANCE portal slots — keyed by
// entry.id ?? index. Each value: { engine, handle, el }. COMPONENT-scope (not
// $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — keeps them in scope.
const markerEntries = new Map()
const popupEntries = new Map()
// ─── declarative-children registry (Phase 37 $provide/$inject dogfood) ───────
// Publish the source/layer register-API the <Source>/<Layer> children $inject and
// self-register into. EVERY method uses WHOLE-OBJECT REPLACEMENT (spread / clone-
// and-delete) so the watched $data.sourceReg/$data.layerReg reference changes once
// per mutation and the parent $watch fires on all 6 targets (D-3 / Pitfall 1 — an
// in-place `$data.sourceReg[id] = spec` is silent on React/Solid/Angular/Lit). The
// register surface mirrors the SHIPPED Tabs.rozie $provide('tabs', { … }) shape;
// register/update share a body (both upsert by id). The values feed the SAME
// applyLayers() reconcile + appliedSourceIds/appliedLayerIds provenance as the
// config-array props, so registry-managed sources/layers are reaped on unregister
// exactly like prop-managed ones (D37-08).
$provide('maplibre:sources', {
register: (id, spec) => { $data.sourceReg = { ...$data.sourceReg, [id]: spec } },
update: (id, spec) => { $data.sourceReg = { ...$data.sourceReg, [id]: spec } },
unregister: (id) => { const n = { ...$data.sourceReg }; delete n[id]; $data.sourceReg = n },
})
$provide('maplibre:layers', {
register: (id, spec) => { $data.layerReg = { ...$data.layerReg, [id]: spec } },
update: (id, spec) => { $data.layerReg = { ...$data.layerReg, [id]: spec } },
unregister: (id) => { const n = { ...$data.layerReg }; delete n[id]; $data.layerReg = n },
})
// standard-control instances (so a controls-prop change can remove + re-add) and
// the mount-once custom-control portal dispose. controlInstances is a null-let
// (→ typeNeutralize `any`) initialized to [] in $onMount: a bare `let x = []`
// infers `never[]` under the strict framework-typecheck harness and rejects the
// `any` control instances pushed into it.
let controlInstances = null
let controlDispose = null
let customControl = null
// layer-scoped feature listeners, registered per interactiveLayerId so they can
// be unregistered on change. id → { enter, leave }.
const featureListeners = new Map()
// previously-applied source/layer ids (null-lets → `any`, [] in $onMount; same
// never[] reason as controlInstances) so a sources/layers prop change can remove
// the dropped ones.
let appliedLayerIds = null
let appliedSourceIds = null
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount (a top-level
// $portals reference fails the bundled-leaf strict typecheck — the CM/TipTap
// portal discipline) and bridged here so the top-level $watch can call them.
let reconcileMarkers = null
let reconcilePopups = null
let reconcileInteractive = null
// ─── pure helpers (no sigils → safe at top level) ───────────────────────────
const sameCenter = (a, b) =>
Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1]
// structured pointer-event payload — stable across targets, avoids handing the
// raw engine event (with its circular `target: Map`) to consumers.
const payload = (e) => ({
lngLat: e.lngLat ? { lng: e.lngLat.lng, lat: e.lngLat.lat } : null,
point: e.point ? { x: e.point.x, y: e.point.y } : null,
features: e.features || [],
originalEvent: e.originalEvent,
})
const buildControl = (spec) => {
const type = typeof spec === 'string' ? spec : spec.type
const opts = (typeof spec === 'object' && spec.options) || {}
if (type === 'navigation') return new maplibregl.NavigationControl(opts)
if (type === 'geolocate') return new maplibregl.GeolocateControl(opts)
if (type === 'scale') return new maplibregl.ScaleControl(opts)
if (type === 'fullscreen') return new maplibregl.FullscreenControl(opts)
if (type === 'attribution') return new maplibregl.AttributionControl(opts)
return null
}
// Standard controls reconcile — no $portals/$emit, so top-level. Remove-all +
// re-add from the config (controls rarely change; cheap and order-correct).
const applyControls = () => {
if (!instance) return
for (const c of controlInstances) instance.removeControl(c)
controlInstances = []
for (const spec of $props.controls) {
if (!spec) continue
const ctrl = buildControl(spec)
if (!ctrl) continue
const position = (typeof spec === 'object' && spec.position) || undefined
instance.addControl(ctrl, position)
controlInstances.push(ctrl)
}
}
// Interaction-toggle reconcile — each toggle maps to a runtime handler object.
const applyInteractionToggles = () => {
if (!instance) return
const set = (name, on) => {
const handler = instance[name]
if (handler) on ? handler.enable() : handler.disable()
}
set('dragPan', $props.dragPan)
set('dragRotate', $props.dragRotate)
set('scrollZoom', $props.scrollZoom)
set('doubleClickZoom', $props.doubleClickZoom)
set('boxZoom', $props.boxZoom)
set('keyboard', $props.keyboard)
set('touchZoomRotate', $props.touchZoomRotate)
set('touchPitch', $props.touchPitch)
}
// Style-load-gated source/layer reconcile. Order matters: drop removed layers
// FIRST, then add/update sources, then add/update layers, then drop removed
// sources (after their layers are gone).
const applyLayers = () => {
if (!instance || !instance.isStyleLoaded()) return
// ─── union the config-array props with the declarative-children registry ────
// (registry ∪ props), keyed by id. D-02: the registry (declarative children) is
// the LAST writer and overrides the config-array on id collision. Ordering: array
// entries first in array order, then registry entries in registration order —
// `[...$props.layers, ...registryLayers]` — each still honoring its explicit
// `beforeId` (the existing applyLayers ordering contract, REUSED unchanged,
// RESEARCH OQ3). The empty-registry path is byte-equivalent to today: with both
// registries empty, mergeById returns exactly the config array (dedup by id of
// an array with no registry overrides is the array itself), so (∅ ∪ props) ===
// props in behavior — the dist-parity zero-drift guarantee (RESEARCH A3).
const mergeById = (arr, reg) => {
// out seeded from the (any-typed) input so strict tsc infers any[] not never[]
// (untyped <script> can't use a TS `: any[]`/`as any[]` annotation; .slice(0,0)
// yields an empty array with identical runtime behavior to `const out = []`).
const out = (Array.isArray(arr) ? arr : []).slice(0, 0)
const idx = new Map()
for (const e of (Array.isArray(arr) ? arr : [])) {
if (!e || !e.id) { out.push(e); continue }
if (idx.has(e.id)) { out[idx.get(e.id)] = e } else { idx.set(e.id, out.length); out.push(e) }
}
for (const id in reg) {
const e = reg[id]
if (!e || !e.id) continue
if (idx.has(e.id)) { out[idx.get(e.id)] = e } else { idx.set(e.id, out.length); out.push(e) }
}
return out
}
const mergedSources = mergeById($props.sources, $data.sourceReg)
const mergedLayers = mergeById($props.layers, $data.layerReg)
const wantLayerIds = mergedLayers.map((l) => l && l.id).filter(Boolean)
const wantSourceIds = mergedSources.map((s) => s && s.id).filter(Boolean)
// 1. drop removed layers
for (const id of appliedLayerIds) {
if (!wantLayerIds.includes(id) && instance.getLayer(id)) instance.removeLayer(id)
}
// 2. add/update sources
for (const s of mergedSources) {
if (!s || !s.id) continue
const spec = s.spec || s
const existing = instance.getSource(s.id)
if (!existing) instance.addSource(s.id, $snapshot(spec))
else if (spec.type === 'geojson' && spec.data) existing.setData($snapshot(spec.data))
}
// 3. add/update layers. DEFENSIVE: a non-background layer whose `source` is not
// (yet) present in the engine is SKIPPED rather than added — a declarative
// <Layer> may register before its <Source> parent has supplied the source id
// (child-before-parent mount order on React/Vue/Svelte/Angular), in which case
// addLayer would throw "source ... doesn't exist" / read null `.type` and abort
// the whole loop (dropping later layers like `bg`). The <Layer> re-registers with
// the resolved source on $onUpdate, re-running this reconcile, so the layer lands
// on the next tick. Background layers need no source. addLayer is wrapped so any
// single malformed spec can't abort the rest of the loop either.
for (const l of mergedLayers) {
if (!l || !l.id) continue
if (!instance.getLayer(l.id)) {
const needsSource = l.type !== 'background'
if (needsSource && (l.source == null || !instance.getSource(l.source))) continue
// Build a CLEAN LayerSpecification: a declarative <Layer> registry spec carries
// a `beforeId` (not a LayerSpecification key — it is the addLayer 2nd arg) and
// explicit `source: undefined` / `layout: undefined` keys (the prop defaults).
// MapLibre v5 rejects a background layer that has ANY `source` key, and an
// undefined `layout` — so emit only the keys MapLibre expects (the config-array
// path is unaffected: those specs are already clean, this just re-emits them).
// null-let → typeNeutralize `any` so the dynamic key assignments below
// type-check on the strict bundled leaves (the `let x = null` idiom).
let clean = null
clean = { id: l.id, type: l.type }
if (needsSource) clean.source = l.source
if (l.paint != null) clean.paint = l.paint
if (l.layout != null) clean.layout = l.layout
if (l.sourceLayer != null) clean['source-layer'] = l.sourceLayer
if (l.filter != null) clean.filter = l.filter
if (l.minzoom != null) clean.minzoom = l.minzoom
if (l.maxzoom != null) clean.maxzoom = l.maxzoom
try {
instance.addLayer($snapshot(clean), l.beforeId)
} catch (e) {
// surfaced via the `error` emit path; skip so later layers still apply.
}
} else {
if (l.paint) for (const k in l.paint) instance.setPaintProperty(l.id, k, l.paint[k])
if (l.layout) for (const k in l.layout) instance.setLayoutProperty(l.id, k, l.layout[k])
}
}
// 4. drop removed sources (their layers are gone)
for (const id of appliedSourceIds) {
if (!wantSourceIds.includes(id) && instance.getSource(id)) instance.removeSource(id)
}
appliedLayerIds = wantLayerIds
appliedSourceIds = wantSourceIds
}
$onMount(() => {
const el = $refs.containerEl
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
controlInstances = []
appliedLayerIds = []
appliedSourceIds = []
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
let mapOptions = null
mapOptions = {
container: el,
...$snapshot($props.options),
style: $snapshot($props.mapStyle) ?? DEFAULT_STYLE,
center: $props.center,
zoom: $props.zoom,
bearing: $props.bearing,
pitch: $props.pitch,
minZoom: $props.minZoom,
maxZoom: $props.maxZoom,
maxBounds: $snapshot($props.maxBounds),
bounds: $snapshot($props.bounds),
fitBoundsOptions: $snapshot($props.fitBoundsOptions),
dragPan: $props.dragPan,
dragRotate: $props.dragRotate,
scrollZoom: $props.scrollZoom,
doubleClickZoom: $props.doubleClickZoom,
boxZoom: $props.boxZoom,
keyboard: $props.keyboard,
touchZoomRotate: $props.touchZoomRotate,
touchPitch: $props.touchPitch,
}
instance = new maplibregl.Map(mapOptions)
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
instance.on('load', (e) => $emit('load', e))
instance.on('idle', (e) => $emit('idle', e))
instance.on('move', (e) => $emit('move', e))
instance.on('rotate', (e) => $emit('rotate', e))
instance.on('dragstart', (e) => $emit('dragstart', e))
instance.on('drag', (e) => $emit('drag', e))
instance.on('dragend', (e) => $emit('dragend', e))
instance.on('click', (e) => $emit('click', payload(e)))
instance.on('dblclick', (e) => $emit('dblclick', payload(e)))
instance.on('contextmenu',(e) => $emit('contextmenu', payload(e)))
instance.on('mousemove', (e) => $emit('mousemove', payload(e)))
instance.on('error', (e) => $emit('error', e))
instance.on('styledata', (e) => $emit('styledata', e))
instance.on('sourcedata', (e) => $emit('sourcedata', e))
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
instance.on('moveend', (e) => {
$emit('moveend', e)
if (e.rozieProgrammatic) return
const c = instance.getCenter()
const next = [c.lng, c.lat]
if (!sameCenter(next, $props.center)) $model.center = next
const z = instance.getZoom()
if (z !== $props.zoom) $model.zoom = z
})
instance.on('zoomend', (e) => {
$emit('zoomend', e)
if (e.rozieProgrammatic) return
const z = instance.getZoom()
if (z !== $props.zoom) $model.zoom = z
})
instance.on('rotateend', (e) => {
$emit('rotateend', e)
if (e.rozieProgrammatic) return
const b = instance.getBearing()
if (b !== $props.bearing) $model.bearing = b
})
instance.on('pitchend', (e) => {
$emit('pitchend', e)
if (e.rozieProgrammatic) return
const p = instance.getPitch()
if (p !== $props.pitch) $model.pitch = p
})
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
reconcileMarkers = (list) => {
if (!$slots.marker) return
const arr = Array.isArray(list) ? list : []
const seen = new Set()
arr.forEach((m, index) => {
if (!m || typeof m.lng !== 'number' || typeof m.lat !== 'number') return
const key = m.id != null ? m.id : index
seen.add(key)
const scope = { marker: m, index }
const entry = markerEntries.get(key)
if (entry) {
entry.engine.setLngLat([m.lng, m.lat])
entry.handle.update(scope)
} else {
const node = document.createElement('div')
node.className = 'rozie-maplibre-marker'
const handle = $portals.marker(node, scope)
const engine = new maplibregl.Marker({ element: node, anchor: m.anchor, offset: m.offset, draggable: m.draggable })
.setLngLat([m.lng, m.lat])
.addTo(instance)
markerEntries.set(key, { engine, handle, el: node })
}
})
for (const [key, entry] of markerEntries) {
if (!seen.has(key)) {
entry.handle.dispose()
entry.engine.remove()
markerEntries.delete(key)
}
}
}
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
reconcilePopups = (list) => {
if (!$slots.popup) return
const arr = Array.isArray(list) ? list : []
const seen = new Set()
arr.forEach((p, index) => {
if (!p || typeof p.lng !== 'number' || typeof p.lat !== 'number') return
const key = p.id != null ? p.id : index
seen.add(key)
const scope = { popup: p, index }
const entry = popupEntries.get(key)
if (entry) {
entry.engine.setLngLat([p.lng, p.lat])
entry.handle.update(scope)
} else {
const node = document.createElement('div')
node.className = 'rozie-maplibre-popup-body'
const handle = $portals.popup(node, scope)
const engine = new maplibregl.Popup({
closeButton: p.closeButton !== undefined ? p.closeButton : true,
closeOnClick: p.closeOnClick !== undefined ? p.closeOnClick : false,
anchor: p.anchor,
offset: p.offset,
})
.setLngLat([p.lng, p.lat])
.setDOMContent(node)
.addTo(instance)
popupEntries.set(key, { engine, handle, el: node })
}
})
for (const [key, entry] of popupEntries) {
if (!seen.has(key)) {
entry.handle.dispose()
entry.engine.remove()
popupEntries.delete(key)
}
}
}
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
reconcileInteractive = (ids) => {
const want = (Array.isArray(ids) ? ids : []).filter(Boolean)
for (const [id, l] of featureListeners) {
if (!want.includes(id)) {
instance.off('mouseenter', id, l.enter)
instance.off('mouseleave', id, l.leave)
featureListeners.delete(id)
}
}
for (const id of want) {
if (featureListeners.has(id)) continue
const enter = (e) => $emit('mouseenter', payload(e))
const leave = (e) => $emit('mouseleave', payload(e))
instance.on('mouseenter', id, enter)
instance.on('mouseleave', id, leave)
featureListeners.set(id, { enter, leave })
}
}
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
if ($slots.control) {
const host = document.createElement('div')
host.className = 'maplibregl-ctrl rozie-maplibre-control'
customControl = {
onAdd() { return host },
onRemove() { if (host.parentNode) host.parentNode.removeChild(host) },
}
instance.addControl(customControl, 'top-right')
controlDispose = $portals.control(host, { map: instance })
}
// standard controls + interaction toggles don't need style load.
applyControls()
applyInteractionToggles()
// markers/popups/interactive are DOM/event overlays — no style-load gate.
reconcileMarkers($props.markers)
reconcilePopups($props.popups)
reconcileInteractive($props.interactiveLayerIds)
// sources/layers need the style loaded.
if (instance.isStyleLoaded()) applyLayers()
else instance.on('load', applyLayers)
return () => {
for (const [, entry] of markerEntries) { entry.handle.dispose(); entry.engine.remove() }
markerEntries.clear()
for (const [, entry] of popupEntries) { entry.handle.dispose(); entry.engine.remove() }
popupEntries.clear()
if (controlDispose) controlDispose()
if (instance) instance.remove()
}
})
// ─── reconcile prop changes into the live map (no remount) ──────────────────
// Camera scalars — easeTo with {animate:false} is the instant runtime path; the
// PROGRAMMATIC eventData makes the resulting moveend skip the echo-back.
$watch(() => $props.center, (v) => {
if (!instance || !Array.isArray(v) || v.length !== 2) return
const c = instance.getCenter()
if (v[0] === c.lng && v[1] === c.lat) return
instance.easeTo({ center: v, animate: false }, PROGRAMMATIC)
})
$watch(() => $props.zoom, (v) => {
if (!instance || typeof v !== 'number' || v === instance.getZoom()) return
instance.easeTo({ zoom: v, animate: false }, PROGRAMMATIC)
})
$watch(() => $props.bearing, (v) => {
if (!instance || typeof v !== 'number' || v === instance.getBearing()) return
instance.easeTo({ bearing: v, animate: false }, PROGRAMMATIC)
})
$watch(() => $props.pitch, (v) => {
if (!instance || typeof v !== 'number' || v === instance.getPitch()) return
instance.easeTo({ pitch: v, animate: false }, PROGRAMMATIC)
})
$watch(() => $props.mapStyle, (v) => {
if (!instance) return
// a new style wipes imperatively-added sources/layers — reset the applied
// tracking and re-apply once the new style loads.
appliedLayerIds = []
appliedSourceIds = []
instance.setStyle($snapshot(v) ?? DEFAULT_STYLE)
instance.once('styledata', () => applyLayers())
})
$watch(() => $props.minZoom, (v) => { if (instance && typeof v === 'number') instance.setMinZoom(v) })
$watch(() => $props.maxZoom, (v) => { if (instance && typeof v === 'number') instance.setMaxZoom(v) })
$watch(() => $props.maxBounds, (v) => { if (instance) instance.setMaxBounds($snapshot(v) || null) })
$watch(() => $props.markers, (v) => { if (reconcileMarkers) reconcileMarkers(v) })
$watch(() => $props.popups, (v) => { if (reconcilePopups) reconcilePopups(v) })
$watch(() => $props.sources, () => applyLayers())
$watch(() => $props.layers, () => applyLayers())
// Declarative-children registries drive the SAME reconcile. Whole-object
// replacement in the $provide methods changes the watched reference once per
// register/update/unregister, so these fire exactly once per child mutation on all
// 6 targets (Pitfall 1). When both registries stay empty (config-array-only
// consumers) these never fire and applyLayers' merge is byte-equivalent to today.
$watch(() => $data.sourceReg, () => applyLayers())
$watch(() => $data.layerReg, () => applyLayers())
$watch(() => $props.interactiveLayerIds, (v) => { if (reconcileInteractive) reconcileInteractive(v) })
$watch(() => $props.controls, () => applyControls())
$watch(() => $props.dragPan, () => applyInteractionToggles())
$watch(() => $props.dragRotate, () => applyInteractionToggles())
$watch(() => $props.scrollZoom, () => applyInteractionToggles())
$watch(() => $props.doubleClickZoom, () => applyInteractionToggles())
$watch(() => $props.boxZoom, () => applyInteractionToggles())
$watch(() => $props.keyboard, () => applyInteractionToggles())
$watch(() => $props.touchZoomRotate, () => applyInteractionToggles())
$watch(() => $props.touchPitch, () => applyInteractionToggles())
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 15 verbs. Collision-clear across all 3 classes: NOT a React model-setter
// (setCenter/setZoom/setBearing/setPitch are the auto-gen'd ones — none here);
// NOT a Lit lifecycle name (update/render/firstUpdated/updated/willUpdate/
// requestUpdate); NOT an emitted event name (move/zoom/rotate/pitch/drag/click/
// idle/error — getCenter/getZoom/resize/flyTo/easeTo/jumpTo/fitBounds/getMap all
// differ; zoomIn/zoomOut differ from the `zoomend` emit). The camera verbs
// deliberately omit PROGRAMMATIC so an imperative move echoes into $model (the
// prop $watch then no-ops, getCenter already matching).
//
// Camera control is well-covered above; the read/hit-test/projection family
// below is what a consumer needs to build custom controls, overlays, and click
// interactivity — none reachable via prop/model/event:
// - queryRenderedFeatures: hit-test "what's under this pixel/box" (click-to-
// inspect, selection beyond per-layer mouseenter/leave).
// - project / unproject: convert geo<->screen for positioning framework DOM
// overlays over map coordinates.
// - getBounds: read the live visible viewport bbox (lazy-fetch data for the
// current view) — distinct from the construction-only `bounds` prop.
// - zoomIn / zoomOut / panBy: ergonomic nudges for a consumer's own controls.
function getMap() { return instance }
function flyTo(opts) { if (instance) instance.flyTo(opts) }
function easeTo(opts) { if (instance) instance.easeTo(opts) }
function jumpTo(opts) { if (instance) instance.jumpTo(opts) }
function fitBounds(bounds, opts) { if (instance) instance.fitBounds(bounds, opts) }
function getCenter() { if (!instance) return null; const c = instance.getCenter(); return [c.lng, c.lat] }
function getZoom() { return instance ? instance.getZoom() : null }
function resize() { if (instance) instance.resize() }
function queryRenderedFeatures(geometry, options) { return instance ? instance.queryRenderedFeatures(geometry, options) : [] }
function project(lngLat) { return instance ? instance.project(lngLat) : null }
function unproject(point) { return instance ? instance.unproject(point) : null }
function getBounds() { return instance ? instance.getBounds() : null }
function zoomIn(opts) { if (instance) instance.zoomIn(opts) }
function zoomOut(opts) { if (instance) instance.zoomOut(opts) }
function panBy(offset, opts) { if (instance) instance.panBy(offset, opts) }
$expose({
getMap, flyTo, easeTo, jumpTo, fitBounds, getCenter, getZoom, resize,
queryRenderedFeatures, project, unproject, getBounds, zoomIn, zoomOut, panBy,
})
</script>
<template>
<div class="rozie-maplibre" ref="containerEl"></div>
<!--
default slot — hosts the declarative <Source>/<Layer> children (Phase 37). They
are RENDERLESS (empty/slot-only templates), so this emits no visible DOM of its
own, but mounting them runs their $onMount register-calls into the registries
above. Distinct from the named portal slots below (no name collision).
-->
<slot />
<!--
marker — REACTIVE MULTI-INSTANCE portal slot. Declared but NOT rendered inline
(per-target emitters skip it). The wrapper invokes it from script via
$portals.marker(node, { marker, index }) per markers[] entry, mounting the
consumer fragment into a maplibregl.Marker element; the reactive handle
({ update, dispose }) re-renders in place as the marker's data changes.
ROZ127-clean: slot `marker` (singular) ≠ prop `markers` (plural).
-->
<slot name="marker" portal reactive :params="['marker', 'index']" />
<!--
popup — REACTIVE MULTI-INSTANCE portal slot. $portals.popup(node, { popup, index })
per popups[] entry → maplibregl.Popup().setDOMContent(node). Slot `popup` ≠ prop
`popups`.
-->
<slot name="popup" portal reactive :params="['popup', 'index']" />
<!--
control — MOUNT-ONCE portal slot for custom control UI. $portals.control(host,
{ map }) mounts the consumer fragment into a custom IControl element added via
addControl. Slot `control` ≠ prop `controls`.
-->
<slot name="control" portal :params="['map']" />
</template>
<style>
.rozie-maplibre {
width: 100%;
height: 100%;
min-height: 300px;
position: relative;
overflow: hidden;
border-radius: 6px;
}
:root {
/* engine-rendered marker/control DOM never carries the [data-rozie-s-*] scope
attribute — reach it via the Phase-34 :root engine-DOM escape hatch (NOT
:global(), which is a ROZ128 hard error). Consumers still import
maplibre-gl/dist/maplibre-gl.css for the base engine styles. */
.rozie-maplibre .rozie-maplibre-marker {
cursor: pointer;
}
.rozie-maplibre .rozie-maplibre-control {
display: flex;
flex-direction: column;
}
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/maplibre-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { rozieContext, useControllableState } from '@rozie/runtime-react';
import './MapLibre.css';
import './MapLibre.global.css';
import maplibregl from 'maplibre-gl';
interface MarkerCtx { marker: any; index: any; }
interface PopupCtx { popup: any; index: any; }
interface ControlCtx { map: any; }
interface MapLibreProps {
/**
* The map center as `[lng, lat]` — **longitude first** (MapLibre's convention, not Leaflet's `[lat, lng]`). Two-way: panning the map writes the new center back through the model path (echo-guarded), and a consumer write `easeTo`s the live map. The `moveend` echo reads `getCenter()` as `[lng, lat]`.
* @example
* <MapLibre r-model:center="center" r-model:zoom="zoom" />
*/
center?: any[];
defaultCenter?: any[];
onCenterChange?: (center: any[]) => void;
/**
* The zoom level. Two-way: scroll / pinch writes the new zoom back, and a consumer write `easeTo`s the camera. Echo-guarded against the wrapper's own programmatic moves.
*/
zoom?: number;
defaultZoom?: number;
onZoomChange?: (zoom: number) => void;
/**
* The map rotation (bearing) in degrees. Two-way via the `rotateend` echo and the `easeTo` reconcile.
*/
bearing?: number;
defaultBearing?: number;
onBearingChange?: (bearing: number) => void;
/**
* The map tilt (pitch) in degrees. Two-way via the `pitchend` echo and the `easeTo` reconcile.
*/
pitch?: number;
defaultPitch?: number;
onPitchChange?: (pitch: number) => void;
/**
* The map style — a `StyleSpecification` object **or** a style-URL string. Named `mapStyle` (not `style`) because `style` is a reserved attribute across the targets — `react-map-gl` and `vue-maplibre-gl` use the same name for the same reason. Defaults to MapLibre's official no-token demo tiles, so the component "just works" with zero config. Changing it calls `setStyle` and re-applies your `sources` / `layers` once the new style loads.
*/
mapStyle?: unknown;
/**
* Minimum zoom level. Applied at construction and via `setMinZoom` on change.
*/
minZoom?: number;
/**
* Maximum zoom level. Applied at construction and via `setMaxZoom` on change.
*/
maxZoom?: number;
/**
* A `LngLatBoundsLike` the camera is constrained to. Applied via `setMaxBounds` on change (pass `undefined` to clear).
*/
maxBounds?: unknown;
/**
* **Construction-only** initial fit — a `LngLatBoundsLike` the map fits to on mount (overrides `center` / `zoom` when set). Pair with `fitBoundsOptions`.
*/
bounds?: unknown;
/**
* **Construction-only** options for the initial `bounds` fit (padding, max-zoom, etc.).
*/
fitBoundsOptions?: Record<string, any>;
/**
* Toggle drag-to-pan. Applied at construction and reconciled live via the handler's `enable()` / `disable()`.
*/
dragPan?: boolean;
/**
* Toggle right-drag / ctrl-drag rotation. Applied at construction and reconciled live.
*/
dragRotate?: boolean;
/**
* Toggle scroll-wheel zoom. Applied at construction and reconciled live.
*/
scrollZoom?: boolean;
/**
* Toggle double-click zoom. Applied at construction and reconciled live.
*/
doubleClickZoom?: boolean;
/**
* Toggle shift-drag box zoom. Applied at construction and reconciled live.
*/
boxZoom?: boolean;
/**
* Toggle keyboard navigation. Applied at construction and reconciled live.
*/
keyboard?: boolean;
/**
* Toggle touch pinch-zoom + rotate. Applied at construction and reconciled live.
*/
touchZoomRotate?: boolean;
/**
* Toggle two-finger touch pitch. Applied at construction and reconciled live.
*/
touchPitch?: boolean;
/**
* The marker data that drives the reactive multi-instance `marker` slot — one entry per marker (`{ lng, lat, id?, anchor?, offset?, draggable?, ... }`). One portal handle mounts per entry; changing the array reconciles markers keep / update / dispose with no remount. Only meaningful when the `marker` slot is filled.
*/
markers?: any[];
/**
* The popup data that drives the reactive multi-instance `popup` slot — one entry per popup (`{ lng, lat, id?, anchor?, offset?, closeButton?, closeOnClick?, ... }`). One portal handle mounts per entry. Only meaningful when the `popup` slot is filled.
*/
popups?: any[];
/**
* Declarative GeoJSON / vector / raster sources — `[{ id, spec }]` (or a bare `SourceSpecification` carrying an `id`). Reconciled into the live style (add / `setData` / remove) once the style has loaded. The config-array authoring shape for sources; declarative `<Source>` / `<Layer>` children are the alternative shape (both feed the same registry).
*/
sources?: any[];
/**
* Declarative layers — `LayerSpecification[]` (each with an `id`). Reconciled into the live style (add / `setPaintProperty` / `setLayoutProperty` / remove) once the style has loaded; `beforeId` controls draw order.
*/
layers?: any[];
/**
* Layer ids whose feature `mouseenter` / `mouseleave` fire the `@mouseenter` / `@mouseleave` events (populating `e.features`). Registered / unregistered per id on change.
*/
interactiveLayerIds?: any[];
/**
* Standard map controls — strings (`'navigation'` / `'geolocate'` / `'scale'` / `'fullscreen'` / `'attribution'`) or `{ type, position?, options? }` objects. Reconciled (remove-all + re-add) on change.
*/
controls?: any[];
/**
* The raw `MapOptions` passthrough — spread into the `Map` constructor **before** the curated keys, so explicit props win. The MapLibre analog of an options bag for anything the curated surface doesn't special-case.
*/
options?: Record<string, any>;
onLoad?: (...args: any[]) => void;
onIdle?: (...args: any[]) => void;
onMove?: (...args: any[]) => void;
onRotate?: (...args: any[]) => void;
onDragstart?: (...args: any[]) => void;
onDrag?: (...args: any[]) => void;
onDragend?: (...args: any[]) => void;
onClick?: (...args: any[]) => void;
onDblclick?: (...args: any[]) => void;
onContextmenu?: (...args: any[]) => void;
onMousemove?: (...args: any[]) => void;
onError?: (...args: any[]) => void;
onStyledata?: (...args: any[]) => void;
onSourcedata?: (...args: any[]) => void;
onMoveend?: (...args: any[]) => void;
onZoomend?: (...args: any[]) => void;
onRotateend?: (...args: any[]) => void;
onPitchend?: (...args: any[]) => void;
onMouseenter?: (...args: any[]) => void;
onMouseleave?: (...args: any[]) => void;
children?: ReactNode;
renderMarker?: (ctx: MarkerCtx) => ReactNode;
renderPopup?: (ctx: PopupCtx) => ReactNode;
renderControl?: (ctx: ControlCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface MapLibreHandle {
getMap: (...args: any[]) => any;
flyTo: (...args: any[]) => any;
easeTo: (...args: any[]) => any;
jumpTo: (...args: any[]) => any;
fitBounds: (...args: any[]) => any;
getCenter: (...args: any[]) => any;
getZoom: (...args: any[]) => any;
resize: (...args: any[]) => any;
queryRenderedFeatures: (...args: any[]) => any;
project: (...args: any[]) => any;
unproject: (...args: any[]) => any;
getBounds: (...args: any[]) => any;
zoomIn: (...args: any[]) => any;
zoomOut: (...args: any[]) => any;
panBy: (...args: any[]) => any;
}
const MapLibre = forwardRef<MapLibreHandle, MapLibreProps>(function MapLibre(_props: MapLibreProps, ref): JSX.Element {
const __ctx_maplibre_sources = rozieContext("maplibre:sources");
const __ctx_maplibre_layers = rozieContext("maplibre:layers");
const portalRoots = useRef<Set<Root>>(new Set());
const __defaultFitBoundsOptions = useState(() => (() => ({}))())[0];
const __defaultMarkers = useState(() => (() => [])())[0];
const __defaultPopups = useState(() => (() => [])())[0];
const __defaultSources = useState(() => (() => [])())[0];
const __defaultLayers = useState(() => (() => [])())[0];
const __defaultInteractiveLayerIds = useState(() => (() => [])())[0];
const __defaultControls = useState(() => (() => [])())[0];
const __defaultOptions = useState(() => (() => ({}))())[0];
const props: Omit<MapLibreProps, 'mapStyle' | 'minZoom' | 'maxZoom' | 'maxBounds' | 'bounds' | 'fitBoundsOptions' | 'dragPan' | 'dragRotate' | 'scrollZoom' | 'doubleClickZoom' | 'boxZoom' | 'keyboard' | 'touchZoomRotate' | 'touchPitch' | 'markers' | 'popups' | 'sources' | 'layers' | 'interactiveLayerIds' | 'controls' | 'options'> & { mapStyle: unknown; minZoom: number; maxZoom: number; maxBounds: unknown; bounds: unknown; fitBoundsOptions: Record<string, any>; dragPan: boolean; dragRotate: boolean; scrollZoom: boolean; doubleClickZoom: boolean; boxZoom: boolean; keyboard: boolean; touchZoomRotate: boolean; touchPitch: boolean; markers: any[]; popups: any[]; sources: any[]; layers: any[]; interactiveLayerIds: any[]; controls: any[]; options: Record<string, any> } = {
..._props,
mapStyle: _props.mapStyle ?? undefined,
minZoom: _props.minZoom ?? 0,
maxZoom: _props.maxZoom ?? 22,
maxBounds: _props.maxBounds ?? undefined,
bounds: _props.bounds ?? undefined,
fitBoundsOptions: _props.fitBoundsOptions ?? __defaultFitBoundsOptions,
dragPan: _props.dragPan ?? true,
dragRotate: _props.dragRotate ?? true,
scrollZoom: _props.scrollZoom ?? true,
doubleClickZoom: _props.doubleClickZoom ?? true,
boxZoom: _props.boxZoom ?? true,
keyboard: _props.keyboard ?? true,
touchZoomRotate: _props.touchZoomRotate ?? true,
touchPitch: _props.touchPitch ?? true,
markers: _props.markers ?? __defaultMarkers,
popups: _props.popups ?? __defaultPopups,
sources: _props.sources ?? __defaultSources,
layers: _props.layers ?? __defaultLayers,
interactiveLayerIds: _props.interactiveLayerIds ?? __defaultInteractiveLayerIds,
controls: _props.controls ?? __defaultControls,
options: _props.options ?? __defaultOptions,
};
const _renderMarkerRef = useRef(props.renderMarker);
_renderMarkerRef.current = props.renderMarker;
const _renderPopupRef = useRef(props.renderPopup);
_renderPopupRef.current = props.renderPopup;
const _renderControlRef = useRef(props.renderControl);
_renderControlRef.current = props.renderControl;
const controlInstances = useRef<any>(null);
const appliedLayerIds = useRef<any>(null);
const appliedSourceIds = useRef<any>(null);
const instance = useRef<any>(null);
const reconcileMarkers = useRef<any>(null);
const reconcilePopups = useRef<any>(null);
const reconcileInteractive = useRef<any>(null);
const customControl = useRef<any>(null);
const controlDispose = useRef<any>(null);
const [center, setCenter] = useControllableState({
value: props.center,
defaultValue: props.defaultCenter ?? (() => [0, 0])(),
onValueChange: props.onCenterChange,
});
const [zoom, setZoom] = useControllableState({
value: props.zoom,
defaultValue: props.defaultZoom ?? 1,
onValueChange: props.onZoomChange,
});
const [bearing, setBearing] = useControllableState({
value: props.bearing,
defaultValue: props.defaultBearing ?? 0,
onValueChange: props.onBearingChange,
});
const [pitch, setPitch] = useControllableState({
value: props.pitch,
defaultValue: props.defaultPitch ?? 0,
onValueChange: props.onPitchChange,
});
const _boxZoomRef = useRef(props.boxZoom);
_boxZoomRef.current = props.boxZoom;
const _doubleClickZoomRef = useRef(props.doubleClickZoom);
_doubleClickZoomRef.current = props.doubleClickZoom;
const _dragPanRef = useRef(props.dragPan);
_dragPanRef.current = props.dragPan;
const _dragRotateRef = useRef(props.dragRotate);
_dragRotateRef.current = props.dragRotate;
const _interactiveLayerIdsRef = useRef(props.interactiveLayerIds);
_interactiveLayerIdsRef.current = props.interactiveLayerIds;
const _keyboardRef = useRef(props.keyboard);
_keyboardRef.current = props.keyboard;
const _mapStyleRef = useRef(props.mapStyle);
_mapStyleRef.current = props.mapStyle;
const _markersRef = useRef(props.markers);
_markersRef.current = props.markers;
const _maxBoundsRef = useRef(props.maxBounds);
_maxBoundsRef.current = props.maxBounds;
const _maxZoomRef = useRef(props.maxZoom);
_maxZoomRef.current = props.maxZoom;
const _minZoomRef = useRef(props.minZoom);
_minZoomRef.current = props.minZoom;
const _popupsRef = useRef(props.popups);
_popupsRef.current = props.popups;
const _scrollZoomRef = useRef(props.scrollZoom);
_scrollZoomRef.current = props.scrollZoom;
const _touchPitchRef = useRef(props.touchPitch);
_touchPitchRef.current = props.touchPitch;
const _touchZoomRotateRef = useRef(props.touchZoomRotate);
_touchZoomRotateRef.current = props.touchZoomRotate;
const _bearingRef = useRef(bearing);
_bearingRef.current = bearing;
const _centerRef = useRef(center);
_centerRef.current = center;
const _pitchRef = useRef(pitch);
_pitchRef.current = pitch;
const _zoomRef = useRef(zoom);
_zoomRef.current = zoom;
const [sourceReg, setSourceReg] = useState<Record<string, any>>({});
const [layerReg, setLayerReg] = useState<Record<string, any>>({});
const containerEl = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
const _watch5First = useRef(true);
const _watch6First = useRef(true);
const _watch7First = useRef(true);
const _watch8First = useRef(true);
const _watch9First = useRef(true);
const _watch10First = useRef(true);
const _watch11First = useRef(true);
const _watch12First = useRef(true);
const _watch13First = useRef(true);
const _watch14First = useRef(true);
const _watch15First = useRef(true);
const _watch16First = useRef(true);
const _watch17First = useRef(true);
const _watch18First = useRef(true);
const _watch19First = useRef(true);
const _watch20First = useRef(true);
const _watch21First = useRef(true);
const _watch22First = useRef(true);
const _watch23First = useRef(true);
const DEFAULT_STYLE = useMemo(() => 'https://demotiles.maplibre.org/style.json', []);
// The eventData merged onto programmatic camera ops so the camera-lifecycle echo
// handlers can ignore our own moves (the documented MapLibre echo-guard — robust
// across batched ops where Leaflet's single boolean would race).
const PROGRAMMATIC = {
rozieProgrammatic: true
};
// Live entry maps for the REACTIVE MULTI-INSTANCE portal slots — keyed by
// entry.id ?? index. Each value: { engine, handle, el }. COMPONENT-scope (not
// $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — keeps them in scope.
const markerEntries = useMemo(() => new Map(), []);
const popupEntries = useMemo(() => new Map(), []);
const featureListeners = useMemo(() => new Map(), []);
const sameCenter = useCallback((a: any, b: any) => Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1], []);
const payload = useCallback((e: any) => ({
lngLat: e.lngLat ? {
lng: e.lngLat.lng,
lat: e.lngLat.lat
} : null,
point: e.point ? {
x: e.point.x,
y: e.point.y
} : null,
features: e.features || [],
originalEvent: e.originalEvent
}), []);
function buildControl(spec: any) {
const type = typeof spec === 'string' ? spec : spec.type;
const opts = typeof spec === 'object' && spec.options || {};
if (type === 'navigation') return new maplibregl.NavigationControl(opts);
if (type === 'geolocate') return new maplibregl.GeolocateControl(opts);
if (type === 'scale') return new maplibregl.ScaleControl(opts);
if (type === 'fullscreen') return new maplibregl.FullscreenControl(opts);
if (type === 'attribution') return new maplibregl.AttributionControl(opts);
return null;
}
const applyControls = useCallback(() => {
if (!instance.current) return;
for (const c of controlInstances.current as any) instance.current.removeControl(c);
controlInstances.current = [];
for (const spec of props.controls as any) {
if (!spec) continue;
const ctrl = buildControl(spec);
if (!ctrl) continue;
const position = typeof spec === 'object' && spec.position || undefined;
instance.current.addControl(ctrl, position);
controlInstances.current.push(ctrl);
}
}, [buildControl, props.controls]);
const applyInteractionToggles = useCallback(() => {
if (!instance.current) return;
const set = (name: any, on: any) => {
const handler = instance.current[name];
if (handler) on ? handler.enable() : handler.disable();
};
set('dragPan', props.dragPan);
set('dragRotate', props.dragRotate);
set('scrollZoom', props.scrollZoom);
set('doubleClickZoom', props.doubleClickZoom);
set('boxZoom', props.boxZoom);
set('keyboard', props.keyboard);
set('touchZoomRotate', props.touchZoomRotate);
set('touchPitch', props.touchPitch);
}, [props.boxZoom, props.doubleClickZoom, props.dragPan, props.dragRotate, props.keyboard, props.scrollZoom, props.touchPitch, props.touchZoomRotate]);
const applyLayers = useCallback(() => {
if (!instance.current || !instance.current.isStyleLoaded()) return;
// ─── union the config-array props with the declarative-children registry ────
// (registry ∪ props), keyed by id. D-02: the registry (declarative children) is
// the LAST writer and overrides the config-array on id collision. Ordering: array
// entries first in array order, then registry entries in registration order —
// `[...$props.layers, ...registryLayers]` — each still honoring its explicit
// `beforeId` (the existing applyLayers ordering contract, REUSED unchanged,
// RESEARCH OQ3). The empty-registry path is byte-equivalent to today: with both
// registries empty, mergeById returns exactly the config array (dedup by id of
// an array with no registry overrides is the array itself), so (∅ ∪ props) ===
// props in behavior — the dist-parity zero-drift guarantee (RESEARCH A3).
const mergeById = (arr: any, reg: any) => {
// out seeded from the (any-typed) input so strict tsc infers any[] not never[]
// (untyped <script> can't use a TS `: any[]`/`as any[]` annotation; .slice(0,0)
// yields an empty array with identical runtime behavior to `const out = []`).
const out = (Array.isArray(arr) ? arr : []).slice(0, 0);
const idx = new Map();
for (const e of (Array.isArray(arr) ? arr : []) as any) {
if (!e || !e.id) {
out.push(e);
continue;
}
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
for (const id in reg) {
const e = reg[id];
if (!e || !e.id) continue;
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
return out;
};
const mergedSources = mergeById(props.sources, sourceReg);
const mergedLayers = mergeById(props.layers, layerReg);
const wantLayerIds = mergedLayers.map((l: any) => l && l.id).filter(Boolean);
const wantSourceIds = mergedSources.map((s: any) => s && s.id).filter(Boolean);
// 1. drop removed layers
for (const id of appliedLayerIds.current as any) {
if (!wantLayerIds.includes(id) && instance.current.getLayer(id)) instance.current.removeLayer(id);
}
// 2. add/update sources
for (const s of mergedSources as any) {
if (!s || !s.id) continue;
const spec = s.spec || s;
const existing = instance.current.getSource(s.id);
if (!existing) instance.current.addSource(s.id, spec);else if (spec.type === 'geojson' && spec.data) existing.setData(spec.data);
}
// 3. add/update layers. DEFENSIVE: a non-background layer whose `source` is not
// (yet) present in the engine is SKIPPED rather than added — a declarative
// <Layer> may register before its <Source> parent has supplied the source id
// (child-before-parent mount order on React/Vue/Svelte/Angular), in which case
// addLayer would throw "source ... doesn't exist" / read null `.type` and abort
// the whole loop (dropping later layers like `bg`). The <Layer> re-registers with
// the resolved source on $onUpdate, re-running this reconcile, so the layer lands
// on the next tick. Background layers need no source. addLayer is wrapped so any
// single malformed spec can't abort the rest of the loop either.
for (const l of mergedLayers as any) {
if (!l || !l.id) continue;
if (!instance.current.getLayer(l.id)) {
const needsSource = l.type !== 'background';
if (needsSource && (l.source == null || !instance.current.getSource(l.source))) continue;
// Build a CLEAN LayerSpecification: a declarative <Layer> registry spec carries
// a `beforeId` (not a LayerSpecification key — it is the addLayer 2nd arg) and
// explicit `source: undefined` / `layout: undefined` keys (the prop defaults).
// MapLibre v5 rejects a background layer that has ANY `source` key, and an
// undefined `layout` — so emit only the keys MapLibre expects (the config-array
// path is unaffected: those specs are already clean, this just re-emits them).
// null-let → typeNeutralize `any` so the dynamic key assignments below
// type-check on the strict bundled leaves (the `let x = null` idiom).
let clean: any = null;
clean = {
id: l.id,
type: l.type
};
if (needsSource) clean.source = l.source;
if (l.paint != null) clean.paint = l.paint;
if (l.layout != null) clean.layout = l.layout;
if (l.sourceLayer != null) clean['source-layer'] = l.sourceLayer;
if (l.filter != null) clean.filter = l.filter;
if (l.minzoom != null) clean.minzoom = l.minzoom;
if (l.maxzoom != null) clean.maxzoom = l.maxzoom;
try {
instance.current.addLayer(clean, l.beforeId);
} catch (e: any) {
// surfaced via the `error` emit path; skip so later layers still apply.
}
} else {
if (l.paint) for (const k in l.paint) instance.current.setPaintProperty(l.id, k, l.paint[k]);
if (l.layout) for (const k in l.layout) instance.current.setLayoutProperty(l.id, k, l.layout[k]);
}
}
// 4. drop removed sources (their layers are gone)
for (const id of appliedSourceIds.current as any) {
if (!wantSourceIds.includes(id) && instance.current.getSource(id)) instance.current.removeSource(id);
}
appliedLayerIds.current = wantLayerIds;
appliedSourceIds.current = wantSourceIds;
}, [layerReg, props.layers, props.sources, sourceReg]);
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 15 verbs. Collision-clear across all 3 classes: NOT a React model-setter
// (setCenter/setZoom/setBearing/setPitch are the auto-gen'd ones — none here);
// NOT a Lit lifecycle name (update/render/firstUpdated/updated/willUpdate/
// requestUpdate); NOT an emitted event name (move/zoom/rotate/pitch/drag/click/
// idle/error — getCenter/getZoom/resize/flyTo/easeTo/jumpTo/fitBounds/getMap all
// differ; zoomIn/zoomOut differ from the `zoomend` emit). The camera verbs
// deliberately omit PROGRAMMATIC so an imperative move echoes into $model (the
// prop $watch then no-ops, getCenter already matching).
//
// Camera control is well-covered above; the read/hit-test/projection family
// below is what a consumer needs to build custom controls, overlays, and click
// interactivity — none reachable via prop/model/event:
// - queryRenderedFeatures: hit-test "what's under this pixel/box" (click-to-
// inspect, selection beyond per-layer mouseenter/leave).
// - project / unproject: convert geo<->screen for positioning framework DOM
// overlays over map coordinates.
// - getBounds: read the live visible viewport bbox (lazy-fetch data for the
// current view) — distinct from the construction-only `bounds` prop.
// - zoomIn / zoomOut / panBy: ergonomic nudges for a consumer's own controls.
function getMap() {
return instance.current;
}
function flyTo(opts: any) {
if (instance.current) instance.current.flyTo(opts);
}
function easeTo(opts: any) {
if (instance.current) instance.current.easeTo(opts);
}
function jumpTo(opts: any) {
if (instance.current) instance.current.jumpTo(opts);
}
function fitBounds(bounds: any, opts: any) {
if (instance.current) instance.current.fitBounds(bounds, opts);
}
function getCenter() {
if (!instance.current) return null;
const c = instance.current.getCenter();
return [c.lng, c.lat];
}
function getZoom() {
return instance.current ? instance.current.getZoom() : null;
}
function resize() {
if (instance.current) instance.current.resize();
}
function queryRenderedFeatures(geometry: any, options: any) {
return instance.current ? instance.current.queryRenderedFeatures(geometry, options) : [];
}
function project(lngLat: any) {
return instance.current ? instance.current.project(lngLat) : null;
}
function unproject(point: any) {
return instance.current ? instance.current.unproject(point) : null;
}
function getBounds() {
return instance.current ? instance.current.getBounds() : null;
}
function zoomIn(opts: any) {
if (instance.current) instance.current.zoomIn(opts);
}
function zoomOut(opts: any) {
if (instance.current) instance.current.zoomOut(opts);
}
function panBy(offset: any, opts: any) {
if (instance.current) instance.current.panBy(offset, opts);
}
useEffect(() => {
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
marker: (container: HTMLElement, scope: { marker: unknown; index: unknown }): ReactivePortalHandle => {
const slot = _renderMarkerRef.current ?? props.slots?.['marker'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
// Cascades the @portal marker { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-marker', 'f1ee1082');
const root = createRoot(container);
const renderScope = (s: { marker: unknown; index: unknown }): void => {
flushSync(() => root.render(slot(s)));
};
renderScope(scope);
portalRoots.current.add(root);
return {
update: (s: { marker: unknown; index: unknown }): void => renderScope(s),
dispose: (): void => {
root.unmount();
portalRoots.current.delete(root);
},
};
},
popup: (container: HTMLElement, scope: { popup: unknown; index: unknown }): ReactivePortalHandle => {
const slot = _renderPopupRef.current ?? props.slots?.['popup'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
// Cascades the @portal popup { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-popup', 'f1ee1082');
const root = createRoot(container);
const renderScope = (s: { popup: unknown; index: unknown }): void => {
flushSync(() => root.render(slot(s)));
};
renderScope(scope);
portalRoots.current.add(root);
return {
update: (s: { popup: unknown; index: unknown }): void => renderScope(s),
dispose: (): void => {
root.unmount();
portalRoots.current.delete(root);
},
};
},
control: (container: HTMLElement, scope: { map: unknown }): (() => void) => {
const slot = _renderControlRef.current ?? props.slots?.['control'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal control { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-control', 'f1ee1082');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
};
const el = containerEl.current;
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
controlInstances.current = [];
appliedLayerIds.current = [];
appliedSourceIds.current = [];
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
let mapOptions: any = null;
mapOptions = {
container: el,
...props.options,
style: _mapStyleRef.current ?? DEFAULT_STYLE,
center: _centerRef.current,
zoom: _zoomRef.current,
bearing: _bearingRef.current,
pitch: _pitchRef.current,
minZoom: _minZoomRef.current,
maxZoom: _maxZoomRef.current,
maxBounds: _maxBoundsRef.current,
bounds: props.bounds,
fitBoundsOptions: props.fitBoundsOptions,
dragPan: _dragPanRef.current,
dragRotate: _dragRotateRef.current,
scrollZoom: _scrollZoomRef.current,
doubleClickZoom: _doubleClickZoomRef.current,
boxZoom: _boxZoomRef.current,
keyboard: _keyboardRef.current,
touchZoomRotate: _touchZoomRotateRef.current,
touchPitch: _touchPitchRef.current
};
instance.current = new maplibregl.Map(mapOptions);
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
instance.current.on('load', (e: any) => props.onLoad && props.onLoad(e));
instance.current.on('idle', (e: any) => props.onIdle && props.onIdle(e));
instance.current.on('move', (e: any) => props.onMove && props.onMove(e));
instance.current.on('rotate', (e: any) => props.onRotate && props.onRotate(e));
instance.current.on('dragstart', (e: any) => props.onDragstart && props.onDragstart(e));
instance.current.on('drag', (e: any) => props.onDrag && props.onDrag(e));
instance.current.on('dragend', (e: any) => props.onDragend && props.onDragend(e));
instance.current.on('click', (e: any) => props.onClick && props.onClick(payload(e)));
instance.current.on('dblclick', (e: any) => props.onDblclick && props.onDblclick(payload(e)));
instance.current.on('contextmenu', (e: any) => props.onContextmenu && props.onContextmenu(payload(e)));
instance.current.on('mousemove', (e: any) => props.onMousemove && props.onMousemove(payload(e)));
instance.current.on('error', (e: any) => props.onError && props.onError(e));
instance.current.on('styledata', (e: any) => props.onStyledata && props.onStyledata(e));
instance.current.on('sourcedata', (e: any) => props.onSourcedata && props.onSourcedata(e));
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
instance.current.on('moveend', (e: any) => {
props.onMoveend && props.onMoveend(e);
if (e.rozieProgrammatic) return;
const c = instance.current.getCenter();
const next = [c.lng, c.lat];
if (!sameCenter(next, _centerRef.current)) setCenter(next);
const z = instance.current.getZoom();
if (z !== _zoomRef.current) setZoom(z);
});
instance.current.on('zoomend', (e: any) => {
props.onZoomend && props.onZoomend(e);
if (e.rozieProgrammatic) return;
const z = instance.current.getZoom();
if (z !== _zoomRef.current) setZoom(z);
});
instance.current.on('rotateend', (e: any) => {
props.onRotateend && props.onRotateend(e);
if (e.rozieProgrammatic) return;
const b = instance.current.getBearing();
if (b !== _bearingRef.current) setBearing(b);
});
instance.current.on('pitchend', (e: any) => {
props.onPitchend && props.onPitchend(e);
if (e.rozieProgrammatic) return;
const p = instance.current.getPitch();
if (p !== _pitchRef.current) setPitch(p);
});
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
reconcileMarkers.current = (list: any) => {
if (!(props.renderMarker ?? props.slots?.["marker"])) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((m: any, index: any) => {
if (!m || typeof m.lng !== 'number' || typeof m.lat !== 'number') return;
const key = m.id != null ? m.id : index;
seen.add(key);
const scope = {
marker: m,
index
};
const entry = markerEntries.get(key);
if (entry) {
entry.engine.setLngLat([m.lng, m.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-marker';
const handle = portals.marker(node, scope);
const engine = new maplibregl.Marker({
element: node,
anchor: m.anchor,
offset: m.offset,
draggable: m.draggable
}).setLngLat([m.lng, m.lat]).addTo(instance.current);
markerEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of markerEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
markerEntries.delete(key);
}
}
};
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
reconcilePopups.current = (list: any) => {
if (!(props.renderPopup ?? props.slots?.["popup"])) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((p: any, index: any) => {
if (!p || typeof p.lng !== 'number' || typeof p.lat !== 'number') return;
const key = p.id != null ? p.id : index;
seen.add(key);
const scope = {
popup: p,
index
};
const entry = popupEntries.get(key);
if (entry) {
entry.engine.setLngLat([p.lng, p.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-popup-body';
const handle = portals.popup(node, scope);
const engine = new maplibregl.Popup({
closeButton: p.closeButton !== undefined ? p.closeButton : true,
closeOnClick: p.closeOnClick !== undefined ? p.closeOnClick : false,
anchor: p.anchor,
offset: p.offset
}).setLngLat([p.lng, p.lat]).setDOMContent(node).addTo(instance.current);
popupEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of popupEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
popupEntries.delete(key);
}
}
};
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
reconcileInteractive.current = (ids: any) => {
const want = (Array.isArray(ids) ? ids : []).filter(Boolean);
for (const [id, l] of featureListeners as any) {
if (!want.includes(id)) {
instance.current.off('mouseenter', id, l.enter);
instance.current.off('mouseleave', id, l.leave);
featureListeners.delete(id);
}
}
for (const id of want as any) {
if (featureListeners.has(id)) continue;
const enter = (e: any) => props.onMouseenter && props.onMouseenter(payload(e));
const leave = (e: any) => props.onMouseleave && props.onMouseleave(payload(e));
instance.current.on('mouseenter', id, enter);
instance.current.on('mouseleave', id, leave);
featureListeners.set(id, {
enter,
leave
});
}
};
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
if ((props.renderControl ?? props.slots?.["control"])) {
const host = document.createElement('div');
host.className = 'maplibregl-ctrl rozie-maplibre-control';
customControl.current = {
onAdd() {
return host;
},
onRemove() {
if (host.parentNode) host.parentNode.removeChild(host);
}
};
instance.current.addControl(customControl.current, 'top-right');
controlDispose.current = portals.control(host, {
map: instance.current
});
}
// standard controls + interaction toggles don't need style load.
applyControls();
applyInteractionToggles();
// markers/popups/interactive are DOM/event overlays — no style-load gate.
reconcileMarkers.current(_markersRef.current);
reconcilePopups.current(_popupsRef.current);
reconcileInteractive.current(_interactiveLayerIdsRef.current);
// sources/layers need the style loaded.
if (instance.current.isStyleLoaded()) applyLayers();else instance.current.on('load', applyLayers);
return () => {
for (const root of portalRoots.current) root.unmount();
portalRoots.current.clear();
for (const [, entry] of markerEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
markerEntries.clear();
for (const [, entry] of popupEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
popupEntries.clear();
if (controlDispose.current) controlDispose.current();
if (instance.current) instance.current.remove();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const v = center;
if (!instance.current || !Array.isArray(v) || v.length !== 2) return;
const c = instance.current.getCenter();
if (v[0] === c.lng && v[1] === c.lat) return;
instance.current.easeTo({
center: v,
animate: false
}, PROGRAMMATIC);
}, [center]);
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
const v = zoom;
if (!instance.current || typeof v !== 'number' || v === instance.current.getZoom()) return;
instance.current.easeTo({
zoom: v,
animate: false
}, PROGRAMMATIC);
}, [zoom]);
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
const v = bearing;
if (!instance.current || typeof v !== 'number' || v === instance.current.getBearing()) return;
instance.current.easeTo({
bearing: v,
animate: false
}, PROGRAMMATIC);
}, [bearing]);
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
const v = pitch;
if (!instance.current || typeof v !== 'number' || v === instance.current.getPitch()) return;
instance.current.easeTo({
pitch: v,
animate: false
}, PROGRAMMATIC);
}, [pitch]);
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
const v = props.mapStyle;
if (!instance.current) return;
// a new style wipes imperatively-added sources/layers — reset the applied
// tracking and re-apply once the new style loads.
appliedLayerIds.current = [];
appliedSourceIds.current = [];
instance.current.setStyle(v ?? DEFAULT_STYLE);
instance.current.once('styledata', () => applyLayers());
}, [props.mapStyle]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch5First.current) { _watch5First.current = false; return; }
const v = props.minZoom;
if (instance.current && typeof v === 'number') instance.current.setMinZoom(v);
}, [props.minZoom]);
useEffect(() => {
if (_watch6First.current) { _watch6First.current = false; return; }
const v = props.maxZoom;
if (instance.current && typeof v === 'number') instance.current.setMaxZoom(v);
}, [props.maxZoom]);
useEffect(() => {
if (_watch7First.current) { _watch7First.current = false; return; }
const v = props.maxBounds;
if (instance.current) instance.current.setMaxBounds(v || null);
}, [props.maxBounds]);
useEffect(() => {
if (_watch8First.current) { _watch8First.current = false; return; }
const v = props.markers;
if (reconcileMarkers.current) reconcileMarkers.current(v);
}, [props.markers]);
useEffect(() => {
if (_watch9First.current) { _watch9First.current = false; return; }
const v = props.popups;
if (reconcilePopups.current) reconcilePopups.current(v);
}, [props.popups]);
useEffect(() => {
if (_watch10First.current) { _watch10First.current = false; return; }
applyLayers();
}, [props.sources]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch11First.current) { _watch11First.current = false; return; }
applyLayers();
}, [props.layers]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch12First.current) { _watch12First.current = false; return; }
applyLayers();
}, [sourceReg]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch13First.current) { _watch13First.current = false; return; }
applyLayers();
}, [layerReg]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch14First.current) { _watch14First.current = false; return; }
const v = props.interactiveLayerIds;
if (reconcileInteractive.current) reconcileInteractive.current(v);
}, [props.interactiveLayerIds]);
useEffect(() => {
if (_watch15First.current) { _watch15First.current = false; return; }
applyControls();
}, [props.controls]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch16First.current) { _watch16First.current = false; return; }
applyInteractionToggles();
}, [props.dragPan]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch17First.current) { _watch17First.current = false; return; }
applyInteractionToggles();
}, [props.dragRotate]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch18First.current) { _watch18First.current = false; return; }
applyInteractionToggles();
}, [props.scrollZoom]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch19First.current) { _watch19First.current = false; return; }
applyInteractionToggles();
}, [props.doubleClickZoom]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch20First.current) { _watch20First.current = false; return; }
applyInteractionToggles();
}, [props.boxZoom]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch21First.current) { _watch21First.current = false; return; }
applyInteractionToggles();
}, [props.keyboard]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch22First.current) { _watch22First.current = false; return; }
applyInteractionToggles();
}, [props.touchZoomRotate]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch23First.current) { _watch23First.current = false; return; }
applyInteractionToggles();
}, [props.touchPitch]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ getMap, flyTo, easeTo, jumpTo, fitBounds, getCenter, getZoom, resize, queryRenderedFeatures, project, unproject, getBounds, zoomIn, zoomOut, panBy });
_rozieExposeRef.current = { getMap, flyTo, easeTo, jumpTo, fitBounds, getCenter, getZoom, resize, queryRenderedFeatures, project, unproject, getBounds, zoomIn, zoomOut, panBy };
useImperativeHandle(ref, () => ({ getMap: (...args: Parameters<typeof getMap>): ReturnType<typeof getMap> => _rozieExposeRef.current.getMap(...args), flyTo: (...args: Parameters<typeof flyTo>): ReturnType<typeof flyTo> => _rozieExposeRef.current.flyTo(...args), easeTo: (...args: Parameters<typeof easeTo>): ReturnType<typeof easeTo> => _rozieExposeRef.current.easeTo(...args), jumpTo: (...args: Parameters<typeof jumpTo>): ReturnType<typeof jumpTo> => _rozieExposeRef.current.jumpTo(...args), fitBounds: (...args: Parameters<typeof fitBounds>): ReturnType<typeof fitBounds> => _rozieExposeRef.current.fitBounds(...args), getCenter: (...args: Parameters<typeof getCenter>): ReturnType<typeof getCenter> => _rozieExposeRef.current.getCenter(...args), getZoom: (...args: Parameters<typeof getZoom>): ReturnType<typeof getZoom> => _rozieExposeRef.current.getZoom(...args), resize: (...args: Parameters<typeof resize>): ReturnType<typeof resize> => _rozieExposeRef.current.resize(...args), queryRenderedFeatures: (...args: Parameters<typeof queryRenderedFeatures>): ReturnType<typeof queryRenderedFeatures> => _rozieExposeRef.current.queryRenderedFeatures(...args), project: (...args: Parameters<typeof project>): ReturnType<typeof project> => _rozieExposeRef.current.project(...args), unproject: (...args: Parameters<typeof unproject>): ReturnType<typeof unproject> => _rozieExposeRef.current.unproject(...args), getBounds: (...args: Parameters<typeof getBounds>): ReturnType<typeof getBounds> => _rozieExposeRef.current.getBounds(...args), zoomIn: (...args: Parameters<typeof zoomIn>): ReturnType<typeof zoomIn> => _rozieExposeRef.current.zoomIn(...args), zoomOut: (...args: Parameters<typeof zoomOut>): ReturnType<typeof zoomOut> => _rozieExposeRef.current.zoomOut(...args), panBy: (...args: Parameters<typeof panBy>): ReturnType<typeof panBy> => _rozieExposeRef.current.panBy(...args) }), []);
return (
<__ctx_maplibre_sources.Provider value={{
register: (id: any, spec: any) => {
setSourceReg(prev => ({
...prev,
[id]: spec
}));
},
update: (id: any, spec: any) => {
setSourceReg(prev => ({
...prev,
[id]: spec
}));
},
unregister: (id: any) => {
const n = {
...sourceReg
};
delete n[id];
setSourceReg(n);
}
}}>
<__ctx_maplibre_layers.Provider value={{
register: (id: any, spec: any) => {
setLayerReg(prev => ({
...prev,
[id]: spec
}));
},
update: (id: any, spec: any) => {
setLayerReg(prev => ({
...prev,
[id]: spec
}));
},
unregister: (id: any) => {
const n = {
...layerReg
};
delete n[id];
setLayerReg(n);
}
}}>
<>
<div className={"rozie-maplibre"} ref={containerEl} data-rozie-s-f1ee1082="" />
{(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
</>
</__ctx_maplibre_layers.Provider>
</__ctx_maplibre_sources.Provider>
);
});
export default MapLibre;vue
<template>
<div class="rozie-maplibre" ref="containerElRef"></div>
<slot></slot>
</template>
<script setup lang="ts">
import { Fragment, h, onBeforeUnmount, onMounted, provide, ref, render, useSlots, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* The map style — a `StyleSpecification` object **or** a style-URL string. Named `mapStyle` (not `style`) because `style` is a reserved attribute across the targets — `react-map-gl` and `vue-maplibre-gl` use the same name for the same reason. Defaults to MapLibre's official no-token demo tiles, so the component "just works" with zero config. Changing it calls `setStyle` and re-applies your `sources` / `layers` once the new style loads.
*/
mapStyle?: unknown;
/**
* Minimum zoom level. Applied at construction and via `setMinZoom` on change.
*/
minZoom?: number;
/**
* Maximum zoom level. Applied at construction and via `setMaxZoom` on change.
*/
maxZoom?: number;
/**
* A `LngLatBoundsLike` the camera is constrained to. Applied via `setMaxBounds` on change (pass `undefined` to clear).
*/
maxBounds?: unknown;
/**
* **Construction-only** initial fit — a `LngLatBoundsLike` the map fits to on mount (overrides `center` / `zoom` when set). Pair with `fitBoundsOptions`.
*/
bounds?: unknown;
/**
* **Construction-only** options for the initial `bounds` fit (padding, max-zoom, etc.).
*/
fitBoundsOptions?: Record<string, any>;
/**
* Toggle drag-to-pan. Applied at construction and reconciled live via the handler's `enable()` / `disable()`.
*/
dragPan?: boolean;
/**
* Toggle right-drag / ctrl-drag rotation. Applied at construction and reconciled live.
*/
dragRotate?: boolean;
/**
* Toggle scroll-wheel zoom. Applied at construction and reconciled live.
*/
scrollZoom?: boolean;
/**
* Toggle double-click zoom. Applied at construction and reconciled live.
*/
doubleClickZoom?: boolean;
/**
* Toggle shift-drag box zoom. Applied at construction and reconciled live.
*/
boxZoom?: boolean;
/**
* Toggle keyboard navigation. Applied at construction and reconciled live.
*/
keyboard?: boolean;
/**
* Toggle touch pinch-zoom + rotate. Applied at construction and reconciled live.
*/
touchZoomRotate?: boolean;
/**
* Toggle two-finger touch pitch. Applied at construction and reconciled live.
*/
touchPitch?: boolean;
/**
* The marker data that drives the reactive multi-instance `marker` slot — one entry per marker (`{ lng, lat, id?, anchor?, offset?, draggable?, ... }`). One portal handle mounts per entry; changing the array reconciles markers keep / update / dispose with no remount. Only meaningful when the `marker` slot is filled.
*/
markers?: any[];
/**
* The popup data that drives the reactive multi-instance `popup` slot — one entry per popup (`{ lng, lat, id?, anchor?, offset?, closeButton?, closeOnClick?, ... }`). One portal handle mounts per entry. Only meaningful when the `popup` slot is filled.
*/
popups?: any[];
/**
* Declarative GeoJSON / vector / raster sources — `[{ id, spec }]` (or a bare `SourceSpecification` carrying an `id`). Reconciled into the live style (add / `setData` / remove) once the style has loaded. The config-array authoring shape for sources; declarative `<Source>` / `<Layer>` children are the alternative shape (both feed the same registry).
*/
sources?: any[];
/**
* Declarative layers — `LayerSpecification[]` (each with an `id`). Reconciled into the live style (add / `setPaintProperty` / `setLayoutProperty` / remove) once the style has loaded; `beforeId` controls draw order.
*/
layers?: any[];
/**
* Layer ids whose feature `mouseenter` / `mouseleave` fire the `@mouseenter` / `@mouseleave` events (populating `e.features`). Registered / unregistered per id on change.
*/
interactiveLayerIds?: any[];
/**
* Standard map controls — strings (`'navigation'` / `'geolocate'` / `'scale'` / `'fullscreen'` / `'attribution'`) or `{ type, position?, options? }` objects. Reconciled (remove-all + re-add) on change.
*/
controls?: any[];
/**
* The raw `MapOptions` passthrough — spread into the `Map` constructor **before** the curated keys, so explicit props win. The MapLibre analog of an options bag for anything the curated surface doesn't special-case.
*/
options?: Record<string, any>;
}>(),
{ mapStyle: undefined, minZoom: 0, maxZoom: 22, maxBounds: undefined, bounds: undefined, fitBoundsOptions: () => ({}), dragPan: true, dragRotate: true, scrollZoom: true, doubleClickZoom: true, boxZoom: true, keyboard: true, touchZoomRotate: true, touchPitch: true, markers: () => [], popups: () => [], sources: () => [], layers: () => [], interactiveLayerIds: () => [], controls: () => [], options: () => ({}) }
);
/**
* The map center as `[lng, lat]` — **longitude first** (MapLibre's convention, not Leaflet's `[lat, lng]`). Two-way: panning the map writes the new center back through the model path (echo-guarded), and a consumer write `easeTo`s the live map. The `moveend` echo reads `getCenter()` as `[lng, lat]`.
* @example
* <MapLibre r-model:center="center" r-model:zoom="zoom" />
*/
const center = defineModel<any[]>('center', { default: () => [0, 0] });
/**
* The zoom level. Two-way: scroll / pinch writes the new zoom back, and a consumer write `easeTo`s the camera. Echo-guarded against the wrapper's own programmatic moves.
*/
const zoom = defineModel<number>('zoom', { default: 1 });
/**
* The map rotation (bearing) in degrees. Two-way via the `rotateend` echo and the `easeTo` reconcile.
*/
const bearing = defineModel<number>('bearing', { default: 0 });
/**
* The map tilt (pitch) in degrees. Two-way via the `pitchend` echo and the `easeTo` reconcile.
*/
const pitch = defineModel<number>('pitch', { default: 0 });
const emit = defineEmits<{
load: [...args: any[]];
idle: [...args: any[]];
move: [...args: any[]];
rotate: [...args: any[]];
dragstart: [...args: any[]];
drag: [...args: any[]];
dragend: [...args: any[]];
click: [...args: any[]];
dblclick: [...args: any[]];
contextmenu: [...args: any[]];
mousemove: [...args: any[]];
error: [...args: any[]];
styledata: [...args: any[]];
sourcedata: [...args: any[]];
moveend: [...args: any[]];
zoomend: [...args: any[]];
rotateend: [...args: any[]];
pitchend: [...args: any[]];
mouseenter: [...args: any[]];
mouseleave: [...args: any[]];
}>();
defineSlots<{
default(props: { }): any;
marker(props: { marker: any; index: any }): any;
popup(props: { popup: any; index: any }): any;
control(props: { map: any }): any;
}>();
const slots = useSlots();
const sourceReg = ref({});
const layerReg = ref({});
const containerElRef = ref<HTMLElement>();
import maplibregl from 'maplibre-gl';
let instance: any = null;
// MapLibre's official no-token demo tiles — the zero-config `mapStyle` fallback
// (the prop default is `undefined`; see the prop note).
// MapLibre's official no-token demo tiles — the zero-config `mapStyle` fallback
// (the prop default is `undefined`; see the prop note).
const DEFAULT_STYLE = 'https://demotiles.maplibre.org/style.json';
// The eventData merged onto programmatic camera ops so the camera-lifecycle echo
// handlers can ignore our own moves (the documented MapLibre echo-guard — robust
// across batched ops where Leaflet's single boolean would race).
// The eventData merged onto programmatic camera ops so the camera-lifecycle echo
// handlers can ignore our own moves (the documented MapLibre echo-guard — robust
// across batched ops where Leaflet's single boolean would race).
const PROGRAMMATIC = {
rozieProgrammatic: true
};
// Live entry maps for the REACTIVE MULTI-INSTANCE portal slots — keyed by
// entry.id ?? index. Each value: { engine, handle, el }. COMPONENT-scope (not
// $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — keeps them in scope.
// Live entry maps for the REACTIVE MULTI-INSTANCE portal slots — keyed by
// entry.id ?? index. Each value: { engine, handle, el }. COMPONENT-scope (not
// $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — keeps them in scope.
const markerEntries = new Map();
const popupEntries = new Map();
// ─── declarative-children registry (Phase 37 $provide/$inject dogfood) ───────
// Publish the source/layer register-API the <Source>/<Layer> children $inject and
// self-register into. EVERY method uses WHOLE-OBJECT REPLACEMENT (spread / clone-
// and-delete) so the watched $data.sourceReg/$data.layerReg reference changes once
// per mutation and the parent $watch fires on all 6 targets (D-3 / Pitfall 1 — an
// in-place `$data.sourceReg[id] = spec` is silent on React/Solid/Angular/Lit). The
// register surface mirrors the SHIPPED Tabs.rozie $provide('tabs', { … }) shape;
// register/update share a body (both upsert by id). The values feed the SAME
// applyLayers() reconcile + appliedSourceIds/appliedLayerIds provenance as the
// config-array props, so registry-managed sources/layers are reaped on unregister
// exactly like prop-managed ones (D37-08).
// standard-control instances (so a controls-prop change can remove + re-add) and
// the mount-once custom-control portal dispose. controlInstances is a null-let
// (→ typeNeutralize `any`) initialized to [] in $onMount: a bare `let x = []`
// infers `never[]` under the strict framework-typecheck harness and rejects the
// `any` control instances pushed into it.
let controlInstances: any = null;
let controlDispose: any = null;
let customControl: any = null;
// layer-scoped feature listeners, registered per interactiveLayerId so they can
// be unregistered on change. id → { enter, leave }.
// layer-scoped feature listeners, registered per interactiveLayerId so they can
// be unregistered on change. id → { enter, leave }.
const featureListeners = new Map();
// previously-applied source/layer ids (null-lets → `any`, [] in $onMount; same
// never[] reason as controlInstances) so a sources/layers prop change can remove
// the dropped ones.
// previously-applied source/layer ids (null-lets → `any`, [] in $onMount; same
// never[] reason as controlInstances) so a sources/layers prop change can remove
// the dropped ones.
let appliedLayerIds: any = null;
let appliedSourceIds: any = null;
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount (a top-level
// $portals reference fails the bundled-leaf strict typecheck — the CM/TipTap
// portal discipline) and bridged here so the top-level $watch can call them.
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount (a top-level
// $portals reference fails the bundled-leaf strict typecheck — the CM/TipTap
// portal discipline) and bridged here so the top-level $watch can call them.
let reconcileMarkers: any = null;
let reconcilePopups: any = null;
let reconcileInteractive: any = null;
// ─── pure helpers (no sigils → safe at top level) ───────────────────────────
// ─── pure helpers (no sigils → safe at top level) ───────────────────────────
const sameCenter = (a: any, b: any) => Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
// structured pointer-event payload — stable across targets, avoids handing the
// raw engine event (with its circular `target: Map`) to consumers.
// structured pointer-event payload — stable across targets, avoids handing the
// raw engine event (with its circular `target: Map`) to consumers.
const payload = (e: any) => ({
lngLat: e.lngLat ? {
lng: e.lngLat.lng,
lat: e.lngLat.lat
} : null,
point: e.point ? {
x: e.point.x,
y: e.point.y
} : null,
features: e.features || [],
originalEvent: e.originalEvent
});
const buildControl = (spec: any) => {
const type = typeof spec === 'string' ? spec : spec.type;
const opts = typeof spec === 'object' && spec.options || {};
if (type === 'navigation') return new maplibregl.NavigationControl(opts);
if (type === 'geolocate') return new maplibregl.GeolocateControl(opts);
if (type === 'scale') return new maplibregl.ScaleControl(opts);
if (type === 'fullscreen') return new maplibregl.FullscreenControl(opts);
if (type === 'attribution') return new maplibregl.AttributionControl(opts);
return null;
};
// Standard controls reconcile — no $portals/$emit, so top-level. Remove-all +
// re-add from the config (controls rarely change; cheap and order-correct).
// Standard controls reconcile — no $portals/$emit, so top-level. Remove-all +
// re-add from the config (controls rarely change; cheap and order-correct).
const applyControls = () => {
if (!instance) return;
for (const c of controlInstances as any) instance.removeControl(c);
controlInstances = [];
for (const spec of props.controls as any) {
if (!spec) continue;
const ctrl = buildControl(spec);
if (!ctrl) continue;
const position = typeof spec === 'object' && spec.position || undefined;
instance.addControl(ctrl, position);
controlInstances.push(ctrl);
}
};
// Interaction-toggle reconcile — each toggle maps to a runtime handler object.
// Interaction-toggle reconcile — each toggle maps to a runtime handler object.
const applyInteractionToggles = () => {
if (!instance) return;
const set = (name: any, on: any) => {
const handler = instance[name];
if (handler) on ? handler.enable() : handler.disable();
};
set('dragPan', props.dragPan);
set('dragRotate', props.dragRotate);
set('scrollZoom', props.scrollZoom);
set('doubleClickZoom', props.doubleClickZoom);
set('boxZoom', props.boxZoom);
set('keyboard', props.keyboard);
set('touchZoomRotate', props.touchZoomRotate);
set('touchPitch', props.touchPitch);
};
// Style-load-gated source/layer reconcile. Order matters: drop removed layers
// FIRST, then add/update sources, then add/update layers, then drop removed
// sources (after their layers are gone).
// Style-load-gated source/layer reconcile. Order matters: drop removed layers
// FIRST, then add/update sources, then add/update layers, then drop removed
// sources (after their layers are gone).
const applyLayers = () => {
if (!instance || !instance.isStyleLoaded()) return;
// ─── union the config-array props with the declarative-children registry ────
// (registry ∪ props), keyed by id. D-02: the registry (declarative children) is
// the LAST writer and overrides the config-array on id collision. Ordering: array
// entries first in array order, then registry entries in registration order —
// `[...$props.layers, ...registryLayers]` — each still honoring its explicit
// `beforeId` (the existing applyLayers ordering contract, REUSED unchanged,
// RESEARCH OQ3). The empty-registry path is byte-equivalent to today: with both
// registries empty, mergeById returns exactly the config array (dedup by id of
// an array with no registry overrides is the array itself), so (∅ ∪ props) ===
// props in behavior — the dist-parity zero-drift guarantee (RESEARCH A3).
const mergeById = (arr: any, reg: any) => {
// out seeded from the (any-typed) input so strict tsc infers any[] not never[]
// (untyped <script> can't use a TS `: any[]`/`as any[]` annotation; .slice(0,0)
// yields an empty array with identical runtime behavior to `const out = []`).
const out = (Array.isArray(arr) ? arr : []).slice(0, 0);
const idx = new Map();
for (const e of (Array.isArray(arr) ? arr : []) as any) {
if (!e || !e.id) {
out.push(e);
continue;
}
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
for (const id in reg) {
const e = reg[id];
if (!e || !e.id) continue;
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
return out;
};
const mergedSources = mergeById(props.sources, sourceReg.value);
const mergedLayers = mergeById(props.layers, layerReg.value);
const wantLayerIds = mergedLayers.map((l: any) => l && l.id).filter(Boolean);
const wantSourceIds = mergedSources.map((s: any) => s && s.id).filter(Boolean);
// 1. drop removed layers
for (const id of appliedLayerIds as any) {
if (!wantLayerIds.includes(id) && instance.getLayer(id)) instance.removeLayer(id);
}
// 2. add/update sources
for (const s of mergedSources as any) {
if (!s || !s.id) continue;
const spec = s.spec || s;
const existing = instance.getSource(s.id);
if (!existing) instance.addSource(s.id, spec);else if (spec.type === 'geojson' && spec.data) existing.setData(spec.data);
}
// 3. add/update layers. DEFENSIVE: a non-background layer whose `source` is not
// (yet) present in the engine is SKIPPED rather than added — a declarative
// <Layer> may register before its <Source> parent has supplied the source id
// (child-before-parent mount order on React/Vue/Svelte/Angular), in which case
// addLayer would throw "source ... doesn't exist" / read null `.type` and abort
// the whole loop (dropping later layers like `bg`). The <Layer> re-registers with
// the resolved source on $onUpdate, re-running this reconcile, so the layer lands
// on the next tick. Background layers need no source. addLayer is wrapped so any
// single malformed spec can't abort the rest of the loop either.
for (const l of mergedLayers as any) {
if (!l || !l.id) continue;
if (!instance.getLayer(l.id)) {
const needsSource = l.type !== 'background';
if (needsSource && (l.source == null || !instance.getSource(l.source))) continue;
// Build a CLEAN LayerSpecification: a declarative <Layer> registry spec carries
// a `beforeId` (not a LayerSpecification key — it is the addLayer 2nd arg) and
// explicit `source: undefined` / `layout: undefined` keys (the prop defaults).
// MapLibre v5 rejects a background layer that has ANY `source` key, and an
// undefined `layout` — so emit only the keys MapLibre expects (the config-array
// path is unaffected: those specs are already clean, this just re-emits them).
// null-let → typeNeutralize `any` so the dynamic key assignments below
// type-check on the strict bundled leaves (the `let x = null` idiom).
let clean: any = null;
clean = {
id: l.id,
type: l.type
};
if (needsSource) clean.source = l.source;
if (l.paint != null) clean.paint = l.paint;
if (l.layout != null) clean.layout = l.layout;
if (l.sourceLayer != null) clean['source-layer'] = l.sourceLayer;
if (l.filter != null) clean.filter = l.filter;
if (l.minzoom != null) clean.minzoom = l.minzoom;
if (l.maxzoom != null) clean.maxzoom = l.maxzoom;
try {
instance.addLayer(clean, l.beforeId);
} catch (e: any) {
// surfaced via the `error` emit path; skip so later layers still apply.
}
} else {
if (l.paint) for (const k in l.paint) instance.setPaintProperty(l.id, k, l.paint[k]);
if (l.layout) for (const k in l.layout) instance.setLayoutProperty(l.id, k, l.layout[k]);
}
}
// 4. drop removed sources (their layers are gone)
for (const id of appliedSourceIds as any) {
if (!wantSourceIds.includes(id) && instance.getSource(id)) instance.removeSource(id);
}
appliedLayerIds = wantLayerIds;
appliedSourceIds = wantSourceIds;
};
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 15 verbs. Collision-clear across all 3 classes: NOT a React model-setter
// (setCenter/setZoom/setBearing/setPitch are the auto-gen'd ones — none here);
// NOT a Lit lifecycle name (update/render/firstUpdated/updated/willUpdate/
// requestUpdate); NOT an emitted event name (move/zoom/rotate/pitch/drag/click/
// idle/error — getCenter/getZoom/resize/flyTo/easeTo/jumpTo/fitBounds/getMap all
// differ; zoomIn/zoomOut differ from the `zoomend` emit). The camera verbs
// deliberately omit PROGRAMMATIC so an imperative move echoes into $model (the
// prop $watch then no-ops, getCenter already matching).
//
// Camera control is well-covered above; the read/hit-test/projection family
// below is what a consumer needs to build custom controls, overlays, and click
// interactivity — none reachable via prop/model/event:
// - queryRenderedFeatures: hit-test "what's under this pixel/box" (click-to-
// inspect, selection beyond per-layer mouseenter/leave).
// - project / unproject: convert geo<->screen for positioning framework DOM
// overlays over map coordinates.
// - getBounds: read the live visible viewport bbox (lazy-fetch data for the
// current view) — distinct from the construction-only `bounds` prop.
// - zoomIn / zoomOut / panBy: ergonomic nudges for a consumer's own controls.
function getMap() {
return instance;
}
function flyTo(opts: any) {
if (instance) instance.flyTo(opts);
}
function easeTo(opts: any) {
if (instance) instance.easeTo(opts);
}
function jumpTo(opts: any) {
if (instance) instance.jumpTo(opts);
}
function fitBounds(bounds: any, opts: any) {
if (instance) instance.fitBounds(bounds, opts);
}
function getCenter() {
if (!instance) return null;
const c = instance.getCenter();
return [c.lng, c.lat];
}
function getZoom() {
return instance ? instance.getZoom() : null;
}
function resize() {
if (instance) instance.resize();
}
function queryRenderedFeatures(geometry: any, options: any) {
return instance ? instance.queryRenderedFeatures(geometry, options) : [];
}
function project(lngLat: any) {
return instance ? instance.project(lngLat) : null;
}
function unproject(point: any) {
return instance ? instance.unproject(point) : null;
}
function getBounds() {
return instance ? instance.getBounds() : null;
}
function zoomIn(opts: any) {
if (instance) instance.zoomIn(opts);
}
function zoomOut(opts: any) {
if (instance) instance.zoomOut(opts);
}
function panBy(offset: any, opts: any) {
if (instance) instance.panBy(offset, opts);
}
provide('maplibre:sources', {
register: (id: any, spec: any) => {
sourceReg.value = {
...sourceReg.value,
[id]: spec
};
},
update: (id: any, spec: any) => {
sourceReg.value = {
...sourceReg.value,
[id]: spec
};
},
unregister: (id: any) => {
const n = {
...sourceReg.value
};
delete n[id];
sourceReg.value = n;
}
});
provide('maplibre:layers', {
register: (id: any, spec: any) => {
layerReg.value = {
...layerReg.value,
[id]: spec
};
},
update: (id: any, spec: any) => {
layerReg.value = {
...layerReg.value,
[id]: spec
};
},
unregister: (id: any) => {
const n = {
...layerReg.value
};
delete n[id];
layerReg.value = n;
}
});
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalContainers = new Set<HTMLElement>();
const portals = {
marker: (container: HTMLElement, scope: { marker: unknown; index: unknown }): ReactivePortalHandle => {
const slotFn = slots.marker;
if (!slotFn) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection. Cascades the @portal
// marker { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-marker', 'f1ee1082');
const renderScope = (s: unknown): void => {
render(h(Fragment, null, slotFn(s)), container);
};
renderScope(scope);
portalContainers.add(container);
return {
update: (s: unknown): void => renderScope(s),
dispose: (): void => {
render(null, container);
portalContainers.delete(container);
},
};
},
popup: (container: HTMLElement, scope: { popup: unknown; index: unknown }): ReactivePortalHandle => {
const slotFn = slots.popup;
if (!slotFn) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection. Cascades the @portal
// popup { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-popup', 'f1ee1082');
const renderScope = (s: unknown): void => {
render(h(Fragment, null, slotFn(s)), container);
};
renderScope(scope);
portalContainers.add(container);
return {
update: (s: unknown): void => renderScope(s),
dispose: (): void => {
render(null, container);
portalContainers.delete(container);
},
};
},
control: (container: HTMLElement, scope: { map: unknown }): (() => void) => {
const slotFn = slots.control;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// control { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-control', 'f1ee1082');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
};
onBeforeUnmount(() => {
for (const container of portalContainers) render(null, container);
portalContainers.clear();
});
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
const el = containerElRef.value;
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
controlInstances = [];
appliedLayerIds = [];
appliedSourceIds = [];
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
let mapOptions: any = null;
mapOptions = {
container: el,
...props.options,
style: props.mapStyle ?? DEFAULT_STYLE,
center: center.value,
zoom: zoom.value,
bearing: bearing.value,
pitch: pitch.value,
minZoom: props.minZoom,
maxZoom: props.maxZoom,
maxBounds: props.maxBounds,
bounds: props.bounds,
fitBoundsOptions: props.fitBoundsOptions,
dragPan: props.dragPan,
dragRotate: props.dragRotate,
scrollZoom: props.scrollZoom,
doubleClickZoom: props.doubleClickZoom,
boxZoom: props.boxZoom,
keyboard: props.keyboard,
touchZoomRotate: props.touchZoomRotate,
touchPitch: props.touchPitch
};
instance = new maplibregl.Map(mapOptions);
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
instance.on('load', (e: any) => emit('load', e));
instance.on('idle', (e: any) => emit('idle', e));
instance.on('move', (e: any) => emit('move', e));
instance.on('rotate', (e: any) => emit('rotate', e));
instance.on('dragstart', (e: any) => emit('dragstart', e));
instance.on('drag', (e: any) => emit('drag', e));
instance.on('dragend', (e: any) => emit('dragend', e));
instance.on('click', (e: any) => emit('click', payload(e)));
instance.on('dblclick', (e: any) => emit('dblclick', payload(e)));
instance.on('contextmenu', (e: any) => emit('contextmenu', payload(e)));
instance.on('mousemove', (e: any) => emit('mousemove', payload(e)));
instance.on('error', (e: any) => emit('error', e));
instance.on('styledata', (e: any) => emit('styledata', e));
instance.on('sourcedata', (e: any) => emit('sourcedata', e));
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
instance.on('moveend', (e: any) => {
emit('moveend', e);
if (e.rozieProgrammatic) return;
const c = instance.getCenter();
const next = [c.lng, c.lat];
if (!sameCenter(next, center.value)) center.value = next;
const z = instance.getZoom();
if (z !== zoom.value) zoom.value = z;
});
instance.on('zoomend', (e: any) => {
emit('zoomend', e);
if (e.rozieProgrammatic) return;
const z = instance.getZoom();
if (z !== zoom.value) zoom.value = z;
});
instance.on('rotateend', (e: any) => {
emit('rotateend', e);
if (e.rozieProgrammatic) return;
const b = instance.getBearing();
if (b !== bearing.value) bearing.value = b;
});
instance.on('pitchend', (e: any) => {
emit('pitchend', e);
if (e.rozieProgrammatic) return;
const p = instance.getPitch();
if (p !== pitch.value) pitch.value = p;
});
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
reconcileMarkers = (list: any) => {
if (!slots.marker) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((m: any, index: any) => {
if (!m || typeof m.lng !== 'number' || typeof m.lat !== 'number') return;
const key = m.id != null ? m.id : index;
seen.add(key);
const scope = {
marker: m,
index
};
const entry = markerEntries.get(key);
if (entry) {
entry.engine.setLngLat([m.lng, m.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-marker';
const handle = portals.marker(node, scope);
const engine = new maplibregl.Marker({
element: node,
anchor: m.anchor,
offset: m.offset,
draggable: m.draggable
}).setLngLat([m.lng, m.lat]).addTo(instance);
markerEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of markerEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
markerEntries.delete(key);
}
}
};
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
reconcilePopups = (list: any) => {
if (!slots.popup) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((p: any, index: any) => {
if (!p || typeof p.lng !== 'number' || typeof p.lat !== 'number') return;
const key = p.id != null ? p.id : index;
seen.add(key);
const scope = {
popup: p,
index
};
const entry = popupEntries.get(key);
if (entry) {
entry.engine.setLngLat([p.lng, p.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-popup-body';
const handle = portals.popup(node, scope);
const engine = new maplibregl.Popup({
closeButton: p.closeButton !== undefined ? p.closeButton : true,
closeOnClick: p.closeOnClick !== undefined ? p.closeOnClick : false,
anchor: p.anchor,
offset: p.offset
}).setLngLat([p.lng, p.lat]).setDOMContent(node).addTo(instance);
popupEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of popupEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
popupEntries.delete(key);
}
}
};
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
reconcileInteractive = (ids: any) => {
const want = (Array.isArray(ids) ? ids : []).filter(Boolean);
for (const [id, l] of featureListeners as any) {
if (!want.includes(id)) {
instance.off('mouseenter', id, l.enter);
instance.off('mouseleave', id, l.leave);
featureListeners.delete(id);
}
}
for (const id of want as any) {
if (featureListeners.has(id)) continue;
const enter = (e: any) => emit('mouseenter', payload(e));
const leave = (e: any) => emit('mouseleave', payload(e));
instance.on('mouseenter', id, enter);
instance.on('mouseleave', id, leave);
featureListeners.set(id, {
enter,
leave
});
}
};
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
if (slots.control) {
const host = document.createElement('div');
host.className = 'maplibregl-ctrl rozie-maplibre-control';
customControl = {
onAdd() {
return host;
},
onRemove() {
if (host.parentNode) host.parentNode.removeChild(host);
}
};
instance.addControl(customControl, 'top-right');
controlDispose = portals.control(host, {
map: instance
});
}
// standard controls + interaction toggles don't need style load.
applyControls();
applyInteractionToggles();
// markers/popups/interactive are DOM/event overlays — no style-load gate.
reconcileMarkers(props.markers);
reconcilePopups(props.popups);
reconcileInteractive(props.interactiveLayerIds);
// sources/layers need the style loaded.
if (instance.isStyleLoaded()) applyLayers();else instance.on('load', applyLayers);
_cleanup_0 = () => {
for (const [, entry] of markerEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
markerEntries.clear();
for (const [, entry] of popupEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
popupEntries.clear();
if (controlDispose) controlDispose();
if (instance) instance.remove();
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => center.value, (v: any) => {
if (!instance || !Array.isArray(v) || v.length !== 2) return;
const c = instance.getCenter();
if (v[0] === c.lng && v[1] === c.lat) return;
instance.easeTo({
center: v,
animate: false
}, PROGRAMMATIC);
});
watch(() => zoom.value, (v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getZoom()) return;
instance.easeTo({
zoom: v,
animate: false
}, PROGRAMMATIC);
});
watch(() => bearing.value, (v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getBearing()) return;
instance.easeTo({
bearing: v,
animate: false
}, PROGRAMMATIC);
});
watch(() => pitch.value, (v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getPitch()) return;
instance.easeTo({
pitch: v,
animate: false
}, PROGRAMMATIC);
});
watch(() => props.mapStyle, (v: any) => {
if (!instance) return;
// a new style wipes imperatively-added sources/layers — reset the applied
// tracking and re-apply once the new style loads.
appliedLayerIds = [];
appliedSourceIds = [];
instance.setStyle(v ?? DEFAULT_STYLE);
instance.once('styledata', () => applyLayers());
});
watch(() => props.minZoom, (v: any) => {
if (instance && typeof v === 'number') instance.setMinZoom(v);
});
watch(() => props.maxZoom, (v: any) => {
if (instance && typeof v === 'number') instance.setMaxZoom(v);
});
watch(() => props.maxBounds, (v: any) => {
if (instance) instance.setMaxBounds(v || null);
});
watch(() => props.markers, (v: any) => {
if (reconcileMarkers) reconcileMarkers(v);
});
watch(() => props.popups, (v: any) => {
if (reconcilePopups) reconcilePopups(v);
});
watch(() => props.sources, () => applyLayers());
watch(() => props.layers, () => applyLayers());
watch(() => sourceReg.value, () => applyLayers());
watch(() => layerReg.value, () => applyLayers());
watch(() => props.interactiveLayerIds, (v: any) => {
if (reconcileInteractive) reconcileInteractive(v);
});
watch(() => props.controls, () => applyControls());
watch(() => props.dragPan, () => applyInteractionToggles());
watch(() => props.dragRotate, () => applyInteractionToggles());
watch(() => props.scrollZoom, () => applyInteractionToggles());
watch(() => props.doubleClickZoom, () => applyInteractionToggles());
watch(() => props.boxZoom, () => applyInteractionToggles());
watch(() => props.keyboard, () => applyInteractionToggles());
watch(() => props.touchZoomRotate, () => applyInteractionToggles());
watch(() => props.touchPitch, () => applyInteractionToggles());
defineExpose({ getMap, flyTo, easeTo, jumpTo, fitBounds, getCenter, getZoom, resize, queryRenderedFeatures, project, unproject, getBounds, zoomIn, zoomOut, panBy });
</script>
<style scoped>
.rozie-maplibre {
width: 100%;
height: 100%;
min-height: 300px;
position: relative;
overflow: hidden;
border-radius: 6px;
}
</style>
<style>
.rozie-maplibre .rozie-maplibre-marker {
cursor: pointer;
}
.rozie-maplibre .rozie-maplibre-control {
display: flex;
flex-direction: column;
}
</style>svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { mount, unmount } from 'svelte';
import PortalHost from '@rozie/runtime-svelte/PortalHost.svelte';
import PortalHostReactive from '@rozie/runtime-svelte/PortalHostReactive.svelte';
import { onMount, setContext, untrack } from 'svelte';
interface Props {
/**
* The map center as `[lng, lat]` — **longitude first** (MapLibre's convention, not Leaflet's `[lat, lng]`). Two-way: panning the map writes the new center back through the model path (echo-guarded), and a consumer write `easeTo`s the live map. The `moveend` echo reads `getCenter()` as `[lng, lat]`.
* @example
* <MapLibre r-model:center="center" r-model:zoom="zoom" />
*/
center?: any[];
/**
* The zoom level. Two-way: scroll / pinch writes the new zoom back, and a consumer write `easeTo`s the camera. Echo-guarded against the wrapper's own programmatic moves.
*/
zoom?: number;
/**
* The map rotation (bearing) in degrees. Two-way via the `rotateend` echo and the `easeTo` reconcile.
*/
bearing?: number;
/**
* The map tilt (pitch) in degrees. Two-way via the `pitchend` echo and the `easeTo` reconcile.
*/
pitch?: number;
/**
* The map style — a `StyleSpecification` object **or** a style-URL string. Named `mapStyle` (not `style`) because `style` is a reserved attribute across the targets — `react-map-gl` and `vue-maplibre-gl` use the same name for the same reason. Defaults to MapLibre's official no-token demo tiles, so the component "just works" with zero config. Changing it calls `setStyle` and re-applies your `sources` / `layers` once the new style loads.
*/
mapStyle?: unknown;
/**
* Minimum zoom level. Applied at construction and via `setMinZoom` on change.
*/
minZoom?: number;
/**
* Maximum zoom level. Applied at construction and via `setMaxZoom` on change.
*/
maxZoom?: number;
/**
* A `LngLatBoundsLike` the camera is constrained to. Applied via `setMaxBounds` on change (pass `undefined` to clear).
*/
maxBounds?: unknown;
/**
* **Construction-only** initial fit — a `LngLatBoundsLike` the map fits to on mount (overrides `center` / `zoom` when set). Pair with `fitBoundsOptions`.
*/
bounds?: unknown;
/**
* **Construction-only** options for the initial `bounds` fit (padding, max-zoom, etc.).
*/
fitBoundsOptions?: any;
/**
* Toggle drag-to-pan. Applied at construction and reconciled live via the handler's `enable()` / `disable()`.
*/
dragPan?: boolean;
/**
* Toggle right-drag / ctrl-drag rotation. Applied at construction and reconciled live.
*/
dragRotate?: boolean;
/**
* Toggle scroll-wheel zoom. Applied at construction and reconciled live.
*/
scrollZoom?: boolean;
/**
* Toggle double-click zoom. Applied at construction and reconciled live.
*/
doubleClickZoom?: boolean;
/**
* Toggle shift-drag box zoom. Applied at construction and reconciled live.
*/
boxZoom?: boolean;
/**
* Toggle keyboard navigation. Applied at construction and reconciled live.
*/
keyboard?: boolean;
/**
* Toggle touch pinch-zoom + rotate. Applied at construction and reconciled live.
*/
touchZoomRotate?: boolean;
/**
* Toggle two-finger touch pitch. Applied at construction and reconciled live.
*/
touchPitch?: boolean;
/**
* The marker data that drives the reactive multi-instance `marker` slot — one entry per marker (`{ lng, lat, id?, anchor?, offset?, draggable?, ... }`). One portal handle mounts per entry; changing the array reconciles markers keep / update / dispose with no remount. Only meaningful when the `marker` slot is filled.
*/
markers?: any[];
/**
* The popup data that drives the reactive multi-instance `popup` slot — one entry per popup (`{ lng, lat, id?, anchor?, offset?, closeButton?, closeOnClick?, ... }`). One portal handle mounts per entry. Only meaningful when the `popup` slot is filled.
*/
popups?: any[];
/**
* Declarative GeoJSON / vector / raster sources — `[{ id, spec }]` (or a bare `SourceSpecification` carrying an `id`). Reconciled into the live style (add / `setData` / remove) once the style has loaded. The config-array authoring shape for sources; declarative `<Source>` / `<Layer>` children are the alternative shape (both feed the same registry).
*/
sources?: any[];
/**
* Declarative layers — `LayerSpecification[]` (each with an `id`). Reconciled into the live style (add / `setPaintProperty` / `setLayoutProperty` / remove) once the style has loaded; `beforeId` controls draw order.
*/
layers?: any[];
/**
* Layer ids whose feature `mouseenter` / `mouseleave` fire the `@mouseenter` / `@mouseleave` events (populating `e.features`). Registered / unregistered per id on change.
*/
interactiveLayerIds?: any[];
/**
* Standard map controls — strings (`'navigation'` / `'geolocate'` / `'scale'` / `'fullscreen'` / `'attribution'`) or `{ type, position?, options? }` objects. Reconciled (remove-all + re-add) on change.
*/
controls?: any[];
/**
* The raw `MapOptions` passthrough — spread into the `Map` constructor **before** the curated keys, so explicit props win. The MapLibre analog of an options bag for anything the curated surface doesn't special-case.
*/
options?: any;
children?: Snippet;
marker?: Snippet<[{ marker: any; index: any }]>;
popup?: Snippet<[{ popup: any; index: any }]>;
control?: Snippet<[{ map: any }]>;
snippets?: Record<string, any>;
onload?: (...args: unknown[]) => void;
onidle?: (...args: unknown[]) => void;
onmove?: (...args: unknown[]) => void;
onrotate?: (...args: unknown[]) => void;
ondragstart?: (...args: unknown[]) => void;
ondrag?: (...args: unknown[]) => void;
ondragend?: (...args: unknown[]) => void;
onclick?: (...args: unknown[]) => void;
ondblclick?: (...args: unknown[]) => void;
oncontextmenu?: (...args: unknown[]) => void;
onmousemove?: (...args: unknown[]) => void;
onerror?: (...args: unknown[]) => void;
onstyledata?: (...args: unknown[]) => void;
onsourcedata?: (...args: unknown[]) => void;
onmoveend?: (...args: unknown[]) => void;
onzoomend?: (...args: unknown[]) => void;
onrotateend?: (...args: unknown[]) => void;
onpitchend?: (...args: unknown[]) => void;
onmouseenter?: (...args: unknown[]) => void;
onmouseleave?: (...args: unknown[]) => void;
}
let __defaultFitBoundsOptions = (() => ({}))();
let __defaultMarkers = (() => [])();
let __defaultPopups = (() => [])();
let __defaultSources = (() => [])();
let __defaultLayers = (() => [])();
let __defaultInteractiveLayerIds = (() => [])();
let __defaultControls = (() => [])();
let __defaultOptions = (() => ({}))();
let {
center = $bindable((() => [0, 0])()),
zoom = $bindable(1),
bearing = $bindable(0),
pitch = $bindable(0),
mapStyle = undefined,
minZoom = 0,
maxZoom = 22,
maxBounds = undefined,
bounds = undefined,
fitBoundsOptions = __defaultFitBoundsOptions,
dragPan = true,
dragRotate = true,
scrollZoom = true,
doubleClickZoom = true,
boxZoom = true,
keyboard = true,
touchZoomRotate = true,
touchPitch = true,
markers = __defaultMarkers,
popups = __defaultPopups,
sources = __defaultSources,
layers = __defaultLayers,
interactiveLayerIds = __defaultInteractiveLayerIds,
controls = __defaultControls,
options = __defaultOptions,
children: __childrenProp,
marker: __markerProp,
popup: __popupProp,
control: __controlProp,
snippets,
onload,
onidle,
onmove,
onrotate,
ondragstart,
ondrag,
ondragend,
onclick,
ondblclick,
oncontextmenu,
onmousemove,
onerror,
onstyledata,
onsourcedata,
onmoveend,
onzoomend,
onrotateend,
onpitchend,
onmouseenter,
onmouseleave
}: Props = $props();
const children = $derived(__childrenProp ?? snippets?.children);
const marker = $derived(__markerProp ?? snippets?.marker);
const popup = $derived(__popupProp ?? snippets?.popup);
const control = $derived(__controlProp ?? snippets?.control);
let sourceReg = $state({});
let layerReg = $state({});
let containerEl = $state<HTMLElement | undefined>(undefined);
import maplibregl from 'maplibre-gl';
let instance: any = null;
// MapLibre's official no-token demo tiles — the zero-config `mapStyle` fallback
// (the prop default is `undefined`; see the prop note).
// MapLibre's official no-token demo tiles — the zero-config `mapStyle` fallback
// (the prop default is `undefined`; see the prop note).
const DEFAULT_STYLE = 'https://demotiles.maplibre.org/style.json';
// The eventData merged onto programmatic camera ops so the camera-lifecycle echo
// handlers can ignore our own moves (the documented MapLibre echo-guard — robust
// across batched ops where Leaflet's single boolean would race).
// The eventData merged onto programmatic camera ops so the camera-lifecycle echo
// handlers can ignore our own moves (the documented MapLibre echo-guard — robust
// across batched ops where Leaflet's single boolean would race).
const PROGRAMMATIC = {
rozieProgrammatic: true
};
// Live entry maps for the REACTIVE MULTI-INSTANCE portal slots — keyed by
// entry.id ?? index. Each value: { engine, handle, el }. COMPONENT-scope (not
// $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — keeps them in scope.
// Live entry maps for the REACTIVE MULTI-INSTANCE portal slots — keyed by
// entry.id ?? index. Each value: { engine, handle, el }. COMPONENT-scope (not
// $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — keeps them in scope.
const markerEntries = new Map();
const popupEntries = new Map();
// ─── declarative-children registry (Phase 37 $provide/$inject dogfood) ───────
// Publish the source/layer register-API the <Source>/<Layer> children $inject and
// self-register into. EVERY method uses WHOLE-OBJECT REPLACEMENT (spread / clone-
// and-delete) so the watched $data.sourceReg/$data.layerReg reference changes once
// per mutation and the parent $watch fires on all 6 targets (D-3 / Pitfall 1 — an
// in-place `$data.sourceReg[id] = spec` is silent on React/Solid/Angular/Lit). The
// register surface mirrors the SHIPPED Tabs.rozie $provide('tabs', { … }) shape;
// register/update share a body (both upsert by id). The values feed the SAME
// applyLayers() reconcile + appliedSourceIds/appliedLayerIds provenance as the
// config-array props, so registry-managed sources/layers are reaped on unregister
// exactly like prop-managed ones (D37-08).
// standard-control instances (so a controls-prop change can remove + re-add) and
// the mount-once custom-control portal dispose. controlInstances is a null-let
// (→ typeNeutralize `any`) initialized to [] in $onMount: a bare `let x = []`
// infers `never[]` under the strict framework-typecheck harness and rejects the
// `any` control instances pushed into it.
let controlInstances: any = null;
let controlDispose: any = null;
let customControl: any = null;
// layer-scoped feature listeners, registered per interactiveLayerId so they can
// be unregistered on change. id → { enter, leave }.
// layer-scoped feature listeners, registered per interactiveLayerId so they can
// be unregistered on change. id → { enter, leave }.
const featureListeners = new Map();
// previously-applied source/layer ids (null-lets → `any`, [] in $onMount; same
// never[] reason as controlInstances) so a sources/layers prop change can remove
// the dropped ones.
// previously-applied source/layer ids (null-lets → `any`, [] in $onMount; same
// never[] reason as controlInstances) so a sources/layers prop change can remove
// the dropped ones.
let appliedLayerIds: any = null;
let appliedSourceIds: any = null;
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount (a top-level
// $portals reference fails the bundled-leaf strict typecheck — the CM/TipTap
// portal discipline) and bridged here so the top-level $watch can call them.
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount (a top-level
// $portals reference fails the bundled-leaf strict typecheck — the CM/TipTap
// portal discipline) and bridged here so the top-level $watch can call them.
let reconcileMarkers: any = null;
let reconcilePopups: any = null;
let reconcileInteractive: any = null;
// ─── pure helpers (no sigils → safe at top level) ───────────────────────────
// ─── pure helpers (no sigils → safe at top level) ───────────────────────────
const sameCenter = (a: any, b: any) => Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
// structured pointer-event payload — stable across targets, avoids handing the
// raw engine event (with its circular `target: Map`) to consumers.
// structured pointer-event payload — stable across targets, avoids handing the
// raw engine event (with its circular `target: Map`) to consumers.
const payload = (e: any) => ({
lngLat: e.lngLat ? {
lng: e.lngLat.lng,
lat: e.lngLat.lat
} : null,
point: e.point ? {
x: e.point.x,
y: e.point.y
} : null,
features: e.features || [],
originalEvent: e.originalEvent
});
const buildControl = (spec: any) => {
const type = typeof spec === 'string' ? spec : spec.type;
const opts = typeof spec === 'object' && spec.options || {};
if (type === 'navigation') return new maplibregl.NavigationControl(opts);
if (type === 'geolocate') return new maplibregl.GeolocateControl(opts);
if (type === 'scale') return new maplibregl.ScaleControl(opts);
if (type === 'fullscreen') return new maplibregl.FullscreenControl(opts);
if (type === 'attribution') return new maplibregl.AttributionControl(opts);
return null;
};
// Standard controls reconcile — no $portals/$emit, so top-level. Remove-all +
// re-add from the config (controls rarely change; cheap and order-correct).
// Standard controls reconcile — no $portals/$emit, so top-level. Remove-all +
// re-add from the config (controls rarely change; cheap and order-correct).
const applyControls = () => {
if (!instance) return;
for (const c of controlInstances as any) instance.removeControl(c);
controlInstances = [];
for (const spec of controls as any) {
if (!spec) continue;
const ctrl = buildControl(spec);
if (!ctrl) continue;
const position = typeof spec === 'object' && spec.position || undefined;
instance.addControl(ctrl, position);
controlInstances.push(ctrl);
}
};
// Interaction-toggle reconcile — each toggle maps to a runtime handler object.
// Interaction-toggle reconcile — each toggle maps to a runtime handler object.
const applyInteractionToggles = () => {
if (!instance) return;
const set = (name: any, on: any) => {
const handler = instance[name];
if (handler) on ? handler.enable() : handler.disable();
};
set('dragPan', dragPan);
set('dragRotate', dragRotate);
set('scrollZoom', scrollZoom);
set('doubleClickZoom', doubleClickZoom);
set('boxZoom', boxZoom);
set('keyboard', keyboard);
set('touchZoomRotate', touchZoomRotate);
set('touchPitch', touchPitch);
};
// Style-load-gated source/layer reconcile. Order matters: drop removed layers
// FIRST, then add/update sources, then add/update layers, then drop removed
// sources (after their layers are gone).
// Style-load-gated source/layer reconcile. Order matters: drop removed layers
// FIRST, then add/update sources, then add/update layers, then drop removed
// sources (after their layers are gone).
const applyLayers = () => {
if (!instance || !instance.isStyleLoaded()) return;
// ─── union the config-array props with the declarative-children registry ────
// (registry ∪ props), keyed by id. D-02: the registry (declarative children) is
// the LAST writer and overrides the config-array on id collision. Ordering: array
// entries first in array order, then registry entries in registration order —
// `[...$props.layers, ...registryLayers]` — each still honoring its explicit
// `beforeId` (the existing applyLayers ordering contract, REUSED unchanged,
// RESEARCH OQ3). The empty-registry path is byte-equivalent to today: with both
// registries empty, mergeById returns exactly the config array (dedup by id of
// an array with no registry overrides is the array itself), so (∅ ∪ props) ===
// props in behavior — the dist-parity zero-drift guarantee (RESEARCH A3).
const mergeById = (arr: any, reg: any) => {
// out seeded from the (any-typed) input so strict tsc infers any[] not never[]
// (untyped <script> can't use a TS `: any[]`/`as any[]` annotation; .slice(0,0)
// yields an empty array with identical runtime behavior to `const out = []`).
const out = (Array.isArray(arr) ? arr : []).slice(0, 0);
const idx = new Map();
for (const e of (Array.isArray(arr) ? arr : []) as any) {
if (!e || !e.id) {
out.push(e);
continue;
}
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
for (const id in reg) {
const e = reg[id];
if (!e || !e.id) continue;
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
return out;
};
const mergedSources = mergeById(sources, sourceReg);
const mergedLayers = mergeById(layers, layerReg);
const wantLayerIds = mergedLayers.map((l: any) => l && l.id).filter(Boolean);
const wantSourceIds = mergedSources.map((s: any) => s && s.id).filter(Boolean);
// 1. drop removed layers
for (const id of appliedLayerIds as any) {
if (!wantLayerIds.includes(id) && instance.getLayer(id)) instance.removeLayer(id);
}
// 2. add/update sources
for (const s of mergedSources as any) {
if (!s || !s.id) continue;
const spec = s.spec || s;
const existing = instance.getSource(s.id);
if (!existing) instance.addSource(s.id, $state.snapshot(spec));else if (spec.type === 'geojson' && spec.data) existing.setData($state.snapshot(spec.data));
}
// 3. add/update layers. DEFENSIVE: a non-background layer whose `source` is not
// (yet) present in the engine is SKIPPED rather than added — a declarative
// <Layer> may register before its <Source> parent has supplied the source id
// (child-before-parent mount order on React/Vue/Svelte/Angular), in which case
// addLayer would throw "source ... doesn't exist" / read null `.type` and abort
// the whole loop (dropping later layers like `bg`). The <Layer> re-registers with
// the resolved source on $onUpdate, re-running this reconcile, so the layer lands
// on the next tick. Background layers need no source. addLayer is wrapped so any
// single malformed spec can't abort the rest of the loop either.
for (const l of mergedLayers as any) {
if (!l || !l.id) continue;
if (!instance.getLayer(l.id)) {
const needsSource = l.type !== 'background';
if (needsSource && (l.source == null || !instance.getSource(l.source))) continue;
// Build a CLEAN LayerSpecification: a declarative <Layer> registry spec carries
// a `beforeId` (not a LayerSpecification key — it is the addLayer 2nd arg) and
// explicit `source: undefined` / `layout: undefined` keys (the prop defaults).
// MapLibre v5 rejects a background layer that has ANY `source` key, and an
// undefined `layout` — so emit only the keys MapLibre expects (the config-array
// path is unaffected: those specs are already clean, this just re-emits them).
// null-let → typeNeutralize `any` so the dynamic key assignments below
// type-check on the strict bundled leaves (the `let x = null` idiom).
let clean: any = null;
clean = {
id: l.id,
type: l.type
};
if (needsSource) clean.source = l.source;
if (l.paint != null) clean.paint = l.paint;
if (l.layout != null) clean.layout = l.layout;
if (l.sourceLayer != null) clean['source-layer'] = l.sourceLayer;
if (l.filter != null) clean.filter = l.filter;
if (l.minzoom != null) clean.minzoom = l.minzoom;
if (l.maxzoom != null) clean.maxzoom = l.maxzoom;
try {
instance.addLayer($state.snapshot(clean), l.beforeId);
} catch (e: any) {
// surfaced via the `error` emit path; skip so later layers still apply.
}
} else {
if (l.paint) for (const k in l.paint) instance.setPaintProperty(l.id, k, l.paint[k]);
if (l.layout) for (const k in l.layout) instance.setLayoutProperty(l.id, k, l.layout[k]);
}
}
// 4. drop removed sources (their layers are gone)
for (const id of appliedSourceIds as any) {
if (!wantSourceIds.includes(id) && instance.getSource(id)) instance.removeSource(id);
}
appliedLayerIds = wantLayerIds;
appliedSourceIds = wantSourceIds;
};
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 15 verbs. Collision-clear across all 3 classes: NOT a React model-setter
// (setCenter/setZoom/setBearing/setPitch are the auto-gen'd ones — none here);
// NOT a Lit lifecycle name (update/render/firstUpdated/updated/willUpdate/
// requestUpdate); NOT an emitted event name (move/zoom/rotate/pitch/drag/click/
// idle/error — getCenter/getZoom/resize/flyTo/easeTo/jumpTo/fitBounds/getMap all
// differ; zoomIn/zoomOut differ from the `zoomend` emit). The camera verbs
// deliberately omit PROGRAMMATIC so an imperative move echoes into $model (the
// prop $watch then no-ops, getCenter already matching).
//
// Camera control is well-covered above; the read/hit-test/projection family
// below is what a consumer needs to build custom controls, overlays, and click
// interactivity — none reachable via prop/model/event:
// - queryRenderedFeatures: hit-test "what's under this pixel/box" (click-to-
// inspect, selection beyond per-layer mouseenter/leave).
// - project / unproject: convert geo<->screen for positioning framework DOM
// overlays over map coordinates.
// - getBounds: read the live visible viewport bbox (lazy-fetch data for the
// current view) — distinct from the construction-only `bounds` prop.
// - zoomIn / zoomOut / panBy: ergonomic nudges for a consumer's own controls.
export function getMap() {
return instance;
}
export function flyTo(opts: any) {
if (instance) instance.flyTo(opts);
}
export function easeTo(opts: any) {
if (instance) instance.easeTo(opts);
}
export function jumpTo(opts: any) {
if (instance) instance.jumpTo(opts);
}
export function fitBounds(bounds: any, opts: any) {
if (instance) instance.fitBounds(bounds, opts);
}
export function getCenter() {
if (!instance) return null;
const c = instance.getCenter();
return [c.lng, c.lat];
}
export function getZoom() {
return instance ? instance.getZoom() : null;
}
export function resize() {
if (instance) instance.resize();
}
export function queryRenderedFeatures(geometry: any, options: any) {
return instance ? instance.queryRenderedFeatures(geometry, options) : [];
}
export function project(lngLat: any) {
return instance ? instance.project(lngLat) : null;
}
export function unproject(point: any) {
return instance ? instance.unproject(point) : null;
}
export function getBounds() {
return instance ? instance.getBounds() : null;
}
export function zoomIn(opts: any) {
if (instance) instance.zoomIn(opts);
}
export function zoomOut(opts: any) {
if (instance) instance.zoomOut(opts);
}
export function panBy(offset: any, opts: any) {
if (instance) instance.panBy(offset, opts);
}
setContext('maplibre:sources', {
register: (id: any, spec: any) => {
sourceReg = {
...sourceReg,
[id]: spec
};
},
update: (id: any, spec: any) => {
sourceReg = {
...sourceReg,
[id]: spec
};
},
unregister: (id: any) => {
const n = {
...sourceReg
};
delete n[id];
sourceReg = n;
}
});
setContext('maplibre:layers', {
register: (id: any, spec: any) => {
layerReg = {
...layerReg,
[id]: spec
};
},
update: (id: any, spec: any) => {
layerReg = {
...layerReg,
[id]: spec
};
},
unregister: (id: any) => {
const n = {
...layerReg
};
delete n[id];
layerReg = n;
}
});
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalInstances = new Set<Record<string, unknown>>();
const portals = {
marker: (container: HTMLElement, scope: { marker: unknown; index: unknown }): ReactivePortalHandle => {
if (!marker) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-marker', 'f1ee1082');
const inst = mount(PortalHostReactive, {
target: container,
props: { snippet: marker, initialScope: scope },
});
portalInstances.add(inst as Record<string, unknown>);
return {
update: (s: unknown): void => {
(inst as unknown as { update(s: unknown): void }).update(s);
},
dispose: (): void => {
unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.delete(inst as Record<string, unknown>);
},
};
},
popup: (container: HTMLElement, scope: { popup: unknown; index: unknown }): ReactivePortalHandle => {
if (!popup) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-popup', 'f1ee1082');
const inst = mount(PortalHostReactive, {
target: container,
props: { snippet: popup, initialScope: scope },
});
portalInstances.add(inst as Record<string, unknown>);
return {
update: (s: unknown): void => {
(inst as unknown as { update(s: unknown): void }).update(s);
},
dispose: (): void => {
unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.delete(inst as Record<string, unknown>);
},
};
},
control: (container: HTMLElement, scope: { map: unknown }): (() => void) => {
if (!control) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-control', 'f1ee1082');
const inst = mount(PortalHost, {
target: container,
props: { snippet: control, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
};
$effect(() => () => {
for (const inst of portalInstances) unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.clear();
});
onMount(() => {
const el = containerEl;
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
controlInstances = [];
appliedLayerIds = [];
appliedSourceIds = [];
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
let mapOptions: any = null;
mapOptions = {
container: el,
...$state.snapshot(options),
style: $state.snapshot(mapStyle) ?? DEFAULT_STYLE,
center: center,
zoom: zoom,
bearing: bearing,
pitch: pitch,
minZoom: minZoom,
maxZoom: maxZoom,
maxBounds: $state.snapshot(maxBounds),
bounds: $state.snapshot(bounds),
fitBoundsOptions: $state.snapshot(fitBoundsOptions),
dragPan: dragPan,
dragRotate: dragRotate,
scrollZoom: scrollZoom,
doubleClickZoom: doubleClickZoom,
boxZoom: boxZoom,
keyboard: keyboard,
touchZoomRotate: touchZoomRotate,
touchPitch: touchPitch
};
instance = new maplibregl.Map(mapOptions);
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
instance.on('load', (e: any) => onload?.(e));
instance.on('idle', (e: any) => onidle?.(e));
instance.on('move', (e: any) => onmove?.(e));
instance.on('rotate', (e: any) => onrotate?.(e));
instance.on('dragstart', (e: any) => ondragstart?.(e));
instance.on('drag', (e: any) => ondrag?.(e));
instance.on('dragend', (e: any) => ondragend?.(e));
instance.on('click', (e: any) => onclick?.(payload(e)));
instance.on('dblclick', (e: any) => ondblclick?.(payload(e)));
instance.on('contextmenu', (e: any) => oncontextmenu?.(payload(e)));
instance.on('mousemove', (e: any) => onmousemove?.(payload(e)));
instance.on('error', (e: any) => onerror?.(e));
instance.on('styledata', (e: any) => onstyledata?.(e));
instance.on('sourcedata', (e: any) => onsourcedata?.(e));
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
instance.on('moveend', (e: any) => {
onmoveend?.(e);
if (e.rozieProgrammatic) return;
const c = instance.getCenter();
const next = [c.lng, c.lat];
if (!sameCenter(next, center)) center = next;
const z = instance.getZoom();
if (z !== zoom) zoom = z;
});
instance.on('zoomend', (e: any) => {
onzoomend?.(e);
if (e.rozieProgrammatic) return;
const z = instance.getZoom();
if (z !== zoom) zoom = z;
});
instance.on('rotateend', (e: any) => {
onrotateend?.(e);
if (e.rozieProgrammatic) return;
const b = instance.getBearing();
if (b !== bearing) bearing = b;
});
instance.on('pitchend', (e: any) => {
onpitchend?.(e);
if (e.rozieProgrammatic) return;
const p = instance.getPitch();
if (p !== pitch) pitch = p;
});
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
reconcileMarkers = (list: any) => {
if (!marker) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((m: any, index: any) => {
if (!m || typeof m.lng !== 'number' || typeof m.lat !== 'number') return;
const key = m.id != null ? m.id : index;
seen.add(key);
const scope = {
marker: m,
index
};
const entry = markerEntries.get(key);
if (entry) {
entry.engine.setLngLat([m.lng, m.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-marker';
const handle = portals.marker(node, scope);
const engine = new maplibregl.Marker({
element: node,
anchor: m.anchor,
offset: m.offset,
draggable: m.draggable
}).setLngLat([m.lng, m.lat]).addTo(instance);
markerEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of markerEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
markerEntries.delete(key);
}
}
};
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
reconcilePopups = (list: any) => {
if (!popup) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((p: any, index: any) => {
if (!p || typeof p.lng !== 'number' || typeof p.lat !== 'number') return;
const key = p.id != null ? p.id : index;
seen.add(key);
const scope = {
popup: p,
index
};
const entry = popupEntries.get(key);
if (entry) {
entry.engine.setLngLat([p.lng, p.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-popup-body';
const handle = portals.popup(node, scope);
const engine = new maplibregl.Popup({
closeButton: p.closeButton !== undefined ? p.closeButton : true,
closeOnClick: p.closeOnClick !== undefined ? p.closeOnClick : false,
anchor: p.anchor,
offset: p.offset
}).setLngLat([p.lng, p.lat]).setDOMContent(node).addTo(instance);
popupEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of popupEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
popupEntries.delete(key);
}
}
};
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
reconcileInteractive = (ids: any) => {
const want = (Array.isArray(ids) ? ids : []).filter(Boolean);
for (const [id, l] of featureListeners as any) {
if (!want.includes(id)) {
instance.off('mouseenter', id, l.enter);
instance.off('mouseleave', id, l.leave);
featureListeners.delete(id);
}
}
for (const id of want as any) {
if (featureListeners.has(id)) continue;
const enter = (e: any) => onmouseenter?.(payload(e));
const leave = (e: any) => onmouseleave?.(payload(e));
instance.on('mouseenter', id, enter);
instance.on('mouseleave', id, leave);
featureListeners.set(id, {
enter,
leave
});
}
};
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
if (control) {
const host = document.createElement('div');
host.className = 'maplibregl-ctrl rozie-maplibre-control';
customControl = {
onAdd() {
return host;
},
onRemove() {
if (host.parentNode) host.parentNode.removeChild(host);
}
};
instance.addControl(customControl, 'top-right');
controlDispose = portals.control(host, {
map: instance
});
}
// standard controls + interaction toggles don't need style load.
applyControls();
applyInteractionToggles();
// markers/popups/interactive are DOM/event overlays — no style-load gate.
reconcileMarkers(markers);
reconcilePopups(popups);
reconcileInteractive(interactiveLayerIds);
// sources/layers need the style loaded.
if (instance.isStyleLoaded()) applyLayers();else instance.on('load', applyLayers);
return () => {
for (const [, entry] of markerEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
markerEntries.clear();
for (const [, entry] of popupEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
popupEntries.clear();
if (controlDispose) controlDispose();
if (instance) instance.remove();
};
});
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => center)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!instance || !Array.isArray(v) || v.length !== 2) return;
const c = instance.getCenter();
if (v[0] === c.lng && v[1] === c.lat) return;
instance.easeTo({
center: v,
animate: false
}, PROGRAMMATIC);
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => zoom)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getZoom()) return;
instance.easeTo({
zoom: v,
animate: false
}, PROGRAMMATIC);
})(__watchVal); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { const __watchVal = (() => bearing)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } ((v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getBearing()) return;
instance.easeTo({
bearing: v,
animate: false
}, PROGRAMMATIC);
})(__watchVal); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => pitch)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getPitch()) return;
instance.easeTo({
pitch: v,
animate: false
}, PROGRAMMATIC);
})(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => mapStyle)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((v: any) => {
if (!instance) return;
// a new style wipes imperatively-added sources/layers — reset the applied
// tracking and re-apply once the new style loads.
appliedLayerIds = [];
appliedSourceIds = [];
instance.setStyle($state.snapshot(v) ?? DEFAULT_STYLE);
instance.once('styledata', () => applyLayers());
})(__watchVal); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => minZoom)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((v: any) => {
if (instance && typeof v === 'number') instance.setMinZoom(v);
})(__watchVal); }); });
let __rozieWatchInitial_6 = true;
$effect(() => { const __watchVal = (() => maxZoom)(); untrack(() => { if (__rozieWatchInitial_6) { __rozieWatchInitial_6 = false; return; } ((v: any) => {
if (instance && typeof v === 'number') instance.setMaxZoom(v);
})(__watchVal); }); });
let __rozieWatchInitial_7 = true;
$effect(() => { const __watchVal = (() => maxBounds)(); untrack(() => { if (__rozieWatchInitial_7) { __rozieWatchInitial_7 = false; return; } ((v: any) => {
if (instance) instance.setMaxBounds($state.snapshot(v) || null);
})(__watchVal); }); });
let __rozieWatchInitial_8 = true;
$effect(() => { const __watchVal = (() => markers)(); untrack(() => { if (__rozieWatchInitial_8) { __rozieWatchInitial_8 = false; return; } ((v: any) => {
if (reconcileMarkers) reconcileMarkers(v);
})(__watchVal); }); });
let __rozieWatchInitial_9 = true;
$effect(() => { const __watchVal = (() => popups)(); untrack(() => { if (__rozieWatchInitial_9) { __rozieWatchInitial_9 = false; return; } ((v: any) => {
if (reconcilePopups) reconcilePopups(v);
})(__watchVal); }); });
let __rozieWatchInitial_10 = true;
$effect(() => { (() => sources)(); untrack(() => { if (__rozieWatchInitial_10) { __rozieWatchInitial_10 = false; return; } (() => applyLayers())(); }); });
let __rozieWatchInitial_11 = true;
$effect(() => { (() => layers)(); untrack(() => { if (__rozieWatchInitial_11) { __rozieWatchInitial_11 = false; return; } (() => applyLayers())(); }); });
let __rozieWatchInitial_12 = true;
$effect(() => { (() => sourceReg)(); untrack(() => { if (__rozieWatchInitial_12) { __rozieWatchInitial_12 = false; return; } (() => applyLayers())(); }); });
let __rozieWatchInitial_13 = true;
$effect(() => { (() => layerReg)(); untrack(() => { if (__rozieWatchInitial_13) { __rozieWatchInitial_13 = false; return; } (() => applyLayers())(); }); });
let __rozieWatchInitial_14 = true;
$effect(() => { const __watchVal = (() => interactiveLayerIds)(); untrack(() => { if (__rozieWatchInitial_14) { __rozieWatchInitial_14 = false; return; } ((v: any) => {
if (reconcileInteractive) reconcileInteractive(v);
})(__watchVal); }); });
let __rozieWatchInitial_15 = true;
$effect(() => { (() => controls)(); untrack(() => { if (__rozieWatchInitial_15) { __rozieWatchInitial_15 = false; return; } (() => applyControls())(); }); });
let __rozieWatchInitial_16 = true;
$effect(() => { (() => dragPan)(); untrack(() => { if (__rozieWatchInitial_16) { __rozieWatchInitial_16 = false; return; } (() => applyInteractionToggles())(); }); });
let __rozieWatchInitial_17 = true;
$effect(() => { (() => dragRotate)(); untrack(() => { if (__rozieWatchInitial_17) { __rozieWatchInitial_17 = false; return; } (() => applyInteractionToggles())(); }); });
let __rozieWatchInitial_18 = true;
$effect(() => { (() => scrollZoom)(); untrack(() => { if (__rozieWatchInitial_18) { __rozieWatchInitial_18 = false; return; } (() => applyInteractionToggles())(); }); });
let __rozieWatchInitial_19 = true;
$effect(() => { (() => doubleClickZoom)(); untrack(() => { if (__rozieWatchInitial_19) { __rozieWatchInitial_19 = false; return; } (() => applyInteractionToggles())(); }); });
let __rozieWatchInitial_20 = true;
$effect(() => { (() => boxZoom)(); untrack(() => { if (__rozieWatchInitial_20) { __rozieWatchInitial_20 = false; return; } (() => applyInteractionToggles())(); }); });
let __rozieWatchInitial_21 = true;
$effect(() => { (() => keyboard)(); untrack(() => { if (__rozieWatchInitial_21) { __rozieWatchInitial_21 = false; return; } (() => applyInteractionToggles())(); }); });
let __rozieWatchInitial_22 = true;
$effect(() => { (() => touchZoomRotate)(); untrack(() => { if (__rozieWatchInitial_22) { __rozieWatchInitial_22 = false; return; } (() => applyInteractionToggles())(); }); });
let __rozieWatchInitial_23 = true;
$effect(() => { (() => touchPitch)(); untrack(() => { if (__rozieWatchInitial_23) { __rozieWatchInitial_23 = false; return; } (() => applyInteractionToggles())(); }); });
</script>
<div class="rozie-maplibre" bind:this={containerEl} data-rozie-s-f1ee1082></div>{@render children?.()}
<style>
:global {
.rozie-maplibre[data-rozie-s-f1ee1082] {
width: 100%;
height: 100%;
min-height: 300px;
position: relative;
overflow: hidden;
border-radius: 6px;
}
}
:global {
.rozie-maplibre .rozie-maplibre-marker {
cursor: pointer;
}
.rozie-maplibre .rozie-maplibre-control {
display: flex;
flex-direction: column;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, InjectionToken, TemplateRef, ViewContainerRef, ViewEncapsulation, contentChild, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import maplibregl from 'maplibre-gl';
interface DefaultCtx {}
interface MarkerCtx {
$implicit: { marker: any; index: any };
marker: any;
index: any;
}
interface PopupCtx {
$implicit: { popup: any; index: any };
popup: any;
index: any;
}
interface ControlCtx {
$implicit: { map: any };
map: any;
}
const __rozieTokenRegistry: Map<string, InjectionToken<unknown>> =
((globalThis as Record<string, unknown>).__rozieCtx ??= new Map()) as Map<
string,
InjectionToken<unknown>
>;
function rozieToken(key: string): InjectionToken<unknown> {
let token = __rozieTokenRegistry.get(key);
if (!token) {
token = new InjectionToken<unknown>('rozie:' + key);
__rozieTokenRegistry.set(key, token);
}
return token;
}
@Component({
selector: 'rozie-map-libre',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<div class="rozie-maplibre" #containerEl></div>
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot'])" />
<ng-container #rozie_portalAnchor></ng-container>
`,
styles: [`
.rozie-maplibre {
width: 100%;
height: 100%;
min-height: 300px;
position: relative;
overflow: hidden;
border-radius: 6px;
}
::ng-deep .rozie-maplibre .rozie-maplibre-marker {
cursor: pointer;
}
::ng-deep .rozie-maplibre .rozie-maplibre-control {
display: flex;
flex-direction: column;
}
`],
providers: [
{
provide: rozieToken('maplibre:sources'),
useFactory: () => { const __rozieCtxHost = inject(forwardRef(() => MapLibre)); return ({
register: (id: any, spec: any) => {
__rozieCtxHost.sourceReg.set({
...__rozieCtxHost.sourceReg(),
[id]: spec
});
},
update: (id: any, spec: any) => {
__rozieCtxHost.sourceReg.set({
...__rozieCtxHost.sourceReg(),
[id]: spec
});
},
unregister: (id: any) => {
const n = {
...__rozieCtxHost.sourceReg()
};
delete n[id];
__rozieCtxHost.sourceReg.set(n);
}
}); },
},
{
provide: rozieToken('maplibre:layers'),
useFactory: () => { const __rozieCtxHost = inject(forwardRef(() => MapLibre)); return ({
register: (id: any, spec: any) => {
__rozieCtxHost.layerReg.set({
...__rozieCtxHost.layerReg(),
[id]: spec
});
},
update: (id: any, spec: any) => {
__rozieCtxHost.layerReg.set({
...__rozieCtxHost.layerReg(),
[id]: spec
});
},
unregister: (id: any) => {
const n = {
...__rozieCtxHost.layerReg()
};
delete n[id];
__rozieCtxHost.layerReg.set(n);
}
}); },
},
],
})
export class MapLibre {
/**
* The map center as `[lng, lat]` — **longitude first** (MapLibre's convention, not Leaflet's `[lat, lng]`). Two-way: panning the map writes the new center back through the model path (echo-guarded), and a consumer write `easeTo`s the live map. The `moveend` echo reads `getCenter()` as `[lng, lat]`.
* @example
* <MapLibre r-model:center="center" r-model:zoom="zoom" />
*/
center = model<any[]>((() => [0, 0])());
/**
* The zoom level. Two-way: scroll / pinch writes the new zoom back, and a consumer write `easeTo`s the camera. Echo-guarded against the wrapper's own programmatic moves.
*/
zoom = model<number>(1);
/**
* The map rotation (bearing) in degrees. Two-way via the `rotateend` echo and the `easeTo` reconcile.
*/
bearing = model<number>(0);
/**
* The map tilt (pitch) in degrees. Two-way via the `pitchend` echo and the `easeTo` reconcile.
*/
pitch = model<number>(0);
/**
* The map style — a `StyleSpecification` object **or** a style-URL string. Named `mapStyle` (not `style`) because `style` is a reserved attribute across the targets — `react-map-gl` and `vue-maplibre-gl` use the same name for the same reason. Defaults to MapLibre's official no-token demo tiles, so the component "just works" with zero config. Changing it calls `setStyle` and re-applies your `sources` / `layers` once the new style loads.
*/
mapStyle = input<unknown>(undefined);
/**
* Minimum zoom level. Applied at construction and via `setMinZoom` on change.
*/
minZoom = input<number>(0);
/**
* Maximum zoom level. Applied at construction and via `setMaxZoom` on change.
*/
maxZoom = input<number>(22);
/**
* A `LngLatBoundsLike` the camera is constrained to. Applied via `setMaxBounds` on change (pass `undefined` to clear).
*/
maxBounds = input<unknown>(undefined);
/**
* **Construction-only** initial fit — a `LngLatBoundsLike` the map fits to on mount (overrides `center` / `zoom` when set). Pair with `fitBoundsOptions`.
*/
bounds = input<unknown>(undefined);
/**
* **Construction-only** options for the initial `bounds` fit (padding, max-zoom, etc.).
*/
fitBoundsOptions = input<Record<string, any>>((() => ({}))());
/**
* Toggle drag-to-pan. Applied at construction and reconciled live via the handler's `enable()` / `disable()`.
*/
dragPan = input<boolean>(true);
/**
* Toggle right-drag / ctrl-drag rotation. Applied at construction and reconciled live.
*/
dragRotate = input<boolean>(true);
/**
* Toggle scroll-wheel zoom. Applied at construction and reconciled live.
*/
scrollZoom = input<boolean>(true);
/**
* Toggle double-click zoom. Applied at construction and reconciled live.
*/
doubleClickZoom = input<boolean>(true);
/**
* Toggle shift-drag box zoom. Applied at construction and reconciled live.
*/
boxZoom = input<boolean>(true);
/**
* Toggle keyboard navigation. Applied at construction and reconciled live.
*/
keyboard = input<boolean>(true);
/**
* Toggle touch pinch-zoom + rotate. Applied at construction and reconciled live.
*/
touchZoomRotate = input<boolean>(true);
/**
* Toggle two-finger touch pitch. Applied at construction and reconciled live.
*/
touchPitch = input<boolean>(true);
/**
* The marker data that drives the reactive multi-instance `marker` slot — one entry per marker (`{ lng, lat, id?, anchor?, offset?, draggable?, ... }`). One portal handle mounts per entry; changing the array reconciles markers keep / update / dispose with no remount. Only meaningful when the `marker` slot is filled.
*/
markers = input<any[]>((() => [])());
/**
* The popup data that drives the reactive multi-instance `popup` slot — one entry per popup (`{ lng, lat, id?, anchor?, offset?, closeButton?, closeOnClick?, ... }`). One portal handle mounts per entry. Only meaningful when the `popup` slot is filled.
*/
popups = input<any[]>((() => [])());
/**
* Declarative GeoJSON / vector / raster sources — `[{ id, spec }]` (or a bare `SourceSpecification` carrying an `id`). Reconciled into the live style (add / `setData` / remove) once the style has loaded. The config-array authoring shape for sources; declarative `<Source>` / `<Layer>` children are the alternative shape (both feed the same registry).
*/
sources = input<any[]>((() => [])());
/**
* Declarative layers — `LayerSpecification[]` (each with an `id`). Reconciled into the live style (add / `setPaintProperty` / `setLayoutProperty` / remove) once the style has loaded; `beforeId` controls draw order.
*/
layers = input<any[]>((() => [])());
/**
* Layer ids whose feature `mouseenter` / `mouseleave` fire the `@mouseenter` / `@mouseleave` events (populating `e.features`). Registered / unregistered per id on change.
*/
interactiveLayerIds = input<any[]>((() => [])());
/**
* Standard map controls — strings (`'navigation'` / `'geolocate'` / `'scale'` / `'fullscreen'` / `'attribution'`) or `{ type, position?, options? }` objects. Reconciled (remove-all + re-add) on change.
*/
controls = input<any[]>((() => [])());
/**
* The raw `MapOptions` passthrough — spread into the `Map` constructor **before** the curated keys, so explicit props win. The MapLibre analog of an options bag for anything the curated surface doesn't special-case.
*/
options = input<Record<string, any>>((() => ({}))());
sourceReg = signal({});
layerReg = signal({});
containerEl = viewChild<ElementRef<HTMLDivElement>>('containerEl');
load = output<unknown>();
idle = output<unknown>();
move = output<unknown>();
rotate = output<unknown>();
dragstart = output<unknown>();
drag = output<unknown>();
dragend = output<unknown>();
click = output<unknown>();
dblclick = output<unknown>();
contextmenu = output<unknown>();
mousemove = output<unknown>();
error = output<unknown>();
styledata = output<unknown>();
sourcedata = output<unknown>();
moveend = output<unknown>();
zoomend = output<unknown>();
rotateend = output<unknown>();
pitchend = output<unknown>();
mouseenter = output<unknown>();
mouseleave = output<unknown>();
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
@ContentChild('marker', { read: TemplateRef }) markerTpl?: TemplateRef<MarkerCtx>;
@ContentChild('popup', { read: TemplateRef }) popupTpl?: TemplateRef<PopupCtx>;
@ContentChild('control', { read: TemplateRef }) controlTpl?: TemplateRef<ControlCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private _portalViews = new Set<EmbeddedViewRef<unknown>>();
private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
private _markerTpl = contentChild('marker', { read: TemplateRef });
private _popupTpl = contentChild('popup', { read: TemplateRef });
private _controlTpl = contentChild('control', { read: TemplateRef });
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
private __rozieWatchInitial_5 = true;
private __rozieWatchInitial_6 = true;
private __rozieWatchInitial_7 = true;
private __rozieWatchInitial_8 = true;
private __rozieWatchInitial_9 = true;
private __rozieWatchInitial_10 = true;
private __rozieWatchInitial_11 = true;
private __rozieWatchInitial_12 = true;
private __rozieWatchInitial_13 = true;
private __rozieWatchInitial_14 = true;
private __rozieWatchInitial_15 = true;
private __rozieWatchInitial_16 = true;
private __rozieWatchInitial_17 = true;
private __rozieWatchInitial_18 = true;
private __rozieWatchInitial_19 = true;
private __rozieWatchInitial_20 = true;
private __rozieWatchInitial_21 = true;
private __rozieWatchInitial_22 = true;
private __rozieWatchInitial_23 = true;
constructor() {
effect(() => { const __watchVal = (() => this.center())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!this.instance || !Array.isArray(v) || v.length !== 2) return;
const c = this.instance.getCenter();
if (v[0] === c.lng && v[1] === c.lat) return;
this.instance.easeTo({
center: v,
animate: false
}, this.PROGRAMMATIC);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.zoom())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => {
if (!this.instance || typeof v !== 'number' || v === this.instance.getZoom()) return;
this.instance.easeTo({
zoom: v,
animate: false
}, this.PROGRAMMATIC);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.bearing())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } ((v: any) => {
if (!this.instance || typeof v !== 'number' || v === this.instance.getBearing()) return;
this.instance.easeTo({
bearing: v,
animate: false
}, this.PROGRAMMATIC);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.pitch())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => {
if (!this.instance || typeof v !== 'number' || v === this.instance.getPitch()) return;
this.instance.easeTo({
pitch: v,
animate: false
}, this.PROGRAMMATIC);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.mapStyle())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => {
if (!this.instance) return;
// a new style wipes imperatively-added sources/layers — reset the applied
// tracking and re-apply once the new style loads.
this.appliedLayerIds = [];
this.appliedSourceIds = [];
this.instance.setStyle(v ?? this.DEFAULT_STYLE);
this.instance.once('styledata', () => this.applyLayers());
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.minZoom())(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((v: any) => {
if (this.instance && typeof v === 'number') this.instance.setMinZoom(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.maxZoom())(); untracked(() => { if (this.__rozieWatchInitial_6) { this.__rozieWatchInitial_6 = false; return; } ((v: any) => {
if (this.instance && typeof v === 'number') this.instance.setMaxZoom(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.maxBounds())(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } ((v: any) => {
if (this.instance) this.instance.setMaxBounds(v || null);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.markers())(); untracked(() => { if (this.__rozieWatchInitial_8) { this.__rozieWatchInitial_8 = false; return; } ((v: any) => {
if (this.reconcileMarkers) this.reconcileMarkers(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.popups())(); untracked(() => { if (this.__rozieWatchInitial_9) { this.__rozieWatchInitial_9 = false; return; } ((v: any) => {
if (this.reconcilePopups) this.reconcilePopups(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.sources())(); untracked(() => { if (this.__rozieWatchInitial_10) { this.__rozieWatchInitial_10 = false; return; } (() => this.applyLayers())(); }); });
effect(() => { const __watchVal = (() => this.layers())(); untracked(() => { if (this.__rozieWatchInitial_11) { this.__rozieWatchInitial_11 = false; return; } (() => this.applyLayers())(); }); });
effect(() => { const __watchVal = (() => this.sourceReg())(); untracked(() => { if (this.__rozieWatchInitial_12) { this.__rozieWatchInitial_12 = false; return; } (() => this.applyLayers())(); }); });
effect(() => { const __watchVal = (() => this.layerReg())(); untracked(() => { if (this.__rozieWatchInitial_13) { this.__rozieWatchInitial_13 = false; return; } (() => this.applyLayers())(); }); });
effect(() => { const __watchVal = (() => this.interactiveLayerIds())(); untracked(() => { if (this.__rozieWatchInitial_14) { this.__rozieWatchInitial_14 = false; return; } ((v: any) => {
if (this.reconcileInteractive) this.reconcileInteractive(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.controls())(); untracked(() => { if (this.__rozieWatchInitial_15) { this.__rozieWatchInitial_15 = false; return; } (() => this.applyControls())(); }); });
effect(() => { const __watchVal = (() => this.dragPan())(); untracked(() => { if (this.__rozieWatchInitial_16) { this.__rozieWatchInitial_16 = false; return; } (() => this.applyInteractionToggles())(); }); });
effect(() => { const __watchVal = (() => this.dragRotate())(); untracked(() => { if (this.__rozieWatchInitial_17) { this.__rozieWatchInitial_17 = false; return; } (() => this.applyInteractionToggles())(); }); });
effect(() => { const __watchVal = (() => this.scrollZoom())(); untracked(() => { if (this.__rozieWatchInitial_18) { this.__rozieWatchInitial_18 = false; return; } (() => this.applyInteractionToggles())(); }); });
effect(() => { const __watchVal = (() => this.doubleClickZoom())(); untracked(() => { if (this.__rozieWatchInitial_19) { this.__rozieWatchInitial_19 = false; return; } (() => this.applyInteractionToggles())(); }); });
effect(() => { const __watchVal = (() => this.boxZoom())(); untracked(() => { if (this.__rozieWatchInitial_20) { this.__rozieWatchInitial_20 = false; return; } (() => this.applyInteractionToggles())(); }); });
effect(() => { const __watchVal = (() => this.keyboard())(); untracked(() => { if (this.__rozieWatchInitial_21) { this.__rozieWatchInitial_21 = false; return; } (() => this.applyInteractionToggles())(); }); });
effect(() => { const __watchVal = (() => this.touchZoomRotate())(); untracked(() => { if (this.__rozieWatchInitial_22) { this.__rozieWatchInitial_22 = false; return; } (() => this.applyInteractionToggles())(); }); });
effect(() => { const __watchVal = (() => this.touchPitch())(); untracked(() => { if (this.__rozieWatchInitial_23) { this.__rozieWatchInitial_23 = false; return; } (() => this.applyInteractionToggles())(); }); });
}
ngAfterViewInit() {
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
marker: (container: HTMLElement, scope: { marker: unknown; index: unknown }): ReactivePortalHandle => {
const tpl = this._markerTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-marker', 'f1ee1082');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return {
update: (s: unknown): void => {
Object.assign(view.context as object, s as object);
view.detectChanges();
},
dispose: (): void => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
},
};
},
popup: (container: HTMLElement, scope: { popup: unknown; index: unknown }): ReactivePortalHandle => {
const tpl = this._popupTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-popup', 'f1ee1082');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return {
update: (s: unknown): void => {
Object.assign(view.context as object, s as object);
view.detectChanges();
},
dispose: (): void => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
},
};
},
control: (container: HTMLElement, scope: { map: unknown }): (() => void) => {
const tpl = this._controlTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-control', 'f1ee1082');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
};
const el = this.containerEl()?.nativeElement;
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
this.controlInstances = [];
this.appliedLayerIds = [];
this.appliedSourceIds = [];
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
let mapOptions: any = null;
mapOptions = {
container: el,
...this.options(),
style: this.mapStyle() ?? this.DEFAULT_STYLE,
center: this.center(),
zoom: this.zoom(),
bearing: this.bearing(),
pitch: this.pitch(),
minZoom: this.minZoom(),
maxZoom: this.maxZoom(),
maxBounds: this.maxBounds(),
bounds: this.bounds(),
fitBoundsOptions: this.fitBoundsOptions(),
dragPan: this.dragPan(),
dragRotate: this.dragRotate(),
scrollZoom: this.scrollZoom(),
doubleClickZoom: this.doubleClickZoom(),
boxZoom: this.boxZoom(),
keyboard: this.keyboard(),
touchZoomRotate: this.touchZoomRotate(),
touchPitch: this.touchPitch()
};
this.instance = new maplibregl.Map(mapOptions);
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
this.instance.on('load', (e: any) => this.load.emit(e));
this.instance.on('idle', (e: any) => this.idle.emit(e));
this.instance.on('move', (e: any) => this.move.emit(e));
this.instance.on('rotate', (e: any) => this.rotate.emit(e));
this.instance.on('dragstart', (e: any) => this.dragstart.emit(e));
this.instance.on('drag', (e: any) => this.drag.emit(e));
this.instance.on('dragend', (e: any) => this.dragend.emit(e));
this.instance.on('click', (e: any) => this.click.emit(this.payload(e)));
this.instance.on('dblclick', (e: any) => this.dblclick.emit(this.payload(e)));
this.instance.on('contextmenu', (e: any) => this.contextmenu.emit(this.payload(e)));
this.instance.on('mousemove', (e: any) => this.mousemove.emit(this.payload(e)));
this.instance.on('error', (e: any) => this.error.emit(e));
this.instance.on('styledata', (e: any) => this.styledata.emit(e));
this.instance.on('sourcedata', (e: any) => this.sourcedata.emit(e));
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
this.instance.on('moveend', (e: any) => {
this.moveend.emit(e);
if (e.rozieProgrammatic) return;
const c = this.instance.getCenter();
const next = [c.lng, c.lat];
if (!this.sameCenter(next, this.center())) this.center.set(next);
const z = this.instance.getZoom();
if (z !== this.zoom()) this.zoom.set(z);
});
this.instance.on('zoomend', (e: any) => {
this.zoomend.emit(e);
if (e.rozieProgrammatic) return;
const z = this.instance.getZoom();
if (z !== this.zoom()) this.zoom.set(z);
});
this.instance.on('rotateend', (e: any) => {
this.rotateend.emit(e);
if (e.rozieProgrammatic) return;
const b = this.instance.getBearing();
if (b !== this.bearing()) this.bearing.set(b);
});
this.instance.on('pitchend', (e: any) => {
this.pitchend.emit(e);
if (e.rozieProgrammatic) return;
const p = this.instance.getPitch();
if (p !== this.pitch()) this.pitch.set(p);
});
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
this.reconcileMarkers = (list: any) => {
if (!(this.markerTpl ?? this.templates()?.['marker'])) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((m: any, index: any) => {
if (!m || typeof m.lng !== 'number' || typeof m.lat !== 'number') return;
const key = m.id != null ? m.id : index;
seen.add(key);
const scope = {
marker: m,
index
};
const entry = this.markerEntries.get(key);
if (entry) {
entry.engine.setLngLat([m.lng, m.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-marker';
const handle = portals.marker(node, scope);
const engine = new maplibregl.Marker({
element: node,
anchor: m.anchor,
offset: m.offset,
draggable: m.draggable
}).setLngLat([m.lng, m.lat]).addTo(this.instance);
this.markerEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of this.markerEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
this.markerEntries.delete(key);
}
}
};
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
this.reconcilePopups = (list: any) => {
if (!(this.popupTpl ?? this.templates()?.['popup'])) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((p: any, index: any) => {
if (!p || typeof p.lng !== 'number' || typeof p.lat !== 'number') return;
const key = p.id != null ? p.id : index;
seen.add(key);
const scope = {
popup: p,
index
};
const entry = this.popupEntries.get(key);
if (entry) {
entry.engine.setLngLat([p.lng, p.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-popup-body';
const handle = portals.popup(node, scope);
const engine = new maplibregl.Popup({
closeButton: p.closeButton !== undefined ? p.closeButton : true,
closeOnClick: p.closeOnClick !== undefined ? p.closeOnClick : false,
anchor: p.anchor,
offset: p.offset
}).setLngLat([p.lng, p.lat]).setDOMContent(node).addTo(this.instance);
this.popupEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of this.popupEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
this.popupEntries.delete(key);
}
}
};
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
this.reconcileInteractive = (ids: any) => {
const want = (Array.isArray(ids) ? ids : []).filter(Boolean);
for (const [id, l] of this.featureListeners as any) {
if (!want.includes(id)) {
this.instance.off('mouseenter', id, l.enter);
this.instance.off('mouseleave', id, l.leave);
this.featureListeners.delete(id);
}
}
for (const id of want as any) {
if (this.featureListeners.has(id)) continue;
const enter = (e: any) => this.mouseenter.emit(this.payload(e));
const leave = (e: any) => this.mouseleave.emit(this.payload(e));
this.instance.on('mouseenter', id, enter);
this.instance.on('mouseleave', id, leave);
this.featureListeners.set(id, {
enter,
leave
});
}
};
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
if ((this.controlTpl ?? this.templates()?.['control'])) {
const host = document.createElement('div');
host.className = 'maplibregl-ctrl rozie-maplibre-control';
this.customControl = {
onAdd() {
return host;
},
onRemove() {
if (host.parentNode) host.parentNode.removeChild(host);
}
};
this.instance.addControl(this.customControl, 'top-right');
this.controlDispose = portals.control(host, {
map: this.instance
});
}
// standard controls + interaction toggles don't need style load.
// standard controls + interaction toggles don't need style load.
this.applyControls();
this.applyInteractionToggles();
// markers/popups/interactive are DOM/event overlays — no style-load gate.
// markers/popups/interactive are DOM/event overlays — no style-load gate.
this.reconcileMarkers(this.markers());
this.reconcilePopups(this.popups());
this.reconcileInteractive(this.interactiveLayerIds());
// sources/layers need the style loaded.
// sources/layers need the style loaded.
if (this.instance.isStyleLoaded()) this.applyLayers();else this.instance.on('load', this.applyLayers);
this.__rozieDestroyRef.onDestroy(() => {
for (const [, entry] of this.markerEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
this.markerEntries.clear();
for (const [, entry] of this.popupEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
this.popupEntries.clear();
if (this.controlDispose) this.controlDispose();
if (this.instance) this.instance.remove();
});
this.__rozieDestroyRef.onDestroy(() => {
for (const view of this._portalViews) view.destroy();
this._portalViews.clear();
});
}
instance: any = null;
DEFAULT_STYLE = 'https://demotiles.maplibre.org/style.json';
PROGRAMMATIC = {
rozieProgrammatic: true
};
markerEntries = new Map();
popupEntries = new Map();
controlInstances: any = null;
controlDispose: any = null;
customControl: any = null;
featureListeners = new Map();
appliedLayerIds: any = null;
appliedSourceIds: any = null;
reconcileMarkers: any = null;
reconcilePopups: any = null;
reconcileInteractive: any = null;
sameCenter = (a: any, b: any) => Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
payload = (e: any) => ({
lngLat: e.lngLat ? {
lng: e.lngLat.lng,
lat: e.lngLat.lat
} : null,
point: e.point ? {
x: e.point.x,
y: e.point.y
} : null,
features: e.features || [],
originalEvent: e.originalEvent
});
buildControl = (spec: any) => {
const type = typeof spec === 'string' ? spec : spec.type;
const opts = typeof spec === 'object' && spec.options || {};
if (type === 'navigation') return new maplibregl.NavigationControl(opts);
if (type === 'geolocate') return new maplibregl.GeolocateControl(opts);
if (type === 'scale') return new maplibregl.ScaleControl(opts);
if (type === 'fullscreen') return new maplibregl.FullscreenControl(opts);
if (type === 'attribution') return new maplibregl.AttributionControl(opts);
return null;
};
applyControls = () => {
if (!this.instance) return;
for (const c of this.controlInstances as any) this.instance.removeControl(c);
this.controlInstances = [];
for (const spec of this.controls() as any) {
if (!spec) continue;
const ctrl = this.buildControl(spec);
if (!ctrl) continue;
const position = typeof spec === 'object' && spec.position || undefined;
this.instance.addControl(ctrl, position);
this.controlInstances.push(ctrl);
}
};
applyInteractionToggles = () => {
if (!this.instance) return;
const set = (name: any, on: any) => {
const handler = this.instance[name];
if (handler) on ? handler.enable() : handler.disable();
};
set('dragPan', this.dragPan());
set('dragRotate', this.dragRotate());
set('scrollZoom', this.scrollZoom());
set('doubleClickZoom', this.doubleClickZoom());
set('boxZoom', this.boxZoom());
set('keyboard', this.keyboard());
set('touchZoomRotate', this.touchZoomRotate());
set('touchPitch', this.touchPitch());
};
applyLayers = () => {
if (!this.instance || !this.instance.isStyleLoaded()) return;
// ─── union the config-array props with the declarative-children registry ────
// (registry ∪ props), keyed by id. D-02: the registry (declarative children) is
// the LAST writer and overrides the config-array on id collision. Ordering: array
// entries first in array order, then registry entries in registration order —
// `[...$props.layers, ...registryLayers]` — each still honoring its explicit
// `beforeId` (the existing applyLayers ordering contract, REUSED unchanged,
// RESEARCH OQ3). The empty-registry path is byte-equivalent to today: with both
// registries empty, mergeById returns exactly the config array (dedup by id of
// an array with no registry overrides is the array itself), so (∅ ∪ props) ===
// props in behavior — the dist-parity zero-drift guarantee (RESEARCH A3).
const mergeById = (arr: any, reg: any) => {
// out seeded from the (any-typed) input so strict tsc infers any[] not never[]
// (untyped <script> can't use a TS `: any[]`/`as any[]` annotation; .slice(0,0)
// yields an empty array with identical runtime behavior to `const out = []`).
const out = (Array.isArray(arr) ? arr : []).slice(0, 0);
const idx = new Map();
for (const e of (Array.isArray(arr) ? arr : []) as any) {
if (!e || !e.id) {
out.push(e);
continue;
}
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
for (const id in reg) {
const e = reg[id];
if (!e || !e.id) continue;
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
return out;
};
const mergedSources = mergeById(this.sources(), this.sourceReg());
const mergedLayers = mergeById(this.layers(), this.layerReg());
const wantLayerIds = mergedLayers.map((l: any) => l && l.id).filter(Boolean);
const wantSourceIds = mergedSources.map((s: any) => s && s.id).filter(Boolean);
// 1. drop removed layers
for (const id of this.appliedLayerIds as any) {
if (!wantLayerIds.includes(id) && this.instance.getLayer(id)) this.instance.removeLayer(id);
}
// 2. add/update sources
for (const s of mergedSources as any) {
if (!s || !s.id) continue;
const spec = s.spec || s;
const existing = this.instance.getSource(s.id);
if (!existing) this.instance.addSource(s.id, spec);else if (spec.type === 'geojson' && spec.data) existing.setData(spec.data);
}
// 3. add/update layers. DEFENSIVE: a non-background layer whose `source` is not
// (yet) present in the engine is SKIPPED rather than added — a declarative
// <Layer> may register before its <Source> parent has supplied the source id
// (child-before-parent mount order on React/Vue/Svelte/Angular), in which case
// addLayer would throw "source ... doesn't exist" / read null `.type` and abort
// the whole loop (dropping later layers like `bg`). The <Layer> re-registers with
// the resolved source on $onUpdate, re-running this reconcile, so the layer lands
// on the next tick. Background layers need no source. addLayer is wrapped so any
// single malformed spec can't abort the rest of the loop either.
for (const l of mergedLayers as any) {
if (!l || !l.id) continue;
if (!this.instance.getLayer(l.id)) {
const needsSource = l.type !== 'background';
if (needsSource && (l.source == null || !this.instance.getSource(l.source))) continue;
// Build a CLEAN LayerSpecification: a declarative <Layer> registry spec carries
// a `beforeId` (not a LayerSpecification key — it is the addLayer 2nd arg) and
// explicit `source: undefined` / `layout: undefined` keys (the prop defaults).
// MapLibre v5 rejects a background layer that has ANY `source` key, and an
// undefined `layout` — so emit only the keys MapLibre expects (the config-array
// path is unaffected: those specs are already clean, this just re-emits them).
// null-let → typeNeutralize `any` so the dynamic key assignments below
// type-check on the strict bundled leaves (the `let x = null` idiom).
let clean: any = null;
clean = {
id: l.id,
type: l.type
};
if (needsSource) clean.source = l.source;
if (l.paint != null) clean.paint = l.paint;
if (l.layout != null) clean.layout = l.layout;
if (l.sourceLayer != null) clean['source-layer'] = l.sourceLayer;
if (l.filter != null) clean.filter = l.filter;
if (l.minzoom != null) clean.minzoom = l.minzoom;
if (l.maxzoom != null) clean.maxzoom = l.maxzoom;
try {
this.instance.addLayer(clean, l.beforeId);
} catch (e: any) {
// surfaced via the `error` emit path; skip so later layers still apply.
}
} else {
if (l.paint) for (const k in l.paint) this.instance.setPaintProperty(l.id, k, l.paint[k]);
if (l.layout) for (const k in l.layout) this.instance.setLayoutProperty(l.id, k, l.layout[k]);
}
}
// 4. drop removed sources (their layers are gone)
for (const id of this.appliedSourceIds as any) {
if (!wantSourceIds.includes(id) && this.instance.getSource(id)) this.instance.removeSource(id);
}
this.appliedLayerIds = wantLayerIds;
this.appliedSourceIds = wantSourceIds;
};
getMap = () => {
return this.instance;
};
flyTo = (opts: any) => {
if (this.instance) this.instance.flyTo(opts);
};
easeTo = (opts: any) => {
if (this.instance) this.instance.easeTo(opts);
};
jumpTo = (opts: any) => {
if (this.instance) this.instance.jumpTo(opts);
};
fitBounds = (bounds: any, opts: any) => {
if (this.instance) this.instance.fitBounds(bounds, opts);
};
getCenter = () => {
if (!this.instance) return null;
const c = this.instance.getCenter();
return [c.lng, c.lat];
};
getZoom = () => {
return this.instance ? this.instance.getZoom() : null;
};
resize = () => {
if (this.instance) this.instance.resize();
};
queryRenderedFeatures = (geometry: any, options: any) => {
return this.instance ? this.instance.queryRenderedFeatures(geometry, options) : [];
};
project = (lngLat: any) => {
return this.instance ? this.instance.project(lngLat) : null;
};
unproject = (point: any) => {
return this.instance ? this.instance.unproject(point) : null;
};
getBounds = () => {
return this.instance ? this.instance.getBounds() : null;
};
zoomIn = (opts: any) => {
if (this.instance) this.instance.zoomIn(opts);
};
zoomOut = (opts: any) => {
if (this.instance) this.instance.zoomOut(opts);
};
panBy = (offset: any, opts: any) => {
if (this.instance) this.instance.panBy(offset, opts);
};
static ngTemplateContextGuard(
_dir: MapLibre,
_ctx: unknown,
): _ctx is DefaultCtx | MarkerCtx | PopupCtx | ControlCtx {
return true;
}
}
export default MapLibre;tsx
import type { JSX } from 'solid-js';
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle, createControllableSignal, rozieContext } from '@rozie/runtime-solid';
import maplibregl from 'maplibre-gl';
__rozieInjectStyle('MapLibre-f1ee1082', `.rozie-maplibre[data-rozie-s-f1ee1082] {
width: 100%;
height: 100%;
min-height: 300px;
position: relative;
overflow: hidden;
border-radius: 6px;
}
.rozie-maplibre .rozie-maplibre-marker {
cursor: pointer;
}
.rozie-maplibre .rozie-maplibre-control {
display: flex;
flex-direction: column;
}`);
interface MarkerSlotCtx { marker: any; index: any; }
interface PopupSlotCtx { popup: any; index: any; }
interface ControlSlotCtx { map: any; }
interface MapLibreProps {
/**
* The map center as `[lng, lat]` — **longitude first** (MapLibre's convention, not Leaflet's `[lat, lng]`). Two-way: panning the map writes the new center back through the model path (echo-guarded), and a consumer write `easeTo`s the live map. The `moveend` echo reads `getCenter()` as `[lng, lat]`.
* @example
* <MapLibre r-model:center="center" r-model:zoom="zoom" />
*/
center?: any[];
defaultCenter?: any[];
onCenterChange?: (center: any[]) => void;
/**
* The zoom level. Two-way: scroll / pinch writes the new zoom back, and a consumer write `easeTo`s the camera. Echo-guarded against the wrapper's own programmatic moves.
*/
zoom?: number;
defaultZoom?: number;
onZoomChange?: (zoom: number) => void;
/**
* The map rotation (bearing) in degrees. Two-way via the `rotateend` echo and the `easeTo` reconcile.
*/
bearing?: number;
defaultBearing?: number;
onBearingChange?: (bearing: number) => void;
/**
* The map tilt (pitch) in degrees. Two-way via the `pitchend` echo and the `easeTo` reconcile.
*/
pitch?: number;
defaultPitch?: number;
onPitchChange?: (pitch: number) => void;
/**
* The map style — a `StyleSpecification` object **or** a style-URL string. Named `mapStyle` (not `style`) because `style` is a reserved attribute across the targets — `react-map-gl` and `vue-maplibre-gl` use the same name for the same reason. Defaults to MapLibre's official no-token demo tiles, so the component "just works" with zero config. Changing it calls `setStyle` and re-applies your `sources` / `layers` once the new style loads.
*/
mapStyle?: unknown;
/**
* Minimum zoom level. Applied at construction and via `setMinZoom` on change.
*/
minZoom?: number;
/**
* Maximum zoom level. Applied at construction and via `setMaxZoom` on change.
*/
maxZoom?: number;
/**
* A `LngLatBoundsLike` the camera is constrained to. Applied via `setMaxBounds` on change (pass `undefined` to clear).
*/
maxBounds?: unknown;
/**
* **Construction-only** initial fit — a `LngLatBoundsLike` the map fits to on mount (overrides `center` / `zoom` when set). Pair with `fitBoundsOptions`.
*/
bounds?: unknown;
/**
* **Construction-only** options for the initial `bounds` fit (padding, max-zoom, etc.).
*/
fitBoundsOptions?: Record<string, any>;
/**
* Toggle drag-to-pan. Applied at construction and reconciled live via the handler's `enable()` / `disable()`.
*/
dragPan?: boolean;
/**
* Toggle right-drag / ctrl-drag rotation. Applied at construction and reconciled live.
*/
dragRotate?: boolean;
/**
* Toggle scroll-wheel zoom. Applied at construction and reconciled live.
*/
scrollZoom?: boolean;
/**
* Toggle double-click zoom. Applied at construction and reconciled live.
*/
doubleClickZoom?: boolean;
/**
* Toggle shift-drag box zoom. Applied at construction and reconciled live.
*/
boxZoom?: boolean;
/**
* Toggle keyboard navigation. Applied at construction and reconciled live.
*/
keyboard?: boolean;
/**
* Toggle touch pinch-zoom + rotate. Applied at construction and reconciled live.
*/
touchZoomRotate?: boolean;
/**
* Toggle two-finger touch pitch. Applied at construction and reconciled live.
*/
touchPitch?: boolean;
/**
* The marker data that drives the reactive multi-instance `marker` slot — one entry per marker (`{ lng, lat, id?, anchor?, offset?, draggable?, ... }`). One portal handle mounts per entry; changing the array reconciles markers keep / update / dispose with no remount. Only meaningful when the `marker` slot is filled.
*/
markers?: any[];
/**
* The popup data that drives the reactive multi-instance `popup` slot — one entry per popup (`{ lng, lat, id?, anchor?, offset?, closeButton?, closeOnClick?, ... }`). One portal handle mounts per entry. Only meaningful when the `popup` slot is filled.
*/
popups?: any[];
/**
* Declarative GeoJSON / vector / raster sources — `[{ id, spec }]` (or a bare `SourceSpecification` carrying an `id`). Reconciled into the live style (add / `setData` / remove) once the style has loaded. The config-array authoring shape for sources; declarative `<Source>` / `<Layer>` children are the alternative shape (both feed the same registry).
*/
sources?: any[];
/**
* Declarative layers — `LayerSpecification[]` (each with an `id`). Reconciled into the live style (add / `setPaintProperty` / `setLayoutProperty` / remove) once the style has loaded; `beforeId` controls draw order.
*/
layers?: any[];
/**
* Layer ids whose feature `mouseenter` / `mouseleave` fire the `@mouseenter` / `@mouseleave` events (populating `e.features`). Registered / unregistered per id on change.
*/
interactiveLayerIds?: any[];
/**
* Standard map controls — strings (`'navigation'` / `'geolocate'` / `'scale'` / `'fullscreen'` / `'attribution'`) or `{ type, position?, options? }` objects. Reconciled (remove-all + re-add) on change.
*/
controls?: any[];
/**
* The raw `MapOptions` passthrough — spread into the `Map` constructor **before** the curated keys, so explicit props win. The MapLibre analog of an options bag for anything the curated surface doesn't special-case.
*/
options?: Record<string, any>;
onLoad?: (...args: unknown[]) => void;
onIdle?: (...args: unknown[]) => void;
onMove?: (...args: unknown[]) => void;
onRotate?: (...args: unknown[]) => void;
onDragstart?: (...args: unknown[]) => void;
onDrag?: (...args: unknown[]) => void;
onDragend?: (...args: unknown[]) => void;
onClick?: (...args: unknown[]) => void;
onDblclick?: (...args: unknown[]) => void;
onContextmenu?: (...args: unknown[]) => void;
onMousemove?: (...args: unknown[]) => void;
onError?: (...args: unknown[]) => void;
onStyledata?: (...args: unknown[]) => void;
onSourcedata?: (...args: unknown[]) => void;
onMoveend?: (...args: unknown[]) => void;
onZoomend?: (...args: unknown[]) => void;
onRotateend?: (...args: unknown[]) => void;
onPitchend?: (...args: unknown[]) => void;
onMouseenter?: (...args: unknown[]) => void;
onMouseleave?: (...args: unknown[]) => void;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
markerSlot?: (ctx: () => MarkerSlotCtx) => JSX.Element;
popupSlot?: (ctx: () => PopupSlotCtx) => JSX.Element;
controlSlot?: (ctx: ControlSlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: MapLibreHandle) => void;
}
export interface MapLibreHandle {
getMap: (...args: any[]) => any;
flyTo: (...args: any[]) => any;
easeTo: (...args: any[]) => any;
jumpTo: (...args: any[]) => any;
fitBounds: (...args: any[]) => any;
getCenter: (...args: any[]) => any;
getZoom: (...args: any[]) => any;
resize: (...args: any[]) => any;
queryRenderedFeatures: (...args: any[]) => any;
project: (...args: any[]) => any;
unproject: (...args: any[]) => any;
getBounds: (...args: any[]) => any;
zoomIn: (...args: any[]) => any;
zoomOut: (...args: any[]) => any;
panBy: (...args: any[]) => any;
}
export default function MapLibre(_props: MapLibreProps): JSX.Element {
const _merged = mergeProps({ mapStyle: undefined, minZoom: 0, maxZoom: 22, maxBounds: undefined, bounds: undefined, fitBoundsOptions: (() => ({}))(), dragPan: true, dragRotate: true, scrollZoom: true, doubleClickZoom: true, boxZoom: true, keyboard: true, touchZoomRotate: true, touchPitch: true, markers: (() => [])(), popups: (() => [])(), sources: (() => [])(), layers: (() => [])(), interactiveLayerIds: (() => [])(), controls: (() => [])(), options: (() => ({}))() }, _props);
const [local, attrs] = splitProps(_merged, ['center', 'zoom', 'bearing', 'pitch', 'mapStyle', 'minZoom', 'maxZoom', 'maxBounds', 'bounds', 'fitBoundsOptions', 'dragPan', 'dragRotate', 'scrollZoom', 'doubleClickZoom', 'boxZoom', 'keyboard', 'touchZoomRotate', 'touchPitch', 'markers', 'popups', 'sources', 'layers', 'interactiveLayerIds', 'controls', 'options', 'children', 'ref']);
const resolved = () => local.children;
onMount(() => { local.ref?.({ getMap, flyTo, easeTo, jumpTo, fitBounds, getCenter, getZoom, resize, queryRenderedFeatures, project, unproject, getBounds, zoomIn, zoomOut, panBy }); });
const __ctx_maplibre_sources = rozieContext("maplibre:sources");
const __ctx_maplibre_layers = rozieContext("maplibre:layers");
const [center, setCenter] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'center', (() => [0, 0])());
const [zoom, setZoom] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'zoom', 1);
const [bearing, setBearing] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'bearing', 0);
const [pitch, setPitch] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'pitch', 0);
const [sourceReg, setSourceReg] = createSignal<Record<string, any>>({});
const [layerReg, setLayerReg] = createSignal<Record<string, any>>({});
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalDisposers = new Set<() => void>();
const portals = {
marker: (container: HTMLElement, scope: { marker: unknown; index: unknown }): ReactivePortalHandle => {
const slot = _props.markerSlot ?? _props.slots?.['marker'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-marker', 'f1ee1082');
const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
const dispose = render(() => slot(scopeSig as unknown as (() => { marker: unknown; index: unknown })), container);
portalDisposers.add(dispose);
return {
update: (s: unknown): void => {
setScopeSig(s);
},
dispose: (): void => {
dispose();
portalDisposers.delete(dispose);
},
};
},
popup: (container: HTMLElement, scope: { popup: unknown; index: unknown }): ReactivePortalHandle => {
const slot = _props.popupSlot ?? _props.slots?.['popup'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-popup', 'f1ee1082');
const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
const dispose = render(() => slot(scopeSig as unknown as (() => { popup: unknown; index: unknown })), container);
portalDisposers.add(dispose);
return {
update: (s: unknown): void => {
setScopeSig(s);
},
dispose: (): void => {
dispose();
portalDisposers.delete(dispose);
},
};
},
control: (container: HTMLElement, scope: { map: unknown }): (() => void) => {
const slot = _props.controlSlot ?? _props.slots?.['control'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-control', 'f1ee1082');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
};
onCleanup(() => {
for (const dispose of portalDisposers) dispose();
portalDisposers.clear();
});
onMount(() => {
const _cleanup = (() => {
const el = containerElRef;
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
controlInstances = [];
appliedLayerIds = [];
appliedSourceIds = [];
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
let mapOptions: any = null;
mapOptions = {
container: el,
...local.options,
style: local.mapStyle ?? DEFAULT_STYLE,
center: center(),
zoom: zoom(),
bearing: bearing(),
pitch: pitch(),
minZoom: local.minZoom,
maxZoom: local.maxZoom,
maxBounds: local.maxBounds,
bounds: local.bounds,
fitBoundsOptions: local.fitBoundsOptions,
dragPan: local.dragPan,
dragRotate: local.dragRotate,
scrollZoom: local.scrollZoom,
doubleClickZoom: local.doubleClickZoom,
boxZoom: local.boxZoom,
keyboard: local.keyboard,
touchZoomRotate: local.touchZoomRotate,
touchPitch: local.touchPitch
};
instance = new maplibregl.Map(mapOptions);
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
instance.on('load', (e: any) => _props.onLoad?.(e));
instance.on('idle', (e: any) => _props.onIdle?.(e));
instance.on('move', (e: any) => _props.onMove?.(e));
instance.on('rotate', (e: any) => _props.onRotate?.(e));
instance.on('dragstart', (e: any) => _props.onDragstart?.(e));
instance.on('drag', (e: any) => _props.onDrag?.(e));
instance.on('dragend', (e: any) => _props.onDragend?.(e));
instance.on('click', (e: any) => _props.onClick?.(payload(e)));
instance.on('dblclick', (e: any) => _props.onDblclick?.(payload(e)));
instance.on('contextmenu', (e: any) => _props.onContextmenu?.(payload(e)));
instance.on('mousemove', (e: any) => _props.onMousemove?.(payload(e)));
instance.on('error', (e: any) => _props.onError?.(e));
instance.on('styledata', (e: any) => _props.onStyledata?.(e));
instance.on('sourcedata', (e: any) => _props.onSourcedata?.(e));
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
instance.on('moveend', (e: any) => {
_props.onMoveend?.(e);
if (e.rozieProgrammatic) return;
const c = instance.getCenter();
const next = [c.lng, c.lat];
if (!sameCenter(next, center())) setCenter(next);
const z = instance.getZoom();
if (z !== zoom()) setZoom(z);
});
instance.on('zoomend', (e: any) => {
_props.onZoomend?.(e);
if (e.rozieProgrammatic) return;
const z = instance.getZoom();
if (z !== zoom()) setZoom(z);
});
instance.on('rotateend', (e: any) => {
_props.onRotateend?.(e);
if (e.rozieProgrammatic) return;
const b = instance.getBearing();
if (b !== bearing()) setBearing(b);
});
instance.on('pitchend', (e: any) => {
_props.onPitchend?.(e);
if (e.rozieProgrammatic) return;
const p = instance.getPitch();
if (p !== pitch()) setPitch(p);
});
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
reconcileMarkers = (list: any) => {
if (!(_props.markerSlot ?? _props.slots?.["marker"])) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((m: any, index: any) => {
if (!m || typeof m.lng !== 'number' || typeof m.lat !== 'number') return;
const key = m.id != null ? m.id : index;
seen.add(key);
const scope = {
marker: m,
index
};
const entry = markerEntries.get(key);
if (entry) {
entry.engine.setLngLat([m.lng, m.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-marker';
const handle = portals.marker(node, scope);
const engine = new maplibregl.Marker({
element: node,
anchor: m.anchor,
offset: m.offset,
draggable: m.draggable
}).setLngLat([m.lng, m.lat]).addTo(instance);
markerEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of markerEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
markerEntries.delete(key);
}
}
};
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
reconcilePopups = (list: any) => {
if (!(_props.popupSlot ?? _props.slots?.["popup"])) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((p: any, index: any) => {
if (!p || typeof p.lng !== 'number' || typeof p.lat !== 'number') return;
const key = p.id != null ? p.id : index;
seen.add(key);
const scope = {
popup: p,
index
};
const entry = popupEntries.get(key);
if (entry) {
entry.engine.setLngLat([p.lng, p.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-popup-body';
const handle = portals.popup(node, scope);
const engine = new maplibregl.Popup({
closeButton: p.closeButton !== undefined ? p.closeButton : true,
closeOnClick: p.closeOnClick !== undefined ? p.closeOnClick : false,
anchor: p.anchor,
offset: p.offset
}).setLngLat([p.lng, p.lat]).setDOMContent(node).addTo(instance);
popupEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of popupEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
popupEntries.delete(key);
}
}
};
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
reconcileInteractive = (ids: any) => {
const want = (Array.isArray(ids) ? ids : []).filter(Boolean);
for (const [id, l] of featureListeners as any) {
if (!want.includes(id)) {
instance.off('mouseenter', id, l.enter);
instance.off('mouseleave', id, l.leave);
featureListeners.delete(id);
}
}
for (const id of want as any) {
if (featureListeners.has(id)) continue;
const enter = (e: any) => _props.onMouseenter?.(payload(e));
const leave = (e: any) => _props.onMouseleave?.(payload(e));
instance.on('mouseenter', id, enter);
instance.on('mouseleave', id, leave);
featureListeners.set(id, {
enter,
leave
});
}
};
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
if ((_props.controlSlot ?? _props.slots?.["control"])) {
const host = document.createElement('div');
host.className = 'maplibregl-ctrl rozie-maplibre-control';
customControl = {
onAdd() {
return host;
},
onRemove() {
if (host.parentNode) host.parentNode.removeChild(host);
}
};
instance.addControl(customControl, 'top-right');
controlDispose = portals.control(host, {
map: instance
});
}
// standard controls + interaction toggles don't need style load.
applyControls();
applyInteractionToggles();
// markers/popups/interactive are DOM/event overlays — no style-load gate.
reconcileMarkers(local.markers);
reconcilePopups(local.popups);
reconcileInteractive(local.interactiveLayerIds);
// sources/layers need the style loaded.
if (instance.isStyleLoaded()) applyLayers();else instance.on('load', applyLayers);
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
for (const [, entry] of markerEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
markerEntries.clear();
for (const [, entry] of popupEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
popupEntries.clear();
if (controlDispose) controlDispose();
if (instance) instance.remove();
});
});
createEffect(on(() => (() => center())(), (v) => untrack(() => ((v: any) => {
if (!instance || !Array.isArray(v) || v.length !== 2) return;
const c = instance.getCenter();
if (v[0] === c.lng && v[1] === c.lat) return;
instance.easeTo({
center: v,
animate: false
}, PROGRAMMATIC);
})(v)), { defer: true }));
createEffect(on(() => (() => zoom())(), (v) => untrack(() => ((v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getZoom()) return;
instance.easeTo({
zoom: v,
animate: false
}, PROGRAMMATIC);
})(v)), { defer: true }));
createEffect(on(() => (() => bearing())(), (v) => untrack(() => ((v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getBearing()) return;
instance.easeTo({
bearing: v,
animate: false
}, PROGRAMMATIC);
})(v)), { defer: true }));
createEffect(on(() => (() => pitch())(), (v) => untrack(() => ((v: any) => {
if (!instance || typeof v !== 'number' || v === instance.getPitch()) return;
instance.easeTo({
pitch: v,
animate: false
}, PROGRAMMATIC);
})(v)), { defer: true }));
createEffect(on(() => (() => local.mapStyle)(), (v) => untrack(() => ((v: any) => {
if (!instance) return;
// a new style wipes imperatively-added sources/layers — reset the applied
// tracking and re-apply once the new style loads.
appliedLayerIds = [];
appliedSourceIds = [];
instance.setStyle(v ?? DEFAULT_STYLE);
instance.once('styledata', () => applyLayers());
})(v)), { defer: true }));
createEffect(on(() => (() => local.minZoom)(), (v) => untrack(() => ((v: any) => {
if (instance && typeof v === 'number') instance.setMinZoom(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.maxZoom)(), (v) => untrack(() => ((v: any) => {
if (instance && typeof v === 'number') instance.setMaxZoom(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.maxBounds)(), (v) => untrack(() => ((v: any) => {
if (instance) instance.setMaxBounds(v || null);
})(v)), { defer: true }));
createEffect(on(() => (() => local.markers)(), (v) => untrack(() => ((v: any) => {
if (reconcileMarkers) reconcileMarkers(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.popups)(), (v) => untrack(() => ((v: any) => {
if (reconcilePopups) reconcilePopups(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.sources)(), (v) => untrack(() => (() => applyLayers())()), { defer: true }));
createEffect(on(() => (() => local.layers)(), (v) => untrack(() => (() => applyLayers())()), { defer: true }));
createEffect(on(() => (() => sourceReg())(), (v) => untrack(() => (() => applyLayers())()), { defer: true }));
createEffect(on(() => (() => layerReg())(), (v) => untrack(() => (() => applyLayers())()), { defer: true }));
createEffect(on(() => (() => local.interactiveLayerIds)(), (v) => untrack(() => ((v: any) => {
if (reconcileInteractive) reconcileInteractive(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.controls)(), (v) => untrack(() => (() => applyControls())()), { defer: true }));
createEffect(on(() => (() => local.dragPan)(), (v) => untrack(() => (() => applyInteractionToggles())()), { defer: true }));
createEffect(on(() => (() => local.dragRotate)(), (v) => untrack(() => (() => applyInteractionToggles())()), { defer: true }));
createEffect(on(() => (() => local.scrollZoom)(), (v) => untrack(() => (() => applyInteractionToggles())()), { defer: true }));
createEffect(on(() => (() => local.doubleClickZoom)(), (v) => untrack(() => (() => applyInteractionToggles())()), { defer: true }));
createEffect(on(() => (() => local.boxZoom)(), (v) => untrack(() => (() => applyInteractionToggles())()), { defer: true }));
createEffect(on(() => (() => local.keyboard)(), (v) => untrack(() => (() => applyInteractionToggles())()), { defer: true }));
createEffect(on(() => (() => local.touchZoomRotate)(), (v) => untrack(() => (() => applyInteractionToggles())()), { defer: true }));
createEffect(on(() => (() => local.touchPitch)(), (v) => untrack(() => (() => applyInteractionToggles())()), { defer: true }));
let containerElRef: HTMLElement | null = null;
let instance: any = null;
// MapLibre's official no-token demo tiles — the zero-config `mapStyle` fallback
// (the prop default is `undefined`; see the prop note).
const DEFAULT_STYLE = 'https://demotiles.maplibre.org/style.json';
// The eventData merged onto programmatic camera ops so the camera-lifecycle echo
// handlers can ignore our own moves (the documented MapLibre echo-guard — robust
// across batched ops where Leaflet's single boolean would race).
const PROGRAMMATIC = {
rozieProgrammatic: true
};
// Live entry maps for the REACTIVE MULTI-INSTANCE portal slots — keyed by
// entry.id ?? index. Each value: { engine, handle, el }. COMPONENT-scope (not
// $onMount-local) so the $onMount-returned teardown — which the Solid emitter
// hoists into a sibling onCleanup() OUTSIDE the mount IIFE — keeps them in scope.
const markerEntries = new Map();
const popupEntries = new Map();
// ─── declarative-children registry (Phase 37 $provide/$inject dogfood) ───────
// Publish the source/layer register-API the <Source>/<Layer> children $inject and
// self-register into. EVERY method uses WHOLE-OBJECT REPLACEMENT (spread / clone-
// and-delete) so the watched $data.sourceReg/$data.layerReg reference changes once
// per mutation and the parent $watch fires on all 6 targets (D-3 / Pitfall 1 — an
// in-place `$data.sourceReg[id] = spec` is silent on React/Solid/Angular/Lit). The
// register surface mirrors the SHIPPED Tabs.rozie $provide('tabs', { … }) shape;
// register/update share a body (both upsert by id). The values feed the SAME
// applyLayers() reconcile + appliedSourceIds/appliedLayerIds provenance as the
// config-array props, so registry-managed sources/layers are reaped on unregister
// exactly like prop-managed ones (D37-08).
// standard-control instances (so a controls-prop change can remove + re-add) and
// the mount-once custom-control portal dispose. controlInstances is a null-let
// (→ typeNeutralize `any`) initialized to [] in $onMount: a bare `let x = []`
// infers `never[]` under the strict framework-typecheck harness and rejects the
// `any` control instances pushed into it.
let controlInstances: any = null;
let controlDispose: any = null;
let customControl: any = null;
// layer-scoped feature listeners, registered per interactiveLayerId so they can
// be unregistered on change. id → { enter, leave }.
const featureListeners = new Map();
// previously-applied source/layer ids (null-lets → `any`, [] in $onMount; same
// never[] reason as controlInstances) so a sources/layers prop change can remove
// the dropped ones.
let appliedLayerIds: any = null;
let appliedSourceIds: any = null;
// The $portals/$emit-capturing reconcilers are built INSIDE $onMount (a top-level
// $portals reference fails the bundled-leaf strict typecheck — the CM/TipTap
// portal discipline) and bridged here so the top-level $watch can call them.
let reconcileMarkers: any = null;
let reconcilePopups: any = null;
let reconcileInteractive: any = null;
// ─── pure helpers (no sigils → safe at top level) ───────────────────────────
function sameCenter(a: any, b: any) {
return Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
}
// structured pointer-event payload — stable across targets, avoids handing the
// raw engine event (with its circular `target: Map`) to consumers.
function payload(e: any) {
return {
lngLat: e.lngLat ? {
lng: e.lngLat.lng,
lat: e.lngLat.lat
} : null,
point: e.point ? {
x: e.point.x,
y: e.point.y
} : null,
features: e.features || [],
originalEvent: e.originalEvent
};
}
function buildControl(spec: any) {
const type = typeof spec === 'string' ? spec : spec.type;
const opts = typeof spec === 'object' && spec.options || {};
if (type === 'navigation') return new maplibregl.NavigationControl(opts);
if (type === 'geolocate') return new maplibregl.GeolocateControl(opts);
if (type === 'scale') return new maplibregl.ScaleControl(opts);
if (type === 'fullscreen') return new maplibregl.FullscreenControl(opts);
if (type === 'attribution') return new maplibregl.AttributionControl(opts);
return null;
}
// Standard controls reconcile — no $portals/$emit, so top-level. Remove-all +
// re-add from the config (controls rarely change; cheap and order-correct).
function applyControls() {
if (!instance) return;
for (const c of controlInstances as any) instance.removeControl(c);
controlInstances = [];
for (const spec of local.controls as any) {
if (!spec) continue;
const ctrl = buildControl(spec);
if (!ctrl) continue;
const position = typeof spec === 'object' && spec.position || undefined;
instance.addControl(ctrl, position);
controlInstances.push(ctrl);
}
}
// Interaction-toggle reconcile — each toggle maps to a runtime handler object.
function applyInteractionToggles() {
if (!instance) return;
const set = (name: any, on: any) => {
const handler = instance[name];
if (handler) on ? handler.enable() : handler.disable();
};
set('dragPan', local.dragPan);
set('dragRotate', local.dragRotate);
set('scrollZoom', local.scrollZoom);
set('doubleClickZoom', local.doubleClickZoom);
set('boxZoom', local.boxZoom);
set('keyboard', local.keyboard);
set('touchZoomRotate', local.touchZoomRotate);
set('touchPitch', local.touchPitch);
}
// Style-load-gated source/layer reconcile. Order matters: drop removed layers
// FIRST, then add/update sources, then add/update layers, then drop removed
// sources (after their layers are gone).
function applyLayers() {
if (!instance || !instance.isStyleLoaded()) return;
// ─── union the config-array props with the declarative-children registry ────
// (registry ∪ props), keyed by id. D-02: the registry (declarative children) is
// the LAST writer and overrides the config-array on id collision. Ordering: array
// entries first in array order, then registry entries in registration order —
// `[...$props.layers, ...registryLayers]` — each still honoring its explicit
// `beforeId` (the existing applyLayers ordering contract, REUSED unchanged,
// RESEARCH OQ3). The empty-registry path is byte-equivalent to today: with both
// registries empty, mergeById returns exactly the config array (dedup by id of
// an array with no registry overrides is the array itself), so (∅ ∪ props) ===
// props in behavior — the dist-parity zero-drift guarantee (RESEARCH A3).
const mergeById = (arr: any, reg: any) => {
// out seeded from the (any-typed) input so strict tsc infers any[] not never[]
// (untyped <script> can't use a TS `: any[]`/`as any[]` annotation; .slice(0,0)
// yields an empty array with identical runtime behavior to `const out = []`).
const out = (Array.isArray(arr) ? arr : []).slice(0, 0);
const idx = new Map();
for (const e of (Array.isArray(arr) ? arr : []) as any) {
if (!e || !e.id) {
out.push(e);
continue;
}
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
for (const id in reg) {
const e = reg[id];
if (!e || !e.id) continue;
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
return out;
};
const mergedSources = mergeById(local.sources, sourceReg());
const mergedLayers = mergeById(local.layers, layerReg());
const wantLayerIds = mergedLayers.map((l: any) => l && l.id).filter(Boolean);
const wantSourceIds = mergedSources.map((s: any) => s && s.id).filter(Boolean);
// 1. drop removed layers
for (const id of appliedLayerIds as any) {
if (!wantLayerIds.includes(id) && instance.getLayer(id)) instance.removeLayer(id);
}
// 2. add/update sources
for (const s of mergedSources as any) {
if (!s || !s.id) continue;
const spec = s.spec || s;
const existing = instance.getSource(s.id);
if (!existing) instance.addSource(s.id, spec);else if (spec.type === 'geojson' && spec.data) existing.setData(spec.data);
}
// 3. add/update layers. DEFENSIVE: a non-background layer whose `source` is not
// (yet) present in the engine is SKIPPED rather than added — a declarative
// <Layer> may register before its <Source> parent has supplied the source id
// (child-before-parent mount order on React/Vue/Svelte/Angular), in which case
// addLayer would throw "source ... doesn't exist" / read null `.type` and abort
// the whole loop (dropping later layers like `bg`). The <Layer> re-registers with
// the resolved source on $onUpdate, re-running this reconcile, so the layer lands
// on the next tick. Background layers need no source. addLayer is wrapped so any
// single malformed spec can't abort the rest of the loop either.
for (const l of mergedLayers as any) {
if (!l || !l.id) continue;
if (!instance.getLayer(l.id)) {
const needsSource = l.type !== 'background';
if (needsSource && (l.source == null || !instance.getSource(l.source))) continue;
// Build a CLEAN LayerSpecification: a declarative <Layer> registry spec carries
// a `beforeId` (not a LayerSpecification key — it is the addLayer 2nd arg) and
// explicit `source: undefined` / `layout: undefined` keys (the prop defaults).
// MapLibre v5 rejects a background layer that has ANY `source` key, and an
// undefined `layout` — so emit only the keys MapLibre expects (the config-array
// path is unaffected: those specs are already clean, this just re-emits them).
// null-let → typeNeutralize `any` so the dynamic key assignments below
// type-check on the strict bundled leaves (the `let x = null` idiom).
let clean: any = null;
clean = {
id: l.id,
type: l.type
};
if (needsSource) clean.source = l.source;
if (l.paint != null) clean.paint = l.paint;
if (l.layout != null) clean.layout = l.layout;
if (l.sourceLayer != null) clean['source-layer'] = l.sourceLayer;
if (l.filter != null) clean.filter = l.filter;
if (l.minzoom != null) clean.minzoom = l.minzoom;
if (l.maxzoom != null) clean.maxzoom = l.maxzoom;
try {
instance.addLayer(clean, l.beforeId);
} catch (e: any) {
// surfaced via the `error` emit path; skip so later layers still apply.
}
} else {
if (l.paint) for (const k in l.paint) instance.setPaintProperty(l.id, k, l.paint[k]);
if (l.layout) for (const k in l.layout) instance.setLayoutProperty(l.id, k, l.layout[k]);
}
}
// 4. drop removed sources (their layers are gone)
for (const id of appliedSourceIds as any) {
if (!wantSourceIds.includes(id) && instance.getSource(id)) instance.removeSource(id);
}
appliedLayerIds = wantLayerIds;
appliedSourceIds = wantSourceIds;
}
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 15 verbs. Collision-clear across all 3 classes: NOT a React model-setter
// (setCenter/setZoom/setBearing/setPitch are the auto-gen'd ones — none here);
// NOT a Lit lifecycle name (update/render/firstUpdated/updated/willUpdate/
// requestUpdate); NOT an emitted event name (move/zoom/rotate/pitch/drag/click/
// idle/error — getCenter/getZoom/resize/flyTo/easeTo/jumpTo/fitBounds/getMap all
// differ; zoomIn/zoomOut differ from the `zoomend` emit). The camera verbs
// deliberately omit PROGRAMMATIC so an imperative move echoes into $model (the
// prop $watch then no-ops, getCenter already matching).
//
// Camera control is well-covered above; the read/hit-test/projection family
// below is what a consumer needs to build custom controls, overlays, and click
// interactivity — none reachable via prop/model/event:
// - queryRenderedFeatures: hit-test "what's under this pixel/box" (click-to-
// inspect, selection beyond per-layer mouseenter/leave).
// - project / unproject: convert geo<->screen for positioning framework DOM
// overlays over map coordinates.
// - getBounds: read the live visible viewport bbox (lazy-fetch data for the
// current view) — distinct from the construction-only `bounds` prop.
// - zoomIn / zoomOut / panBy: ergonomic nudges for a consumer's own controls.
function getMap() {
return instance;
}
function flyTo(opts: any) {
if (instance) instance.flyTo(opts);
}
function easeTo(opts: any) {
if (instance) instance.easeTo(opts);
}
function jumpTo(opts: any) {
if (instance) instance.jumpTo(opts);
}
function fitBounds(bounds: any, opts: any) {
if (instance) instance.fitBounds(bounds, opts);
}
function getCenter() {
if (!instance) return null;
const c = instance.getCenter();
return [c.lng, c.lat];
}
function getZoom() {
return instance ? instance.getZoom() : null;
}
function resize() {
if (instance) instance.resize();
}
function queryRenderedFeatures(geometry: any, options: any) {
return instance ? instance.queryRenderedFeatures(geometry, options) : [];
}
function project(lngLat: any) {
return instance ? instance.project(lngLat) : null;
}
function unproject(point: any) {
return instance ? instance.unproject(point) : null;
}
function getBounds() {
return instance ? instance.getBounds() : null;
}
function zoomIn(opts: any) {
if (instance) instance.zoomIn(opts);
}
function zoomOut(opts: any) {
if (instance) instance.zoomOut(opts);
}
function panBy(offset: any, opts: any) {
if (instance) instance.panBy(offset, opts);
}
return (
<__ctx_maplibre_sources.Provider value={{
register: (id: any, spec: any) => {
setSourceReg({
...sourceReg(),
[id]: spec
});
},
update: (id: any, spec: any) => {
setSourceReg({
...sourceReg(),
[id]: spec
});
},
unregister: (id: any) => {
const n = {
...sourceReg()
};
delete n[id];
setSourceReg(n);
}
}}>
<__ctx_maplibre_layers.Provider value={{
register: (id: any, spec: any) => {
setLayerReg({
...layerReg(),
[id]: spec
});
},
update: (id: any, spec: any) => {
setLayerReg({
...layerReg(),
[id]: spec
});
},
unregister: (id: any) => {
const n = {
...layerReg()
};
delete n[id];
setLayerReg(n);
}
}}>
<>
<div class={"rozie-maplibre"} ref={(el) => { containerElRef = el as HTMLElement; }} data-rozie-s-f1ee1082="" />
{resolved()}
</>
</__ctx_maplibre_layers.Provider>
</__ctx_maplibre_sources.Provider>
);
}ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, createLitControllableProperty, injectGlobalStyles } from '@rozie/runtime-lit';
import { ContextProvider, createContext } from '@lit/context';
import maplibregl from 'maplibre-gl';
const __rozieCtx_maplibre_sources = createContext(Symbol.for("rozie:maplibre:sources"));
const __rozieCtx_maplibre_layers = createContext(Symbol.for("rozie:maplibre:layers"));
interface RozieMarkerSlotCtx {
marker: unknown;
index: unknown;
}
interface RoziePopupSlotCtx {
popup: unknown;
index: unknown;
}
interface RozieControlSlotCtx {
map: unknown;
}
@customElement('rozie-map-libre')
export default class MapLibre extends SignalWatcher(LitElement) {
static styles = css`
.rozie-maplibre[data-rozie-s-f1ee1082] {
width: 100%;
height: 100%;
min-height: 300px;
position: relative;
overflow: hidden;
border-radius: 6px;
}
.rozie-maplibre .rozie-maplibre-marker {
cursor: pointer;
}
.rozie-maplibre .rozie-maplibre-control {
display: flex;
flex-direction: column;
}
`;
/**
* The map center as `[lng, lat]` — **longitude first** (MapLibre's convention, not Leaflet's `[lat, lng]`). Two-way: panning the map writes the new center back through the model path (echo-guarded), and a consumer write `easeTo`s the live map. The `moveend` echo reads `getCenter()` as `[lng, lat]`.
* @example
* <MapLibre r-model:center="center" r-model:zoom="zoom" />
*/
@property({ type: Array, attribute: 'center' }) _center_attr: any[] = [0, 0];
private _centerControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'center-change', defaultValue: [0, 0], initialControlledValue: undefined });
/**
* The zoom level. Two-way: scroll / pinch writes the new zoom back, and a consumer write `easeTo`s the camera. Echo-guarded against the wrapper's own programmatic moves.
*/
@property({ type: Number, attribute: 'zoom' }) _zoom_attr: number = 1;
private _zoomControllable = createLitControllableProperty<number>({ host: this, eventName: 'zoom-change', defaultValue: 1, initialControlledValue: undefined });
/**
* The map rotation (bearing) in degrees. Two-way via the `rotateend` echo and the `easeTo` reconcile.
*/
@property({ type: Number, attribute: 'bearing' }) _bearing_attr: number = 0;
private _bearingControllable = createLitControllableProperty<number>({ host: this, eventName: 'bearing-change', defaultValue: 0, initialControlledValue: undefined });
/**
* The map tilt (pitch) in degrees. Two-way via the `pitchend` echo and the `easeTo` reconcile.
*/
@property({ type: Number, attribute: 'pitch' }) _pitch_attr: number = 0;
private _pitchControllable = createLitControllableProperty<number>({ host: this, eventName: 'pitch-change', defaultValue: 0, initialControlledValue: undefined });
/**
* The map style — a `StyleSpecification` object **or** a style-URL string. Named `mapStyle` (not `style`) because `style` is a reserved attribute across the targets — `react-map-gl` and `vue-maplibre-gl` use the same name for the same reason. Defaults to MapLibre's official no-token demo tiles, so the component "just works" with zero config. Changing it calls `setStyle` and re-applies your `sources` / `layers` once the new style loads.
*/
@property({ type: Object }) mapStyle: unknown = undefined;
/**
* Minimum zoom level. Applied at construction and via `setMinZoom` on change.
*/
@property({ type: Number, reflect: true }) minZoom: number = 0;
/**
* Maximum zoom level. Applied at construction and via `setMaxZoom` on change.
*/
@property({ type: Number, reflect: true }) maxZoom: number = 22;
/**
* A `LngLatBoundsLike` the camera is constrained to. Applied via `setMaxBounds` on change (pass `undefined` to clear).
*/
@property({ type: Object }) maxBounds: unknown = undefined;
/**
* **Construction-only** initial fit — a `LngLatBoundsLike` the map fits to on mount (overrides `center` / `zoom` when set). Pair with `fitBoundsOptions`.
*/
@property({ type: Object }) bounds: unknown = undefined;
/**
* **Construction-only** options for the initial `bounds` fit (padding, max-zoom, etc.).
*/
@property({ type: Object }) fitBoundsOptions: any = {};
/**
* Toggle drag-to-pan. Applied at construction and reconciled live via the handler's `enable()` / `disable()`.
*/
@property({ type: Boolean, reflect: true }) dragPan: boolean = true;
/**
* Toggle right-drag / ctrl-drag rotation. Applied at construction and reconciled live.
*/
@property({ type: Boolean, reflect: true }) dragRotate: boolean = true;
/**
* Toggle scroll-wheel zoom. Applied at construction and reconciled live.
*/
@property({ type: Boolean, reflect: true }) scrollZoom: boolean = true;
/**
* Toggle double-click zoom. Applied at construction and reconciled live.
*/
@property({ type: Boolean, reflect: true }) doubleClickZoom: boolean = true;
/**
* Toggle shift-drag box zoom. Applied at construction and reconciled live.
*/
@property({ type: Boolean, reflect: true }) boxZoom: boolean = true;
/**
* Toggle keyboard navigation. Applied at construction and reconciled live.
*/
@property({ type: Boolean, reflect: true }) keyboard: boolean = true;
/**
* Toggle touch pinch-zoom + rotate. Applied at construction and reconciled live.
*/
@property({ type: Boolean, reflect: true }) touchZoomRotate: boolean = true;
/**
* Toggle two-finger touch pitch. Applied at construction and reconciled live.
*/
@property({ type: Boolean, reflect: true }) touchPitch: boolean = true;
/**
* The marker data that drives the reactive multi-instance `marker` slot — one entry per marker (`{ lng, lat, id?, anchor?, offset?, draggable?, ... }`). One portal handle mounts per entry; changing the array reconciles markers keep / update / dispose with no remount. Only meaningful when the `marker` slot is filled.
*/
@property({ type: Array }) markers: any[] = [];
/**
* The popup data that drives the reactive multi-instance `popup` slot — one entry per popup (`{ lng, lat, id?, anchor?, offset?, closeButton?, closeOnClick?, ... }`). One portal handle mounts per entry. Only meaningful when the `popup` slot is filled.
*/
@property({ type: Array }) popups: any[] = [];
/**
* Declarative GeoJSON / vector / raster sources — `[{ id, spec }]` (or a bare `SourceSpecification` carrying an `id`). Reconciled into the live style (add / `setData` / remove) once the style has loaded. The config-array authoring shape for sources; declarative `<Source>` / `<Layer>` children are the alternative shape (both feed the same registry).
*/
@property({ type: Array }) sources: any[] = [];
/**
* Declarative layers — `LayerSpecification[]` (each with an `id`). Reconciled into the live style (add / `setPaintProperty` / `setLayoutProperty` / remove) once the style has loaded; `beforeId` controls draw order.
*/
@property({ type: Array }) layers: any[] = [];
/**
* Layer ids whose feature `mouseenter` / `mouseleave` fire the `@mouseenter` / `@mouseleave` events (populating `e.features`). Registered / unregistered per id on change.
*/
@property({ type: Array }) interactiveLayerIds: any[] = [];
/**
* Standard map controls — strings (`'navigation'` / `'geolocate'` / `'scale'` / `'fullscreen'` / `'attribution'`) or `{ type, position?, options? }` objects. Reconciled (remove-all + re-add) on change.
*/
@property({ type: Array }) controls: any[] = [];
/**
* The raw `MapOptions` passthrough — spread into the `Map` constructor **before** the curated keys, so explicit props win. The MapLibre analog of an options bag for anything the curated surface doesn't special-case.
*/
@property({ type: Object }) options: any = {};
private _sourceReg = signal<any>({});
private _layerReg = signal<any>({});
@query('[data-rozie-ref="containerEl"]') private _refContainerEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_12 = true;
private __rozieWatchInitial_13 = true;
private __rozieFirstUpdateDone = false;
private _portalContainers = new Set<HTMLElement>();
private __rozieCtxProvider_maplibre_sources = new ContextProvider(this, { context: __rozieCtx_maplibre_sources, initialValue: ((__rozieCtxHost) => ({
register: (id: any, spec: any) => {
__rozieCtxHost._sourceReg.value = {
...__rozieCtxHost._sourceReg.value,
[id]: spec
};
},
update: (id: any, spec: any) => {
__rozieCtxHost._sourceReg.value = {
...__rozieCtxHost._sourceReg.value,
[id]: spec
};
},
unregister: (id: any) => {
const n = {
...__rozieCtxHost._sourceReg.value
};
delete n[id];
__rozieCtxHost._sourceReg.value = n;
}
}))(this) });
private __rozieCtxProvider_maplibre_layers = new ContextProvider(this, { context: __rozieCtx_maplibre_layers, initialValue: ((__rozieCtxHost) => ({
register: (id: any, spec: any) => {
__rozieCtxHost._layerReg.value = {
...__rozieCtxHost._layerReg.value,
[id]: spec
};
},
update: (id: any, spec: any) => {
__rozieCtxHost._layerReg.value = {
...__rozieCtxHost._layerReg.value,
[id]: spec
};
},
unregister: (id: any) => {
const n = {
...__rozieCtxHost._layerReg.value
};
delete n[id];
__rozieCtxHost._layerReg.value = n;
}
}))(this) });
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
@state() private _hasSlotMarker = false;
@queryAssignedElements({ slot: 'marker', flatten: true }) private _slotMarkerElements!: Element[];
@property({ attribute: false }) marker?: (scope: { marker: unknown; index: unknown }) => unknown;
@state() private _hasSlotPopup = false;
@queryAssignedElements({ slot: 'popup', flatten: true }) private _slotPopupElements!: Element[];
@property({ attribute: false }) popup?: (scope: { popup: unknown; index: unknown }) => unknown;
@state() private _hasSlotControl = false;
@queryAssignedElements({ slot: 'control', flatten: true }) private _slotControlElements!: Element[];
@property({ attribute: false }) control?: (scope: { map: unknown }) => unknown;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="marker"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotMarker = this._slotMarkerElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="popup"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotPopup = this._slotPopupElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="control"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotControl = this._slotControlElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
this._hasSlotMarker = Array.from(this.children).some((el) => el.getAttribute('slot') === 'marker');
this._hasSlotPopup = Array.from(this.children).some((el) => el.getAttribute('slot') === 'popup');
this._hasSlotControl = Array.from(this.children).some((el) => el.getAttribute('slot') === 'control');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
adoptDocumentStyles(this);
this._armListeners();
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
marker: (container: HTMLElement, scope: { marker: unknown; index: unknown }): ReactivePortalHandle => {
const tpl = this.marker;
if (typeof tpl !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-marker', 'f1ee1082');
const renderScope = (s: { marker: unknown; index: unknown }): void => {
render(tpl(s), container);
};
renderScope(scope);
this._portalContainers.add(container);
return {
update: (s: { marker: unknown; index: unknown }): void => renderScope(s),
dispose: (): void => {
render(nothing, container);
this._portalContainers.delete(container);
},
};
},
popup: (container: HTMLElement, scope: { popup: unknown; index: unknown }): ReactivePortalHandle => {
const tpl = this.popup;
if (typeof tpl !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-popup', 'f1ee1082');
const renderScope = (s: { popup: unknown; index: unknown }): void => {
render(tpl(s), container);
};
renderScope(scope);
this._portalContainers.add(container);
return {
update: (s: { popup: unknown; index: unknown }): void => renderScope(s),
dispose: (): void => {
render(nothing, container);
this._portalContainers.delete(container);
},
};
},
control: (container: HTMLElement, scope: { map: unknown }): (() => void) => {
const tpl = this.control;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-control', 'f1ee1082');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
};
this._disconnectCleanups.push((() => {
for (const [, entry] of this.markerEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
this.markerEntries.clear();
for (const [, entry] of this.popupEntries as any) {
entry.handle.dispose();
entry.engine.remove();
}
this.popupEntries.clear();
if (this.controlDispose) this.controlDispose();
if (this.instance) this.instance.remove();
}));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.center)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!this.instance || !Array.isArray(v) || v.length !== 2) return;
const c = this.instance.getCenter();
if (v[0] === c.lng && v[1] === c.lat) return;
this.instance.easeTo({
center: v,
animate: false
}, this.PROGRAMMATIC);
})(__watchVal); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.zoom)(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => {
if (!this.instance || typeof v !== 'number' || v === this.instance.getZoom()) return;
this.instance.easeTo({
zoom: v,
animate: false
}, this.PROGRAMMATIC);
})(__watchVal); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.bearing)(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } ((v: any) => {
if (!this.instance || typeof v !== 'number' || v === this.instance.getBearing()) return;
this.instance.easeTo({
bearing: v,
animate: false
}, this.PROGRAMMATIC);
})(__watchVal); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.pitch)(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => {
if (!this.instance || typeof v !== 'number' || v === this.instance.getPitch()) return;
this.instance.easeTo({
pitch: v,
animate: false
}, this.PROGRAMMATIC);
})(__watchVal); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._sourceReg.value)(); untracked(() => { if (this.__rozieWatchInitial_12) { this.__rozieWatchInitial_12 = false; return; } (() => this.applyLayers())(); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._layerReg.value)(); untracked(() => { if (this.__rozieWatchInitial_13) { this.__rozieWatchInitial_13 = false; return; } (() => this.applyLayers())(); }); }));
this._disconnectCleanups.push(effect(() => { void this._sourceReg.value; this.__rozieCtxProvider_maplibre_sources.setValue(((__rozieCtxHost) => ({
register: (id: any, spec: any) => {
__rozieCtxHost._sourceReg.value = {
...__rozieCtxHost._sourceReg.value,
[id]: spec
};
},
update: (id: any, spec: any) => {
__rozieCtxHost._sourceReg.value = {
...__rozieCtxHost._sourceReg.value,
[id]: spec
};
},
unregister: (id: any) => {
const n = {
...__rozieCtxHost._sourceReg.value
};
delete n[id];
__rozieCtxHost._sourceReg.value = n;
}
}))(this)); }));
this._disconnectCleanups.push(effect(() => { void this._layerReg.value; this.__rozieCtxProvider_maplibre_layers.setValue(((__rozieCtxHost) => ({
register: (id: any, spec: any) => {
__rozieCtxHost._layerReg.value = {
...__rozieCtxHost._layerReg.value,
[id]: spec
};
},
update: (id: any, spec: any) => {
__rozieCtxHost._layerReg.value = {
...__rozieCtxHost._layerReg.value,
[id]: spec
};
},
unregister: (id: any) => {
const n = {
...__rozieCtxHost._layerReg.value
};
delete n[id];
__rozieCtxHost._layerReg.value = n;
}
}))(this)); }));
const el = this._refContainerEl;
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
// seed the null-let tracking arrays (declared null so typeNeutralize types them
// `any`; the reconcile/teardown code only runs after this mount init).
this.controlInstances = [];
this.appliedLayerIds = [];
this.appliedSourceIds = [];
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
// mapOptions is a null-let so the bundled-leaf typeNeutralize pass annotates it
// `any` — MapLibre's MapOptions strict-types center (LngLatLike tuple), style
// (string|StyleSpecification) and maxBounds/bounds (LngLatBoundsLike), which the
// loosely-typed .rozie props (any[] / unknown) don't satisfy under the strict
// react/solid/lit tsc. Routing the construction through an `any` options object
// is the .rozie-native fix (no codegen type-aid, no lang="ts") — the same
// null-let idiom `let instance = null` already relies on.
let mapOptions: any = null;
mapOptions = {
container: el,
...this.options,
style: this.mapStyle ?? this.DEFAULT_STYLE,
center: this.center,
zoom: this.zoom,
bearing: this.bearing,
pitch: this.pitch,
minZoom: this.minZoom,
maxZoom: this.maxZoom,
maxBounds: this.maxBounds,
bounds: this.bounds,
fitBoundsOptions: this.fitBoundsOptions,
dragPan: this.dragPan,
dragRotate: this.dragRotate,
scrollZoom: this.scrollZoom,
doubleClickZoom: this.doubleClickZoom,
boxZoom: this.boxZoom,
keyboard: this.keyboard,
touchZoomRotate: this.touchZoomRotate,
touchPitch: this.touchPitch
};
this.instance = new maplibregl.Map(mapOptions);
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
// ─── forward map events ─────────────────────────────────────────────────
// NOTE: the CONTINUOUS `zoom` and `pitch` events are deliberately NOT forwarded
// — `zoom` and `pitch` are also two-way `model: true` camera props, and a same-
// named emit collides with the model on Vue (defineModel vs defineEmits) and
// Angular (ModelSignal vs OutputEmitterRef). The two-way binding already conveys
// zoom/pitch changes; consumers wanting an event get the terminal `zoomend` /
// `pitchend` below. `move`/`rotate` have no such clash (the models are `center`
// and `bearing`, not `move`/`rotate`), so those continuous events stay.
this.instance.on('load', (e: any) => this.dispatchEvent(new CustomEvent("load", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('idle', (e: any) => this.dispatchEvent(new CustomEvent("idle", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('move', (e: any) => this.dispatchEvent(new CustomEvent("move", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('rotate', (e: any) => this.dispatchEvent(new CustomEvent("rotate", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('dragstart', (e: any) => this.dispatchEvent(new CustomEvent("dragstart", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('drag', (e: any) => this.dispatchEvent(new CustomEvent("drag", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('dragend', (e: any) => this.dispatchEvent(new CustomEvent("dragend", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('click', (e: any) => this.dispatchEvent(new CustomEvent("click", {
detail: this.payload(e),
bubbles: true,
composed: true
})));
this.instance.on('dblclick', (e: any) => this.dispatchEvent(new CustomEvent("dblclick", {
detail: this.payload(e),
bubbles: true,
composed: true
})));
this.instance.on('contextmenu', (e: any) => this.dispatchEvent(new CustomEvent("contextmenu", {
detail: this.payload(e),
bubbles: true,
composed: true
})));
this.instance.on('mousemove', (e: any) => this.dispatchEvent(new CustomEvent("mousemove", {
detail: this.payload(e),
bubbles: true,
composed: true
})));
this.instance.on('error', (e: any) => this.dispatchEvent(new CustomEvent("error", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('styledata', (e: any) => this.dispatchEvent(new CustomEvent("styledata", {
detail: e,
bubbles: true,
composed: true
})));
this.instance.on('sourcedata', (e: any) => this.dispatchEvent(new CustomEvent("sourcedata", {
detail: e,
bubbles: true,
composed: true
})));
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
// ─── camera-lifecycle + two-way echo (echo-guarded) ─────────────────────
this.instance.on('moveend', (e: any) => {
this.dispatchEvent(new CustomEvent("moveend", {
detail: e,
bubbles: true,
composed: true
}));
if (e.rozieProgrammatic) return;
const c = this.instance.getCenter();
const next = [c.lng, c.lat];
if (!this.sameCenter(next, this.center)) this._centerControllable.write(next);
const z = this.instance.getZoom();
if (z !== this.zoom) this._zoomControllable.write(z);
});
this.instance.on('zoomend', (e: any) => {
this.dispatchEvent(new CustomEvent("zoomend", {
detail: e,
bubbles: true,
composed: true
}));
if (e.rozieProgrammatic) return;
const z = this.instance.getZoom();
if (z !== this.zoom) this._zoomControllable.write(z);
});
this.instance.on('rotateend', (e: any) => {
this.dispatchEvent(new CustomEvent("rotateend", {
detail: e,
bubbles: true,
composed: true
}));
if (e.rozieProgrammatic) return;
const b = this.instance.getBearing();
if (b !== this.bearing) this._bearingControllable.write(b);
});
this.instance.on('pitchend', (e: any) => {
this.dispatchEvent(new CustomEvent("pitchend", {
detail: e,
bubbles: true,
composed: true
}));
if (e.rozieProgrammatic) return;
const p = this.instance.getPitch();
if (p !== this.pitch) this._pitchControllable.write(p);
});
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
// ─── REACTIVE MULTI-INSTANCE marker portal slot ─────────────────────────
// One reactive portal handle per markers[] entry, reconciled keep/update/dispose
// on prop change. Built here so $portals.marker is in the mount scope; bridged
// to the top-level $watch via reconcileMarkers (CM rebuildGutterExt discipline).
this.reconcileMarkers = (list: any) => {
if (!(this.marker !== undefined)) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((m: any, index: any) => {
if (!m || typeof m.lng !== 'number' || typeof m.lat !== 'number') return;
const key = m.id != null ? m.id : index;
seen.add(key);
const scope = {
marker: m,
index
};
const entry = this.markerEntries.get(key);
if (entry) {
entry.engine.setLngLat([m.lng, m.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-marker';
const handle = portals.marker(node, scope);
const engine = new maplibregl.Marker({
element: node,
anchor: m.anchor,
offset: m.offset,
draggable: m.draggable
}).setLngLat([m.lng, m.lat]).addTo(this.instance);
this.markerEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of this.markerEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
this.markerEntries.delete(key);
}
}
};
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
// ─── REACTIVE MULTI-INSTANCE popup portal slot ──────────────────────────
this.reconcilePopups = (list: any) => {
if (!(this.popup !== undefined)) return;
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
arr.forEach((p: any, index: any) => {
if (!p || typeof p.lng !== 'number' || typeof p.lat !== 'number') return;
const key = p.id != null ? p.id : index;
seen.add(key);
const scope = {
popup: p,
index
};
const entry = this.popupEntries.get(key);
if (entry) {
entry.engine.setLngLat([p.lng, p.lat]);
entry.handle.update(scope);
} else {
const node = document.createElement('div');
node.className = 'rozie-maplibre-popup-body';
const handle = portals.popup(node, scope);
const engine = new maplibregl.Popup({
closeButton: p.closeButton !== undefined ? p.closeButton : true,
closeOnClick: p.closeOnClick !== undefined ? p.closeOnClick : false,
anchor: p.anchor,
offset: p.offset
}).setLngLat([p.lng, p.lat]).setDOMContent(node).addTo(this.instance);
this.popupEntries.set(key, {
engine,
handle,
el: node
});
}
});
for (const [key, entry] of this.popupEntries as any) {
if (!seen.has(key)) {
entry.handle.dispose();
entry.engine.remove();
this.popupEntries.delete(key);
}
}
};
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
// ─── layer-scoped feature mouseenter/mouseleave (needs a layer id) ───────
this.reconcileInteractive = (ids: any) => {
const want = (Array.isArray(ids) ? ids : []).filter(Boolean);
for (const [id, l] of this.featureListeners as any) {
if (!want.includes(id)) {
this.instance.off('mouseenter', id, l.enter);
this.instance.off('mouseleave', id, l.leave);
this.featureListeners.delete(id);
}
}
for (const id of want as any) {
if (this.featureListeners.has(id)) continue;
const enter = (e: any) => this.dispatchEvent(new CustomEvent("mouseenter", {
detail: this.payload(e),
bubbles: true,
composed: true
}));
const leave = (e: any) => this.dispatchEvent(new CustomEvent("mouseleave", {
detail: this.payload(e),
bubbles: true,
composed: true
}));
this.instance.on('mouseenter', id, enter);
this.instance.on('mouseleave', id, leave);
this.featureListeners.set(id, {
enter,
leave
});
}
};
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
// ─── mount-once custom CONTROL portal slot ──────────────────────────────
if (this.control !== undefined) {
const host = document.createElement('div');
host.className = 'maplibregl-ctrl rozie-maplibre-control';
this.customControl = {
onAdd() {
return host;
},
onRemove() {
if (host.parentNode) host.parentNode.removeChild(host);
}
};
this.instance.addControl(this.customControl, 'top-right');
this.controlDispose = portals.control(host, {
map: this.instance
});
}
// standard controls + interaction toggles don't need style load.
// standard controls + interaction toggles don't need style load.
this.applyControls();
this.applyInteractionToggles();
// markers/popups/interactive are DOM/event overlays — no style-load gate.
// markers/popups/interactive are DOM/event overlays — no style-load gate.
this.reconcileMarkers(this.markers);
this.reconcilePopups(this.popups);
this.reconcileInteractive(this.interactiveLayerIds);
// sources/layers need the style loaded.
// sources/layers need the style loaded.
if (this.instance.isStyleLoaded()) this.applyLayers();else this.instance.on('load', this.applyLayers);
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('mapStyle'))) { const __watchVal = (() => this.mapStyle)(); ((v: any) => {
if (!this.instance) return;
// a new style wipes imperatively-added sources/layers — reset the applied
// tracking and re-apply once the new style loads.
this.appliedLayerIds = [];
this.appliedSourceIds = [];
this.instance.setStyle(v ?? this.DEFAULT_STYLE);
this.instance.once('styledata', () => this.applyLayers());
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('minZoom'))) { const __watchVal = (() => this.minZoom)(); ((v: any) => {
if (this.instance && typeof v === 'number') this.instance.setMinZoom(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('maxZoom'))) { const __watchVal = (() => this.maxZoom)(); ((v: any) => {
if (this.instance && typeof v === 'number') this.instance.setMaxZoom(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('maxBounds'))) { const __watchVal = (() => this.maxBounds)(); ((v: any) => {
if (this.instance) this.instance.setMaxBounds(v || null);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('markers'))) { const __watchVal = (() => this.markers)(); ((v: any) => {
if (this.reconcileMarkers) this.reconcileMarkers(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('popups'))) { const __watchVal = (() => this.popups)(); ((v: any) => {
if (this.reconcilePopups) this.reconcilePopups(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('sources'))) { const __watchVal = (() => this.sources)(); (() => this.applyLayers())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('layers'))) { const __watchVal = (() => this.layers)(); (() => this.applyLayers())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('interactiveLayerIds'))) { const __watchVal = (() => this.interactiveLayerIds)(); ((v: any) => {
if (this.reconcileInteractive) this.reconcileInteractive(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('controls'))) { const __watchVal = (() => this.controls)(); (() => this.applyControls())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('dragPan'))) { const __watchVal = (() => this.dragPan)(); (() => this.applyInteractionToggles())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('dragRotate'))) { const __watchVal = (() => this.dragRotate)(); (() => this.applyInteractionToggles())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('scrollZoom'))) { const __watchVal = (() => this.scrollZoom)(); (() => this.applyInteractionToggles())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('doubleClickZoom'))) { const __watchVal = (() => this.doubleClickZoom)(); (() => this.applyInteractionToggles())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('boxZoom'))) { const __watchVal = (() => this.boxZoom)(); (() => this.applyInteractionToggles())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('keyboard'))) { const __watchVal = (() => this.keyboard)(); (() => this.applyInteractionToggles())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('touchZoomRotate'))) { const __watchVal = (() => this.touchZoomRotate)(); (() => this.applyInteractionToggles())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('touchPitch'))) { const __watchVal = (() => this.touchPitch)(); (() => this.applyInteractionToggles())(); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const container of this._portalContainers) render(nothing, container);
this._portalContainers.clear();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'center') this._centerControllable.notifyAttributeChange(value as unknown as any[]);
if (name === 'zoom') this._zoomControllable.notifyAttributeChange(value === null ? 1 : Number(value));
if (name === 'bearing') this._bearingControllable.notifyAttributeChange(value === null ? 0 : Number(value));
if (name === 'pitch') this._pitchControllable.notifyAttributeChange(value === null ? 0 : Number(value));
}
render() {
return html`
<div class="rozie-maplibre" data-rozie-ref="containerEl" data-rozie-s-f1ee1082></div>
<slot></slot>
<slot name="marker"></slot>
<slot name="popup"></slot>
<slot name="control"></slot>
`;
}
instance: any = null;
DEFAULT_STYLE = 'https://demotiles.maplibre.org/style.json';
PROGRAMMATIC = {
rozieProgrammatic: true
};
markerEntries = new Map();
popupEntries = new Map();
controlInstances: any = null;
controlDispose: any = null;
customControl: any = null;
featureListeners = new Map();
appliedLayerIds: any = null;
appliedSourceIds: any = null;
reconcileMarkers: any = null;
reconcilePopups: any = null;
reconcileInteractive: any = null;
sameCenter = (a: any, b: any) => Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
payload = (e: any) => ({
lngLat: e.lngLat ? {
lng: e.lngLat.lng,
lat: e.lngLat.lat
} : null,
point: e.point ? {
x: e.point.x,
y: e.point.y
} : null,
features: e.features || [],
originalEvent: e.originalEvent
});
buildControl = (spec: any) => {
const type = typeof spec === 'string' ? spec : spec.type;
const opts = typeof spec === 'object' && spec.options || {};
if (type === 'navigation') return new maplibregl.NavigationControl(opts);
if (type === 'geolocate') return new maplibregl.GeolocateControl(opts);
if (type === 'scale') return new maplibregl.ScaleControl(opts);
if (type === 'fullscreen') return new maplibregl.FullscreenControl(opts);
if (type === 'attribution') return new maplibregl.AttributionControl(opts);
return null;
};
applyControls = () => {
if (!this.instance) return;
for (const c of this.controlInstances as any) this.instance.removeControl(c);
this.controlInstances = [];
for (const spec of this.controls as any) {
if (!spec) continue;
const ctrl = this.buildControl(spec);
if (!ctrl) continue;
const position = typeof spec === 'object' && spec.position || undefined;
this.instance.addControl(ctrl, position);
this.controlInstances.push(ctrl);
}
};
applyInteractionToggles = () => {
if (!this.instance) return;
const set = (name: any, on: any) => {
const handler = this.instance[name];
if (handler) on ? handler.enable() : handler.disable();
};
set('dragPan', this.dragPan);
set('dragRotate', this.dragRotate);
set('scrollZoom', this.scrollZoom);
set('doubleClickZoom', this.doubleClickZoom);
set('boxZoom', this.boxZoom);
set('keyboard', this.keyboard);
set('touchZoomRotate', this.touchZoomRotate);
set('touchPitch', this.touchPitch);
};
applyLayers = () => {
if (!this.instance || !this.instance.isStyleLoaded()) return;
// ─── union the config-array props with the declarative-children registry ────
// (registry ∪ props), keyed by id. D-02: the registry (declarative children) is
// the LAST writer and overrides the config-array on id collision. Ordering: array
// entries first in array order, then registry entries in registration order —
// `[...$props.layers, ...registryLayers]` — each still honoring its explicit
// `beforeId` (the existing applyLayers ordering contract, REUSED unchanged,
// RESEARCH OQ3). The empty-registry path is byte-equivalent to today: with both
// registries empty, mergeById returns exactly the config array (dedup by id of
// an array with no registry overrides is the array itself), so (∅ ∪ props) ===
// props in behavior — the dist-parity zero-drift guarantee (RESEARCH A3).
const mergeById = (arr: any, reg: any) => {
// out seeded from the (any-typed) input so strict tsc infers any[] not never[]
// (untyped <script> can't use a TS `: any[]`/`as any[]` annotation; .slice(0,0)
// yields an empty array with identical runtime behavior to `const out = []`).
const out = (Array.isArray(arr) ? arr : []).slice(0, 0);
const idx = new Map();
for (const e of (Array.isArray(arr) ? arr : []) as any) {
if (!e || !e.id) {
out.push(e);
continue;
}
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
for (const id in reg) {
const e = reg[id];
if (!e || !e.id) continue;
if (idx.has(e.id)) {
out[idx.get(e.id)] = e;
} else {
idx.set(e.id, out.length);
out.push(e);
}
}
return out;
};
const mergedSources = mergeById(this.sources, this._sourceReg.value);
const mergedLayers = mergeById(this.layers, this._layerReg.value);
const wantLayerIds = mergedLayers.map((l: any) => l && l.id).filter(Boolean);
const wantSourceIds = mergedSources.map((s: any) => s && s.id).filter(Boolean);
// 1. drop removed layers
for (const id of this.appliedLayerIds as any) {
if (!wantLayerIds.includes(id) && this.instance.getLayer(id)) this.instance.removeLayer(id);
}
// 2. add/update sources
for (const s of mergedSources as any) {
if (!s || !s.id) continue;
const spec = s.spec || s;
const existing = this.instance.getSource(s.id);
if (!existing) this.instance.addSource(s.id, spec);else if (spec.type === 'geojson' && spec.data) existing.setData(spec.data);
}
// 3. add/update layers. DEFENSIVE: a non-background layer whose `source` is not
// (yet) present in the engine is SKIPPED rather than added — a declarative
// <Layer> may register before its <Source> parent has supplied the source id
// (child-before-parent mount order on React/Vue/Svelte/Angular), in which case
// addLayer would throw "source ... doesn't exist" / read null `.type` and abort
// the whole loop (dropping later layers like `bg`). The <Layer> re-registers with
// the resolved source on $onUpdate, re-running this reconcile, so the layer lands
// on the next tick. Background layers need no source. addLayer is wrapped so any
// single malformed spec can't abort the rest of the loop either.
for (const l of mergedLayers as any) {
if (!l || !l.id) continue;
if (!this.instance.getLayer(l.id)) {
const needsSource = l.type !== 'background';
if (needsSource && (l.source == null || !this.instance.getSource(l.source))) continue;
// Build a CLEAN LayerSpecification: a declarative <Layer> registry spec carries
// a `beforeId` (not a LayerSpecification key — it is the addLayer 2nd arg) and
// explicit `source: undefined` / `layout: undefined` keys (the prop defaults).
// MapLibre v5 rejects a background layer that has ANY `source` key, and an
// undefined `layout` — so emit only the keys MapLibre expects (the config-array
// path is unaffected: those specs are already clean, this just re-emits them).
// null-let → typeNeutralize `any` so the dynamic key assignments below
// type-check on the strict bundled leaves (the `let x = null` idiom).
let clean: any = null;
clean = {
id: l.id,
type: l.type
};
if (needsSource) clean.source = l.source;
if (l.paint != null) clean.paint = l.paint;
if (l.layout != null) clean.layout = l.layout;
if (l.sourceLayer != null) clean['source-layer'] = l.sourceLayer;
if (l.filter != null) clean.filter = l.filter;
if (l.minzoom != null) clean.minzoom = l.minzoom;
if (l.maxzoom != null) clean.maxzoom = l.maxzoom;
try {
this.instance.addLayer(clean, l.beforeId);
} catch (e: any) {
// surfaced via the `error` emit path; skip so later layers still apply.
}
} else {
if (l.paint) for (const k in l.paint) this.instance.setPaintProperty(l.id, k, l.paint[k]);
if (l.layout) for (const k in l.layout) this.instance.setLayoutProperty(l.id, k, l.layout[k]);
}
}
// 4. drop removed sources (their layers are gone)
for (const id of this.appliedSourceIds as any) {
if (!wantSourceIds.includes(id) && this.instance.getSource(id)) this.instance.removeSource(id);
}
this.appliedLayerIds = wantLayerIds;
this.appliedSourceIds = wantSourceIds;
};
getMap() {
return this.instance;
}
flyTo(opts: any) {
if (this.instance) this.instance.flyTo(opts);
}
easeTo(opts: any) {
if (this.instance) this.instance.easeTo(opts);
}
jumpTo(opts: any) {
if (this.instance) this.instance.jumpTo(opts);
}
fitBounds(bounds: any, opts: any) {
if (this.instance) this.instance.fitBounds(bounds, opts);
}
getCenter() {
if (!this.instance) return null;
const c = this.instance.getCenter();
return [c.lng, c.lat];
}
getZoom() {
return this.instance ? this.instance.getZoom() : null;
}
resize() {
if (this.instance) this.instance.resize();
}
queryRenderedFeatures(geometry: any, options: any) {
return this.instance ? this.instance.queryRenderedFeatures(geometry, options) : [];
}
project(lngLat: any) {
return this.instance ? this.instance.project(lngLat) : null;
}
unproject(point: any) {
return this.instance ? this.instance.unproject(point) : null;
}
getBounds() {
return this.instance ? this.instance.getBounds() : null;
}
zoomIn(opts: any) {
if (this.instance) this.instance.zoomIn(opts);
}
zoomOut(opts: any) {
if (this.instance) this.instance.zoomOut(opts);
}
panBy(offset: any, opts: any) {
if (this.instance) this.instance.panBy(offset, opts);
}
get center(): any[] { return this._centerControllable.read(); }
set center(v: any[]) { this._centerControllable.notifyPropertyWrite(v); }
get zoom(): number { return this._zoomControllable.read(); }
set zoom(v: number) { this._zoomControllable.notifyPropertyWrite(v); }
get bearing(): number { return this._bearingControllable.read(); }
set bearing(v: number) { this._bearingControllable.notifyPropertyWrite(v); }
get pitch(): number { return this._pitchControllable.read(); }
set pitch(v: number) { this._pitchControllable.notifyPropertyWrite(v); }
}
injectGlobalStyles('rozie-map-libre-global', `
.rozie-maplibre .rozie-maplibre-marker {
cursor: pointer;
}
.rozie-maplibre .rozie-maplibre-control {
display: flex;
flex-direction: column;
}
`);Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component with model() signals, a Solid component, and a Lit custom element. Same props, same 20 events, same eight-verb imperative handle, same reactive marker / popup / control portal slots, all from the one source above.
See also
- MapLibre — showcase & API — install, quick starts for all six frameworks, the 20 events, the four two-way camera bindings, the imperative handle, and the portal slots.
- MapLibre libraries comparison — how
@rozie-ui/maplibrestacks up against the per-framework wrappers (and the Solid / Lit gap it closes).