feat: split leaflet-icon into image Icon and DivIcon, event-only marker↔icon handshake

- 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
main
Buddy 3 months ago
parent 0855851504
commit 35190bacc5

@ -300,6 +300,17 @@
></leaflet-icon>
<leaflet-popup>I am a orange leaf.</leaflet-popup>
</leaflet-marker>
<leaflet-marker lat="51.48" lng="-0.095">
<leaflet-div-icon
class-name="my-div-icon"
icon-size="[24, 24]"
icon-anchor="[12, 12]"
>
<div style="width:24px;height:24px;background:#e74c3c;border:2px solid #c0392b;border-radius:50%;display:flex;align-items:center;justify-content:center;color:white;font-weight:bold;font-size:14px;"></div>
</leaflet-div-icon>
<leaflet-popup>I use a DivIcon! Content comes from innerHTML.</leaflet-popup>
</leaflet-marker>
</leaflet-map>

@ -303,6 +303,13 @@
<leaflet-feature-group>
<leaflet-marker lat="51.513" lng="-0.07" title="Feature Group Marker">
<leaflet-div-icon
class-name="my-div-icon"
icon-size="[24, 24]"
icon-anchor="[12, 12]"
>
<div style="width:24px;height:24px;background:#e74c3c;border:2px solid #c0392b;border-radius:50%;display:flex;align-items:center;justify-content:center;color:white;font-weight:bold;font-size:14px;"></div>
</leaflet-div-icon>
<leaflet-tooltip>Part of a feature group</leaflet-tooltip>
</leaflet-marker>
<leaflet-circle lat="51.513" lng="-0.07" radius="100" color="purple" fill-color="purple" fill-opacity="0.2">
@ -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' },

@ -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<string, unknown> = {};
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);

@ -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<string, unknown> = {};
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;
}

@ -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());
}
}

@ -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<Record<string, never>>;
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 = {

@ -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';

Loading…
Cancel
Save