refactor: complete WithProps mixin, generic event forwarding, and full prop coverage
Finishes the WithProps(PROPS, options) mixin (src/core/with-props.ts): every
component gets reactive attributes, live-value property getters that read
through to the Leaflet object where possible, and every Leaflet event
re-emitted on the element as `leaflet:<type>` via a generic fire() patch (no
per-component or per-event registration needed).
Prop coverage:
- Centralized pathProps in shared-props.ts (stroke, lineCap, lineJoin,
dashArray, dashOffset, fillRule, interactive, className,
bubblingMouseEvents, pane), deduped leaflet-geojson against it.
- Added live getters backed by Leaflet's own accessors: radius (circle,
circle-marker), bounds (rectangle, image/video/svg-overlay), url
(image/video-overlay).
- Filled gaps: smoothFactor/noClip on polyline; a shared tileLayerProps
fragment now used by both tile-layer and tile-layer-wms (WMS previously
exposed none of its base tile options); crs on WMS; zIndex/className/
keepAspectRatio/errorOverlayUrl on video-overlay; fixed tile-layer's
zIndex default and div-icon's className default to match Leaflet.
Tooling:
- Removed dead src/core/events.ts (broken, unused, superseded by the
generic event forwarding above) and src/core/attributes.ts (emptied by
an earlier rename, nothing imported it).
- Swapped ESLint + @typescript-eslint for oxlint: no released
@typescript-eslint version supports the pinned typescript@7, even for
parsing alone. oxlint has its own parser and lints clean.
- Dropped the Rollup CJS/UMD bundle step; dist/ is ESM-only from tsc now.
Updated package.json's main/module/exports/unpkg accordingly.
- Updated CLAUDE.md to match: build/lint commands, ESM-only output, the
event-forwarding mechanism, and layer-group/feature-group now going
through WithProps({}) instead of raw HTMLElement.
main
parent
bdf5bd56f3
commit
14e35846d5
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["typescript", "unicorn", "oxc"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error",
|
||||||
|
"suspicious": "warn",
|
||||||
|
"pedantic": "warn"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"eslint/max-lines": "off",
|
||||||
|
"eslint/max-lines-per-function": "off"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist/"]
|
||||||
|
}
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { defineConfig } from 'eslint/config';
|
|
||||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
|
||||||
import prettierPlugin from 'eslint-plugin-prettier';
|
|
||||||
import prettierConfig from 'eslint-config-prettier';
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
{
|
|
||||||
ignores: ['dist/'],
|
|
||||||
},
|
|
||||||
|
|
||||||
...tsPlugin.configs['flat/strict-type-checked'].map(c => ({
|
|
||||||
...c,
|
|
||||||
files: ['**/*.ts'],
|
|
||||||
})),
|
|
||||||
|
|
||||||
...tsPlugin.configs['flat/stylistic-type-checked'].map(c => ({
|
|
||||||
...c,
|
|
||||||
files: ['**/*.ts'],
|
|
||||||
})),
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{
|
|
||||||
files: ['**/*.ts'],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: { project: true },
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
prettier: prettierPlugin,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...prettierConfig.rules,
|
|
||||||
'prettier/prettier': 'error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
files: ['**/*.ts'],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +0,0 @@
|
|||||||
import typescript from '@rollup/plugin-typescript';
|
|
||||||
import terser from '@rollup/plugin-terser';
|
|
||||||
import dts from 'rollup-plugin-dts';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
input: 'src/index.ts',
|
|
||||||
external: ['leaflet'],
|
|
||||||
plugins: [typescript({ declaration: false, declarationMap: false })],
|
|
||||||
output: [
|
|
||||||
{ format: 'es', file: 'dist/index.js' },
|
|
||||||
{ format: 'es', file: 'dist/index.min.js', plugins: [terser()] },
|
|
||||||
{ format: 'cjs', file: 'dist/index.cjs' },
|
|
||||||
{ format: 'cjs', file: 'dist/index.min.cjs', plugins: [terser()] },
|
|
||||||
{ format: 'umd', name: 'LeafletComponents', file: 'dist/index.umd.js', globals: { leaflet: 'L' } },
|
|
||||||
{ format: 'umd', name: 'LeafletComponents', file: 'dist/index.umd.min.js', globals: { leaflet: 'L' }, plugins: [terser()] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: 'src/index.ts',
|
|
||||||
external: ['leaflet'],
|
|
||||||
plugins: [dts()],
|
|
||||||
output: { format: 'es', file: 'dist/index.d.ts' },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import type { LatLngBoundsExpression } from 'leaflet';
|
|
||||||
import { Path } from 'leaflet';
|
|
||||||
import type { PropDef } from './props.ts';
|
|
||||||
|
|
||||||
// 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<T extends Record<string, PropDef>>(props: T): Map<string, keyof T> {
|
|
||||||
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];
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) });
|
|
||||||
}
|
|
||||||
@ -1,183 +1,127 @@
|
|||||||
export interface NumProp<T = unknown> {
|
// Every element property is described by a PropDef: how its value encodes to
|
||||||
kind: 'num';
|
// and decodes from an HTML attribute, and how it is pushed into (and read back
|
||||||
attr: string;
|
// out of) the Leaflet object. `WithProps` in with-props.ts is the only consumer
|
||||||
default: number;
|
// -- components just declare a table of these and never touch the plumbing.
|
||||||
mapGet?(m: T): number | undefined;
|
export interface PropDef<T = unknown, TObj = unknown> {
|
||||||
mapSet?(m: T, v: number): void;
|
// Attribute name. Defaults to the kebab-cased property name. A function
|
||||||
viewState?: boolean;
|
// receives the property name and returns the attribute (see `disabled`).
|
||||||
|
attribute?: string | ((name: string) => string);
|
||||||
|
|
||||||
|
// The property value when the attribute is absent. Keep this equal to
|
||||||
|
// Leaflet's own default: an absent attribute is left out of the options
|
||||||
|
// object entirely, so it is Leaflet's default that actually takes effect.
|
||||||
|
default: T;
|
||||||
|
|
||||||
|
// Set through `positional()` for values the Leaflet constructor takes as an
|
||||||
|
// argument (coordinates, urls, bounds) rather than as an option.
|
||||||
|
option?: false;
|
||||||
|
|
||||||
|
decode(raw: string): T;
|
||||||
|
|
||||||
|
// Returning null removes the attribute, which restores Leaflet's default.
|
||||||
|
encode(value: T): string | null;
|
||||||
|
|
||||||
|
// Pushes a new value into the Leaflet object. Defaults to calling the
|
||||||
|
// matching setter when the object has one (`opacity` -> `setOpacity`).
|
||||||
|
set?(obj: TObj, value: T, el: HTMLElement): void;
|
||||||
|
|
||||||
|
// Reads the live value back out of the Leaflet object. Used by the property
|
||||||
|
// getter, and by `event` below to write the value back to the attribute.
|
||||||
|
get?(obj: TObj): T | undefined;
|
||||||
|
|
||||||
|
// Leaflet event after which `get` is re-read and synced to the attribute,
|
||||||
|
// e.g. `move` keeps lat/lng current while a marker is dragged.
|
||||||
event?: string;
|
event?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrProp {
|
export type PropTable<T> = Record<string, PropDef<unknown, T>>;
|
||||||
kind: 'str';
|
|
||||||
attr: string;
|
|
||||||
default: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BoolOnProp {
|
|
||||||
kind: 'bool-on';
|
|
||||||
attr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BoolOffProp<T = unknown> {
|
|
||||||
kind: 'bool-off';
|
|
||||||
attr: string;
|
|
||||||
mapSet?(m: T, enabled: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PropDef = NumProp | StrProp | BoolOnProp | BoolOffProp;
|
|
||||||
|
|
||||||
export type PropTypeOf<T extends PropDef> = T extends NumProp
|
|
||||||
? number
|
|
||||||
: T extends StrProp
|
|
||||||
? string
|
|
||||||
: boolean;
|
|
||||||
|
|
||||||
export type PropTypesFromTable<T extends Record<string, PropDef>> = {
|
|
||||||
[K in keyof T]: PropTypeOf<T[K]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OptionalAttr<T> = Omit<T, 'attr'> & { attr?: string };
|
// Everything a codec factory doesn't fill in for you. `option` is not here:
|
||||||
|
// it has to come from `positional()` to be visible in PropOptionValues.
|
||||||
|
export type PropOptions<TObj, T> = Partial<
|
||||||
|
Omit<PropDef<T, TObj>, 'default' | 'decode' | 'encode' | 'option'>
|
||||||
|
>;
|
||||||
|
|
||||||
type NumPropInput<T = unknown> = OptionalAttr<NumProp<T>>;
|
// The value type of a single prop, and of a whole table.
|
||||||
type StrPropInput = OptionalAttr<StrProp>;
|
export type PropValue<P> = P extends { default: infer T } ? T : never;
|
||||||
type BoolOnPropInput = OptionalAttr<BoolOnProp>;
|
|
||||||
type BoolOffPropInput<T = unknown> = OptionalAttr<BoolOffProp<T>>;
|
|
||||||
|
|
||||||
type PropDefInput = NumPropInput | StrPropInput | BoolOnPropInput | BoolOffPropInput;
|
export type PropValues<T> = { [K in keyof T]: PropValue<T[K]> };
|
||||||
|
|
||||||
type ValueFor<T, K extends keyof T> = T[K];
|
// The options object handed to `createLeafletObject`. Partial because a prop
|
||||||
|
// only appears when its attribute is present; `option: false` props never do.
|
||||||
|
export type PropOptionValues<T> = Partial<{
|
||||||
|
[K in keyof T as T[K] extends { option: false } ? never : K]: PropValue<T[K]>;
|
||||||
|
}>;
|
||||||
|
|
||||||
type PropDefFromInput<T extends PropDefInput, Key extends string> = (T extends NumPropInput<infer U>
|
// Marks a prop the Leaflet constructor takes as an argument, so it is left out
|
||||||
? NumProp<U>
|
// of the options object handed to createLeafletObject().
|
||||||
: T extends { kind: 'str' }
|
export function positional<T extends PropDef>(def: T): T & { option: false } {
|
||||||
? StrProp
|
return { ...def, option: false };
|
||||||
: T extends BoolOffPropInput<infer U>
|
|
||||||
? BoolOffProp<U>
|
|
||||||
: BoolOnProp) & { attr: AttributeValue<T, Key> };
|
|
||||||
|
|
||||||
type AttributeValue<T extends PropDefInput, Key extends string> = T extends { attr: string }
|
|
||||||
? ValueFor<T, 'attr'>
|
|
||||||
: T extends BoolOffPropInput
|
|
||||||
? AsDisableKebab<Key>
|
|
||||||
: AsKebab<Key>;
|
|
||||||
|
|
||||||
type AsKebab<T extends string> = T extends `${infer L}${infer R}`
|
|
||||||
? `${L extends Uppercase<L> ? '-' : ''}${Lowercase<L>}${AsKebab<R>}`
|
|
||||||
: T;
|
|
||||||
|
|
||||||
type AsDisableKebab<T extends string> = `disable-${AsKebab<T>}`;
|
|
||||||
|
|
||||||
function disableCamelToKebab<S extends string>(s: S): AsDisableKebab<S> {
|
|
||||||
return ('disable-' + s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())) as AsDisableKebab<S>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function camelToKebab(s: string): string {
|
export function kebab(name: string): string {
|
||||||
return s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
|
return name.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function num<T = unknown>(
|
export function num<TObj = unknown>(
|
||||||
def = 0,
|
def = 0,
|
||||||
opts?: Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string },
|
opts?: PropOptions<TObj, number>,
|
||||||
): NumPropInput<T> {
|
): PropDef<number, TObj> {
|
||||||
return { kind: 'num', default: def, ...opts };
|
return { default: def, decode: Number, encode: String, ...opts };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function str(def = '', attr?: string): StrPropInput {
|
export function str<TObj = unknown>(
|
||||||
return { kind: 'str', default: def, ...(attr ? { attr } : {}) };
|
def = '',
|
||||||
|
opts?: PropOptions<TObj, string>,
|
||||||
|
): PropDef<string, TObj> {
|
||||||
|
return { default: def, decode: (raw) => raw, encode: (value) => value, ...opts };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function on(attr?: string): BoolOnPropInput {
|
// A string attribute whose values Leaflet types as a union -- ControlPosition,
|
||||||
return { kind: 'bool-on', ...(attr ? { attr } : {}) };
|
// CrossOrigin, tooltip Direction. Nothing is validated at runtime; this is how
|
||||||
|
// the options object comes out with the type Leaflet's constructor expects.
|
||||||
|
export function choice<T extends string, TObj = unknown>(
|
||||||
|
def: T,
|
||||||
|
opts?: PropOptions<TObj, T>,
|
||||||
|
): PropDef<T, TObj> {
|
||||||
|
return { default: def, decode: (raw) => raw as T, encode: (value) => value, ...opts };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function off<T = unknown>(mapSet?: (m: T, enabled: boolean) => void): BoolOffPropInput<T> {
|
// A boolean attribute: present is true, `="false"` is false, absent is `def`.
|
||||||
return { kind: 'bool-off', ...(mapSet ? { mapSet } : {}) };
|
// Use `bool(true)` for options Leaflet already defaults to true, so that
|
||||||
|
// `<leaflet-popup auto-pan="false">` can turn them off.
|
||||||
|
export function bool<TObj = unknown>(
|
||||||
|
def = false,
|
||||||
|
opts?: PropOptions<TObj, boolean>,
|
||||||
|
): PropDef<boolean, TObj> {
|
||||||
|
return {
|
||||||
|
default: def,
|
||||||
|
decode: (raw) => raw !== 'false',
|
||||||
|
encode: (value) => (value === def ? null : value ? '' : 'false'),
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defineProps<T extends Record<string, PropDefInput>>(
|
// The inverse of `bool(true)`: `<leaflet-map disable-dragging>` reads as
|
||||||
input: T,
|
// `dragging === false`. Named `disable-<kebab>` unless `attribute` says else.
|
||||||
): { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> } {
|
export function disabled<TObj = unknown>(
|
||||||
const output = {} as Record<string, PropDef>;
|
opts?: PropOptions<TObj, boolean>,
|
||||||
for (const key of Object.keys(input)) {
|
): PropDef<boolean, TObj> {
|
||||||
const val = input[key];
|
return {
|
||||||
const { attr: override, ...rest } = val as unknown as Record<string, unknown>;
|
attribute: (name) => `disable-${kebab(name)}`,
|
||||||
const attr =
|
default: true,
|
||||||
override ?? (rest.kind === 'bool-off' ? disableCamelToKebab(key) : camelToKebab(key));
|
decode: (raw) => raw === 'false',
|
||||||
output[key] = { ...(rest as object), attr } as PropDef;
|
encode: (value) => (value ? null : ''),
|
||||||
}
|
...opts,
|
||||||
return output as unknown as { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> };
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// For attributes holding JSON: bounds, icon sizes and anchors, GeoJSON data.
|
||||||
type Ctor<T = object> = new (...args: any[]) => T;
|
export function json<T, TObj = unknown>(def: T, opts?: PropOptions<TObj, T>): PropDef<T, TObj> {
|
||||||
|
return {
|
||||||
export function WithProps<TBase extends Ctor<HTMLElement>, TProps extends Record<string, PropDef>>(
|
default: def,
|
||||||
Base: TBase,
|
decode: (raw) => JSON.parse(raw) as T,
|
||||||
props: TProps,
|
encode: (value) => JSON.stringify(value),
|
||||||
): TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>> {
|
...opts,
|
||||||
class WithProps extends Base {
|
};
|
||||||
static get observedAttributes(): string[] {
|
|
||||||
return Object.values(props).map((s) => s.attr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
definePropAccessors(WithProps.prototype, props);
|
|
||||||
return WithProps as TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
||||||
else el.setAttribute(spec.attr, `${v}`);
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
enumerable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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]>;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
import type {
|
||||||
|
CrossOrigin,
|
||||||
|
FillRule,
|
||||||
|
LatLng,
|
||||||
|
LatLngBounds,
|
||||||
|
LatLngBoundsExpression,
|
||||||
|
LineCapShape,
|
||||||
|
LineJoinShape,
|
||||||
|
PathOptions,
|
||||||
|
ReferrerPolicy,
|
||||||
|
} from 'leaflet';
|
||||||
|
import { bool, choice, json, num, positional, str, type PropDef } from './props.ts';
|
||||||
|
|
||||||
|
// Leaflet classes share no common interface, so the prop fragments below
|
||||||
|
// describe structurally what they need from the object they update.
|
||||||
|
export interface Positioned {
|
||||||
|
getLatLng(): LatLng | undefined;
|
||||||
|
setLatLng(latlng: [number, number]): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Styleable {
|
||||||
|
setStyle(style: PathOptions): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sourced {
|
||||||
|
setUrl(url: string): unknown;
|
||||||
|
getUrl?(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bounded {
|
||||||
|
getBounds(): LatLngBounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared `get` for any `bounds` prop backed by Leaflet's getBounds(). Returns
|
||||||
|
// a plain [[south, west], [north, east]] pair rather than the LatLngBounds
|
||||||
|
// instance, matching the JSON-encoded shape the attribute round-trips through.
|
||||||
|
export function getBounds(obj: Bounded): LatLngBoundsExpression {
|
||||||
|
const b = obj.getBounds();
|
||||||
|
return [
|
||||||
|
[b.getSouth(), b.getWest()],
|
||||||
|
[b.getNorth(), b.getEast()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PositionedHost extends HTMLElement {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A `set` for any option Leaflet only exposes through setStyle().
|
||||||
|
export function style<T>(key: keyof PathOptions): (obj: Styleable, value: T) => void {
|
||||||
|
return (obj, value) => {
|
||||||
|
obj.setStyle({ [key]: value } as PathOptions);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The source url. Passed positionally by every Leaflet constructor that takes
|
||||||
|
// one, and ignored when blank so clearing the attribute can't request nothing.
|
||||||
|
export const urlProp = positional(
|
||||||
|
str<Sourced>('', {
|
||||||
|
set(obj, value) {
|
||||||
|
if (value) obj.setUrl(value);
|
||||||
|
},
|
||||||
|
// Only ImageOverlay/VideoOverlay implement getUrl(); TileLayer doesn't,
|
||||||
|
// so this falls back to the attribute there, same as omitting `get`.
|
||||||
|
get: (obj) => obj.getUrl?.(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// lat/lng travel together: both are passed positionally to the Leaflet
|
||||||
|
// constructor rather than as options, setting either re-issues setLatLng with
|
||||||
|
// the other's current value, and both are written back whenever the object
|
||||||
|
// moves -- which is what keeps the attributes current while a marker is
|
||||||
|
// dragged. Shared by marker, circle, circle-marker, popup and tooltip.
|
||||||
|
export const latLngProps = {
|
||||||
|
lat: positional(
|
||||||
|
num<Positioned>(0, {
|
||||||
|
event: 'move',
|
||||||
|
get: (obj) => obj.getLatLng()?.lat,
|
||||||
|
set(obj, value, el) {
|
||||||
|
obj.setLatLng([value, (el as PositionedHost).lng]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
lng: positional(
|
||||||
|
num<Positioned>(0, {
|
||||||
|
event: 'move',
|
||||||
|
get: (obj) => obj.getLatLng()?.lng,
|
||||||
|
set(obj, value, el) {
|
||||||
|
obj.setLatLng([(el as PositionedHost).lat, value]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// The style options every Path accepts. Defaults match Leaflet's own, so an
|
||||||
|
// absent attribute and an unset option mean the same thing.
|
||||||
|
export const pathProps = {
|
||||||
|
stroke: bool<Styleable>(true, { set: style('stroke') }),
|
||||||
|
color: str<Styleable>('#3388ff', { set: style('color') }),
|
||||||
|
weight: num<Styleable>(3, { set: style('weight') }),
|
||||||
|
opacity: num<Styleable>(1.0, { set: style('opacity') }),
|
||||||
|
lineCap: choice<LineCapShape, Styleable>('round', { set: style('lineCap') }),
|
||||||
|
lineJoin: choice<LineJoinShape, Styleable>('round', { set: style('lineJoin') }),
|
||||||
|
dashArray: str<Styleable>('', { set: style('dashArray') }),
|
||||||
|
dashOffset: str<Styleable>('', { set: style('dashOffset') }),
|
||||||
|
fill: bool<Styleable>(true, { set: style('fill') }),
|
||||||
|
fillColor: str<Styleable>('#3388ff', { set: style('fillColor') }),
|
||||||
|
fillOpacity: num<Styleable>(0.2, { set: style('fillOpacity') }),
|
||||||
|
fillRule: choice<FillRule, Styleable>('evenodd', { set: style('fillRule') }),
|
||||||
|
// Constructor-only: Leaflet has no setter for these, so changing the
|
||||||
|
// attribute after creation has no effect (same as leaflet-map's zoomSnap).
|
||||||
|
className: str(),
|
||||||
|
interactive: bool(true),
|
||||||
|
bubblingMouseEvents: bool(true),
|
||||||
|
pane: str('overlay'),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// The GridLayer/TileLayer options every tile source accepts, shared by
|
||||||
|
// leaflet-tile-layer and leaflet-tile-layer-wms (WMSOptions extends
|
||||||
|
// TileLayerOptions). Most are constructor-only -- Leaflet exposes no setter
|
||||||
|
// for them -- so changing the attribute after creation has no effect, same as
|
||||||
|
// leaflet-map's zoomSnap.
|
||||||
|
export const tileLayerProps = {
|
||||||
|
attribution: str(),
|
||||||
|
minZoom: num(0),
|
||||||
|
maxZoom: num(18),
|
||||||
|
opacity: num(1.0),
|
||||||
|
zIndex: num(1),
|
||||||
|
subdomains: str('abc'),
|
||||||
|
tms: bool(),
|
||||||
|
zoomOffset: num(0),
|
||||||
|
zoomReverse: bool(),
|
||||||
|
detectRetina: bool(),
|
||||||
|
crossOrigin: choice<CrossOrigin>(''),
|
||||||
|
// Leaflet's ReferrerPolicy type has no "unset" member (unlike the DOM's),
|
||||||
|
// so this is a plain PropDef rather than choice(), with `undefined` as the
|
||||||
|
// fallback the property getter reports when the attribute is absent.
|
||||||
|
referrerPolicy: {
|
||||||
|
default: undefined,
|
||||||
|
decode: (raw) => raw as ReferrerPolicy,
|
||||||
|
encode: (value) => value ?? null,
|
||||||
|
} as PropDef<ReferrerPolicy | undefined>,
|
||||||
|
errorTileUrl: str(),
|
||||||
|
tileSize: num(256),
|
||||||
|
noWrap: bool(),
|
||||||
|
bounds: json<LatLngBoundsExpression>([]),
|
||||||
|
className: str(),
|
||||||
|
minNativeZoom: num(),
|
||||||
|
maxNativeZoom: num(),
|
||||||
|
keepBuffer: num(2),
|
||||||
|
updateWhenIdle: bool(),
|
||||||
|
updateWhenZooming: bool(true),
|
||||||
|
updateInterval: num(200),
|
||||||
|
pane: str('tilePane'),
|
||||||
|
} as const;
|
||||||
@ -0,0 +1,307 @@
|
|||||||
|
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)
|
||||||
|
// 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 root; icons aren't part of the tree)
|
||||||
|
export type Attach = 'children' | 'self' | 'none';
|
||||||
|
|
||||||
|
export interface ElementOptions {
|
||||||
|
attach?: Attach;
|
||||||
|
|
||||||
|
// Rebuild the Leaflet object on every attribute change instead of calling
|
||||||
|
// setters, for objects Leaflet gives us no way to mutate in place (icons).
|
||||||
|
recreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The members WithProps contributes on top of the property accessors.
|
||||||
|
export interface LeafletElement<TObj, TProps> {
|
||||||
|
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, so
|
||||||
|
// it can be handed straight to the Leaflet constructor.
|
||||||
|
createLeafletObject(options: PropOptionValues<TProps>): TObj | undefined;
|
||||||
|
|
||||||
|
// Called after the object is created and after every recreate.
|
||||||
|
leafletObjectCreated(): void;
|
||||||
|
|
||||||
|
recreateLeafletObject(): void;
|
||||||
|
|
||||||
|
connectedCallback(): void;
|
||||||
|
disconnectedCallback(): void;
|
||||||
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeafletElementConstructor<TObj, TProps> = new () => HTMLElement &
|
||||||
|
PropValues<TProps> &
|
||||||
|
LeafletElement<TObj, TProps>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a custom element base class from a table of property definitions.
|
||||||
|
//
|
||||||
|
// The generated class owns the whole lifecycle: it derives observedAttributes,
|
||||||
|
// defines two-way property accessors, builds the Leaflet options object, wires
|
||||||
|
// the object into the component tree, keeps attributes in sync with the object
|
||||||
|
// (in both directions, without cycles), and re-fires every Leaflet event on the
|
||||||
|
// element as `leaflet:<type>`. Subclasses implement createLeafletObject().
|
||||||
|
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.
|
||||||
|
#recreateLeafletObject(): void {
|
||||||
|
this.#destroyObject();
|
||||||
|
this.#createObject();
|
||||||
|
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);
|
||||||
|
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail: data ?? {} }));
|
||||||
|
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>;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue