Skip to content

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/maplibre stacks up against the per-framework wrappers (and the Solid / Lit gap it closes).

Pre-v1.0 — internal monorepo.