From 35190bacc5896fb9144026deba0fa4cc2513b5f7 Mon Sep 17 00:00:00 2001 From: Buddy Date: Wed, 10 Jun 2026 21:31:19 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20split=20leaflet-icon=20into=20image=20I?= =?UTF-8?q?con=20and=20DivIcon,=20event-only=20marker=E2=86=94icon=20hands?= =?UTF-8?q?hake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip leaflet-icon.ts to image-only (new Icon(), remove div/html/bgPos) - Create leaflet-div-icon.ts with DivIcon, innerHTML content, MutationObserver - Convert marker↔icon from querySelector to event-only protocol: marker iterates children dispatching leaflet-request-icon on each, icons listen on themselves and respond with icon-changed - Extract emitIconChanged() helper into register.ts to eliminate repeated CustomEvent construction across both icon components - Type icon-changed and leaflet-request-icon in HTMLElementEventMap - Update marker's #onIconChanged to use typed event instead of cast - Export LeafletDivIcon from index.ts - Add DivIcon demo markers to custom-icon.html and index.html --- demos/custom-icon.html | 11 +++++ index.html | 35 +++++++++++++- src/components/leaflet-div-icon.ts | 74 +++++++++++++++++++++++++++++ src/components/leaflet-icon.ts | 75 ++++++++++++------------------ src/components/leaflet-marker.ts | 14 +++--- src/core/register.ts | 17 ++++++- src/index.ts | 1 + 7 files changed, 173 insertions(+), 54 deletions(-) create mode 100644 src/components/leaflet-div-icon.ts diff --git a/demos/custom-icon.html b/demos/custom-icon.html index 076e88e..1448d5e 100644 --- a/demos/custom-icon.html +++ b/demos/custom-icon.html @@ -300,6 +300,17 @@ > I am a orange leaf. + + + +
+
+ I use a DivIcon! Content comes from innerHTML. +
diff --git a/index.html b/index.html index a5f82bd..bbfdda2 100644 --- a/index.html +++ b/index.html @@ -303,6 +303,13 @@ + +
+
Part of a feature group
@@ -353,7 +360,7 @@ 'leaflet-polygon': ['leaflet-line', 'leaflet-popup'], 'leaflet-polyline': ['leaflet-line', 'leaflet-popup'], 'leaflet-rectangle': ['leaflet-popup'], - 'leaflet-marker': ['leaflet-popup', 'leaflet-tooltip'], + 'leaflet-marker': ['leaflet-popup', 'leaflet-tooltip', 'leaflet-icon', 'leaflet-div-icon'], 'leaflet-circle': ['leaflet-popup', 'leaflet-tooltip'], 'leaflet-circle-marker': ['leaflet-popup', 'leaflet-tooltip'], 'leaflet-image-overlay': ['leaflet-popup', 'leaflet-tooltip'], @@ -630,6 +637,32 @@ }, 'leaflet-layer-group': {}, 'leaflet-feature-group': {}, + 'leaflet-icon': { + Attributes: [ + { attr: 'icon-url', kind: 'text' }, + { attr: 'icon-retina-url', kind: 'text' }, + { attr: 'icon-size', kind: 'text' }, + { attr: 'icon-anchor', kind: 'text' }, + { attr: 'popup-anchor', kind: 'text' }, + { attr: 'tooltip-anchor', kind: 'text' }, + { attr: 'shadow-url', kind: 'text' }, + { attr: 'shadow-retina-url', kind: 'text' }, + { attr: 'shadow-size', kind: 'text' }, + { attr: 'shadow-anchor', kind: 'text' }, + { attr: 'class-name', kind: 'text' }, + ], + }, + 'leaflet-div-icon': { + Attributes: [ + { attr: 'icon-size', kind: 'text' }, + { attr: 'icon-anchor', kind: 'text' }, + { attr: 'popup-anchor', kind: 'text' }, + { attr: 'tooltip-anchor', kind: 'text' }, + { attr: 'class-name', kind: 'text' }, + { attr: 'html', kind: 'text' }, + { attr: 'bg-pos', kind: 'text' }, + ], + }, 'leaflet-popup': { Position: [ { attr: 'lat', kind: 'num', step: 'any' }, diff --git a/src/components/leaflet-div-icon.ts b/src/components/leaflet-div-icon.ts new file mode 100644 index 0000000..9c6ca9d --- /dev/null +++ b/src/components/leaflet-div-icon.ts @@ -0,0 +1,74 @@ +import { DivIcon, DivIconOptions, Icon } from 'leaflet'; +import { emitIconChanged } from '../core/register.ts'; + +const PROPS = { + iconSize: { attr: 'icon-size' }, + iconAnchor: { attr: 'icon-anchor' }, + popupAnchor: { attr: 'popup-anchor' }, + tooltipAnchor: { attr: 'tooltip-anchor' }, + className: { attr: 'class-name' }, + html: { attr: 'html' }, + bgPos: { attr: 'bg-pos' }, +} as const; + +const JSON_KEYS = new Set(['iconSize', 'iconAnchor', 'popupAnchor', 'tooltipAnchor', 'bgPos']); + +export class LeafletDivIcon extends HTMLElement { + #obj?: Icon; + #observer?: MutationObserver; + #onRequest = () => { + if (!this.#obj) return; + emitIconChanged(this, this.#obj); + }; + + connectedCallback() { + this.#applyIcon(); + emitIconChanged(this, this.#obj); + this.addEventListener('leaflet-request-icon', this.#onRequest); + + this.#observer = new MutationObserver(() => { + this.#applyIcon(); + emitIconChanged(this, this.#obj); + }); + this.#observer.observe(this, { + childList: true, + characterData: true, + subtree: true, + }); + } + + disconnectedCallback() { + this.removeEventListener('leaflet-request-icon', this.#onRequest); + this.#observer?.disconnect(); + this.#observer = undefined; + emitIconChanged(this, null); + } + + static get observedAttributes() { + return Object.values(PROPS).map((s) => s.attr); + } + + attributeChangedCallback() { + this.#applyIcon(); + if (!this.#obj) return; + emitIconChanged(this, this.#obj); + } + + get leafletObject() { + return this.#obj; + } + + #applyIcon() { + const opts: Record = {}; + for (const [key, spec] of Object.entries(PROPS)) { + const v = this.getAttribute(spec.attr); + if (v === null) continue; + opts[key] = JSON_KEYS.has(key) ? JSON.parse(v) : v; + } + const content = this.innerHTML; + if (content) opts.html = content; + this.#obj = new DivIcon(opts as DivIconOptions) as unknown as Icon; + } +} + +customElements.define('leaflet-div-icon', LeafletDivIcon); diff --git a/src/components/leaflet-icon.ts b/src/components/leaflet-icon.ts index 42023b0..0ba2ddc 100644 --- a/src/components/leaflet-icon.ts +++ b/src/components/leaflet-icon.ts @@ -1,23 +1,19 @@ -import { Icon, icon, divIcon, IconOptions, DivIconOptions } from 'leaflet'; -import { defineProps, str, on } from '../core/props.ts'; -import { WithProps } from '../core/utils.ts'; +import { Icon, IconOptions } from 'leaflet'; +import { emitIconChanged } from '../core/register.ts'; -const PROPS = defineProps({ - iconUrl: str(), - iconRetinaUrl: str(), - iconSize: str(), - iconAnchor: str(), - popupAnchor: str(), - tooltipAnchor: str(), - shadowUrl: str(), - shadowRetinaUrl: str(), - shadowSize: str(), - shadowAnchor: str(), - className: str(), - div: on(), - html: str(), - bgPos: str(), -}); +const PROPS = { + iconUrl: { attr: 'icon-url' }, + iconRetinaUrl: { attr: 'icon-retina-url' }, + iconSize: { attr: 'icon-size' }, + iconAnchor: { attr: 'icon-anchor' }, + popupAnchor: { attr: 'popup-anchor' }, + tooltipAnchor: { attr: 'tooltip-anchor' }, + shadowUrl: { attr: 'shadow-url' }, + shadowRetinaUrl: { attr: 'shadow-retina-url' }, + shadowSize: { attr: 'shadow-size' }, + shadowAnchor: { attr: 'shadow-anchor' }, + className: { attr: 'class-name' }, +} as const; const JSON_KEYS = new Set([ 'iconSize', @@ -26,40 +22,34 @@ const JSON_KEYS = new Set([ 'tooltipAnchor', 'shadowSize', 'shadowAnchor', - 'bgPos', ]); -export class LeafletIcon extends WithProps(HTMLElement, PROPS) { +export class LeafletIcon extends HTMLElement { #obj?: Icon; + #onRequest = () => { + if (!this.#obj) return; + emitIconChanged(this, this.#obj); + }; connectedCallback() { this.#applyIcon(); - this.dispatchEvent( - new CustomEvent('icon-changed', { - bubbles: true, - detail: { icon: this.#obj }, - }), - ); + emitIconChanged(this, this.#obj); + this.addEventListener('leaflet-request-icon', this.#onRequest); } disconnectedCallback() { - this.dispatchEvent( - new CustomEvent('icon-changed', { - bubbles: true, - detail: { icon: null }, - }), - ); + this.removeEventListener('leaflet-request-icon', this.#onRequest); + emitIconChanged(this, null); + } + + static get observedAttributes() { + return Object.values(PROPS).map((s) => s.attr); } attributeChangedCallback() { this.#applyIcon(); if (!this.#obj) return; - this.dispatchEvent( - new CustomEvent('icon-changed', { - bubbles: true, - detail: { icon: this.#obj }, - }), - ); + emitIconChanged(this, this.#obj); } get leafletObject() { @@ -69,15 +59,12 @@ export class LeafletIcon extends WithProps(HTMLElement, PROPS) { #applyIcon() { const opts: Record = {}; for (const [key, spec] of Object.entries(PROPS)) { - if (key === 'div') continue; const v = this.getAttribute(spec.attr); if (v === null) continue; opts[key] = JSON_KEYS.has(key) ? JSON.parse(v) : v; } - if (this.hasAttribute('div')) { - this.#obj = divIcon(opts as unknown as DivIconOptions) as unknown as Icon; - } else if (opts.iconUrl) { - this.#obj = icon(opts as unknown as IconOptions) as unknown as Icon; + if (opts.iconUrl) { + this.#obj = new Icon(opts as unknown as IconOptions); } else { this.#obj = undefined; } diff --git a/src/components/leaflet-marker.ts b/src/components/leaflet-marker.ts index 3454555..73b296f 100644 --- a/src/components/leaflet-marker.ts +++ b/src/components/leaflet-marker.ts @@ -1,6 +1,6 @@ import { Icon, Marker } from 'leaflet'; import { defineProps, num, str, on } from '../core/props.ts'; -import { registerChildren, unregisterChildren } from '../core/register.ts'; +import { LeafletIconChangedEvent, registerChildren, unregisterChildren } from '../core/register.ts'; import { WithProps, buildAttrMap, buildOptions, numAttr, setLayerAttr } from '../core/utils.ts'; const PROPS = defineProps({ @@ -27,10 +27,9 @@ export class LeafletMarker extends WithProps(HTMLElement, PROPS) { this.#obj.on('dragend move', this.#onChange, this); registerChildren(this, this.#obj); this.addEventListener('icon-changed', this.#onIconChanged); - const iconEl = this.querySelector(':scope > leaflet-icon') as unknown as { - leafletObject: Icon; - } | null; - if (iconEl?.leafletObject) this.#obj.setIcon(iconEl.leafletObject); + for (const child of this.children) { + child.dispatchEvent(new CustomEvent('leaflet-request-icon')); + } } disconnectedCallback() { @@ -72,10 +71,9 @@ export class LeafletMarker extends WithProps(HTMLElement, PROPS) { this.#syncing = false; } - #onIconChanged(e: Event) { + #onIconChanged(e: LeafletIconChangedEvent) { if (!this.#obj) return; - const detail = (e as CustomEvent).detail; - if (detail?.icon) this.#obj.setIcon(detail.icon); + if (e.detail.icon) this.#obj.setIcon(e.detail.icon); else this.#obj.setIcon(new Icon.Default()); } } diff --git a/src/core/register.ts b/src/core/register.ts index 4f3e340..68cb5a1 100644 --- a/src/core/register.ts +++ b/src/core/register.ts @@ -1,4 +1,4 @@ -import { Layer, LayerGroup, Popup, Tooltip } from 'leaflet'; +import { Icon, Layer, LayerGroup, Popup, Tooltip } from 'leaflet'; import { registerWithParent } from './utils.ts'; // Custom event type for the bubbling registration protocol. Carries the @@ -11,14 +11,29 @@ export type LeafletRegisterEvent = CustomEvent<{ export type LeafletLayerEvent = CustomEvent<{ layer: Layer }>; +export type LeafletIconChangedEvent = CustomEvent<{ icon: Icon | null }>; + +export type LeafletRequestIconEvent = CustomEvent>; + declare global { interface HTMLElementEventMap { 'leaflet-register': LeafletRegisterEvent; 'leaflet-add-layer': LeafletLayerEvent; 'leaflet-remove-layer': LeafletLayerEvent; + 'icon-changed': LeafletIconChangedEvent; + 'leaflet-request-icon': LeafletRequestIconEvent; } } +export function emitIconChanged(el: HTMLElement, icon: Icon | null | undefined) { + el.dispatchEvent( + new CustomEvent('icon-changed', { + bubbles: true, + detail: { icon: icon ?? null }, + }), + ); +} + // Tracks whether a child registered as a plain layer, popup, or tooltip -- // used by the controls panel and by cleanup logic. type ChildEntry = { diff --git a/src/index.ts b/src/index.ts index ba8b88b..d2f6b77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,3 +24,4 @@ export { LeafletControlScale } from './components/leaflet-control-scale.ts'; export { LeafletPopup } from './components/leaflet-popup.ts'; export { LeafletTooltip } from './components/leaflet-tooltip.ts'; export { LeafletIcon } from './components/leaflet-icon.ts'; +export { LeafletDivIcon } from './components/leaflet-div-icon.ts';