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