import { Icon, Map as LMap, MapOptions } from 'leaflet'; import { defineProps, num, off, on, type NumProp, type PropDef } from '../core/props.ts'; import { LeafletRegisterEvent } from '../core/register.ts'; const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='; const CSS_ATTRS = ['css-url', 'css-integrity', 'css-crossorigin'] as const; const PROPS = defineProps({ // View state — excluded from #buildOptions, initialised via setView() lat: num(0, { viewState: true, event: 'moveend', mapGet: (m: LMap) => m.getCenter()?.lat, mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter()?.lng ?? 0], m.getZoom()), }), lng: num(0, { viewState: true, event: 'moveend', mapGet: (m: LMap) => m.getCenter()?.lng, mapSet: (m: LMap, v: number) => m.setView([m.getCenter()?.lat ?? 0, v], m.getZoom()), }), zoom: num(2, { viewState: true, event: 'zoomend', mapGet: (m: LMap) => m.getZoom(), mapSet: (m: LMap, v: number) => m.setZoom(v), }), // Live numeric options — have Leaflet setters minZoom: num(0, { mapGet: (m: LMap) => m.getMinZoom(), mapSet: (m: LMap, v: number) => m.setMinZoom(v), }), maxZoom: num(Infinity, { mapGet: (m: LMap) => m.getMaxZoom(), mapSet: (m: LMap, v: number) => m.setMaxZoom(v), }), // Constructor-only numeric options zoomSnap: num(1), zoomDelta: num(1), keyboardPanDelta: num(80), wheelDebounceTime: num(40), wheelPxPerZoomLevel: num(60), inertiaDeceleration: num(3000), inertiaMaxSpeed: num(Infinity), easeLinearity: num(0.2), maxBoundsViscosity: num(0), tapTolerance: num(15), zoomAnimationThreshold: num(4), transform3DLimit: num(8388608), // Boolean defaults-true → disable-* attribute; handler-based options have live mapSet scrollWheelZoom: off((m: LMap, v: boolean) => v ? m.scrollWheelZoom.enable() : m.scrollWheelZoom.disable(), ), dragging: off((m: LMap, v: boolean) => (v ? m.dragging.enable() : m.dragging.disable())), touchZoom: off((m: LMap, v: boolean) => (v ? m.touchZoom.enable() : m.touchZoom.disable())), doubleClickZoom: off((m: LMap, v: boolean) => v ? m.doubleClickZoom.enable() : m.doubleClickZoom.disable(), ), boxZoom: off((m: LMap, v: boolean) => (v ? m.boxZoom.enable() : m.boxZoom.disable())), keyboard: off((m: LMap, v: boolean) => (v ? m.keyboard.enable() : m.keyboard.disable())), closePopupOnClick: off(), trackResize: off(), zoomControl: off(), attributionControl: off(), inertia: off(), zoomAnimation: off(), fadeAnimation: off(), markerZoomAnimation: off(), bounceAtZoomLimits: off(), tapHold: off(), // Boolean defaults-false → normal attribute preferCanvas: on(), worldCopyJump: on(), }); type PropName = keyof typeof PROPS; const ATTR_TO_PROP = new globalThis.Map( (Object.entries(PROPS) as [PropName, PropDef][]).map(([name, spec]) => [spec.attr, name]), ); // Derive property types directly from the PROPS table so adding a prop to the // table automatically makes it part of the LeafletMap instance type. type PropTypes = { [K in keyof typeof PROPS]: (typeof PROPS)[K] extends { kind: 'num' } ? number : boolean; }; const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; export default class LeafletMap extends TypedBase { #map?: LMap; #container!: HTMLDivElement; #cssLink?: HTMLLinkElement; #syncing = false; #mapEventHandlers = new globalThis.Map void>(); #resizeObserver?: ResizeObserver; static get observedAttributes(): string[] { return [...Object.values(PROPS).map((s) => s.attr), ...CSS_ATTRS]; } // Generates a getter/setter on the prototype for every entry in PROPS. // The functions are defined inside the class body so they can access private fields. static { for (const [propName, spec] of Object.entries(PROPS) as [PropName, PropDef][]) { if (spec.kind === 'num') { Object.defineProperty(LeafletMap.prototype, propName, { get(this: LeafletMap) { const val = spec.mapGet && this.#map ? spec.mapGet(this.#map) : undefined; return val !== undefined ? val : +(this.getAttribute(spec.attr) ?? spec.default); }, set(this: LeafletMap, v: number) { if (spec.event) { this.#syncAttr(spec.attr, `${v}`); } else { this.setAttribute(spec.attr, `${v}`); } }, configurable: true, enumerable: true, }); } else if (spec.kind === 'bool-off') { Object.defineProperty(LeafletMap.prototype, propName, { get(this: LeafletMap) { return !this.hasAttribute(spec.attr); }, set(this: LeafletMap, v: boolean) { this.toggleAttribute(spec.attr, !v); }, configurable: true, enumerable: true, }); } else { Object.defineProperty(LeafletMap.prototype, propName, { get(this: LeafletMap) { return this.hasAttribute(spec.attr); }, set(this: LeafletMap, v: boolean) { this.toggleAttribute(spec.attr, v); }, configurable: true, enumerable: true, }); } } } connectedCallback() { if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }); this.#container = document.createElement('div'); this.#container.style.width = '100%'; this.#container.style.height = '100%'; this.shadowRoot!.appendChild(this.#container); const style = document.createElement('style'); style.textContent = ':host { display: block; width: 100%; height: 400px; }'; this.shadowRoot!.appendChild(style); } this.#applyCss(); // Read view-state from attributes directly — the getters call getCenter()/getZoom() // which throw if invoked before setView(), so we can't use them here. const lat = +(this.getAttribute('lat') ?? 0); const lng = +(this.getAttribute('lng') ?? 0); const zoom = +(this.getAttribute('zoom') ?? 2); // Assign #map only after setView so the getters' `this.#map` guard is // equivalent to "map is ready" — getCenter()/getZoom() throw before setView. const map = new LMap(this.#container, this.#buildOptions()); map.setView([lat, lng], zoom); this.#map = map; this.#resizeObserver = new ResizeObserver(() => this.#map?.invalidateSize()); this.#resizeObserver.observe(this); // Register one handler per unique map event, updating all props that share it const groups = new globalThis.Map(); for (const spec of Object.values(PROPS) as PropDef[]) { if (spec.kind === 'num' && spec.event && spec.mapGet) { const g = groups.get(spec.event) ?? []; g.push(spec); groups.set(spec.event, g); } } for (const [event, specs] of groups) { const handler = () => { for (const s of specs) { if (this.#map && s.mapGet) { const v = s.mapGet(this.#map); if (v !== undefined) this.#syncAttr(s.attr, `${v}`); } } }; this.#mapEventHandlers.set(event, handler); this.#map.on(event, handler); } this.addEventListener( 'leaflet-register', this.#handleLeafletRegister as EventListenerOrEventListenerObject, ); } disconnectedCallback() { this.#resizeObserver?.disconnect(); this.#resizeObserver = undefined; this.removeEventListener( 'leaflet-register', this.#handleLeafletRegister as EventListenerOrEventListenerObject, ); if (!this.#map) return; for (const [event, handler] of this.#mapEventHandlers) { this.#map.off(event, handler); } this.#mapEventHandlers.clear(); this.#map.remove(); this.#map = undefined; } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { if (oldValue === newValue || !this.#map || this.#syncing) return; if (CSS_ATTRS.includes(name as (typeof CSS_ATTRS)[number])) { this.#applyCss(); return; } const propName = ATTR_TO_PROP.get(name); if (!propName) return; const spec = PROPS[propName] as PropDef; if (spec.kind === 'num') { if (spec.mapSet && newValue !== null) spec.mapSet(this.#map, +newValue); } else if (spec.kind === 'bool-off') { spec.mapSet?.(this.#map, newValue === null); } // skip spec.kind === 'bool-on': constructor-only, no live update } #applyCss() { const sr = this.shadowRoot; if (!sr) return; if (this.#cssLink) { this.#cssLink.remove(); this.#cssLink = undefined; } const customUrl = this.hasAttribute('css-url'); const url = customUrl ? this.getAttribute('css-url')! : DEFAULT_CSS_URL; let integrity: string | undefined; if (this.hasAttribute('css-integrity')) { integrity = this.getAttribute('css-integrity')!; } else if (!customUrl) { integrity = DEFAULT_CSS_INTEGRITY; } let crossorigin: string | undefined; if (this.hasAttribute('css-crossorigin')) { const val = this.getAttribute('css-crossorigin'); crossorigin = val ?? undefined; } else if (integrity) { crossorigin = 'anonymous'; } const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = url; if (integrity) link.setAttribute('integrity', integrity); if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin); sr.appendChild(link); this.#cssLink = link; // Derive marker icon path from the CSS URL (replaces leaflet.css → images/) Icon.Default.imagePath = url.replace(/\/[^/]+$/, '/images/'); } #syncAttr(name: string, value: string) { if (this.#syncing || this.getAttribute(name) === value) return; this.#syncing = true; this.setAttribute(name, value); this.#syncing = false; } #buildOptions(): MapOptions { const o: Record = {}; for (const [propName, spec] of Object.entries(PROPS) as [PropName, PropDef][]) { if (spec.kind === 'num') { if (spec.viewState) continue; const v = this.getAttribute(spec.attr); if (v !== null) o[propName] = +v; } else if (spec.kind === 'bool-off') { if (this.hasAttribute(spec.attr)) o[propName] = false; } else { if (this.hasAttribute(spec.attr)) o[propName] = true; } } return o as MapOptions; } #handleLeafletRegister = (e: LeafletRegisterEvent) => { e.stopPropagation(); if (this.#map && e.detail.leafletObject) { e.detail.leafletObject.addTo(this.#map); } }; get map(): LMap | undefined { return this.#map; } } customElements.define('leaflet-map', LeafletMap);