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
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="© <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);
|
||||
Loading…
Reference in New Issue