You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/src/components/leaflet-map.ts

330 lines
11 KiB
TypeScript

import { Icon, Layer, 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<string, PropName>(
(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 class LeafletMap extends TypedBase {
#map?: LMap;
#container!: HTMLDivElement;
#cssLink?: HTMLLinkElement;
#syncing = false;
#mapEventHandlers = new globalThis.Map<string, () => 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<string, NumProp[]>();
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,
);
this.addEventListener('leaflet-add-layer', this.#onAddLayer);
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
}
disconnectedCallback() {
this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined;
this.removeEventListener(
'leaflet-register',
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
);
this.removeEventListener('leaflet-add-layer', this.#onAddLayer);
this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer);
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<string, unknown> = {};
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);
}
};
#onAddLayer = (e: Event) => {
this.#map?.addLayer((e as CustomEvent<{ layer: Layer }>).detail.layer);
};
#onRemoveLayer = (e: Event) => {
this.#map?.removeLayer((e as CustomEvent<{ layer: Layer }>).detail.layer);
};
get leafletObject() {
return this.#map;
}
}
customElements.define('leaflet-map', LeafletMap);