<!-- GENERATED by scripts/build-llms.mjs from llms/media.md — do not edit this file. -->

# `lr-map`

- **Import** `import '@aceshooting/lyra-ui/components/lr-map.js';` (stable tag alias; registers the tag)
- **Class** `LyraMap`, also available unregistered from `@aceshooting/lyra-ui/components/media/map/map.class.js`
- **Family** `components/media/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** `maplibre-gl` — see `llms/peers.md`
- **Themeable via** 26 parts, 8 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-map`

A `maplibre-gl` wrapper with a declarative legend, a single choropleth GeoJSON fill layer, markers,
and additive `dataLayers` — plain GeoJSON, natively clustered points, or a heatmap density surface —
plus a peer-neutral `map` getter for common imperative operations. Its runtime value is the
underlying MapLibre map. The component observes its own map-container allocation and calls the
peer's `resize()` when that allocation changes.

Markers and popups use the physical top-left origin required by MapLibre's projected transforms in
both LTR and RTL. Their text and surrounding controls retain logical direction-aware layout.

Live ancestor theme or token changes resolve the choropleth's stop colors and explicit or implicit
step base color again, as well as its opacity. These are paint-only updates on the existing fill
layer: the map instance, source, layer and viewport are preserved. Linear, logarithmic and step
modes use the same expression semantics at initial render and after a theme change.

**Properties:**

- `center: [number, number] = [0, 0]`
- `zoom: number = 2`
- `renderWorldCopies?: boolean` (attribute: false) — forwarded to MapLibre when its map is
  constructed. Leave it unset to preserve MapLibre's own current default; set `false` before
  construction to stop repeating the world horizontally. This is a construction-time option, so a
  later change takes effect after the component is disconnected and reconnected.
- `mapStyle?: LyraMapStyleSpecification | string` (attribute: false) — required before a map is
  constructed. Object assignments are detached and recursively frozen; create and reassign a new
  style to update it. `LyraMapStyleSpecification` is the peer-neutral structural subset accepted from
  MapLibre's `StyleSpecification`, including its string or multi-sprite form. No provider or style
  is selected implicitly: an unset, empty, or whitespace-only value renders the localized
  style-required failure and makes no tile/style request. Assign a hosted vector/raster style from
  a provider whose terms fit your application, or an explicitly network-silent style for local
  geometry.
- `legendGradient: readonly (readonly [number, string])[] = []` (attribute: false, type
  `LyraMapLegendGradientStop[]`) — renders the legend as a **continuous** gradient bar with endpoint
  captions instead of (or alongside) the discrete `legend` swatches, which is the standard key for a
  choropleth whose `interpolate` fill is itself a continuous ramp. Takes the same `[value, color]`
  stop shape as `choropleth.stops`, so the usual assignment is `map.legendGradient =
  myChoropleth.stops` and the key cannot drift from the layer it describes. Stops are sorted
  ascending, bounded to 64, and filtered to finite values carrying a CSS-parsable color; fewer than
  two usable stops render no bar at all, since a one-stop "gradient" is a flat block that describes
  nothing. Each stop sits at its true proportion of the value range, so the bar shows the ramp the
  expression actually produces rather than evenly spacing unevenly-spaced values. A logarithmic
  choropleth renders bounded samples of the same exponential interpolation in the gradient rather
  than showing a misleading CSS-linear ramp. In development, independently authored
  `legendGradient` and `choropleth.stops` arrays that disagree produce a once-per-page warning;
  assigning the same stops (or deriving both from one source) avoids drift. Part names
  (`legend-gradient`, `legend-lo`, `legend-hi`) mirror `lr-heatmap`'s gradient legend, and the bar
  is `aria-hidden`/`inert` with the captions carrying the meaning. Mirrors under RTL
- `legendGradientLoLabel: string | null = null` (attribute `legend-gradient-lo-label`),
  `legendGradientHiLabel: string | null = null` (attribute `legend-gradient-hi-label`) — override
  the endpoint captions, which otherwise default to the lowest/highest stop value in the component's
  own locale-aware numeric formatting
- `legend: readonly LyraMapLegendEntry[] = []` (attribute: false) — immutable defensive snapshots
  of `LyraMapLegendEntry { readonly color: string; readonly label: string; readonly pattern:
LyraMapLegendPattern }`, where `LyraMapLegendPattern` is `'solid' | 'diagonal' | 'dots' |
'crosshatch'`. Pattern is required so color is never the sole category cue. At most 100 valid
  rows, 256 characters per label, and 8,192 aggregate label characters are retained; colors are
  bounded before validation. The overlay scrolls within the map allocation.
  A row may also carry its own `value`: the category key that row stands for — the same string a
  `point.colors`/`point.icons` entry matches against `point.field`/`point.iconField`. It is
  trimmed, bounded to 256 characters (sliced, never ellipsized, so a bounded key still matches) and
  **retained** in the canonical readback; a non-string, empty or whitespace-only key leaves no
  `value` property on the frozen row at all. It is what makes a row operable under
  `legendInteractive`, and it does not count toward the label budget.
  A row may also carry `icon`, deliberately the same record a `point.icons` entry uses — hand the
  legend the very icon object its point layer renders and the key shows the symbol drawn on the
  map instead of describing it in colour alone. The **icon record's own** `value` is a separate
  field from the row-level one above: it is accepted so a pass-through needs no reshaping, and it
  is still left out of the canonical readback. A row's key is never derived from its glyph's, which
  would silently make a row interactive that the author never marked;
  `path`, `viewBox`, `mode`, `strokeWidth`, `lineCap` and `lineJoin` keep their point-icon meaning
  and their point-icon defaults, and the same validation applies (path data only, at most 8,192
  characters, positive `viewBox` dimensions). An unusable record is dropped and that row keeps
  rendering its colour swatch, exactly as a row that supplies no `icon` does.
  A row may finally carry a `group`: the section it belongs to, for a key that describes two
  layers at once and otherwise could not say which rows belong to which. The rule is pinned
  rather than inferred: **consecutive** entries sharing an identical `group` render as one section
  — a visible heading plus a `role="group"` that heading names — an entry with **no** `group`
  keeps its **declared** position rather than being hoisted above or sunk below a section, and a
  `group` that reappears after an interruption opens a *second* section rather than reordering
  rows to merge them. Declaration order is the one thing the legend never rewrites, because the
  order is itself information about the map. `group` is caller-supplied **data**: it renders
  verbatim and is never resolved through the locale catalogue. It is trimmed and bounded to 256
  characters (ellipsized, since it is rendered prose rather than a key matched against
  `point.field`); a non-string, empty or whitespace-only value leaves no `group` property on the
  frozen row at all, so an empty string means "ungrouped" instead of an empty heading. Like the
  row-level `value`, it does not count toward the aggregate label budget — the rendered total is
  already finite and stated: at most one heading per rendered row, so at most 100 of them. A
  section is not a row: the 100-row cap and the `legend-limit` summary count rows, never sections.
- readonly `legendProjection: LyraMapLegendProjection` — frozen `{ inputCount, renderedCount,
omittedCount, truncatedLabelCount, truncated }` result for the latest assignment. A truncated
  projection renders a localized visible `1–N of M items` summary rather than silently claiming
  the bounded rows are complete.
- `legendInteractive: boolean = false` (attribute `legend-interactive`, reflected) — opt-in: turns
  every legend row that carries a `value` into a keyboard-operable visibility toggle, and leaves a
  row without one inert. Unset, the legend renders exactly the read-only key it rendered before —
  no `button`, no extra attribute, and no extra MapLibre paint key. Each toggle is an independently
  tabbable native `button`, so Enter and Space are the platform's own activation and there is no
  roving tabindex: a 100-row interactive legend contributes 100 tab stops, exactly as a 100-series
  `lr-chart` legend does. Each interactive row also grows to the shared `--lr-icon-button-size`
  hit-area floor (WCAG 2.5.8), which the panel's existing `max-block-size` and scrolling contain.
- `legendControlRole: 'button' | 'checkbox' = 'button'` (attribute `legend-control-role`,
  reflected) — how each `legendInteractive` row's toggle presents itself to assistive tech; inert
  while `legendInteractive` is unset, exactly like the toggle itself. `'button'` is byte-identical
  to every legend rendered before this property existed: `<button aria-pressed>`. `'checkbox'`
  renders the SAME `<button>` element with its implicit role overridden to `role="checkbox"` and
  `aria-checked` in place of `aria-pressed` — the swatch, the label, the click handler and the
  platform's own Enter/Space activation are unchanged, so `hiddenCategories`, the cancelable
  `lr-map-legend-toggle` veto, `group` sections and `legendCollapsible` all compose with either
  role. `aria-checked` tracks the same visibility flag `aria-pressed` does, inverted from
  `hiddenCategories`: a hidden category renders `aria-checked="false"`. Prefer `'checkbox'` when the
  legend reads as a checklist of independent show/hide toggles; prefer the `'button'` default when
  it reads as a set of filtering actions. An unsupported value normalizes back to `'button'`.
- `legendCollapsible: boolean = false` (attribute `legend-collapsible`, reflected) — opt-in: renders
  a `legend-disclosure` `button` inside the panel that collapses the key down to its header, so a
  large legend stops permanently covering part of the map. Unset, the panel renders exactly what it
  rendered before — no button, no `id` minted on the row list, and no `hidden` attribute anywhere.
  Collapsing hides the gradient bar, the rows, the `legend-limit` summary and the trailing `legend`
  slot; the `legend-start` slot and the disclosure itself stay visible, so a slotted header survives
  the collapse and the control that restores the key is never what the collapse hides.
- `legendOpen: boolean = true` (attribute `legend-open`, reflected) — whether a `legendCollapsible`
  panel is expanded. It defaults **open**, so adding only `legendCollapsible` never hides an existing
  key, and it does nothing at all while `legendCollapsible` is unset. Because it is a
  `true`-defaulting boolean it uses a custom attribute converter, so `legend-open="false"` parses —
  the bare presence-based boolean form cannot express `false` at all — and the reflection follows the
  same converter: open (the default) reflects as an **absent** attribute and collapsed reflects as
  `legend-open="false"`. It is controlled public state and survives a disconnect/reconnect. Assigning
  it programmatically reconciles the rendered panel and emits nothing.
- `hiddenCategories: readonly string[] = []` (attribute: false) — the complete controlled set of
  muted category keys, mirroring `lr-chart`'s `hiddenDatasets`. Clone-owned and frozen; non-string,
  empty, whitespace-only and duplicate entries are dropped (first occurrence wins, matching
  `point.colors`), and at most 100 keys are retained. Honoured on the **first** render and the
  first MapLibre paint, not only after a user toggle, so a host can restore a saved selection. An
  empty array deliberately means every category is visible. It is controlled public state and
  survives a disconnect/reconnect. A hidden key that matches no category is harmless. Assigning it
  programmatically emits nothing.
- `choropleth?: LyraMapChoroplethLayer` (attribute: false) — `LyraMapChoroplethLayer { sourceId:
string; geojson: GeoJSON.FeatureCollection; field: string; stops: [number, string][]; interpolation?:
'linear' | 'logarithmic' | 'step'; stepBaseColor?: string }` (interpolated
  fill-color expression from `field`'s value against `stops`; `stops` must contain at least one
  `[value, color]` pair — an empty array is ignored, leaving whatever fill layer already exists, if
  any, untouched, rather than being applied).
  `interpolation` (type `LyraMapChoroplethInterpolation`, default `'linear'`) chooses how the colour
  is interpolated between stops. `'logarithmic'` compresses the ramp, which is what a heavy-tailed
  quantity — price, population, income — needs: on a linear ramp every value below the maximum falls
  into the first colour band, so the map reads as one flat colour plus a couple of outliers. It
  emits maplibre's own `['interpolate', ['exponential', 0.25], …]`, exposing an existing capability
  rather than adding one (maplibre has no `['log']` interpolation type; a sub-1 exponential base is
  the documented way to weight a ramp toward the low end). **`stops` stay in the data's own units
  under either mode**, so the legend keeps reading in real values instead of log units — no
  pre-transforming to log10 and hand-relabelling the legend back.
  `'step'` (new in 11.0.0) emits maplibre's `['step', …]` instead of `['interpolate', …]`, giving
  **discrete bands rather than a continuous ramp**. Use it whenever the legend advertises a fixed
  set of ranges with one swatch each: a ramp would put colours on the map that appear nowhere in the
  legend, and would render two regions in the same advertised band as visibly different colours
  (`legendGradient` covers the opposite case, a gradient legend). `stepBaseColor` is the colour for
  values below the first threshold, which `['step', …]` requires; it defaults to the first stop's own
  colour, so a legend whose first band starts at the data minimum needs no extra configuration.
  Stop colors and `stepBaseColor` accept CSS custom-property references; Lyra resolves them from
  the live host cascade before passing the expression to MapLibre's WebGL renderer.
- `markers: LyraMapMarker[] = []` (attribute: false) — `LyraMapMarker { id?: string; lngLat:
[number, number]; color?: string; label?: string; unsafeHtml?: string }`; an explicit `id` is
  trimmed and must be nonempty, and the first successfully admitted marker for an explicit ID wins.
  A malformed earlier row does not reserve that ID. Markers are reconciled
  by that explicit ID (falling back
  to a `lng,lat` key, disambiguated by occurrence order for duplicate-coordinate id-less markers,
  when `id` is omitted) so an unchanged marker isn't torn down and recreated on every `markers`
  reassignment — its `lngLat` **and** its popup content (`unsafeHtml`/`label`, in that precedence)
  are both updated in place, and the popup is removed if a later update sets neither. `unsafeHtml` is
  rendered via `Popup.setHTML()` — **raw markup, inline event handlers included** — only pass trusted
  content, sanitize anything derived from user input first; prefer `label` (`Popup.setText()`,
  escaped) when the content is plain text. For marker/popup naming, visible text is extracted from
  trusted markup while `script`, `style`, `template`, `[hidden]`, and `aria-hidden="true"` subtrees
  are excluded; an explicitly supplied `label` remains the more predictable accessible name. A
  marker whose `color` changes for a persisting `id`
  can't be recolored in place (no `Marker.setColor()`) and is torn down/reconstructed instead — see
  gotchas. Entries with non-finite coordinates, latitude outside `[-90, 90]`, or a runtime
  non-string `label` are skipped without aborting valid siblings. `color` is used only when the
  browser accepts it as CSS `color`;
  declaration breaks and `url()` paint servers fall back to MapLibre's default marker color.
  Every retained marker is a named `role="button"` tab stop, including one without a popup. Click,
  Enter, and Space emit `lr-map-marker-activate`; Space suppresses its page-scroll default while
  preserving MapLibre's popup toggle. A popup-bearing marker additionally exposes
  `aria-haspopup="dialog"`, `aria-controls`, and explicit `aria-expanded`.
- `dataLayers: LyraMapGeoJsonDataLayer[] = []` (attribute: false) —
  `LyraMapGeoJsonDataLayer { sourceId: string; geojson: GeoJSON.Feature |
GeoJSON.FeatureCollection; tone?: 'accent' | 'success' | 'warning' |
'danger' | 'neutral'; color?: string; strokeColor?: string; line?: LyraMapLineOptions;
point?: LyraMapPointOptions; kind?: LyraMapDataLayerKind;
heatmap?: LyraMapHeatmapOptions; cluster?: LyraMapClusterOptions }`. `sourceId` is trimmed and must be nonempty; the first layer for a
  `sourceId` that is successfully admitted wins; blank, malformed, and later duplicate records are
  ignored without reserving an identity for a valid later sibling. Each retained entry adds one
  GeoJSON source plus three geometry-filtered layers
  (fill, line, and circle, so a mixed `FeatureCollection` renders correctly), colored from the
  matching `--lr-color-*` token (`tone` defaults to `'accent'` → `--lr-color-brand`).
  `color` and `strokeColor` (both new in 11.0.0) override that per surface — `color` paints the
  polygon fill, `strokeColor` the line and circle layers, falling back to `color` and then to `tone`.
  They are separable because a fill and its outline want opposite things on a
  choropleth-plus-overlay map, and the difference is measurable rather than aesthetic: the fill
  competes for area with the choropleth beside it and has to sit quiet, while the 1px outline
  competes with nothing and is the only thing keeping a no-data region's shape readable once the
  fill is that faint. Deriving one from the other measured 1.41:1 against a light basemap — under
  WCAG 1.4.11's 3:1 floor for graphical objects. A `var(--lr-…)` reference is resolved against the
  host before it reaches MapLibre, which paints to a WebGL canvas and never sees the CSS cascade.
  The component
  assigns collision-free private MapLibre ids for those resources: `sourceId` is the stable
  declarative reconciliation key, **not** an id to retrieve from `map`. This prevents a data layer
  from overwriting or removing a same-named source supplied by `mapStyle`. Independent of
  `choropleth` — no `field`/`stops` color-interpolation, just the geometry rendered in a flat tone;
  use `choropleth` instead when you need a data-driven color ramp. An entry whose `sourceId`
  persists across a `dataLayers` reassignment gets its GeoJSON updated in place (`updateData()`
  when stable feature IDs make a safe diff possible, otherwise `setData()`), one that's dropped has
  its private source/layers removed, and a genuinely new `sourceId` gets new resources — nothing
  leaks on removal, style change, or disconnect.

  The component snapshots the configuration it reads. It passes `choropleth.geojson` and
  `dataLayers[].geojson` through to MapLibre; assign a new `choropleth`, `dataLayers`, or `markers`
  value after changing configuration, because mutating an assigned value is not observed.

  `cluster` and `kind` (both new in 12.0.0) opt one entry out of that three-layer geometry split.
  **Both are strictly additive: an entry that sets neither renders exactly what it rendered before,
  down to the layer ids and the point layer's filter.**

  `cluster?: LyraMapClusterOptions { radius?: number; maxZoom?: number; radiusSteps?: [number,
number][]; colorSteps?: [number, string][]; countFont?: string[] }` turns the entry's source into a
  natively clustered one — `cluster`/`clusterRadius` (default 50)/`clusterMaxZoom` (default 14) on
  the source, plus a `${sourceId}-cluster` circle filtered on `has('point_count')`, a
  `${sourceId}-cluster-count` label, and a `${sourceId}-circle` layer for the points that stayed
  unclustered. `cluster: {}` opts in at every default. This is what a thousands-of-points map needs
  and what `markers` cannot be: `markers` mints one real, individually focusable DOM element per
  entry, which is right for tens of pins and both unreadable and expensive for thousands.
  `radiusSteps` and `colorSteps` are `['step', …]` breaks keyed on `point_count`, in the same
  ascending `[value, output]` vocabulary as `choropleth.stops` — including the same base rule, where
  the first entry's own output covers everything below the first threshold, and the same colour
  resolution, where a `var(--lr-…)` reference in a `colorSteps` entry is resolved against the host
  before it reaches MapLibre (which paints to a WebGL canvas and never sees the CSS cascade), so a
  retheme moves the cluster breaks with everything else. No fill or line layer is created for a
  clustered entry, because MapLibre's clustering keeps point features only. The count
  label needs glyphs: a style that declares none gets the graduated circles without the numbers
  (adding a text layer against a glyph-less style paints nothing and only emits peer errors), and
  `countFont` names the font stack when your style's glyph source lacks MapLibre's spec default.
  Cluster options are baked into the source at creation time by MapLibre and have no setter, so
  changing them (or `kind`) rebuilds that one entry's source and layers; every other update still
  reconciles in place.

  `kind?: 'auto' | 'heatmap'` (`LyraMapDataLayerKind`, default `'auto'` — today's geometry split)
  renders the source as MapLibre's own first-class `heatmap` layer instead, which the geometry split
  cannot express at all: thousands of overlapping circles read as one opaque blob rather than as
  where the data is concentrated. `heatmap?: LyraMapHeatmapOptions { weightField?: string;
weightRange?: [number, number]; stops?: [number, string][]; radius?: LyraMapHeatmapZoomValue;
intensity?: LyraMapHeatmapZoomValue; opacity?: number }`, where `LyraMapHeatmapZoomValue` is a
  scalar number or bounded `[zoom, value][]` stops,
  configures it. `weightField` weights each point by a feature property, and `weightRange` maps that
  property's own units onto the 0–1 weight MapLibre expects — without it the raw value is passed
  through, which saturates the surface for any quantity above ~1; with neither, every point weighs 1.
  `stops` are `[density, color]` pairs with density in `[0, 1]`, **the same `[value, color]`
  vocabulary `choropleth.stops` and `legendGradient` already share**, so a `legendGradient` bar can
  describe the ramp without a second copy of it, and `var(--lr-…)` stops resolve against the host the
  same way `color`/`strokeColor` do. A ramp that doesn't start at density 0 gets a fully transparent
  stop prepended, because a coloured zero tints the entire map — so **a single stop is already a
  complete ramp**, as long as it sits above density 0: `stops: [[1, '#ff0000']]` is exactly
  transparent → red. The one authored ramp that can't be honoured is a lone stop AT density 0, which
  describes a flat colour rather than a gradient; that one — like an unset or wholly unusable
  `stops` — falls back to the built-in ramp, which runs transparent → `--lr-color-brand` →
  `--lr-color-success` → `--lr-color-warning` → `--lr-color-danger`, so a retheme moves the density
  surface with everything else. Scalar `radius` (default 30) and `intensity` (default 1) preserve
  their established behavior. Two or more usable `[zoom, value]` stops emit linear zoom
  interpolation; stops are sorted, duplicate zooms removed, zoom clamped into `[0, 24]`, radius
  into `[1, 200]`, and intensity into `[0, 100]`. One usable stop becomes a scalar and an unusable
  array falls back to the existing default. `opacity` is clamped into `[0, 1]`; omission leaves the
  peer's default untouched on construction, and dropping a previously-authored value restores 1.
  `cluster` is ignored on a heatmap entry: a heatmap already aggregates density, and clustering its
  input would feed it one point per cluster instead of the real distribution.
  `line?: LyraMapLineOptions { field?: string; stops?: readonly (readonly [number, string])[];
  width?: number; opacity?: number }` configures line/outline paint for an ordinary `dataLayers`
  entry. `field` names a numeric feature property; two usable `[value, color]` stops produce a
  continuous linear ramp. Missing, null, and non-numeric feature values use `strokeColor`, then
  `color`, then `tone`. Stops inspect the first 64 entries, discard non-finite values and invalid
  CSS colors, sort ascending, and retain the first duplicate threshold. Fewer than two stops or
  an absent field preserves flat color. Values beyond the endpoints clamp to their endpoint color.
  CSS variables resolve on the host during reconciliation and retheming. `width` defaults to 2 CSS
  pixels and clamps to `[0, 200]`; `opacity` defaults to 1 and clamps to `[0, 1]`. Non-finite values
  restore those defaults, as does dropping a previously-authored option. Point colors and polygon
  fills are unaffected; cluster and heatmap entries ignore `line`.

  `point?: LyraMapPointOptions` styles individual points on ordinary and clustered auto entries.
  It is ignored for heatmaps. `field` names a **string** feature property; `colors` maps exact
  `[category, CSS color]` pairs. Missing, unknown and non-string categories use the existing
  `strokeColor`/`color`/`tone` fallback. The first 32 pairs are inspected and valid duplicates are
  first-wins. CSS variables resolve on the host and follow ancestor theme changes. `radius`
  defaults to 5 CSS pixels and `strokeWidth` to 0; both clamp to `[0, 200]`. `strokeColor` overrides
  the outline only, with the layer's flat color as fallback. Dropping options restores defaults.

  `radius` also accepts `LyraMapPointRadiusOptions { field, stops, interpolation?, fallback? }`
  for **numeric** feature-driven sizes. `field` is independent of the category field. The first 32
  pairs are inspected and finite `[value, radius]` pairs are sorted ascending; duplicate thresholds are first-wins and
  radii clamp to `[0, 200]`. `interpolation` defaults to `'step'`: below the first threshold use
  its radius, and each threshold is inclusive. `'linear'` interpolates between adjacent stops and
  clamps outside the domain. One usable stop is a constant for numeric features. Missing, null,
  non-numeric and non-finite feature values use `fallback` (default 5, clamped to `[0, 200]`), as
  do an empty field, unusable stops or a linear domain that cannot retain distinct finite intervals.
  Invalid interpolation values use step. These options style individual/unclustered points;
  cluster-count radii remain independent. Switching back to a number or removing the radius
  scale restores the fixed radius without recreating the source.

  `point.icons` maps exact category values to `LyraMapPointIcon { value: string; path: string;
  viewBox?: readonly [number, number, number, number]; mode?: LyraMapPointIconMode;
  strokeWidth?: number; lineCap?: LyraMapPointIconLineCap; lineJoin?: LyraMapPointIconLineJoin }`.
  `iconField` defaults to `field`. Each icon is **SVG path data**, not SVG markup or a URL. Copy an icon's path data,
  combine filled subpaths in that string, and supply its `[minX, minY, width, height]` viewBox
  (default `[0, 0, 24, 24]`). `mode` defaults to `'fill'`; `'stroke'` draws open outline paths,
  and `'fill-stroke'` paints both. `strokeWidth` uses **viewBox units** (default 2, clamped to
  `[0, 200]`; zero omits the stroke), so it scales with the icon. `lineCap` accepts `'butt'`,
  `'round'`, or `'square'`; `lineJoin` accepts `'miter'`, `'round'`, or `'bevel'`. Both default
  to round; miter joins retain a limit of 10. Invalid modes/caps/joins restore their defaults,
  and a non-finite stroke width restores 2. Keep padding inside the viewBox for strokes at its
  edges, which are clipped to the fixed atlas square. These are icon-path options;
  `point.strokeWidth` and `point.strokeColor` continue to style the surrounding circle only.
  The first 32 entries are inspected, valid duplicates are first-wins, path data is capped at
  8192 characters, and viewBox coordinates must be finite within ±10000 with dimensions at least
  0.001. Invalid paths are ignored; markup, external references and event handlers cannot execute.
  Rasterization is synchronous and local, with no fetch or HTML/SVG document insertion.
  `iconColor` supplies both fill and stroke ink, accepts CSS variables and defaults to the tone's contrasting foreground; `iconSize`
  is the displayed bounding square in CSS pixels (default 16, clamped to `[1, 200]`). The fixed
  64px raster preserves the viewBox aspect ratio. Unknown categories, invalid icons and partial
  peers lacking image-atlas methods retain their colored circles. No sprite or glyph URL is needed.

  All categories use the **same GeoJSON source** and cluster across categories. Cluster circles
  keep the existing count-based styling; only unclustered points get category colors/icons.
  An optional symbol layer and at most 32 images are allocated per configured source, with no
  per-feature DOM markers. Data changes reuse the source, retheming updates paint/icon pixels,
  and style reloads restore the owned resources. Removing the entry or icons releases its images.
  `lr-map-click` on either a point's circle or icon returns `origin: 'data-layer'`, the public
  `sourceId`, and the rendered feature. For clustered GeoJSON, use numeric feature IDs and keep
  string business IDs in feature properties, as MapLibre's cluster tiling uses numeric IDs. Canvas export includes both; supply an accessible textual
  legend/list for category interpretation and individual keyboard actions.

  ```js
  import '@aceshooting/lyra-ui/components/lr-map.js';
  map.dataLayers = [{
    sourceId: 'places', geojson: locations, cluster: {},
    point: {
      field: 'category',
      radius: { field: 'visits', stops: [[0, 10], [10, 12], [50, 14], [100, 16]], fallback: 10 },
      strokeWidth: 1,
      colors: [['home', 'var(--lr-color-brand)'], ['work', 'var(--lr-color-success)']],
      icons: [
        { value: 'home', path: 'M2 12L12 2L22 12V22H2Z' },
        { value: 'work', path: 'M8 6V3H16V6M3 6H21V21H3ZM3 11H21',
          mode: 'stroke', strokeWidth: 1.75, lineCap: 'round', lineJoin: 'round' },
      ],
      iconSize: 14,
    },
  }];
  ```

  Share the same stops with `legendGradient` and set its endpoint labels for units. A gradient
  matching a line ramp remains linear even alongside a logarithmic choropleth. Changing metric,
  filtered GeoJSON, width or opacity reconciles existing resources; style reloads restore the layer.
  Lines remain in MapLibre's canvas and can be captured with the map's normal render-event PNG
  export flow.

  ```js
  mapElement.dataLayers = [{
    sourceId: 'routes', geojson: routes,
    line: { field: 'kmh', stops: speedStops, width: 4, opacity: 0.9 }
  }];
  mapElement.legendGradient = speedStops;
  mapElement.legendGradientLoLabel = '0 km/h';
  mapElement.legendGradientHiLabel = '100 km/h';
  ```
- `maxBounds: LyraMapBounds | null = null` (attribute: false) — box the map may not pan outside,
  `[[west, south], [east, north]]`. Prefer it over calling `map.setMaxBounds()` through the `.map`
  escape hatch: constraining the camera can wedge maplibre-gl at a sub-1 fractional zoom in a wide
  container, leaving `getZoom()` returning `null` permanently, every frame throwing from inside the
  peer's own matrix math, and the canvas never painting again — a blank map, with nothing thrown at
  the call site to attribute it to. This property applies the same call, then reads the camera back
  and reverts (restoring zoom and centre) if it did not survive, so the worst case is an
  unconstrained map plus a dev-mode warning. The defensive camera snapshots are inside the same
  failure boundary, so a peer whose damaged transform already throws from `getZoom()` or
  `getCenter()` is also reduced to an unconstrained map instead of leaking through Lit's update.
  A malformed box is rejected rather than clamped

**Choropleth and `dataLayers` updates are diffed before they reach the peer.** `setData()`
unconditionally re-tiles and repaints an entire source, which is invisible on a static map and
expensive on an animated one. When every feature has a unique `string`/`number` `id` and retained
features keep semantically unchanged geometry/bbox values, the component emits MapLibre's
incremental `updateData()` for property changes, additions, removals, and order changes. The exact
next order is preserved: an unchanged prefix stays in place and only the invalidated suffix is
removed and re-added. Lyra snapshots control and projection data while retaining opaque GeoJSON
identity at the MapLibre boundary; a bounded, accessor-free comparison verifies the JSON geometry
graph instead. A missing/duplicate ID, changed geometry, exceeded bound, or uncertain
comparison falls back to `setData()` with no change in rendered behaviour. Peers without
`updateData()` always take the old path.

**Feature properties are tiled, and therefore bounded in numeric magnitude.** MapLibre GL tiles
every GeoJSON source through a worker into a protobuf vector tile, so a property carrying a huge
integer throws *inside that worker* — "Given varint doesn't fit into 10 bytes". That throw is not
catchable by your app and not a rejected promise; it reaches you only as an opaque message on
`lr-map`'s own error handler, while the rest of the layer still paints, so one bad feature in a
large collection is invisible until someone walks the data by hand. Both `choropleth.geojson` and
`dataLayers[].geojson` are now pre-scanned (first 10,000 features) and any numeric property beyond
`Number.MAX_SAFE_INTEGER` draws a dev-mode warning naming the feature and the property. Carry a
reduced figure in the feature — a log, a bucket, an index — and keep the exact value in your own
payload beside the map.

- `label: string = ''` — purpose-specific accessible name for MapLibre's actual focusable canvas.
  A nonempty host `aria-label` remains on the host and is not duplicated onto the canvas; the canvas
  uses `label` or the localized map name. An explicitly empty host `aria-label` is preserved as an
  empty canvas name. The non-semantic `[part="base"]` wrapper is not named instead.

**Authoring types:** `LyraMapLegendEntry`, `LyraMapLegendPattern`, `LyraMapLegendProjection`, `LyraMapChoroplethLayer`,
`LyraMapGeoJsonDataLayer`, `LyraMapDataLayerKind`, `LyraMapClusterOptions`, `LyraMapHeatmapOptions`,
`LyraMapHeatmapZoomValue`, `LyraMapLineOptions`, `LyraMapPointOptions`, `LyraMapPointRadiusOptions`,
`LyraMapPointRadiusInterpolation`, `LyraMapPointIcon`, `LyraMapPointIconMode`,
`LyraMapPointIconLineCap`, `LyraMapPointIconLineJoin`, `LyraMapMarker`, `LyraMapMarkerActivationDetail`,
`LyraMapMarkerActivationSource`, `LyraMapLegendToggleDetail`, `LyraMapLegendPanelToggleDetail`,
`LyraMapStyleSpecification`, and `LyraMapInstance`.
The former `LegendEntry`, `ChoroplethLayer`, `GeoJsonDataLayer`, and `MapMarker` names are removed
in v9 rather than retained as aliases.

**Getters:** `map: LyraMapInstance | undefined` → the underlying runtime `maplibregl.Map`, exposed
through the peer-neutral `getCanvas()`, `getCenter()`, `getZoom()`, `setCenter()`, `setZoom()`, and
`resize()` subset so merely importing Lyra does not require `maplibre-gl` declarations. A consumer
that installed the optional peer and needs its full imperative API can explicitly narrow the runtime
value to `maplibregl.Map`.

**Methods:** `LyraMap.preload(): Promise<boolean>` is a static optional-peer warm-up that starts the
shared `maplibre-gl` import without constructing a map or allocating a WebGL context. It resolves to
`false` when the peer is unavailable, allowing an application to choose a fallback before connecting
an element.

**Events:** `lr-map-load` (fired once, after the underlying map's own `'load'`),
`lr-map-legend-toggle` (**cancelable**; frozen `LyraMapLegendToggleDetail { value, visible,
hiddenCategories }` — the activated row's category key, its proposed visibility, and the complete
proposed hidden set in the order it would be committed, with the array detached and frozen so a
listener cannot mutate the component's state through it). `preventDefault()` is a genuine veto, not
a notification: `hiddenCategories` is not written, the row's `aria-pressed` does not change, the
MapLibre paint is untouched, and nothing is announced — which is exactly what a host needs to own
the set and assign its own value instead. There is deliberately no second, confirmation event: the
committed state is `hiddenCategories`, which the host already observes, so a paired before/after
vocabulary would be permanent public surface nobody asked for. The event is a DOM-interaction
proposal only, so a programmatic `hiddenCategories` assignment reconciles without emitting it.
Also `lr-map-legend-panel-toggle` (**cancelable**; frozen `LyraMapLegendPanelToggleDetail { open }` —
the proposed `legendOpen` value), fired once when the `legendCollapsible` disclosure is activated by
pointer or by Enter/Space. It is the *panel's* disclosure, not a *category's* visibility, so it
deliberately does not reuse `lr-map-legend-toggle`. `preventDefault()` is the same genuine veto:
`legendOpen` is not written, the rendered rows and the disclosure's `aria-expanded` do not change,
so a host can own the open state and assign its own value from `event.detail.open`. A programmatic
`legendOpen` assignment reconciles without emitting it, so a controlled host cannot loop.
Also `lr-map-marker-activate` (non-cancelable; frozen `LyraMapMarkerActivationDetail { id, lngLat,
marker, source }`; `id` is the trimmed explicit identity or `undefined`, `marker` is the accepted
declarative snapshot, and `source` is `'pointer' | 'keyboard'`), and `lr-map-click`
(frozen `detail: { readonly lngLat: readonly [lng, lat], readonly feature?, readonly origin?,
readonly sourceId? }`; the tuple and any hit GeoJSON feature are detached and recursively frozen).
`feature` resolves against the choropleth fill layer **and** every applied `dataLayers`
fill/line/circle/cluster layer, topmost first — so a shape painted through `dataLayers` is
identifiable instead of being indistinguishable from empty space. `origin` is `'choropleth'`,
`'data-layer'` or `'cluster'`, and `sourceId` carries the authored `dataLayers[].sourceId` for a
data-layer or cluster hit; both are `undefined` whenever `feature` is. A cluster hit is reported
separately because it is a synthetic aggregate rather than one of your features: its useful payload
is MapLibre's `point_count`/`point_count_abbreviated`/`cluster_id` properties, which is what a
zoom-to-cluster handler reads. The count label is deliberately not hit-tested (it sits exactly on
the circle already queried and would only make the label the topmost hit), and a `kind: 'heatmap'`
layer is never queried at all — MapLibre returns no features for a rendered density surface

The outer marker-activation detail is frozen, while an opaque marker `unsafeHtml` value remains the
original supplied value at the MapLibre popup and marker-activation boundary. Treat it as trusted
markup as described above.

**Slots:** `legend` — custom legend content, rendered inside the legend panel's own layout so it
stays positioned with the map instead of floating beside it. Supplying it opens the panel even
when `legend` and `legendGradient` are both empty. `legend-start` — the same extension point at the
**top** of the panel: it renders ahead of the gradient bar and every projected row, where `legend`
renders after them, so a host-authored panel header is no longer forced to be a footer. Content in
it alone opens the panel too, and neither slot is ever made interactive by `legendInteractive`,
which only reaches rows projected from `legend`.

**CSS parts:** `base`, `container`, `legend`, `legend-disclosure`, `legend-disclosure-icon`,
`legend-group`, `legend-group-heading`, `legend-swatch`, `legend-toggle`,
`legend-toggle-hidden`, `legend-gradient`, `legend-lo`,
`legend-hi`, `legend-limit`, `marker`, `popup`,
`popup-content`, `popup-close-button`, `attribution`, `attribution-toggle`, `navigation`,
`zoom-in`, `zoom-out`, `compass`, `scale`, `error`.
The compact attribution summary has a decorative, current-color information glyph and retains
MapLibre's accessible name, keyboard/touch disclosure behavior and attribution links. To make
only this control compact, scope the shared target-size token to its public wrapper:

```css
lr-map::part(attribution) { --lr-icon-button-size: var(--lr-size-24px); }
```

Keep that target at least 24px in both axes. This leaves navigation controls at their existing size.

`legend` is a localized `role="group"` containing a real list associated to the map canvas with
`aria-describedby`; each entry is a `listitem`, decorative swatches are inert/accessibility-hidden,
and the overlay is bounded to the map allocation with scrolling and long-label wrapping.
A swatch rendering an entry's `icon` carries `data-icon="true"`, paints the glyph in that entry's
own colour through `currentColor`, and drops the colour block, its border and the `pattern`
overlay — all three would sit on top of the shape they identify, and the shape is itself the
non-colour cue the pattern supplies for a colour-only row. The glyph stays decorative: the row's
visible label carries its meaning. That association is advertised only while it is true — while
the optional `maplibre-gl` peer is still loading, and after any failure, there is no map
container in the tree, so `legend` withholds `aria-controls` rather than leaving a dangling
idref.
Under `legendInteractive`, a row carrying a `value` wraps its swatch and label in a
`legend-toggle` `button` with `aria-pressed` rendered as the literal `"true"`/`"false"` — never
omitted, because a missing attribute reports "not a toggle button" rather than "unpressed". Setting
`legendControlRole="checkbox"` renders the SAME `button` with `role="checkbox"` and `aria-checked`
(also always the literal `"true"`/`"false"`) in place of `aria-pressed`, and nothing else about the
row changes. The button's accessible name is its own visible label (caller-supplied data, so
deliberately not localized), scoped by the legend's own localized group name; no `aria-label`
restates the state, which would make assistive tech announce it twice. A hidden row's button
additionally carries the
`legend-toggle-hidden` token — state lives in the part name, so `::part(legend-toggle-hidden)` is a
reachable hook — and dims only its `aria-hidden` swatch while re-colouring the label through the
quiet text token, so the label keeps AA contrast rather than fading with the whole button. In
forced colors the hidden row falls back to `line-through`, which survives a system-color collapse.
Each activation is announced through the shared light-DOM polite live region, using the same
`legendTypeShown`/`legendTypeHidden` strings `lr-graph-legend` announces with.
Under `legendCollapsible`, `legend-disclosure` is a native `button` whose **visible localized text
is its accessible name** and whose `aria-expanded` renders the literal `"true"`/`"false"` — never
omitted — with `aria-controls` naming the row list in the same shadow root (idrefs do not cross
shadow boundaries, so this is deliberately not an idref into the light DOM). It carries the shared
`--lr-icon-button-size` hit-area floor, and `legend-disclosure-icon` is the decorative chevron it
rotates: the shared icon set ships one right-pointing glyph and asks callers to rotate the wrapping
part, so collapsed points along the reading direction and expanded points down in both directions.
The rotation runs on `--lr-transition-fast`, which the token layer already flattens under
`prefers-reduced-motion`. A collapsed panel hides its gradient, rows, `legend-limit` summary and
trailing `legend` slot with the plain `hidden` attribute, so they leave layout, the accessibility
tree and the tab order together.
When at least one entry carries a `group`, each consecutive run renders inside `legend-group`
(`role="group"`, named by its own `legend-group-heading` through `aria-labelledby`) and **each run
gets its own `role="list"`**, because a `list` may only own `listitem`s — a `group` sitting directly
inside the outer list is an `aria-required-children`/`aria-required-parent` violation. The outer
container therefore drops its own `role` in that case; a legend with no groups keeps the single
`role="list"` it has always had. Row `aria-posinset`/`aria-setsize` stay whole-key values inside a
section: `aria-setsize` already reports the *input* count so a bounded key stays honest, and a
dropped row carries no attributable group, so a section adds a labelled sub-region without
renumbering the key.
`legend-limit` is the localized bounded-projection summary. The five peer-chrome parts project
stable Lyra names onto MapLibre-generated DOM without erasing peer-supplied part tokens;
`marker` retains a 24px minimum target in both axes even when a peer/custom marker has no intrinsic
content size. `popup-close-button` is the generated close control on an open marker popup. `error` is ordinary localized visible
text rendered in place of `container` for four distinct states: explicit style required, optional
peer unavailable, owner-realm WebGL2 unavailable, or initialization failed. A post-mount failure is
appended to the document's pre-mounted `[data-lr-live-region="assertive"]` sink rather than making
shadow chrome live; raw caught errors are never exposed.

**Standard peer controls:** after `lr-map-load`, narrow `.map` to the installed MapLibre peer
and use `addControl(new NavigationControl(), 'bottom-right')` and
`addControl(new ScaleControl({ unit: 'metric' }), 'bottom-left')`. No document-level peer CSS or
shadow-root modification is needed: navigation buttons, compass glyph, scale bar and control
corners are styled with Lyra tokens. The corners follow inline start/end under RTL. The legend
reserves vertical room for these controls and attribution, including after resize or removal.
Attribution also reserves legend space when it is the only control. Opposing occupied corners
share the available width, so expanded attribution wraps without covering a scale or navigation
control on the other side; controls stay aligned to their logical edge in both text directions.
Navigation exposes `navigation`, `zoom-in`, `zoom-out`, and `compass` parts; the scale exposes `scale`.
Button names update through `zoomIn`, `zoomOut`, and `mapResetNorth` locale strings. MapLibre retains
keyboard activation, compass rotation, zoom limits, scale units and viewport updates. No controls
are created until the application explicitly adds them. This is a supported imperative peer-control
integration; there is no declarative controls property.

**Themeable custom properties:**

- `--lr-map-height` (default `var(--lr-size-24rem)`) — host block size, shared with the optional
  pre-upgrade reservation stylesheet. An explicit outer `block-size` still wins.
- `--lr-map-choropleth-fill-opacity` (default `0.75`) — fill opacity for the declarative
  `choropleth` layer and polygon fills in every `dataLayers` entry. It intentionally inherits from
  an ancestor, so one scoped declaration rethemes every nested map without setting each host.
- `--lr-map-hidden-category-opacity` (default `0.15`) — opacity a category listed in
  `hiddenCategories` is muted to in the rendered MapLibre paint: `circle-opacity` and
  `circle-stroke-opacity` on the points layer and `icon-opacity` on the point-icon symbol layer.
  Like the fill-opacity token it is read from the resolved cascade on every paint, because MapLibre
  draws to a WebGL canvas the CSS cascade never reaches, and it inherits from an ancestor. A
  `kind: 'heatmap'` entry is out of scope: a density surface has no per-category field to mute.
- `--lr-map-legend-hidden-swatch-opacity` (default `0.5`) — opacity of a hidden interactive legend
  row's decorative swatch. Only the `aria-hidden` swatch dims; the label re-colours through
  `--lr-color-text-quiet` instead, so it never drops below 4.5:1.
- `--lr-map-popup-close-button-hover-bg` (default `var(--lr-color-brand-quiet)`) and
  `--lr-map-popup-close-button-hover-color` (default `var(--lr-color-brand)`) — hover background
  and foreground of `popup-close-button`.
- `--lr-map-popup-close-button-active-bg` (default `color-mix(in oklab,
var(--lr-color-brand-quiet), var(--lr-color-mix-partner) var(--lr-color-mix-active))`) and
  `--lr-map-popup-close-button-active-color` (default `var(--lr-color-brand)`) — pressed
  background and foreground of `popup-close-button`.
- Shared tokens — `--lr-space-xs/-s`, `--lr-color-surface`, `--lr-color-border`, `--lr-shadow`,
  `--lr-radius`.

**Optional peer deps:** `maplibre-gl` `>=5 <7` (lazy-loaded). `<lr-map>` styles MapLibre's
generated canvas, marker, popup, and control DOM inside its shadow root; a page-level MapLibre
stylesheet is neither required nor able to reach those nodes. MapLibre v5's standard build includes
its worker. MapLibre v6 is ESM-only, requires WebGL2, and additionally needs its module-worker URL
configured for the bundler once — the component cannot choose a bundler-specific worker URL. For
Vite with v6:

```html
<lr-map center="[2.35, 48.85]" zoom="10"></lr-map>
<script type="module">
  import { setWorkerUrl } from "maplibre-gl";
  import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
  setWorkerUrl(workerUrl);

  const m = document.querySelector("lr-map");
  m.mapStyle = { version: 8, sources: {}, layers: [] }; // explicit, network-silent baseline
  m.choropleth = {
    sourceId: "regions",
    geojson: myGeoJson,
    field: "value",
    stops: [
      [0, "#cde2fb"],
      [100, "#0969da"],
    ],
  };
  m.legend = [
    { color: "#cde2fb", label: "Low", pattern: "solid" },
    { color: "#0969da", label: "High", pattern: "diagonal" },
  ];
  m.markers = [{ lngLat: [2.29, 48.86], label: "Eiffel Tower" }];
  m.renderWorldCopies = false;
  m.addEventListener("lr-map-marker-activate", (e) =>
    console.log(e.detail.id, e.detail.lngLat)
  );
  m.addEventListener("lr-map-click", (e) =>
    console.log(e.detail.feature?.properties)
  );
</script>
```

Webpack, esbuild, Rollup, and direct-browser ESM use different worker URL forms; use MapLibre's ESM
installation guide for the matching setup:
https://maplibre.org/maplibre-gl-js/docs/#esm.

**Known gotchas:**

- Construction is transactional. A constructor/setup/get-canvas failure removes any partially
  created peer instance, renders only the localized initialization failure, and can retry after a
  new style or reconnect without an unhandled promise rejection. Capability probes and error
  constructors come from the current owner document, including after same-origin adoption.
- Every marker handles Enter and Space as button activation. Space suppresses the page-scroll
  default whether or not a popup exists; when one does, MapLibre still receives its own popup
  toggle and the component emits exactly one `lr-map-marker-activate` notification.
- clearing or swapping the choropleth no longer leaks the old layer: setting `choropleth =
undefined`, or changing `choropleth.sourceId` to a different value, now calls `removeLayer`/
  `removeSource` on whatever was previously applied before adding the new one (or nothing, if
  cleared).
- `mapStyle` changes after construction now call `setStyle()` (in addition to `center`/`zoom`
  already calling `setCenter`/`setZoom`) — the choropleth and `dataLayers` are both automatically
  re-applied once the new style's own `'style.load'` fires, since a style change wipes every
  layer/source maplibre-gl knows about.
- Point markers now have a declarative API (`markers`, above) with popup support — narrowing the
  runtime `.map` value and manually constructing `new maplibregl.Marker()` are no longer the only
  way to place pins.
- A marker uses `label` as its accessible name, then visible text extracted from trusted
  `unsafeHtml`, and only then the localized map label. Popup ownership is exposed through
  `aria-controls`/`aria-expanded`; an open popup is a named
  `role="dialog"` and its localized close button exposes `part="popup-close-button"`. The map
  canvas, markers, popups, and MapLibre's own control strings all follow the component's effective
  locale.
- Ancestor theme-attribute and custom-property changes repaint the already-applied choropleth and
  data-layer colors/opacities in place. This does not recreate MapLibre sources/layers, replace the
  style, or reset the current viewport.
- a marker whose `color` changes for a persisting `id` is torn down and reconstructed (maplibre-gl's
  `Marker` has no `setColor()`) rather than mutated in place — this also closes any popup the user
  currently has open on that marker (a fresh, closed `Popup` is built for the new instance); an
  accepted side effect of the reconstruction fallback, not a bug.
- No click-select visual highlight on choropleth features (no `setFeatureState` call) — clicking
  only fires the event, no built-in visual feedback. Popups are still only reachable declaratively
  through `markers`' `unsafeHtml`/`label` — a choropleth-feature click still has no built-in popup,
  only the raw `lr-map-click` event.
- `LyraMapLegendEntry.color` is validated against a strict CSS-color-syntax allowlist before being applied
  to the legend swatch's `background-color`, rejecting anything that isn't recognizable color syntax
  (notably `url(...)`, which `background` also accepts and would otherwise fetch as soon as the
  swatch renders). The required `pattern` remains distinct in forced colors through solid, dashed,
  dotted, and double border/shape encodings.
- while the `maplibre-gl` peer is resolving, the host/base expose `aria-busy="true"` and show a
  decorative `<lr-skeleton shape="rect" announce="false">` in place of the map container.
  Ordinary sr-only text preserves the localized `loading` label without creating a shadow live
  region.
- construction of the real `maplibregl.Map` (and its WebGL context) is additionally gated on this
  element being observed intersecting the viewport (`IntersectionObserver`), independent of whether
  the `maplibre-gl` peer has already loaded — an off-screen `<lr-map>` swaps its skeleton for the
  empty `[part="container"]` div as soon as the peer resolves, but `map` stays `undefined` and
  `lr-map-load` never fires until the element is actually scrolled into view. Deliberate: caps
  concurrent WebGL contexts when many `<lr-map>`s sit in one dashboard/grid. Skipped entirely
  (constructs immediately once the peer loads) when `IntersectionObserver` itself is unavailable.
- Once constructed, the owner realm's `ResizeObserver` watches the rendered map container and calls
  the current peer's `resize()` on allocation changes. It is replaced on reconnect/adoption and
  disconnected before peer teardown, so a stale delivery cannot resize a removed map. Browsers
  without `ResizeObserver` retain MapLibre's own window-resize behavior.

---
