refactor: replace inheritance hierarchy with composable PROPS-table pattern

Remove LeafletElement and LeafletControl base classes. Every component
now self-describes its attributes via a PROPS table, derives observed-
Attributes from it, and generates prototype getters/setters in static {}.

Registration lifecycle is extracted into standalone helpers
(registerWithParent, createChildRegisterHandler). Common update
patterns (path style) are shared via imported helpers instead of
duplicated across 5+ components.

Components manage their own #obj private field instead of a shared
protected leafletObject. Leaflet-map-style declarative patterns are
now consistent across all 20 components.
main
Buddy 4 months ago
parent 6b448ba092
commit 5f3fd5f61a

@ -1,54 +1,79 @@
import { Layer, CircleMarker } from 'leaflet'; import { CircleMarker } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions } from '../core/utils.js';
import { isPathStyleAttr, updatePathStyle } from '../core/path-style.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
lat: { kind: 'num', attr: 'lat', default: 0 },
lng: { kind: 'num', attr: 'lng', default: 0 },
radius: { kind: 'num', attr: 'radius', default: 10 },
color: { kind: 'str', attr: 'color', default: '#3388ff' },
weight: { kind: 'num', attr: 'weight', default: 3 },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
fill: { kind: 'bool-on', attr: 'fill' },
fillColor: { kind: 'str', attr: 'fill-color', default: '#3388ff' },
fillOpacity: { kind: 'num', attr: 'fill-opacity', default: 0.2 },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletCircleMarker extends TypedBase {
#obj?: CircleMarker;
export class LeafletCircleMarker extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return [ return Object.values(PROPS).map((s) => s.attr);
'lat', }
'lng',
'radius', static {
'color', for (const [name, spec] of Object.entries(PROPS)) {
'weight', Object.defineProperty(LeafletCircleMarker.prototype, name, {
'opacity', get(this: LeafletCircleMarker) {
'fill', const val = this.getAttribute(spec.attr);
'fill-color', if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
'fill-opacity', if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
]; return val ?? spec.default;
},
set(this: LeafletCircleMarker, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
this.#obj = new CircleMarker(
[this.#num('lat'), this.#num('lng')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
registerWithParent(this, this.#obj);
} }
protected createLeafletObject(): Layer { disconnectedCallback() {
const lat = parseFloat(this.getAttribute('lat') || '0'); this.#obj?.remove();
const lng = parseFloat(this.getAttribute('lng') || '0'); this.#obj = undefined;
return new CircleMarker([lat, lng], this.options);
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof CircleMarker) { if (!this.#obj) return;
if (property === 'lat' || property === 'lng') { if (name === 'lat' || name === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0'); this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]);
const lng = parseFloat(this.getAttribute('lng') || '0'); } else if (name === 'radius') {
this.leafletObject.setLatLng([lat, lng]); this.#obj.setRadius(this.#num('radius'));
} else if ( } else if (isPathStyleAttr(name)) {
[ updatePathStyle(this.#obj, name, val);
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
} }
} }
#num(name: string): number {
const v = this.getAttribute(name);
return v !== null
? Number(v)
: (PROPS[name as keyof typeof PROPS] as { default: number }).default;
}
} }
customElements.define('leaflet-circle-marker', LeafletCircleMarker); customElements.define('leaflet-circle-marker', LeafletCircleMarker);

@ -1,54 +1,79 @@
import { Circle, Layer } from 'leaflet'; import { Circle } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions } from '../core/utils.js';
import { isPathStyleAttr, updatePathStyle } from '../core/path-style.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
lat: { kind: 'num', attr: 'lat', default: 0 },
lng: { kind: 'num', attr: 'lng', default: 0 },
radius: { kind: 'num', attr: 'radius', default: 1000 },
color: { kind: 'str', attr: 'color', default: '#3388ff' },
weight: { kind: 'num', attr: 'weight', default: 3 },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
fill: { kind: 'bool-on', attr: 'fill' },
fillColor: { kind: 'str', attr: 'fill-color', default: '#3388ff' },
fillOpacity: { kind: 'num', attr: 'fill-opacity', default: 0.2 },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletCircle extends TypedBase {
#obj?: Circle;
export class LeafletCircle extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return [ return Object.values(PROPS).map((s) => s.attr);
'lat', }
'lng',
'radius', static {
'color', for (const [name, spec] of Object.entries(PROPS)) {
'weight', Object.defineProperty(LeafletCircle.prototype, name, {
'opacity', get(this: LeafletCircle) {
'fill', const val = this.getAttribute(spec.attr);
'fill-color', if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
'fill-opacity', if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
]; return val ?? spec.default;
},
set(this: LeafletCircle, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
this.#obj = new Circle(
[this.#num('lat'), this.#num('lng')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
registerWithParent(this, this.#obj);
} }
protected createLeafletObject(): Layer { disconnectedCallback() {
const lat = parseFloat(this.getAttribute('lat') || '0'); this.#obj?.remove();
const lng = parseFloat(this.getAttribute('lng') || '0'); this.#obj = undefined;
return new Circle([lat, lng], this.options);
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof Circle) { if (!this.#obj) return;
if (property === 'lat' || property === 'lng') { if (name === 'lat' || name === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0'); this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]);
const lng = parseFloat(this.getAttribute('lng') || '0'); } else if (name === 'radius') {
this.leafletObject.setLatLng([lat, lng]); this.#obj.setRadius(this.#num('radius'));
} else if ( } else if (isPathStyleAttr(name)) {
[ updatePathStyle(this.#obj, name, val);
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
} }
} }
#num(name: string): number {
const v = this.getAttribute(name);
return v !== null
? Number(v)
: (PROPS[name as keyof typeof PROPS] as { default: number }).default;
}
} }
customElements.define('leaflet-circle', LeafletCircle); customElements.define('leaflet-circle', LeafletCircle);

@ -1,18 +1,48 @@
import { Control, ControlPosition } from 'leaflet'; import { Control, ControlPosition } from 'leaflet';
import { LeafletControl } from '../core/LeafletControl.js'; import { registerWithParent } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
position: { kind: 'str', attr: 'position', default: 'bottomright' },
prefix: { kind: 'str', attr: 'prefix', default: '' },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletControlAttribution extends TypedBase {
#obj?: Control.Attribution;
export class LeafletControlAttribution extends LeafletControl {
static get observedAttributes() { static get observedAttributes() {
return ['position', 'prefix']; return Object.values(PROPS).map((s) => s.attr);
}
static {
for (const [name, spec] of Object.entries(PROPS)) {
Object.defineProperty(LeafletControlAttribution.prototype, name, {
get(this: LeafletControlAttribution) {
return this.getAttribute(spec.attr) ?? spec.default;
},
set(this: LeafletControlAttribution, v: string) {
this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
this.#obj = new Control.Attribution({
position: this.getAttribute('position') as ControlPosition | undefined,
prefix: this.getAttribute('prefix') ?? undefined,
});
registerWithParent(this, this.#obj);
} }
protected createControl(): Control { disconnectedCallback() {
const options: Control.AttributionOptions = {}; this.#obj?.remove();
const position = this.getAttribute('position') as ControlPosition | null; this.#obj = undefined;
const prefix = this.getAttribute('prefix');
if (position) options.position = position;
if (prefix !== null) options.prefix = prefix;
return new Control.Attribution(options);
} }
} }

@ -1,24 +1,59 @@
import { Control, ControlPosition } from 'leaflet'; import { Control, ControlPosition } from 'leaflet';
import { LeafletControl } from '../core/LeafletControl.js'; import { registerWithParent } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
position: { kind: 'str', attr: 'position', default: 'bottomleft' },
maxWidth: { kind: 'num', attr: 'max-width', default: 100 },
metric: { kind: 'bool-on', attr: 'metric' },
imperial: { kind: 'bool-on', attr: 'imperial' },
updateWhenIdle: { kind: 'bool-on', attr: 'update-when-idle' },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletControlScale extends TypedBase {
#obj?: Control.Scale;
export class LeafletControlScale extends LeafletControl {
static get observedAttributes() { static get observedAttributes() {
return ['position', 'max-width', 'metric', 'imperial', 'update-when-idle']; return Object.values(PROPS).map((s) => s.attr);
}
static {
for (const [name, spec] of Object.entries(PROPS)) {
Object.defineProperty(LeafletControlScale.prototype, name, {
get(this: LeafletControlScale) {
const val = this.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? spec.default;
},
set(this: LeafletControlScale, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
this.#obj = new Control.Scale({
position: this.getAttribute('position') as ControlPosition | undefined,
maxWidth: parseInt(this.getAttribute('max-width') ?? '100', 10),
metric: !this.hasAttribute('metric') || this.getAttribute('metric') !== 'false',
imperial: !this.hasAttribute('imperial') || this.getAttribute('imperial') !== 'false',
updateWhenIdle:
!this.hasAttribute('update-when-idle') || this.getAttribute('update-when-idle') !== 'false',
});
registerWithParent(this, this.#obj);
} }
protected createControl(): Control { disconnectedCallback() {
const options: Control.ScaleOptions = {}; this.#obj?.remove();
const position = this.getAttribute('position') as ControlPosition | null; this.#obj = undefined;
const maxWidth = this.getAttribute('max-width');
const metric = this.getAttribute('metric');
const imperial = this.getAttribute('imperial');
const updateWhenIdle = this.getAttribute('update-when-idle');
if (position) options.position = position;
if (maxWidth !== null) options.maxWidth = parseInt(maxWidth, 10);
if (metric !== null) options.metric = metric !== 'false';
if (imperial !== null) options.imperial = imperial !== 'false';
if (updateWhenIdle !== null) options.updateWhenIdle = updateWhenIdle !== 'false';
return new Control.Scale(options);
} }
} }

@ -1,24 +1,55 @@
import { Control, ControlPosition } from 'leaflet'; import { Control, ControlPosition } from 'leaflet';
import { LeafletControl } from '../core/LeafletControl.js'; import { registerWithParent } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
position: { kind: 'str', attr: 'position', default: 'topleft' },
zoomInText: { kind: 'str', attr: 'zoom-in-text', default: '+' },
zoomInTitle: { kind: 'str', attr: 'zoom-in-title', default: 'Zoom in' },
zoomOutText: { kind: 'str', attr: 'zoom-out-text', default: '-' },
zoomOutTitle: { kind: 'str', attr: 'zoom-out-title', default: 'Zoom out' },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletControlZoom extends TypedBase {
#obj?: Control.Zoom;
export class LeafletControlZoom extends LeafletControl {
static get observedAttributes() { static get observedAttributes() {
return ['position', 'zoom-in-text', 'zoom-in-title', 'zoom-out-text', 'zoom-out-title']; return Object.values(PROPS).map((s) => s.attr);
}
static {
for (const [name, spec] of Object.entries(PROPS)) {
Object.defineProperty(LeafletControlZoom.prototype, name, {
get(this: LeafletControlZoom) {
const val = this.getAttribute(spec.attr);
return val ?? (spec as { default: string }).default;
},
set(this: LeafletControlZoom, v: number | string) {
this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
this.#obj = new Control.Zoom({
position: this.getAttribute('position') as ControlPosition | undefined,
zoomInText: this.getAttribute('zoom-in-text') ?? '+',
zoomInTitle: this.getAttribute('zoom-in-title') ?? 'Zoom in',
zoomOutText: this.getAttribute('zoom-out-text') ?? '-',
zoomOutTitle: this.getAttribute('zoom-out-title') ?? 'Zoom out',
});
registerWithParent(this, this.#obj);
} }
protected createControl(): Control { disconnectedCallback() {
const options: Control.ZoomOptions = {}; this.#obj?.remove();
const position = this.getAttribute('position') as ControlPosition | null; this.#obj = undefined;
const zoomInText = this.getAttribute('zoom-in-text');
const zoomInTitle = this.getAttribute('zoom-in-title');
const zoomOutText = this.getAttribute('zoom-out-text');
const zoomOutTitle = this.getAttribute('zoom-out-title');
if (position) options.position = position;
if (zoomInText !== null) options.zoomInText = zoomInText;
if (zoomInTitle !== null) options.zoomInTitle = zoomInTitle;
if (zoomOutText !== null) options.zoomOutText = zoomOutText;
if (zoomOutTitle !== null) options.zoomOutTitle = zoomOutTitle;
return new Control.Zoom(options);
} }
} }

@ -1,9 +1,32 @@
import { FeatureGroup, Layer } from 'leaflet'; import { FeatureGroup } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { buildOptions, registerWithParent } from '../core/utils.js';
import { createChildRegisterHandler, type ChildEntry } from '../core/register.js';
export class LeafletFeatureGroup extends LeafletElement { const PROPS = {} as const satisfies Record<string, never>;
protected createLeafletObject(): Layer {
return new FeatureGroup([], this.options); export class LeafletFeatureGroup extends HTMLElement {
#obj?: FeatureGroup;
#children = new Map<HTMLElement, ChildEntry>();
#childHandler?: (e: Event) => void;
static get observedAttributes() {
return [];
}
connectedCallback() {
this.#obj = new FeatureGroup([], buildOptions(this, PROPS));
this.#childHandler = createChildRegisterHandler(this.#obj, this.#children) as EventListener;
this.addEventListener('leaflet-register', this.#childHandler);
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
if (this.#childHandler) {
this.removeEventListener('leaflet-register', this.#childHandler);
}
this.#children.clear();
this.#obj?.remove();
this.#obj = undefined;
} }
} }

@ -1,43 +1,88 @@
import { GeoJSON, PathOptions, Layer } from 'leaflet'; import { GeoJSON, PathOptions } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { buildOptions, registerWithParent } from '../core/utils.js';
import { createChildRegisterHandler, type ChildEntry } from '../core/register.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
data: { kind: 'str', attr: 'data', default: '' },
stroke: { kind: 'str', attr: 'stroke', default: '' },
color: { kind: 'str', attr: 'color', default: '#3388ff' },
weight: { kind: 'num', attr: 'weight', default: 3 },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
lineCap: { kind: 'str', attr: 'line-cap', default: 'round' },
lineJoin: { kind: 'str', attr: 'line-join', default: 'round' },
dashArray: { kind: 'str', attr: 'dash-array', default: '' },
dashOffset: { kind: 'str', attr: 'dash-offset', default: '' },
fill: { kind: 'bool-on', attr: 'fill' },
fillColor: { kind: 'str', attr: 'fill-color', default: '#3388ff' },
fillOpacity: { kind: 'num', attr: 'fill-opacity', default: 0.2 },
fillRule: { kind: 'str', attr: 'fill-rule', default: 'evenodd' },
} satisfies Record<string, PropDef>;
const PROP_BY_ATTR = new Map<string, string>(
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
);
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletGeoJSON extends TypedBase {
#obj?: GeoJSON;
#children = new Map<HTMLElement, ChildEntry>();
#childHandler?: (e: Event) => void;
export class LeafletGeoJSON extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return [ return Object.values(PROPS).map((s) => s.attr);
'data', }
'stroke',
'color', static {
'weight', for (const [name, spec] of Object.entries(PROPS)) {
'opacity', Object.defineProperty(LeafletGeoJSON.prototype, name, {
'line-cap', get(this: LeafletGeoJSON) {
'line-join', if (spec.kind === 'num') return Number(this.getAttribute(spec.attr) ?? spec.default);
'dash-array', if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
'dash-offset', return this.getAttribute(spec.attr) ?? spec.default;
'fill', },
'fill-color', set(this: LeafletGeoJSON, v: number | string | boolean) {
'fill-opacity', if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
'fill-rule', else this.setAttribute(spec.attr, String(v));
]; },
configurable: true,
enumerable: true,
});
}
} }
protected createLeafletObject(): Layer { connectedCallback() {
const raw = this.getAttribute('data'); const raw = this.getAttribute('data');
const data = raw ? JSON.parse(raw) : undefined; const data = raw ? JSON.parse(raw) : undefined;
const styleOpts = Object.fromEntries( const styleOpts = Object.fromEntries(
Object.entries(this.options).filter(([k]) => k !== 'data'), Object.entries(buildOptions(this, PROPS, ['data'])).filter(([, v]) => v !== ''),
); );
return new GeoJSON(data, { style: styleOpts as PathOptions }); this.#obj = new GeoJSON(data, { style: styleOpts as PathOptions });
this.#childHandler = createChildRegisterHandler(this.#obj, this.#children) as EventListener;
this.addEventListener('leaflet-register', this.#childHandler);
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
if (this.#childHandler) {
this.removeEventListener('leaflet-register', this.#childHandler);
}
this.#children.clear();
this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!(this.leafletObject instanceof GeoJSON)) return; if (!this.#obj) return;
if (property === 'data') { if (name === 'data') {
this.leafletObject.clearLayers(); this.#obj.clearLayers();
if (value) { if (val) this.#obj.addData(JSON.parse(val));
this.leafletObject.addData(value as Parameters<GeoJSON['addData']>[0]);
}
} else { } else {
this.leafletObject.setStyle({ [property]: value } as PathOptions); const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
this.#obj.setStyle({ [propName]: val } as PathOptions);
} }
} }
} }

@ -1,44 +1,88 @@
import { import { ImageOverlay, LatLngBounds, LatLngBoundsExpression, LatLngExpression } from 'leaflet';
ImageOverlay, import { registerWithParent, buildOptions, parseAttributeValue } from '../core/utils.js';
LatLngBounds, import type { PropDef, PropTypesFromTable } from '../types/props.js';
LatLngBoundsExpression,
LatLngExpression, const PROPS = {
Layer, url: { kind: 'str', attr: 'url', default: '' },
} from 'leaflet'; bounds: { kind: 'str', attr: 'bounds', default: '' },
import { LeafletElement } from '../core/LeafletElement.js'; opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
alt: { kind: 'str', attr: 'alt', default: '' },
export class LeafletImageOverlay extends LeafletElement { interactive: { kind: 'bool-on', attr: 'interactive' },
crossOrigin: { kind: 'str', attr: 'cross-origin', default: '' },
errorOverlayUrl: { kind: 'str', attr: 'error-overlay-url', default: '' },
zIndex: { kind: 'num', attr: 'z-index', default: 0 },
className: { kind: 'str', attr: 'class-name', default: '' },
} satisfies Record<string, PropDef>;
const PROP_BY_ATTR = new Map<string, string>(
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
);
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletImageOverlay extends TypedBase {
#obj?: ImageOverlay;
static get observedAttributes() { static get observedAttributes() {
return [ return Object.values(PROPS).map((s) => s.attr);
'url', }
'bounds',
'opacity', static {
'alt', for (const [name, spec] of Object.entries(PROPS)) {
'interactive', Object.defineProperty(LeafletImageOverlay.prototype, name, {
'cross-origin', get(this: LeafletImageOverlay) {
'error-overlay-url', const val = this.getAttribute(spec.attr);
'z-index', if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
'className', if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
]; return val ?? spec.default;
},
set(this: LeafletImageOverlay, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
} }
protected createLeafletObject(): Layer { connectedCallback() {
const url = this.getAttribute('url') || ''; const url = this.getAttribute('url') || '';
const bounds = this.options.bounds as LatLngBoundsExpression; this.#obj = new ImageOverlay(
return new ImageOverlay(url, bounds, this.options); url,
this.#parsedBounds(),
buildOptions(this, PROPS, ['url', 'bounds']),
);
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof ImageOverlay) { if (!this.#obj) return;
if (property === 'url') { if (name === 'url') {
this.leafletObject.setUrl(value as string); if (val) this.#obj.setUrl(val);
} else if (property === 'bounds') { } else if (name === 'bounds') {
this.leafletObject.setBounds(new LatLngBounds(value as LatLngExpression[])); this.#obj.setBounds(new LatLngBounds(this.#parsedBounds() as LatLngExpression[]));
} else { } else {
super.updateLeafletObject(property, value); const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const setter =
`set${propName.charAt(0).toUpperCase()}${propName.slice(1)}` as keyof ImageOverlay;
if (typeof this.#obj[setter] === 'function') {
(this.#obj[setter] as (v: unknown) => void)(parseAttributeValue(val));
} }
} }
} }
#parsedBounds(): LatLngBoundsExpression {
const raw = this.getAttribute('bounds');
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
}
} }
customElements.define('leaflet-image-overlay', LeafletImageOverlay); customElements.define('leaflet-image-overlay', LeafletImageOverlay);

@ -1,9 +1,35 @@
import { LayerGroup, Layer } from 'leaflet'; import { LayerGroup } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { buildOptions, registerWithParent } from '../core/utils.js';
import { createChildRegisterHandler, type ChildEntry } from '../core/register.js';
export class LeafletLayerGroup extends LeafletElement { const PROPS = {} as const satisfies Record<string, never>;
protected createLeafletObject(): Layer {
return new LayerGroup([], this.options); export class LeafletLayerGroup extends HTMLElement {
#obj?: LayerGroup;
#children = new Map<HTMLElement, ChildEntry>();
#childHandler?: (e: Event) => void;
static get observedAttributes() {
return [];
}
connectedCallback() {
this.#obj = new LayerGroup([], buildOptions(this, PROPS));
this.#childHandler = createChildRegisterHandler(this.#obj, this.#children) as EventListener;
this.addEventListener('leaflet-register', this.#childHandler);
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
if (this.#childHandler) {
this.removeEventListener('leaflet-register', this.#childHandler);
}
for (const [el] of this.#children) {
this.#obj?.removeLayer(el as unknown as LayerGroup);
}
this.#children.clear();
this.#obj?.remove();
this.#obj = undefined;
} }
} }

@ -1,6 +1,6 @@
import { Map as LMap, MapOptions } from 'leaflet'; import { Map as LMap, MapOptions } from 'leaflet';
import leafletCSS from 'leaflet/dist/leaflet.css'; import leafletCSS from 'leaflet/dist/leaflet.css';
import { LeafletRegisterEvent } from '../core/LeafletElement.js'; import { LeafletRegisterEvent } from '../core/register.js';
// ── Prop types ───────────────────────────────────────────────────────────── // ── Prop types ─────────────────────────────────────────────────────────────

@ -1,28 +1,83 @@
import L from 'leaflet'; import { Marker } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions, parseAttributeValue } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
lat: { kind: 'num', attr: 'lat', default: 0 },
lng: { kind: 'num', attr: 'lng', default: 0 },
title: { kind: 'str', attr: 'title', default: '' },
alt: { kind: 'str', attr: 'alt', default: '' },
draggable: { kind: 'bool-on', attr: 'draggable' },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
zIndexOffset: { kind: 'num', attr: 'z-index-offset', default: 0 },
} satisfies Record<string, PropDef>;
const PROP_BY_ATTR = new Map<string, string>(
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
);
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletMarker extends TypedBase {
#obj?: Marker;
export class LeafletMarker extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return ['lat', 'lng', 'title', 'alt', 'draggable', 'opacity', 'z-index-offset']; return Object.values(PROPS).map((s) => s.attr);
} }
protected createLeafletObject(): L.Layer { static {
const lat = parseFloat(this.getAttribute('lat') || '0'); for (const [name, spec] of Object.entries(PROPS)) {
const lng = parseFloat(this.getAttribute('lng') || '0'); Object.defineProperty(LeafletMarker.prototype, name, {
return new L.Marker([lat, lng], this.options); get(this: LeafletMarker) {
const val = this.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? spec.default;
},
set(this: LeafletMarker, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
} }
protected updateLeafletObject(property: string, value: unknown) { connectedCallback() {
if (this.leafletObject instanceof L.Marker) { this.#obj = new Marker(
if (property === 'lat' || property === 'lng') { [this.#num('lat'), this.#num('lng')],
const lat = parseFloat(this.getAttribute('lat') || '0'); buildOptions(this, PROPS, ['lat', 'lng']),
const lng = parseFloat(this.getAttribute('lng') || '0'); );
this.leafletObject.setLatLng([lat, lng]); registerWithParent(this, this.#obj);
} else { }
super.updateLeafletObject(property, value);
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]);
} else {
const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const setter = `set${propName.charAt(0).toUpperCase()}${propName.slice(1)}` as keyof Marker;
if (typeof this.#obj[setter] === 'function') {
(this.#obj[setter] as (v: unknown) => void)(parseAttributeValue(val));
} }
} }
} }
#num(name: string): number {
const v = this.getAttribute(name);
return v !== null
? Number(v)
: (PROPS[name as keyof typeof PROPS] as { default: number }).default;
}
} }
customElements.define('leaflet-marker', LeafletMarker); customElements.define('leaflet-marker', LeafletMarker);

@ -1,68 +1,80 @@
import L from 'leaflet'; import { Polygon } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions } from '../core/utils.js';
import { LeafletLine } from './leaflet-line.js'; import { isPathStyleAttr, updatePathStyle } from '../core/path-style.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
import type { LeafletLine } from './leaflet-line.js';
export class LeafletPolygon extends LeafletElement { const PROPS = {
private _observer?: MutationObserver; color: { kind: 'str', attr: 'color', default: '#3388ff' },
static get observedAttributes() { weight: { kind: 'num', attr: 'weight', default: 3 },
return ['color', 'weight', 'opacity', 'fill', 'fill-color', 'fill-opacity']; opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
} fill: { kind: 'bool-on', attr: 'fill' },
fillColor: { kind: 'str', attr: 'fill-color', default: '#3388ff' },
fillOpacity: { kind: 'num', attr: 'fill-opacity', default: 0.2 },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletPolygon extends TypedBase {
#obj?: Polygon;
#observer?: MutationObserver;
protected createLeafletObject(): L.Layer { static get observedAttributes() {
const coords = this.getCoords(); return Object.values(PROPS).map((s) => s.attr);
return new L.Polygon(coords, this.options);
} }
private getCoords(): [number, number][] { static {
const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[]; for (const [name, spec] of Object.entries(PROPS)) {
return lines.map((line) => line.latlng); Object.defineProperty(LeafletPolygon.prototype, name, {
get(this: LeafletPolygon) {
const val = this.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? spec.default;
},
set(this: LeafletPolygon, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
} }
connectedCallback() { connectedCallback() {
super.connectedCallback(); this.#obj = new Polygon(this.#getCoords(), buildOptions(this, PROPS));
this.addEventListener('line-updated', () => { registerWithParent(this, this.#obj);
if (this.leafletObject instanceof L.Polygon) {
this.leafletObject.setLatLngs(this.getCoords());
}
});
this._observer = new MutationObserver(() => { this.addEventListener('line-updated', this.#syncCoords);
if (this.leafletObject instanceof L.Polygon) { this.#observer = new MutationObserver(() => this.#syncCoords());
this.leafletObject.setLatLngs(this.getCoords()); this.#observer.observe(this, { childList: true });
}
});
this._observer.observe(this, { childList: true });
} }
disconnectedCallback() { disconnectedCallback() {
this._observer?.disconnect(); this.#observer?.disconnect();
this._observer = undefined; this.#observer = undefined;
super.disconnectedCallback(); this.removeEventListener('line-updated', this.#syncCoords);
this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof L.Polygon) { if (!this.#obj) return;
if ( if (isPathStyleAttr(name)) {
[ updatePathStyle(this.#obj, name, val);
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
} }
} }
#syncCoords = () => {
this.#obj?.setLatLngs(this.#getCoords());
};
#getCoords(): [number, number][] {
const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[];
return lines.map((line) => line.latlng);
}
} }
customElements.define('leaflet-polygon', LeafletPolygon); customElements.define('leaflet-polygon', LeafletPolygon);

@ -1,68 +1,80 @@
import L from 'leaflet'; import { Polyline } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions } from '../core/utils.js';
import { LeafletLine } from './leaflet-line.js'; import { isPathStyleAttr, updatePathStyle } from '../core/path-style.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
import type { LeafletLine } from './leaflet-line.js';
export class LeafletPolyline extends LeafletElement { const PROPS = {
private _observer?: MutationObserver; color: { kind: 'str', attr: 'color', default: '#3388ff' },
static get observedAttributes() { weight: { kind: 'num', attr: 'weight', default: 3 },
return ['color', 'weight', 'opacity', 'fill', 'fill-color', 'fill-opacity']; opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
} fill: { kind: 'bool-on', attr: 'fill' },
fillColor: { kind: 'str', attr: 'fill-color', default: '#3388ff' },
fillOpacity: { kind: 'num', attr: 'fill-opacity', default: 0.2 },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletPolyline extends TypedBase {
#obj?: Polyline;
#observer?: MutationObserver;
protected createLeafletObject(): L.Layer { static get observedAttributes() {
const coords = this.getCoords(); return Object.values(PROPS).map((s) => s.attr);
return new L.Polyline(coords, this.options);
} }
private getCoords(): [number, number][] { static {
const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[]; for (const [name, spec] of Object.entries(PROPS)) {
return lines.map((line) => line.latlng); Object.defineProperty(LeafletPolyline.prototype, name, {
get(this: LeafletPolyline) {
const val = this.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? spec.default;
},
set(this: LeafletPolyline, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
} }
connectedCallback() { connectedCallback() {
super.connectedCallback(); this.#obj = new Polyline(this.#getCoords(), buildOptions(this, PROPS));
this.addEventListener('line-updated', () => { registerWithParent(this, this.#obj);
if (this.leafletObject instanceof L.Polyline) {
this.leafletObject.setLatLngs(this.getCoords());
}
});
this._observer = new MutationObserver(() => { this.addEventListener('line-updated', this.#syncCoords);
if (this.leafletObject instanceof L.Polyline) { this.#observer = new MutationObserver(() => this.#syncCoords());
this.leafletObject.setLatLngs(this.getCoords()); this.#observer.observe(this, { childList: true });
}
});
this._observer.observe(this, { childList: true });
} }
disconnectedCallback() { disconnectedCallback() {
this._observer?.disconnect(); this.#observer?.disconnect();
this._observer = undefined; this.#observer = undefined;
super.disconnectedCallback(); this.removeEventListener('line-updated', this.#syncCoords);
this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof L.Polyline) { if (!this.#obj) return;
if ( if (isPathStyleAttr(name)) {
[ updatePathStyle(this.#obj, name, val);
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
} }
} }
#syncCoords = () => {
this.#obj?.setLatLngs(this.#getCoords());
};
#getCoords(): [number, number][] {
const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[];
return lines.map((line) => line.latlng);
}
} }
customElements.define('leaflet-polyline', LeafletPolyline); customElements.define('leaflet-polyline', LeafletPolyline);

@ -1,59 +1,85 @@
import L from 'leaflet'; import { Popup } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
lat: { kind: 'num', attr: 'lat', default: 0 },
lng: { kind: 'num', attr: 'lng', default: 0 },
maxWidth: { kind: 'num', attr: 'max-width', default: 300 },
minWidth: { kind: 'num', attr: 'min-width', default: 50 },
maxHeight: { kind: 'num', attr: 'max-height', default: 0 },
autoPan: { kind: 'bool-on', attr: 'auto-pan' },
closeButton: { kind: 'bool-on', attr: 'close-button' },
autoClose: { kind: 'bool-on', attr: 'auto-close' },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletPopup extends TypedBase {
#obj?: Popup;
#observer?: MutationObserver;
export class LeafletPopup extends LeafletElement {
private _observer?: MutationObserver;
static get observedAttributes() { static get observedAttributes() {
return [ return Object.values(PROPS).map((s) => s.attr);
'lat',
'lng',
'max-width',
'min-width',
'max-height',
'auto-pan',
'close-button',
'auto-close',
];
} }
protected createLeafletObject(): L.Layer { static {
const lat = this.getAttribute('lat'); for (const [name, spec] of Object.entries(PROPS)) {
const lng = this.getAttribute('lng'); Object.defineProperty(LeafletPopup.prototype, name, {
const options = { ...this.options, content: this.innerHTML }; get(this: LeafletPopup) {
const popup = new L.Popup(options); const val = this.getAttribute(spec.attr);
if (lat && lng) { if ('default' in spec && spec.kind === 'num')
popup.setLatLng([parseFloat(lat), parseFloat(lng)]); return val !== null ? Number(val) : spec.default;
if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? ('default' in spec ? (spec as { default: string }).default : '');
},
set(this: LeafletPopup, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
} }
return popup;
} }
connectedCallback() { connectedCallback() {
super.connectedCallback(); this.#obj = new Popup({
this._observer = new MutationObserver(() => { ...buildOptions(this, PROPS, ['lat', 'lng']),
if (this.leafletObject instanceof L.Popup) { content: this.innerHTML,
this.leafletObject.setContent(this.innerHTML); });
} if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]);
}
registerWithParent(this, this.#obj);
this.#observer = new MutationObserver(() => {
this.#obj?.setContent(this.innerHTML);
}); });
this._observer.observe(this, { childList: true, characterData: true, subtree: true }); this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
} }
disconnectedCallback() { disconnectedCallback() {
this._observer?.disconnect(); this.#observer?.disconnect();
this._observer = undefined; this.#observer = undefined;
super.disconnectedCallback(); this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string) {
if (this.leafletObject instanceof L.Popup) { if (!this.#obj) return;
if (property === 'lat' || property === 'lng') { if (name === 'lat' || name === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0'); this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]);
const lng = parseFloat(this.getAttribute('lng') || '0');
this.leafletObject.setLatLng([lat, lng]);
} else {
super.updateLeafletObject(property, value);
}
} }
} }
#num(name: string): number {
const v = this.getAttribute(name);
return v !== null
? Number(v)
: (PROPS[name as keyof typeof PROPS] as { default: number }).default;
}
} }
customElements.define('leaflet-popup', LeafletPopup); customElements.define('leaflet-popup', LeafletPopup);

@ -1,41 +1,70 @@
import L from 'leaflet'; import { Rectangle, LatLngBoundsExpression } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions } from '../core/utils.js';
import { isPathStyleAttr, updatePathStyle } from '../core/path-style.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
bounds: { kind: 'str', attr: 'bounds', default: '' },
color: { kind: 'str', attr: 'color', default: '#3388ff' },
weight: { kind: 'num', attr: 'weight', default: 3 },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
fill: { kind: 'bool-on', attr: 'fill' },
fillColor: { kind: 'str', attr: 'fill-color', default: '#3388ff' },
fillOpacity: { kind: 'num', attr: 'fill-opacity', default: 0.2 },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletRectangle extends TypedBase {
#obj?: Rectangle;
export class LeafletRectangle extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return ['bounds', 'color', 'weight', 'opacity', 'fill', 'fill-color', 'fill-opacity']; return Object.values(PROPS).map((s) => s.attr);
}
static {
for (const [name, spec] of Object.entries(PROPS)) {
Object.defineProperty(LeafletRectangle.prototype, name, {
get(this: LeafletRectangle) {
const val = this.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? spec.default;
},
set(this: LeafletRectangle, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
this.#obj = new Rectangle(this.#parsedBounds(), buildOptions(this, PROPS, ['bounds']));
registerWithParent(this, this.#obj);
} }
protected createLeafletObject(): L.Layer { disconnectedCallback() {
const bounds = this.options.bounds as L.LatLngBoundsExpression; this.#obj?.remove();
return new L.Rectangle(bounds, this.options); this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof L.Rectangle) { if (!this.#obj) return;
if (property === 'bounds') { if (name === 'bounds') {
this.leafletObject.setBounds(value as L.LatLngBoundsExpression); this.#obj.setBounds(this.#parsedBounds());
} else if ( } else if (isPathStyleAttr(name)) {
[ updatePathStyle(this.#obj, name, val);
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
} }
} }
#parsedBounds(): LatLngBoundsExpression {
const raw = this.getAttribute('bounds');
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
}
} }
customElements.define('leaflet-rectangle', LeafletRectangle); customElements.define('leaflet-rectangle', LeafletRectangle);

@ -1,31 +1,84 @@
import L from 'leaflet'; import { SVGOverlay, LatLngBounds, LatLngBoundsExpression, LatLngExpression } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions, parseAttributeValue } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
bounds: { kind: 'str', attr: 'bounds', default: '' },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
interactive: { kind: 'bool-on', attr: 'interactive' },
crossOrigin: { kind: 'str', attr: 'cross-origin', default: '' },
zIndex: { kind: 'num', attr: 'z-index', default: 0 },
className: { kind: 'str', attr: 'class-name', default: '' },
} satisfies Record<string, PropDef>;
const PROP_BY_ATTR = new Map<string, string>(
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
);
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletSVGOverlay extends TypedBase {
#obj?: SVGOverlay;
export class LeafletSVGOverlay extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return ['bounds', 'opacity', 'alt', 'interactive', 'cross-origin', 'z-index', 'className']; return Object.values(PROPS).map((s) => s.attr);
} }
protected createLeafletObject(): L.Layer { static {
const svg = this.querySelector('svg'); for (const [name, spec] of Object.entries(PROPS)) {
const bounds = this.options.bounds as L.LatLngBoundsExpression; Object.defineProperty(LeafletSVGOverlay.prototype, name, {
if (!svg) { get(this: LeafletSVGOverlay) {
// Create a dummy SVG if none provided const val = this.getAttribute(spec.attr);
const dummy = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
return new L.SVGOverlay(dummy, bounds, this.options); if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? spec.default;
},
set(this: LeafletSVGOverlay, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
} }
return new L.SVGOverlay(svg, bounds, this.options);
} }
protected updateLeafletObject(property: string, value: unknown) { connectedCallback() {
if (this.leafletObject instanceof L.SVGOverlay) { const svg = this.querySelector('svg');
if (property === 'bounds') { const dummy = !svg ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') : undefined;
this.leafletObject.setBounds(new L.LatLngBounds(value as L.LatLngExpression[])); this.#obj = new SVGOverlay(
} else { svg ?? dummy!,
super.updateLeafletObject(property, value); this.#parsedBounds(),
buildOptions(this, PROPS, ['bounds']),
);
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'bounds') {
this.#obj.setBounds(new LatLngBounds(this.#parsedBounds() as LatLngExpression[]));
} else {
const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const setter =
`set${propName.charAt(0).toUpperCase()}${propName.slice(1)}` as keyof SVGOverlay;
if (typeof this.#obj[setter] === 'function') {
(this.#obj[setter] as (v: unknown) => void)(parseAttributeValue(val));
} }
} }
} }
#parsedBounds(): LatLngBoundsExpression {
const raw = this.getAttribute('bounds');
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
}
} }
customElements.define('leaflet-svg-overlay', LeafletSVGOverlay); customElements.define('leaflet-svg-overlay', LeafletSVGOverlay);

@ -1,22 +1,81 @@
import L from 'leaflet'; import { TileLayer } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions, parseAttributeValue } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
url: { kind: 'str', attr: 'url', default: '' },
layers: { kind: 'str', attr: 'layers', default: '' },
styles: { kind: 'str', attr: 'styles', default: '' },
format: { kind: 'str', attr: 'format', default: 'image/jpeg' },
transparent: { kind: 'bool-on', attr: 'transparent' },
version: { kind: 'str', attr: 'version', default: '1.1.1' },
uppercase: { kind: 'bool-on', attr: 'uppercase' },
} satisfies Record<string, PropDef>;
const PROP_BY_ATTR = new Map<string, string>(
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
);
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletTileLayerWMS extends TypedBase {
#obj?: TileLayer.WMS;
export class LeafletTileLayerWMS extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return ['url', 'layers', 'styles', 'format', 'transparent', 'version', 'crs', 'uppercase']; return Object.values(PROPS).map((s) => s.attr);
} }
protected createLeafletObject(): L.Layer { static {
for (const [name, spec] of Object.entries(PROPS)) {
Object.defineProperty(LeafletTileLayerWMS.prototype, name, {
get(this: LeafletTileLayerWMS) {
const val = this.getAttribute(spec.attr);
const s = spec as { kind: string; default: unknown };
if (s.kind === 'num') return val !== null ? Number(val) : (s.default as number);
if (s.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? (s.default as string);
},
set(this: LeafletTileLayerWMS, v: number | string | boolean) {
const s = spec as { kind: string };
if (s.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
const url = this.getAttribute('url') || ''; const url = this.getAttribute('url') || '';
return new L.TileLayer.WMS(url, this.options as L.WMSOptions); this.#obj = new TileLayer.WMS(
url,
buildOptions(this, PROPS, ['url']) as Record<string, string>,
);
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof L.TileLayer.WMS) { if (!this.#obj) return;
if (property === 'url') { if (name === 'url') {
this.leafletObject.setUrl(value as string); if (val) this.#obj.setUrl(val);
} else {
const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const setter =
`set${propName.charAt(0).toUpperCase()}${propName.slice(1)}` as keyof TileLayer.WMS;
if (typeof this.#obj[setter] === 'function') {
(this.#obj[setter] as (v: unknown) => void)(parseAttributeValue(val));
} else { } else {
super.updateLeafletObject(property, value); (this.#obj.setParams as unknown as (params: Record<string, unknown>) => void)({
[propName]: parseAttributeValue(val),
});
} }
} }
} }

@ -1,22 +1,69 @@
import L from 'leaflet'; import { TileLayer } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions, parseAttributeValue } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
url: { kind: 'str', attr: 'url', default: '' },
attribution: { kind: 'str', attr: 'attribution', default: '' },
minZoom: { kind: 'num', attr: 'min-zoom', default: 0 },
maxZoom: { kind: 'num', attr: 'max-zoom', default: 18 },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
zIndex: { kind: 'num', attr: 'z-index', default: 0 },
} satisfies Record<string, PropDef>;
const PROP_BY_ATTR = new Map<string, string>(
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
);
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletTileLayer extends TypedBase {
#obj?: TileLayer;
export class LeafletTileLayer extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return ['url', 'attribution', 'min-zoom', 'max-zoom', 'opacity', 'z-index']; return Object.values(PROPS).map((s) => s.attr);
} }
protected createLeafletObject(): L.Layer { static {
for (const [name, spec] of Object.entries(PROPS)) {
Object.defineProperty(LeafletTileLayer.prototype, name, {
get(this: LeafletTileLayer) {
const val = this.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
return val ?? spec.default;
},
set(this: LeafletTileLayer, v: number | string) {
this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
const url = this.getAttribute('url') || ''; const url = this.getAttribute('url') || '';
return new L.TileLayer(url, this.options); this.#obj = new TileLayer(url, buildOptions(this, PROPS, ['url']));
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof L.TileLayer) { if (!this.#obj) return;
if (property === 'url') { if (name === 'url') {
this.leafletObject.setUrl(value as string); if (val) this.#obj.setUrl(val);
} else { } else {
super.updateLeafletObject(property, value); const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const setter =
`set${propName.charAt(0).toUpperCase()}${propName.slice(1)}` as keyof TileLayer;
if (typeof this.#obj[setter] === 'function') {
(this.#obj[setter] as (v: unknown) => void)(parseAttributeValue(val));
} }
} }
} }

@ -1,50 +1,84 @@
import L from 'leaflet'; import { Tooltip } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
lat: { kind: 'num', attr: 'lat', default: 0 },
lng: { kind: 'num', attr: 'lng', default: 0 },
pane: { kind: 'str', attr: 'pane', default: '' },
offset: { kind: 'str', attr: 'offset', default: '' },
direction: { kind: 'str', attr: 'direction', default: 'auto' },
permanent: { kind: 'bool-on', attr: 'permanent' },
sticky: { kind: 'bool-on', attr: 'sticky' },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
} satisfies Record<string, PropDef>;
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletTooltip extends TypedBase {
#obj?: Tooltip;
#observer?: MutationObserver;
export class LeafletTooltip extends LeafletElement {
private _observer?: MutationObserver;
static get observedAttributes() { static get observedAttributes() {
return ['lat', 'lng', 'pane', 'offset', 'direction', 'permanent', 'sticky', 'opacity']; return Object.values(PROPS).map((s) => s.attr);
} }
protected createLeafletObject(): L.Layer { static {
const lat = this.getAttribute('lat'); for (const [name, spec] of Object.entries(PROPS)) {
const lng = this.getAttribute('lng'); Object.defineProperty(LeafletTooltip.prototype, name, {
const options = { ...this.options, content: this.innerHTML }; get(this: LeafletTooltip) {
const tooltip = new L.Tooltip(options); const val = this.getAttribute(spec.attr);
if (lat && lng) { if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
tooltip.setLatLng([parseFloat(lat), parseFloat(lng)]); if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? spec.default;
},
set(this: LeafletTooltip, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
} }
return tooltip;
} }
connectedCallback() { connectedCallback() {
super.connectedCallback(); this.#obj = new Tooltip({
this._observer = new MutationObserver(() => { ...buildOptions(this, PROPS, ['lat', 'lng']),
if (this.leafletObject instanceof L.Tooltip) { content: this.innerHTML,
this.leafletObject.setContent(this.innerHTML); });
} if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]);
}
registerWithParent(this, this.#obj);
this.#observer = new MutationObserver(() => {
this.#obj?.setContent(this.innerHTML);
}); });
this._observer.observe(this, { childList: true, characterData: true, subtree: true }); this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
} }
disconnectedCallback() { disconnectedCallback() {
this._observer?.disconnect(); this.#observer?.disconnect();
this._observer = undefined; this.#observer = undefined;
super.disconnectedCallback(); this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string) {
if (this.leafletObject instanceof L.Tooltip) { if (!this.#obj) return;
if (property === 'lat' || property === 'lng') { if (name === 'lat' || name === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0'); this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]);
const lng = parseFloat(this.getAttribute('lng') || '0');
this.leafletObject.setLatLng([lat, lng]);
} else {
super.updateLeafletObject(property, value);
}
} }
} }
#num(name: string): number {
const v = this.getAttribute(name);
return v !== null
? Number(v)
: (PROPS[name as keyof typeof PROPS] as { default: number }).default;
}
} }
customElements.define('leaflet-tooltip', LeafletTooltip); customElements.define('leaflet-tooltip', LeafletTooltip);

@ -1,44 +1,92 @@
import L from 'leaflet'; import { VideoOverlay, LatLngBounds, LatLngBoundsExpression, LatLngExpression } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js'; import { registerWithParent, buildOptions, parseAttributeValue } from '../core/utils.js';
import type { PropDef, PropTypesFromTable } from '../types/props.js';
const PROPS = {
url: { kind: 'str', attr: 'url', default: '' },
bounds: { kind: 'str', attr: 'bounds', default: '' },
opacity: { kind: 'num', attr: 'opacity', default: 1.0 },
alt: { kind: 'str', attr: 'alt', default: '' },
interactive: { kind: 'bool-on', attr: 'interactive' },
crossOrigin: { kind: 'str', attr: 'cross-origin', default: '' },
loop: { kind: 'bool-on', attr: 'loop' },
autoplay: { kind: 'bool-on', attr: 'autoplay' },
muted: { kind: 'bool-on', attr: 'muted' },
playsInline: { kind: 'bool-on', attr: 'playsinline' },
} satisfies Record<string, PropDef>;
const PROP_BY_ATTR = new Map<string, string>(
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
);
type PropTypes = PropTypesFromTable<typeof PROPS>;
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletVideoOverlay extends TypedBase {
#obj?: VideoOverlay;
export class LeafletVideoOverlay extends LeafletElement {
static get observedAttributes() { static get observedAttributes() {
return [ return Object.values(PROPS).map((s) => s.attr);
'url',
'bounds',
'opacity',
'alt',
'interactive',
'cross-origin',
'loop',
'autoplay',
'muted',
'playsinline',
];
} }
protected createLeafletObject(): L.Layer { static {
for (const [name, spec] of Object.entries(PROPS)) {
Object.defineProperty(LeafletVideoOverlay.prototype, name, {
get(this: LeafletVideoOverlay) {
const val = this.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? Number(val) : spec.default;
if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr);
return val ?? spec.default;
},
set(this: LeafletVideoOverlay, v: number | string | boolean) {
if (spec.kind === 'bool-on') this.toggleAttribute(spec.attr, !!v);
else this.setAttribute(spec.attr, String(v));
},
configurable: true,
enumerable: true,
});
}
}
connectedCallback() {
const url = this.getAttribute('url') || ''; const url = this.getAttribute('url') || '';
const bounds = this.options.bounds as L.LatLngBoundsExpression; this.#obj = new VideoOverlay(
return new L.VideoOverlay(url, bounds, this.options); url,
this.#parsedBounds(),
buildOptions(this, PROPS, ['url', 'bounds']),
);
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
} }
protected updateLeafletObject(property: string, value: unknown) { attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (this.leafletObject instanceof L.VideoOverlay) { if (!this.#obj) return;
if (property === 'url') { if (name === 'url') {
this.leafletObject.setUrl(value as string); if (val) this.#obj.setUrl(val);
} else if (property === 'bounds') { } else if (name === 'bounds') {
this.leafletObject.setBounds(new L.LatLngBounds(value as L.LatLngExpression[])); this.#obj.setBounds(new LatLngBounds(this.#parsedBounds() as LatLngExpression[]));
} else { } else {
super.updateLeafletObject(property, value); const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const setter =
`set${propName.charAt(0).toUpperCase()}${propName.slice(1)}` as keyof VideoOverlay;
if (typeof this.#obj[setter] === 'function') {
(this.#obj[setter] as (v: unknown) => void)(parseAttributeValue(val));
} }
} }
} }
#parsedBounds(): LatLngBoundsExpression {
const raw = this.getAttribute('bounds');
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
}
getElement(): HTMLVideoElement | undefined { getElement(): HTMLVideoElement | undefined {
return this.leafletObject instanceof L.VideoOverlay return this.#obj?.getElement();
? this.leafletObject.getElement()
: undefined;
} }
} }

@ -1,36 +0,0 @@
import { Control } from 'leaflet';
export abstract class LeafletControl extends HTMLElement {
protected control?: Control;
static get observedAttributes(): string[] {
return [];
}
connectedCallback() {
this.control = this.createControl();
this.register();
}
disconnectedCallback() {
this.control?.remove();
this.control = undefined;
}
protected abstract createControl(): Control;
protected register() {
if (this.control) {
this.dispatchEvent(
new CustomEvent('leaflet-register', {
detail: {
leafletObject: this.control,
element: this,
},
bubbles: true,
composed: true,
}),
);
}
}
}

@ -1,141 +0,0 @@
import { Layer, Control, Popup, Tooltip, LayerGroup } from 'leaflet';
export interface LeafletRegisterEvent extends CustomEvent {
detail: {
leafletObject: Layer | Control;
element: HTMLElement;
};
}
export abstract class LeafletElement extends HTMLElement {
protected leafletObject?: Layer;
protected options: Record<string, unknown> = {};
protected _parentLayer?: Layer;
protected _parentBindingType?: 'popup' | 'tooltip' | 'layer';
private _registerHandler?: EventListener;
static get observedAttributes(): string[] {
return [];
}
connectedCallback() {
this.initOptions();
this.leafletObject = this.createLeafletObject();
this._registerHandler = ((e: LeafletRegisterEvent) => {
if (this.leafletObject) {
const childObj = e.detail.leafletObject;
const childEl = e.detail.element;
if (childObj instanceof Popup) {
e.stopPropagation();
this.leafletObject.bindPopup(childObj);
if (childEl instanceof LeafletElement) {
childEl._parentLayer = this.leafletObject;
childEl._parentBindingType = 'popup';
}
} else if (childObj instanceof Tooltip) {
e.stopPropagation();
this.leafletObject.bindTooltip(childObj);
if (childEl instanceof LeafletElement) {
childEl._parentLayer = this.leafletObject;
childEl._parentBindingType = 'tooltip';
}
} else if (childObj instanceof Layer && 'addLayer' in this.leafletObject) {
e.stopPropagation();
(this.leafletObject as LayerGroup).addLayer(childObj);
if (childEl instanceof LeafletElement) {
childEl._parentLayer = this.leafletObject;
childEl._parentBindingType = 'layer';
}
}
}
}) as EventListener;
this.addEventListener('leaflet-register', this._registerHandler);
this.register();
}
disconnectedCallback() {
if (this._registerHandler) {
this.removeEventListener('leaflet-register', this._registerHandler);
this._registerHandler = undefined;
}
if (!this.leafletObject) return;
if (this._parentBindingType === 'popup' && this._parentLayer) {
this._parentLayer.unbindPopup();
} else if (this._parentBindingType === 'tooltip' && this._parentLayer) {
this._parentLayer.unbindTooltip();
} else if (
this._parentBindingType === 'layer' &&
this._parentLayer &&
'removeLayer' in this._parentLayer
) {
(this._parentLayer as LayerGroup).removeLayer(this.leafletObject);
} else {
this.leafletObject.remove();
}
this._parentLayer = undefined;
this._parentBindingType = undefined;
this.leafletObject = undefined;
}
protected initOptions() {
const observed = (this.constructor as typeof LeafletElement).observedAttributes;
observed.forEach((attr) => {
const val = this.getAttribute(attr);
if (val !== null) {
this.options[camelCase(attr)] = parseAttributeValue(val);
}
});
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue === newValue) return;
const propertyName = camelCase(name);
const parsedValue = parseAttributeValue(newValue);
this.options[propertyName] = parsedValue;
if (this.leafletObject) {
this.updateLeafletObject(propertyName, parsedValue);
}
}
protected abstract createLeafletObject(): Layer;
protected updateLeafletObject(property: string, value: unknown) {
const setter = `set${property.charAt(0).toUpperCase()}${property.slice(1)}`;
const obj = this.leafletObject as unknown as Record<string, unknown>;
if (obj && typeof obj[setter] === 'function') {
(obj[setter] as (val: unknown) => void)(value);
}
}
protected register() {
if (!this.leafletObject) return;
const event = new CustomEvent('leaflet-register', {
detail: {
leafletObject: this.leafletObject,
element: this,
},
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
}
}
function camelCase(str: string): string {
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
}
function parseAttributeValue(value: string): unknown {
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;
}
}

@ -0,0 +1,26 @@
import { Path } from 'leaflet';
import { parseAttributeValue } from './utils.js';
export 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) });
}

@ -0,0 +1,35 @@
import { Layer, LayerGroup, Popup, Tooltip } from 'leaflet';
export interface LeafletRegisterEvent extends CustomEvent {
detail: {
leafletObject: Layer;
element: HTMLElement;
};
}
export type ChildEntry = {
type: 'layer' | 'popup' | 'tooltip';
};
export function createChildRegisterHandler(
container: LayerGroup,
children: globalThis.Map<HTMLElement, ChildEntry>,
) {
return (e: LeafletRegisterEvent) => {
const obj = e.detail.leafletObject;
const el = e.detail.element;
if (obj instanceof Popup) {
e.stopPropagation();
container.bindPopup(obj);
children.set(el, { type: 'popup' });
} else if (obj instanceof Tooltip) {
e.stopPropagation();
container.bindTooltip(obj);
children.set(el, { type: 'tooltip' });
} else if (obj instanceof Layer && 'addLayer' in container) {
e.stopPropagation();
container.addLayer(obj);
children.set(el, { type: 'layer' });
}
};
}

@ -0,0 +1,57 @@
import type { PropDef } from '../types/props.js';
export function camelCase(str: string): string {
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
}
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;
}
}
export function buildOptions(
el: HTMLElement,
props: Record<string, PropDef>,
exclude: string[] = [],
): Record<string, unknown> {
const opts: 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) 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;
}
export function registerWithParent(el: HTMLElement, obj: unknown) {
el.dispatchEvent(
new CustomEvent('leaflet-register', {
detail: { leafletObject: obj, element: el },
bubbles: true,
composed: true,
}),
);
}
export function updateViaSetter(obj: Record<string, unknown>, name: string, value: unknown) {
const prop = camelCase(name);
const setter = `set${prop.charAt(0).toUpperCase()}${prop.slice(1)}`;
if (typeof obj[setter] === 'function') {
(obj[setter] as (v: unknown) => void)(value ?? parseAttributeValue(name));
}
}

@ -1,5 +1,7 @@
export * from './core/LeafletElement.js'; export * from './core/utils.js';
export * from './core/LeafletControl.js'; export * from './core/path-style.js';
export * from './core/register.js';
export * from './types/props.js';
export * from './components/leaflet-map.js'; export * from './components/leaflet-map.js';
export * from './components/leaflet-marker.js'; export * from './components/leaflet-marker.js';
export * from './components/leaflet-circle.js'; export * from './components/leaflet-circle.js';

@ -0,0 +1,43 @@
export type NumProp = {
kind: 'num';
attr: string;
default: number;
};
export type StrProp = {
kind: 'str';
attr: string;
default: string;
};
export type BoolOnProp = {
kind: 'bool-on';
attr: string;
};
export type BoolOffProp = {
kind: 'bool-off';
attr: string;
};
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 function attrToPropName<P extends Record<string, PropDef>>(
props: P,
attr: string,
): keyof P | undefined {
for (const [name, spec] of Object.entries(props) as [keyof P, PropDef][]) {
if (spec.attr === attr) return name;
}
return undefined;
}
Loading…
Cancel
Save