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.
193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
import { Path } from 'leaflet';
|
|
import type { PropDef, PropTypesFromTable } from './props.ts';
|
|
import type { LatLngBoundsExpression } from 'leaflet';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type Ctor<T = object> = new (...args: any[]) => T;
|
|
|
|
export function WithProps<TBase extends Ctor<HTMLElement>, TProps extends Record<string, PropDef>>(
|
|
Base: TBase,
|
|
props: TProps,
|
|
): TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>> {
|
|
class WithProps extends Base {
|
|
static {
|
|
definePropAccessors(WithProps.prototype, props);
|
|
}
|
|
static get observedAttributes(): string[] {
|
|
return Object.values(props).map((s) => s.attr);
|
|
}
|
|
}
|
|
|
|
return WithProps as TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>>;
|
|
}
|
|
|
|
// 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.
|
|
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 ? +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, `${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 +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;
|
|
}
|
|
|
|
// 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 = +value;
|
|
if (!isNaN(num) && value !== '') return num;
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
// 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] = +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,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const PATH_STYLE_ATTRS = new Set([
|
|
'color',
|
|
'weight',
|
|
'opacity',
|
|
'fill',
|
|
'fill-color',
|
|
'fill-opacity',
|
|
'stroke',
|
|
'dash-array',
|
|
'dash-offset',
|
|
'line-cap',
|
|
'line-join',
|
|
'fill-rule',
|
|
]);
|
|
|
|
export function isPathStyleAttr(name: string): boolean {
|
|
return PATH_STYLE_ATTRS.has(name);
|
|
}
|
|
|
|
export function updatePathStyle(obj: Path, name: string, value: string | null) {
|
|
const key = name.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
obj.setStyle({ [key]: parseAttributeValue(value) });
|
|
}
|