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.

149 lines
6.2 KiB
TypeScript

import type { PropDef, PropTypesFromTable } from './props.ts';
import type { LatLngBoundsExpression } from 'leaflet';
// Parses an HTML attribute string into a typed JS value:
// null → null, "true"/"false" → boolean, numeric strings → number,
// valid JSON → parsed object/array, otherwise the raw string.
// This is the bridge between HTML attribute strings and Leaflet options.
export function parseAttributeValue(value: string | null): unknown {
if (value === null) return null;
if (value === 'true') return true;
if (value === 'false') return false;
const num = Number(value);
if (!isNaN(num) && value !== '') return num;
try {
return JSON.parse(value);
} catch {
return value;
}
}
// Installs reactive getter/setter pairs on a prototype for every entry in
// a PROPS table. Each getter reads from the attribute (coerced to the
// correct type), each setter writes via setAttribute/toggleAttribute.
// Used by the WithProps() mixin so components have `el.lat = 51.5` sugar.
export function definePropAccessors(proto: object, props: Record<string, PropDef>) {
for (const [name, spec] of Object.entries(props)) {
Object.defineProperty(proto, name, {
get() {
const el = this as HTMLElement;
const val = el.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
if (spec.kind === 'bool-on') return el.hasAttribute(spec.attr);
return val ?? (spec as { default: string }).default;
},
set(v: unknown) {
const el = this as HTMLElement;
if (spec.kind === 'bool-on') el.toggleAttribute(spec.attr, !!v);
else el.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
// Reads a numeric attribute from an element, falling back to the default
// value declared in the PROPS table. Handles the common pattern of
// reading lat/lng/radius/opacity with a guaranteed number return.
export function numAttr(el: HTMLElement, props: Record<string, PropDef>, name: string): number {
const v = el.getAttribute(name);
if (v !== null) return Number(v);
return (props[name] as { default: number }).default;
}
// Reads the `bounds` attribute from an element and parses it as JSON
// into a LatLngBoundsExpression. Returns an empty array when no
// attribute is set. Shared by rectangle, image-overlay, video-overlay,
// and svg-overlay, all of which accept a `bounds` attribute.
export function parseBoundsAttr(el: HTMLElement): LatLngBoundsExpression {
const raw = el.getAttribute('bounds');
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
}
// Builds a reverse lookup map from HTML attribute name → PROP key.
// For example, { fillColor: { attr: 'fill-color' } } becomes
// { 'fill-color' → 'fillColor' }. Used by setLayerAttr to find the
// Leaflet setter name when an attribute changes at runtime.
export function buildAttrMap(props: Record<string, PropDef>): Map<string, string> {
return new Map(Object.entries(props).map(([name, spec]) => [spec.attr, name]));
}
// Generic dispatcher for runtime attribute changes on Leaflet objects.
// Given the attribute name, it looks up the PROP key, constructs the
// matching Leaflet setter name (e.g. "opacity" → "setOpacity"), and
// calls it with the parsed value. Returns true if a setter was found
// and called, false otherwise (caller can fall back, as WMS does with
// setParams). Handles bool-on props by passing the presence/absence of
// the attribute rather than its string value.
export function setLayerAttr(
obj: object,
props: Record<string, PropDef>,
attrMap: Map<string, string>,
name: string,
val: string | null,
): boolean {
const propName = attrMap.get(name);
if (!propName) return false;
const spec = props[propName as keyof typeof props];
const setter = `set${propName.charAt(0).toUpperCase()}${propName.slice(1)}`;
const fn = (obj as Record<string, unknown>)[setter];
if (typeof fn === 'function') {
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val);
(fn as (v: unknown) => void)(value);
return true;
}
return false;
}
// Builds the initial Leaflet options object from the PROPS table and the
// element's current attributes at connection time. Props listed in
// `exclude` are skipped (they're passed separately to Leaflet
// constructors, like coordinates or URLs). Null attributes use the
// default from PROPS if one exists (skipping empty-string defaults to
// avoid Leaflet rejecting them).
//
// The return type is derived from the PROPS table: each prop name maps
// to its kind's value type (number for `num`, boolean for `bool-on`/`bool-off`,
// string for `str`). The `const` type parameter makes literal exclude arrays
// narrow correctly so excluded keys are stripped from the return type.
export function buildOptions<
TProps extends Record<string, PropDef>,
const TExclude extends readonly string[] = [],
>(
el: HTMLElement,
props: TProps,
exclude: TExclude = [] as unknown as TExclude,
): Omit<PropTypesFromTable<TProps>, TExclude[number]> {
const opts = {} as Record<string, unknown>;
for (const [propName, spec] of Object.entries(props)) {
if (exclude.includes(propName)) continue;
const val = el.getAttribute(spec.attr);
if (val === null) {
if ('default' in spec && spec.default !== '') opts[propName] = spec.default;
continue;
}
if (spec.kind === 'num') opts[propName] = Number(val);
else if (spec.kind === 'bool-on') opts[propName] = true;
else if (spec.kind === 'bool-off') opts[propName] = false;
else opts[propName] = val;
}
return opts as Omit<PropTypesFromTable<TProps>, TExclude[number]>;
}
// Dispatches a custom `leaflet-register` event upward through the DOM
// tree, carrying a Leaflet object and its host element. Parent components
// (map, circles, groups, etc.) intercept this event and add the layer,
// bind the popup, or bind the tooltip. This is the core wiring mechanism
// that replaces the parent-child relationship that Leaflet normally
// manages via imperative code.
export function registerWithParent(el: HTMLElement, obj: unknown) {
el.dispatchEvent(
new CustomEvent('leaflet-register', {
detail: { leafletObject: obj, element: el },
bubbles: true,
composed: true,
}),
);
}