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
commit
6b448ba092
@ -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,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="© <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"
|
||||
}
|
||||
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…
Reference in New Issue