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

410 lines
14 KiB
TypeScript

import { Icon, Map as LMap, MapOptions } from 'leaflet';
import { LeafletRegisterEvent } from '../core/register.js';
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;
// ── Prop types ─────────────────────────────────────────────────────────────
type NumProp = {
kind: 'num';
attr: string;
default: number;
viewState?: true;
mapGet?: (m: LMap) => number | undefined;
mapSet?: (m: LMap, v: number) => void;
event?: string;
};
type BoolOffProp = {
kind: 'bool-off';
attr: string;
mapSet?: (m: LMap, enabled: boolean) => void;
};
type BoolOnProp = {
kind: 'bool-on';
attr: string;
};
type PropDef = NumProp | BoolOffProp | BoolOnProp;
// ── Property table ─────────────────────────────────────────────────────────
const PROPS = {
// View state — excluded from #buildOptions, initialised via setView()
lat: {
kind: 'num',
attr: 'lat',
default: 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: {
kind: 'num',
attr: 'lng',
default: 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: {
kind: 'num',
attr: 'zoom',
default: 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: {
kind: 'num',
attr: 'min-zoom',
default: 0,
mapGet: (m: LMap) => m.getMinZoom(),
mapSet: (m: LMap, v: number) => m.setMinZoom(v),
},
maxZoom: {
kind: 'num',
attr: 'max-zoom',
default: Infinity,
mapGet: (m: LMap) => m.getMaxZoom(),
mapSet: (m: LMap, v: number) => m.setMaxZoom(v),
},
// Constructor-only numeric options
zoomSnap: { kind: 'num', attr: 'zoom-snap', default: 1 },
zoomDelta: { kind: 'num', attr: 'zoom-delta', default: 1 },
keyboardPanDelta: { kind: 'num', attr: 'keyboard-pan-delta', default: 80 },
wheelDebounceTime: { kind: 'num', attr: 'wheel-debounce-time', default: 40 },
wheelPxPerZoomLevel: {
kind: 'num',
attr: 'wheel-px-per-zoom-level',
default: 60,
},
inertiaDeceleration: {
kind: 'num',
attr: 'inertia-deceleration',
default: 3000,
},
inertiaMaxSpeed: {
kind: 'num',
attr: 'inertia-max-speed',
default: Infinity,
},
easeLinearity: { kind: 'num', attr: 'ease-linearity', default: 0.2 },
maxBoundsViscosity: { kind: 'num', attr: 'max-bounds-viscosity', default: 0 },
tapTolerance: { kind: 'num', attr: 'tap-tolerance', default: 15 },
zoomAnimationThreshold: {
kind: 'num',
attr: 'zoom-animation-threshold',
default: 4,
},
transform3DLimit: {
kind: 'num',
attr: 'transform-3d-limit',
default: 8388608,
},
// Boolean defaults-true → disable-* attribute; handler-based options have live mapSet
scrollWheelZoom: {
kind: 'bool-off',
attr: 'disable-scroll-wheel-zoom',
mapSet: (m: LMap, v: boolean) => (v ? m.scrollWheelZoom.enable() : m.scrollWheelZoom.disable()),
},
dragging: {
kind: 'bool-off',
attr: 'disable-dragging',
mapSet: (m: LMap, v: boolean) => (v ? m.dragging.enable() : m.dragging.disable()),
},
touchZoom: {
kind: 'bool-off',
attr: 'disable-touch-zoom',
mapSet: (m: LMap, v: boolean) => (v ? m.touchZoom.enable() : m.touchZoom.disable()),
},
doubleClickZoom: {
kind: 'bool-off',
attr: 'disable-double-click-zoom',
mapSet: (m: LMap, v: boolean) => (v ? m.doubleClickZoom.enable() : m.doubleClickZoom.disable()),
},
boxZoom: {
kind: 'bool-off',
attr: 'disable-box-zoom',
mapSet: (m: LMap, v: boolean) => (v ? m.boxZoom.enable() : m.boxZoom.disable()),
},
keyboard: {
kind: 'bool-off',
attr: 'disable-keyboard',
mapSet: (m: LMap, v: boolean) => (v ? m.keyboard.enable() : m.keyboard.disable()),
},
closePopupOnClick: { kind: 'bool-off', attr: 'disable-close-popup-on-click' },
trackResize: { kind: 'bool-off', attr: 'disable-track-resize' },
zoomControl: { kind: 'bool-off', attr: 'disable-zoom-control' },
attributionControl: { kind: 'bool-off', attr: 'disable-attribution-control' },
inertia: { kind: 'bool-off', attr: 'disable-inertia' },
zoomAnimation: { kind: 'bool-off', attr: 'disable-zoom-animation' },
fadeAnimation: { kind: 'bool-off', attr: 'disable-fade-animation' },
markerZoomAnimation: {
kind: 'bool-off',
attr: 'disable-marker-zoom-animation',
},
bounceAtZoomLimits: {
kind: 'bool-off',
attr: 'disable-bounce-at-zoom-limits',
},
tapHold: { kind: 'bool-off', attr: 'disable-tap-hold' },
// Boolean defaults-false → normal attribute
preferCanvas: { kind: 'bool-on', attr: 'prefer-canvas' },
worldCopyJump: { kind: 'bool-on', attr: 'world-copy-jump' },
} satisfies Record<string, PropDef>;
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]),
);
// ── Element ────────────────────────────────────────────────────────────────
// 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;
};
// Cast HTMLElement to a typed base whose instances include PropTypes. This is
// the mixin pattern — TypeScript sees LeafletMap as having all prop types, the
// runtime still extends HTMLElement, and no interface merge is required.
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 : Number(this.getAttribute(spec.attr) ?? spec.default);
},
set(this: LeafletMap, v: number) {
if (spec.event) {
this.#syncAttr(spec.attr, String(v));
} else {
this.setAttribute(spec.attr, String(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 = Number(this.getAttribute('lat') ?? 0);
const lng = Number(this.getAttribute('lng') ?? 0);
const zoom = Number(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, String(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, Number(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] = Number(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);