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) { 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, 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): Map { 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, attrMap: Map, 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)[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, const TExclude extends readonly string[] = [], >( el: HTMLElement, props: TProps, exclude: TExclude = [] as unknown as TExclude, ): Omit, TExclude[number]> { const opts = {} as Record; 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, 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, }), ); }