import { Layer, Popup, Tooltip, type Class } from 'leaflet'; import { kebab, type PropDef, type PropOptionValues, type PropTable, type PropValues, } from './props.ts'; import { registerWithParent, type LeafletRegisterEvent } from './register.ts'; /** * How an element joins the component tree: * * - `children` — register with the nearest parent and adopt registering * descendants as layers, popups and tooltips (every layer type). * - `self` — register with the nearest parent only (popups, tooltips, controls: * they have a parent but manage no children here). * - `none` — neither (the map is the tree root; icons aren't tree members). */ export type Attach = 'children' | 'self' | 'none'; /** Second argument to {@link WithProps}. */ export interface ElementOptions { /** Tree-membership mode; defaults to `'children'`. See {@link Attach}. */ attach?: Attach; /** * Rebuild the Leaflet object on every attribute change instead of calling * setters — for objects Leaflet gives no way to mutate in place (icons). */ recreate?: boolean; } /** The members {@link WithProps} contributes on top of the generated property accessors. */ export interface LeafletElement { /** The wrapped Leaflet object, once created (undefined before connect / after disconnect). */ readonly leafletObject?: TObj; /** * The one method every component must implement. `options` holds the decoded * value of every prop whose attribute is present, keyed by property name, * ready to hand to the Leaflet constructor. */ createLeafletObject(options: PropOptionValues): TObj | undefined; /** Hook called after the object is created and after every recreate; override to react. */ leafletObjectCreated(): void; /** Tear the object down and rebuild it from current attributes (used by `recreate` and by WMS CRS children). */ recreateLeafletObject(): void; /** Custom-element lifecycle: builds the object and joins the tree. Call `super.connectedCallback()` if you override. */ connectedCallback(): void; /** Custom-element lifecycle: leaves the tree and destroys the object. Call `super.disconnectedCallback()` if you override. */ disconnectedCallback(): void; /** Custom-element lifecycle: pushes an attribute change into the live object. */ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; } /** * The constructor type {@link WithProps} returns: an `HTMLElement` subclass with * a two-way accessor per prop ({@link PropValues}) plus the * {@link LeafletElement} lifecycle members. Element files annotate their * `const Base` with this. */ export type LeafletElementConstructor = new () => HTMLElement & PropValues & LeafletElement; /** * Type-only narrowing for `addEventListener` so a component's `leaflet:` * events (see `#forwardEvents`) type-check against the right Leaflet payload. A * component applies it with a `declare addEventListener: LeafletAddEventListener` * field — the same zero-runtime idiom as `declare readonly leafletObject?: X`. * * Deliberately has **no** generic `(type: string, …)` fallback overload (unlike * the real DOM API): a fallback would silently accept any misspelled * `leaflet:*` name. The cost is that a genuinely dynamic event-name string * needs a cast. * * @typeParam TEvents - an event-name → payload map from `event-types.ts` */ export type LeafletAddEventListener = (( type: `leaflet:${K}`, listener: (ev: CustomEvent) => void, options?: boolean | AddEventListenerOptions, ) => void) & (( type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void, options?: boolean | AddEventListenerOptions, ) => void); /** The `removeEventListener` counterpart of {@link LeafletAddEventListener}. */ export type LeafletRemoveEventListener = (( type: `leaflet:${K}`, listener: (ev: CustomEvent) => void, options?: boolean | EventListenerOptions, ) => void) & (( type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void, options?: boolean | EventListenerOptions, ) => void); interface ResolvedProp { name: string; attribute: string; setter: keyof TObj; def: PropDef; } type ChildEntry = { type: 'layer' | 'popup' | 'tooltip'; object: Layer }; type AnyMethod = (...args: unknown[]) => unknown; // Looks up a Leaflet method by name and binds it, or returns undefined. Leaflet // objects are duck-typed here on purpose: Layer, Control and Icon share no // common interface, and which setters exist varies per class. // function method, TName extends keyof TObj>(obj: TObj, name: TName | string): TObj[TName] | undefined { function method( obj: TObj, name: TName | string, ): AnyMethod | undefined { const value = obj[name as TName]; return typeof value === 'function' ? value.bind(obj) : undefined; } function resolve(props: PropTable): ResolvedProp[] { return Object.entries(props).map(([name, def]) => ({ name, attribute: typeof def.attribute === 'function' ? def.attribute(name) : (def.attribute ?? kebab(name)), setter: `set${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TObj, def, })); } /** * Mixin factory: builds a custom-element base class from a table of * {@link PropDef}s. Always extends `HTMLElement` internally (there is no * base-class parameter). * * The generated class owns the whole lifecycle — it derives * `observedAttributes`, defines a two-way property accessor per prop, builds * the Leaflet options object on connect, wires the object into the component * tree, keeps attributes and the live object in sync (both directions, no * cycles), and re-fires every Leaflet event on the element as `leaflet:`. * A subclass only implements `createLeafletObject()`. * * Element files must not write `class Foo extends WithProps({…})` — JSR's * type checker rejects a call expression as a superclass. Use a * `const Base: LeafletElementConstructor = WithProps(PROPS)` * and `extends Base` instead. * * @param props - the `PROPS` table (needs an explicit type annotation) * @param options - tree-attach mode and `recreate` behaviour; see {@link ElementOptions} */ export function WithProps>( props: TProps, options: ElementOptions = {}, ): LeafletElementConstructor { const resolved = resolve(props); const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop])); const attach = options.attach ?? 'children'; class WithPropsElement extends HTMLElement { static readonly observedAttributes = resolved.map((prop) => prop.attribute); #obj?: TObj; #connected = false; #children = new Map(); #listeners: [event: string, handler: () => void][] = []; // Set while writing an attribute on the object's behalf, so the resulting // attributeChangedCallback doesn't push the value straight back into // Leaflet. This is the only place a cycle could form. #syncing = false; get leafletObject(): TObj | undefined { return this.#obj; } createLeafletObject(_options: PropOptionValues): TObj | undefined { throw new Error(`<${this.localName}> does not implement createLeafletObject()`); } leafletObjectCreated(): void { // Overridden by components that need to react to a new Leaflet object. } connectedCallback(): void { if (this.#connected) return; this.#connected = true; this.#createObject(); if (attach === 'children') { this.addEventListener('leaflet-register', this.#onChildRegister); } if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj); this.leafletObjectCreated(); } disconnectedCallback(): void { if (attach === 'children') { this.removeEventListener('leaflet-register', this.#onChildRegister); } this.#releaseChildren(); this.#destroyObject(); this.#connected = false; } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { if (oldValue === newValue || this.#syncing || !this.#connected) return; const prop = byAttribute.get(name); if (!prop) return; if (options.recreate) { this.recreateLeafletObject(); return; } const obj = this.#obj; if (!obj) return; const value = newValue === null ? prop.def.default : prop.def.decode(newValue); if (prop.def.set) prop.def.set(obj, value, this); else method(obj, prop.setter)?.(value); } // Throws the current object away and builds a fresh one from the current // attributes, leaving the element's place in the tree untouched. Also // re-registers the new object with the parent (attach !== 'none') -- // registerWithParent only fires once from connectedCallback otherwise, // so without this a recreated layer would never get (re-)added anywhere. recreateLeafletObject(): void { this.#destroyObject(); this.#createObject(); if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj); this.leafletObjectCreated(); } #createObject(): void { const obj = this.createLeafletObject(this.#buildOptions()); this.#obj = obj; if (!obj) return; this.#watchObject(obj); this.#forwardEvents(obj); } #destroyObject(): void { const obj = this.#obj; this.#obj = undefined; if (!obj) return; const off = method(obj, 'off'); for (const [event, handler] of this.#listeners) off?.(event, handler); this.#listeners = []; method(obj, 'remove')?.(); } #buildOptions(): PropOptionValues { const opts: Record = {}; for (const prop of resolved) { if (prop.def.option === false) continue; const raw = this.getAttribute(prop.attribute); if (raw !== null) opts[prop.name] = prop.def.decode(raw); } return opts as PropOptionValues; } // Registers one Leaflet listener per distinct event, updating every prop // that names it. Dragging a marker fires `move` once and writes both lat // and lng. #watchObject(obj: TObj): void { const on = method(obj, 'on'); if (!on) return; const groups = new Map[]>(); for (const prop of resolved) { if (!prop.def.event || !prop.def.get) continue; const group = groups.get(prop.def.event) ?? []; group.push(prop); groups.set(prop.def.event, group); } for (const [event, group] of groups) { const handler = () => { for (const prop of group) this.#syncAttribute(prop); }; this.#listeners.push([event, handler]); on(event, handler); } } #syncAttribute(prop: ResolvedProp): void { const obj = this.#obj; const value = obj && prop.def.get?.(obj); if (value === undefined) return; const raw = prop.def.encode(value); if (this.getAttribute(prop.attribute) === raw) return; this.#syncing = true; try { if (raw === null) this.removeAttribute(prop.attribute); else this.setAttribute(prop.attribute, raw); } finally { this.#syncing = false; } } // Leaflet has no wildcard listener, so we wrap the instance's own `fire` -- // it's an object we created and hold alone. Every Leaflet event becomes a // `leaflet:` DOM event carrying the Leaflet event as its detail, // dispatched after Leaflet's handlers so synced attributes are current. // Not bubbling: Leaflet already propagates layer events up to the map, so // would otherwise see each of them twice. #forwardEvents(obj: object): void { const target = obj as { fire?: (type: string, data?: object, propagate?: boolean) => unknown; }; const fire = target.fire; if (typeof fire !== 'function') return; target.fire = (type, data, propagate) => { const result = fire.call(obj, type, data, propagate); // Mirrors Leaflet's own Evented#fire merge (extend({}, data, {type, // target, sourceTarget})) so `detail` matches what a real `.on()` // listener receives -- and so Leaflet's typed event interfaces // (LeafletEvent's type/target/sourceTarget fields) are honest. const sourceTarget = (data as { sourceTarget?: unknown } | undefined)?.sourceTarget ?? obj; const detail = { ...data, type, target: obj, sourceTarget }; this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail })); return result; }; } // Claims descendants that announce themselves with `leaflet-register`. // Skips our own announcement, which is dispatched on this element too. #onChildRegister = (e: LeafletRegisterEvent) => { const obj = this.#obj; const child = e.detail.leafletObject; const el = e.detail.element; if (!obj || el === this) return; if (child instanceof Popup) { e.stopPropagation(); method(obj, 'bindPopup')?.(child); this.#children.set(el, { type: 'popup', object: child }); } else if (child instanceof Tooltip) { e.stopPropagation(); method(obj, 'bindTooltip')?.(child); this.#children.set(el, { type: 'tooltip', object: child }); } else if (child instanceof Layer && 'addLayer' in obj) { e.stopPropagation(); method(obj, 'addLayer')?.(child); this.#children.set(el, { type: 'layer', object: child }); } }; #releaseChildren(): void { const obj = this.#obj; if (obj) { for (const entry of this.#children.values()) { if (entry.type === 'popup') method(obj, 'unbindPopup')?.(); else if (entry.type === 'tooltip') method(obj, 'unbindTooltip')?.(); else method(obj, 'removeLayer')?.(entry.object); } } this.#children.clear(); } } for (const prop of resolved) { Object.defineProperty(WithPropsElement.prototype, prop.name, { configurable: true, enumerable: true, get(this: WithPropsElement) { const obj = this.leafletObject; if (obj && prop.def.get) { const live = prop.def.get(obj); if (live !== undefined) return live; } const raw = this.getAttribute(prop.attribute); return raw === null ? prop.def.default : prop.def.decode(raw); }, set(this: WithPropsElement, value: unknown) { const raw = prop.def.encode(value); if (raw === null) this.removeAttribute(prop.attribute); else this.setAttribute(prop.attribute, raw); }, }); } return WithPropsElement as unknown as LeafletElementConstructor; }