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.
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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<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 default 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,
|
|
);
|
|
}
|
|
|
|
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<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);
|
|
}
|
|
};
|
|
|
|
get map(): LMap | undefined {
|
|
return this.#map;
|
|
}
|
|
}
|
|
|
|
customElements.define('leaflet-map', LeafletMap);
|