{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "leaflet-map",
  "title": "Leaflet Map",
  "type": "registry:ui",
  "description": "A thin, theme-aware Leaflet wrapper rendering free raster tiles (OpenStreetMap, OpenTopoMap, Esri) — no API key required. Drop LeafletMarker / LeafletPopup / LeafletPolyline / LeafletGeoJson into the children to build any map — fleet boards, journey maps, store locators. The base style follows light/dark automatically, an opt-in `muted` prop desaturates the tile pane so overlaid data is the only colour, and `onCreated` hands you the raw L.Map instance for custom layers and fitBounds. The Mapbox counterpart (`map`) adds GL styles, 3D, and globe; this one trades those for zero-key setup. Import `leaflet/dist/leaflet.css` once — the component does it for you.",
  "categories": [
    "data-display"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/charts/leaflet-map.ts",
      "content": "import { LitElement, css, html, isServer, nothing, unsafeCSS } from 'lit'\nimport { unsafeHTML } from 'lit/directives/unsafe-html.js'\nimport type * as L from 'leaflet'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport leafletCss from 'leaflet/dist/leaflet.css?inline'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\nimport { Minus, Plus, RotateCcw } from 'lucide'\nimport { buttonVariants } from '../button/button.variants'\nimport { badgeVariants, type BadgeVariants } from '../badge/badge.variants'\n\ntype LeafletModule = typeof import('leaflet')\nlet leafletPromise: Promise<LeafletModule> | null = null\n\n/** Leaflet touches `window`/`document` at import time — load it lazily so SSR\n *  renders never evaluate the module. */\nfunction loadLeaflet(): Promise<LeafletModule> {\n  if (!leafletPromise) leafletPromise = import('leaflet')\n  return leafletPromise\n}\n\n/** [lng, lat] (Mapbox order, matching the `map` component) -> Leaflet [lat, lng]. */\nfunction toLatLng(c: [number, number]): L.LatLngExpression {\n  return [c[1], c[0]]\n}\nfunction toLatLngs(path: [number, number][] | [number, number][][]): L.LatLngExpression[] | L.LatLngExpression[][] {\n  if (!path.length) return []\n  return Array.isArray(path[0][0])\n    ? (path as [number, number][][]).map((ring) => ring.map(toLatLng))\n    : (path as [number, number][]).map(toLatLng)\n}\nfunction toLatLngBounds(bounds: [[number, number], [number, number]]): L.LatLngBoundsExpression {\n  return [toLatLng(bounds[0]), toLatLng(bounds[1])] as L.LatLngBoundsExpression\n}\ntype LeafletPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'\n\n/** Leaflet merges options by assignment, so an explicit `undefined` clobbers\n *  its defaults (e.g. `subdomains: 'abc'` -> crash, `icon: undefined` kills the\n *  default pin). Strip undefined keys before handing options to Leaflet. */\nfunction defined<T extends object>(o: T): T {\n  return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T\n}\n\nconst LEAFLET_ICON_BASE = 'https://unpkg.com/leaflet@1.9.4/dist/images'\nlet defaultIconFixed = false\n/** Leaflet's default pin references image paths bundlers can't resolve. */\nfunction fixDefaultLeafletIcon(leaflet: LeafletModule) {\n  if (defaultIconFixed) return\n  defaultIconFixed = true\n  leaflet.Icon.Default.mergeOptions({\n    iconRetinaUrl: `${LEAFLET_ICON_BASE}/marker-icon-2x.png`,\n    iconUrl: `${LEAFLET_ICON_BASE}/marker-icon.png`,\n    shadowUrl: `${LEAFLET_ICON_BASE}/marker-shadow.png`,\n  })\n}\n\n// ── Variants + tile presets (verbatim copy of React's leaflet-map.variants.ts;\n// inlined because charts/ items ship as one flat file) ───────────────────────\n\nexport type LeafletMapVariant =\n  | 'default'\n  | 'muted'\n  | 'streets'\n  | 'outdoors'\n  | 'light'\n  | 'dark'\n  | 'satellite'\n  | 'satellite-streets'\n  | 'navigation-day'\n  | 'navigation-night'\n  | 'standard'\n\nexport interface LeafletTilePreset {\n  /** Raster tile URL template ({z}/{x}/{y}, optional {s} subdomains + {r} retina). */\n  url: string\n  /** Required provider attribution HTML. */\n  attribution: string\n  subdomains?: string | string[]\n  maxZoom?: number\n  /** Optional label/boundary overlay composited above the base tiles. */\n  overlayUrl?: string\n}\n\nconst OSM_ATTR = '&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors'\nconst TOPO_ATTR = `${OSM_ATTR} | map style: &copy; <a href=\"https://opentopomap.org\">OpenTopoMap</a> (<a href=\"https://creativecommons.org/licenses/by-sa/3.0/\">CC-BY-SA</a>)`\nconst ESRI_ATTR = 'Tiles &copy; Esri &mdash; Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community'\n\nconst OSM_STANDARD: LeafletTilePreset = {\n  url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',\n  attribution: OSM_ATTR,\n  maxZoom: 19,\n}\n// CARTO basemaps moved behind an API key — Esri Canvas/Street services stay key-free.\nconst ESRI_LIGHT: LeafletTilePreset = {\n  url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',\n  attribution: ESRI_ATTR,\n  maxZoom: 16,\n  overlayUrl:\n    'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}',\n}\nconst ESRI_DARK: LeafletTilePreset = {\n  url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',\n  attribution: ESRI_ATTR,\n  maxZoom: 16,\n  overlayUrl:\n    'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}',\n}\nconst ESRI_STREETS: LeafletTilePreset = {\n  url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',\n  attribution: ESRI_ATTR,\n  maxZoom: 19,\n}\nconst ESRI_SATELLITE: LeafletTilePreset = {\n  url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',\n  attribution: ESRI_ATTR,\n  maxZoom: 19,\n}\n\nexport const LEAFLET_TILES: Record<Exclude<LeafletMapVariant, 'default' | 'muted'>, LeafletTilePreset> = {\n  streets: OSM_STANDARD,\n  standard: OSM_STANDARD,\n  outdoors: {\n    url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',\n    attribution: TOPO_ATTR,\n    subdomains: 'abc',\n    maxZoom: 17,\n  },\n  light: ESRI_LIGHT,\n  dark: ESRI_DARK,\n  satellite: ESRI_SATELLITE,\n  'satellite-streets': {\n    ...ESRI_SATELLITE,\n    overlayUrl:\n      'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',\n  },\n  'navigation-day': ESRI_STREETS,\n  'navigation-night': ESRI_DARK,\n}\n\n/** Theme-aware default tiles: Esri light/dark canvas following the app theme. */\nexport const LEAFLET_THEME_TILES = { light: ESRI_LIGHT, dark: ESRI_DARK }\n\nexport const leafletMapVariants = cva('relative size-full overflow-hidden bg-muted isolate', {\n  variants: {\n    variant: {\n      default: '',\n      muted: '',\n      streets: '',\n      outdoors: '',\n      light: '',\n      dark: '',\n      satellite: '',\n      'satellite-streets': '',\n      'navigation-day': '',\n      'navigation-night': '',\n      standard: '',\n    },\n    size: {\n      default: 'h-96 w-full',\n      sm: 'h-64 w-full',\n      lg: 'h-[500px] w-full',\n      xl: 'h-[650px] w-full',\n      full: 'size-full',\n    },\n  },\n  defaultVariants: {\n    variant: 'default',\n  },\n})\n\nexport type LeafletMapVariants = VariantProps<typeof leafletMapVariants>\n\n// ── Chrome CSS (verbatim copy of React's leaflet-map.css; the\n// [data-slot][data-muted] rule keeps working because the inner root div\n// carries the same data-slot/data-muted attributes as React's root) ──────────\n\nconst leafletChrome = css`\n  .leaflet-container {\n    font: inherit;\n    background: var(--muted);\n  }\n  .leaflet-bar {\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    box-shadow: var(--shadow-sm);\n  }\n  .leaflet-bar a,\n  .leaflet-bar a:hover {\n    background: var(--card);\n    color: var(--foreground);\n    border-bottom-color: var(--border);\n  }\n  .leaflet-bar a:hover {\n    background: var(--muted);\n  }\n  .leaflet-bar a.leaflet-disabled {\n    background: var(--card);\n    color: var(--muted-foreground);\n  }\n  .leaflet-popup-content-wrapper {\n    background: var(--popover);\n    color: var(--popover-foreground);\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    box-shadow: var(--shadow-md);\n  }\n  .leaflet-popup-content {\n    margin: var(--spacing-2, 8px) var(--spacing-3, 12px);\n    font: inherit;\n    line-height: 1.5;\n  }\n  .leaflet-popup-tip {\n    background: var(--popover);\n    border: 1px solid var(--border);\n    box-shadow: none;\n  }\n  .leaflet-popup-close-button {\n    color: var(--muted-foreground) !important;\n  }\n  .leaflet-tooltip {\n    background: var(--popover);\n    color: var(--popover-foreground);\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    box-shadow: var(--shadow-sm);\n    font: inherit;\n  }\n  .leaflet-tooltip-top:before {\n    border-top-color: var(--border);\n  }\n  .leaflet-tooltip-bottom:before {\n    border-bottom-color: var(--border);\n  }\n  [data-slot='leaflet-map'][data-muted='true'] .leaflet-tile-pane {\n    filter: grayscale(55%) contrast(0.92);\n  }\n  .uipkge-leaflet-div-icon {\n    background: transparent;\n    border: none;\n    width: auto !important;\n    height: auto !important;\n    margin: 0 !important;\n  }\n  .uipkge-leaflet-anchor {\n    display: none;\n  }\n  .leaflet-marker-icon .uipkge-leaflet-anchor {\n    display: block;\n    width: max-content;\n  }\n  .uipkge-leaflet-anchor[data-anchor='center'] {\n    transform: translate(-50%, -50%);\n  }\n  .uipkge-leaflet-anchor[data-anchor='top'] {\n    transform: translate(-50%, 0);\n  }\n  .uipkge-leaflet-anchor[data-anchor='bottom'] {\n    transform: translate(-50%, -100%);\n  }\n  .uipkge-leaflet-anchor[data-anchor='left'] {\n    transform: translate(0, -50%);\n  }\n  .uipkge-leaflet-anchor[data-anchor='right'] {\n    transform: translate(-100%, -50%);\n  }\n  .uipkge-leaflet-anchor[data-anchor='top-left'] {\n    transform: translate(0, 0);\n  }\n  .uipkge-leaflet-anchor[data-anchor='top-right'] {\n    transform: translate(-100%, 0);\n  }\n  .uipkge-leaflet-anchor[data-anchor='bottom-left'] {\n    transform: translate(0, -100%);\n  }\n  .uipkge-leaflet-anchor[data-anchor='bottom-right'] {\n    transform: translate(-100%, -100%);\n  }\n  .uipkge-leaflet-popup-src,\n  .uipkge-leaflet-tooltip-src {\n    display: none;\n  }\n  .leaflet-popup-content .uipkge-leaflet-popup-src,\n  .leaflet-tooltip .uipkge-leaflet-tooltip-src {\n    display: block;\n  }\n`\n\n/** `true` unless the attribute is absent or the string \"false\" — so defaults\n *  of `true` (navigation, attribution, scroll-wheel…) can be turned off in HTML. */\nconst boolAttr = { fromAttribute: (v: string | null) => v !== null && v !== 'false' }\n/** Tri-state: absent → undefined (Leaflet's default), \"false\" → false, else true. */\nconst optionalBool = { fromAttribute: (v: string | null) => (v === null ? undefined : v !== 'false') }\n/** `dash-array=\"6 4\"` from markup, or a number[] from JS / JSON attribute. */\nconst dashArrayConverter = {\n  fromAttribute: (v: string | null) => {\n    if (v === null) return undefined\n    const t = v.trim()\n    if (t.startsWith('[')) return JSON.parse(t) as number[]\n    return t\n  },\n}\n\nconst isDarkPage = () => !isServer && document.documentElement.classList.contains('dark')\n\n/** Children notify their map when their properties change. */\nfunction notifyMap(el: HTMLElement) {\n  el.closest<UipLeafletMap>('uip-leaflet-map')?.requestSync()\n}\n\nfunction isOverlay(el: Node): el is UipLeafletPopup | UipLeafletTooltip {\n  return el instanceof UipLeafletPopup || el instanceof UipLeafletTooltip\n}\n\n/** Non-empty content children (overlays bind to the layer instead of becoming\n *  icon content; whitespace text nodes don't count). */\nfunction htmlKids(el: HTMLElement): Node[] {\n  return [...el.childNodes].filter(\n    (n) => !isOverlay(n) && (n.nodeType === 1 || (n.nodeType === 3 && (n.textContent?.trim() ?? '') !== '')),\n  )\n}\n\nexport interface LeafletFlyToOptions {\n  /** [lng, lat] — Mapbox order. */\n  center?: [number, number]\n  zoom?: number\n  /** Milliseconds (converted to Leaflet's seconds). */\n  duration?: number\n}\nexport interface LeafletViewOptions {\n  center?: [number, number]\n  zoom?: number\n}\n\n// ── <uip-leaflet-map> ─────────────────────────────────────────────────────────\n\n/**\n * <uip-leaflet-map> — the registry LeafletMap as a web component. Same\n * key-free raster tiles, theme-aware default, zoom/fullscreen chrome and ⓘ\n * attribution as React. Leaflet is imported lazily (once the element is near\n * the viewport), disposed on disconnect, and resized by a ResizeObserver.\n * Leaflet's CSS and React's leaflet-map.css are adopted into the shadow root,\n * because Leaflet renders its panes and overlays there.\n *\n * Children (the web-component stand-in for React's JSX children):\n *  - `<uip-leaflet-marker>` — light-DOM content becomes the div icon (no\n *    content = Leaflet's default pin); nested popups/tooltips bind to it.\n *  - `<uip-leaflet-popup>` / `<uip-leaflet-tooltip>` — bind to the nearest\n *    ancestor layer, or float standalone at `lng-lat`. Once the map is created,\n *    a nested overlay is moved up to be a direct child of the map (slots can\n *    only project the host's own children); it moves back into its layer\n *    element if that layer is removed.\n *  - `<uip-leaflet-polyline>` / `-polygon` / `-circle` / `-circle-marker` /\n *    `-geo-json` / `-tile-layer` — declarative vector + raster layers.\n *\n * `center` / `zoom` are the initial view; later changes call setView (React's\n * watcher). The theme that picks the default tiles is hand-rolled from\n * <html class=\"dark\"> (React's next-themes equivalent).\n *\n * Events: `created` (detail: { map }) once the map is created — React's\n * `onCreated`. The raw instance is also on the `map` property, plus the\n * imperative helpers `flyTo`/`setView`/`jumpTo`/`fitBounds`/`panTo`/`zoomIn`/\n * `zoomOut`/`resize` (React's LeafletMapRef).\n */\nexport class UipLeafletMap extends LitElement {\n  // Manual slot assignment: each marker/popup/tooltip child is assigned to a\n  // <slot> created inside its Leaflet container (same pattern as <uip-map>).\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' as const }\n  static styles = [tailwind, unsafeCSS(leafletCss), leafletChrome, css`:host { display: block; }`]\n\n  static properties = {\n    variant: { reflect: true },\n    size: { reflect: true },\n    tileUrl: { attribute: 'tile-url' },\n    tileAttribution: { attribute: 'tile-attribution' },\n    tileSubdomains: { attribute: 'tile-subdomains' },\n    center: { type: Array },\n    zoom: { type: Number },\n    minZoom: { attribute: 'min-zoom', type: Number },\n    maxZoom: { attribute: 'max-zoom', type: Number },\n    navigation: { converter: boolAttr },\n    navigationPosition: { attribute: 'navigation-position' },\n    fullscreen: { type: Boolean },\n    fullscreenPosition: { attribute: 'fullscreen-position' },\n    attribution: { converter: boolAttr },\n    scrollWheelZoom: { attribute: 'scroll-wheel-zoom', converter: boolAttr },\n    muted: { type: Boolean },\n    mapReady: { state: true },\n    isFullscreen: { state: true },\n    canZoomIn: { state: true },\n    canZoomOut: { state: true },\n    attributions: { state: true },\n    showAttribution: { state: true },\n    isDark: { state: true },\n  }\n\n  variant: LeafletMapVariant = 'default'\n  /** Height preset. Omit to size the host with classes. */\n  size?: LeafletMapVariants['size']\n  /** Custom raster tile URL template — overrides `variant`. */\n  tileUrl?: string\n  /** Attribution HTML for a custom `tileUrl`. Defaults to the OpenStreetMap credit. */\n  tileAttribution?: string\n  /** Tile subdomains for a custom `tileUrl`. */\n  tileSubdomains?: string | string[]\n  /** Initial [lng, lat] — Mapbox order, matching the `map` component. */\n  center: [number, number] = [0, 20]\n  zoom = 2\n  minZoom?: number\n  /** Caps the map's max zoom. Defaults to the tile provider's own maxZoom. */\n  maxZoom?: number\n  /** Show the zoom control. */\n  navigation = true\n  navigationPosition: LeafletPosition = 'bottom-right'\n  /** Show the HTML5 fullscreen toggle button. */\n  fullscreen = false\n  fullscreenPosition: LeafletPosition = 'top-right'\n  /** Show tile credits behind a ⓘ button. Keep on — OSM/Esri tiles require attribution. */\n  attribution = true\n  /** Wheel zoom. Set false for maps embedded in scrollable pages. */\n  scrollWheelZoom = true\n  /** Desaturate the tile pane to a quiet canvas (markers stay coloured). */\n  muted = false\n  private mapReady = false\n  private isFullscreen = false\n  private canZoomIn = true\n  private canZoomOut = true\n  private attributions: string[] = []\n  private showAttribution = false\n  private isDark = false\n\n  private leaflet?: LeafletModule\n  private instance?: L.Map\n  private initializing = false\n  private inView = false\n  private io?: IntersectionObserver\n  private ro?: ResizeObserver\n  private themeObserver?: MutationObserver\n  private childObserver?: MutationObserver\n  private baseLayer?: L.TileLayer | null\n  private overlayLayer?: L.TileLayer | null\n  private layers = new Map<Element, L.Layer>()\n  private layerKeys = new Map<Element, string>()\n  private overlayKeys = new Map<Element, string>()\n  private overlayContent = new Map<Element, HTMLElement>()\n  private geoJsonData = new Map<Element, unknown>()\n  private syncQueued = false\n  private tilesKey?: string\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  /** The raw Leaflet Map once created (React's `ref.map`). */\n  get map(): L.Map | null {\n    return this.instance ?? null\n  }\n  getMap(): L.Map | null {\n    return this.instance ?? null\n  }\n  flyTo(options: LeafletFlyToOptions = {}) {\n    const m = this.instance\n    if (!m) return\n    m.flyTo(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom(), {\n      duration: (options.duration ?? 800) / 1000,\n    })\n  }\n  setView(options: LeafletViewOptions = {}) {\n    const m = this.instance\n    if (!m) return\n    m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom())\n  }\n  jumpTo(options: LeafletViewOptions = {}) {\n    const m = this.instance\n    if (!m) return\n    m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom(), { animate: false })\n  }\n  fitBounds(bounds: [[number, number], [number, number]] | L.LatLngBoundsExpression, options?: L.FitBoundsOptions) {\n    this.instance?.fitBounds(toLatLngBounds(bounds as [[number, number], [number, number]]), options)\n  }\n  panTo(c: [number, number]) {\n    this.instance?.panTo(toLatLng(c))\n  }\n  zoomIn() {\n    this.instance?.zoomIn()\n  }\n  zoomOut() {\n    this.instance?.zoomOut()\n  }\n  resize() {\n    this.instance?.invalidateSize()\n  }\n\n  private get isMuted() {\n    return this.muted || this.variant === 'muted'\n  }\n\n  private get resolvedTiles(): LeafletTilePreset {\n    if (this.tileUrl) {\n      return {\n        url: this.tileUrl,\n        attribution:\n          this.tileAttribution ??\n          '&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors',\n        subdomains: this.tileSubdomains,\n        maxZoom: this.maxZoom,\n      }\n    }\n    if (\n      this.variant &&\n      this.variant !== 'default' &&\n      this.variant !== 'muted' &&\n      LEAFLET_TILES[this.variant as keyof typeof LEAFLET_TILES]\n    ) {\n      return LEAFLET_TILES[this.variant as keyof typeof LEAFLET_TILES]\n    }\n    return this.isDark ? LEAFLET_THEME_TILES.dark : LEAFLET_THEME_TILES.light\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'leaflet-map')\n    this.isDark = isDarkPage()\n    this.themeObserver = new MutationObserver(() => (this.isDark = isDarkPage()))\n    this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })\n    document.addEventListener('fullscreenchange', this.onFullscreenChange)\n    this.childObserver = new MutationObserver(() => this.requestSync())\n    this.childObserver.observe(this, { childList: true, subtree: true })\n    if (this.hasUpdated) this.observeViewport()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.themeObserver?.disconnect()\n    this.childObserver?.disconnect()\n    document.removeEventListener('fullscreenchange', this.onFullscreenChange)\n    this.io?.disconnect()\n    this.ro?.disconnect()\n    this.destroy()\n  }\n\n  private onFullscreenChange = () => {\n    this.isFullscreen = Boolean(document.fullscreenElement)\n  }\n\n  protected firstUpdated() {\n    this.observeViewport()\n    const ro = (this.ro = new ResizeObserver(() => this.instance?.invalidateSize()))\n    ro.observe(this)\n  }\n\n  private observeViewport() {\n    if (typeof IntersectionObserver === 'undefined') {\n      this.inView = true\n      return void this.init()\n    }\n    this.io?.disconnect()\n    this.io = new IntersectionObserver(\n      ([entry]) => {\n        // Latch like <uip-map>: the observer only defers the first paint.\n        if (!entry.isIntersecting) return\n        this.inView = true\n        this.io?.disconnect()\n        this.init()\n      },\n      { rootMargin: '160px', threshold: 0.01 },\n    )\n    this.io.observe(this)\n  }\n\n  private async init() {\n    if (!this.inView || this.instance || this.initializing || !this.isConnected) return\n    this.initializing = true\n    try {\n      const leaflet = (this.leaflet ??= await loadLeaflet())\n      await this.updateComplete\n      const container = this.renderRoot.querySelector<HTMLElement>('[data-leaflet-container]')\n      if (!container || !this.isConnected) return\n      fixDefaultLeafletIcon(leaflet)\n      const tiles = this.resolvedTiles\n      const m = leaflet.map(\n        container,\n        defined({\n          center: toLatLng(this.center),\n          zoom: this.zoom,\n          minZoom: this.minZoom,\n          maxZoom: this.maxZoom ?? tiles.maxZoom,\n          zoomControl: false,\n          attributionControl: false,\n          scrollWheelZoom: this.scrollWheelZoom,\n        }),\n      )\n      this.instance = m\n      this.tilesKey = JSON.stringify(tiles)\n      this.applyTiles(tiles)\n      m.on('zoomend', this.syncZoomBounds)\n      this.syncZoomBounds()\n      m.on('layeradd layerremove', this.collectAttributions)\n      this.collectAttributions()\n      this.mapReady = true\n      this.syncChildren()\n      this.dispatchEvent(new CustomEvent('created', { detail: { map: m }, bubbles: true, composed: true }))\n    } finally {\n      this.initializing = false\n    }\n  }\n\n  private syncZoomBounds = () => {\n    const m = this.instance\n    if (!m) return\n    this.canZoomIn = m.getZoom() < m.getMaxZoom()\n    this.canZoomOut = m.getZoom() > m.getMinZoom()\n  }\n\n  private collectAttributions = () => {\n    const m = this.instance\n    if (!m) return\n    const seen = new Set<string>()\n    m.eachLayer((layer) => {\n      const a = (layer as L.TileLayer).options?.attribution\n      if (typeof a === 'string' && a) seen.add(a)\n    })\n    this.attributions = [...seen]\n  }\n\n  private applyTiles(tiles: LeafletTilePreset) {\n    const m = this.instance\n    const leaflet = this.leaflet\n    if (!m || !leaflet) return\n    this.baseLayer?.remove()\n    this.baseLayer = null\n    this.overlayLayer?.remove()\n    this.overlayLayer = null\n    this.baseLayer = leaflet.tileLayer(\n      tiles.url,\n      defined({\n        attribution: tiles.attribution,\n        subdomains: tiles.subdomains,\n        maxZoom: tiles.maxZoom ?? 19,\n      }),\n    )\n    this.baseLayer.addTo(m)\n    if (tiles.overlayUrl) {\n      this.overlayLayer = leaflet.tileLayer(tiles.overlayUrl, { maxZoom: tiles.maxZoom ?? 19 })\n      this.overlayLayer.addTo(m)\n    }\n    this.collectAttributions()\n  }\n\n  private destroy() {\n    this.instance?.remove()\n    this.instance = undefined\n    this.baseLayer = this.overlayLayer = null\n    this.layers.clear()\n    this.layerKeys.clear()\n    this.overlayKeys.clear()\n    this.overlayContent.clear()\n    this.geoJsonData.clear()\n    this.mapReady = false\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    const tiles = this.resolvedTiles\n    const key = JSON.stringify(tiles)\n    if (this.instance && key !== this.tilesKey) {\n      this.tilesKey = key\n      this.applyTiles(tiles)\n    }\n    if (this.instance && (changed.has('center') || changed.has('zoom')) && this.center) {\n      this.instance.setView(toLatLng(this.center), this.zoom)\n    }\n    if (changed.has('attribution') && !this.attribution) this.showAttribution = false\n    if (this.instance && changed.has('scrollWheelZoom')) {\n      if (this.scrollWheelZoom) this.instance.scrollWheelZoom.enable()\n      else this.instance.scrollWheelZoom.disable()\n    }\n  }\n\n  /** Batched: re-reconcile children with the Leaflet instance. */\n  requestSync() {\n    if (this.syncQueued) return\n    this.syncQueued = true\n    queueMicrotask(() => {\n      this.syncQueued = false\n      this.syncChildren()\n    })\n  }\n\n  /** A <slot> inside a Leaflet-owned container, manually assigned to `el`. */\n  private slotFor(el: HTMLElement, cls?: string, anchor?: string) {\n    const holder = document.createElement('div')\n    if (cls) holder.className = cls\n    if (anchor) holder.dataset.anchor = anchor\n    const slot = document.createElement('slot')\n    holder.append(slot)\n    slot.assign(el)\n    return holder\n  }\n\n  private layerChildren(): Element[] {\n    return [...this.children].filter(\n      (k) =>\n        k instanceof UipLeafletMarker ||\n        k instanceof UipLeafletPolyline ||\n        k instanceof UipLeafletPolygon ||\n        k instanceof UipLeafletCircle ||\n        k instanceof UipLeafletCircleMarker ||\n        k instanceof UipLeafletGeoJson ||\n        k instanceof UipLeafletTileLayer,\n    )\n  }\n\n  /**\n   * Nested overlays (a popup inside a marker) can't be slotted into the\n   * Leaflet popup/tooltip containers in this shadow root: manual slot\n   * assignment only takes the host's direct children. So each nested overlay\n   * is moved up to be a direct child of the map (still light DOM, so the\n   * page's styles apply), remembering its layer element; it moves back into\n   * that element if the layer leaves the map.\n   */\n  private hoistOverlays() {\n    for (const el of [...this.children]) {\n      const home = (el as any).__uipLayer as Element | undefined\n      if (isOverlay(el) && home && home.parentElement !== this) {\n        delete (el as any).__uipLayer\n        home.append(el)\n      }\n    }\n    for (const el of [...this.querySelectorAll('uip-leaflet-popup,uip-leaflet-tooltip')]) {\n      if (el.parentElement === this) continue\n      let layer: Element | null = el.parentElement\n      while (layer && layer.parentElement !== this) layer = layer.parentElement\n      if (!layer || !this.layerChildren().includes(layer)) continue\n      ;(el as any).__uipLayer = layer\n      this.append(el)\n    }\n  }\n\n  private syncChildren() {\n    const m = this.instance\n    const leaflet = this.leaflet\n    if (!m || !leaflet) return\n    this.hoistOverlays()\n\n    // Drop removed layers.\n    const kids = this.layerChildren()\n    for (const [el, layer] of this.layers) {\n      if (!kids.includes(el)) {\n        m.removeLayer(layer)\n        this.layers.delete(el)\n        this.layerKeys.delete(el)\n      }\n    }\n    // Drop removed overlays.\n    const overlayEls = [...this.querySelectorAll('uip-leaflet-popup,uip-leaflet-tooltip')] as (\n      | UipLeafletPopup\n      | UipLeafletTooltip\n    )[]\n    for (const [el, key] of this.overlayKeys) {\n      void key\n      if (!overlayEls.includes(el as UipLeafletPopup | UipLeafletTooltip)) {\n        // Detached already, so resolve the bound layer from the remembered\n        // parent (the live parent chain no longer reaches the map).\n        try {\n          const prevParent = (el as any).__uipParent as Element | null | undefined\n          const prevLayer = prevParent ? this.layers.get(prevParent) : undefined\n          if (el instanceof UipLeafletPopup) {\n            if (prevLayer) (prevLayer as L.Marker).unbindPopup()\n            else (this.layers.get(el) as L.Popup | undefined)?.remove()\n          } else if (el instanceof UipLeafletTooltip) {\n            if (prevLayer) (prevLayer as L.Marker).unbindTooltip()\n            else (this.layers.get(el) as L.Tooltip | undefined)?.remove()\n          }\n        } catch {\n          /* layer already gone */\n        }\n        this.overlayKeys.delete(el)\n        this.overlayContent.delete(el)\n        this.layers.delete(el)\n      }\n    }\n\n    for (const el of kids) {\n      if (el instanceof UipLeafletMarker) this.syncMarker(m, leaflet, el)\n      else if (el instanceof UipLeafletPolyline) this.syncPath(m, leaflet, el, 'polyline')\n      else if (el instanceof UipLeafletPolygon) this.syncPath(m, leaflet, el, 'polygon')\n      else if (el instanceof UipLeafletCircle) this.syncPath(m, leaflet, el, 'circle')\n      else if (el instanceof UipLeafletCircleMarker) this.syncPath(m, leaflet, el, 'circle-marker')\n      else if (el instanceof UipLeafletGeoJson) this.syncGeoJson(m, leaflet, el)\n      else if (el instanceof UipLeafletTileLayer) this.syncTileLayer(m, leaflet, el)\n    }\n    for (const el of overlayEls) {\n      if (el instanceof UipLeafletPopup) this.syncPopup(m, leaflet, el)\n      else if (el instanceof UipLeafletTooltip) this.syncTooltip(m, leaflet, el)\n    }\n    this.collectAttributions()\n  }\n\n  private overlayParent(el: UipLeafletPopup | UipLeafletTooltip): Element | null {\n    // A hoisted overlay (see hoistOverlays) binds to the layer it came from.\n    const home = (el as any).__uipLayer as Element | undefined\n    if (home) return this.layers.has(home) ? home : null\n    // Otherwise: nearest ancestor that is a direct layer child of the map.\n    let node: Element | null = el.parentElement\n    while (node && node.parentElement !== this) node = node.parentElement\n    if (node && this.layers.has(node)) return node\n    return null\n  }\n\n  private overlayLayerFor(el: UipLeafletPopup | UipLeafletTooltip): L.Layer | undefined {\n    const parent = this.overlayParent(el)\n    return parent ? this.layers.get(parent) : undefined\n  }\n\n  private syncMarker(m: L.Map, leaflet: LeafletModule, el: UipLeafletMarker) {\n    const hasHtml = htmlKids(el).length > 0\n    const key = JSON.stringify([el.anchor, hasHtml, el.markerDraggable, el.zIndexOffset, el.markerTitle, el.alt])\n    let layer = this.layers.get(el) as L.Marker | undefined\n    if (layer && this.layerKeys.get(el) !== key) {\n      m.removeLayer(layer)\n      this.layers.delete(el)\n      layer = undefined\n    }\n    if (!layer) {\n      layer = leaflet.marker(\n        toLatLng(el.lngLat ?? [0, 0]),\n        defined({\n          icon: hasHtml\n            ? leaflet.divIcon({ className: 'uipkge-leaflet-div-icon', html: this.slotFor(el, 'uipkge-leaflet-anchor', el.anchor) })\n            : undefined,\n          interactive: true,\n          draggable: el.markerDraggable,\n          zIndexOffset: el.zIndexOffset,\n          title: el.markerTitle,\n          alt: el.alt,\n          opacity: el.opacity,\n        }),\n      )\n      layer.on('click', (ev) => el.dispatchEvent(new CustomEvent('click', { detail: ev, bubbles: true, composed: true })))\n      layer.addTo(m)\n      this.layers.set(el, layer)\n      this.layerKeys.set(el, key)\n    }\n    if (el.lngLat) layer.setLatLng(toLatLng(el.lngLat))\n    if (el.opacity !== undefined) layer.setOpacity(el.opacity)\n    if (el.zIndexOffset !== undefined) layer.setZIndexOffset(el.zIndexOffset)\n  }\n\n  private pathStyle(el: UipLeafletPath): L.PathOptions {\n    return defined({\n      color: el.color,\n      weight: el.weight,\n      opacity: el.opacity,\n      lineCap: el.lineCap,\n      lineJoin: el.lineJoin,\n      dashArray: el.dashArray,\n      dashOffset: el.dashOffset,\n      fill: el.fill,\n      fillColor: el.fillColor,\n      fillOpacity: el.fillOpacity,\n      className: el.pathClass,\n    })\n  }\n\n  private syncPath(m: L.Map, leaflet: LeafletModule, el: UipLeafletPath, kind: 'polyline' | 'polygon' | 'circle' | 'circle-marker') {\n    let layer = this.layers.get(el) as L.Path | undefined\n    if (!layer) {\n      const opts = { ...this.pathStyle(el), interactive: true }\n      if (kind === 'polyline') {\n        const p = el as UipLeafletPolyline\n        layer = leaflet.polyline(toLatLngs(p.lngLatPath ?? []) as L.LatLngExpression[], {\n          ...opts,\n          smoothFactor: p.smoothFactor,\n          noClip: p.noClip,\n        })\n      } else if (kind === 'polygon') {\n        layer = leaflet.polygon(toLatLngs((el as UipLeafletPolygon).lngLatPath ?? []) as L.LatLngExpression[], opts)\n      } else if (kind === 'circle') {\n        const c = el as UipLeafletCircle\n        layer = leaflet.circle(toLatLng(c.center ?? [0, 0]), { ...opts, radius: c.radius })\n      } else {\n        const c = el as UipLeafletCircleMarker\n        layer = leaflet.circleMarker(toLatLng(c.center ?? [0, 0]), { ...opts, radius: c.radius })\n      }\n      layer.on('click', (ev: any) => el.dispatchEvent(new CustomEvent('click', { detail: ev, bubbles: true, composed: true })))\n      layer.addTo(m)\n      this.layers.set(el, layer)\n    }\n    if (kind === 'polyline' || kind === 'polygon') {\n      const path = (el as UipLeafletPolyline | UipLeafletPolygon).lngLatPath ?? []\n      ;(layer as L.Polyline).setLatLngs(toLatLngs(path) as L.LatLngExpression[])\n    } else {\n      const c = el as UipLeafletCircle | UipLeafletCircleMarker\n      ;(layer as L.Circle).setLatLng(toLatLng(c.center ?? [0, 0]))\n      if (c.radius !== undefined) (layer as L.Circle).setRadius(c.radius)\n    }\n    layer.setStyle({ ...this.pathStyle(el), interactive: true })\n  }\n\n  private syncGeoJson(m: L.Map, leaflet: LeafletModule, el: UipLeafletGeoJson) {\n    let layer = this.layers.get(el) as L.GeoJSON | undefined\n    if (!layer) {\n      layer = leaflet.geoJSON(el.geojson as any, el.options)\n      layer.on('click', (ev: any) => el.dispatchEvent(new CustomEvent('click', { detail: ev, bubbles: true, composed: true })))\n      layer.addTo(m)\n      this.layers.set(el, layer)\n      this.geoJsonData.set(el, el.geojson)\n      return\n    }\n    if (el.geojson && this.geoJsonData.get(el) !== el.geojson) {\n      layer.clearLayers()\n      layer.addData(el.geojson as any)\n      this.geoJsonData.set(el, el.geojson)\n    }\n  }\n\n  private syncTileLayer(m: L.Map, leaflet: LeafletModule, el: UipLeafletTileLayer) {\n    const key = JSON.stringify([el.attribution, el.subdomains, el.minZoom, el.maxZoom, el.tms])\n    let layer = this.layers.get(el) as L.TileLayer | undefined\n    if (layer && this.layerKeys.get(el) !== key) {\n      m.removeLayer(layer)\n      this.layers.delete(el)\n      layer = undefined\n    }\n    if (!layer) {\n      layer = leaflet.tileLayer(\n        el.url ?? '',\n        defined({ attribution: el.attribution, subdomains: el.subdomains, minZoom: el.minZoom, maxZoom: el.maxZoom, tms: el.tms }),\n      )\n      layer.addTo(m)\n      this.layers.set(el, layer)\n      this.layerKeys.set(el, key)\n    }\n    if (el.url) layer.setUrl(el.url)\n    if (el.opacity !== undefined) layer.setOpacity(el.opacity)\n    if (el.zIndex !== undefined) layer.setZIndex(el.zIndex)\n  }\n\n  private overlayContentFor(el: UipLeafletPopup | UipLeafletTooltip, cls: string) {\n    let holder = this.overlayContent.get(el)\n    if (!holder) {\n      holder = this.slotFor(el, cls)\n      this.overlayContent.set(el, holder)\n    }\n    return holder\n  }\n\n  private syncPopup(m: L.Map, leaflet: LeafletModule, el: UipLeafletPopup) {\n    const parent = this.overlayParent(el)\n    const parentLayer = parent ? this.layers.get(parent) : undefined\n    const key = JSON.stringify([parent ? [...this.children].indexOf(parent) : -1, el.lngLat, el.minWidth, el.maxWidth, el.offset, el.popupClass, el.autoClose, el.closeOnClick, el.closeButton, el.keepInView])\n    if (this.overlayKeys.get(el) === key) return\n    // Tear down the previous binding.\n    const prevParent = (el as any).__uipParent as Element | null | undefined\n    const prevLayer = prevParent ? this.layers.get(prevParent) : undefined\n    try {\n      if (prevLayer && el.lngLat == null) (prevLayer as any).unbindPopup?.()\n      ;(this.layers.get(el as unknown as Element) as L.Popup | undefined)?.remove()\n    } catch {\n      /* map already destroyed */\n    }\n    this.layers.delete(el as unknown as Element)\n    if (parentLayer) {\n      ;(parentLayer as L.Marker).bindPopup(\n        this.overlayContentFor(el, 'uipkge-leaflet-popup-src'),\n        defined({ minWidth: el.minWidth, maxWidth: el.maxWidth, offset: el.offset as any, className: el.popupClass, autoClose: el.autoClose, closeOnClick: el.closeOnClick, closeButton: el.closeButton, keepInView: el.keepInView }) as L.PopupOptions,\n      )\n    } else if (el.lngLat) {\n      const popup = leaflet\n        .popup(\n          defined({ minWidth: el.minWidth, maxWidth: el.maxWidth, offset: el.offset as any, className: el.popupClass, autoClose: el.autoClose, closeOnClick: el.closeOnClick, closeButton: el.closeButton, keepInView: el.keepInView }) as L.PopupOptions,\n        )\n        .setLatLng(toLatLng(el.lngLat))\n        .setContent(this.overlayContentFor(el, 'uipkge-leaflet-popup-src'))\n      popup.openOn(m)\n      this.layers.set(el as unknown as Element, popup as unknown as L.Layer)\n    }\n    ;(el as any).__uipParent = parent\n    this.overlayKeys.set(el, key)\n  }\n\n  private syncTooltip(m: L.Map, leaflet: LeafletModule, el: UipLeafletTooltip) {\n    const parent = this.overlayParent(el)\n    const parentLayer = parent ? this.layers.get(parent) : undefined\n    const key = JSON.stringify([parent ? [...this.children].indexOf(parent) : -1, el.lngLat, el.offset, el.direction, el.permanent, el.sticky, el.opacity, el.tooltipClass, el.interactive])\n    if (this.overlayKeys.get(el) === key) return\n    const prevParent = (el as any).__uipParent as Element | null | undefined\n    const prevLayer = prevParent ? this.layers.get(prevParent) : undefined\n    try {\n      if (prevLayer && el.lngLat == null) (prevLayer as any).unbindTooltip?.()\n      ;(this.layers.get(el as unknown as Element) as L.Tooltip | undefined)?.remove()\n    } catch {\n      /* map already destroyed */\n    }\n    this.layers.delete(el as unknown as Element)\n    if (parentLayer) {\n      ;(parentLayer as L.Marker).bindTooltip(\n        this.overlayContentFor(el, 'uipkge-leaflet-tooltip-src'),\n        defined({ offset: el.offset as any, direction: el.direction, permanent: el.permanent, sticky: el.sticky, opacity: el.opacity, className: el.tooltipClass, interactive: el.interactive }) as L.TooltipOptions,\n      )\n    } else if (el.lngLat) {\n      const tooltip = leaflet\n        .tooltip(\n          defined({ offset: el.offset as any, direction: el.direction, permanent: el.permanent, sticky: el.sticky, opacity: el.opacity, className: el.tooltipClass, interactive: el.interactive }) as L.TooltipOptions,\n        )\n        .setLatLng(toLatLng(el.lngLat))\n        .setContent(this.overlayContentFor(el, 'uipkge-leaflet-tooltip-src'))\n      tooltip.addTo(m)\n      this.layers.set(el as unknown as Element, tooltip as unknown as L.Layer)\n    }\n    ;(el as any).__uipParent = parent\n    this.overlayKeys.set(el, key)\n  }\n\n  private toggleFullscreen() {\n    if (document.fullscreenElement) document.exitFullscreen()\n    else (this as HTMLElement).requestFullscreen?.()\n  }\n\n  render() {\n    const cornerClasses: Record<LeafletPosition, string> = {\n      'top-left': 'left-3 top-3',\n      'top-right': 'right-3 top-3',\n      // above the ⓘ button (bottom-left) when credits are shown\n      'bottom-left': this.attribution && this.attributions.length ? 'bottom-9 left-3' : 'bottom-3 left-3',\n      'bottom-right': 'bottom-3 right-3',\n    }\n    const cornerOrder: LeafletPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']\n    const navPosition = this.navigationPosition ?? 'bottom-right'\n    const fsPosition = this.fullscreenPosition ?? 'top-right'\n    return html`<div\n      part=\"base\"\n      data-slot=\"leaflet-map\"\n      data-variant=${this.variant}\n      data-muted=${String(this.isMuted)}\n      class=${cn(leafletMapVariants({ variant: this.variant, ...(this.size ? { size: this.size } : {}) }), 'rounded-[inherit]')}\n    >\n      <div data-leaflet-container class=\"size-full\"></div>\n      ${cornerOrder.map((corner) => {\n        const showZoom = this.mapReady && this.navigation && navPosition === corner\n        const showFullscreen = this.mapReady && this.fullscreen && fsPosition === corner\n        if (!showZoom && !showFullscreen) return null\n        return html`<div class=${cn('absolute z-[1000] flex flex-col gap-2.5', cornerClasses[corner])}>\n          ${showZoom\n            ? html`<div class=\"border-border bg-card divide-border flex flex-col divide-y overflow-hidden rounded-lg border shadow-sm\">\n                <button\n                  type=\"button\"\n                  class=\"text-muted-foreground hover:bg-muted flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40\"\n                  aria-label=\"Zoom in\"\n                  ?disabled=${!this.canZoomIn}\n                  @click=${() => this.instance?.zoomIn()}\n                >\n                  <svg class=\"size-full\" viewBox=\"0 0 29 29\" fill=\"currentColor\" aria-hidden=\"true\">\n                    <path\n                      d=\"M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z\"\n                    />\n                  </svg>\n                </button>\n                <button\n                  type=\"button\"\n                  class=\"text-muted-foreground hover:bg-muted flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40\"\n                  aria-label=\"Zoom out\"\n                  ?disabled=${!this.canZoomOut}\n                  @click=${() => this.instance?.zoomOut()}\n                >\n                  <svg class=\"size-full\" viewBox=\"0 0 29 29\" fill=\"currentColor\" aria-hidden=\"true\">\n                    <path d=\"M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z\" />\n                  </svg>\n                </button>\n              </div>`\n            : null}\n          ${showFullscreen\n            ? html`<button\n                type=\"button\"\n                class=\"border-border bg-card text-muted-foreground hover:bg-muted flex size-8 items-center justify-center rounded-lg border shadow-sm transition-colors\"\n                aria-label=\"Toggle fullscreen\"\n                @click=${this.toggleFullscreen}\n              >\n                <svg class=\"size-full\" viewBox=\"0 0 29 29\" fill=\"currentColor\" aria-hidden=\"true\">\n                  ${this.isFullscreen\n                    ? html`<path\n                        d=\"M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z\"\n                      />`\n                    : html`<path\n                        d=\"M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z\"\n                      />`}\n                </svg>\n              </button>`\n            : null}\n        </div>`\n      })}\n      ${this.mapReady && this.attribution && this.attributions.length > 0\n        ? html`<div class=\"group absolute bottom-3 left-3 z-[1000] flex flex-col items-start gap-1.5\">\n            <div\n              role=\"note\"\n              class=${cn(\n                'border-border bg-popover text-popover-foreground max-w-64 rounded-md border px-2.5 py-1.5 text-[11px] leading-relaxed shadow-md transition-opacity [&_a]:underline',\n                this.showAttribution\n                  ? 'visible opacity-100'\n                  : 'invisible opacity-0 group-hover:visible group-hover:opacity-100',\n              )}\n            >\n              ${unsafeHTML(this.attributions.join(' | '))}\n            </div>\n            <button\n              type=\"button\"\n              class=\"border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground flex size-4 items-center justify-center rounded-full border shadow-xs transition-colors\"\n              aria-label=\"Map data attribution\"\n              aria-expanded=${String(this.showAttribution)}\n              @click=${() => (this.showAttribution = !this.showAttribution)}\n            >\n              <svg\n                class=\"size-3\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                aria-hidden=\"true\"\n              >\n                <circle cx=\"12\" cy=\"12\" r=\"10\" />\n                <path d=\"M12 16v-4M12 8h.01\" />\n              </svg>\n            </button>\n          </div>`\n        : null}\n    </div>`\n  }\n}\n\n// ── Child elements ────────────────────────────────────────────────────────────\n\nexport type LeafletMarkerAnchor =\n  | 'center'\n  | 'top'\n  | 'bottom'\n  | 'left'\n  | 'right'\n  | 'top-left'\n  | 'top-right'\n  | 'bottom-left'\n  | 'bottom-right'\n\n/**\n * <uip-leaflet-marker> — React's `LeafletMarker`. Place inside\n * `<uip-leaflet-map>`; its content becomes the div icon (no content =\n * Leaflet's default pin). Position with `lng-lat=\"[lng, lat]\"`. Nested\n * popups/tooltips bind to the marker instead of becoming icon content.\n * Events: `click` when the marker is clicked (detail: the Leaflet event —\n * React's `onClick`).\n */\nexport class UipLeafletMarker extends LitElement {\n  // Manual assignment: only non-overlay children render in the icon; nested\n  // popups/tooltips are assigned by the map into their own overlay slots.\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' as const }\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    lngLat: { attribute: 'lng-lat', type: Array },\n    anchor: {},\n    markerDraggable: { attribute: 'marker-draggable', type: Boolean },\n    opacity: { type: Number },\n    zIndexOffset: { attribute: 'z-index-offset', type: Number },\n    markerTitle: { attribute: 'marker-title' },\n    alt: {},\n  }\n\n  /** [lng, lat] — Mapbox order, matching the `map` component's MapMarker. */\n  lngLat?: [number, number]\n  /** Which edge/corner of the marker content sits on the coordinate. */\n  anchor: LeafletMarkerAnchor = 'center'\n  /** Native `draggable` is taken by HTMLElement — this sets Leaflet's draggable. */\n  markerDraggable?: boolean\n  opacity?: number\n  zIndexOffset?: number\n  /** Native `title` is taken by HTMLElement — this sets Leaflet's title. */\n  markerTitle?: string\n  alt?: string\n\n  private childObserver?: MutationObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-marker')\n    this.assignContent()\n    this.childObserver = new MutationObserver(() => this.assignContent())\n    this.childObserver.observe(this, { childList: true })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.childObserver?.disconnect()\n  }\n\n  // connectedCallback runs before the first render, so the <slot> doesn't\n  // exist yet there; assign again once it does.\n  protected firstUpdated() {\n    this.assignContent()\n  }\n\n  private assignContent() {\n    const slot = this.renderRoot?.querySelector('slot')\n    // Element children only (whitespace text nodes would still position fine,\n    // but Text nodes assigned alongside Elements are harmless to skip).\n    slot?.assign(...htmlKids(this).filter((n): n is Element => n.nodeType === 1))\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html`<slot></slot>`\n  }\n}\n\n/**\n * <uip-leaflet-popup> — React's `LeafletPopup`. Binds to the nearest ancestor\n * layer, or opens standalone at `lng-lat`.\n */\nexport class UipLeafletPopup extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    lngLat: { attribute: 'lng-lat', type: Array },\n    maxWidth: { attribute: 'max-width', type: Number },\n    minWidth: { attribute: 'min-width', type: Number },\n    offset: { type: Array },\n    popupClass: { attribute: 'popup-class' },\n    autoClose: { attribute: 'auto-close', converter: optionalBool },\n    closeOnClick: { attribute: 'close-on-click', converter: optionalBool },\n    closeButton: { attribute: 'close-button', converter: optionalBool },\n    keepInView: { attribute: 'keep-in-view', converter: optionalBool },\n  }\n\n  /** [lng, lat] — standalone popup on the map. Omit inside a layer to bind to it. */\n  lngLat?: [number, number]\n  maxWidth?: number\n  /** Minimum popup width. Defaults to 200 — keeps card-style content from collapsing narrow. */\n  minWidth = 200\n  offset?: [number, number]\n  /** Leaflet container class (React's `className`; `class`/`className` are taken by HTMLElement). */\n  popupClass?: string\n  // Leaflet's own defaults, declared explicitly (same as every framework's\n  // LeafletPopup): one popup open at a time, closes on map click, has a close\n  // button. `auto-close=\"false\"` etc. turn them off.\n  autoClose = true\n  closeOnClick = true\n  closeButton = true\n  keepInView = false\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-popup')\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html`<slot></slot>`\n  }\n}\n\n/**\n * <uip-leaflet-tooltip> — React's `LeafletTooltip`. Binds to the nearest\n * ancestor layer, or floats standalone at `lng-lat`.\n */\nexport class UipLeafletTooltip extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    lngLat: { attribute: 'lng-lat', type: Array },\n    offset: { type: Array },\n    direction: {},\n    permanent: { type: Boolean },\n    sticky: { type: Boolean },\n    opacity: { type: Number },\n    tooltipClass: { attribute: 'tooltip-class' },\n    interactive: { type: Boolean },\n  }\n\n  /** [lng, lat] — standalone tooltip on the map. Omit inside a layer to bind to it. */\n  lngLat?: [number, number]\n  offset?: [number, number]\n  direction?: 'top' | 'bottom' | 'left' | 'right' | 'center' | 'auto'\n  permanent?: boolean\n  sticky?: boolean\n  opacity?: number\n  /** Leaflet container class (React's `className`; `class`/`className` are taken by HTMLElement). */\n  tooltipClass?: string\n  interactive?: boolean\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-tooltip')\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html`<slot></slot>`\n  }\n}\n\ntype UipLeafletPath = UipLeafletPolyline | UipLeafletPolygon | UipLeafletCircle | UipLeafletCircleMarker\n\n/** Shared paint props for the vector children (React's LeafletPathProps). */\nconst pathProps = {\n  color: {},\n  weight: { type: Number },\n  opacity: { type: Number },\n  lineCap: { attribute: 'line-cap' },\n  lineJoin: { attribute: 'line-join' },\n  dashArray: { attribute: 'dash-array', converter: dashArrayConverter },\n  dashOffset: { attribute: 'dash-offset' },\n  fill: { type: Boolean },\n  fillColor: { attribute: 'fill-color' },\n  fillOpacity: { attribute: 'fill-opacity', type: Number },\n  pathClass: { attribute: 'path-class' },\n}\n\n/**\n * <uip-leaflet-polyline> — React's `LeafletPolyline`. Path points as\n * `lng-lat-path=\"[[lng,lat],…]\"` (or multi-part `[[[lng,lat],…]]`).\n * Events: `click` (detail: the Leaflet event — React's `onClick`).\n */\nexport class UipLeafletPolyline extends LitElement {\n  static styles = [css`:host { display: none; }`]\n  static properties = {\n    ...pathProps,\n    lngLatPath: { attribute: 'lng-lat-path', type: Array },\n    smoothFactor: { attribute: 'smooth-factor', type: Number },\n    noClip: { attribute: 'no-clip', type: Boolean },\n  }\n\n  lngLatPath?: [number, number][] | [number, number][][]\n  smoothFactor?: number\n  noClip?: boolean\n  color?: string\n  weight?: number\n  opacity?: number\n  lineCap?: 'butt' | 'round' | 'square'\n  lineJoin?: 'miter' | 'round' | 'bevel'\n  dashArray?: string | number[]\n  dashOffset?: string\n  fill?: boolean\n  fillColor?: string\n  fillOpacity?: number\n  pathClass?: string\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-polyline')\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html``\n  }\n}\n\n/**\n * <uip-leaflet-polygon> — React's `LeafletPolygon`. Ring points as\n * `lng-lat-path=\"[[lng,lat],…]\"` (or with holes `[[[lng,lat],…]]`).\n * Events: `click` (detail: the Leaflet event — React's `onClick`).\n */\nexport class UipLeafletPolygon extends LitElement {\n  static styles = [css`:host { display: none; }`]\n  static properties = {\n    ...pathProps,\n    lngLatPath: { attribute: 'lng-lat-path', type: Array },\n  }\n\n  lngLatPath?: [number, number][] | [number, number][][]\n  color?: string\n  weight?: number\n  opacity?: number\n  lineCap?: 'butt' | 'round' | 'square'\n  lineJoin?: 'miter' | 'round' | 'bevel'\n  dashArray?: string | number[]\n  dashOffset?: string\n  fill?: boolean\n  fillColor?: string\n  fillOpacity?: number\n  pathClass?: string\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-polygon')\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html``\n  }\n}\n\n/**\n * <uip-leaflet-circle> — React's `LeafletCircle`. Meter-accurate coverage\n * rings: `center=\"[lng, lat]\"` + `radius` in meters.\n * Events: `click` (detail: the Leaflet event — React's `onClick`).\n */\nexport class UipLeafletCircle extends LitElement {\n  static styles = [css`:host { display: none; }`]\n  static properties = {\n    ...pathProps,\n    center: { type: Array },\n    radius: { type: Number },\n  }\n\n  /** [lng, lat] — Mapbox order. */\n  center?: [number, number]\n  /** Radius in meters. */\n  radius?: number\n  color?: string\n  weight?: number\n  opacity?: number\n  lineCap?: 'butt' | 'round' | 'square'\n  lineJoin?: 'miter' | 'round' | 'bevel'\n  dashArray?: string | number[]\n  dashOffset?: string\n  fill?: boolean\n  fillColor?: string\n  fillOpacity?: number\n  pathClass?: string\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-circle')\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html``\n  }\n}\n\n/**\n * <uip-leaflet-circle-marker> — React's `LeafletCircleMarker`. Pixel-fixed\n * data points: `center=\"[lng, lat]\"` + `radius` in pixels.\n * Events: `click` (detail: the Leaflet event — React's `onClick`).\n */\nexport class UipLeafletCircleMarker extends LitElement {\n  static styles = [css`:host { display: none; }`]\n  static properties = {\n    ...pathProps,\n    center: { type: Array },\n    radius: { type: Number },\n  }\n\n  /** [lng, lat] — Mapbox order. */\n  center?: [number, number]\n  /** Radius in pixels. */\n  radius?: number\n  color?: string\n  weight?: number\n  opacity?: number\n  lineCap?: 'butt' | 'round' | 'square'\n  lineJoin?: 'miter' | 'round' | 'bevel'\n  dashArray?: string | number[]\n  dashOffset?: string\n  fill?: boolean\n  fillColor?: string\n  fillOpacity?: number\n  pathClass?: string\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-circle-marker')\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html``\n  }\n}\n\n/**\n * <uip-leaflet-geo-json> — React's `LeafletGeoJson`. `geojson` (property or\n * JSON attribute) + Leaflet `options` (property only — `style`,\n * `pointToLayer`, `onEachFeature`, …).\n * Events: `click` (detail: the Leaflet event — React's `onClick`).\n */\nexport class UipLeafletGeoJson extends LitElement {\n  static styles = [css`:host { display: none; }`]\n  static properties = {\n    geojson: { type: Object },\n    options: { type: Object },\n  }\n\n  /** GeoJSON FeatureCollection / Feature / geometry. */\n  geojson?: GeoJSON.GeoJSON\n  /** Leaflet GeoJSON options: `style`, `pointToLayer`, `onEachFeature`, `filter`, `coordsToLatLng`. */\n  options?: L.GeoJSONOptions\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-geo-json')\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html``\n  }\n}\n\n/**\n * <uip-leaflet-tile-layer> — React's `LeafletTileLayer`. Extra raster layers\n * stacked above the basemap.\n */\nexport class UipLeafletTileLayer extends LitElement {\n  static styles = [css`:host { display: none; }`]\n  static properties = {\n    url: {},\n    attribution: {},\n    subdomains: {},\n    minZoom: { attribute: 'min-zoom', type: Number },\n    maxZoom: { attribute: 'max-zoom', type: Number },\n    opacity: { type: Number },\n    zIndex: { attribute: 'z-index', type: Number },\n    tms: { type: Boolean },\n  }\n\n  /** Raster tile URL template ({z}/{x}/{y}, optional {s} subdomains + {r} retina). */\n  url?: string\n  attribution?: string\n  subdomains?: string | string[]\n  minZoom?: number\n  maxZoom?: number\n  opacity?: number\n  zIndex?: number\n  tms?: boolean\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-slot', 'leaflet-tile-layer')\n  }\n\n  protected updated() {\n    notifyMap(this)\n  }\n\n  render() {\n    return html``\n  }\n}\n\n// ── <uip-leaflet-map-controls> ────────────────────────────────────────────────\n\n/**\n * <uip-leaflet-map-controls> — LeafletMapControls: a zoom in / zoom out /\n * reset-view button stack for custom map chrome (e.g. with `navigation=\"false\"`\n * on the map). Place it NEXT TO the map inside a `relative isolate` wrapper;\n * it sits bottom-right above the map panes:\n *\n *   <div class=\"relative isolate\">\n *     <uip-leaflet-map id=\"offices\" navigation=\"false\" …></uip-leaflet-map>\n *     <uip-leaflet-map-controls for=\"offices\"></uip-leaflet-map-controls>\n *   </div>\n *\n * `for` is the map's id; without it the first <uip-leaflet-map> in the parent\n * is used. Each button fires a cancelable `zoom-in` / `zoom-out` / `reset`\n * event (React's onZoomIn/onZoomOut/onReset); unless prevented it acts on the\n * map — reset returns to the map's `center` / `zoom` (e.g. call `fitBounds` in\n * your `reset` handler and `preventDefault()` for reset-to-fit).\n *\n * Labels (aria-label + tooltip): `zoom-in-label`, `zoom-out-label`,\n * `reset-label`. Parts: `base`, `zoom-in`, `zoom-out`, `reset`.\n */\nexport class UipLeafletMapControls extends LitElement {\n  // The absolute stack positions against the consumer's wrapper, not the host.\n  static styles = [tailwind, css`:host { display: contents; }`]\n\n  static properties = {\n    for: {},\n    zoomInLabel: { attribute: 'zoom-in-label' },\n    zoomOutLabel: { attribute: 'zoom-out-label' },\n    resetLabel: { attribute: 'reset-label' },\n  }\n\n  for?: string\n  zoomInLabel = 'Zoom in'\n  zoomOutLabel = 'Zoom out'\n  resetLabel = 'Reset view'\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'leaflet-map-controls')\n  }\n\n  private get targetMap(): UipLeafletMap | null {\n    const root = this.getRootNode() as Document | ShadowRoot\n    if (this.for) return (root.getElementById?.(this.for) as UipLeafletMap | null) ?? null\n    return this.parentElement?.querySelector('uip-leaflet-map') ?? null\n  }\n\n  private act(name: 'zoom-in' | 'zoom-out' | 'reset') {\n    const ev = new CustomEvent(name, { bubbles: true, composed: true, cancelable: true })\n    if (!this.dispatchEvent(ev)) return\n    const map = this.targetMap\n    if (!map) return\n    if (name === 'zoom-in') map.zoomIn()\n    else if (name === 'zoom-out') map.zoomOut()\n    else map.flyTo({ center: map.center, zoom: map.zoom })\n  }\n\n  private button(name: 'zoom-in' | 'zoom-out' | 'reset', label: string, glyph: ReturnType<typeof icon>, first = false) {\n    return html`<button\n      type=\"button\"\n      part=${name}\n      data-slot=${`leaflet-map-${name}`}\n      class=${cn(buttonVariants({ variant: 'ghost', size: 'icon' }), 'size-8 rounded-none', !first && 'border-t')}\n      aria-label=${label}\n      title=${label}\n      @click=${() => this.act(name)}\n    >\n      ${glyph}\n    </button>`\n  }\n\n  render() {\n    return html`<div\n      part=\"base\"\n      data-slot=\"leaflet-map-controls\"\n      class=\"bg-card/90 absolute right-3 bottom-3 z-[800] flex flex-col overflow-hidden rounded-md border backdrop-blur-sm\"\n    >\n      ${this.button('zoom-in', this.zoomInLabel, icon(Plus, 'plus', 'size-4'), true)}\n      ${this.button('zoom-out', this.zoomOutLabel, icon(Minus, 'minus', 'size-4'))}\n      ${this.button('reset', this.resetLabel, icon(RotateCcw, 'rotate-ccw', 'size-4'))}\n    </div>`\n  }\n}\n\n// ── <uip-leaflet-popup-card> ──────────────────────────────────────────────────\n\n/**\n * <uip-leaflet-popup-card> — LeafletPopupCard: card-style content for the\n * inside of a <uip-leaflet-popup>: title row (category dot, heading, muted\n * subtitle, trailing badge), then its children — usually a\n * <uip-leaflet-popup-card-stats> strip and free content. It renders in its own\n * shadow root, so Leaflet's `.leaflet-popup-content p` margins can't reach it.\n *\n *   <uip-leaflet-popup lng-lat=\"[-0.0877, 51.5155]\">\n *     <uip-leaflet-popup-card heading=\"London HQ\" subtitle=\"United Kingdom\" badge=\"Headquarters\" badge-variant=\"info\"\n *       dot class=\"[&::part(dot)]:bg-chart-1\">\n *       <uip-leaflet-popup-card-stats>\n *         <uip-leaflet-popup-card-stat label=\"People\" value=\"184\" hint=\"+6%\" hint-tone=\"success\"></uip-leaflet-popup-card-stat>\n *         <uip-leaflet-popup-card-stat label=\"Roles\" value=\"9\" hint=\"Hiring\"></uip-leaflet-popup-card-stat>\n *       </uip-leaflet-popup-card-stats>\n *       <div class=\"text-xs\">John Doe · Site lead</div>\n *     </uip-leaflet-popup-card>\n *   </uip-leaflet-popup>\n *\n * Properties: `heading` (React's `title`; `title` is an HTMLElement\n * attribute), `subtitle`, `badge` (text) + `badge-variant` (the Badge\n * variants, default `secondary`), `dot` (category dot before the heading —\n * React's `dotClassName`; colour it with `[&::part(dot)]:bg-…`). Slots:\n * `subtitle` (rich subtitle, e.g. with an icon), `badge` (a custom badge),\n * default (children, stacked with `gap-3`). Parts: `base`, `dot`, `heading`,\n * `subtitle`, `badge`.\n */\nexport class UipLeafletPopupCard extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    heading: {},\n    subtitle: {},\n    badge: {},\n    badgeVariant: { attribute: 'badge-variant' },\n    dot: { type: Boolean },\n  }\n\n  heading = ''\n  subtitle?: string\n  badge?: string\n  badgeVariant: NonNullable<BadgeVariants['variant']> = 'secondary'\n  dot = false\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'leaflet-popup-card')\n  }\n\n  render() {\n    // Slotted children aren't shadow-tree children, so React's `space-y-3` is a flex gap here.\n    return html`<div part=\"base\" data-slot=\"leaflet-popup-card\" class=\"text-popover-foreground flex w-60 flex-col gap-3 text-left\">\n      <!-- pr-5 keeps the title clear of Leaflet's close button. -->\n      <div class=\"flex items-start justify-between gap-3 pr-5\">\n        <div class=\"min-w-0\">\n          <div class=\"flex items-center gap-1.5\">\n            ${this.dot\n              ? html`<span part=\"dot\" class=\"bg-primary size-2 shrink-0 rounded-full\" aria-hidden=\"true\"></span>`\n              : nothing}\n            <span part=\"heading\" class=\"truncate text-sm font-semibold\">${this.heading}</span>\n          </div>\n          <div part=\"subtitle\" class=\"text-muted-foreground mt-0.5 flex min-w-0 items-center gap-1.5 text-xs\">\n            <slot name=\"subtitle\"><span class=\"truncate\">${this.subtitle ?? ''}</span></slot>\n          </div>\n        </div>\n        <div class=\"shrink-0\">\n          <slot name=\"badge\"\n            >${this.badge\n              ? html`<span part=\"badge\" data-slot=\"badge\" class=${badgeVariants({ variant: this.badgeVariant })}\n                  >${this.badge}</span\n                >`\n              : nothing}</slot\n          >\n        </div>\n      </div>\n      <slot></slot>\n    </div>`\n  }\n}\n\n/**\n * <uip-leaflet-popup-card-stats> — LeafletPopupStats: bordered strip of\n * equal-width stat cells. Children: <uip-leaflet-popup-card-stat>s. React's\n * `divide-x` can't reach slotted cells, so the strip marks every cell after\n * the first as `divided` (it draws its own left border). Part: `base`.\n */\nexport class UipLeafletPopupCardStats extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'leaflet-popup-stats')\n  }\n\n  private onSlotChange(e: Event) {\n    const cells = (e.target as HTMLSlotElement).assignedElements()\n    cells.forEach((el, i) => {\n      if (el instanceof UipLeafletPopupCardStat) el.divided = i > 0\n    })\n  }\n\n  render() {\n    return html`<div\n      part=\"base\"\n      data-slot=\"leaflet-popup-stats\"\n      class=\"bg-muted/50 grid auto-cols-fr grid-flow-col overflow-hidden rounded-md border\"\n    >\n      <slot @slotchange=${this.onSlotChange}></slot>\n    </div>`\n  }\n}\n\n/**\n * <uip-leaflet-popup-card-stat> — LeafletPopupStat: one cell of a\n * <uip-leaflet-popup-card-stats> strip. Properties: `label`, `value`, `hint`\n * (small line under the value: \"+4%\", \"Hiring\", a timezone; or `slot=\"hint\"`\n * for rich content), `hint-tone` (`muted` default | `success` — React's\n * `hintClassName`). Slot `icon`: a small icon before the label. Parts:\n * `base`, `label`, `value`, `hint`.\n */\nexport class UipLeafletPopupCardStat extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    label: {},\n    value: {},\n    hint: {},\n    hintTone: { attribute: 'hint-tone' },\n    divided: { type: Boolean, attribute: false },\n    hasHint: { state: true },\n  }\n\n  label = ''\n  value = ''\n  hint?: string\n  hintTone: 'muted' | 'success' = 'muted'\n  /** Set by the stats strip on every cell after the first. */\n  divided = false\n  private hasHint = false\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'leaflet-popup-stat')\n  }\n\n  private onHintSlotChange(e: Event) {\n    this.hasHint = (e.target as HTMLSlotElement)\n      .assignedNodes()\n      .some((n) => n.nodeType === 1 || n.textContent?.trim())\n  }\n\n  render() {\n    const showHint = this.hasHint || !!this.hint\n    return html`<div part=\"base\" data-slot=\"leaflet-popup-stat\" class=${cn('h-full min-w-0 px-2 py-1.5', this.divided && 'border-l')}>\n      <div part=\"label\" class=\"text-muted-foreground flex items-center gap-1 text-xs\">\n        <slot name=\"icon\" class=\"[&::slotted(svg)]:size-3.5 [&::slotted(svg)]:shrink-0\"></slot>\n        <span class=\"truncate\">${this.label}</span>\n      </div>\n      <div part=\"value\" class=\"mt-0.5 text-sm font-semibold tabular-nums\">${this.value}</div>\n      <div\n        part=\"hint\"\n        class=${cn('text-xs tabular-nums', this.hintTone === 'success' ? 'text-success' : 'text-muted-foreground')}\n        ?hidden=${!showHint}\n      >\n        <slot name=\"hint\" @slotchange=${this.onHintSlotChange}>${this.hint ?? ''}</slot>\n      </div>\n    </div>`\n  }\n}\n\ncustomElements.get('uip-leaflet-map') || customElements.define('uip-leaflet-map', UipLeafletMap)\ncustomElements.get('uip-leaflet-marker') || customElements.define('uip-leaflet-marker', UipLeafletMarker)\ncustomElements.get('uip-leaflet-popup') || customElements.define('uip-leaflet-popup', UipLeafletPopup)\ncustomElements.get('uip-leaflet-tooltip') || customElements.define('uip-leaflet-tooltip', UipLeafletTooltip)\ncustomElements.get('uip-leaflet-polyline') || customElements.define('uip-leaflet-polyline', UipLeafletPolyline)\ncustomElements.get('uip-leaflet-polygon') || customElements.define('uip-leaflet-polygon', UipLeafletPolygon)\ncustomElements.get('uip-leaflet-circle') || customElements.define('uip-leaflet-circle', UipLeafletCircle)\ncustomElements.get('uip-leaflet-circle-marker') ||\n  customElements.define('uip-leaflet-circle-marker', UipLeafletCircleMarker)\ncustomElements.get('uip-leaflet-geo-json') || customElements.define('uip-leaflet-geo-json', UipLeafletGeoJson)\ncustomElements.get('uip-leaflet-tile-layer') || customElements.define('uip-leaflet-tile-layer', UipLeafletTileLayer)\ncustomElements.get('uip-leaflet-map-controls') ||\n  customElements.define('uip-leaflet-map-controls', UipLeafletMapControls)\ncustomElements.get('uip-leaflet-popup-card') || customElements.define('uip-leaflet-popup-card', UipLeafletPopupCard)\ncustomElements.get('uip-leaflet-popup-card-stats') ||\n  customElements.define('uip-leaflet-popup-card-stats', UipLeafletPopupCardStats)\ncustomElements.get('uip-leaflet-popup-card-stat') ||\n  customElements.define('uip-leaflet-popup-card-stat', UipLeafletPopupCardStat)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-leaflet-map': UipLeafletMap\n    'uip-leaflet-marker': UipLeafletMarker\n    'uip-leaflet-popup': UipLeafletPopup\n    'uip-leaflet-tooltip': UipLeafletTooltip\n    'uip-leaflet-polyline': UipLeafletPolyline\n    'uip-leaflet-polygon': UipLeafletPolygon\n    'uip-leaflet-circle': UipLeafletCircle\n    'uip-leaflet-circle-marker': UipLeafletCircleMarker\n    'uip-leaflet-geo-json': UipLeafletGeoJson\n    'uip-leaflet-tile-layer': UipLeafletTileLayer\n    'uip-leaflet-map-controls': UipLeafletMapControls\n    'uip-leaflet-popup-card': UipLeafletPopupCard\n    'uip-leaflet-popup-card-stats': UipLeafletPopupCardStats\n    'uip-leaflet-popup-card-stat': UipLeafletPopupCardStat\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/charts/leaflet-map.ts"
    },
    {
      "path": "packages/elements-lit/src/components/charts/lib/chart-element.ts",
      "content": "import { LitElement, css, html, type PropertyDeclarations, type PropertyValues } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport type { ECharts } from 'echarts/core'\nimport { tailwind } from '../../../lib/styles'\nimport { ThemeController } from '../../../lib/theme'\nimport { heightToStyle, resolveChartTheme, subscribeChartTheme, type ChartTheme } from './chart-theme'\n\n/**\n * Attribute converter for booleans whose React default is `true`\n * (`showLabels`, `autoresize`): Lit's `type: Boolean` can't be switched off\n * from markup, so `show-labels=\"false\"` turns it off and anything else\n * (including removing the attribute) means true.\n */\nexport const defaultTrue = {\n  fromAttribute: (v: string | null) => v !== 'false',\n  toAttribute: (v: boolean) => (v ? '' : 'false'),\n}\n\n/**\n * `yField=\"value\"` from markup, `yField=['north', 'south']` from markup as a\n * JSON array or from JS as a property.\n */\nexport const stringOrArray = {\n  fromAttribute: (v: string | null) => (v?.trim().startsWith('[') ? (JSON.parse(v) as string[]) : v),\n}\n\n/**\n * Shared base for every ECharts-backed element — the Lit stand-in for React's\n * `ChartFrame` + `EChart` + `useChartTheme()`:\n *\n *  - renders React's ChartFrame chrome (role=\"img\", tabindex, focus ring,\n *    `height`) with a full-size inner container for the canvas;\n *  - resolves the chart theme from the CSS tokens and re-resolves it (and\n *    re-sets the option) whenever <html> class/style/data-theme changes;\n *  - lazily `import()`s echarts in firstUpdated (SSR-safe: nothing runs on the\n *    server), resizes with a ResizeObserver and disposes on disconnect;\n *  - applies `buildOption()` with `{ notMerge: true, lazyUpdate: true }`,\n *    exactly like React's EChart.\n *\n * Subclasses implement `buildOption()` (React's `mergedOption` useMemo) and\n * set `chartSlot` (the host's data-slot). `instance` exposes the ECharts\n * instance (undefined until initialised) for getOption()/dispatchAction().\n */\nexport abstract class ChartElement extends LitElement {\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties: PropertyDeclarations = {\n    height: {},\n    // role=\"img\" lives on the inner frame, so the host's aria-label is forwarded.\n    accessibleLabel: { attribute: 'aria-label' },\n    chartTheme: { state: true },\n  }\n\n  height: number | string = 300\n  accessibleLabel?: string\n  protected chartTheme: ChartTheme = resolveChartTheme()\n  /** Resize with the container. Only RawChart exposes it as a property. */\n  protected autoresize = true\n\n  protected abstract readonly chartSlot: string\n  protected abstract buildOption(): any\n\n  private chart?: ECharts\n  private resizeObserver?: ResizeObserver\n  private unsubscribeTheme?: () => void\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  /** The ECharts instance, once initialised in the browser. */\n  get instance(): ECharts | undefined {\n    return this.chart\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', this.chartSlot)\n    this.chartTheme = resolveChartTheme()\n    this.unsubscribeTheme = subscribeChartTheme(() => {\n      this.chartTheme = resolveChartTheme()\n    })\n    // Re-attached after a move: firstUpdated won't run again.\n    if (this.hasUpdated) void this.initChart()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.unsubscribeTheme?.()\n    this.resizeObserver?.disconnect()\n    this.resizeObserver = undefined\n    this.chart?.dispose()\n    this.chart = undefined\n  }\n\n  protected firstUpdated() {\n    void this.initChart()\n  }\n\n  protected updated(changed: PropertyValues) {\n    if (!this.chart) return\n    if (changed.has('autoresize')) this.observeResize()\n    this.chart.setOption(this.buildOption(), { notMerge: true, lazyUpdate: true })\n  }\n\n  private get container() {\n    return this.renderRoot.querySelector<HTMLDivElement>('[data-chart]')\n  }\n\n  private async initChart() {\n    const { echarts } = await import('./echarts')\n    const el = this.container\n    if (!this.isConnected || this.chart || !el) return\n    this.chart = echarts.init(el)\n    this.chart.setOption(this.buildOption(), { notMerge: true, lazyUpdate: true })\n    this.observeResize()\n  }\n\n  private observeResize() {\n    this.resizeObserver?.disconnect()\n    this.resizeObserver = undefined\n    const el = this.container\n    if (!this.autoresize || !el) return\n    this.resizeObserver = new ResizeObserver(() => this.chart?.resize())\n    this.resizeObserver.observe(el)\n  }\n\n  render() {\n    return html`<div\n      part=\"base\"\n      role=\"img\"\n      aria-label=${this.accessibleLabel || 'Chart'}\n      tabindex=\"0\"\n      style=${styleMap({ height: heightToStyle(this.height) })}\n      class=\"focus-visible:ring-ring w-full focus-visible:ring-2 focus-visible:outline-none\"\n    >\n      <div data-chart class=\"h-full w-full\"></div>\n    </div>`\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/charts/lib/chart-element.ts"
    },
    {
      "path": "packages/elements-lit/src/components/charts/lib/chart-theme.ts",
      "content": "import { isServer } from 'lit'\n\n// Port of packages/registry-react/components/charts/useChartTheme.ts.\n//\n// The chart palette is driven by the design tokens (`--chart-1`..`--chart-5`,\n// `--muted-foreground`, `--border`, `--popover`, …). They are resolved with\n// getComputedStyle on <html> and re-resolved whenever <html> class / style /\n// data-theme changes, so a light/dark flip re-colours every chart. Instead of\n// React's useChartTheme() hook, elements call subscribeChartTheme().\n\nconst listeners = new Set<() => void>()\nlet observing = false\n\nfunction bump() {\n  listeners.forEach((l) => l())\n}\n\n/** Call `cb` whenever the page theme changes. Returns the unsubscribe fn. */\nexport function subscribeChartTheme(cb: () => void): () => void {\n  listeners.add(cb)\n  if (!observing && !isServer) {\n    observing = true\n    // Same as React: one bump on the first frame (tokens resolved after the\n    // stylesheet applies), then on every <html> class/style/data-theme change.\n    requestAnimationFrame(bump)\n    new MutationObserver(bump).observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: ['class', 'style', 'data-theme'],\n    })\n  }\n  return () => listeners.delete(cb)\n}\n\n// ECharts' canvas renderer does not accept OKLCH in every browser. Assigning\n// one of our Tailwind tokens to `fillStyle` can silently leave the sentinel\n// colour in place, turning a whole chart black. Convert OKLCH ourselves and\n// let the canvas normalize older CSS colour formats.\nlet _hexCanvas: CanvasRenderingContext2D | null = null\n\nexport function toCanvasColor(cssColor: string): string {\n  const value = cssColor.trim()\n  const match = value.match(\n    /^oklch\\(\\s*([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(%?)\\s+([+-]?(?:\\d+\\.?\\d*|\\.\\d+))\\s+([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(?:deg)?(?:\\s*\\/\\s*([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(%?))?\\s*\\)$/i,\n  )\n\n  if (match) {\n    const lightness = Number(match[1]) / (match[2] === '%' ? 100 : 1)\n    const chroma = Number(match[3])\n    const hue = (Number(match[4]) * Math.PI) / 180\n    const alpha = match[5] == null ? 1 : Number(match[5]) / (match[6] === '%' ? 100 : 1)\n    const a = chroma * Math.cos(hue)\n    const b = chroma * Math.sin(hue)\n\n    const l = Math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3)\n    const m = Math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3)\n    const s = Math.pow(lightness - 0.0894841775 * a - 1.291485548 * b, 3)\n    const linear = [\n      4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,\n      -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,\n      -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,\n    ]\n    const rgb = linear.map((channel) => {\n      const encoded = channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055\n      return Math.round(Math.min(1, Math.max(0, encoded)) * 255)\n    })\n\n    return alpha < 1 ? `rgba(${rgb.join(', ')}, ${alpha})` : `rgb(${rgb.join(', ')})`\n  }\n\n  if (isServer) return cssColor\n  if (!_hexCanvas) {\n    _hexCanvas = document.createElement('canvas').getContext('2d')\n  }\n  if (!_hexCanvas) return cssColor\n  _hexCanvas.fillStyle = '#010203'\n  _hexCanvas.fillStyle = value\n  const normalized = _hexCanvas.fillStyle as string\n  return normalized === '#010203' && value.toLowerCase() !== '#010203' ? value : normalized\n}\n\n// Convert any CSS color (hex, rgb, oklch, color()) + alpha 0..1 to a\n// canvas-safe rgba(r,g,b,a).\nexport function toRgba(cssColor: string, alpha: number): string {\n  const normalized = toCanvasColor(cssColor)\n  if (normalized.startsWith('#') && normalized.length === 7) {\n    const r = parseInt(normalized.slice(1, 3), 16)\n    const g = parseInt(normalized.slice(3, 5), 16)\n    const b = parseInt(normalized.slice(5, 7), 16)\n    return `rgba(${r},${g},${b},${alpha})`\n  }\n  if (normalized.startsWith('rgba(')) {\n    return normalized.replace(/,\\s*[\\d.]+\\s*\\)$/, `,${alpha})`)\n  }\n  if (normalized.startsWith('rgb(')) {\n    return normalized.replace(/^rgb\\(/, 'rgba(').replace(/\\)$/, `,${alpha})`)\n  }\n  return cssColor\n}\n\nexport function resolveVar(name: string, fallback: string): string {\n  if (isServer) return fallback\n  const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()\n  if (!v) return fallback\n  return toCanvasColor(v)\n}\n\n// SSR / pre-hydration fallback palette (same values as React).\nconst CHART_FALLBACK = ['#f59e0b', '#14b8a6', '#3b82f6', '#f97316', '#eab308']\n\n/** The resolved chart theme tokens. All values are canvas-safe hex/rgba. */\nexport interface ChartTheme {\n  colors: string[]\n  textColor: string\n  axisColor: string\n  splitLineColor: string\n  tooltipBg: string\n  tooltipBorder: string\n  tooltipText: string\n  bgColor: string\n  accentColor: string\n  dangerColor: string\n  /** `--card`: cell borders and inside-label text, so they read as gaps /\n   *  contrast on light and dark surfaces (never '#fff'). */\n  surfaceColor: string\n  /** `--foreground`: primary numbers drawn on the canvas (gauge value). */\n  foregroundColor: string\n  /** Gauge stoplight from the status tokens, same rule as <uip-usage-bar>:\n   *  < 70% success, 70-89% warning, >= 90% destructive. */\n  semanticGaugeThresholds: [number, string][]\n}\n\nexport function resolveChartTheme(): ChartTheme {\n  return {\n    colors: Array.from({ length: 5 }, (_, i) => resolveVar(`--chart-${i + 1}`, CHART_FALLBACK[i]!)),\n    textColor: resolveVar('--muted-foreground', '#888888'),\n    axisColor: resolveVar('--border', '#e5e5e5'),\n    splitLineColor: resolveVar('--border', '#f0f0f0'),\n    tooltipBg: resolveVar('--popover', 'rgba(255,255,255,0.96)'),\n    tooltipBorder: resolveVar('--border', '#e5e5e5'),\n    tooltipText: resolveVar('--popover-foreground', '#333333'),\n    bgColor: resolveVar('--card', resolveVar('--background', '#ffffff')),\n    accentColor: resolveVar('--primary', '#38bdf8'),\n    dangerColor: resolveVar('--destructive', '#dc2626'),\n    surfaceColor: resolveVar('--card', '#ffffff'),\n    foregroundColor: resolveVar('--foreground', '#171717'),\n    semanticGaugeThresholds: [\n      [0.7, resolveVar('--success', '#14b8a6')],\n      [0.9, resolveVar('--warning', '#f59e0b')],\n      [1, resolveVar('--destructive', '#dc2626')],\n    ],\n  }\n}\n\n// Two-level deep merge for ECharts option blocks (verbatim from React): top-level\n// keys merge shallowly, and one nested level (axisLabel, splitLine, …) merges\n// shallowly too. Arrays + primitives replace outright — including a whole\n// block given as an array (e.g. `xAxis: [a, b]` for two axes).\nexport function mergeOptionBlock<T extends Record<string, any>>(base: T, user: Partial<T> | undefined): T {\n  if (!user) return base\n  if (Array.isArray(user)) return user as unknown as T\n  const out: any = { ...base }\n  for (const k of Object.keys(user)) {\n    const bv = (base as any)[k]\n    const uv = (user as any)[k]\n    if (\n      bv != null &&\n      uv != null &&\n      typeof bv === 'object' &&\n      typeof uv === 'object' &&\n      !Array.isArray(bv) &&\n      !Array.isArray(uv)\n    ) {\n      out[k] = { ...bv, ...uv }\n    } else {\n      out[k] = uv\n    }\n  }\n  return out\n}\n\n/** `height` prop → CSS: bare digits are px, anything else is raw CSS. */\nexport function heightToStyle(height: number | string): string {\n  return /^\\d+$/.test(String(height)) ? `${height}px` : String(height)\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/charts/lib/chart-theme.ts"
    },
    {
      "path": "packages/elements-lit/src/components/charts/lib/echarts.ts",
      "content": "// ECharts core with every chart type / component the React wrappers register\n// (same list as packages/registry-react/components/charts/shared.tsx), so\n// <uip-raw-chart> can render any of them. Only ever loaded with a dynamic\n// import() from ChartElement.firstUpdated — never at module scope — so the\n// elements stay SSR-safe and echarts stays out of the initial bundle.\n// (Lives in charts/lib/ so src/index.ts's components/*/*.ts glob skips it.)\nimport * as echarts from 'echarts/core'\nimport { CanvasRenderer } from 'echarts/renderers'\nimport {\n  LineChart,\n  BarChart,\n  PieChart,\n  ScatterChart,\n  EffectScatterChart,\n  PictorialBarChart,\n  RadarChart,\n  GaugeChart,\n  HeatmapChart,\n  TreemapChart,\n  FunnelChart,\n  BoxplotChart,\n  CandlestickChart,\n  CustomChart,\n  ChordChart,\n  MapChart,\n  GraphChart,\n  ParallelChart,\n  SankeyChart,\n  SunburstChart,\n  ThemeRiverChart,\n  TreeChart,\n} from 'echarts/charts'\nimport {\n  GridComponent,\n  PolarComponent,\n  TooltipComponent,\n  LegendComponent,\n  MarkLineComponent,\n  MarkAreaComponent,\n  MarkPointComponent,\n  GraphicComponent,\n  RadarComponent,\n  VisualMapComponent,\n  CalendarComponent,\n  DataZoomComponent,\n  ParallelComponent,\n  SingleAxisComponent,\n  TitleComponent,\n} from 'echarts/components'\n\necharts.use([\n  CanvasRenderer,\n  LineChart,\n  BarChart,\n  PieChart,\n  ScatterChart,\n  EffectScatterChart,\n  PictorialBarChart,\n  RadarChart,\n  GaugeChart,\n  HeatmapChart,\n  TreemapChart,\n  FunnelChart,\n  BoxplotChart,\n  CandlestickChart,\n  CustomChart,\n  ChordChart,\n  MapChart,\n  GraphChart,\n  ParallelChart,\n  SankeyChart,\n  SunburstChart,\n  ThemeRiverChart,\n  TreeChart,\n  GridComponent,\n  PolarComponent,\n  TooltipComponent,\n  LegendComponent,\n  MarkLineComponent,\n  MarkAreaComponent,\n  MarkPointComponent,\n  GraphicComponent,\n  RadarComponent,\n  VisualMapComponent,\n  CalendarComponent,\n  DataZoomComponent,\n  ParallelComponent,\n  SingleAxisComponent,\n  TitleComponent,\n])\n\nexport { echarts }\n",
      "type": "registry:file",
      "target": "src/uip/components/charts/lib/echarts.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "echarts",
    "leaflet",
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/badge.json",
    "https://uipkge.dev/r/lit/button.json",
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
