feat: Initial implementation of leaflet-components

Wraps Leaflet.js as native Web Components. Each element maps 1:1 to a
Leaflet object with reactive attribute binding and proper lifecycle cleanup.

Key design decisions:
- LeafletElement base class handles attribute→option mapping, the
  leaflet-register bubble event for parent/child wiring, and
  disconnectedCallback cleanup tracking parent bindings.
- LeafletControl base class for map controls (zoom, attribution, scale).
- leaflet-map uses a PROPS table to drive observedAttributes, getters/setters,
  attributeChangedCallback, and map event listeners from a single source of
  truth. Property types are derived via a mapped type over the table; the
  mixin-base pattern (TypedBase) makes them visible to TypeScript without an
  interface merge.
- Boolean map options that default to true use disable-* attributes.
- Leaflet and its CSS are bundled into dist/index.js via esbuild.
- JSR config included for @buddy/leaflet-components.
main
Buddy 4 months ago
commit 6b448ba092

32
.gitignore vendored

@ -0,0 +1,32 @@
/.idea/
/jspm_packages/
/node_modules/
/web_modules/
/dist/
*.pid
*.pid.lock
*.seed
*.tgz
*.tsbuildinfo
.env
.env.development.local
.env.local
.env.production.local
.env.test.local
.eslintcache
.node_repl_history
.npm
.pnp.*
.pnpm-debug.log*
.stylelintcache
.yarn-integrity
.yarn/build-state.yml
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
lerna-debug.log*
npm-debug.log*
pids
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
yarn-debug.log*
yarn-error.log*

@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

@ -0,0 +1,50 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run build # Type-check + emit .d.ts via tsc, then bundle to dist/index.js via esbuild
npm run lint # ESLint over src/**/*.{ts,js}
npm run format # Prettier formatting
```
There are no tests in this project. To preview components locally, open `index.html` in a browser with a dev server that supports TypeScript (e.g. `npx vite`).
## Architecture
This library wraps [Leaflet.js](https://leafletjs.com/) as native Web Components (Custom Elements). Each HTML element maps 1:1 to a Leaflet object.
### Core base class: `src/core/LeafletElement.ts`
`LeafletElement` (extends `HTMLElement`) is the abstract base for all components except `leaflet-map`. Subclasses must implement `createLeafletObject()` returning an `L.Layer`. The base class:
- Reads `observedAttributes` to auto-populate `this.options` via `initOptions()` on connect, converting kebab-case attribute names to camelCase for Leaflet options.
- Parses attribute values to booleans, numbers, or JSON automatically (`parseAttributeValue`).
- On `attributeChangedCallback`, calls `updateLeafletObject()` which by default invokes the matching Leaflet setter (e.g. `setOpacity`, `setRadius`). Subclasses override this for attributes needing custom logic (e.g. lat/lng pairs).
- Uses a custom `leaflet-register` bubbling event to wire children into parents. When a child connects, it dispatches this event upward; each parent intercepts and either calls `addLayer`, `bindPopup`, or `bindTooltip` depending on the child's type.
### `leaflet-map`: `src/components/leaflet-map.ts`
`LeafletMap` does **not** extend `LeafletElement` — it extends `HTMLElement` directly and uses Shadow DOM. It is the root of the component tree and terminates all bubbling `leaflet-register` events by calling `layer.addTo(this.map)`.
### Child component pattern
All other components extend `LeafletElement`. To add a new component:
1. Extend `LeafletElement`, implement `createLeafletObject()`.
2. Declare `static get observedAttributes()` listing HTML attributes (kebab-case).
3. Override `updateLeafletObject()` only if the default setter-based update won't work (common for coordinate pairs).
4. Call `customElements.define('leaflet-foo', LeafletFoo)` at the bottom.
5. Export from `src/index.ts`.
### 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`**: a passthrough container; children register themselves into it via the standard bubble mechanism.
### Output
`tsc` compiles `src/``dist/` with `.js` files and `.d.ts` declarations. Imports within the source use `.js` extensions (required for ESM `"moduleResolution": "bundler"`).

@ -0,0 +1,222 @@
# leaflet-components
[Leaflet.js](https://leafletjs.com/) as native Web Components (Custom Elements). Each HTML element maps 1:1 to a Leaflet object with reactive attribute binding.
## Installation
```bash
npm install leaflet-components
```
Available on JSR as `@buddy/leaflet-components`.
## Usage
Import once to register all custom elements, then use them declaratively in HTML.
```html
<script type="module">
import 'leaflet-components';
</script>
<leaflet-map lat="51.505" lng="-0.09" zoom="13" style="height:400px">
<leaflet-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="© OpenStreetMap contributors"
></leaflet-tile-layer>
<leaflet-marker lat="51.505" lng="-0.09">
<leaflet-popup><b>Hello!</b></leaflet-popup>
<leaflet-tooltip>Hover me</leaflet-tooltip>
</leaflet-marker>
<leaflet-circle lat="51.508" lng="-0.11" radius="500" color="red" fill-color="#f03" fill-opacity="0.5">
<leaflet-popup>I am a circle</leaflet-popup>
</leaflet-circle>
<leaflet-polygon color="blue">
<leaflet-line lat="51.509" lng="-0.08"></leaflet-line>
<leaflet-line lat="51.503" lng="-0.06"></leaflet-line>
<leaflet-line lat="51.510" lng="-0.047"></leaflet-line>
</leaflet-polygon>
<leaflet-control-scale position="bottomleft"></leaflet-control-scale>
</leaflet-map>
```
## `leaflet-map`
The root component. All other components must be children of `<leaflet-map>`.
```html
<leaflet-map lat="51.505" lng="-0.09" zoom="13" disable-scroll-wheel-zoom>
```
### View state
These attributes stay in sync with the map as the user interacts with it — panning updates `lat`/`lng`, zooming updates `zoom`.
| Attribute | Default | Description |
|---|---|---|
| `lat` | `0` | Center latitude |
| `lng` | `0` | Center longitude |
| `zoom` | `2` | Zoom level |
| `min-zoom` | `0` | Minimum zoom level |
| `max-zoom` | — | Maximum zoom level. When unset, Leaflet uses the tile layer's own max zoom. |
### Interaction
Boolean options that default to `true` are controlled by the presence of a `disable-*` attribute. Handler-based options can be toggled at any time; the rest only apply at construction.
| Attribute | Controls | Live? |
|---|---|---|
| `disable-dragging` | Mouse/touch panning | ✓ |
| `disable-scroll-wheel-zoom` | Scroll-wheel zoom | ✓ |
| `disable-double-click-zoom` | Double-click zoom | ✓ |
| `disable-touch-zoom` | Pinch-to-zoom | ✓ |
| `disable-box-zoom` | Shift-drag zoom box | ✓ |
| `disable-keyboard` | Keyboard pan/zoom | ✓ |
| `disable-zoom-control` | Built-in zoom control | — |
| `disable-attribution-control` | Built-in attribution | — |
| `disable-close-popup-on-click` | Close popup on map click | — |
| `disable-track-resize` | Auto-resize on window resize | — |
| `disable-bounce-at-zoom-limits` | Bounce animation at min/max zoom | — |
| `disable-tap-hold` | Long-press context menu (mobile) | — |
Boolean options that default to `false` are enabled by adding the attribute:
| Attribute | Description |
|---|---|
| `prefer-canvas` | Render vector layers on Canvas instead of SVG |
| `world-copy-jump` | Pan to the original world copy when crossing the antimeridian |
### Animation
| Attribute | Default | Description |
|---|---|---|
| `disable-zoom-animation` | — | Disable CSS zoom animation |
| `disable-fade-animation` | — | Disable tile fade-in |
| `disable-marker-zoom-animation` | — | Disable marker zoom animation |
| `zoom-animation-threshold` | `4` | Max zoom delta for animated zoom |
### Inertia & panning
| Attribute | Default | Description |
|---|---|---|
| `disable-inertia` | — | Disable inertial panning |
| `inertia-deceleration` | `3000` | Deceleration rate (px/s²) |
| `inertia-max-speed` | `Infinity` | Maximum inertia speed (px/s) |
| `ease-linearity` | `0.2` | Pan easing linearity |
### Zoom behaviour
| Attribute | Default | Description |
|---|---|---|
| `zoom-snap` | `1` | Zoom snapping interval; `0` for continuous zoom |
| `zoom-delta` | `1` | Zoom step per keyboard/button press |
| `max-bounds-viscosity` | `0` | How much the bounds resist panning past them (`0``1`) |
### Scroll wheel
| Attribute | Default | Description |
|---|---|---|
| `wheel-debounce-time` | `40` | Debounce delay for wheel events (ms) |
| `wheel-px-per-zoom-level` | `60` | Pixels of scroll per zoom level |
### Keyboard & touch
| Attribute | Default | Description |
|---|---|---|
| `keyboard-pan-delta` | `80` | Pan distance per key press (px) |
| `tap-tolerance` | `15` | Max touch movement to trigger a tap (px) |
### Rendering
| Attribute | Default | Description |
|---|---|---|
| `transform-3d-limit` | `8388608` | Max CSS `translate3d` component value before a layer reset |
### Accessing the underlying map
```js
const el = document.querySelector('leaflet-map');
el.map; // L.Map instance (undefined before connected)
el.zoom; // current zoom (reads live from map, falls back to attribute)
el.scrollWheelZoom = false; // disable at runtime
```
All `leaflet-map` properties are two-way: reading returns the live map value; writing updates both the attribute and the map.
---
## Other components
### Tile layers
| Element | Key attributes |
|---|---|
| `leaflet-tile-layer` | `url`, `attribution`, `min-zoom`, `max-zoom`, `opacity`, `z-index` |
| `leaflet-tile-layer-wms` | `url`, `layers`, `styles`, `format`, `transparent`, `version` |
### Vector layers
Path style attributes (`color`, `weight`, `opacity`, `fill`, `fill-color`, `fill-opacity`, `dash-array`, `line-cap`, `line-join`) are shared by all vector layers.
| Element | Additional attributes | Notes |
|---|---|---|
| `leaflet-circle` | `lat`, `lng`, `radius` (meters) | |
| `leaflet-circle-marker` | `lat`, `lng`, `radius` (pixels) | |
| `leaflet-polyline` | — | Vertices via `<leaflet-line>` children |
| `leaflet-polygon` | — | Vertices via `<leaflet-line>` children |
| `leaflet-rectangle` | `bounds` (JSON) | |
| `leaflet-line` | `lat`, `lng` | Vertex only — not rendered directly |
### Markers & overlays
| Element | Key attributes |
|---|---|
| `leaflet-marker` | `lat`, `lng`, `title`, `alt`, `draggable`, `opacity`, `z-index-offset` |
| `leaflet-image-overlay` | `url`, `bounds` (JSON), `opacity`, `alt`, `interactive` |
| `leaflet-video-overlay` | `url`, `bounds` (JSON), `opacity`, `loop`, `autoplay`, `muted` |
| `leaflet-svg-overlay` | `bounds` (JSON), `opacity`, `interactive` — wrap an `<svg>` element |
### Groups & GeoJSON
| Element | Key attributes |
|---|---|
| `leaflet-layer-group` | — |
| `leaflet-feature-group` | — |
| `leaflet-geojson` | `data` (GeoJSON string), plus path style attributes |
### UI layers
Content is set via `innerHTML`, not attributes.
| Element | Key attributes |
|---|---|
| `leaflet-popup` | `lat`, `lng`, `max-width`, `min-width`, `auto-pan`, `close-button`, `auto-close` |
| `leaflet-tooltip` | `lat`, `lng`, `direction`, `permanent`, `sticky`, `opacity` |
### Controls
| Element | Key attributes |
|---|---|
| `leaflet-control-zoom` | `position`, `zoom-in-text`, `zoom-out-text` |
| `leaflet-control-attribution` | `position`, `prefix` |
| `leaflet-control-scale` | `position`, `max-width`, `metric`, `imperial` |
## Nesting rules
- **Popup / tooltip as child of a layer** → bound via `bindPopup` / `bindTooltip`.
- **Layer as child of a group** → added via `addLayer`.
- **Any layer as child of `leaflet-map`** → added to the map directly.
- **`leaflet-polygon` / `leaflet-polyline`** take their coordinates from `<leaflet-line>` children, not from attributes.
## Development
```bash
npm run build # type-check + emit .d.ts via tsc, then bundle to dist/index.js via esbuild
npm run lint # ESLint
npm run format # Prettier
npx vite # open index.html for a live demo
```

@ -0,0 +1,24 @@
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import prettierPlugin from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
export default [
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: {
'@typescript-eslint': tsPlugin,
prettier: prettierPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
...prettierConfig.rules,
'prettier/prettier': 'error',
},
},
];

@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leaflet Components Demo</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: sans-serif;
}
leaflet-map {
height: 500px;
border: 1px solid #ccc;
}
</style>
</head>
<body>
<h1>Leaflet Web Components</h1>
<leaflet-map lat="51.505" lng="-0.09" zoom="13">
<leaflet-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="&copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"
>
</leaflet-tile-layer>
<leaflet-marker lat="51.505" lng="-0.09" title="A Marker">
<leaflet-popup><b>Hello world!</b><br />I am a popup.</leaflet-popup>
<leaflet-tooltip>I am a tooltip</leaflet-tooltip>
</leaflet-marker>
<leaflet-circle
lat="51.508"
lng="-0.11"
color="red"
fill-color="#f03"
fill-opacity="0.5"
radius="500"
>
<leaflet-popup>I am a circle.</leaflet-popup>
</leaflet-circle>
<leaflet-polygon color="blue" fill-color="cyan" fill-opacity="0.3">
<leaflet-line lat="51.509" lng="-0.08"></leaflet-line>
<leaflet-line lat="51.503" lng="-0.06"></leaflet-line>
<leaflet-line lat="51.51" lng="-0.047"></leaflet-line>
<leaflet-popup>I am a polygon.</leaflet-popup>
</leaflet-polygon>
<leaflet-feature-group>
<leaflet-marker lat="51.513" lng="-0.07" title="Feature Group Marker">
<leaflet-tooltip>Part of a feature group</leaflet-tooltip>
</leaflet-marker>
<leaflet-circle lat="51.513" lng="-0.07" radius="100" color="purple" fill-color="purple" fill-opacity="0.2">
</leaflet-circle>
</leaflet-feature-group>
<leaflet-geojson
color="#e67e22"
weight="3"
fill-color="#f39c12"
fill-opacity="0.4"
data='{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-0.13,51.50],[-0.12,51.50],[-0.12,51.51],[-0.13,51.51],[-0.13,51.50]]]},"properties":{}}'
></leaflet-geojson>
<leaflet-control-scale position="bottomleft"></leaflet-control-scale>
</leaflet-map>
<!-- script type="module" src="./src/index.ts"></script -->
<script type="module" src="/dist/index.js"></script>
</body>
</html>

@ -0,0 +1,5 @@
{
"name": "@buddy/leaflet-components",
"version": "0.1.0",
"exports": "./src/index.ts"
}

1853
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,41 @@
{
"name": "leaflet-components",
"version": "0.1.0",
"description": "LeafletJS as Web Components",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "rm -rf dist && tsc --emitDeclarationOnly && esbuild src/index.ts --bundle --format=esm --loader:.css=text --outfile=dist/index.js",
"lint": "eslint 'src/**/*.{ts,js}'",
"format": "prettier --write 'src/**/*.{ts,js,json,md}'",
"prepublishOnly": "npm run build"
},
"keywords": [
"leaflet",
"web-components",
"maps"
],
"author": "buddy",
"license": "MIT",
"type": "module",
"dependencies": {},
"devDependencies": {
"@types/leaflet": "1.9.21",
"leaflet": "1.9.4",
"@typescript-eslint/eslint-plugin": "8.60.1",
"@typescript-eslint/parser": "8.60.1",
"esbuild": "^0.28.0",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.6",
"prettier": "3.8.3",
"typescript": "6.0.3"
}
}

@ -0,0 +1,54 @@
import { Layer, CircleMarker } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletCircleMarker extends LeafletElement {
static get observedAttributes() {
return [
'lat',
'lng',
'radius',
'color',
'weight',
'opacity',
'fill',
'fill-color',
'fill-opacity',
];
}
protected createLeafletObject(): Layer {
const lat = parseFloat(this.getAttribute('lat') || '0');
const lng = parseFloat(this.getAttribute('lng') || '0');
return new CircleMarker([lat, lng], this.options);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof CircleMarker) {
if (property === 'lat' || property === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0');
const lng = parseFloat(this.getAttribute('lng') || '0');
this.leafletObject.setLatLng([lat, lng]);
} else if (
[
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-circle-marker', LeafletCircleMarker);

@ -0,0 +1,54 @@
import { Circle, Layer } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletCircle extends LeafletElement {
static get observedAttributes() {
return [
'lat',
'lng',
'radius',
'color',
'weight',
'opacity',
'fill',
'fill-color',
'fill-opacity',
];
}
protected createLeafletObject(): Layer {
const lat = parseFloat(this.getAttribute('lat') || '0');
const lng = parseFloat(this.getAttribute('lng') || '0');
return new Circle([lat, lng], this.options);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof Circle) {
if (property === 'lat' || property === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0');
const lng = parseFloat(this.getAttribute('lng') || '0');
this.leafletObject.setLatLng([lat, lng]);
} else if (
[
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-circle', LeafletCircle);

@ -0,0 +1,19 @@
import { Control, ControlPosition } from 'leaflet';
import { LeafletControl } from '../core/LeafletControl.js';
export class LeafletControlAttribution extends LeafletControl {
static get observedAttributes() {
return ['position', 'prefix'];
}
protected createControl(): Control {
const options: Control.AttributionOptions = {};
const position = this.getAttribute('position') as ControlPosition | null;
const prefix = this.getAttribute('prefix');
if (position) options.position = position;
if (prefix !== null) options.prefix = prefix;
return new Control.Attribution(options);
}
}
customElements.define('leaflet-control-attribution', LeafletControlAttribution);

@ -0,0 +1,25 @@
import { Control, ControlPosition } from 'leaflet';
import { LeafletControl } from '../core/LeafletControl.js';
export class LeafletControlScale extends LeafletControl {
static get observedAttributes() {
return ['position', 'max-width', 'metric', 'imperial', 'update-when-idle'];
}
protected createControl(): Control {
const options: Control.ScaleOptions = {};
const position = this.getAttribute('position') as ControlPosition | null;
const maxWidth = this.getAttribute('max-width');
const metric = this.getAttribute('metric');
const imperial = this.getAttribute('imperial');
const updateWhenIdle = this.getAttribute('update-when-idle');
if (position) options.position = position;
if (maxWidth !== null) options.maxWidth = parseInt(maxWidth, 10);
if (metric !== null) options.metric = metric !== 'false';
if (imperial !== null) options.imperial = imperial !== 'false';
if (updateWhenIdle !== null) options.updateWhenIdle = updateWhenIdle !== 'false';
return new Control.Scale(options);
}
}
customElements.define('leaflet-control-scale', LeafletControlScale);

@ -0,0 +1,25 @@
import { Control, ControlPosition } from 'leaflet';
import { LeafletControl } from '../core/LeafletControl.js';
export class LeafletControlZoom extends LeafletControl {
static get observedAttributes() {
return ['position', 'zoom-in-text', 'zoom-in-title', 'zoom-out-text', 'zoom-out-title'];
}
protected createControl(): Control {
const options: Control.ZoomOptions = {};
const position = this.getAttribute('position') as ControlPosition | null;
const zoomInText = this.getAttribute('zoom-in-text');
const zoomInTitle = this.getAttribute('zoom-in-title');
const zoomOutText = this.getAttribute('zoom-out-text');
const zoomOutTitle = this.getAttribute('zoom-out-title');
if (position) options.position = position;
if (zoomInText !== null) options.zoomInText = zoomInText;
if (zoomInTitle !== null) options.zoomInTitle = zoomInTitle;
if (zoomOutText !== null) options.zoomOutText = zoomOutText;
if (zoomOutTitle !== null) options.zoomOutTitle = zoomOutTitle;
return new Control.Zoom(options);
}
}
customElements.define('leaflet-control-zoom', LeafletControlZoom);

@ -0,0 +1,10 @@
import { FeatureGroup, Layer } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletFeatureGroup extends LeafletElement {
protected createLeafletObject(): Layer {
return new FeatureGroup([], this.options);
}
}
customElements.define('leaflet-feature-group', LeafletFeatureGroup);

@ -0,0 +1,45 @@
import { GeoJSON, PathOptions, Layer } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletGeoJSON extends LeafletElement {
static get observedAttributes() {
return [
'data',
'stroke',
'color',
'weight',
'opacity',
'line-cap',
'line-join',
'dash-array',
'dash-offset',
'fill',
'fill-color',
'fill-opacity',
'fill-rule',
];
}
protected createLeafletObject(): Layer {
const raw = this.getAttribute('data');
const data = raw ? JSON.parse(raw) : undefined;
const styleOpts = Object.fromEntries(
Object.entries(this.options).filter(([k]) => k !== 'data'),
);
return new GeoJSON(data, { style: styleOpts as PathOptions });
}
protected updateLeafletObject(property: string, value: unknown) {
if (!(this.leafletObject instanceof GeoJSON)) return;
if (property === 'data') {
this.leafletObject.clearLayers();
if (value) {
this.leafletObject.addData(value as Parameters<GeoJSON['addData']>[0]);
}
} else {
this.leafletObject.setStyle({ [property]: value } as PathOptions);
}
}
}
customElements.define('leaflet-geojson', LeafletGeoJSON);

@ -0,0 +1,44 @@
import {
ImageOverlay,
LatLngBounds,
LatLngBoundsExpression,
LatLngExpression,
Layer,
} from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletImageOverlay extends LeafletElement {
static get observedAttributes() {
return [
'url',
'bounds',
'opacity',
'alt',
'interactive',
'cross-origin',
'error-overlay-url',
'z-index',
'className',
];
}
protected createLeafletObject(): Layer {
const url = this.getAttribute('url') || '';
const bounds = this.options.bounds as LatLngBoundsExpression;
return new ImageOverlay(url, bounds, this.options);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof ImageOverlay) {
if (property === 'url') {
this.leafletObject.setUrl(value as string);
} else if (property === 'bounds') {
this.leafletObject.setBounds(new LatLngBounds(value as LatLngExpression[]));
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-image-overlay', LeafletImageOverlay);

@ -0,0 +1,10 @@
import { LayerGroup, Layer } from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletLayerGroup extends LeafletElement {
protected createLeafletObject(): Layer {
return new LayerGroup([], this.options);
}
}
customElements.define('leaflet-layer-group', LeafletLayerGroup);

@ -0,0 +1,18 @@
export class LeafletLine extends HTMLElement {
static get observedAttributes() {
return ['lat', 'lng'];
}
get latlng(): [number, number] {
return [
parseFloat(this.getAttribute('lat') || '0'),
parseFloat(this.getAttribute('lng') || '0'),
];
}
attributeChangedCallback() {
this.dispatchEvent(new CustomEvent('line-updated', { bubbles: true }));
}
}
customElements.define('leaflet-line', LeafletLine);

@ -0,0 +1,329 @@
import { Map as LMap, MapOptions } from 'leaflet';
import leafletCSS from 'leaflet/dist/leaflet.css';
import { LeafletRegisterEvent } from '../core/LeafletElement.js';
// ── Prop types ─────────────────────────────────────────────────────────────
type NumProp = {
kind: 'num';
attr: string;
default: number;
viewState?: true;
mapGet?: (m: LMap) => number | undefined;
mapSet?: (m: LMap, v: number) => void;
event?: string;
};
type BoolOffProp = {
kind: 'bool-off';
attr: string;
mapSet?: (m: LMap, enabled: boolean) => void;
};
type BoolOnProp = {
kind: 'bool-on';
attr: string;
};
type PropDef = NumProp | BoolOffProp | BoolOnProp;
// ── Property table ─────────────────────────────────────────────────────────
const PROPS = {
// View state — excluded from #buildOptions, initialised via setView()
lat: {
kind: 'num',
attr: 'lat',
default: 0,
viewState: true,
event: 'moveend',
mapGet: (m: LMap) => m.getCenter()?.lat,
mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter()?.lng ?? 0], m.getZoom()),
},
lng: {
kind: 'num',
attr: 'lng',
default: 0,
viewState: true,
event: 'moveend',
mapGet: (m: LMap) => m.getCenter()?.lng,
mapSet: (m: LMap, v: number) => m.setView([m.getCenter()?.lat ?? 0, v], m.getZoom()),
},
zoom: {
kind: 'num',
attr: 'zoom',
default: 2,
viewState: true,
event: 'zoomend',
mapGet: (m: LMap) => m.getZoom(),
mapSet: (m: LMap, v: number) => m.setZoom(v),
},
// Live numeric options — have Leaflet setters
minZoom: {
kind: 'num',
attr: 'min-zoom',
default: 0,
mapGet: (m: LMap) => m.getMinZoom(),
mapSet: (m: LMap, v: number) => m.setMinZoom(v),
},
maxZoom: {
kind: 'num',
attr: 'max-zoom',
default: Infinity,
mapGet: (m: LMap) => m.getMaxZoom(),
mapSet: (m: LMap, v: number) => m.setMaxZoom(v),
},
// Constructor-only numeric options
zoomSnap: { kind: 'num', attr: 'zoom-snap', default: 1 },
zoomDelta: { kind: 'num', attr: 'zoom-delta', default: 1 },
keyboardPanDelta: { kind: 'num', attr: 'keyboard-pan-delta', default: 80 },
wheelDebounceTime: { kind: 'num', attr: 'wheel-debounce-time', default: 40 },
wheelPxPerZoomLevel: { kind: 'num', attr: 'wheel-px-per-zoom-level', default: 60 },
inertiaDeceleration: { kind: 'num', attr: 'inertia-deceleration', default: 3000 },
inertiaMaxSpeed: { kind: 'num', attr: 'inertia-max-speed', default: Infinity },
easeLinearity: { kind: 'num', attr: 'ease-linearity', default: 0.2 },
maxBoundsViscosity: { kind: 'num', attr: 'max-bounds-viscosity', default: 0 },
tapTolerance: { kind: 'num', attr: 'tap-tolerance', default: 15 },
zoomAnimationThreshold: { kind: 'num', attr: 'zoom-animation-threshold', default: 4 },
transform3DLimit: { kind: 'num', attr: 'transform-3d-limit', default: 8388608 },
// Boolean defaults-true → disable-* attribute; handler-based options have live mapSet
scrollWheelZoom: {
kind: 'bool-off',
attr: 'disable-scroll-wheel-zoom',
mapSet: (m: LMap, v: boolean) => (v ? m.scrollWheelZoom.enable() : m.scrollWheelZoom.disable()),
},
dragging: {
kind: 'bool-off',
attr: 'disable-dragging',
mapSet: (m: LMap, v: boolean) => (v ? m.dragging.enable() : m.dragging.disable()),
},
touchZoom: {
kind: 'bool-off',
attr: 'disable-touch-zoom',
mapSet: (m: LMap, v: boolean) => (v ? m.touchZoom.enable() : m.touchZoom.disable()),
},
doubleClickZoom: {
kind: 'bool-off',
attr: 'disable-double-click-zoom',
mapSet: (m: LMap, v: boolean) => (v ? m.doubleClickZoom.enable() : m.doubleClickZoom.disable()),
},
boxZoom: {
kind: 'bool-off',
attr: 'disable-box-zoom',
mapSet: (m: LMap, v: boolean) => (v ? m.boxZoom.enable() : m.boxZoom.disable()),
},
keyboard: {
kind: 'bool-off',
attr: 'disable-keyboard',
mapSet: (m: LMap, v: boolean) => (v ? m.keyboard.enable() : m.keyboard.disable()),
},
closePopupOnClick: { kind: 'bool-off', attr: 'disable-close-popup-on-click' },
trackResize: { kind: 'bool-off', attr: 'disable-track-resize' },
zoomControl: { kind: 'bool-off', attr: 'disable-zoom-control' },
attributionControl: { kind: 'bool-off', attr: 'disable-attribution-control' },
inertia: { kind: 'bool-off', attr: 'disable-inertia' },
zoomAnimation: { kind: 'bool-off', attr: 'disable-zoom-animation' },
fadeAnimation: { kind: 'bool-off', attr: 'disable-fade-animation' },
markerZoomAnimation: { kind: 'bool-off', attr: 'disable-marker-zoom-animation' },
bounceAtZoomLimits: { kind: 'bool-off', attr: 'disable-bounce-at-zoom-limits' },
tapHold: { kind: 'bool-off', attr: 'disable-tap-hold' },
// Boolean defaults-false → normal attribute
preferCanvas: { kind: 'bool-on', attr: 'prefer-canvas' },
worldCopyJump: { kind: 'bool-on', attr: 'world-copy-jump' },
} satisfies Record<string, PropDef>;
type PropName = keyof typeof PROPS;
const ATTR_TO_PROP = new globalThis.Map<string, PropName>(
(Object.entries(PROPS) as [PropName, PropDef][]).map(([name, spec]) => [spec.attr, name]),
);
// ── Element ────────────────────────────────────────────────────────────────
// 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;
};
// Cast HTMLElement to a typed base whose instances include PropTypes. This is
// the mixin pattern — TypeScript sees LeafletMap as having all prop types, the
// runtime still extends HTMLElement, and no interface merge is required.
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
export class LeafletMap extends TypedBase {
#map?: LMap;
#container!: HTMLDivElement;
#syncing = false;
#mapEventHandlers = new globalThis.Map<string, () => void>();
static get observedAttributes(): string[] {
return Object.values(PROPS).map((s) => s.attr);
}
// 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 !== undefined ? val : Number(this.getAttribute(spec.attr) ?? spec.default);
},
set(this: LeafletMap, v: number) {
if (spec.event) {
this.#syncAttr(spec.attr, String(v));
} else {
this.setAttribute(spec.attr, String(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,
});
}
}
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.#container = document.createElement('div');
this.#container.style.width = '100%';
this.#container.style.height = '100%';
this.shadowRoot!.appendChild(this.#container);
const style = document.createElement('style');
style.textContent = `:host { display: block; width: 100%; height: 400px; }\n${leafletCSS}`;
this.shadowRoot!.appendChild(style);
}
connectedCallback() {
// 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 = Number(this.getAttribute('lat') ?? 0);
const lng = Number(this.getAttribute('lng') ?? 0);
const zoom = Number(this.getAttribute('zoom') ?? 2);
// 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;
// Register one handler per unique map event, updating all props that share it
const groups = new globalThis.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, String(v));
}
}
};
this.#mapEventHandlers.set(event, handler);
this.#map.on(event, handler);
}
this.addEventListener(
'leaflet-register',
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
);
}
disconnectedCallback() {
this.removeEventListener(
'leaflet-register',
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
);
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;
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, Number(newValue));
} else if (spec.kind === 'bool-off') {
spec.mapSet?.(this.#map, newValue === null);
}
// bool-on: constructor-only, no live update
}
#syncAttr(name: string, value: string) {
if (this.#syncing || this.getAttribute(name) === value) return;
this.#syncing = true;
this.setAttribute(name, value);
this.#syncing = false;
}
#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] = Number(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 as MapOptions;
}
#handleLeafletRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
if (this.#map && e.detail.leafletObject) {
e.detail.leafletObject.addTo(this.#map);
}
};
get map(): LMap | undefined {
return this.#map;
}
}
customElements.define('leaflet-map', LeafletMap);

@ -0,0 +1,28 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletMarker extends LeafletElement {
static get observedAttributes() {
return ['lat', 'lng', 'title', 'alt', 'draggable', 'opacity', 'z-index-offset'];
}
protected createLeafletObject(): L.Layer {
const lat = parseFloat(this.getAttribute('lat') || '0');
const lng = parseFloat(this.getAttribute('lng') || '0');
return new L.Marker([lat, lng], this.options);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.Marker) {
if (property === 'lat' || property === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0');
const lng = parseFloat(this.getAttribute('lng') || '0');
this.leafletObject.setLatLng([lat, lng]);
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-marker', LeafletMarker);

@ -0,0 +1,68 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
import { LeafletLine } from './leaflet-line.js';
export class LeafletPolygon extends LeafletElement {
private _observer?: MutationObserver;
static get observedAttributes() {
return ['color', 'weight', 'opacity', 'fill', 'fill-color', 'fill-opacity'];
}
protected createLeafletObject(): L.Layer {
const coords = this.getCoords();
return new L.Polygon(coords, this.options);
}
private getCoords(): [number, number][] {
const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[];
return lines.map((line) => line.latlng);
}
connectedCallback() {
super.connectedCallback();
this.addEventListener('line-updated', () => {
if (this.leafletObject instanceof L.Polygon) {
this.leafletObject.setLatLngs(this.getCoords());
}
});
this._observer = new MutationObserver(() => {
if (this.leafletObject instanceof L.Polygon) {
this.leafletObject.setLatLngs(this.getCoords());
}
});
this._observer.observe(this, { childList: true });
}
disconnectedCallback() {
this._observer?.disconnect();
this._observer = undefined;
super.disconnectedCallback();
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.Polygon) {
if (
[
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-polygon', LeafletPolygon);

@ -0,0 +1,68 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
import { LeafletLine } from './leaflet-line.js';
export class LeafletPolyline extends LeafletElement {
private _observer?: MutationObserver;
static get observedAttributes() {
return ['color', 'weight', 'opacity', 'fill', 'fill-color', 'fill-opacity'];
}
protected createLeafletObject(): L.Layer {
const coords = this.getCoords();
return new L.Polyline(coords, this.options);
}
private getCoords(): [number, number][] {
const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[];
return lines.map((line) => line.latlng);
}
connectedCallback() {
super.connectedCallback();
this.addEventListener('line-updated', () => {
if (this.leafletObject instanceof L.Polyline) {
this.leafletObject.setLatLngs(this.getCoords());
}
});
this._observer = new MutationObserver(() => {
if (this.leafletObject instanceof L.Polyline) {
this.leafletObject.setLatLngs(this.getCoords());
}
});
this._observer.observe(this, { childList: true });
}
disconnectedCallback() {
this._observer?.disconnect();
this._observer = undefined;
super.disconnectedCallback();
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.Polyline) {
if (
[
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-polyline', LeafletPolyline);

@ -0,0 +1,59 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletPopup extends LeafletElement {
private _observer?: MutationObserver;
static get observedAttributes() {
return [
'lat',
'lng',
'max-width',
'min-width',
'max-height',
'auto-pan',
'close-button',
'auto-close',
];
}
protected createLeafletObject(): L.Layer {
const lat = this.getAttribute('lat');
const lng = this.getAttribute('lng');
const options = { ...this.options, content: this.innerHTML };
const popup = new L.Popup(options);
if (lat && lng) {
popup.setLatLng([parseFloat(lat), parseFloat(lng)]);
}
return popup;
}
connectedCallback() {
super.connectedCallback();
this._observer = new MutationObserver(() => {
if (this.leafletObject instanceof L.Popup) {
this.leafletObject.setContent(this.innerHTML);
}
});
this._observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback() {
this._observer?.disconnect();
this._observer = undefined;
super.disconnectedCallback();
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.Popup) {
if (property === 'lat' || property === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0');
const lng = parseFloat(this.getAttribute('lng') || '0');
this.leafletObject.setLatLng([lat, lng]);
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-popup', LeafletPopup);

@ -0,0 +1,41 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletRectangle extends LeafletElement {
static get observedAttributes() {
return ['bounds', 'color', 'weight', 'opacity', 'fill', 'fill-color', 'fill-opacity'];
}
protected createLeafletObject(): L.Layer {
const bounds = this.options.bounds as L.LatLngBoundsExpression;
return new L.Rectangle(bounds, this.options);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.Rectangle) {
if (property === 'bounds') {
this.leafletObject.setBounds(value as L.LatLngBoundsExpression);
} else if (
[
'color',
'weight',
'opacity',
'fill',
'fillColor',
'fillOpacity',
'stroke',
'dashArray',
'dashOffset',
'lineCap',
'lineJoin',
].includes(property)
) {
this.leafletObject.setStyle({ [property]: value });
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-rectangle', LeafletRectangle);

@ -0,0 +1,31 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletSVGOverlay extends LeafletElement {
static get observedAttributes() {
return ['bounds', 'opacity', 'alt', 'interactive', 'cross-origin', 'z-index', 'className'];
}
protected createLeafletObject(): L.Layer {
const svg = this.querySelector('svg');
const bounds = this.options.bounds as L.LatLngBoundsExpression;
if (!svg) {
// Create a dummy SVG if none provided
const dummy = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
return new L.SVGOverlay(dummy, bounds, this.options);
}
return new L.SVGOverlay(svg, bounds, this.options);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.SVGOverlay) {
if (property === 'bounds') {
this.leafletObject.setBounds(new L.LatLngBounds(value as L.LatLngExpression[]));
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-svg-overlay', LeafletSVGOverlay);

@ -0,0 +1,25 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletTileLayerWMS extends LeafletElement {
static get observedAttributes() {
return ['url', 'layers', 'styles', 'format', 'transparent', 'version', 'crs', 'uppercase'];
}
protected createLeafletObject(): L.Layer {
const url = this.getAttribute('url') || '';
return new L.TileLayer.WMS(url, this.options as L.WMSOptions);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.TileLayer.WMS) {
if (property === 'url') {
this.leafletObject.setUrl(value as string);
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-tile-layer-wms', LeafletTileLayerWMS);

@ -0,0 +1,25 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletTileLayer extends LeafletElement {
static get observedAttributes() {
return ['url', 'attribution', 'min-zoom', 'max-zoom', 'opacity', 'z-index'];
}
protected createLeafletObject(): L.Layer {
const url = this.getAttribute('url') || '';
return new L.TileLayer(url, this.options);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.TileLayer) {
if (property === 'url') {
this.leafletObject.setUrl(value as string);
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-tile-layer', LeafletTileLayer);

@ -0,0 +1,50 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletTooltip extends LeafletElement {
private _observer?: MutationObserver;
static get observedAttributes() {
return ['lat', 'lng', 'pane', 'offset', 'direction', 'permanent', 'sticky', 'opacity'];
}
protected createLeafletObject(): L.Layer {
const lat = this.getAttribute('lat');
const lng = this.getAttribute('lng');
const options = { ...this.options, content: this.innerHTML };
const tooltip = new L.Tooltip(options);
if (lat && lng) {
tooltip.setLatLng([parseFloat(lat), parseFloat(lng)]);
}
return tooltip;
}
connectedCallback() {
super.connectedCallback();
this._observer = new MutationObserver(() => {
if (this.leafletObject instanceof L.Tooltip) {
this.leafletObject.setContent(this.innerHTML);
}
});
this._observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback() {
this._observer?.disconnect();
this._observer = undefined;
super.disconnectedCallback();
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.Tooltip) {
if (property === 'lat' || property === 'lng') {
const lat = parseFloat(this.getAttribute('lat') || '0');
const lng = parseFloat(this.getAttribute('lng') || '0');
this.leafletObject.setLatLng([lat, lng]);
} else {
super.updateLeafletObject(property, value);
}
}
}
}
customElements.define('leaflet-tooltip', LeafletTooltip);

@ -0,0 +1,45 @@
import L from 'leaflet';
import { LeafletElement } from '../core/LeafletElement.js';
export class LeafletVideoOverlay extends LeafletElement {
static get observedAttributes() {
return [
'url',
'bounds',
'opacity',
'alt',
'interactive',
'cross-origin',
'loop',
'autoplay',
'muted',
'playsinline',
];
}
protected createLeafletObject(): L.Layer {
const url = this.getAttribute('url') || '';
const bounds = this.options.bounds as L.LatLngBoundsExpression;
return new L.VideoOverlay(url, bounds, this.options);
}
protected updateLeafletObject(property: string, value: unknown) {
if (this.leafletObject instanceof L.VideoOverlay) {
if (property === 'url') {
this.leafletObject.setUrl(value as string);
} else if (property === 'bounds') {
this.leafletObject.setBounds(new L.LatLngBounds(value as L.LatLngExpression[]));
} else {
super.updateLeafletObject(property, value);
}
}
}
getElement(): HTMLVideoElement | undefined {
return this.leafletObject instanceof L.VideoOverlay
? this.leafletObject.getElement()
: undefined;
}
}
customElements.define('leaflet-video-overlay', LeafletVideoOverlay);

@ -0,0 +1,36 @@
import { Control } from 'leaflet';
export abstract class LeafletControl extends HTMLElement {
protected control?: Control;
static get observedAttributes(): string[] {
return [];
}
connectedCallback() {
this.control = this.createControl();
this.register();
}
disconnectedCallback() {
this.control?.remove();
this.control = undefined;
}
protected abstract createControl(): Control;
protected register() {
if (this.control) {
this.dispatchEvent(
new CustomEvent('leaflet-register', {
detail: {
leafletObject: this.control,
element: this,
},
bubbles: true,
composed: true,
}),
);
}
}
}

@ -0,0 +1,141 @@
import { Layer, Control, Popup, Tooltip, LayerGroup } from 'leaflet';
export interface LeafletRegisterEvent extends CustomEvent {
detail: {
leafletObject: Layer | Control;
element: HTMLElement;
};
}
export abstract class LeafletElement extends HTMLElement {
protected leafletObject?: Layer;
protected options: Record<string, unknown> = {};
protected _parentLayer?: Layer;
protected _parentBindingType?: 'popup' | 'tooltip' | 'layer';
private _registerHandler?: EventListener;
static get observedAttributes(): string[] {
return [];
}
connectedCallback() {
this.initOptions();
this.leafletObject = this.createLeafletObject();
this._registerHandler = ((e: LeafletRegisterEvent) => {
if (this.leafletObject) {
const childObj = e.detail.leafletObject;
const childEl = e.detail.element;
if (childObj instanceof Popup) {
e.stopPropagation();
this.leafletObject.bindPopup(childObj);
if (childEl instanceof LeafletElement) {
childEl._parentLayer = this.leafletObject;
childEl._parentBindingType = 'popup';
}
} else if (childObj instanceof Tooltip) {
e.stopPropagation();
this.leafletObject.bindTooltip(childObj);
if (childEl instanceof LeafletElement) {
childEl._parentLayer = this.leafletObject;
childEl._parentBindingType = 'tooltip';
}
} else if (childObj instanceof Layer && 'addLayer' in this.leafletObject) {
e.stopPropagation();
(this.leafletObject as LayerGroup).addLayer(childObj);
if (childEl instanceof LeafletElement) {
childEl._parentLayer = this.leafletObject;
childEl._parentBindingType = 'layer';
}
}
}
}) as EventListener;
this.addEventListener('leaflet-register', this._registerHandler);
this.register();
}
disconnectedCallback() {
if (this._registerHandler) {
this.removeEventListener('leaflet-register', this._registerHandler);
this._registerHandler = undefined;
}
if (!this.leafletObject) return;
if (this._parentBindingType === 'popup' && this._parentLayer) {
this._parentLayer.unbindPopup();
} else if (this._parentBindingType === 'tooltip' && this._parentLayer) {
this._parentLayer.unbindTooltip();
} else if (
this._parentBindingType === 'layer' &&
this._parentLayer &&
'removeLayer' in this._parentLayer
) {
(this._parentLayer as LayerGroup).removeLayer(this.leafletObject);
} else {
this.leafletObject.remove();
}
this._parentLayer = undefined;
this._parentBindingType = undefined;
this.leafletObject = undefined;
}
protected initOptions() {
const observed = (this.constructor as typeof LeafletElement).observedAttributes;
observed.forEach((attr) => {
const val = this.getAttribute(attr);
if (val !== null) {
this.options[camelCase(attr)] = parseAttributeValue(val);
}
});
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue === newValue) return;
const propertyName = camelCase(name);
const parsedValue = parseAttributeValue(newValue);
this.options[propertyName] = parsedValue;
if (this.leafletObject) {
this.updateLeafletObject(propertyName, parsedValue);
}
}
protected abstract createLeafletObject(): Layer;
protected updateLeafletObject(property: string, value: unknown) {
const setter = `set${property.charAt(0).toUpperCase()}${property.slice(1)}`;
const obj = this.leafletObject as unknown as Record<string, unknown>;
if (obj && typeof obj[setter] === 'function') {
(obj[setter] as (val: unknown) => void)(value);
}
}
protected register() {
if (!this.leafletObject) return;
const event = new CustomEvent('leaflet-register', {
detail: {
leafletObject: this.leafletObject,
element: this,
},
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
}
}
function camelCase(str: string): string {
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
}
function parseAttributeValue(value: string): unknown {
if (value === 'true') return true;
if (value === 'false') return false;
const num = Number(value);
if (!isNaN(num) && value !== '') return num;
try {
return JSON.parse(value);
} catch {
return value;
}
}

@ -0,0 +1,23 @@
export * from './core/LeafletElement.js';
export * from './core/LeafletControl.js';
export * from './components/leaflet-map.js';
export * from './components/leaflet-marker.js';
export * from './components/leaflet-circle.js';
export * from './components/leaflet-circle-marker.js';
export * from './components/leaflet-line.js';
export * from './components/leaflet-polygon.js';
export * from './components/leaflet-polyline.js';
export * from './components/leaflet-rectangle.js';
export * from './components/leaflet-tile-layer.js';
export * from './components/leaflet-tile-layer-wms.js';
export * from './components/leaflet-image-overlay.js';
export * from './components/leaflet-video-overlay.js';
export * from './components/leaflet-svg-overlay.js';
export * from './components/leaflet-layer-group.js';
export * from './components/leaflet-feature-group.js';
export * from './components/leaflet-geojson.js';
export * from './components/leaflet-control-zoom.js';
export * from './components/leaflet-control-attribution.js';
export * from './components/leaflet-control-scale.js';
export * from './components/leaflet-popup.js';
export * from './components/leaflet-tooltip.js';

@ -0,0 +1,4 @@
declare module '*.css' {
const content: string;
export default content;
}

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
},
"include": ["src/**/*"]
}
Loading…
Cancel
Save