From 5f3fd5f61ae2b8b0636c7df9fc29490ab94387d5 Mon Sep 17 00:00:00 2001 From: Buddy Date: Sat, 6 Jun 2026 14:10:49 -0700 Subject: [PATCH] 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. --- src/components/leaflet-circle-marker.ts | 111 ++++++++------ src/components/leaflet-circle.ts | 111 ++++++++------ src/components/leaflet-control-attribution.ts | 50 +++++-- src/components/leaflet-control-scale.ts | 67 +++++++-- src/components/leaflet-control-zoom.ts | 63 ++++++-- src/components/leaflet-feature-group.ts | 33 +++- src/components/leaflet-geojson.ts | 103 +++++++++---- src/components/leaflet-image-overlay.ts | 108 ++++++++++---- src/components/leaflet-layer-group.ts | 36 ++++- src/components/leaflet-map.ts | 2 +- src/components/leaflet-marker.ts | 87 +++++++++-- src/components/leaflet-polygon.ts | 112 +++++++------- src/components/leaflet-polyline.ts | 112 +++++++------- src/components/leaflet-popup.ts | 106 ++++++++----- src/components/leaflet-rectangle.ts | 89 +++++++---- src/components/leaflet-svg-overlay.ts | 89 ++++++++--- src/components/leaflet-tile-layer-wms.ts | 81 ++++++++-- src/components/leaflet-tile-layer.ts | 71 +++++++-- src/components/leaflet-tooltip.ts | 96 ++++++++---- src/components/leaflet-video-overlay.ts | 106 +++++++++---- src/core/LeafletControl.ts | 36 ----- src/core/LeafletElement.ts | 141 ------------------ src/core/path-style.ts | 26 ++++ src/core/register.ts | 35 +++++ src/core/utils.ts | 57 +++++++ src/index.ts | 6 +- src/types/props.ts | 43 ++++++ 27 files changed, 1311 insertions(+), 666 deletions(-) delete mode 100644 src/core/LeafletControl.ts delete mode 100644 src/core/LeafletElement.ts create mode 100644 src/core/path-style.ts create mode 100644 src/core/register.ts create mode 100644 src/core/utils.ts create mode 100644 src/types/props.ts diff --git a/src/components/leaflet-circle-marker.ts b/src/components/leaflet-circle-marker.ts index 53bd8f1..32e91f6 100644 --- a/src/components/leaflet-circle-marker.ts +++ b/src/components/leaflet-circle-marker.ts @@ -1,54 +1,79 @@ -import { Layer, CircleMarker } from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { CircleMarker } from 'leaflet'; +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; + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletCircleMarker extends TypedBase { + #obj?: CircleMarker; -export class LeafletCircleMarker extends LeafletElement { static get observedAttributes() { - return [ - 'lat', - 'lng', - 'radius', - '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(LeafletCircleMarker.prototype, name, { + get(this: LeafletCircleMarker) { + 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: 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 { - const lat = parseFloat(this.getAttribute('lat') || '0'); - const lng = parseFloat(this.getAttribute('lng') || '0'); - return new CircleMarker([lat, lng], this.options); + disconnectedCallback() { + this.#obj?.remove(); + this.#obj = undefined; } - protected updateLeafletObject(property: string, value: unknown) { - if (this.leafletObject instanceof CircleMarker) { - if (property === 'lat' || property === 'lng') { - const lat = parseFloat(this.getAttribute('lat') || '0'); - const lng = parseFloat(this.getAttribute('lng') || '0'); - this.leafletObject.setLatLng([lat, lng]); - } else if ( - [ - 'color', - 'weight', - 'opacity', - 'fill', - 'fillColor', - 'fillOpacity', - 'stroke', - 'dashArray', - 'dashOffset', - 'lineCap', - 'lineJoin', - ].includes(property) - ) { - this.leafletObject.setStyle({ [property]: value }); - } else { - super.updateLeafletObject(property, value); - } + 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 if (name === 'radius') { + this.#obj.setRadius(this.#num('radius')); + } else if (isPathStyleAttr(name)) { + updatePathStyle(this.#obj, name, 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-circle-marker', LeafletCircleMarker); diff --git a/src/components/leaflet-circle.ts b/src/components/leaflet-circle.ts index 0b34f08..9264ee8 100644 --- a/src/components/leaflet-circle.ts +++ b/src/components/leaflet-circle.ts @@ -1,54 +1,79 @@ -import { Circle, Layer } from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { Circle } from 'leaflet'; +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; + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletCircle extends TypedBase { + #obj?: Circle; -export class LeafletCircle extends LeafletElement { static get observedAttributes() { - return [ - 'lat', - 'lng', - 'radius', - '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(LeafletCircle.prototype, name, { + get(this: LeafletCircle) { + 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: 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 { - const lat = parseFloat(this.getAttribute('lat') || '0'); - const lng = parseFloat(this.getAttribute('lng') || '0'); - return new Circle([lat, lng], this.options); + disconnectedCallback() { + this.#obj?.remove(); + this.#obj = undefined; } - protected updateLeafletObject(property: string, value: unknown) { - if (this.leafletObject instanceof Circle) { - if (property === 'lat' || property === 'lng') { - const lat = parseFloat(this.getAttribute('lat') || '0'); - const lng = parseFloat(this.getAttribute('lng') || '0'); - this.leafletObject.setLatLng([lat, lng]); - } else if ( - [ - 'color', - 'weight', - 'opacity', - 'fill', - 'fillColor', - 'fillOpacity', - 'stroke', - 'dashArray', - 'dashOffset', - 'lineCap', - 'lineJoin', - ].includes(property) - ) { - this.leafletObject.setStyle({ [property]: value }); - } else { - super.updateLeafletObject(property, value); - } + 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 if (name === 'radius') { + this.#obj.setRadius(this.#num('radius')); + } else if (isPathStyleAttr(name)) { + updatePathStyle(this.#obj, name, 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-circle', LeafletCircle); diff --git a/src/components/leaflet-control-attribution.ts b/src/components/leaflet-control-attribution.ts index dd102da..9f726ee 100644 --- a/src/components/leaflet-control-attribution.ts +++ b/src/components/leaflet-control-attribution.ts @@ -1,18 +1,48 @@ 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; + +type PropTypes = PropTypesFromTable; +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() { - 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 { - const options: Control.AttributionOptions = {}; - const position = this.getAttribute('position') as ControlPosition | null; - const prefix = this.getAttribute('prefix'); - if (position) options.position = position; - if (prefix !== null) options.prefix = prefix; - return new Control.Attribution(options); + disconnectedCallback() { + this.#obj?.remove(); + this.#obj = undefined; } } diff --git a/src/components/leaflet-control-scale.ts b/src/components/leaflet-control-scale.ts index 0190d20..ded2b62 100644 --- a/src/components/leaflet-control-scale.ts +++ b/src/components/leaflet-control-scale.ts @@ -1,24 +1,59 @@ 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; + +type PropTypes = PropTypesFromTable; +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() { - 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 { - const options: Control.ScaleOptions = {}; - const position = this.getAttribute('position') as ControlPosition | null; - 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); + disconnectedCallback() { + this.#obj?.remove(); + this.#obj = undefined; } } diff --git a/src/components/leaflet-control-zoom.ts b/src/components/leaflet-control-zoom.ts index adf3b09..df2ff14 100644 --- a/src/components/leaflet-control-zoom.ts +++ b/src/components/leaflet-control-zoom.ts @@ -1,24 +1,55 @@ 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; + +type PropTypes = PropTypesFromTable; +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() { - 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 { - const options: Control.ZoomOptions = {}; - const position = this.getAttribute('position') as ControlPosition | null; - 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); + disconnectedCallback() { + this.#obj?.remove(); + this.#obj = undefined; } } diff --git a/src/components/leaflet-feature-group.ts b/src/components/leaflet-feature-group.ts index 21d2009..6a86e12 100644 --- a/src/components/leaflet-feature-group.ts +++ b/src/components/leaflet-feature-group.ts @@ -1,9 +1,32 @@ -import { FeatureGroup, Layer } from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { FeatureGroup } from 'leaflet'; +import { buildOptions, registerWithParent } from '../core/utils.js'; +import { createChildRegisterHandler, type ChildEntry } from '../core/register.js'; -export class LeafletFeatureGroup extends LeafletElement { - protected createLeafletObject(): Layer { - return new FeatureGroup([], this.options); +const PROPS = {} as const satisfies Record; + +export class LeafletFeatureGroup extends HTMLElement { + #obj?: FeatureGroup; + #children = new Map(); + #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; } } diff --git a/src/components/leaflet-geojson.ts b/src/components/leaflet-geojson.ts index a25cbc0..cb17491 100644 --- a/src/components/leaflet-geojson.ts +++ b/src/components/leaflet-geojson.ts @@ -1,43 +1,88 @@ -import { GeoJSON, PathOptions, Layer } from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { GeoJSON, PathOptions } from 'leaflet'; +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; + +const PROP_BY_ATTR = new Map( + Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]), +); + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletGeoJSON extends TypedBase { + #obj?: GeoJSON; + #children = new Map(); + #childHandler?: (e: Event) => void; -export class LeafletGeoJSON extends LeafletElement { static get observedAttributes() { - return [ - 'data', - 'stroke', - 'color', - 'weight', - 'opacity', - 'line-cap', - 'line-join', - 'dash-array', - 'dash-offset', - 'fill', - 'fill-color', - 'fill-opacity', - 'fill-rule', - ]; + return Object.values(PROPS).map((s) => s.attr); + } + + static { + for (const [name, spec] of Object.entries(PROPS)) { + Object.defineProperty(LeafletGeoJSON.prototype, name, { + get(this: LeafletGeoJSON) { + if (spec.kind === 'num') return Number(this.getAttribute(spec.attr) ?? spec.default); + if (spec.kind === 'bool-on') return this.hasAttribute(spec.attr); + return this.getAttribute(spec.attr) ?? spec.default; + }, + set(this: LeafletGeoJSON, 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 raw = this.getAttribute('data'); const data = raw ? JSON.parse(raw) : undefined; 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) { - if (!(this.leafletObject instanceof GeoJSON)) return; - if (property === 'data') { - this.leafletObject.clearLayers(); - if (value) { - this.leafletObject.addData(value as Parameters[0]); - } + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (!this.#obj) return; + if (name === 'data') { + this.#obj.clearLayers(); + if (val) this.#obj.addData(JSON.parse(val)); } 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); } } } diff --git a/src/components/leaflet-image-overlay.ts b/src/components/leaflet-image-overlay.ts index 204102a..a6bce84 100644 --- a/src/components/leaflet-image-overlay.ts +++ b/src/components/leaflet-image-overlay.ts @@ -1,44 +1,88 @@ -import { - ImageOverlay, - LatLngBounds, - LatLngBoundsExpression, - LatLngExpression, - Layer, -} from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; - -export class LeafletImageOverlay extends LeafletElement { +import { ImageOverlay, LatLngBounds, LatLngBoundsExpression, LatLngExpression } from 'leaflet'; +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: '' }, + 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; + +const PROP_BY_ATTR = new Map( + Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]), +); + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletImageOverlay extends TypedBase { + #obj?: ImageOverlay; + static get observedAttributes() { - return [ - 'url', - 'bounds', - 'opacity', - 'alt', - 'interactive', - 'cross-origin', - 'error-overlay-url', - 'z-index', - 'className', - ]; + return Object.values(PROPS).map((s) => s.attr); + } + + static { + for (const [name, spec] of Object.entries(PROPS)) { + Object.defineProperty(LeafletImageOverlay.prototype, name, { + get(this: LeafletImageOverlay) { + 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: 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 bounds = this.options.bounds as LatLngBoundsExpression; - return new ImageOverlay(url, bounds, this.options); + this.#obj = new ImageOverlay( + 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) { - if (this.leafletObject instanceof ImageOverlay) { - if (property === 'url') { - this.leafletObject.setUrl(value as string); - } else if (property === 'bounds') { - this.leafletObject.setBounds(new LatLngBounds(value as LatLngExpression[])); - } else { - super.updateLeafletObject(property, value); + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (!this.#obj) return; + if (name === 'url') { + if (val) this.#obj.setUrl(val); + } else 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 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); diff --git a/src/components/leaflet-layer-group.ts b/src/components/leaflet-layer-group.ts index 5e80ddf..ff26102 100644 --- a/src/components/leaflet-layer-group.ts +++ b/src/components/leaflet-layer-group.ts @@ -1,9 +1,35 @@ -import { LayerGroup, Layer } from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { LayerGroup } from 'leaflet'; +import { buildOptions, registerWithParent } from '../core/utils.js'; +import { createChildRegisterHandler, type ChildEntry } from '../core/register.js'; -export class LeafletLayerGroup extends LeafletElement { - protected createLeafletObject(): Layer { - return new LayerGroup([], this.options); +const PROPS = {} as const satisfies Record; + +export class LeafletLayerGroup extends HTMLElement { + #obj?: LayerGroup; + #children = new Map(); + #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; } } diff --git a/src/components/leaflet-map.ts b/src/components/leaflet-map.ts index 81e5231..f19aae4 100644 --- a/src/components/leaflet-map.ts +++ b/src/components/leaflet-map.ts @@ -1,6 +1,6 @@ import { Map as LMap, MapOptions } from 'leaflet'; import leafletCSS from 'leaflet/dist/leaflet.css'; -import { LeafletRegisterEvent } from '../core/LeafletElement.js'; +import { LeafletRegisterEvent } from '../core/register.js'; // ── Prop types ───────────────────────────────────────────────────────────── diff --git a/src/components/leaflet-marker.ts b/src/components/leaflet-marker.ts index 46cda03..47085ea 100644 --- a/src/components/leaflet-marker.ts +++ b/src/components/leaflet-marker.ts @@ -1,28 +1,83 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { Marker } from 'leaflet'; +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; + +const PROP_BY_ATTR = new Map( + Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]), +); + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletMarker extends TypedBase { + #obj?: Marker; -export class LeafletMarker extends LeafletElement { 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 { - const lat = parseFloat(this.getAttribute('lat') || '0'); - const lng = parseFloat(this.getAttribute('lng') || '0'); - return new L.Marker([lat, lng], this.options); + static { + for (const [name, spec] of Object.entries(PROPS)) { + Object.defineProperty(LeafletMarker.prototype, name, { + 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) { - if (this.leafletObject instanceof L.Marker) { - if (property === 'lat' || property === 'lng') { - const lat = parseFloat(this.getAttribute('lat') || '0'); - const lng = parseFloat(this.getAttribute('lng') || '0'); - this.leafletObject.setLatLng([lat, lng]); - } else { - super.updateLeafletObject(property, value); + connectedCallback() { + this.#obj = new Marker( + [this.#num('lat'), this.#num('lng')], + buildOptions(this, PROPS, ['lat', 'lng']), + ); + 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 === '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); diff --git a/src/components/leaflet-polygon.ts b/src/components/leaflet-polygon.ts index 1e7c819..45810e7 100644 --- a/src/components/leaflet-polygon.ts +++ b/src/components/leaflet-polygon.ts @@ -1,68 +1,80 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; -import { LeafletLine } from './leaflet-line.js'; +import { Polygon } from 'leaflet'; +import { registerWithParent, buildOptions } from '../core/utils.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 { - private _observer?: MutationObserver; - static get observedAttributes() { - return ['color', 'weight', 'opacity', 'fill', 'fill-color', 'fill-opacity']; - } +const PROPS = { + 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; + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletPolygon extends TypedBase { + #obj?: Polygon; + #observer?: MutationObserver; - protected createLeafletObject(): L.Layer { - const coords = this.getCoords(); - return new L.Polygon(coords, this.options); + static get observedAttributes() { + return Object.values(PROPS).map((s) => s.attr); } - private getCoords(): [number, number][] { - const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[]; - return lines.map((line) => line.latlng); + static { + for (const [name, spec] of Object.entries(PROPS)) { + 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() { - super.connectedCallback(); - this.addEventListener('line-updated', () => { - if (this.leafletObject instanceof L.Polygon) { - this.leafletObject.setLatLngs(this.getCoords()); - } - }); + this.#obj = new Polygon(this.#getCoords(), buildOptions(this, PROPS)); + registerWithParent(this, this.#obj); - this._observer = new MutationObserver(() => { - if (this.leafletObject instanceof L.Polygon) { - this.leafletObject.setLatLngs(this.getCoords()); - } - }); - this._observer.observe(this, { childList: true }); + this.addEventListener('line-updated', this.#syncCoords); + this.#observer = new MutationObserver(() => this.#syncCoords()); + this.#observer.observe(this, { childList: true }); } disconnectedCallback() { - this._observer?.disconnect(); - this._observer = undefined; - super.disconnectedCallback(); + this.#observer?.disconnect(); + this.#observer = undefined; + this.removeEventListener('line-updated', this.#syncCoords); + this.#obj?.remove(); + this.#obj = undefined; } - protected updateLeafletObject(property: string, value: unknown) { - if (this.leafletObject instanceof L.Polygon) { - if ( - [ - 'color', - 'weight', - 'opacity', - 'fill', - 'fillColor', - 'fillOpacity', - 'stroke', - 'dashArray', - 'dashOffset', - 'lineCap', - 'lineJoin', - ].includes(property) - ) { - this.leafletObject.setStyle({ [property]: value }); - } else { - super.updateLeafletObject(property, value); - } + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (!this.#obj) return; + if (isPathStyleAttr(name)) { + updatePathStyle(this.#obj, name, val); } } + + #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); diff --git a/src/components/leaflet-polyline.ts b/src/components/leaflet-polyline.ts index c26ccc2..1283e18 100644 --- a/src/components/leaflet-polyline.ts +++ b/src/components/leaflet-polyline.ts @@ -1,68 +1,80 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; -import { LeafletLine } from './leaflet-line.js'; +import { Polyline } from 'leaflet'; +import { registerWithParent, buildOptions } from '../core/utils.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 { - private _observer?: MutationObserver; - static get observedAttributes() { - return ['color', 'weight', 'opacity', 'fill', 'fill-color', 'fill-opacity']; - } +const PROPS = { + 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; + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletPolyline extends TypedBase { + #obj?: Polyline; + #observer?: MutationObserver; - protected createLeafletObject(): L.Layer { - const coords = this.getCoords(); - return new L.Polyline(coords, this.options); + static get observedAttributes() { + return Object.values(PROPS).map((s) => s.attr); } - private getCoords(): [number, number][] { - const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[]; - return lines.map((line) => line.latlng); + static { + for (const [name, spec] of Object.entries(PROPS)) { + 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() { - super.connectedCallback(); - this.addEventListener('line-updated', () => { - if (this.leafletObject instanceof L.Polyline) { - this.leafletObject.setLatLngs(this.getCoords()); - } - }); + this.#obj = new Polyline(this.#getCoords(), buildOptions(this, PROPS)); + registerWithParent(this, this.#obj); - this._observer = new MutationObserver(() => { - if (this.leafletObject instanceof L.Polyline) { - this.leafletObject.setLatLngs(this.getCoords()); - } - }); - this._observer.observe(this, { childList: true }); + this.addEventListener('line-updated', this.#syncCoords); + this.#observer = new MutationObserver(() => this.#syncCoords()); + this.#observer.observe(this, { childList: true }); } disconnectedCallback() { - this._observer?.disconnect(); - this._observer = undefined; - super.disconnectedCallback(); + this.#observer?.disconnect(); + this.#observer = undefined; + this.removeEventListener('line-updated', this.#syncCoords); + this.#obj?.remove(); + this.#obj = undefined; } - protected updateLeafletObject(property: string, value: unknown) { - if (this.leafletObject instanceof L.Polyline) { - if ( - [ - 'color', - 'weight', - 'opacity', - 'fill', - 'fillColor', - 'fillOpacity', - 'stroke', - 'dashArray', - 'dashOffset', - 'lineCap', - 'lineJoin', - ].includes(property) - ) { - this.leafletObject.setStyle({ [property]: value }); - } else { - super.updateLeafletObject(property, value); - } + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (!this.#obj) return; + if (isPathStyleAttr(name)) { + updatePathStyle(this.#obj, name, val); } } + + #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); diff --git a/src/components/leaflet-popup.ts b/src/components/leaflet-popup.ts index 1625f18..4301603 100644 --- a/src/components/leaflet-popup.ts +++ b/src/components/leaflet-popup.ts @@ -1,59 +1,85 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { Popup } from 'leaflet'; +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; + +type PropTypes = PropTypesFromTable; +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() { - return [ - 'lat', - 'lng', - 'max-width', - 'min-width', - 'max-height', - 'auto-pan', - 'close-button', - 'auto-close', - ]; + return Object.values(PROPS).map((s) => s.attr); } - protected createLeafletObject(): L.Layer { - const lat = this.getAttribute('lat'); - const lng = this.getAttribute('lng'); - const options = { ...this.options, content: this.innerHTML }; - const popup = new L.Popup(options); - if (lat && lng) { - popup.setLatLng([parseFloat(lat), parseFloat(lng)]); + static { + for (const [name, spec] of Object.entries(PROPS)) { + Object.defineProperty(LeafletPopup.prototype, name, { + get(this: LeafletPopup) { + const val = this.getAttribute(spec.attr); + if ('default' in spec && spec.kind === 'num') + 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() { - super.connectedCallback(); - this._observer = new MutationObserver(() => { - if (this.leafletObject instanceof L.Popup) { - this.leafletObject.setContent(this.innerHTML); - } + this.#obj = new Popup({ + ...buildOptions(this, PROPS, ['lat', 'lng']), + content: 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() { - this._observer?.disconnect(); - this._observer = undefined; - super.disconnectedCallback(); + this.#observer?.disconnect(); + this.#observer = undefined; + this.#obj?.remove(); + this.#obj = undefined; } - protected updateLeafletObject(property: string, value: unknown) { - if (this.leafletObject instanceof L.Popup) { - if (property === 'lat' || property === 'lng') { - const lat = parseFloat(this.getAttribute('lat') || '0'); - const lng = parseFloat(this.getAttribute('lng') || '0'); - this.leafletObject.setLatLng([lat, lng]); - } else { - super.updateLeafletObject(property, value); - } + attributeChangedCallback(name: string) { + if (!this.#obj) return; + if (name === 'lat' || name === 'lng') { + this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]); } } + + #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); diff --git a/src/components/leaflet-rectangle.ts b/src/components/leaflet-rectangle.ts index 1daa39d..3480790 100644 --- a/src/components/leaflet-rectangle.ts +++ b/src/components/leaflet-rectangle.ts @@ -1,41 +1,70 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { Rectangle, LatLngBoundsExpression } from 'leaflet'; +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; + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletRectangle extends TypedBase { + #obj?: Rectangle; -export class LeafletRectangle extends LeafletElement { 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 { - const bounds = this.options.bounds as L.LatLngBoundsExpression; - return new L.Rectangle(bounds, this.options); + disconnectedCallback() { + this.#obj?.remove(); + this.#obj = undefined; } - protected updateLeafletObject(property: string, value: unknown) { - if (this.leafletObject instanceof L.Rectangle) { - if (property === 'bounds') { - this.leafletObject.setBounds(value as L.LatLngBoundsExpression); - } else if ( - [ - 'color', - 'weight', - 'opacity', - 'fill', - 'fillColor', - 'fillOpacity', - 'stroke', - 'dashArray', - 'dashOffset', - 'lineCap', - 'lineJoin', - ].includes(property) - ) { - this.leafletObject.setStyle({ [property]: value }); - } else { - super.updateLeafletObject(property, value); - } + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (!this.#obj) return; + if (name === 'bounds') { + this.#obj.setBounds(this.#parsedBounds()); + } else if (isPathStyleAttr(name)) { + updatePathStyle(this.#obj, name, val); } } + + #parsedBounds(): LatLngBoundsExpression { + const raw = this.getAttribute('bounds'); + return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : []; + } } customElements.define('leaflet-rectangle', LeafletRectangle); diff --git a/src/components/leaflet-svg-overlay.ts b/src/components/leaflet-svg-overlay.ts index b8fafda..dff4d7e 100644 --- a/src/components/leaflet-svg-overlay.ts +++ b/src/components/leaflet-svg-overlay.ts @@ -1,31 +1,84 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { SVGOverlay, LatLngBounds, LatLngBoundsExpression, LatLngExpression } from 'leaflet'; +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; + +const PROP_BY_ATTR = new Map( + Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]), +); + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletSVGOverlay extends TypedBase { + #obj?: SVGOverlay; -export class LeafletSVGOverlay extends LeafletElement { 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 { - const svg = this.querySelector('svg'); - const bounds = this.options.bounds as L.LatLngBoundsExpression; - if (!svg) { - // Create a dummy SVG if none provided - const dummy = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - return new L.SVGOverlay(dummy, bounds, this.options); + static { + for (const [name, spec] of Object.entries(PROPS)) { + Object.defineProperty(LeafletSVGOverlay.prototype, name, { + get(this: LeafletSVGOverlay) { + 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: 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) { - if (this.leafletObject instanceof L.SVGOverlay) { - if (property === 'bounds') { - this.leafletObject.setBounds(new L.LatLngBounds(value as L.LatLngExpression[])); - } else { - super.updateLeafletObject(property, value); + connectedCallback() { + const svg = this.querySelector('svg'); + const dummy = !svg ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') : undefined; + this.#obj = new SVGOverlay( + svg ?? dummy!, + 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); diff --git a/src/components/leaflet-tile-layer-wms.ts b/src/components/leaflet-tile-layer-wms.ts index fca2982..fa2e0ae 100644 --- a/src/components/leaflet-tile-layer-wms.ts +++ b/src/components/leaflet-tile-layer-wms.ts @@ -1,22 +1,81 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { TileLayer } from 'leaflet'; +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; + +const PROP_BY_ATTR = new Map( + Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]), +); + +type PropTypes = PropTypesFromTable; +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() { - 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') || ''; - return new L.TileLayer.WMS(url, this.options as L.WMSOptions); + this.#obj = new TileLayer.WMS( + url, + buildOptions(this, PROPS, ['url']) as Record, + ); + registerWithParent(this, this.#obj); + } + + disconnectedCallback() { + this.#obj?.remove(); + this.#obj = undefined; } - protected updateLeafletObject(property: string, value: unknown) { - if (this.leafletObject instanceof L.TileLayer.WMS) { - if (property === 'url') { - this.leafletObject.setUrl(value as string); + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (!this.#obj) return; + if (name === 'url') { + 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 { - super.updateLeafletObject(property, value); + (this.#obj.setParams as unknown as (params: Record) => void)({ + [propName]: parseAttributeValue(val), + }); } } } diff --git a/src/components/leaflet-tile-layer.ts b/src/components/leaflet-tile-layer.ts index 4cfc2e7..8bfc936 100644 --- a/src/components/leaflet-tile-layer.ts +++ b/src/components/leaflet-tile-layer.ts @@ -1,22 +1,69 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { TileLayer } from 'leaflet'; +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; + +const PROP_BY_ATTR = new Map( + Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]), +); + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletTileLayer extends TypedBase { + #obj?: TileLayer; -export class LeafletTileLayer extends LeafletElement { 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') || ''; - 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) { - if (this.leafletObject instanceof L.TileLayer) { - if (property === 'url') { - this.leafletObject.setUrl(value as string); - } else { - super.updateLeafletObject(property, value); + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (!this.#obj) return; + if (name === 'url') { + 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; + if (typeof this.#obj[setter] === 'function') { + (this.#obj[setter] as (v: unknown) => void)(parseAttributeValue(val)); } } } diff --git a/src/components/leaflet-tooltip.ts b/src/components/leaflet-tooltip.ts index 1f34be2..36015cc 100644 --- a/src/components/leaflet-tooltip.ts +++ b/src/components/leaflet-tooltip.ts @@ -1,50 +1,84 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { Tooltip } from 'leaflet'; +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; + +type PropTypes = PropTypesFromTable; +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() { - return ['lat', 'lng', 'pane', 'offset', 'direction', 'permanent', 'sticky', 'opacity']; + return Object.values(PROPS).map((s) => s.attr); } - protected createLeafletObject(): L.Layer { - const lat = this.getAttribute('lat'); - const lng = this.getAttribute('lng'); - const options = { ...this.options, content: this.innerHTML }; - const tooltip = new L.Tooltip(options); - if (lat && lng) { - tooltip.setLatLng([parseFloat(lat), parseFloat(lng)]); + static { + for (const [name, spec] of Object.entries(PROPS)) { + Object.defineProperty(LeafletTooltip.prototype, name, { + get(this: LeafletTooltip) { + 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: 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() { - super.connectedCallback(); - this._observer = new MutationObserver(() => { - if (this.leafletObject instanceof L.Tooltip) { - this.leafletObject.setContent(this.innerHTML); - } + this.#obj = new Tooltip({ + ...buildOptions(this, PROPS, ['lat', 'lng']), + content: 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() { - this._observer?.disconnect(); - this._observer = undefined; - super.disconnectedCallback(); + this.#observer?.disconnect(); + this.#observer = undefined; + this.#obj?.remove(); + this.#obj = undefined; } - protected updateLeafletObject(property: string, value: unknown) { - if (this.leafletObject instanceof L.Tooltip) { - if (property === 'lat' || property === 'lng') { - const lat = parseFloat(this.getAttribute('lat') || '0'); - const lng = parseFloat(this.getAttribute('lng') || '0'); - this.leafletObject.setLatLng([lat, lng]); - } else { - super.updateLeafletObject(property, value); - } + attributeChangedCallback(name: string) { + if (!this.#obj) return; + if (name === 'lat' || name === 'lng') { + this.#obj.setLatLng([this.#num('lat'), this.#num('lng')]); } } + + #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); diff --git a/src/components/leaflet-video-overlay.ts b/src/components/leaflet-video-overlay.ts index 1132642..014475c 100644 --- a/src/components/leaflet-video-overlay.ts +++ b/src/components/leaflet-video-overlay.ts @@ -1,44 +1,92 @@ -import L from 'leaflet'; -import { LeafletElement } from '../core/LeafletElement.js'; +import { VideoOverlay, LatLngBounds, LatLngBoundsExpression, LatLngExpression } from 'leaflet'; +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; + +const PROP_BY_ATTR = new Map( + Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]), +); + +type PropTypes = PropTypesFromTable; +const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; + +export class LeafletVideoOverlay extends TypedBase { + #obj?: VideoOverlay; -export class LeafletVideoOverlay extends LeafletElement { static get observedAttributes() { - return [ - 'url', - 'bounds', - 'opacity', - 'alt', - 'interactive', - 'cross-origin', - 'loop', - 'autoplay', - 'muted', - 'playsinline', - ]; + return Object.values(PROPS).map((s) => s.attr); } - 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 bounds = this.options.bounds as L.LatLngBoundsExpression; - return new L.VideoOverlay(url, bounds, this.options); + this.#obj = new VideoOverlay( + 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) { - if (this.leafletObject instanceof L.VideoOverlay) { - if (property === 'url') { - this.leafletObject.setUrl(value as string); - } else if (property === 'bounds') { - this.leafletObject.setBounds(new L.LatLngBounds(value as L.LatLngExpression[])); - } else { - super.updateLeafletObject(property, value); + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (!this.#obj) return; + if (name === 'url') { + if (val) this.#obj.setUrl(val); + } else 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 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 { - return this.leafletObject instanceof L.VideoOverlay - ? this.leafletObject.getElement() - : undefined; + return this.#obj?.getElement(); } } diff --git a/src/core/LeafletControl.ts b/src/core/LeafletControl.ts deleted file mode 100644 index 1e65e75..0000000 --- a/src/core/LeafletControl.ts +++ /dev/null @@ -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, - }), - ); - } - } -} diff --git a/src/core/LeafletElement.ts b/src/core/LeafletElement.ts deleted file mode 100644 index 6eb51d1..0000000 --- a/src/core/LeafletElement.ts +++ /dev/null @@ -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 = {}; - 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; - 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; - } -} diff --git a/src/core/path-style.ts b/src/core/path-style.ts new file mode 100644 index 0000000..eeed421 --- /dev/null +++ b/src/core/path-style.ts @@ -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) }); +} diff --git a/src/core/register.ts b/src/core/register.ts new file mode 100644 index 0000000..28fa2c7 --- /dev/null +++ b/src/core/register.ts @@ -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, +) { + 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' }); + } + }; +} diff --git a/src/core/utils.ts b/src/core/utils.ts new file mode 100644 index 0000000..8bac4e3 --- /dev/null +++ b/src/core/utils.ts @@ -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, + exclude: string[] = [], +): Record { + const opts: Record = {}; + for (const [propName, spec] of Object.entries(props)) { + if (exclude.includes(propName)) continue; + const val = el.getAttribute(spec.attr); + if (val === null) { + if ('default' in spec) 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, 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)); + } +} diff --git a/src/index.ts b/src/index.ts index 2cb74cf..7a2dc1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ -export * from './core/LeafletElement.js'; -export * from './core/LeafletControl.js'; +export * from './core/utils.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-marker.js'; export * from './components/leaflet-circle.js'; diff --git a/src/types/props.ts b/src/types/props.ts new file mode 100644 index 0000000..297a692 --- /dev/null +++ b/src/types/props.ts @@ -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 NumProp + ? number + : T extends StrProp + ? string + : boolean; + +export type PropTypesFromTable> = { + [K in keyof T]: PropTypeOf; +}; + +export function attrToPropName

>( + 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; +}