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> {
|
||||
kind: 'num';
|
||||
attr: string;
|
||||
default: number;
|
||||
mapGet?(m: T): number | undefined;
|
||||
mapSet?(m: T, v: number): void;
|
||||
viewState?: boolean;
|
||||
// Every element property is described by a PropDef: how its value encodes to
|
||||
// and decodes from an HTML attribute, and how it is pushed into (and read back
|
||||
// out of) the Leaflet object. `WithProps` in with-props.ts is the only consumer
|
||||
// -- components just declare a table of these and never touch the plumbing.
|
||||
export interface PropDef<T = unknown, TObj = unknown> {
|
||||
// Attribute name. Defaults to the kebab-cased property name. A function
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface StrProp {
|
||||
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]>;
|
||||
};
|
||||
export type PropTable<T> = Record<string, PropDef<unknown, T>>;
|
||||
|
||||
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>>;
|
||||
type StrPropInput = OptionalAttr<StrProp>;
|
||||
type BoolOnPropInput = OptionalAttr<BoolOnProp>;
|
||||
type BoolOffPropInput<T = unknown> = OptionalAttr<BoolOffProp<T>>;
|
||||
// The value type of a single prop, and of a whole table.
|
||||
export type PropValue<P> = P extends { default: infer T } ? T : never;
|
||||
|
||||
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>
|
||||
? NumProp<U>
|
||||
: T extends { kind: 'str' }
|
||||
? StrProp
|
||||
: 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>;
|
||||
// Marks a prop the Leaflet constructor takes as an argument, so it is left out
|
||||
// of the options object handed to createLeafletObject().
|
||||
export function positional<T extends PropDef>(def: T): T & { option: false } {
|
||||
return { ...def, option: false };
|
||||
}
|
||||
|
||||
function camelToKebab(s: string): string {
|
||||
return s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
|
||||
export function kebab(name: string): string {
|
||||
return name.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
|
||||
}
|
||||
|
||||
export function num<T = unknown>(
|
||||
export function num<TObj = unknown>(
|
||||
def = 0,
|
||||
opts?: Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string },
|
||||
): NumPropInput<T> {
|
||||
return { kind: 'num', default: def, ...opts };
|
||||
}
|
||||
|
||||
export function str(def = '', attr?: string): StrPropInput {
|
||||
return { kind: 'str', default: def, ...(attr ? { attr } : {}) };
|
||||
}
|
||||
|
||||
export function on(attr?: string): BoolOnPropInput {
|
||||
return { kind: 'bool-on', ...(attr ? { attr } : {}) };
|
||||
}
|
||||
|
||||
export function off<T = unknown>(mapSet?: (m: T, enabled: boolean) => void): BoolOffPropInput<T> {
|
||||
return { kind: 'bool-off', ...(mapSet ? { mapSet } : {}) };
|
||||
}
|
||||
|
||||
export function defineProps<T extends Record<string, PropDefInput>>(
|
||||
input: T,
|
||||
): { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> } {
|
||||
const output = {} as Record<string, PropDef>;
|
||||
for (const key of Object.keys(input)) {
|
||||
const val = input[key];
|
||||
const { attr: override, ...rest } = val as unknown as Record<string, unknown>;
|
||||
const attr =
|
||||
override ?? (rest.kind === 'bool-off' ? disableCamelToKebab(key) : camelToKebab(key));
|
||||
output[key] = { ...(rest as object), attr } as PropDef;
|
||||
}
|
||||
return output as unknown as { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> };
|
||||
}
|
||||
|
||||
// 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 get observedAttributes(): string[] {
|
||||
return Object.values(props).map((s) => s.attr);
|
||||
}
|
||||
}
|
||||
definePropAccessors(WithProps.prototype, props);
|
||||
return WithProps as TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>>;
|
||||
opts?: PropOptions<TObj, number>,
|
||||
): PropDef<number, TObj> {
|
||||
return { default: def, decode: Number, encode: String, ...opts };
|
||||
}
|
||||
|
||||
export function str<TObj = unknown>(
|
||||
def = '',
|
||||
opts?: PropOptions<TObj, string>,
|
||||
): PropDef<string, TObj> {
|
||||
return { default: def, decode: (raw) => raw, encode: (value) => value, ...opts };
|
||||
}
|
||||
|
||||
// A string attribute whose values Leaflet types as a union -- ControlPosition,
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// A boolean attribute: present is true, `="false"` is false, absent is `def`.
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
// The inverse of `bool(true)`: `<leaflet-map disable-dragging>` reads as
|
||||
// `dragging === false`. Named `disable-<kebab>` unless `attribute` says else.
|
||||
export function disabled<TObj = unknown>(
|
||||
opts?: PropOptions<TObj, boolean>,
|
||||
): PropDef<boolean, TObj> {
|
||||
return {
|
||||
attribute: (name) => `disable-${kebab(name)}`,
|
||||
default: true,
|
||||
decode: (raw) => raw === 'false',
|
||||
encode: (value) => (value ? null : ''),
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
// 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]>;
|
||||
// For attributes holding JSON: bounds, icon sizes and anchors, GeoJSON data.
|
||||
export function json<T, TObj = unknown>(def: T, opts?: PropOptions<TObj, T>): PropDef<T, TObj> {
|
||||
return {
|
||||
default: def,
|
||||
decode: (raw) => JSON.parse(raw) as T,
|
||||
encode: (value) => JSON.stringify(value),
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
@ -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