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.
387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
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<TObj, TProps> {
|
|
/** 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<TProps>): 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<TObj, TProps> = new () => HTMLElement &
|
|
PropValues<TProps> &
|
|
LeafletElement<TObj, TProps>;
|
|
|
|
/**
|
|
* Type-only narrowing for `addEventListener` so a component's `leaflet:<name>`
|
|
* events (see `#forwardEvents`) type-check against the right Leaflet payload. A
|
|
* component applies it with a `declare addEventListener: LeafletAddEventListener<TheEvents>`
|
|
* 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<TEvents> = (<K extends keyof TEvents & string>(
|
|
type: `leaflet:${K}`,
|
|
listener: (ev: CustomEvent<TEvents[K]>) => void,
|
|
options?: boolean | AddEventListenerOptions,
|
|
) => void) &
|
|
(<K extends keyof HTMLElementEventMap>(
|
|
type: K,
|
|
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
|
|
options?: boolean | AddEventListenerOptions,
|
|
) => void);
|
|
|
|
/** The `removeEventListener` counterpart of {@link LeafletAddEventListener}. */
|
|
export type LeafletRemoveEventListener<TEvents> = (<K extends keyof TEvents & string>(
|
|
type: `leaflet:${K}`,
|
|
listener: (ev: CustomEvent<TEvents[K]>) => void,
|
|
options?: boolean | EventListenerOptions,
|
|
) => void) &
|
|
(<K extends keyof HTMLElementEventMap>(
|
|
type: K,
|
|
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
|
|
options?: boolean | EventListenerOptions,
|
|
) => void);
|
|
|
|
interface ResolvedProp<TObj extends Class> {
|
|
name: string;
|
|
attribute: string;
|
|
setter: keyof TObj;
|
|
def: PropDef<unknown, TObj>;
|
|
}
|
|
|
|
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<TObj extends Record<string, unknown>, TName extends keyof TObj>(obj: TObj, name: TName | string): TObj[TName] | undefined {
|
|
function method<TObj extends Class, TName extends keyof TObj>(
|
|
obj: TObj,
|
|
name: TName | string,
|
|
): AnyMethod | undefined {
|
|
const value = obj[name as TName];
|
|
return typeof value === 'function' ? value.bind(obj) : undefined;
|
|
}
|
|
|
|
function resolve<TObj extends Class>(props: PropTable<TObj>): ResolvedProp<TObj>[] {
|
|
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:<type>`.
|
|
* 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<TheClass, typeof PROPS> = 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<TObj extends Class, TProps extends PropTable<TObj>>(
|
|
props: TProps,
|
|
options: ElementOptions = {},
|
|
): LeafletElementConstructor<TObj, TProps> {
|
|
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<HTMLElement, ChildEntry>();
|
|
#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<TProps>): 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<TProps> {
|
|
const opts: Record<string, unknown> = {};
|
|
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<TProps>;
|
|
}
|
|
|
|
// 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<string, ResolvedProp<TObj>[]>();
|
|
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<TObj>): 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:<type>` 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
|
|
// <leaflet-map> 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<TObj, TProps>;
|
|
}
|