feat: add leaflet-icon component for custom marker icons

New <leaflet-icon> element wraps L.Icon / L.DivIcon and dispatches
icon-changed custom events to parent markers. Marker listens for
the event and queries for existing icon children on connect.

Fix: use kebab-case attribute names (via PROPS table spec.attr) in
applyIcon() instead of camelCase, and guard against empty iconUrl
to avoid L.icon({}) throwing.
main
Buddy 3 months ago
parent c283632262
commit f64b4e70de

@ -0,0 +1,317 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leaflet Components Demo</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 20px;
font-family: sans-serif;
}
.two-pane {
display: flex;
gap: 16px;
margin-bottom: 16px;
height: 600px;
}
.pane {
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pane-header {
padding: 10px 14px;
font-weight: 600;
font-size: 13px;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
}
.pane-body {
flex: 1;
overflow-y: auto;
padding: 10px 14px;
}
#tree-pane {
width: 280px;
min-width: 200px;
}
#editor-pane {
flex: 1;
min-width: 300px;
}
/* ── Tree styles ── */
.tree-node {
margin: 2px 0;
}
.tree-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
font-family: monospace;
font-size: 13px;
}
.tree-row:hover {
background: #e8e8e8;
}
.tree-row.selected {
background: #dbeafe;
font-weight: 600;
}
.tree-row .tag {
color: #2563eb;
}
.tree-children {
margin-left: 18px;
border-left: 1px solid #ddd;
padding-left: 4px;
}
.tree-row .remove-btn {
margin-left: auto;
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
}
.tree-row .remove-btn:hover {
color: #dc2626;
}
.tree-row .add-child-btn {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
margin: 0 0 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tree-row .add-child-btn:hover {
color: #22c55e;
}
.add-child-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 4px 10px;
font-size: 12px;
border: 1px dashed #aaa;
border-radius: 4px;
background: none;
color: #666;
cursor: pointer;
}
.add-child-btn:hover {
border-color: #3b82f6;
color: #2563eb;
}
/* ── Editor styles ── */
.editor-empty {
color: #999;
font-style: italic;
font-size: 13px;
}
.editor-section {
margin-bottom: 14px;
}
.editor-section h4 {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
}
.editor-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.editor-row label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #555;
min-width: 120px;
}
.editor-row label:has(input[type="checkbox"]) {
font-family: monospace;
font-size: 12px;
min-width: auto;
}
.editor-row input[type="number"],
.editor-row input[type="text"] {
flex: 1;
max-width: 200px;
padding: 5px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
}
.editor-row input:focus {
outline: none;
border-color: #66f;
}
.editor-row input[type="checkbox"] {
width: 16px;
height: 16px;
}
.pick-btn {
padding: 4px 8px;
font-size: 11px;
border: 1px solid #22c55e;
border-radius: 4px;
background: #f0fdf4;
color: #16a34a;
cursor: pointer;
white-space: nowrap;
}
.pick-btn:hover {
background: #dcfce7;
}
.pick-btn.picking {
background: #16a34a;
color: white;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ── Add-child modal ── */
.add-child-form {
margin-top: 10px;
padding: 10px;
background: #fafafa;
border: 1px solid #ddd;
border-radius: 6px;
}
.add-child-form h5 {
margin: 0 0 8px;
font-size: 12px;
color: #555;
}
.add-child-form select,
.add-child-form input {
width: 100%;
padding: 5px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
margin-bottom: 6px;
}
.add-child-form .form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.add-child-form button {
padding: 6px 14px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
border: 1px solid #ccc;
background: white;
}
.add-child-form button.primary {
background: #2563eb;
color: white;
border-color: #2563eb;
}
.add-child-form button.primary:hover {
background: #1d4ed8;
}
.add-child-form button.cancel {
background: #f5f5f5;
}
leaflet-map {
height: 500px;
border: 1px solid #ccc;
display: block;
}
</style>
</head>
<body>
<h1>Leaflet Web Components</h1>
<leaflet-map id="map" lat="51.50" lng="-0.09" zoom="13">
<leaflet-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="&copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"
>
</leaflet-tile-layer>
<leaflet-marker lat="51.5" lng="-0.09">
<leaflet-icon
icon-url="https://leafletjs.com/examples/custom-icons/leaf-green.png"
icon-anchor="[22, 94]"
icon-size="[38, 95]"
popup-anchor="[-3, -76]"
shadow-url="https://leafletjs.com/examples/custom-icons/leaf-shadow.png"
shadow-anchor="[4, 62]"
shadow-size="[50, 64]"
></leaflet-icon>
<leaflet-popup>I am a green leaf.</leaflet-popup>
</leaflet-marker>
<leaflet-marker lat="51.495" lng="-0.083">
<leaflet-icon
icon-url="https://leafletjs.com/examples/custom-icons/leaf-red.png"
icon-anchor="[22, 94]"
icon-size="[38, 95]"
popup-anchor="[-3, -76]"
shadow-anchor="[4, 62]"
shadow-size="[50, 64]"
shadow-url="https://leafletjs.com/examples/custom-icons/leaf-shadow.png"
></leaflet-icon>
<leaflet-popup>I am a red leaf.</leaflet-popup>
</leaflet-marker>
<leaflet-marker lat="51.49" lng="-0.1">
<leaflet-icon
icon-url="https://leafletjs.com/examples/custom-icons/leaf-orange.png"
icon-anchor="[22, 94]"
icon-size="[38, 95]"
shadow-url="https://leafletjs.com/examples/custom-icons/leaf-shadow.png"
popup-anchor="[-3, -76]"
shadow-anchor="[4, 62]"
shadow-size="[50, 64]"
></leaflet-icon>
<leaflet-popup>I am a orange leaf.</leaflet-popup>
</leaflet-marker>
</leaflet-map>
<script type="importmap">
{
"imports": {
"leaflet": "/node_modules/leaflet/dist/leaflet-src.esm.js"
}
}
</script>
<script type="module" src="/dist/index.js"></script>
<script type="module">
</script>
</body>
</html>

@ -0,0 +1,87 @@
import { Icon, icon, divIcon, IconOptions, DivIconOptions } from 'leaflet';
import { defineProps, str, on } from '../core/props.ts';
import { WithProps } from '../core/utils.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 JSON_KEYS = new Set([
'iconSize',
'iconAnchor',
'popupAnchor',
'tooltipAnchor',
'shadowSize',
'shadowAnchor',
'bgPos',
]);
export class LeafletIcon extends WithProps(HTMLElement, PROPS) {
#obj?: Icon;
connectedCallback() {
this.#applyIcon();
this.dispatchEvent(
new CustomEvent('icon-changed', {
bubbles: true,
detail: { icon: this.#obj },
}),
);
}
disconnectedCallback() {
this.dispatchEvent(
new CustomEvent('icon-changed', {
bubbles: true,
detail: { icon: null },
}),
);
}
attributeChangedCallback() {
this.#applyIcon();
if (!this.#obj) return;
this.dispatchEvent(
new CustomEvent('icon-changed', {
bubbles: true,
detail: { icon: this.#obj },
}),
);
}
get leafletObject() {
return this.#obj;
}
#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;
} else {
this.#obj = undefined;
}
}
}
customElements.define('leaflet-icon', LeafletIcon);

@ -1,4 +1,4 @@
import { Marker } from 'leaflet'; import { Icon, Marker } from 'leaflet';
import { defineProps, num, str, on } from '../core/props.ts'; import { defineProps, num, str, on } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts'; import { registerChildren, unregisterChildren } from '../core/register.ts';
import { WithProps, buildAttrMap, buildOptions, numAttr, setLayerAttr } from '../core/utils.ts'; import { WithProps, buildAttrMap, buildOptions, numAttr, setLayerAttr } from '../core/utils.ts';
@ -26,10 +26,16 @@ export class LeafletMarker extends WithProps(HTMLElement, PROPS) {
); );
this.#obj.on('dragend move', this.#onChange, this); this.#obj.on('dragend move', this.#onChange, this);
registerChildren(this, this.#obj); 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);
} }
disconnectedCallback() { disconnectedCallback() {
this.#obj?.off('dragend move', this.#onChange, this); this.#obj?.off('dragend move', this.#onChange, this);
this.removeEventListener('icon-changed', this.#onIconChanged);
unregisterChildren(this); unregisterChildren(this);
this.#obj?.remove(); this.#obj?.remove();
this.#obj = undefined; this.#obj = undefined;
@ -65,6 +71,13 @@ export class LeafletMarker extends WithProps(HTMLElement, PROPS) {
this.setAttribute('lng', `${pos.lng}`); this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false; this.#syncing = false;
} }
#onIconChanged(e: Event) {
if (!this.#obj) return;
const detail = (e as CustomEvent).detail;
if (detail?.icon) this.#obj.setIcon(detail.icon);
else this.#obj.setIcon(new Icon.Default());
}
} }
customElements.define('leaflet-marker', LeafletMarker); customElements.define('leaflet-marker', LeafletMarker);

@ -22,3 +22,4 @@ export { LeafletControlAttribution } from './components/leaflet-control-attribut
export { LeafletControlScale } from './components/leaflet-control-scale.ts'; export { LeafletControlScale } from './components/leaflet-control-scale.ts';
export { LeafletPopup } from './components/leaflet-popup.ts'; export { LeafletPopup } from './components/leaflet-popup.ts';
export { LeafletTooltip } from './components/leaflet-tooltip.ts'; export { LeafletTooltip } from './components/leaflet-tooltip.ts';
export { LeafletIcon } from './components/leaflet-icon.ts';

Loading…
Cancel
Save