refactor: complete WithProps mixin, generic event forwarding, and full prop coverage

Finishes the WithProps(PROPS, options) mixin (src/core/with-props.ts): every
component gets reactive attributes, live-value property getters that read
through to the Leaflet object where possible, and every Leaflet event
re-emitted on the element as `leaflet:<type>` via a generic fire() patch (no
per-component or per-event registration needed).

Prop coverage:
- Centralized pathProps in shared-props.ts (stroke, lineCap, lineJoin,
  dashArray, dashOffset, fillRule, interactive, className,
  bubblingMouseEvents, pane), deduped leaflet-geojson against it.
- Added live getters backed by Leaflet's own accessors: radius (circle,
  circle-marker), bounds (rectangle, image/video/svg-overlay), url
  (image/video-overlay).
- Filled gaps: smoothFactor/noClip on polyline; a shared tileLayerProps
  fragment now used by both tile-layer and tile-layer-wms (WMS previously
  exposed none of its base tile options); crs on WMS; zIndex/className/
  keepAspectRatio/errorOverlayUrl on video-overlay; fixed tile-layer's
  zIndex default and div-icon's className default to match Leaflet.

Tooling:
- Removed dead src/core/events.ts (broken, unused, superseded by the
  generic event forwarding above) and src/core/attributes.ts (emptied by
  an earlier rename, nothing imported it).
- Swapped ESLint + @typescript-eslint for oxlint: no released
  @typescript-eslint version supports the pinned typescript@7, even for
  parsing alone. oxlint has its own parser and lints clean.
- Dropped the Rollup CJS/UMD bundle step; dist/ is ESM-only from tsc now.
  Updated package.json's main/module/exports/unpkg accordingly.
- Updated CLAUDE.md to match: build/lint commands, ESM-only output, the
  event-forwarding mechanism, and layer-group/feature-group now going
  through WithProps({}) instead of raw HTMLElement.
main
Buddy 4 weeks ago
parent bdf5bd56f3
commit 14e35846d5

@ -0,0 +1,17 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"pedantic": "warn"
},
"rules": {
"eslint/max-lines": "off",
"eslint/max-lines-per-function": "off"
},
"env": {
"builtin": true
},
"ignorePatterns": ["dist/"]
}

@ -5,12 +5,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands
```bash
npm run build # tsc emits individual ESM modules, then Rollup bundles ESM/CJS/UMD
npm run build # tsc emits individual ESM modules to dist/ (no bundling)
npm run typecheck # tsc --noEmit
npm run lint # ESLint over src/**/*.{ts,js}
npm run lint # oxlint over src/
npm run format # Prettier formatting
```
Linting runs on `oxlint`, not ESLint/`@typescript-eslint`. This project pins `typescript@^7.0.2`, and `@typescript-eslint` has no released version that supports it (peer range caps at `<6.1.0`, and even loading `@typescript-eslint/parser` crashes against TS 7's package shape). `oxlint` has its own parser and doesn't touch the `typescript` package, so it works regardless of TS version — the tradeoff is no type-aware rules (no `no-floating-promises`, `no-unnecessary-condition`, etc.). Revisit once `@typescript-eslint` supports TS 7.
There are no tests in this project. To preview components locally, open `index.html` in a browser with any static-file server.
## Architecture
@ -23,9 +25,9 @@ The `WithProps(Base, PROPS)` mixin factory replaces the old `LeafletElement` bas
- `observedAttributes` getter derived from the PROPS table keys.
- `definePropAccessors` — property getters/setters on the prototype that sync attributes.
- `parseAttributeValue` for boolean, number, and JSON coersion in `attributeChangedCallback`.
- On connect, `initOptions()` builds a Leaflet options object from current attributes + PROPS defaults, then calls `createLeafletObject()`.
- On attribute change, `updateLeafletObject()` by default dispatches to the matching Leaflet setter (e.g. `setOpacity`, `setRadius`). Components override this for custom attribute handling (e.g. lat/lng pairs).
- Every Leaflet event the created object fires is re-emitted on the element as `leaflet:<type>` (e.g. `leaflet:zoomend`, `leaflet:dragend`), carrying the original Leaflet event object as `event.detail`. This is generic and automatic: `WithProps` wraps the object's own `fire()` method, so no per-component or per-event-type registration is needed. Not bubbling — Leaflet already propagates layer events up to the map, so `<leaflet-map>` would otherwise see each one twice.
### `leaflet-map`: `src/components/leaflet-map.ts`
@ -41,14 +43,12 @@ All non-map components extend `WithProps(HTMLElement, PROPS)`. To add a new comp
4. Call `customElements.define('leaflet-foo', LeafletFoo)` at the bottom.
5. Export from `src/index.ts`.
For components that accept children (layers, popups, tooltips), use the `registerChildren`/`unregisterChildren`/`getChildren` WeakMap-backed helpers from `src/core/register.ts` in `connectedCallback`/`disconnectedCallback` instead of maintaining private fields.
### Special cases
- **`leaflet-polygon`** uses `<leaflet-line>` children for vertices. The polygon collects lat/lng from child `leaflet-line` elements rather than having them as direct attributes.
- **`leaflet-popup`** / **`leaflet-tooltip`**: content comes from `innerHTML`, not attributes. `leaflet-popup` watches for DOM mutations to keep Leaflet in sync.
- **`leaflet-layer-group`** / **`leaflet-feature-group`**: passthrough containers extending `HTMLElement` directly (not via `WithProps`). Children register themselves into them via the standard bubble mechanism.
- **`leaflet-layer-group`** / **`leaflet-feature-group`**: passthrough containers built with `WithProps({})` (an empty props table) — they have no options of their own, but still get the standard lifecycle, child registration, and `leaflet:` event forwarding for free. Children register themselves into them via the standard bubble mechanism.
### Output
`tsc` compiles `src/``dist/` as individual ESM modules (`.js` + `.d.ts` + `.d.ts.map`), then `rollup -c` bundles `dist/index.js` (ESM), `dist/index.cjs` (CJS), and `dist/index.umd.js` (UMD) — each with minified variants. Leaflet is always external (never bundled). Imports within source use `.ts` extensions; `rewriteRelativeImportExtensions` in tsconfig strips them to `.js` in the tsc output (`tsconfig.json:16`).
`tsc` compiles `src/``dist/` as individual ESM modules (`.js` + `.d.ts` + `.d.ts.map`) — no bundling step. Consumers import `dist/index.js` (or any individual module) directly; there is no CJS or UMD build. Leaflet is always external (never bundled). Imports within source use `.ts` extensions; `rewriteRelativeImportExtensions` in tsconfig strips them to `.js` in the tsc output (`tsconfig.json:16`).

@ -1,43 +0,0 @@
import { defineConfig } from 'eslint/config';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import prettierPlugin from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
export default defineConfig([
{
ignores: ['dist/'],
},
...tsPlugin.configs['flat/strict-type-checked'].map(c => ({
...c,
files: ['**/*.ts'],
})),
...tsPlugin.configs['flat/stylistic-type-checked'].map(c => ({
...c,
files: ['**/*.ts'],
})),
{
files: ['**/*.ts'],
languageOptions: {
parserOptions: { project: true },
},
plugins: {
prettier: prettierPlugin,
},
rules: {
...prettierConfig.rules,
'prettier/prettier': 'error',
},
},
{
files: ['**/*.ts'],
rules: {
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
},
},
]);

2390
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -2,15 +2,13 @@
"name": "leaflet-components",
"version": "0.1.0",
"description": "LeafletJS as Web Components",
"main": "dist/index.cjs",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"unpkg": "dist/index.umd.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"import": "./dist/index.js"
},
"./dist/*": "./dist/*",
"./package.json": "./package.json"
@ -21,9 +19,9 @@
"README.md"
],
"scripts": {
"build": "rm -rf dist && tsc --outDir dist && rollup -c rollup.config.mjs",
"build": "rm -rf dist && tsc --outDir dist",
"typecheck": "tsc --noEmit",
"lint": "eslint 'src/**/*.{ts,js}'",
"lint": "oxlint src",
"format": "prettier --write 'src/**/*.{ts,js,json,md}'",
"prepublishOnly": "npm run build"
},
@ -39,19 +37,9 @@
"leaflet": "1.9.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"@types/leaflet": "1.9.21",
"@typescript-eslint/eslint-plugin": "8.60.1",
"@typescript-eslint/parser": "8.60.1",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.6",
"oxlint": "^1.79.0",
"prettier": "3.8.3",
"rollup": "^4.61.1",
"rollup-plugin-dts": "^6.4.1",
"tslib": "^2.8.1",
"typescript": "6.0.3"
"typescript": "^7.0.2"
}
}

@ -1,25 +0,0 @@
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
import dts from 'rollup-plugin-dts';
export default [
{
input: 'src/index.ts',
external: ['leaflet'],
plugins: [typescript({ declaration: false, declarationMap: false })],
output: [
{ format: 'es', file: 'dist/index.js' },
{ format: 'es', file: 'dist/index.min.js', plugins: [terser()] },
{ format: 'cjs', file: 'dist/index.cjs' },
{ format: 'cjs', file: 'dist/index.min.cjs', plugins: [terser()] },
{ format: 'umd', name: 'LeafletComponents', file: 'dist/index.umd.js', globals: { leaflet: 'L' } },
{ format: 'umd', name: 'LeafletComponents', file: 'dist/index.umd.min.js', globals: { leaflet: 'L' }, plugins: [terser()] },
],
},
{
input: 'src/index.ts',
external: ['leaflet'],
plugins: [dts()],
output: { format: 'es', file: 'dist/index.d.ts' },
},
];

@ -1,66 +1,17 @@
import { CircleMarker } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { isPathStyleAttr, numAttr, updatePathStyle } from '../core/attributes.ts';
const PROPS = defineProps({
lat: num(),
lng: num(),
radius: num(10),
color: str('#3388ff'),
weight: num(3),
opacity: num(1.0),
fill: on(),
fillColor: str('#3388ff'),
fillOpacity: num(0.2),
});
export class LeafletCircleMarker extends WithProps(HTMLElement, PROPS) {
#obj?: CircleMarker;
#syncing = false;
connectedCallback() {
this.#obj = new CircleMarker(
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
// The `on` method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onMove, this);
registerChildren(this, this.#obj);
}
disconnectedCallback() {
// The `off` method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onMove, this);
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
} else if (name === 'radius') {
this.#obj.setRadius(numAttr(this, PROPS, 'radius'));
} else if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
}
#onMove() {
if (!this.#obj || this.#syncing) return;
this.#syncing = true;
const pos = this.#obj.getLatLng();
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
import { CircleMarker, type CircleMarkerOptions } from 'leaflet';
import { num } from '../core/props.ts';
import { latLngProps, pathProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletCircleMarker extends WithProps({
...latLngProps,
radius: num<CircleMarker>(10, { get: (obj) => obj.getRadius() }),
...pathProps,
}) {
declare readonly leafletObject?: CircleMarker;
createLeafletObject(options: CircleMarkerOptions): CircleMarker {
return new CircleMarker([this.lat, this.lng], options);
}
}

@ -1,66 +1,19 @@
import { Circle } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { isPathStyleAttr, numAttr, updatePathStyle } from '../core/attributes.ts';
const PROPS = defineProps({
lat: num(),
lng: num(),
radius: num(1000),
color: str('#3388ff'),
weight: num(3),
opacity: num(1.0),
fill: on(),
fillColor: str('#3388ff'),
fillOpacity: num(0.2),
});
export class LeafletCircle extends WithProps(HTMLElement, PROPS) {
#obj?: Circle;
#syncing = false;
connectedCallback() {
this.#obj = new Circle(
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
// the on method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onMove, this);
registerChildren(this, this.#obj);
}
disconnectedCallback() {
// the off method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onMove, this);
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
} else if (name === 'radius') {
this.#obj.setRadius(numAttr(this, PROPS, 'radius'));
} else if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
}
#onMove() {
if (!this.#obj || this.#syncing) return;
this.#syncing = true;
const pos = this.#obj.getLatLng();
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
import { Circle, type CircleOptions } from 'leaflet';
import { num, positional } from '../core/props.ts';
import { latLngProps, pathProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletCircle extends WithProps({
...latLngProps,
...pathProps,
// Leaflet has no default radius -- it throws without one -- so unlike every
// other option, this one is always passed, from the default when unset.
radius: positional(num<Circle>(1000, { get: (obj) => obj.getRadius() })),
}) {
declare readonly leafletObject?: Circle;
createLeafletObject(options: CircleOptions): Circle {
return new Circle([this.lat, this.lng], { ...options, radius: this.radius });
}
}

@ -1,31 +1,18 @@
import { Control, ControlPosition } from 'leaflet';
import { WithProps, defineProps, str } from '../core/props.ts';
import { Control, type ControlPosition } from 'leaflet';
import { choice, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import { registerWithParent } from '../core/register.ts';
const PROPS = defineProps({
position: str('bottomright'),
export class LeafletControlAttribution extends WithProps(
{
position: choice<ControlPosition>('bottomright'),
prefix: str(),
});
export class LeafletControlAttribution extends WithProps(HTMLElement, PROPS) {
#obj?: Control.Attribution;
connectedCallback() {
this.#obj = new Control.Attribution({
position: this.getAttribute('position') as ControlPosition | undefined,
prefix: this.getAttribute('prefix') ?? undefined,
});
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
}
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Attribution;
get leafletObject() {
return this.#obj;
createLeafletObject(options: Control.AttributionOptions): Control.Attribution {
return new Control.Attribution(options);
}
}

@ -1,78 +1,81 @@
import { Control, ControlPosition, Layer } from 'leaflet';
import { WithProps, defineProps, str } from '../core/props.ts';
import { LeafletRegisterEvent, registerWithParent } from '../core/register.ts';
import { Control, type ControlPosition, type Layer } from 'leaflet';
import { bool, choice } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import type { LeafletRegisterEvent } from '../core/register.ts';
const PROPS = defineProps({
position: str('topright'),
});
interface ChildLayer {
layer: Layer;
name: string;
base: boolean;
active: boolean;
}
export class LeafletControlLayers extends WithProps(HTMLElement, PROPS) {
#obj?: Control.Layers;
export class LeafletControlLayers extends WithProps(
{
position: choice<ControlPosition>('topright'),
collapsed: bool(true),
autoZIndex: bool(true),
hideSingleBase: bool(),
sortLayers: bool(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Layers;
connectedCallback() {
// Children are read straight off the DOM rather than through registration:
// any that already exist are listed as base layers or overlays by their
// `name`, and `type="base"` / `active` decide how.
createLeafletObject(options: Control.LayersOptions): Control.Layers {
const baseLayers: Record<string, Layer> = {};
const overlays: Record<string, Layer> = {};
const inactiveLayers: Layer[] = [];
for (const child of this.querySelectorAll(':scope > *')) {
const layer = (child as unknown as { leafletObject?: Layer }).leafletObject;
const name = child.getAttribute('name');
if (!name || !layer) continue;
const base = child.getAttribute('type') === 'base';
const active = child.hasAttribute('active');
(base ? baseLayers : overlays)[name] = layer;
if (!active) inactiveLayers.push(layer);
for (const child of this.#childLayers()) {
(child.base ? baseLayers : overlays)[child.name] = child.layer;
}
return new Control.Layers(baseLayers, overlays, options);
}
for (const layer of inactiveLayers) {
connectedCallback(): void {
super.connectedCallback();
for (const child of this.#childLayers()) {
if (child.active) continue;
this.dispatchEvent(
new CustomEvent('leaflet-remove-layer', { bubbles: true, detail: { layer } }),
new CustomEvent('leaflet-remove-layer', { bubbles: true, detail: { layer: child.layer } }),
);
}
this.#obj = new Control.Layers(baseLayers, overlays, {
position: this.getAttribute('position') as ControlPosition | undefined,
collapsed: !this.hasAttribute('collapsed') || this.getAttribute('collapsed') !== 'false',
autoZIndex:
!this.hasAttribute('auto-z-index') || this.getAttribute('auto-z-index') !== 'false',
hideSingleBase: this.hasAttribute('hide-single-base'),
sortLayers: this.hasAttribute('sort-layers'),
});
registerWithParent(this, this.#obj);
this.addEventListener('leaflet-register', this.#onChildRegister);
}
disconnectedCallback() {
disconnectedCallback(): void {
this.removeEventListener('leaflet-register', this.#onChildRegister);
this.#obj?.remove();
this.#obj = undefined;
super.disconnectedCallback();
}
attributeChangedCallback(name: string) {
if (name === 'position' && this.#obj) {
this.#obj.setPosition(this.getAttribute('position') as ControlPosition);
}
#childLayers(): ChildLayer[] {
const children: ChildLayer[] = [];
for (const child of this.querySelectorAll(':scope > *')) {
const layer = (child as { leafletObject?: Layer }).leafletObject;
const name = child.getAttribute('name');
if (!name || !layer) continue;
children.push({
layer,
name,
base: child.getAttribute('type') === 'base',
active: child.hasAttribute('active'),
});
}
get leafletObject() {
return this.#obj;
return children;
}
// Children that connect after us announce themselves instead.
#onChildRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
const el = e.detail.element;
const layer = e.detail.leafletObject;
const name = el.getAttribute('name');
if (!name) return;
const base = el.getAttribute('type') === 'base';
const active = el.hasAttribute('active');
if (base) {
this.#obj?.addBaseLayer(layer, name);
} else {
this.#obj?.addOverlay(layer, name);
}
if (active) {
if (el.getAttribute('type') === 'base') this.leafletObject?.addBaseLayer(layer, name);
else this.leafletObject?.addOverlay(layer, name);
if (el.hasAttribute('active')) {
this.dispatchEvent(
new CustomEvent('leaflet-add-layer', { bubbles: true, detail: { layer } }),
);

@ -1,38 +1,21 @@
import { Control, ControlPosition } from 'leaflet';
import { WithProps, defineProps, num, str, on } from '../core/props.ts';
import { Control, type ControlPosition } from 'leaflet';
import { bool, choice, num } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import { registerWithParent } from '../core/register.ts';
const PROPS = defineProps({
position: str('bottomleft'),
export class LeafletControlScale extends WithProps(
{
position: choice<ControlPosition>('bottomleft'),
maxWidth: num(100),
metric: on(),
imperial: on(),
updateWhenIdle: on(),
});
export class LeafletControlScale extends WithProps(HTMLElement, PROPS) {
#obj?: Control.Scale;
connectedCallback() {
this.#obj = new Control.Scale({
position: this.getAttribute('position') as ControlPosition | undefined,
maxWidth: +(this.getAttribute('max-width') ?? 100),
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);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
}
metric: bool(true),
imperial: bool(true),
updateWhenIdle: bool(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Scale;
get leafletObject() {
return this.#obj;
createLeafletObject(options: Control.ScaleOptions): Control.Scale {
return new Control.Scale(options);
}
}

@ -1,37 +1,23 @@
import { Control, ControlPosition } from 'leaflet';
import { WithProps, defineProps, str } from '../core/props.ts';
import { Control, type ControlPosition } from 'leaflet';
import { choice, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import { registerWithParent } from '../core/register.ts';
const PROPS = defineProps({
position: str('topleft'),
zoomInText: str('+'),
// The button labels default to Leaflet's own markup, which hides the glyph
// from screen readers in favour of the title.
export class LeafletControlZoom extends WithProps(
{
position: choice<ControlPosition>('topleft'),
zoomInText: str('<span aria-hidden="true">+</span>'),
zoomInTitle: str('Zoom in'),
zoomOutText: str('-'),
zoomOutText: str('<span aria-hidden="true"></span>'),
zoomOutTitle: str('Zoom out'),
});
export class LeafletControlZoom extends WithProps(HTMLElement, PROPS) {
#obj?: Control.Zoom;
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);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
}
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Zoom;
get leafletObject() {
return this.#obj;
createLeafletObject(options: Control.ZoomOptions): Control.Zoom {
return new Control.Zoom(options);
}
}

@ -1,76 +1,47 @@
import { DivIcon, DivIconOptions } from 'leaflet';
import { defineProps, str } from '../core/props.ts';
import { DivIcon, type DivIconOptions, type PointExpression } from 'leaflet';
import { json, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts';
const PROPS = defineProps({
iconSize: str(),
iconAnchor: str(),
popupAnchor: str(),
tooltipAnchor: str(),
className: str(),
import { WithProps } from '../core/with-props.ts';
// Like leaflet-icon, rebuilt on every change -- including changes to the
// markup, which is what the icon renders when `html` isn't set.
export class LeafletDivIcon extends WithProps(
{
iconSize: json<PointExpression>([0, 0]),
iconAnchor: json<PointExpression>([0, 0]),
popupAnchor: json<PointExpression>([0, 0]),
tooltipAnchor: json<PointExpression>([0, 0]),
className: str('leaflet-div-icon'),
html: str(),
bgPos: str(),
});
const JSON_KEYS = new Set<keyof typeof PROPS>([
'iconSize',
'iconAnchor',
'popupAnchor',
'tooltipAnchor',
'bgPos',
]);
bgPos: json<PointExpression>([0, 0]),
},
{ attach: 'none', recreate: true },
) {
declare readonly leafletObject?: DivIcon;
export class LeafletDivIcon extends HTMLElement {
#obj?: DivIcon;
#observer?: MutationObserver;
connectedCallback() {
this.#applyIcon();
emitIconChanged(this, this.#obj);
createLeafletObject(options: DivIconOptions): DivIcon {
return new DivIcon(this.innerHTML ? { ...options, html: this.innerHTML } : options);
}
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.#applyIcon();
emitIconChanged(this, this.#obj);
});
this.#observer.observe(this, {
childList: true,
characterData: true,
subtree: true,
this.recreateLeafletObject();
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback() {
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
super.disconnectedCallback();
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: Partial<DivIconOptions> = {};
for (const [key, spec] of Object.entries(PROPS)) {
const v = this.getAttribute(spec.attr);
if (v === null) continue;
const typedKey = key as keyof typeof PROPS;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
opts[typedKey as keyof DivIconOptions] = JSON_KEYS.has(typedKey) ? JSON.parse(v) : v;
}
const content = this.innerHTML;
if (content) opts.html = content;
this.#obj = new DivIcon(opts);
leafletObjectCreated(): void {
emitIconChanged(this, this.leafletObject);
}
}

@ -1,33 +1,12 @@
import { FeatureGroup, Layer } from 'leaflet';
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
import { FeatureGroup } from 'leaflet';
import { WithProps } from '../core/with-props.ts';
import { buildOptions } from '../core/props.ts';
// Like leaflet-layer-group, but its children share events and a bounding box.
export class LeafletFeatureGroup extends WithProps({}) {
declare readonly leafletObject?: FeatureGroup;
export class LeafletFeatureGroup extends HTMLElement {
#obj?: FeatureGroup;
static get observedAttributes() {
return [];
}
connectedCallback() {
this.#obj = new FeatureGroup([], buildOptions(this, {}));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip();
else this.#obj?.removeLayer(el as unknown as Layer);
}
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
createLeafletObject(): FeatureGroup {
return new FeatureGroup([]);
}
}

@ -1,69 +1,26 @@
import { GeoJSON, PathOptions, Layer } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
import { GeoJSON, type PathOptions } from 'leaflet';
import type { GeoJsonObject } from 'geojson';
const PROPS = defineProps({
data: str(),
stroke: str(),
color: str('#3388ff'),
weight: num(3),
opacity: num(1.0),
lineCap: str('round'),
lineJoin: str('round'),
dashArray: str(),
dashOffset: str(),
fill: on(),
fillColor: str('#3388ff'),
fillOpacity: num(0.2),
fillRule: str('evenodd'),
});
const PROP_BY_ATTR = new Map<string, string>(
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
);
export class LeafletGeoJSON extends WithProps(HTMLElement, PROPS) {
#obj?: GeoJSON;
connectedCallback() {
const raw = this.getAttribute('data');
const data = raw ? (JSON.parse(raw) as GeoJsonObject) : null;
const styleOpts = Object.fromEntries(
Object.entries(buildOptions(this, PROPS, ['data'])).filter(([, v]) => v !== ''),
);
this.#obj = new GeoJSON(data, { style: styleOpts as PathOptions });
registerChildren(this, this.#obj);
}
disconnectedCallback() {
for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip();
else this.#obj?.removeLayer(el as unknown as Layer);
}
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'data') {
this.#obj.clearLayers();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (val) this.#obj.addData(JSON.parse(val));
} else {
const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const spec = PROPS[propName as keyof typeof PROPS];
const styleVal = spec.kind === 'bool-on' ? val !== null : val;
this.#obj.setStyle({ [propName]: styleVal } as PathOptions);
}
import { json, positional } from '../core/props.ts';
import { pathProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletGeoJSON extends WithProps({
data: positional(
json<GeoJsonObject | null>(null, {
set(obj: GeoJSON, value) {
obj.clearLayers();
if (value) obj.addData(value);
},
}),
),
...pathProps,
}) {
declare readonly leafletObject?: GeoJSON;
// Every prop but `data` is a style option, and GeoJSON takes those nested
// under `style` so they apply to each feature it builds.
createLeafletObject(options: PathOptions): GeoJSON {
return new GeoJSON(this.data, { style: options });
}
}

@ -1,68 +1,38 @@
import { Icon, IconOptions } from 'leaflet';
import { defineProps, str } from '../core/props.ts';
import { Icon, type IconOptions, type PointExpression } from 'leaflet';
import { json, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts';
import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({
const PROPS = {
iconUrl: str(),
iconRetinaUrl: str(),
iconSize: str(),
iconAnchor: str(),
popupAnchor: str(),
tooltipAnchor: str(),
iconSize: json<PointExpression>([0, 0]),
iconAnchor: json<PointExpression>([0, 0]),
popupAnchor: json<PointExpression>([0, 0]),
tooltipAnchor: json<PointExpression>([0, 0]),
shadowUrl: str(),
shadowRetinaUrl: str(),
shadowSize: str(),
shadowAnchor: str(),
shadowSize: json<PointExpression>([0, 0]),
shadowAnchor: json<PointExpression>([0, 0]),
className: str(),
});
} as const;
const JSON_KEYS = new Set([
'iconSize',
'iconAnchor',
'popupAnchor',
'tooltipAnchor',
'shadowSize',
'shadowAnchor',
]);
// An Icon has no setters, so every attribute change builds a new one and the
// parent marker is told to swap it in.
export class LeafletIcon extends WithProps(PROPS, { attach: 'none', recreate: true }) {
declare readonly leafletObject?: Icon;
export class LeafletIcon extends HTMLElement {
#obj?: Icon;
connectedCallback() {
this.#applyIcon();
emitIconChanged(this, this.#obj);
}
disconnectedCallback() {
emitIconChanged(this, null);
}
static get observedAttributes() {
return Object.values(PROPS).map((s) => s.attr);
createLeafletObject(options: Partial<IconOptions>): Icon | undefined {
return options.iconUrl ? new Icon(options as IconOptions) : undefined;
}
attributeChangedCallback() {
this.#applyIcon();
if (!this.#obj) return;
emitIconChanged(this, this.#obj);
leafletObjectCreated(): void {
emitIconChanged(this, this.leafletObject);
}
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;
}
if (opts.iconUrl) {
this.#obj = new Icon(opts as unknown as IconOptions);
} else {
this.#obj = undefined;
}
disconnectedCallback(): void {
super.disconnectedCallback();
emitIconChanged(this, null);
}
}

@ -1,58 +1,33 @@
import { ImageOverlay, LatLngBounds, LatLngExpression } from 'leaflet';
import type { ImageOverlayOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts';
const PROPS = defineProps({
url: str(),
bounds: str(),
import {
ImageOverlay,
type CrossOrigin,
type ImageOverlayOptions,
type LatLngBoundsExpression,
} from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletImageOverlay extends WithProps({
url: urlProp,
bounds: positional(json<LatLngBoundsExpression, ImageOverlay>([], { get: getBounds })),
opacity: num(1.0),
alt: str(),
interactive: on(),
crossOrigin: str(),
alt: str('', {
set(obj: ImageOverlay, value) {
const el = obj.getElement();
if (el) el.alt = value;
},
}),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
errorOverlayUrl: str(),
zIndex: num(),
className: str(),
});
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletImageOverlay extends WithProps(HTMLElement, PROPS) {
#obj?: ImageOverlay;
connectedCallback() {
const url = this.getAttribute('url') ?? '';
this.#obj = new ImageOverlay(
url,
parseBoundsAttr(this),
buildOptions(this, PROPS, ['url', 'bounds']) as ImageOverlayOptions,
);
registerChildren(this, this.#obj);
}
}) {
declare readonly leafletObject?: ImageOverlay;
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
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(parseBoundsAttr(this) as LatLngExpression[]));
} else if (name === 'alt') {
const el = this.#obj.getElement();
if (el) el.alt = val ?? '';
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
createLeafletObject(options: ImageOverlayOptions): ImageOverlay {
return new ImageOverlay(this.url, this.bounds, options);
}
}

@ -1,33 +1,13 @@
import { LayerGroup, Layer } from 'leaflet';
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
import { LayerGroup } from 'leaflet';
import { WithProps } from '../core/with-props.ts';
import { buildOptions } from '../core/props.ts';
// A passthrough container: it has no options of its own, and children add
// themselves to it through the standard registration bubble.
export class LeafletLayerGroup extends WithProps({}) {
declare readonly leafletObject?: LayerGroup;
export class LeafletLayerGroup extends HTMLElement {
#obj?: LayerGroup;
static get observedAttributes() {
return [];
}
connectedCallback() {
this.#obj = new LayerGroup([], buildOptions(this, {}));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip();
else this.#obj?.removeLayer(el as unknown as Layer);
}
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
createLeafletObject(): LayerGroup {
return new LayerGroup([]);
}
}

@ -1,44 +1,70 @@
import { Icon, Layer, Map as LMap, MapOptions } from 'leaflet';
import { defineProps, num, off, on, type NumProp, type PropDef } from '../core/props.ts';
import { LeafletRegisterEvent } from '../core/register.ts';
import { Icon, Map as LMap, type MapOptions } from 'leaflet';
import { bool, disabled, num, positional, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts';
const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
const CSS_ATTRS = ['css-url', 'css-integrity', 'css-crossorigin'] as const;
interface CssHost extends HTMLElement {
applyCss(): void;
}
// The css-* attributes describe the stylesheet in the shadow root rather than
// anything about the Leaflet map, so a change just re-links it.
function relinkCss(_map: LMap, _value: string, el: HTMLElement): void {
(el as CssHost).applyCss();
}
const PROPS = defineProps({
// View state — excluded from #buildOptions, initialised via setView()
lat: num(0, {
viewState: true,
// The root of the component tree. Every other component bubbles a
// `leaflet-register` event up to here, which is where it stops.
export class LeafletMap extends WithProps(
{
// View state. Not constructor options -- the map is positioned with setView
// once it exists -- and written back whenever the user pans or zooms.
lat: positional(
num<LMap>(0, {
event: 'moveend',
mapGet: (m: LMap) => m.getCenter().lat,
mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter().lng], m.getZoom()),
get: (map) => map.getCenter().lat,
set: (map, value) => {
map.setView([value, map.getCenter().lng], map.getZoom());
},
}),
lng: num(0, {
viewState: true,
),
lng: positional(
num<LMap>(0, {
event: 'moveend',
mapGet: (m: LMap) => m.getCenter().lng,
mapSet: (m: LMap, v: number) => m.setView([m.getCenter().lat, v], m.getZoom()),
get: (map) => map.getCenter().lng,
set: (map, value) => {
map.setView([map.getCenter().lat, value], map.getZoom());
},
}),
zoom: num(2, {
viewState: true,
),
zoom: positional(
num<LMap>(2, {
event: 'zoomend',
mapGet: (m: LMap) => m.getZoom(),
mapSet: (m: LMap, v: number) => m.setZoom(v),
get: (map) => map.getZoom(),
set: (map, value) => {
map.setZoom(value);
},
}),
),
// Live numeric options — have Leaflet setters
minZoom: num(0, {
mapGet: (m: LMap) => m.getMinZoom(),
mapSet: (m: LMap, v: number) => m.setMinZoom(v),
// Numeric options Leaflet lets us change after construction.
minZoom: num<LMap>(0, {
get: (map) => map.getMinZoom(),
set: (map, value) => {
map.setMinZoom(value);
},
}),
maxZoom: num(Infinity, {
mapGet: (m: LMap) => m.getMaxZoom(),
mapSet: (m: LMap, v: number) => m.setMaxZoom(v),
maxZoom: num<LMap>(Infinity, {
get: (map) => map.getMaxZoom(),
set: (map, value) => {
map.setMaxZoom(value);
},
}),
// Constructor-only numeric options
// Constructor-only numeric options.
zoomSnap: num(1),
zoomDelta: num(1),
keyboardPanDelta: num(80),
@ -52,208 +78,95 @@ const PROPS = defineProps({
zoomAnimationThreshold: num(4),
transform3DLimit: num(8388608),
// Boolean defaults-true → disable-* attribute; handler-based options have live mapSet
scrollWheelZoom: off((m: LMap, v: boolean) =>
v ? m.scrollWheelZoom.enable() : m.scrollWheelZoom.disable(),
),
dragging: off((m: LMap, v: boolean) => (v ? m.dragging.enable() : m.dragging.disable())),
touchZoom: off((m: LMap, v: boolean) => (v ? m.touchZoom.enable() : m.touchZoom.disable())),
doubleClickZoom: off((m: LMap, v: boolean) =>
v ? m.doubleClickZoom.enable() : m.doubleClickZoom.disable(),
),
boxZoom: off((m: LMap, v: boolean) => (v ? m.boxZoom.enable() : m.boxZoom.disable())),
keyboard: off((m: LMap, v: boolean) => (v ? m.keyboard.enable() : m.keyboard.disable())),
closePopupOnClick: off(),
trackResize: off(),
zoomControl: off(),
attributionControl: off(),
inertia: off(),
zoomAnimation: off(),
fadeAnimation: off(),
markerZoomAnimation: off(),
bounceAtZoomLimits: off(),
tapHold: off(),
// Boolean defaults-false → normal attribute
preferCanvas: on(),
worldCopyJump: on(),
});
type PropName = keyof typeof PROPS;
const ATTR_TO_PROP = new Map<string, PropName>(
(Object.entries(PROPS) as [PropName, PropDef][]).map(([name, spec]) => [spec.attr, name]),
);
// Derive property types directly from the PROPS table so adding a prop to the
// table automatically makes it part of the LeafletMap instance type.
type PropTypes = {
[K in keyof typeof PROPS]: (typeof PROPS)[K] extends { kind: 'num' } ? number : boolean;
};
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
// Options Leaflet defaults to true, turned off with disable-* attributes.
// The interaction handlers can be toggled after construction.
scrollWheelZoom: disabled<LMap>({
set: (map, on) => (on ? map.scrollWheelZoom.enable() : map.scrollWheelZoom.disable()),
}),
dragging: disabled<LMap>({
set: (map, on) => (on ? map.dragging.enable() : map.dragging.disable()),
}),
touchZoom: disabled<LMap>({
set: (map, on) => (on ? map.touchZoom.enable() : map.touchZoom.disable()),
}),
doubleClickZoom: disabled<LMap>({
set: (map, on) => (on ? map.doubleClickZoom.enable() : map.doubleClickZoom.disable()),
}),
boxZoom: disabled<LMap>({
set: (map, on) => (on ? map.boxZoom.enable() : map.boxZoom.disable()),
}),
keyboard: disabled<LMap>({
set: (map, on) => (on ? map.keyboard.enable() : map.keyboard.disable()),
}),
closePopupOnClick: disabled(),
trackResize: disabled(),
zoomControl: disabled(),
attributionControl: disabled(),
inertia: disabled(),
zoomAnimation: disabled(),
fadeAnimation: disabled(),
markerZoomAnimation: disabled(),
bounceAtZoomLimits: disabled(),
tapHold: disabled(),
// Options Leaflet defaults to false.
preferCanvas: bool(),
worldCopyJump: bool(),
// Not Leaflet options at all -- see relinkCss above.
cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })),
cssCrossorigin: positional(str('', { set: relinkCss })),
},
{ attach: 'none' },
) {
declare readonly leafletObject?: LMap;
export class LeafletMap extends TypedBase {
#map?: LMap;
#container!: HTMLDivElement;
#container?: HTMLDivElement;
#cssLink?: HTMLLinkElement;
#syncing = false;
#mapEventHandlers = new Map<string, () => void>();
#resizeObserver?: ResizeObserver;
static get observedAttributes(): string[] {
return [...Object.values(PROPS).map((s) => s.attr), ...CSS_ATTRS];
createLeafletObject(options: MapOptions): LMap {
const map = new LMap(this.#container ?? this.#buildShadowRoot(), options);
// getCenter() and getZoom() throw until the view is set. Reading this.lat,
// this.lng and this.zoom here is safe for the same reason: the object
// doesn't exist yet, so the getters fall back to the attributes.
map.setView([this.lat, this.lng], this.zoom);
return map;
}
// Generates a getter/setter on the prototype for every entry in PROPS.
// The functions are defined inside the class body so they can access private fields.
static {
for (const [propName, spec] of Object.entries(PROPS) as [PropName, PropDef][]) {
if (spec.kind === 'num') {
Object.defineProperty(LeafletMap.prototype, propName, {
get(this: LeafletMap) {
const val = spec.mapGet && this.#map ? spec.mapGet(this.#map) : undefined;
return val ?? +(this.getAttribute(spec.attr) ?? spec.default);
},
set(this: LeafletMap, v: number) {
if (spec.event) {
this.#syncAttr(spec.attr, `${v}`);
} else {
this.setAttribute(spec.attr, `${v}`);
}
},
configurable: true,
enumerable: true,
});
} else if (spec.kind === 'bool-off') {
Object.defineProperty(LeafletMap.prototype, propName, {
get(this: LeafletMap) {
return !this.hasAttribute(spec.attr);
},
set(this: LeafletMap, v: boolean) {
this.toggleAttribute(spec.attr, !v);
},
configurable: true,
enumerable: true,
});
} else {
Object.defineProperty(LeafletMap.prototype, propName, {
get(this: LeafletMap) {
return this.hasAttribute(spec.attr);
},
set(this: LeafletMap, v: boolean) {
this.toggleAttribute(spec.attr, v);
},
configurable: true,
enumerable: true,
});
}
}
}
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.#container = document.createElement('div');
this.#container.style.width = '100%';
this.#container.style.height = '100%';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.shadowRoot!.appendChild(this.#container);
const style = document.createElement('style');
style.textContent = ':host { display: block; width: 100%; height: 400px; }';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.shadowRoot!.appendChild(style);
}
this.#applyCss();
// Read view-state from attributes directly — the getters call getCenter()/getZoom()
// which throw if invoked before setView(), so we can't use them here.
const lat = +(this.getAttribute('lat') ?? 0);
const lng = +(this.getAttribute('lng') ?? 0);
const zoom = +(this.getAttribute('zoom') ?? 2);
connectedCallback(): void {
this.#buildShadowRoot();
this.applyCss();
super.connectedCallback();
// Assign #map only after setView so the getters' `this.#map` guard is
// equivalent to "map is ready" — getCenter()/getZoom() throw before setView.
const map = new LMap(this.#container, this.#buildOptions());
map.setView([lat, lng], zoom);
this.#map = map;
this.#resizeObserver = new ResizeObserver(() => this.#map?.invalidateSize());
this.#resizeObserver = new ResizeObserver(() => this.leafletObject?.invalidateSize());
this.#resizeObserver.observe(this);
// Register one handler per unique map event, updating all props that share it
const groups = new Map<string, NumProp[]>();
for (const spec of Object.values(PROPS) as PropDef[]) {
if (spec.kind === 'num' && spec.event && spec.mapGet) {
const g = groups.get(spec.event) ?? [];
g.push(spec);
groups.set(spec.event, g);
}
}
for (const [event, specs] of groups) {
const handler = () => {
for (const s of specs) {
if (this.#map && s.mapGet) {
const v = s.mapGet(this.#map);
if (v !== undefined) this.#syncAttr(s.attr, `${v}`);
}
}
};
this.#mapEventHandlers.set(event, handler);
this.#map.on(event, handler);
}
this.addEventListener(
'leaflet-register',
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
);
this.addEventListener('leaflet-register', this.#onRegister);
this.addEventListener('leaflet-add-layer', this.#onAddLayer);
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
}
disconnectedCallback() {
disconnectedCallback(): void {
this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined;
this.removeEventListener(
'leaflet-register',
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
);
this.removeEventListener('leaflet-register', this.#onRegister);
this.removeEventListener('leaflet-add-layer', this.#onAddLayer);
this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer);
if (!this.#map) return;
for (const [event, handler] of this.#mapEventHandlers) {
this.#map.off(event, handler);
}
this.#mapEventHandlers.clear();
this.#map.remove();
this.#map = undefined;
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (oldValue === newValue || !this.#map || this.#syncing) return;
if (CSS_ATTRS.includes(name as (typeof CSS_ATTRS)[number])) {
this.#applyCss();
return;
}
const propName = ATTR_TO_PROP.get(name);
if (!propName) return;
const spec = PROPS[propName] as PropDef;
if (spec.kind === 'num') {
if (spec.mapSet && newValue !== null) spec.mapSet(this.#map, +newValue);
} else if (spec.kind === 'bool-off') {
spec.mapSet?.(this.#map, newValue === null);
}
// skip spec.kind === 'bool-on': constructor-only, no live update
super.disconnectedCallback();
}
#applyCss() {
const sr = this.shadowRoot;
if (!sr) return;
// Replaces the <link> in the shadow root, and derives the marker icon path
// from the same URL. Called on connect and whenever a css-* attribute
// changes. Absence and emptiness mean different things here, so this reads
// the attributes rather than the properties.
applyCss(): void {
const root = this.shadowRoot;
if (!root) return;
if (this.#cssLink) {
this.#cssLink.remove();
this.#cssLink?.remove();
this.#cssLink = undefined;
}
const customUrl = this.hasAttribute('css-url');
const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL;
@ -267,8 +180,7 @@ export class LeafletMap extends TypedBase {
let crossorigin: string | undefined;
if (this.hasAttribute('css-crossorigin')) {
const val = this.getAttribute('css-crossorigin');
crossorigin = val ?? undefined;
crossorigin = this.getAttribute('css-crossorigin') ?? undefined;
} else if (integrity) {
crossorigin = 'anonymous';
}
@ -278,54 +190,42 @@ export class LeafletMap extends TypedBase {
link.href = url ?? '';
if (integrity) link.setAttribute('integrity', integrity);
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
sr.appendChild(link);
root.append(link);
this.#cssLink = link;
// Derive marker icon path from the CSS URL (replaces leaflet.css → images/)
Icon.Default.imagePath = url?.replace(/\/[^/]+$/, '/images/');
Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
}
#syncAttr(name: string, value: string) {
if (this.#syncing || this.getAttribute(name) === value) return;
this.#syncing = true;
this.setAttribute(name, value);
this.#syncing = false;
}
#buildShadowRoot(): HTMLDivElement {
if (this.#container) return this.#container;
#buildOptions(): MapOptions {
const o: Record<string, unknown> = {};
for (const [propName, spec] of Object.entries(PROPS) as [PropName, PropDef][]) {
if (spec.kind === 'num') {
if (spec.viewState) continue;
const v = this.getAttribute(spec.attr);
if (v !== null) o[propName] = +v;
} else if (spec.kind === 'bool-off') {
if (this.hasAttribute(spec.attr)) o[propName] = false;
} else {
if (this.hasAttribute(spec.attr)) o[propName] = true;
}
}
return o;
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
const container = document.createElement('div');
container.style.width = '100%';
container.style.height = '100%';
root.append(container);
const style = document.createElement('style');
style.textContent = ':host { display: block; width: 100%; height: 400px; }';
root.append(style);
this.#container = container;
return container;
}
#handleLeafletRegister = (e: LeafletRegisterEvent) => {
#onRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
if (this.#map) {
e.detail.leafletObject.addTo(this.#map);
}
const map = this.leafletObject;
if (map) e.detail.leafletObject.addTo(map);
};
#onAddLayer = (e: Event) => {
this.#map?.addLayer((e as CustomEvent<{ layer: Layer }>).detail.layer);
#onAddLayer = (e: LeafletLayerEvent) => {
this.leafletObject?.addLayer(e.detail.layer);
};
#onRemoveLayer = (e: Event) => {
this.#map?.removeLayer((e as CustomEvent<{ layer: Layer }>).detail.layer);
#onRemoveLayer = (e: LeafletLayerEvent) => {
this.leafletObject?.removeLayer(e.detail.layer);
};
get leafletObject() {
return this.#map;
}
}
customElements.define('leaflet-map', LeafletMap);

@ -1,79 +1,54 @@
import { Icon, Marker } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { LeafletIconChangedEvent, registerChildren, unregisterChildren } from '../core/register.ts';
import { buildAttrMap, numAttr, setLayerAttr } from '../core/attributes.ts';
const PROPS = defineProps({
lat: num(),
lng: num(),
title: str(),
alt: str(),
draggable: on(),
import { Icon, Marker, type MarkerOptions } from 'leaflet';
import { bool, num, str } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
import type { LeafletIconChangedEvent } from '../core/register.ts';
const PROPS = {
...latLngProps,
// title and alt end up on the <img> Leaflet renders, so live updates go there.
title: str('', {
set: (obj: Marker, value) => {
const el = obj.getElement();
if (el) el.title = value;
},
}),
alt: str('', {
set: (obj: Marker, value) => {
const el = obj.getElement() as HTMLImageElement | undefined;
if (el) el.alt = value;
},
}),
draggable: bool(false, {
set(obj: Marker, value) {
if (value) obj.dragging?.enable();
else obj.dragging?.disable();
},
}),
opacity: num(1.0),
zIndexOffset: num(),
});
} as const;
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletMarker extends WithProps(PROPS) {
declare readonly leafletObject?: Marker;
export class LeafletMarker extends WithProps(HTMLElement, PROPS) {
#obj?: Marker;
#syncing = false;
createLeafletObject(options: MarkerOptions): Marker {
return new Marker([this.lat, this.lng], options);
}
connectedCallback() {
this.#obj = new Marker(
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('dragend move', this.#onChange, this);
registerChildren(this, this.#obj);
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('icon-changed', this.#onIconChanged);
}
disconnectedCallback() {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('dragend move', this.#onChange, this);
disconnectedCallback(): void {
this.removeEventListener('icon-changed', this.#onIconChanged);
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
} else if (name === 'draggable') {
if (val !== null) this.#obj.dragging?.enable();
else this.#obj.dragging?.disable();
} else if (name === 'title') {
const el = this.#obj.getElement();
if (el) (el as HTMLImageElement).title = val ?? '';
} else if (name === 'alt') {
const el = this.#obj.getElement();
if (el) (el as HTMLImageElement).alt = val ?? '';
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
}
#onChange() {
if (!this.#obj || this.#syncing) return;
this.#syncing = true;
const pos = this.#obj.getLatLng();
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
super.disconnectedCallback();
}
// A <leaflet-icon> or <leaflet-div-icon> child announces itself this way.
#onIconChanged = (e: LeafletIconChangedEvent) => {
if (!this.#obj) return;
if (e.detail.icon) this.#obj.setIcon(e.detail.icon);
else this.#obj.setIcon(new Icon.Default());
this.leafletObject?.setIcon(e.detail.icon ?? new Icon.Default());
};
}

@ -1,59 +1,39 @@
import { Polygon } from 'leaflet';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { isPathStyleAttr, updatePathStyle } from '../core/attributes.ts';
import { Polygon, type PolylineOptions } from 'leaflet';
import { pathProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
import type { LeafletLine } from './leaflet-line.ts';
const PROPS = defineProps({
color: str('#3388ff'),
weight: num(3),
opacity: num(1.0),
fill: on(),
fillColor: str('#3388ff'),
fillOpacity: num(0.2),
});
export class LeafletPolygon extends WithProps(HTMLElement, PROPS) {
#obj?: Polygon;
export class LeafletPolygon extends WithProps({ ...pathProps }) {
declare readonly leafletObject?: Polygon;
#observer?: MutationObserver;
connectedCallback() {
this.#obj = new Polygon(this.#getCoords(), buildOptions(this, PROPS));
registerChildren(this, this.#obj);
createLeafletObject(options: PolylineOptions): Polygon {
return new Polygon(this.#coords(), options);
}
// Vertices come from <leaflet-line> children rather than an attribute, so we
// re-read them whenever one is added, removed or moved.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(() => {
this.#syncCoords();
});
this.#observer = new MutationObserver(this.#syncCoords);
this.#observer.observe(this, { childList: true });
}
disconnectedCallback() {
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
this.removeEventListener('line-updated', this.#syncCoords);
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
super.disconnectedCallback();
}
#syncCoords = () => {
this.#obj?.setLatLngs(this.#getCoords());
this.leafletObject?.setLatLngs(this.#coords());
};
#getCoords(): [number, number][] {
const lines: LeafletLine[] = Array.from(this.querySelectorAll('leaflet-line'));
#coords(): [number, number][] {
const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
return lines.map((line) => line.latlng);
}
}

@ -1,58 +1,45 @@
import { Polyline } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { isPathStyleAttr, updatePathStyle } from '../core/attributes.ts';
import { Polyline, type PolylineOptions } from 'leaflet';
import { bool, num } from '../core/props.ts';
import { pathProps, style } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
import type { LeafletLine } from './leaflet-line.ts';
const PROPS = defineProps({
color: str('#3388ff'),
weight: num(3),
opacity: num(1.0),
fill: on(),
fillColor: str('#3388ff'),
fillOpacity: num(0.2),
});
export class LeafletPolyline extends WithProps(HTMLElement, PROPS) {
#obj?: Polyline;
export class LeafletPolyline extends WithProps({
...pathProps,
// Unlike closed shapes, a polyline is unfilled by default.
fill: bool(false, { set: style('fill') }),
smoothFactor: num(1.0),
noClip: bool(),
}) {
declare readonly leafletObject?: Polyline;
#observer?: MutationObserver;
connectedCallback() {
this.#obj = new Polyline(this.#getCoords(), buildOptions(this, PROPS));
registerChildren(this, this.#obj);
createLeafletObject(options: PolylineOptions): Polyline {
return new Polyline(this.#coords(), options);
}
// Vertices come from <leaflet-line> children rather than an attribute, so we
// re-read them whenever one is added, removed or moved.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(() => {
this.#syncCoords();
});
this.#observer = new MutationObserver(this.#syncCoords);
this.#observer.observe(this, { childList: true });
}
disconnectedCallback() {
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
this.removeEventListener('line-updated', this.#syncCoords);
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
super.disconnectedCallback();
}
#syncCoords = () => {
this.#obj?.setLatLngs(this.#getCoords());
this.leafletObject?.setLatLngs(this.#coords());
};
#getCoords(): [number, number][] {
#coords(): [number, number][] {
const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
return lines.map((line) => line.latlng);
}

@ -1,74 +1,46 @@
import { Popup } from 'leaflet';
import { WithProps, defineProps, num, on, buildOptions } from '../core/props.ts';
import { numAttr } from '../core/attributes.ts';
import { registerWithParent } from '../core/register.ts';
const PROPS = defineProps({
lat: num(),
lng: num(),
import { Popup, type PopupOptions } from 'leaflet';
import { bool, num } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletPopup extends WithProps(
{
...latLngProps,
maxWidth: num(300),
minWidth: num(50),
maxHeight: num(),
autoPan: on(),
closeButton: on(),
autoClose: on(),
});
autoPan: bool(true),
closeButton: bool(true),
autoClose: bool(true),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Popup;
export class LeafletPopup extends WithProps(HTMLElement, PROPS) {
#obj?: Popup;
#observer?: MutationObserver;
#syncing = false;
connectedCallback() {
this.#obj = new Popup({
...buildOptions(this, PROPS, ['lat', 'lng']),
content: this.innerHTML,
});
// Content is the element's markup, not an attribute. A popup only carries a
// position of its own when it isn't bound to a parent layer.
createLeafletObject(options: PopupOptions): Popup {
const popup = new Popup({ ...options, content: this.innerHTML });
if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
popup.setLatLng([this.lat, this.lng]);
}
return popup;
}
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onChange, this);
registerWithParent(this, this.#obj);
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.#obj?.setContent(this.innerHTML);
});
this.#observer.observe(this, {
childList: true,
characterData: true,
subtree: true,
this.leafletObject?.setContent(this.innerHTML);
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback() {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onChange, this);
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
}
}
#onChange() {
if (!this.#obj || this.#syncing) return;
const pos = this.#obj.getLatLng();
if (!pos) return;
this.#syncing = true;
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
super.disconnectedCallback();
}
}

@ -1,43 +1,16 @@
import { Rectangle } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { isPathStyleAttr, parseBoundsAttr, updatePathStyle } from '../core/attributes.ts';
import { Rectangle, type LatLngBoundsExpression, type PolylineOptions } from 'leaflet';
import { json, positional } from '../core/props.ts';
import { pathProps, getBounds } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({
bounds: str(),
color: str('#3388ff'),
weight: num(3),
opacity: num(1.0),
fill: on(),
fillColor: str('#3388ff'),
fillOpacity: num(0.2),
});
export class LeafletRectangle extends WithProps({
bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })),
...pathProps,
}) {
declare readonly leafletObject?: Rectangle;
export class LeafletRectangle extends WithProps(HTMLElement, PROPS) {
#obj?: Rectangle;
connectedCallback() {
this.#obj = new Rectangle(parseBoundsAttr(this), buildOptions(this, PROPS, ['bounds']));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'bounds') {
this.#obj.setBounds(parseBoundsAttr(this));
} else if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
createLeafletObject(options: PolylineOptions): Rectangle {
return new Rectangle(this.bounds, options);
}
}

@ -1,52 +1,27 @@
import { SVGOverlay, LatLngBounds, LatLngExpression } from 'leaflet';
import type { ImageOverlayOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts';
const PROPS = defineProps({
bounds: str(),
import {
SVGOverlay,
type CrossOrigin,
type ImageOverlayOptions,
type LatLngBoundsExpression,
} from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletSVGOverlay extends WithProps({
bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })),
opacity: num(1.0),
interactive: on(),
crossOrigin: str(),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
zIndex: num(),
className: str(),
});
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletSVGOverlay extends WithProps(HTMLElement, PROPS) {
#obj?: SVGOverlay;
connectedCallback() {
const svg = this.querySelector('svg');
const dummy = !svg ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') : undefined;
this.#obj = new SVGOverlay(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
svg ?? dummy!,
parseBoundsAttr(this),
buildOptions(this, PROPS, ['bounds']) as ImageOverlayOptions,
);
registerChildren(this, this.#obj);
}
}) {
declare readonly leafletObject?: SVGOverlay;
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'bounds') {
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
createLeafletObject(options: ImageOverlayOptions): SVGOverlay {
const svg =
this.querySelector('svg') ?? document.createElementNS('http://www.w3.org/2000/svg', 'svg');
return new SVGOverlay(svg, this.bounds, options);
}
}

@ -1,54 +1,51 @@
import { TileLayer } from 'leaflet';
import { WithProps, defineProps, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { buildAttrMap, parseAttributeValue, setLayerAttr } from '../core/attributes.ts';
const PROPS = defineProps({
url: str(),
layers: str(),
styles: str(),
format: str('image/jpeg'),
transparent: on(),
version: str('1.1.1'),
uppercase: on(),
});
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletTileLayerWMS extends WithProps(HTMLElement, PROPS) {
#obj?: TileLayer.WMS;
connectedCallback() {
const url = this.getAttribute('url') ?? '';
this.#obj = new TileLayer.WMS(url, buildOptions(this, PROPS, ['url']));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
import { CRS, TileLayer, type WMSOptions, type WMSParams } from 'leaflet';
import { bool, str, type PropDef } from '../core/props.ts';
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
// WMS request parameters have no individual setters -- they're merged into the
// query string through setParams().
function param<T>(key: keyof WMSParams): (obj: TileLayer.WMS, value: T) => void {
return (obj, value) => {
obj.setParams({ [key]: value } as unknown as WMSParams);
};
}
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 spec = PROPS[propName];
if (!setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val)) {
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val);
(this.#obj.setParams as unknown as (params: Record<string, unknown>) => void)({
[propName]: value,
});
}
}
// `crs` takes a CRS instance, not a primitive, so it's looked up by name from
// Leaflet's built-in set rather than decoded like a plain value. Constructor-
// only, like the rest of tileLayerProps -- Leaflet has no setter for it.
const NAMED_CRS = {
EPSG3857: CRS.EPSG3857,
EPSG4326: CRS.EPSG4326,
EPSG3395: CRS.EPSG3395,
Simple: CRS.Simple,
};
type CrsName = keyof typeof NAMED_CRS;
const crsProp: PropDef<CRS> = {
default: CRS.EPSG3857,
decode: (raw) => NAMED_CRS[raw as CrsName] ?? CRS.EPSG3857,
encode: (value) =>
value === CRS.EPSG3857
? null
: ((Object.keys(NAMED_CRS) as CrsName[]).find((name) => NAMED_CRS[name] === value) ?? null),
};
export class LeafletTileLayerWMS extends WithProps({
url: urlProp,
...tileLayerProps,
layers: str('', { set: param('layers') }),
styles: str('', { set: param('styles') }),
format: str('image/jpeg', { set: param('format') }),
transparent: bool(false, { set: param('transparent') }),
version: str('1.1.1', { set: param('version') }),
uppercase: bool(),
crs: crsProp,
}) {
declare readonly leafletObject?: TileLayer.WMS;
createLeafletObject(options: WMSOptions): TileLayer.WMS {
return new TileLayer.WMS(this.url, options);
}
}

@ -1,45 +1,15 @@
import { TileLayer } from 'leaflet';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { WithProps, defineProps, num, str, buildOptions } from '../core/props.ts';
import { buildAttrMap, setLayerAttr } from '../core/attributes.ts';
const PROPS = defineProps({
url: str(),
attribution: str(),
minZoom: num(),
maxZoom: num(18),
opacity: num(1.0),
zIndex: num(),
});
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletTileLayer extends WithProps(HTMLElement, PROPS) {
#obj?: TileLayer;
connectedCallback() {
const url = this.getAttribute('url') ?? '';
this.#obj = new TileLayer(url, buildOptions(this, PROPS, ['url']));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'url') {
if (val) this.#obj.setUrl(val);
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
import { TileLayer, type TileLayerOptions } from 'leaflet';
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletTileLayer extends WithProps({
url: urlProp,
...tileLayerProps,
}) {
declare readonly leafletObject?: TileLayer;
createLeafletObject(options: TileLayerOptions): TileLayer {
return new TileLayer(this.url, options);
}
}

@ -1,75 +1,46 @@
import { Tooltip } from 'leaflet';
import type { TooltipOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { numAttr } from '../core/attributes.ts';
import { registerWithParent } from '../core/register.ts';
const PROPS = defineProps({
lat: num(),
lng: num(),
import { Tooltip, type Direction, type PointExpression, type TooltipOptions } from 'leaflet';
import { bool, choice, json, num, str } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletTooltip extends WithProps(
{
...latLngProps,
pane: str(),
offset: str(),
direction: str('auto'),
permanent: on(),
sticky: on(),
offset: json<PointExpression>([0, 0]),
direction: choice<Direction>('auto'),
permanent: bool(),
sticky: bool(),
opacity: num(1.0),
});
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Tooltip;
export class LeafletTooltip extends WithProps(HTMLElement, PROPS) {
#obj?: Tooltip;
#observer?: MutationObserver;
#syncing = false;
connectedCallback() {
this.#obj = new Tooltip({
...buildOptions(this, PROPS, ['lat', 'lng']),
content: this.innerHTML,
} as unknown as TooltipOptions);
// Content is the element's markup, not an attribute. A tooltip only carries a
// position of its own when it isn't bound to a parent layer.
createLeafletObject(options: TooltipOptions): Tooltip {
const tooltip = new Tooltip({ ...options, content: this.innerHTML });
if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
tooltip.setLatLng([this.lat, this.lng]);
}
return tooltip;
}
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onChange, this);
registerWithParent(this, this.#obj);
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.#obj?.setContent(this.innerHTML);
});
this.#observer.observe(this, {
childList: true,
characterData: true,
subtree: true,
this.leafletObject?.setContent(this.innerHTML);
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback() {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onChange, this);
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
}
}
#onChange() {
if (!this.#obj || this.#syncing) return;
const pos = this.#obj.getLatLng();
if (!pos) return;
this.#syncing = true;
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
super.disconnectedCallback();
}
}

@ -1,73 +1,48 @@
import { VideoOverlay, LatLngBounds, LatLngExpression } from 'leaflet';
import type { VideoOverlayOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts';
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts';
const PROPS = defineProps({
url: str(),
bounds: str(),
import {
VideoOverlay,
type CrossOrigin,
type LatLngBoundsExpression,
type VideoOverlayOptions,
} from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
// Playback options live on the <video> element Leaflet builds, so live updates
// go there rather than through a Leaflet setter.
function media(
key: 'loop' | 'autoplay' | 'muted' | 'playsInline',
): (obj: VideoOverlay, value: boolean) => void {
return (obj, value) => {
const el = obj.getElement();
if (el) el[key] = value;
};
}
export class LeafletVideoOverlay extends WithProps({
url: urlProp,
bounds: positional(json<LatLngBoundsExpression, VideoOverlay>([], { get: getBounds })),
opacity: num(1.0),
alt: str(),
interactive: on(),
crossOrigin: str(),
loop: on(),
autoplay: on(),
muted: on(),
playsInline: on('playsinline'),
});
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletVideoOverlay extends WithProps(HTMLElement, PROPS) {
#obj?: VideoOverlay;
connectedCallback() {
const url = this.getAttribute('url') ?? '';
this.#obj = new VideoOverlay(
url,
parseBoundsAttr(this),
buildOptions(this, PROPS, ['url', 'bounds']) as VideoOverlayOptions,
);
registerChildren(this, this.#obj);
}
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
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(parseBoundsAttr(this) as LatLngExpression[]));
} else if (
name === 'loop' ||
name === 'autoplay' ||
name === 'muted' ||
name === 'playsinline'
) {
const el = this.#obj.getElement();
if (el) {
if (name === 'loop') el.loop = val !== null;
else if (name === 'autoplay') el.autoplay = val !== null;
else if (name === 'muted') el.muted = val !== null;
else el.playsInline = val !== null;
}
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
loop: bool(false, { set: media('loop') }),
autoplay: bool(false, { set: media('autoplay') }),
muted: bool(false, { set: media('muted') }),
playsInline: bool(false, { attribute: 'playsinline', set: media('playsInline') }),
zIndex: num(),
className: str(),
keepAspectRatio: bool(true),
errorOverlayUrl: str(),
}) {
declare readonly leafletObject?: VideoOverlay;
createLeafletObject(options: VideoOverlayOptions): VideoOverlay {
return new VideoOverlay(this.url, this.bounds, options);
}
getElement(): HTMLVideoElement | undefined {
return this.#obj?.getElement();
return this.leafletObject?.getElement();
}
}

@ -1,97 +0,0 @@
import type { LatLngBoundsExpression } from 'leaflet';
import { Path } from 'leaflet';
import type { PropDef } from './props.ts';
// Reads a numeric attribute from an element, falling back to the default
// value declared in the PROPS table. Handles the common pattern of
// reading lat/lng/radius/opacity with a guaranteed number return.
export function numAttr(el: HTMLElement, props: Record<string, PropDef>, name: string): number {
const v = el.getAttribute(name);
if (v !== null) return +v;
return (props[name] as { default: number }).default;
}
// Reads the `bounds` attribute from an element and parses it as JSON
// into a LatLngBoundsExpression. Returns an empty array when no
// attribute is set. Shared by rectangle, image-overlay, video-overlay,
// and svg-overlay, all of which accept a `bounds` attribute.
export function parseBoundsAttr(el: HTMLElement): LatLngBoundsExpression {
const raw = el.getAttribute('bounds');
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
}
// Builds a reverse lookup map from HTML attribute name → PROP key.
// For example, { fillColor: { attr: 'fill-color' } } becomes
// { 'fill-color' → 'fillColor' }. Used by setLayerAttr to find the
// Leaflet setter name when an attribute changes at runtime.
export function buildAttrMap<T extends Record<string, PropDef>>(props: T): Map<string, keyof T> {
return new Map(Object.entries(props).map(([name, spec]) => [spec.attr, name]));
}
// Generic dispatcher for runtime attribute changes on Leaflet objects.
// Given the attribute name, it looks up the PROP key, constructs the
// matching Leaflet setter name (e.g. "opacity" → "setOpacity"), and
// calls it with the parsed value. Returns true if a setter was found
// and called, false otherwise (caller can fall back, as WMS does with
// setParams). Handles bool-on props by passing the presence/absence of
// the attribute rather than its string value.
export function setLayerAttr(
obj: object,
props: Record<string, PropDef>,
attrMap: Map<string, string>,
name: string,
val: string | null,
): boolean {
const propName = attrMap.get(name);
if (!propName) return false;
const spec = props[propName];
const setter = `set${propName.charAt(0).toUpperCase()}${propName.slice(1)}`;
const fn = (obj as Record<string, unknown>)[setter];
if (typeof fn === 'function') {
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val);
(fn as (v: unknown) => void)(value);
return true;
}
return false;
}
// Parses an HTML attribute string into a typed JS value:
// null → null, "true"/"false" → boolean, numeric strings → number,
// valid JSON → parsed object/array, otherwise the raw string.
// This is the bridge between HTML attribute strings and Leaflet options.
export function parseAttributeValue(value: string | null): unknown {
if (value === null) return null;
if (value === 'true') return true;
if (value === 'false') return false;
const num = +value;
if (!isNaN(num) && value !== '') return num;
try {
return JSON.parse(value);
} catch {
return value;
}
}
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) });
}

@ -1,183 +1,127 @@
export interface NumProp<T = unknown> {
kind: 'num';
attr: string;
default: number;
mapGet?(m: T): number | undefined;
mapSet?(m: T, v: number): void;
viewState?: boolean;
// Every element property is described by a PropDef: how its value encodes to
// and decodes from an HTML attribute, and how it is pushed into (and read back
// out of) the Leaflet object. `WithProps` in with-props.ts is the only consumer
// -- components just declare a table of these and never touch the plumbing.
export interface PropDef<T = unknown, TObj = unknown> {
// Attribute name. Defaults to the kebab-cased property name. A function
// receives the property name and returns the attribute (see `disabled`).
attribute?: string | ((name: string) => string);
// The property value when the attribute is absent. Keep this equal to
// Leaflet's own default: an absent attribute is left out of the options
// object entirely, so it is Leaflet's default that actually takes effect.
default: T;
// Set through `positional()` for values the Leaflet constructor takes as an
// argument (coordinates, urls, bounds) rather than as an option.
option?: false;
decode(raw: string): T;
// Returning null removes the attribute, which restores Leaflet's default.
encode(value: T): string | null;
// Pushes a new value into the Leaflet object. Defaults to calling the
// matching setter when the object has one (`opacity` -> `setOpacity`).
set?(obj: TObj, value: T, el: HTMLElement): void;
// Reads the live value back out of the Leaflet object. Used by the property
// getter, and by `event` below to write the value back to the attribute.
get?(obj: TObj): T | undefined;
// Leaflet event after which `get` is re-read and synced to the attribute,
// e.g. `move` keeps lat/lng current while a marker is dragged.
event?: string;
}
export interface StrProp {
kind: 'str';
attr: string;
default: string;
}
export interface BoolOnProp {
kind: 'bool-on';
attr: string;
}
export interface BoolOffProp<T = unknown> {
kind: 'bool-off';
attr: string;
mapSet?(m: T, enabled: boolean): void;
}
export type PropDef = NumProp | StrProp | BoolOnProp | BoolOffProp;
export type PropTypeOf<T extends PropDef> = T extends NumProp
? number
: T extends StrProp
? string
: boolean;
export type PropTypesFromTable<T extends Record<string, PropDef>> = {
[K in keyof T]: PropTypeOf<T[K]>;
};
export type PropTable<T> = Record<string, PropDef<unknown, T>>;
type OptionalAttr<T> = Omit<T, 'attr'> & { attr?: string };
// Everything a codec factory doesn't fill in for you. `option` is not here:
// it has to come from `positional()` to be visible in PropOptionValues.
export type PropOptions<TObj, T> = Partial<
Omit<PropDef<T, TObj>, 'default' | 'decode' | 'encode' | 'option'>
>;
type NumPropInput<T = unknown> = OptionalAttr<NumProp<T>>;
type StrPropInput = OptionalAttr<StrProp>;
type BoolOnPropInput = OptionalAttr<BoolOnProp>;
type BoolOffPropInput<T = unknown> = OptionalAttr<BoolOffProp<T>>;
// The value type of a single prop, and of a whole table.
export type PropValue<P> = P extends { default: infer T } ? T : never;
type PropDefInput = NumPropInput | StrPropInput | BoolOnPropInput | BoolOffPropInput;
export type PropValues<T> = { [K in keyof T]: PropValue<T[K]> };
type ValueFor<T, K extends keyof T> = T[K];
// The options object handed to `createLeafletObject`. Partial because a prop
// only appears when its attribute is present; `option: false` props never do.
export type PropOptionValues<T> = Partial<{
[K in keyof T as T[K] extends { option: false } ? never : K]: PropValue<T[K]>;
}>;
type PropDefFromInput<T extends PropDefInput, Key extends string> = (T extends NumPropInput<infer U>
? NumProp<U>
: T extends { kind: 'str' }
? StrProp
: T extends BoolOffPropInput<infer U>
? BoolOffProp<U>
: BoolOnProp) & { attr: AttributeValue<T, Key> };
type AttributeValue<T extends PropDefInput, Key extends string> = T extends { attr: string }
? ValueFor<T, 'attr'>
: T extends BoolOffPropInput
? AsDisableKebab<Key>
: AsKebab<Key>;
type AsKebab<T extends string> = T extends `${infer L}${infer R}`
? `${L extends Uppercase<L> ? '-' : ''}${Lowercase<L>}${AsKebab<R>}`
: T;
type AsDisableKebab<T extends string> = `disable-${AsKebab<T>}`;
function disableCamelToKebab<S extends string>(s: S): AsDisableKebab<S> {
return ('disable-' + s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())) as AsDisableKebab<S>;
// Marks a prop the Leaflet constructor takes as an argument, so it is left out
// of the options object handed to createLeafletObject().
export function positional<T extends PropDef>(def: T): T & { option: false } {
return { ...def, option: false };
}
function camelToKebab(s: string): string {
return s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
export function kebab(name: string): string {
return name.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
}
export function num<T = unknown>(
export function num<TObj = unknown>(
def = 0,
opts?: Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string },
): NumPropInput<T> {
return { kind: 'num', default: def, ...opts };
}
export function str(def = '', attr?: string): StrPropInput {
return { kind: 'str', default: def, ...(attr ? { attr } : {}) };
}
export function on(attr?: string): BoolOnPropInput {
return { kind: 'bool-on', ...(attr ? { attr } : {}) };
}
export function off<T = unknown>(mapSet?: (m: T, enabled: boolean) => void): BoolOffPropInput<T> {
return { kind: 'bool-off', ...(mapSet ? { mapSet } : {}) };
}
export function defineProps<T extends Record<string, PropDefInput>>(
input: T,
): { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> } {
const output = {} as Record<string, PropDef>;
for (const key of Object.keys(input)) {
const val = input[key];
const { attr: override, ...rest } = val as unknown as Record<string, unknown>;
const attr =
override ?? (rest.kind === 'bool-off' ? disableCamelToKebab(key) : camelToKebab(key));
output[key] = { ...(rest as object), attr } as PropDef;
}
return output as unknown as { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Ctor<T = object> = new (...args: any[]) => T;
export function WithProps<TBase extends Ctor<HTMLElement>, TProps extends Record<string, PropDef>>(
Base: TBase,
props: TProps,
): TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>> {
class WithProps extends Base {
static get observedAttributes(): string[] {
return Object.values(props).map((s) => s.attr);
}
}
definePropAccessors(WithProps.prototype, props);
return WithProps as TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>>;
opts?: PropOptions<TObj, number>,
): PropDef<number, TObj> {
return { default: def, decode: Number, encode: String, ...opts };
}
export function str<TObj = unknown>(
def = '',
opts?: PropOptions<TObj, string>,
): PropDef<string, TObj> {
return { default: def, decode: (raw) => raw, encode: (value) => value, ...opts };
}
// A string attribute whose values Leaflet types as a union -- ControlPosition,
// CrossOrigin, tooltip Direction. Nothing is validated at runtime; this is how
// the options object comes out with the type Leaflet's constructor expects.
export function choice<T extends string, TObj = unknown>(
def: T,
opts?: PropOptions<TObj, T>,
): PropDef<T, TObj> {
return { default: def, decode: (raw) => raw as T, encode: (value) => value, ...opts };
}
// A boolean attribute: present is true, `="false"` is false, absent is `def`.
// Use `bool(true)` for options Leaflet already defaults to true, so that
// `<leaflet-popup auto-pan="false">` can turn them off.
export function bool<TObj = unknown>(
def = false,
opts?: PropOptions<TObj, boolean>,
): PropDef<boolean, TObj> {
return {
default: def,
decode: (raw) => raw !== 'false',
encode: (value) => (value === def ? null : value ? '' : 'false'),
...opts,
};
}
function definePropAccessors(proto: object, props: Record<string, PropDef>) {
for (const [name, spec] of Object.entries(props)) {
Object.defineProperty(proto, name, {
get() {
const el = this as HTMLElement;
const val = el.getAttribute(spec.attr);
if (spec.kind === 'num') return val !== null ? +val : spec.default;
if (spec.kind === 'bool-on') return el.hasAttribute(spec.attr);
return val ?? (spec as { default: string }).default;
},
set(v: unknown) {
const el = this as HTMLElement;
if (spec.kind === 'bool-on') el.toggleAttribute(spec.attr, !!v);
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
else el.setAttribute(spec.attr, `${v}`);
},
configurable: true,
enumerable: true,
});
}
// The inverse of `bool(true)`: `<leaflet-map disable-dragging>` reads as
// `dragging === false`. Named `disable-<kebab>` unless `attribute` says else.
export function disabled<TObj = unknown>(
opts?: PropOptions<TObj, boolean>,
): PropDef<boolean, TObj> {
return {
attribute: (name) => `disable-${kebab(name)}`,
default: true,
decode: (raw) => raw === 'false',
encode: (value) => (value ? null : ''),
...opts,
};
}
// Builds the initial Leaflet options object from the PROPS table and the
// element's current attributes at connection time. Props listed in
// `exclude` are skipped (they're passed separately to Leaflet
// constructors, like coordinates or URLs). Null attributes use the
// default from PROPS if one exists (skipping empty-string defaults to
// avoid Leaflet rejecting them).
//
// The return type is derived from the PROPS table: each prop name maps
// to its kind's value type (number for `num`, boolean for `bool-on`/`bool-off`,
// string for `str`). The `const` type parameter makes literal exclude arrays
// narrow correctly, so excluded keys are stripped from the return type.
export function buildOptions<
TProps extends Record<string, PropDef>,
const TExclude extends readonly string[] = [],
>(
el: HTMLElement,
props: TProps,
exclude: TExclude = [] as unknown as TExclude,
): Omit<PropTypesFromTable<TProps>, TExclude[number]> {
const opts = {} as Record<string, unknown>;
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 && spec.default !== '') opts[propName] = spec.default;
continue;
}
if (spec.kind === 'num') opts[propName] = +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 as Omit<PropTypesFromTable<TProps>, TExclude[number]>;
// For attributes holding JSON: bounds, icon sizes and anchors, GeoJSON data.
export function json<T, TObj = unknown>(def: T, opts?: PropOptions<TObj, T>): PropDef<T, TObj> {
return {
default: def,
decode: (raw) => JSON.parse(raw) as T,
encode: (value) => JSON.stringify(value),
...opts,
};
}

@ -1,4 +1,4 @@
import { DivIcon, Icon, Layer, LayerGroup, Popup, Tooltip } from 'leaflet';
import { DivIcon, Icon, Layer } from 'leaflet';
// Custom event type for the bubbling registration protocol. Carries the
// Leaflet object and the originating element so the nearest parent can
@ -30,41 +30,6 @@ export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | u
);
}
// Tracks whether a child registered as a plain layer, popup, or tooltip --
// used by the controls panel and by cleanup logic.
type ChildEntry = 'layer' | 'popup' | 'tooltip';
// Builds the event handler for a given parent layer. On each
// leaflet-register event from a descendant, it checks the Leaflet type:
// Popup → bindPopup, Tooltip → bindTooltip, Layer → addLayer (if the
// parent supports it). The handler stops propagation so the event
// bubbles no further (the nearest parent claims the child).
function createChildRegisterHandler(layer: Layer, children: Map<HTMLElement, ChildEntry>) {
return function (e: LeafletRegisterEvent) {
const obj = e.detail.leafletObject;
const el = e.detail.element;
if (obj instanceof Popup) {
e.stopPropagation();
layer.bindPopup(obj);
children.set(el, 'popup');
} else if (obj instanceof Tooltip) {
e.stopPropagation();
layer.bindTooltip(obj);
children.set(el, 'tooltip');
} else if (obj instanceof Layer && 'addLayer' in layer) {
e.stopPropagation();
(layer as unknown as LayerGroup).addLayer(obj);
children.set(el, 'layer');
}
};
}
// Module-scoped WeakMaps keyed by the parent element so we don't pollute
// component instances with #children / #handler fields. This keeps the
// data accessible to any caller that holds a reference to the element.
const childrenMap = new WeakMap<HTMLElement, Map<HTMLElement, ChildEntry>>();
const handlerMap = new WeakMap<HTMLElement, (e: LeafletRegisterEvent) => void>();
// Dispatches a custom `leaflet-register` event upward through the DOM
// tree, carrying a Leaflet object and its host element. Parent components
// (map, circles, groups, etc.) intercept this event and add the layer,
@ -80,33 +45,3 @@ export function registerWithParent(el: HTMLElement, obj: unknown) {
}),
);
}
// Called during connectedCallback: creates the child tracker and event
// handler, wires up the listener, and registers this element with its
// own parent (so nested structures like polygon→line→point work).
// Returns the children map for the caller to keep a reference.
export function registerChildren(el: HTMLElement, layer: Layer): Map<HTMLElement, ChildEntry> {
const children = new Map<HTMLElement, ChildEntry>();
const handler = createChildRegisterHandler(layer, children);
childrenMap.set(el, children);
handlerMap.set(el, handler);
el.addEventListener('leaflet-register', handler);
registerWithParent(el, layer);
return children;
}
// Called during disconnectedCallback: tears down the event listener and
// removes the WeakMap entries so both the handler and children map can
// be GC'd when the element is removed from the DOM.
export function unregisterChildren(el: HTMLElement): void {
const handler = handlerMap.get(el);
if (handler) el.removeEventListener('leaflet-register', handler);
childrenMap.delete(el);
handlerMap.delete(el);
}
// Public accessor for the children map. Used by the controls panel and
// by any component that needs to inspect its registered descendants.
export function getChildren(el: HTMLElement): Map<HTMLElement, ChildEntry> | undefined {
return childrenMap.get(el);
}

@ -0,0 +1,156 @@
import type {
CrossOrigin,
FillRule,
LatLng,
LatLngBounds,
LatLngBoundsExpression,
LineCapShape,
LineJoinShape,
PathOptions,
ReferrerPolicy,
} from 'leaflet';
import { bool, choice, json, num, positional, str, type PropDef } from './props.ts';
// Leaflet classes share no common interface, so the prop fragments below
// describe structurally what they need from the object they update.
export interface Positioned {
getLatLng(): LatLng | undefined;
setLatLng(latlng: [number, number]): unknown;
}
export interface Styleable {
setStyle(style: PathOptions): unknown;
}
export interface Sourced {
setUrl(url: string): unknown;
getUrl?(): string;
}
export interface Bounded {
getBounds(): LatLngBounds;
}
// Shared `get` for any `bounds` prop backed by Leaflet's getBounds(). Returns
// a plain [[south, west], [north, east]] pair rather than the LatLngBounds
// instance, matching the JSON-encoded shape the attribute round-trips through.
export function getBounds(obj: Bounded): LatLngBoundsExpression {
const b = obj.getBounds();
return [
[b.getSouth(), b.getWest()],
[b.getNorth(), b.getEast()],
];
}
interface PositionedHost extends HTMLElement {
lat: number;
lng: number;
}
// A `set` for any option Leaflet only exposes through setStyle().
export function style<T>(key: keyof PathOptions): (obj: Styleable, value: T) => void {
return (obj, value) => {
obj.setStyle({ [key]: value } as PathOptions);
};
}
// The source url. Passed positionally by every Leaflet constructor that takes
// one, and ignored when blank so clearing the attribute can't request nothing.
export const urlProp = positional(
str<Sourced>('', {
set(obj, value) {
if (value) obj.setUrl(value);
},
// Only ImageOverlay/VideoOverlay implement getUrl(); TileLayer doesn't,
// so this falls back to the attribute there, same as omitting `get`.
get: (obj) => obj.getUrl?.(),
}),
);
// lat/lng travel together: both are passed positionally to the Leaflet
// constructor rather than as options, setting either re-issues setLatLng with
// the other's current value, and both are written back whenever the object
// moves -- which is what keeps the attributes current while a marker is
// dragged. Shared by marker, circle, circle-marker, popup and tooltip.
export const latLngProps = {
lat: positional(
num<Positioned>(0, {
event: 'move',
get: (obj) => obj.getLatLng()?.lat,
set(obj, value, el) {
obj.setLatLng([value, (el as PositionedHost).lng]);
},
}),
),
lng: positional(
num<Positioned>(0, {
event: 'move',
get: (obj) => obj.getLatLng()?.lng,
set(obj, value, el) {
obj.setLatLng([(el as PositionedHost).lat, value]);
},
}),
),
} as const;
// The style options every Path accepts. Defaults match Leaflet's own, so an
// absent attribute and an unset option mean the same thing.
export const pathProps = {
stroke: bool<Styleable>(true, { set: style('stroke') }),
color: str<Styleable>('#3388ff', { set: style('color') }),
weight: num<Styleable>(3, { set: style('weight') }),
opacity: num<Styleable>(1.0, { set: style('opacity') }),
lineCap: choice<LineCapShape, Styleable>('round', { set: style('lineCap') }),
lineJoin: choice<LineJoinShape, Styleable>('round', { set: style('lineJoin') }),
dashArray: str<Styleable>('', { set: style('dashArray') }),
dashOffset: str<Styleable>('', { set: style('dashOffset') }),
fill: bool<Styleable>(true, { set: style('fill') }),
fillColor: str<Styleable>('#3388ff', { set: style('fillColor') }),
fillOpacity: num<Styleable>(0.2, { set: style('fillOpacity') }),
fillRule: choice<FillRule, Styleable>('evenodd', { set: style('fillRule') }),
// Constructor-only: Leaflet has no setter for these, so changing the
// attribute after creation has no effect (same as leaflet-map's zoomSnap).
className: str(),
interactive: bool(true),
bubblingMouseEvents: bool(true),
pane: str('overlay'),
} as const;
// The GridLayer/TileLayer options every tile source accepts, shared by
// leaflet-tile-layer and leaflet-tile-layer-wms (WMSOptions extends
// TileLayerOptions). Most are constructor-only -- Leaflet exposes no setter
// for them -- so changing the attribute after creation has no effect, same as
// leaflet-map's zoomSnap.
export const tileLayerProps = {
attribution: str(),
minZoom: num(0),
maxZoom: num(18),
opacity: num(1.0),
zIndex: num(1),
subdomains: str('abc'),
tms: bool(),
zoomOffset: num(0),
zoomReverse: bool(),
detectRetina: bool(),
crossOrigin: choice<CrossOrigin>(''),
// Leaflet's ReferrerPolicy type has no "unset" member (unlike the DOM's),
// so this is a plain PropDef rather than choice(), with `undefined` as the
// fallback the property getter reports when the attribute is absent.
referrerPolicy: {
default: undefined,
decode: (raw) => raw as ReferrerPolicy,
encode: (value) => value ?? null,
} as PropDef<ReferrerPolicy | undefined>,
errorTileUrl: str(),
tileSize: num(256),
noWrap: bool(),
bounds: json<LatLngBoundsExpression>([]),
className: str(),
minNativeZoom: num(),
maxNativeZoom: num(),
keepBuffer: num(2),
updateWhenIdle: bool(),
updateWhenZooming: bool(true),
updateInterval: num(200),
pane: str('tilePane'),
} as const;

@ -0,0 +1,307 @@
import { Layer, Popup, Tooltip, type Class } from 'leaflet';
import {
kebab,
type PropDef,
type PropOptionValues,
type PropTable,
type PropValues,
} from './props.ts';
import { registerWithParent, type LeafletRegisterEvent } from './register.ts';
// How an element joins the component tree:
// children -- register with the nearest parent and adopt registering
// descendants as layers, popups and tooltips (every layer)
// self -- register with the nearest parent only (popups, tooltips,
// controls: they have a parent but manage no children here)
// none -- neither (the map is the root; icons aren't part of the tree)
export type Attach = 'children' | 'self' | 'none';
export interface ElementOptions {
attach?: Attach;
// Rebuild the Leaflet object on every attribute change instead of calling
// setters, for objects Leaflet gives us no way to mutate in place (icons).
recreate?: boolean;
}
// The members WithProps contributes on top of the property accessors.
export interface LeafletElement<TObj, TProps> {
readonly leafletObject?: TObj;
// The one method every component must implement. `options` holds the decoded
// value of every prop whose attribute is present, keyed by property name, so
// it can be handed straight to the Leaflet constructor.
createLeafletObject(options: PropOptionValues<TProps>): TObj | undefined;
// Called after the object is created and after every recreate.
leafletObjectCreated(): void;
recreateLeafletObject(): void;
connectedCallback(): void;
disconnectedCallback(): void;
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
}
export type LeafletElementConstructor<TObj, TProps> = new () => HTMLElement &
PropValues<TProps> &
LeafletElement<TObj, TProps>;
interface ResolvedProp<TObj extends Class> {
name: string;
attribute: string;
setter: keyof TObj;
def: PropDef<unknown, TObj>;
}
type ChildEntry = { type: 'layer' | 'popup' | 'tooltip'; object: Layer };
type AnyMethod = (...args: unknown[]) => unknown;
// Looks up a Leaflet method by name and binds it, or returns undefined. Leaflet
// objects are duck-typed here on purpose: Layer, Control and Icon share no
// common interface, and which setters exist varies per class.
// function method<TObj extends Record<string, unknown>, TName extends keyof TObj>(obj: TObj, name: TName | string): TObj[TName] | undefined {
function method<TObj extends Class, TName extends keyof TObj>(
obj: TObj,
name: TName | string,
): AnyMethod | undefined {
const value = obj[name as TName];
return typeof value === 'function' ? value.bind(obj) : undefined;
}
function resolve<TObj extends Class>(props: PropTable<TObj>): ResolvedProp<TObj>[] {
return Object.entries(props).map(([name, def]) => ({
name,
attribute:
typeof def.attribute === 'function' ? def.attribute(name) : (def.attribute ?? kebab(name)),
setter: `set${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TObj,
def,
}));
}
// Builds a custom element base class from a table of property definitions.
//
// The generated class owns the whole lifecycle: it derives observedAttributes,
// defines two-way property accessors, builds the Leaflet options object, wires
// the object into the component tree, keeps attributes in sync with the object
// (in both directions, without cycles), and re-fires every Leaflet event on the
// element as `leaflet:<type>`. Subclasses implement createLeafletObject().
export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
props: TProps,
options: ElementOptions = {},
): LeafletElementConstructor<TObj, TProps> {
const resolved = resolve(props);
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
const attach = options.attach ?? 'children';
class WithPropsElement extends HTMLElement {
static readonly observedAttributes = resolved.map((prop) => prop.attribute);
#obj?: TObj;
#connected = false;
#children = new Map<HTMLElement, ChildEntry>();
#listeners: [event: string, handler: () => void][] = [];
// Set while writing an attribute on the object's behalf, so the resulting
// attributeChangedCallback doesn't push the value straight back into
// Leaflet. This is the only place a cycle could form.
#syncing = false;
get leafletObject(): TObj | undefined {
return this.#obj;
}
createLeafletObject(_options: PropOptionValues<TProps>): TObj | undefined {
throw new Error(`<${this.localName}> does not implement createLeafletObject()`);
}
leafletObjectCreated(): void {
// Overridden by components that need to react to a new Leaflet object.
}
connectedCallback(): void {
if (this.#connected) return;
this.#connected = true;
this.#createObject();
if (attach === 'children') {
this.addEventListener('leaflet-register', this.#onChildRegister);
}
if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj);
this.leafletObjectCreated();
}
disconnectedCallback(): void {
if (attach === 'children') {
this.removeEventListener('leaflet-register', this.#onChildRegister);
}
this.#releaseChildren();
this.#destroyObject();
this.#connected = false;
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (oldValue === newValue || this.#syncing || !this.#connected) return;
const prop = byAttribute.get(name);
if (!prop) return;
if (options.recreate) {
this.#recreateLeafletObject();
return;
}
const obj = this.#obj;
if (!obj) return;
const value = newValue === null ? prop.def.default : prop.def.decode(newValue);
if (prop.def.set) prop.def.set(obj, value, this);
else method(obj, prop.setter)?.(value);
}
// Throws the current object away and builds a fresh one from the current
// attributes, leaving the element's place in the tree untouched.
#recreateLeafletObject(): void {
this.#destroyObject();
this.#createObject();
this.leafletObjectCreated();
}
#createObject(): void {
const obj = this.createLeafletObject(this.#buildOptions());
this.#obj = obj;
if (!obj) return;
this.#watchObject(obj);
this.#forwardEvents(obj);
}
#destroyObject(): void {
const obj = this.#obj;
this.#obj = undefined;
if (!obj) return;
const off = method(obj, 'off');
for (const [event, handler] of this.#listeners) off?.(event, handler);
this.#listeners = [];
method(obj, 'remove')?.();
}
#buildOptions(): PropOptionValues<TProps> {
const opts: Record<string, unknown> = {};
for (const prop of resolved) {
if (prop.def.option === false) continue;
const raw = this.getAttribute(prop.attribute);
if (raw !== null) opts[prop.name] = prop.def.decode(raw);
}
return opts as PropOptionValues<TProps>;
}
// Registers one Leaflet listener per distinct event, updating every prop
// that names it. Dragging a marker fires `move` once and writes both lat
// and lng.
#watchObject(obj: TObj): void {
const on = method(obj, 'on');
if (!on) return;
const groups = new Map<string, ResolvedProp<TObj>[]>();
for (const prop of resolved) {
if (!prop.def.event || !prop.def.get) continue;
const group = groups.get(prop.def.event) ?? [];
group.push(prop);
groups.set(prop.def.event, group);
}
for (const [event, group] of groups) {
const handler = () => {
for (const prop of group) this.#syncAttribute(prop);
};
this.#listeners.push([event, handler]);
on(event, handler);
}
}
#syncAttribute(prop: ResolvedProp<TObj>): void {
const obj = this.#obj;
const value = obj && prop.def.get?.(obj);
if (value === undefined) return;
const raw = prop.def.encode(value);
if (this.getAttribute(prop.attribute) === raw) return;
this.#syncing = true;
try {
if (raw === null) this.removeAttribute(prop.attribute);
else this.setAttribute(prop.attribute, raw);
} finally {
this.#syncing = false;
}
}
// Leaflet has no wildcard listener, so we wrap the instance's own `fire` --
// it's an object we created and hold alone. Every Leaflet event becomes a
// `leaflet:<type>` DOM event carrying the Leaflet event as its detail,
// dispatched after Leaflet's handlers so synced attributes are current.
// Not bubbling: Leaflet already propagates layer events up to the map, so
// <leaflet-map> would otherwise see each of them twice.
#forwardEvents(obj: object): void {
const target = obj as {
fire?: (type: string, data?: object, propagate?: boolean) => unknown;
};
const fire = target.fire;
if (typeof fire !== 'function') return;
target.fire = (type, data, propagate) => {
const result = fire.call(obj, type, data, propagate);
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail: data ?? {} }));
return result;
};
}
// Claims descendants that announce themselves with `leaflet-register`.
// Skips our own announcement, which is dispatched on this element too.
#onChildRegister = (e: LeafletRegisterEvent) => {
const obj = this.#obj;
const child = e.detail.leafletObject;
const el = e.detail.element;
if (!obj || el === this) return;
if (child instanceof Popup) {
e.stopPropagation();
method(obj, 'bindPopup')?.(child);
this.#children.set(el, { type: 'popup', object: child });
} else if (child instanceof Tooltip) {
e.stopPropagation();
method(obj, 'bindTooltip')?.(child);
this.#children.set(el, { type: 'tooltip', object: child });
} else if (child instanceof Layer && 'addLayer' in obj) {
e.stopPropagation();
method(obj, 'addLayer')?.(child);
this.#children.set(el, { type: 'layer', object: child });
}
};
#releaseChildren(): void {
const obj = this.#obj;
if (obj) {
for (const entry of this.#children.values()) {
if (entry.type === 'popup') method(obj, 'unbindPopup')?.();
else if (entry.type === 'tooltip') method(obj, 'unbindTooltip')?.();
else method(obj, 'removeLayer')?.(entry.object);
}
}
this.#children.clear();
}
}
for (const prop of resolved) {
Object.defineProperty(WithPropsElement.prototype, prop.name, {
configurable: true,
enumerable: true,
get(this: WithPropsElement) {
const obj = this.leafletObject;
if (obj && prop.def.get) {
const live = prop.def.get(obj);
if (live !== undefined) return live;
}
const raw = this.getAttribute(prop.attribute);
return raw === null ? prop.def.default : prop.def.decode(raw);
},
set(this: WithPropsElement, value: unknown) {
const raw = prop.def.encode(value);
if (raw === null) this.removeAttribute(prop.attribute);
else this.setAttribute(prop.attribute, raw);
},
});
}
return WithPropsElement as unknown as LeafletElementConstructor<TObj, TProps>;
}

@ -1,4 +1,3 @@
export * from './core/attributes.ts';
export * from './core/register.ts';
export * from './core/props.ts';
export { LeafletMap } from './components/leaflet-map.ts';

Loading…
Cancel
Save