refactor: complete WithProps mixin, generic event forwarding, and full prop coverage
Finishes the WithProps(PROPS, options) mixin (src/core/with-props.ts): every
component gets reactive attributes, live-value property getters that read
through to the Leaflet object where possible, and every Leaflet event
re-emitted on the element as `leaflet:<type>` via a generic fire() patch (no
per-component or per-event registration needed).
Prop coverage:
- Centralized pathProps in shared-props.ts (stroke, lineCap, lineJoin,
dashArray, dashOffset, fillRule, interactive, className,
bubblingMouseEvents, pane), deduped leaflet-geojson against it.
- Added live getters backed by Leaflet's own accessors: radius (circle,
circle-marker), bounds (rectangle, image/video/svg-overlay), url
(image/video-overlay).
- Filled gaps: smoothFactor/noClip on polyline; a shared tileLayerProps
fragment now used by both tile-layer and tile-layer-wms (WMS previously
exposed none of its base tile options); crs on WMS; zIndex/className/
keepAspectRatio/errorOverlayUrl on video-overlay; fixed tile-layer's
zIndex default and div-icon's className default to match Leaflet.
Tooling:
- Removed dead src/core/events.ts (broken, unused, superseded by the
generic event forwarding above) and src/core/attributes.ts (emptied by
an earlier rename, nothing imported it).
- Swapped ESLint + @typescript-eslint for oxlint: no released
@typescript-eslint version supports the pinned typescript@7, even for
parsing alone. oxlint has its own parser and lints clean.
- Dropped the Rollup CJS/UMD bundle step; dist/ is ESM-only from tsc now.
Updated package.json's main/module/exports/unpkg accordingly.
- Updated CLAUDE.md to match: build/lint commands, ESM-only output, the
event-forwarding mechanism, and layer-group/feature-group now going
through WithProps({}) instead of raw HTMLElement.
This commit is contained in:
parent
bdf5bd56f3
commit
14e35846d5
17
.oxlintrc.json
Normal file
17
.oxlintrc.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"pedantic": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"eslint/max-lines": "off",
|
||||
"eslint/max-lines-per-function": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"ignorePatterns": ["dist/"]
|
||||
}
|
||||
14
CLAUDE.md
14
CLAUDE.md
@ -5,12 +5,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run build # tsc emits individual ESM modules, then Rollup bundles ESM/CJS/UMD
|
||||
npm run build # tsc emits individual ESM modules to dist/ (no bundling)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # ESLint over src/**/*.{ts,js}
|
||||
npm run lint # oxlint over src/
|
||||
npm run format # Prettier formatting
|
||||
```
|
||||
|
||||
Linting runs on `oxlint`, not ESLint/`@typescript-eslint`. This project pins `typescript@^7.0.2`, and `@typescript-eslint` has no released version that supports it (peer range caps at `<6.1.0`, and even loading `@typescript-eslint/parser` crashes against TS 7's package shape). `oxlint` has its own parser and doesn't touch the `typescript` package, so it works regardless of TS version — the tradeoff is no type-aware rules (no `no-floating-promises`, `no-unnecessary-condition`, etc.). Revisit once `@typescript-eslint` supports TS 7.
|
||||
|
||||
There are no tests in this project. To preview components locally, open `index.html` in a browser with any static-file server.
|
||||
|
||||
## Architecture
|
||||
@ -23,9 +25,9 @@ The `WithProps(Base, PROPS)` mixin factory replaces the old `LeafletElement` bas
|
||||
|
||||
- `observedAttributes` getter derived from the PROPS table keys.
|
||||
- `definePropAccessors` — property getters/setters on the prototype that sync attributes.
|
||||
- `parseAttributeValue` for boolean, number, and JSON coersion in `attributeChangedCallback`.
|
||||
- On connect, `initOptions()` builds a Leaflet options object from current attributes + PROPS defaults, then calls `createLeafletObject()`.
|
||||
- On attribute change, `updateLeafletObject()` by default dispatches to the matching Leaflet setter (e.g. `setOpacity`, `setRadius`). Components override this for custom attribute handling (e.g. lat/lng pairs).
|
||||
- Every Leaflet event the created object fires is re-emitted on the element as `leaflet:<type>` (e.g. `leaflet:zoomend`, `leaflet:dragend`), carrying the original Leaflet event object as `event.detail`. This is generic and automatic: `WithProps` wraps the object's own `fire()` method, so no per-component or per-event-type registration is needed. Not bubbling — Leaflet already propagates layer events up to the map, so `<leaflet-map>` would otherwise see each one twice.
|
||||
|
||||
### `leaflet-map`: `src/components/leaflet-map.ts`
|
||||
|
||||
@ -41,14 +43,12 @@ All non-map components extend `WithProps(HTMLElement, PROPS)`. To add a new comp
|
||||
4. Call `customElements.define('leaflet-foo', LeafletFoo)` at the bottom.
|
||||
5. Export from `src/index.ts`.
|
||||
|
||||
For components that accept children (layers, popups, tooltips), use the `registerChildren`/`unregisterChildren`/`getChildren` WeakMap-backed helpers from `src/core/register.ts` in `connectedCallback`/`disconnectedCallback` instead of maintaining private fields.
|
||||
|
||||
### Special cases
|
||||
|
||||
- **`leaflet-polygon`** uses `<leaflet-line>` children for vertices. The polygon collects lat/lng from child `leaflet-line` elements rather than having them as direct attributes.
|
||||
- **`leaflet-popup`** / **`leaflet-tooltip`**: content comes from `innerHTML`, not attributes. `leaflet-popup` watches for DOM mutations to keep Leaflet in sync.
|
||||
- **`leaflet-layer-group`** / **`leaflet-feature-group`**: passthrough containers extending `HTMLElement` directly (not via `WithProps`). Children register themselves into them via the standard bubble mechanism.
|
||||
- **`leaflet-layer-group`** / **`leaflet-feature-group`**: passthrough containers built with `WithProps({})` (an empty props table) — they have no options of their own, but still get the standard lifecycle, child registration, and `leaflet:` event forwarding for free. Children register themselves into them via the standard bubble mechanism.
|
||||
|
||||
### Output
|
||||
|
||||
`tsc` compiles `src/` → `dist/` as individual ESM modules (`.js` + `.d.ts` + `.d.ts.map`), then `rollup -c` bundles `dist/index.js` (ESM), `dist/index.cjs` (CJS), and `dist/index.umd.js` (UMD) — each with minified variants. Leaflet is always external (never bundled). Imports within source use `.ts` extensions; `rewriteRelativeImportExtensions` in tsconfig strips them to `.js` in the tsc output (`tsconfig.json:16`).
|
||||
`tsc` compiles `src/` → `dist/` as individual ESM modules (`.js` + `.d.ts` + `.d.ts.map`) — no bundling step. Consumers import `dist/index.js` (or any individual module) directly; there is no CJS or UMD build. Leaflet is always external (never bundled). Imports within source use `.ts` extensions; `rewriteRelativeImportExtensions` in tsconfig strips them to `.js` in the tsc output (`tsconfig.json:16`).
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ['dist/'],
|
||||
},
|
||||
|
||||
...tsPlugin.configs['flat/strict-type-checked'].map(c => ({
|
||||
...c,
|
||||
files: ['**/*.ts'],
|
||||
})),
|
||||
|
||||
...tsPlugin.configs['flat/stylistic-type-checked'].map(c => ({
|
||||
...c,
|
||||
files: ['**/*.ts'],
|
||||
})),
|
||||
|
||||
|
||||
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: { project: true },
|
||||
},
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
rules: {
|
||||
...prettierConfig.rules,
|
||||
'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
2414
package-lock.json
generated
2414
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@ -2,15 +2,13 @@
|
||||
"name": "leaflet-components",
|
||||
"version": "0.1.0",
|
||||
"description": "LeafletJS as Web Components",
|
||||
"main": "dist/index.cjs",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"unpkg": "dist/index.umd.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./dist/*": "./dist/*",
|
||||
"./package.json": "./package.json"
|
||||
@ -21,9 +19,9 @@
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc --outDir dist && rollup -c rollup.config.mjs",
|
||||
"build": "rm -rf dist && tsc --outDir dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint 'src/**/*.{ts,js}'",
|
||||
"lint": "oxlint src",
|
||||
"format": "prettier --write 'src/**/*.{ts,js,json,md}'",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
@ -39,19 +37,9 @@
|
||||
"leaflet": "1.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
"@rollup/plugin-typescript": "^12.3.0",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"eslint": "10.4.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-prettier": "5.5.6",
|
||||
"oxlint": "^1.79.0",
|
||||
"prettier": "3.8.3",
|
||||
"rollup": "^4.61.1",
|
||||
"rollup-plugin-dts": "^6.4.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import dts from 'rollup-plugin-dts';
|
||||
|
||||
export default [
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
external: ['leaflet'],
|
||||
plugins: [typescript({ declaration: false, declarationMap: false })],
|
||||
output: [
|
||||
{ format: 'es', file: 'dist/index.js' },
|
||||
{ format: 'es', file: 'dist/index.min.js', plugins: [terser()] },
|
||||
{ format: 'cjs', file: 'dist/index.cjs' },
|
||||
{ format: 'cjs', file: 'dist/index.min.cjs', plugins: [terser()] },
|
||||
{ format: 'umd', name: 'LeafletComponents', file: 'dist/index.umd.js', globals: { leaflet: 'L' } },
|
||||
{ format: 'umd', name: 'LeafletComponents', file: 'dist/index.umd.min.js', globals: { leaflet: 'L' }, plugins: [terser()] },
|
||||
],
|
||||
},
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
external: ['leaflet'],
|
||||
plugins: [dts()],
|
||||
output: { format: 'es', file: 'dist/index.d.ts' },
|
||||
},
|
||||
];
|
||||
@ -1,66 +1,17 @@
|
||||
import { CircleMarker } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { isPathStyleAttr, numAttr, updatePathStyle } from '../core/attributes.ts';
|
||||
import { CircleMarker, type CircleMarkerOptions } from 'leaflet';
|
||||
import { num } from '../core/props.ts';
|
||||
import { latLngProps, pathProps } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
lat: num(),
|
||||
lng: num(),
|
||||
radius: num(10),
|
||||
color: str('#3388ff'),
|
||||
weight: num(3),
|
||||
opacity: num(1.0),
|
||||
fill: on(),
|
||||
fillColor: str('#3388ff'),
|
||||
fillOpacity: num(0.2),
|
||||
});
|
||||
export class LeafletCircleMarker extends WithProps({
|
||||
...latLngProps,
|
||||
radius: num<CircleMarker>(10, { get: (obj) => obj.getRadius() }),
|
||||
...pathProps,
|
||||
}) {
|
||||
declare readonly leafletObject?: CircleMarker;
|
||||
|
||||
export class LeafletCircleMarker extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: CircleMarker;
|
||||
#syncing = false;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new CircleMarker(
|
||||
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
|
||||
buildOptions(this, PROPS, ['lat', 'lng']),
|
||||
);
|
||||
// The `on` method binds the function to `this`, we can ignore the error
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj.on('move', this.#onMove, this);
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// The `off` method binds the function to `this`, we can ignore the error
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj?.off('move', this.#onMove, this);
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
if (name === 'lat' || name === 'lng') {
|
||||
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
|
||||
} else if (name === 'radius') {
|
||||
this.#obj.setRadius(numAttr(this, PROPS, 'radius'));
|
||||
} else if (isPathStyleAttr(name)) {
|
||||
updatePathStyle(this.#obj, name, val);
|
||||
}
|
||||
}
|
||||
|
||||
#onMove() {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
this.#syncing = true;
|
||||
const pos = this.#obj.getLatLng();
|
||||
this.setAttribute('lat', `${pos.lat}`);
|
||||
this.setAttribute('lng', `${pos.lng}`);
|
||||
this.#syncing = false;
|
||||
createLeafletObject(options: CircleMarkerOptions): CircleMarker {
|
||||
return new CircleMarker([this.lat, this.lng], options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,66 +1,19 @@
|
||||
import { Circle } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { isPathStyleAttr, numAttr, updatePathStyle } from '../core/attributes.ts';
|
||||
import { Circle, type CircleOptions } from 'leaflet';
|
||||
import { num, positional } from '../core/props.ts';
|
||||
import { latLngProps, pathProps } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
lat: num(),
|
||||
lng: num(),
|
||||
radius: num(1000),
|
||||
color: str('#3388ff'),
|
||||
weight: num(3),
|
||||
opacity: num(1.0),
|
||||
fill: on(),
|
||||
fillColor: str('#3388ff'),
|
||||
fillOpacity: num(0.2),
|
||||
});
|
||||
export class LeafletCircle extends WithProps({
|
||||
...latLngProps,
|
||||
...pathProps,
|
||||
// Leaflet has no default radius -- it throws without one -- so unlike every
|
||||
// other option, this one is always passed, from the default when unset.
|
||||
radius: positional(num<Circle>(1000, { get: (obj) => obj.getRadius() })),
|
||||
}) {
|
||||
declare readonly leafletObject?: Circle;
|
||||
|
||||
export class LeafletCircle extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Circle;
|
||||
#syncing = false;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Circle(
|
||||
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
|
||||
buildOptions(this, PROPS, ['lat', 'lng']),
|
||||
);
|
||||
// the on method binds the function to `this`, we can ignore the error
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj.on('move', this.#onMove, this);
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// the off method binds the function to `this`, we can ignore the error
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj?.off('move', this.#onMove, this);
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
if (name === 'lat' || name === 'lng') {
|
||||
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
|
||||
} else if (name === 'radius') {
|
||||
this.#obj.setRadius(numAttr(this, PROPS, 'radius'));
|
||||
} else if (isPathStyleAttr(name)) {
|
||||
updatePathStyle(this.#obj, name, val);
|
||||
}
|
||||
}
|
||||
|
||||
#onMove() {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
this.#syncing = true;
|
||||
const pos = this.#obj.getLatLng();
|
||||
this.setAttribute('lat', `${pos.lat}`);
|
||||
this.setAttribute('lng', `${pos.lng}`);
|
||||
this.#syncing = false;
|
||||
createLeafletObject(options: CircleOptions): Circle {
|
||||
return new Circle([this.lat, this.lng], { ...options, radius: this.radius });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,31 +1,18 @@
|
||||
import { Control, ControlPosition } from 'leaflet';
|
||||
import { WithProps, defineProps, str } from '../core/props.ts';
|
||||
import { Control, type ControlPosition } from 'leaflet';
|
||||
import { choice, str } from '../core/props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
import { registerWithParent } from '../core/register.ts';
|
||||
export class LeafletControlAttribution extends WithProps(
|
||||
{
|
||||
position: choice<ControlPosition>('bottomright'),
|
||||
prefix: str(),
|
||||
},
|
||||
{ attach: 'self' },
|
||||
) {
|
||||
declare readonly leafletObject?: Control.Attribution;
|
||||
|
||||
const PROPS = defineProps({
|
||||
position: str('bottomright'),
|
||||
prefix: str(),
|
||||
});
|
||||
|
||||
export class LeafletControlAttribution extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Control.Attribution;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Control.Attribution({
|
||||
position: this.getAttribute('position') as ControlPosition | undefined,
|
||||
prefix: this.getAttribute('prefix') ?? undefined,
|
||||
});
|
||||
registerWithParent(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
createLeafletObject(options: Control.AttributionOptions): Control.Attribution {
|
||||
return new Control.Attribution(options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,78 +1,81 @@
|
||||
import { Control, ControlPosition, Layer } from 'leaflet';
|
||||
import { WithProps, defineProps, str } from '../core/props.ts';
|
||||
import { LeafletRegisterEvent, registerWithParent } from '../core/register.ts';
|
||||
import { Control, type ControlPosition, type Layer } from 'leaflet';
|
||||
import { bool, choice } from '../core/props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
import type { LeafletRegisterEvent } from '../core/register.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
position: str('topright'),
|
||||
});
|
||||
interface ChildLayer {
|
||||
layer: Layer;
|
||||
name: string;
|
||||
base: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export class LeafletControlLayers extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Control.Layers;
|
||||
export class LeafletControlLayers extends WithProps(
|
||||
{
|
||||
position: choice<ControlPosition>('topright'),
|
||||
collapsed: bool(true),
|
||||
autoZIndex: bool(true),
|
||||
hideSingleBase: bool(),
|
||||
sortLayers: bool(),
|
||||
},
|
||||
{ attach: 'self' },
|
||||
) {
|
||||
declare readonly leafletObject?: Control.Layers;
|
||||
|
||||
connectedCallback() {
|
||||
// Children are read straight off the DOM rather than through registration:
|
||||
// any that already exist are listed as base layers or overlays by their
|
||||
// `name`, and `type="base"` / `active` decide how.
|
||||
createLeafletObject(options: Control.LayersOptions): Control.Layers {
|
||||
const baseLayers: Record<string, Layer> = {};
|
||||
const overlays: Record<string, Layer> = {};
|
||||
const inactiveLayers: Layer[] = [];
|
||||
|
||||
for (const child of this.querySelectorAll(':scope > *')) {
|
||||
const layer = (child as unknown as { leafletObject?: Layer }).leafletObject;
|
||||
const name = child.getAttribute('name');
|
||||
if (!name || !layer) continue;
|
||||
const base = child.getAttribute('type') === 'base';
|
||||
const active = child.hasAttribute('active');
|
||||
(base ? baseLayers : overlays)[name] = layer;
|
||||
if (!active) inactiveLayers.push(layer);
|
||||
for (const child of this.#childLayers()) {
|
||||
(child.base ? baseLayers : overlays)[child.name] = child.layer;
|
||||
}
|
||||
return new Control.Layers(baseLayers, overlays, options);
|
||||
}
|
||||
|
||||
for (const layer of inactiveLayers) {
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
for (const child of this.#childLayers()) {
|
||||
if (child.active) continue;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('leaflet-remove-layer', { bubbles: true, detail: { layer } }),
|
||||
new CustomEvent('leaflet-remove-layer', { bubbles: true, detail: { layer: child.layer } }),
|
||||
);
|
||||
}
|
||||
|
||||
this.#obj = new Control.Layers(baseLayers, overlays, {
|
||||
position: this.getAttribute('position') as ControlPosition | undefined,
|
||||
collapsed: !this.hasAttribute('collapsed') || this.getAttribute('collapsed') !== 'false',
|
||||
autoZIndex:
|
||||
!this.hasAttribute('auto-z-index') || this.getAttribute('auto-z-index') !== 'false',
|
||||
hideSingleBase: this.hasAttribute('hide-single-base'),
|
||||
sortLayers: this.hasAttribute('sort-layers'),
|
||||
});
|
||||
|
||||
registerWithParent(this, this.#obj);
|
||||
this.addEventListener('leaflet-register', this.#onChildRegister);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener('leaflet-register', this.#onChildRegister);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string) {
|
||||
if (name === 'position' && this.#obj) {
|
||||
this.#obj.setPosition(this.getAttribute('position') as ControlPosition);
|
||||
#childLayers(): ChildLayer[] {
|
||||
const children: ChildLayer[] = [];
|
||||
for (const child of this.querySelectorAll(':scope > *')) {
|
||||
const layer = (child as { leafletObject?: Layer }).leafletObject;
|
||||
const name = child.getAttribute('name');
|
||||
if (!name || !layer) continue;
|
||||
children.push({
|
||||
layer,
|
||||
name,
|
||||
base: child.getAttribute('type') === 'base',
|
||||
active: child.hasAttribute('active'),
|
||||
});
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
// Children that connect after us announce themselves instead.
|
||||
#onChildRegister = (e: LeafletRegisterEvent) => {
|
||||
e.stopPropagation();
|
||||
const el = e.detail.element;
|
||||
const layer = e.detail.leafletObject;
|
||||
const name = el.getAttribute('name');
|
||||
if (!name) return;
|
||||
const base = el.getAttribute('type') === 'base';
|
||||
const active = el.hasAttribute('active');
|
||||
if (base) {
|
||||
this.#obj?.addBaseLayer(layer, name);
|
||||
} else {
|
||||
this.#obj?.addOverlay(layer, name);
|
||||
}
|
||||
if (active) {
|
||||
if (el.getAttribute('type') === 'base') this.leafletObject?.addBaseLayer(layer, name);
|
||||
else this.leafletObject?.addOverlay(layer, name);
|
||||
if (el.hasAttribute('active')) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('leaflet-add-layer', { bubbles: true, detail: { layer } }),
|
||||
);
|
||||
|
||||
@ -1,38 +1,21 @@
|
||||
import { Control, ControlPosition } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on } from '../core/props.ts';
|
||||
import { Control, type ControlPosition } from 'leaflet';
|
||||
import { bool, choice, num } from '../core/props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
import { registerWithParent } from '../core/register.ts';
|
||||
export class LeafletControlScale extends WithProps(
|
||||
{
|
||||
position: choice<ControlPosition>('bottomleft'),
|
||||
maxWidth: num(100),
|
||||
metric: bool(true),
|
||||
imperial: bool(true),
|
||||
updateWhenIdle: bool(),
|
||||
},
|
||||
{ attach: 'self' },
|
||||
) {
|
||||
declare readonly leafletObject?: Control.Scale;
|
||||
|
||||
const PROPS = defineProps({
|
||||
position: str('bottomleft'),
|
||||
maxWidth: num(100),
|
||||
metric: on(),
|
||||
imperial: on(),
|
||||
updateWhenIdle: on(),
|
||||
});
|
||||
|
||||
export class LeafletControlScale extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Control.Scale;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Control.Scale({
|
||||
position: this.getAttribute('position') as ControlPosition | undefined,
|
||||
maxWidth: +(this.getAttribute('max-width') ?? 100),
|
||||
metric: !this.hasAttribute('metric') || this.getAttribute('metric') !== 'false',
|
||||
imperial: !this.hasAttribute('imperial') || this.getAttribute('imperial') !== 'false',
|
||||
updateWhenIdle:
|
||||
!this.hasAttribute('update-when-idle') || this.getAttribute('update-when-idle') !== 'false',
|
||||
});
|
||||
registerWithParent(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
createLeafletObject(options: Control.ScaleOptions): Control.Scale {
|
||||
return new Control.Scale(options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,37 +1,23 @@
|
||||
import { Control, ControlPosition } from 'leaflet';
|
||||
import { WithProps, defineProps, str } from '../core/props.ts';
|
||||
import { Control, type ControlPosition } from 'leaflet';
|
||||
import { choice, str } from '../core/props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
import { registerWithParent } from '../core/register.ts';
|
||||
// The button labels default to Leaflet's own markup, which hides the glyph
|
||||
// from screen readers in favour of the title.
|
||||
export class LeafletControlZoom extends WithProps(
|
||||
{
|
||||
position: choice<ControlPosition>('topleft'),
|
||||
zoomInText: str('<span aria-hidden="true">+</span>'),
|
||||
zoomInTitle: str('Zoom in'),
|
||||
zoomOutText: str('<span aria-hidden="true">−</span>'),
|
||||
zoomOutTitle: str('Zoom out'),
|
||||
},
|
||||
{ attach: 'self' },
|
||||
) {
|
||||
declare readonly leafletObject?: Control.Zoom;
|
||||
|
||||
const PROPS = defineProps({
|
||||
position: str('topleft'),
|
||||
zoomInText: str('+'),
|
||||
zoomInTitle: str('Zoom in'),
|
||||
zoomOutText: str('-'),
|
||||
zoomOutTitle: str('Zoom out'),
|
||||
});
|
||||
|
||||
export class LeafletControlZoom extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Control.Zoom;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Control.Zoom({
|
||||
position: this.getAttribute('position') as ControlPosition | undefined,
|
||||
zoomInText: this.getAttribute('zoom-in-text') ?? '+',
|
||||
zoomInTitle: this.getAttribute('zoom-in-title') ?? 'Zoom in',
|
||||
zoomOutText: this.getAttribute('zoom-out-text') ?? '-',
|
||||
zoomOutTitle: this.getAttribute('zoom-out-title') ?? 'Zoom out',
|
||||
});
|
||||
registerWithParent(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
createLeafletObject(options: Control.ZoomOptions): Control.Zoom {
|
||||
return new Control.Zoom(options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,76 +1,47 @@
|
||||
import { DivIcon, DivIconOptions } from 'leaflet';
|
||||
import { defineProps, str } from '../core/props.ts';
|
||||
import { DivIcon, type DivIconOptions, type PointExpression } from 'leaflet';
|
||||
import { json, str } from '../core/props.ts';
|
||||
import { emitIconChanged } from '../core/register.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
iconSize: str(),
|
||||
iconAnchor: str(),
|
||||
popupAnchor: str(),
|
||||
tooltipAnchor: str(),
|
||||
className: str(),
|
||||
html: str(),
|
||||
bgPos: str(),
|
||||
});
|
||||
// Like leaflet-icon, rebuilt on every change -- including changes to the
|
||||
// markup, which is what the icon renders when `html` isn't set.
|
||||
export class LeafletDivIcon extends WithProps(
|
||||
{
|
||||
iconSize: json<PointExpression>([0, 0]),
|
||||
iconAnchor: json<PointExpression>([0, 0]),
|
||||
popupAnchor: json<PointExpression>([0, 0]),
|
||||
tooltipAnchor: json<PointExpression>([0, 0]),
|
||||
className: str('leaflet-div-icon'),
|
||||
html: str(),
|
||||
bgPos: json<PointExpression>([0, 0]),
|
||||
},
|
||||
{ attach: 'none', recreate: true },
|
||||
) {
|
||||
declare readonly leafletObject?: DivIcon;
|
||||
|
||||
const JSON_KEYS = new Set<keyof typeof PROPS>([
|
||||
'iconSize',
|
||||
'iconAnchor',
|
||||
'popupAnchor',
|
||||
'tooltipAnchor',
|
||||
'bgPos',
|
||||
]);
|
||||
|
||||
export class LeafletDivIcon extends HTMLElement {
|
||||
#obj?: DivIcon;
|
||||
#observer?: MutationObserver;
|
||||
|
||||
connectedCallback() {
|
||||
this.#applyIcon();
|
||||
emitIconChanged(this, this.#obj);
|
||||
|
||||
this.#observer = new MutationObserver(() => {
|
||||
this.#applyIcon();
|
||||
emitIconChanged(this, this.#obj);
|
||||
});
|
||||
this.#observer.observe(this, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
createLeafletObject(options: DivIconOptions): DivIcon {
|
||||
return new DivIcon(this.innerHTML ? { ...options, html: this.innerHTML } : options);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.#observer = new MutationObserver(() => {
|
||||
this.recreateLeafletObject();
|
||||
});
|
||||
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#observer?.disconnect();
|
||||
this.#observer = undefined;
|
||||
super.disconnectedCallback();
|
||||
emitIconChanged(this, null);
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return Object.values(PROPS).map((s) => s.attr);
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.#applyIcon();
|
||||
if (!this.#obj) return;
|
||||
emitIconChanged(this, this.#obj);
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
#applyIcon() {
|
||||
const opts: Partial<DivIconOptions> = {};
|
||||
for (const [key, spec] of Object.entries(PROPS)) {
|
||||
const v = this.getAttribute(spec.attr);
|
||||
if (v === null) continue;
|
||||
const typedKey = key as keyof typeof PROPS;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
opts[typedKey as keyof DivIconOptions] = JSON_KEYS.has(typedKey) ? JSON.parse(v) : v;
|
||||
}
|
||||
const content = this.innerHTML;
|
||||
if (content) opts.html = content;
|
||||
this.#obj = new DivIcon(opts);
|
||||
leafletObjectCreated(): void {
|
||||
emitIconChanged(this, this.leafletObject);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,33 +1,12 @@
|
||||
import { FeatureGroup, Layer } from 'leaflet';
|
||||
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
|
||||
import { FeatureGroup } from 'leaflet';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
import { buildOptions } from '../core/props.ts';
|
||||
// Like leaflet-layer-group, but its children share events and a bounding box.
|
||||
export class LeafletFeatureGroup extends WithProps({}) {
|
||||
declare readonly leafletObject?: FeatureGroup;
|
||||
|
||||
export class LeafletFeatureGroup extends HTMLElement {
|
||||
#obj?: FeatureGroup;
|
||||
|
||||
static get observedAttributes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new FeatureGroup([], buildOptions(this, {}));
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const [el, type] of getChildren(this) ?? []) {
|
||||
if (type === 'popup') this.#obj?.unbindPopup();
|
||||
else if (type === 'tooltip') this.#obj?.unbindTooltip();
|
||||
else this.#obj?.removeLayer(el as unknown as Layer);
|
||||
}
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
createLeafletObject(): FeatureGroup {
|
||||
return new FeatureGroup([]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,69 +1,26 @@
|
||||
import { GeoJSON, PathOptions, Layer } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
|
||||
import { GeoJSON, type PathOptions } from 'leaflet';
|
||||
import type { GeoJsonObject } from 'geojson';
|
||||
import { json, positional } from '../core/props.ts';
|
||||
import { pathProps } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
data: str(),
|
||||
stroke: str(),
|
||||
color: str('#3388ff'),
|
||||
weight: num(3),
|
||||
opacity: num(1.0),
|
||||
lineCap: str('round'),
|
||||
lineJoin: str('round'),
|
||||
dashArray: str(),
|
||||
dashOffset: str(),
|
||||
fill: on(),
|
||||
fillColor: str('#3388ff'),
|
||||
fillOpacity: num(0.2),
|
||||
fillRule: str('evenodd'),
|
||||
});
|
||||
export class LeafletGeoJSON extends WithProps({
|
||||
data: positional(
|
||||
json<GeoJsonObject | null>(null, {
|
||||
set(obj: GeoJSON, value) {
|
||||
obj.clearLayers();
|
||||
if (value) obj.addData(value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
...pathProps,
|
||||
}) {
|
||||
declare readonly leafletObject?: GeoJSON;
|
||||
|
||||
const PROP_BY_ATTR = new Map<string, string>(
|
||||
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]),
|
||||
);
|
||||
|
||||
export class LeafletGeoJSON extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: GeoJSON;
|
||||
|
||||
connectedCallback() {
|
||||
const raw = this.getAttribute('data');
|
||||
const data = raw ? (JSON.parse(raw) as GeoJsonObject) : null;
|
||||
const styleOpts = Object.fromEntries(
|
||||
Object.entries(buildOptions(this, PROPS, ['data'])).filter(([, v]) => v !== ''),
|
||||
);
|
||||
this.#obj = new GeoJSON(data, { style: styleOpts as PathOptions });
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const [el, type] of getChildren(this) ?? []) {
|
||||
if (type === 'popup') this.#obj?.unbindPopup();
|
||||
else if (type === 'tooltip') this.#obj?.unbindTooltip();
|
||||
else this.#obj?.removeLayer(el as unknown as Layer);
|
||||
}
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (name === 'data') {
|
||||
this.#obj.clearLayers();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (val) this.#obj.addData(JSON.parse(val));
|
||||
} else {
|
||||
const propName = PROP_BY_ATTR.get(name);
|
||||
if (!propName) return;
|
||||
const spec = PROPS[propName as keyof typeof PROPS];
|
||||
const styleVal = spec.kind === 'bool-on' ? val !== null : val;
|
||||
this.#obj.setStyle({ [propName]: styleVal } as PathOptions);
|
||||
}
|
||||
// Every prop but `data` is a style option, and GeoJSON takes those nested
|
||||
// under `style` so they apply to each feature it builds.
|
||||
createLeafletObject(options: PathOptions): GeoJSON {
|
||||
return new GeoJSON(this.data, { style: options });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,69 +1,39 @@
|
||||
import { Icon, IconOptions } from 'leaflet';
|
||||
import { defineProps, str } from '../core/props.ts';
|
||||
import { Icon, type IconOptions, type PointExpression } from 'leaflet';
|
||||
import { json, str } from '../core/props.ts';
|
||||
import { emitIconChanged } from '../core/register.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
const PROPS = {
|
||||
iconUrl: str(),
|
||||
iconRetinaUrl: str(),
|
||||
iconSize: str(),
|
||||
iconAnchor: str(),
|
||||
popupAnchor: str(),
|
||||
tooltipAnchor: str(),
|
||||
iconSize: json<PointExpression>([0, 0]),
|
||||
iconAnchor: json<PointExpression>([0, 0]),
|
||||
popupAnchor: json<PointExpression>([0, 0]),
|
||||
tooltipAnchor: json<PointExpression>([0, 0]),
|
||||
shadowUrl: str(),
|
||||
shadowRetinaUrl: str(),
|
||||
shadowSize: str(),
|
||||
shadowAnchor: str(),
|
||||
shadowSize: json<PointExpression>([0, 0]),
|
||||
shadowAnchor: json<PointExpression>([0, 0]),
|
||||
className: str(),
|
||||
});
|
||||
} as const;
|
||||
|
||||
const JSON_KEYS = new Set([
|
||||
'iconSize',
|
||||
'iconAnchor',
|
||||
'popupAnchor',
|
||||
'tooltipAnchor',
|
||||
'shadowSize',
|
||||
'shadowAnchor',
|
||||
]);
|
||||
// An Icon has no setters, so every attribute change builds a new one and the
|
||||
// parent marker is told to swap it in.
|
||||
export class LeafletIcon extends WithProps(PROPS, { attach: 'none', recreate: true }) {
|
||||
declare readonly leafletObject?: Icon;
|
||||
|
||||
export class LeafletIcon extends HTMLElement {
|
||||
#obj?: Icon;
|
||||
|
||||
connectedCallback() {
|
||||
this.#applyIcon();
|
||||
emitIconChanged(this, this.#obj);
|
||||
createLeafletObject(options: Partial<IconOptions>): Icon | undefined {
|
||||
return options.iconUrl ? new Icon(options as IconOptions) : undefined;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
leafletObjectCreated(): void {
|
||||
emitIconChanged(this, this.leafletObject);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
emitIconChanged(this, null);
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return Object.values(PROPS).map((s) => s.attr);
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.#applyIcon();
|
||||
if (!this.#obj) return;
|
||||
emitIconChanged(this, this.#obj);
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
#applyIcon() {
|
||||
const opts: Record<string, unknown> = {};
|
||||
for (const [key, spec] of Object.entries(PROPS)) {
|
||||
const v = this.getAttribute(spec.attr);
|
||||
if (v === null) continue;
|
||||
opts[key] = JSON_KEYS.has(key) ? JSON.parse(v) : v;
|
||||
}
|
||||
if (opts.iconUrl) {
|
||||
this.#obj = new Icon(opts as unknown as IconOptions);
|
||||
} else {
|
||||
this.#obj = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('leaflet-icon', LeafletIcon);
|
||||
|
||||
@ -1,58 +1,33 @@
|
||||
import { ImageOverlay, LatLngBounds, LatLngExpression } from 'leaflet';
|
||||
import type { ImageOverlayOptions } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts';
|
||||
import {
|
||||
ImageOverlay,
|
||||
type CrossOrigin,
|
||||
type ImageOverlayOptions,
|
||||
type LatLngBoundsExpression,
|
||||
} from 'leaflet';
|
||||
import { bool, choice, json, num, positional, str } from '../core/props.ts';
|
||||
import { getBounds, urlProp } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
url: str(),
|
||||
bounds: str(),
|
||||
export class LeafletImageOverlay extends WithProps({
|
||||
url: urlProp,
|
||||
bounds: positional(json<LatLngBoundsExpression, ImageOverlay>([], { get: getBounds })),
|
||||
opacity: num(1.0),
|
||||
alt: str(),
|
||||
interactive: on(),
|
||||
crossOrigin: str(),
|
||||
alt: str('', {
|
||||
set(obj: ImageOverlay, value) {
|
||||
const el = obj.getElement();
|
||||
if (el) el.alt = value;
|
||||
},
|
||||
}),
|
||||
interactive: bool(),
|
||||
crossOrigin: choice<CrossOrigin>(''),
|
||||
errorOverlayUrl: str(),
|
||||
zIndex: num(),
|
||||
className: str(),
|
||||
});
|
||||
}) {
|
||||
declare readonly leafletObject?: ImageOverlay;
|
||||
|
||||
const PROP_BY_ATTR = buildAttrMap(PROPS);
|
||||
|
||||
export class LeafletImageOverlay extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: ImageOverlay;
|
||||
|
||||
connectedCallback() {
|
||||
const url = this.getAttribute('url') ?? '';
|
||||
this.#obj = new ImageOverlay(
|
||||
url,
|
||||
parseBoundsAttr(this),
|
||||
buildOptions(this, PROPS, ['url', 'bounds']) as ImageOverlayOptions,
|
||||
);
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (name === 'url') {
|
||||
if (val) this.#obj.setUrl(val);
|
||||
} else if (name === 'bounds') {
|
||||
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
|
||||
} else if (name === 'alt') {
|
||||
const el = this.#obj.getElement();
|
||||
if (el) el.alt = val ?? '';
|
||||
} else {
|
||||
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
|
||||
}
|
||||
createLeafletObject(options: ImageOverlayOptions): ImageOverlay {
|
||||
return new ImageOverlay(this.url, this.bounds, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,33 +1,13 @@
|
||||
import { LayerGroup, Layer } from 'leaflet';
|
||||
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
|
||||
import { LayerGroup } from 'leaflet';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
import { buildOptions } from '../core/props.ts';
|
||||
// A passthrough container: it has no options of its own, and children add
|
||||
// themselves to it through the standard registration bubble.
|
||||
export class LeafletLayerGroup extends WithProps({}) {
|
||||
declare readonly leafletObject?: LayerGroup;
|
||||
|
||||
export class LeafletLayerGroup extends HTMLElement {
|
||||
#obj?: LayerGroup;
|
||||
|
||||
static get observedAttributes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new LayerGroup([], buildOptions(this, {}));
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const [el, type] of getChildren(this) ?? []) {
|
||||
if (type === 'popup') this.#obj?.unbindPopup();
|
||||
else if (type === 'tooltip') this.#obj?.unbindTooltip();
|
||||
else this.#obj?.removeLayer(el as unknown as Layer);
|
||||
}
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
createLeafletObject(): LayerGroup {
|
||||
return new LayerGroup([]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,259 +1,172 @@
|
||||
import { Icon, Layer, Map as LMap, MapOptions } from 'leaflet';
|
||||
import { defineProps, num, off, on, type NumProp, type PropDef } from '../core/props.ts';
|
||||
import { LeafletRegisterEvent } from '../core/register.ts';
|
||||
import { Icon, Map as LMap, type MapOptions } from 'leaflet';
|
||||
import { bool, disabled, num, positional, str } from '../core/props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts';
|
||||
|
||||
const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||
|
||||
const CSS_ATTRS = ['css-url', 'css-integrity', 'css-crossorigin'] as const;
|
||||
interface CssHost extends HTMLElement {
|
||||
applyCss(): void;
|
||||
}
|
||||
|
||||
const PROPS = defineProps({
|
||||
// View state — excluded from #buildOptions, initialised via setView()
|
||||
lat: num(0, {
|
||||
viewState: true,
|
||||
event: 'moveend',
|
||||
mapGet: (m: LMap) => m.getCenter().lat,
|
||||
mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter().lng], m.getZoom()),
|
||||
}),
|
||||
lng: num(0, {
|
||||
viewState: true,
|
||||
event: 'moveend',
|
||||
mapGet: (m: LMap) => m.getCenter().lng,
|
||||
mapSet: (m: LMap, v: number) => m.setView([m.getCenter().lat, v], m.getZoom()),
|
||||
}),
|
||||
zoom: num(2, {
|
||||
viewState: true,
|
||||
event: 'zoomend',
|
||||
mapGet: (m: LMap) => m.getZoom(),
|
||||
mapSet: (m: LMap, v: number) => m.setZoom(v),
|
||||
}),
|
||||
// The css-* attributes describe the stylesheet in the shadow root rather than
|
||||
// anything about the Leaflet map, so a change just re-links it.
|
||||
function relinkCss(_map: LMap, _value: string, el: HTMLElement): void {
|
||||
(el as CssHost).applyCss();
|
||||
}
|
||||
|
||||
// Live numeric options — have Leaflet setters
|
||||
minZoom: num(0, {
|
||||
mapGet: (m: LMap) => m.getMinZoom(),
|
||||
mapSet: (m: LMap, v: number) => m.setMinZoom(v),
|
||||
}),
|
||||
maxZoom: num(Infinity, {
|
||||
mapGet: (m: LMap) => m.getMaxZoom(),
|
||||
mapSet: (m: LMap, v: number) => m.setMaxZoom(v),
|
||||
}),
|
||||
// The root of the component tree. Every other component bubbles a
|
||||
// `leaflet-register` event up to here, which is where it stops.
|
||||
export class LeafletMap extends WithProps(
|
||||
{
|
||||
// View state. Not constructor options -- the map is positioned with setView
|
||||
// once it exists -- and written back whenever the user pans or zooms.
|
||||
lat: positional(
|
||||
num<LMap>(0, {
|
||||
event: 'moveend',
|
||||
get: (map) => map.getCenter().lat,
|
||||
set: (map, value) => {
|
||||
map.setView([value, map.getCenter().lng], map.getZoom());
|
||||
},
|
||||
}),
|
||||
),
|
||||
lng: positional(
|
||||
num<LMap>(0, {
|
||||
event: 'moveend',
|
||||
get: (map) => map.getCenter().lng,
|
||||
set: (map, value) => {
|
||||
map.setView([map.getCenter().lat, value], map.getZoom());
|
||||
},
|
||||
}),
|
||||
),
|
||||
zoom: positional(
|
||||
num<LMap>(2, {
|
||||
event: 'zoomend',
|
||||
get: (map) => map.getZoom(),
|
||||
set: (map, value) => {
|
||||
map.setZoom(value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// Constructor-only numeric options
|
||||
zoomSnap: num(1),
|
||||
zoomDelta: num(1),
|
||||
keyboardPanDelta: num(80),
|
||||
wheelDebounceTime: num(40),
|
||||
wheelPxPerZoomLevel: num(60),
|
||||
inertiaDeceleration: num(3000),
|
||||
inertiaMaxSpeed: num(Infinity),
|
||||
easeLinearity: num(0.2),
|
||||
maxBoundsViscosity: num(0),
|
||||
tapTolerance: num(15),
|
||||
zoomAnimationThreshold: num(4),
|
||||
transform3DLimit: num(8388608),
|
||||
// Numeric options Leaflet lets us change after construction.
|
||||
minZoom: num<LMap>(0, {
|
||||
get: (map) => map.getMinZoom(),
|
||||
set: (map, value) => {
|
||||
map.setMinZoom(value);
|
||||
},
|
||||
}),
|
||||
maxZoom: num<LMap>(Infinity, {
|
||||
get: (map) => map.getMaxZoom(),
|
||||
set: (map, value) => {
|
||||
map.setMaxZoom(value);
|
||||
},
|
||||
}),
|
||||
|
||||
// Boolean defaults-true → disable-* attribute; handler-based options have live mapSet
|
||||
scrollWheelZoom: off((m: LMap, v: boolean) =>
|
||||
v ? m.scrollWheelZoom.enable() : m.scrollWheelZoom.disable(),
|
||||
),
|
||||
dragging: off((m: LMap, v: boolean) => (v ? m.dragging.enable() : m.dragging.disable())),
|
||||
touchZoom: off((m: LMap, v: boolean) => (v ? m.touchZoom.enable() : m.touchZoom.disable())),
|
||||
doubleClickZoom: off((m: LMap, v: boolean) =>
|
||||
v ? m.doubleClickZoom.enable() : m.doubleClickZoom.disable(),
|
||||
),
|
||||
boxZoom: off((m: LMap, v: boolean) => (v ? m.boxZoom.enable() : m.boxZoom.disable())),
|
||||
keyboard: off((m: LMap, v: boolean) => (v ? m.keyboard.enable() : m.keyboard.disable())),
|
||||
closePopupOnClick: off(),
|
||||
trackResize: off(),
|
||||
zoomControl: off(),
|
||||
attributionControl: off(),
|
||||
inertia: off(),
|
||||
zoomAnimation: off(),
|
||||
fadeAnimation: off(),
|
||||
markerZoomAnimation: off(),
|
||||
bounceAtZoomLimits: off(),
|
||||
tapHold: off(),
|
||||
// Constructor-only numeric options.
|
||||
zoomSnap: num(1),
|
||||
zoomDelta: num(1),
|
||||
keyboardPanDelta: num(80),
|
||||
wheelDebounceTime: num(40),
|
||||
wheelPxPerZoomLevel: num(60),
|
||||
inertiaDeceleration: num(3000),
|
||||
inertiaMaxSpeed: num(Infinity),
|
||||
easeLinearity: num(0.2),
|
||||
maxBoundsViscosity: num(0),
|
||||
tapTolerance: num(15),
|
||||
zoomAnimationThreshold: num(4),
|
||||
transform3DLimit: num(8388608),
|
||||
|
||||
// Boolean defaults-false → normal attribute
|
||||
preferCanvas: on(),
|
||||
worldCopyJump: on(),
|
||||
});
|
||||
// Options Leaflet defaults to true, turned off with disable-* attributes.
|
||||
// The interaction handlers can be toggled after construction.
|
||||
scrollWheelZoom: disabled<LMap>({
|
||||
set: (map, on) => (on ? map.scrollWheelZoom.enable() : map.scrollWheelZoom.disable()),
|
||||
}),
|
||||
dragging: disabled<LMap>({
|
||||
set: (map, on) => (on ? map.dragging.enable() : map.dragging.disable()),
|
||||
}),
|
||||
touchZoom: disabled<LMap>({
|
||||
set: (map, on) => (on ? map.touchZoom.enable() : map.touchZoom.disable()),
|
||||
}),
|
||||
doubleClickZoom: disabled<LMap>({
|
||||
set: (map, on) => (on ? map.doubleClickZoom.enable() : map.doubleClickZoom.disable()),
|
||||
}),
|
||||
boxZoom: disabled<LMap>({
|
||||
set: (map, on) => (on ? map.boxZoom.enable() : map.boxZoom.disable()),
|
||||
}),
|
||||
keyboard: disabled<LMap>({
|
||||
set: (map, on) => (on ? map.keyboard.enable() : map.keyboard.disable()),
|
||||
}),
|
||||
closePopupOnClick: disabled(),
|
||||
trackResize: disabled(),
|
||||
zoomControl: disabled(),
|
||||
attributionControl: disabled(),
|
||||
inertia: disabled(),
|
||||
zoomAnimation: disabled(),
|
||||
fadeAnimation: disabled(),
|
||||
markerZoomAnimation: disabled(),
|
||||
bounceAtZoomLimits: disabled(),
|
||||
tapHold: disabled(),
|
||||
|
||||
type PropName = keyof typeof PROPS;
|
||||
// Options Leaflet defaults to false.
|
||||
preferCanvas: bool(),
|
||||
worldCopyJump: bool(),
|
||||
|
||||
const ATTR_TO_PROP = new Map<string, PropName>(
|
||||
(Object.entries(PROPS) as [PropName, PropDef][]).map(([name, spec]) => [spec.attr, name]),
|
||||
);
|
||||
// Not Leaflet options at all -- see relinkCss above.
|
||||
cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
|
||||
cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })),
|
||||
cssCrossorigin: positional(str('', { set: relinkCss })),
|
||||
},
|
||||
{ attach: 'none' },
|
||||
) {
|
||||
declare readonly leafletObject?: LMap;
|
||||
|
||||
// Derive property types directly from the PROPS table so adding a prop to the
|
||||
// table automatically makes it part of the LeafletMap instance type.
|
||||
type PropTypes = {
|
||||
[K in keyof typeof PROPS]: (typeof PROPS)[K] extends { kind: 'num' } ? number : boolean;
|
||||
};
|
||||
|
||||
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes;
|
||||
|
||||
export class LeafletMap extends TypedBase {
|
||||
#map?: LMap;
|
||||
#container!: HTMLDivElement;
|
||||
#container?: HTMLDivElement;
|
||||
#cssLink?: HTMLLinkElement;
|
||||
#syncing = false;
|
||||
#mapEventHandlers = new Map<string, () => void>();
|
||||
#resizeObserver?: ResizeObserver;
|
||||
|
||||
static get observedAttributes(): string[] {
|
||||
return [...Object.values(PROPS).map((s) => s.attr), ...CSS_ATTRS];
|
||||
createLeafletObject(options: MapOptions): LMap {
|
||||
const map = new LMap(this.#container ?? this.#buildShadowRoot(), options);
|
||||
// getCenter() and getZoom() throw until the view is set. Reading this.lat,
|
||||
// this.lng and this.zoom here is safe for the same reason: the object
|
||||
// doesn't exist yet, so the getters fall back to the attributes.
|
||||
map.setView([this.lat, this.lng], this.zoom);
|
||||
return map;
|
||||
}
|
||||
|
||||
// Generates a getter/setter on the prototype for every entry in PROPS.
|
||||
// The functions are defined inside the class body so they can access private fields.
|
||||
static {
|
||||
for (const [propName, spec] of Object.entries(PROPS) as [PropName, PropDef][]) {
|
||||
if (spec.kind === 'num') {
|
||||
Object.defineProperty(LeafletMap.prototype, propName, {
|
||||
get(this: LeafletMap) {
|
||||
const val = spec.mapGet && this.#map ? spec.mapGet(this.#map) : undefined;
|
||||
return val ?? +(this.getAttribute(spec.attr) ?? spec.default);
|
||||
},
|
||||
set(this: LeafletMap, v: number) {
|
||||
if (spec.event) {
|
||||
this.#syncAttr(spec.attr, `${v}`);
|
||||
} else {
|
||||
this.setAttribute(spec.attr, `${v}`);
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
} else if (spec.kind === 'bool-off') {
|
||||
Object.defineProperty(LeafletMap.prototype, propName, {
|
||||
get(this: LeafletMap) {
|
||||
return !this.hasAttribute(spec.attr);
|
||||
},
|
||||
set(this: LeafletMap, v: boolean) {
|
||||
this.toggleAttribute(spec.attr, !v);
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
} else {
|
||||
Object.defineProperty(LeafletMap.prototype, propName, {
|
||||
get(this: LeafletMap) {
|
||||
return this.hasAttribute(spec.attr);
|
||||
},
|
||||
set(this: LeafletMap, v: boolean) {
|
||||
this.toggleAttribute(spec.attr, v);
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
connectedCallback(): void {
|
||||
this.#buildShadowRoot();
|
||||
this.applyCss();
|
||||
super.connectedCallback();
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.shadowRoot) {
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.#container = document.createElement('div');
|
||||
this.#container.style.width = '100%';
|
||||
this.#container.style.height = '100%';
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.shadowRoot!.appendChild(this.#container);
|
||||
const style = document.createElement('style');
|
||||
style.textContent = ':host { display: block; width: 100%; height: 400px; }';
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.shadowRoot!.appendChild(style);
|
||||
}
|
||||
this.#applyCss();
|
||||
|
||||
// Read view-state from attributes directly — the getters call getCenter()/getZoom()
|
||||
// which throw if invoked before setView(), so we can't use them here.
|
||||
const lat = +(this.getAttribute('lat') ?? 0);
|
||||
const lng = +(this.getAttribute('lng') ?? 0);
|
||||
const zoom = +(this.getAttribute('zoom') ?? 2);
|
||||
|
||||
// Assign #map only after setView so the getters' `this.#map` guard is
|
||||
// equivalent to "map is ready" — getCenter()/getZoom() throw before setView.
|
||||
const map = new LMap(this.#container, this.#buildOptions());
|
||||
map.setView([lat, lng], zoom);
|
||||
this.#map = map;
|
||||
|
||||
this.#resizeObserver = new ResizeObserver(() => this.#map?.invalidateSize());
|
||||
this.#resizeObserver = new ResizeObserver(() => this.leafletObject?.invalidateSize());
|
||||
this.#resizeObserver.observe(this);
|
||||
|
||||
// Register one handler per unique map event, updating all props that share it
|
||||
const groups = new Map<string, NumProp[]>();
|
||||
for (const spec of Object.values(PROPS) as PropDef[]) {
|
||||
if (spec.kind === 'num' && spec.event && spec.mapGet) {
|
||||
const g = groups.get(spec.event) ?? [];
|
||||
g.push(spec);
|
||||
groups.set(spec.event, g);
|
||||
}
|
||||
}
|
||||
for (const [event, specs] of groups) {
|
||||
const handler = () => {
|
||||
for (const s of specs) {
|
||||
if (this.#map && s.mapGet) {
|
||||
const v = s.mapGet(this.#map);
|
||||
if (v !== undefined) this.#syncAttr(s.attr, `${v}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.#mapEventHandlers.set(event, handler);
|
||||
this.#map.on(event, handler);
|
||||
}
|
||||
|
||||
this.addEventListener(
|
||||
'leaflet-register',
|
||||
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
|
||||
);
|
||||
this.addEventListener('leaflet-register', this.#onRegister);
|
||||
this.addEventListener('leaflet-add-layer', this.#onAddLayer);
|
||||
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
disconnectedCallback(): void {
|
||||
this.#resizeObserver?.disconnect();
|
||||
this.#resizeObserver = undefined;
|
||||
this.removeEventListener(
|
||||
'leaflet-register',
|
||||
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
|
||||
);
|
||||
this.removeEventListener('leaflet-register', this.#onRegister);
|
||||
this.removeEventListener('leaflet-add-layer', this.#onAddLayer);
|
||||
this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer);
|
||||
if (!this.#map) return;
|
||||
for (const [event, handler] of this.#mapEventHandlers) {
|
||||
this.#map.off(event, handler);
|
||||
}
|
||||
this.#mapEventHandlers.clear();
|
||||
this.#map.remove();
|
||||
this.#map = undefined;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
||||
if (oldValue === newValue || !this.#map || this.#syncing) return;
|
||||
if (CSS_ATTRS.includes(name as (typeof CSS_ATTRS)[number])) {
|
||||
this.#applyCss();
|
||||
return;
|
||||
}
|
||||
const propName = ATTR_TO_PROP.get(name);
|
||||
if (!propName) return;
|
||||
const spec = PROPS[propName] as PropDef;
|
||||
if (spec.kind === 'num') {
|
||||
if (spec.mapSet && newValue !== null) spec.mapSet(this.#map, +newValue);
|
||||
} else if (spec.kind === 'bool-off') {
|
||||
spec.mapSet?.(this.#map, newValue === null);
|
||||
}
|
||||
// skip spec.kind === 'bool-on': constructor-only, no live update
|
||||
}
|
||||
// Replaces the <link> in the shadow root, and derives the marker icon path
|
||||
// from the same URL. Called on connect and whenever a css-* attribute
|
||||
// changes. Absence and emptiness mean different things here, so this reads
|
||||
// the attributes rather than the properties.
|
||||
applyCss(): void {
|
||||
const root = this.shadowRoot;
|
||||
if (!root) return;
|
||||
|
||||
#applyCss() {
|
||||
const sr = this.shadowRoot;
|
||||
if (!sr) return;
|
||||
|
||||
if (this.#cssLink) {
|
||||
this.#cssLink.remove();
|
||||
this.#cssLink = undefined;
|
||||
}
|
||||
this.#cssLink?.remove();
|
||||
this.#cssLink = undefined;
|
||||
|
||||
const customUrl = this.hasAttribute('css-url');
|
||||
const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL;
|
||||
@ -267,8 +180,7 @@ export class LeafletMap extends TypedBase {
|
||||
|
||||
let crossorigin: string | undefined;
|
||||
if (this.hasAttribute('css-crossorigin')) {
|
||||
const val = this.getAttribute('css-crossorigin');
|
||||
crossorigin = val ?? undefined;
|
||||
crossorigin = this.getAttribute('css-crossorigin') ?? undefined;
|
||||
} else if (integrity) {
|
||||
crossorigin = 'anonymous';
|
||||
}
|
||||
@ -278,54 +190,42 @@ export class LeafletMap extends TypedBase {
|
||||
link.href = url ?? '';
|
||||
if (integrity) link.setAttribute('integrity', integrity);
|
||||
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
|
||||
sr.appendChild(link);
|
||||
root.append(link);
|
||||
this.#cssLink = link;
|
||||
|
||||
// Derive marker icon path from the CSS URL (replaces leaflet.css → images/)
|
||||
Icon.Default.imagePath = url?.replace(/\/[^/]+$/, '/images/');
|
||||
Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
|
||||
}
|
||||
|
||||
#syncAttr(name: string, value: string) {
|
||||
if (this.#syncing || this.getAttribute(name) === value) return;
|
||||
this.#syncing = true;
|
||||
this.setAttribute(name, value);
|
||||
this.#syncing = false;
|
||||
#buildShadowRoot(): HTMLDivElement {
|
||||
if (this.#container) return this.#container;
|
||||
|
||||
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
|
||||
const container = document.createElement('div');
|
||||
container.style.width = '100%';
|
||||
container.style.height = '100%';
|
||||
root.append(container);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = ':host { display: block; width: 100%; height: 400px; }';
|
||||
root.append(style);
|
||||
|
||||
this.#container = container;
|
||||
return container;
|
||||
}
|
||||
|
||||
#buildOptions(): MapOptions {
|
||||
const o: Record<string, unknown> = {};
|
||||
for (const [propName, spec] of Object.entries(PROPS) as [PropName, PropDef][]) {
|
||||
if (spec.kind === 'num') {
|
||||
if (spec.viewState) continue;
|
||||
const v = this.getAttribute(spec.attr);
|
||||
if (v !== null) o[propName] = +v;
|
||||
} else if (spec.kind === 'bool-off') {
|
||||
if (this.hasAttribute(spec.attr)) o[propName] = false;
|
||||
} else {
|
||||
if (this.hasAttribute(spec.attr)) o[propName] = true;
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
#handleLeafletRegister = (e: LeafletRegisterEvent) => {
|
||||
#onRegister = (e: LeafletRegisterEvent) => {
|
||||
e.stopPropagation();
|
||||
if (this.#map) {
|
||||
e.detail.leafletObject.addTo(this.#map);
|
||||
}
|
||||
const map = this.leafletObject;
|
||||
if (map) e.detail.leafletObject.addTo(map);
|
||||
};
|
||||
|
||||
#onAddLayer = (e: Event) => {
|
||||
this.#map?.addLayer((e as CustomEvent<{ layer: Layer }>).detail.layer);
|
||||
#onAddLayer = (e: LeafletLayerEvent) => {
|
||||
this.leafletObject?.addLayer(e.detail.layer);
|
||||
};
|
||||
|
||||
#onRemoveLayer = (e: Event) => {
|
||||
this.#map?.removeLayer((e as CustomEvent<{ layer: Layer }>).detail.layer);
|
||||
#onRemoveLayer = (e: LeafletLayerEvent) => {
|
||||
this.leafletObject?.removeLayer(e.detail.layer);
|
||||
};
|
||||
|
||||
get leafletObject() {
|
||||
return this.#map;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('leaflet-map', LeafletMap);
|
||||
|
||||
@ -1,79 +1,54 @@
|
||||
import { Icon, Marker } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { LeafletIconChangedEvent, registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { buildAttrMap, numAttr, setLayerAttr } from '../core/attributes.ts';
|
||||
import { Icon, Marker, type MarkerOptions } from 'leaflet';
|
||||
import { bool, num, str } from '../core/props.ts';
|
||||
import { latLngProps } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
import type { LeafletIconChangedEvent } from '../core/register.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
lat: num(),
|
||||
lng: num(),
|
||||
title: str(),
|
||||
alt: str(),
|
||||
draggable: on(),
|
||||
const PROPS = {
|
||||
...latLngProps,
|
||||
// title and alt end up on the <img> Leaflet renders, so live updates go there.
|
||||
title: str('', {
|
||||
set: (obj: Marker, value) => {
|
||||
const el = obj.getElement();
|
||||
if (el) el.title = value;
|
||||
},
|
||||
}),
|
||||
alt: str('', {
|
||||
set: (obj: Marker, value) => {
|
||||
const el = obj.getElement() as HTMLImageElement | undefined;
|
||||
if (el) el.alt = value;
|
||||
},
|
||||
}),
|
||||
draggable: bool(false, {
|
||||
set(obj: Marker, value) {
|
||||
if (value) obj.dragging?.enable();
|
||||
else obj.dragging?.disable();
|
||||
},
|
||||
}),
|
||||
opacity: num(1.0),
|
||||
zIndexOffset: num(),
|
||||
});
|
||||
} as const;
|
||||
|
||||
const PROP_BY_ATTR = buildAttrMap(PROPS);
|
||||
export class LeafletMarker extends WithProps(PROPS) {
|
||||
declare readonly leafletObject?: Marker;
|
||||
|
||||
export class LeafletMarker extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Marker;
|
||||
#syncing = false;
|
||||
createLeafletObject(options: MarkerOptions): Marker {
|
||||
return new Marker([this.lat, this.lng], options);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Marker(
|
||||
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
|
||||
buildOptions(this, PROPS, ['lat', 'lng']),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj.on('dragend move', this.#onChange, this);
|
||||
registerChildren(this, this.#obj);
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('icon-changed', this.#onIconChanged);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj?.off('dragend move', this.#onChange, this);
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener('icon-changed', this.#onIconChanged);
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
if (name === 'lat' || name === 'lng') {
|
||||
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
|
||||
} else if (name === 'draggable') {
|
||||
if (val !== null) this.#obj.dragging?.enable();
|
||||
else this.#obj.dragging?.disable();
|
||||
} else if (name === 'title') {
|
||||
const el = this.#obj.getElement();
|
||||
if (el) (el as HTMLImageElement).title = val ?? '';
|
||||
} else if (name === 'alt') {
|
||||
const el = this.#obj.getElement();
|
||||
if (el) (el as HTMLImageElement).alt = val ?? '';
|
||||
} else {
|
||||
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
|
||||
}
|
||||
}
|
||||
|
||||
#onChange() {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
this.#syncing = true;
|
||||
const pos = this.#obj.getLatLng();
|
||||
this.setAttribute('lat', `${pos.lat}`);
|
||||
this.setAttribute('lng', `${pos.lng}`);
|
||||
this.#syncing = false;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
// A <leaflet-icon> or <leaflet-div-icon> child announces itself this way.
|
||||
#onIconChanged = (e: LeafletIconChangedEvent) => {
|
||||
if (!this.#obj) return;
|
||||
if (e.detail.icon) this.#obj.setIcon(e.detail.icon);
|
||||
else this.#obj.setIcon(new Icon.Default());
|
||||
this.leafletObject?.setIcon(e.detail.icon ?? new Icon.Default());
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,59 +1,39 @@
|
||||
import { Polygon } from 'leaflet';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { isPathStyleAttr, updatePathStyle } from '../core/attributes.ts';
|
||||
import { Polygon, type PolylineOptions } from 'leaflet';
|
||||
import { pathProps } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
import type { LeafletLine } from './leaflet-line.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
color: str('#3388ff'),
|
||||
weight: num(3),
|
||||
opacity: num(1.0),
|
||||
fill: on(),
|
||||
fillColor: str('#3388ff'),
|
||||
fillOpacity: num(0.2),
|
||||
});
|
||||
export class LeafletPolygon extends WithProps({ ...pathProps }) {
|
||||
declare readonly leafletObject?: Polygon;
|
||||
|
||||
export class LeafletPolygon extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Polygon;
|
||||
#observer?: MutationObserver;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Polygon(this.#getCoords(), buildOptions(this, PROPS));
|
||||
registerChildren(this, this.#obj);
|
||||
createLeafletObject(options: PolylineOptions): Polygon {
|
||||
return new Polygon(this.#coords(), options);
|
||||
}
|
||||
|
||||
// Vertices come from <leaflet-line> children rather than an attribute, so we
|
||||
// re-read them whenever one is added, removed or moved.
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('line-updated', this.#syncCoords);
|
||||
this.#observer = new MutationObserver(() => {
|
||||
this.#syncCoords();
|
||||
});
|
||||
this.#observer = new MutationObserver(this.#syncCoords);
|
||||
this.#observer.observe(this, { childList: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
disconnectedCallback(): void {
|
||||
this.#observer?.disconnect();
|
||||
this.#observer = undefined;
|
||||
this.removeEventListener('line-updated', this.#syncCoords);
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (isPathStyleAttr(name)) {
|
||||
updatePathStyle(this.#obj, name, val);
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
#syncCoords = () => {
|
||||
this.#obj?.setLatLngs(this.#getCoords());
|
||||
this.leafletObject?.setLatLngs(this.#coords());
|
||||
};
|
||||
|
||||
#getCoords(): [number, number][] {
|
||||
const lines: LeafletLine[] = Array.from(this.querySelectorAll('leaflet-line'));
|
||||
#coords(): [number, number][] {
|
||||
const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
|
||||
return lines.map((line) => line.latlng);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,58 +1,45 @@
|
||||
import { Polyline } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { isPathStyleAttr, updatePathStyle } from '../core/attributes.ts';
|
||||
import { Polyline, type PolylineOptions } from 'leaflet';
|
||||
import { bool, num } from '../core/props.ts';
|
||||
import { pathProps, style } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
import type { LeafletLine } from './leaflet-line.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
color: str('#3388ff'),
|
||||
weight: num(3),
|
||||
opacity: num(1.0),
|
||||
fill: on(),
|
||||
fillColor: str('#3388ff'),
|
||||
fillOpacity: num(0.2),
|
||||
});
|
||||
export class LeafletPolyline extends WithProps({
|
||||
...pathProps,
|
||||
// Unlike closed shapes, a polyline is unfilled by default.
|
||||
fill: bool(false, { set: style('fill') }),
|
||||
smoothFactor: num(1.0),
|
||||
noClip: bool(),
|
||||
}) {
|
||||
declare readonly leafletObject?: Polyline;
|
||||
|
||||
export class LeafletPolyline extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Polyline;
|
||||
#observer?: MutationObserver;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Polyline(this.#getCoords(), buildOptions(this, PROPS));
|
||||
registerChildren(this, this.#obj);
|
||||
createLeafletObject(options: PolylineOptions): Polyline {
|
||||
return new Polyline(this.#coords(), options);
|
||||
}
|
||||
|
||||
// Vertices come from <leaflet-line> children rather than an attribute, so we
|
||||
// re-read them whenever one is added, removed or moved.
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('line-updated', this.#syncCoords);
|
||||
this.#observer = new MutationObserver(() => {
|
||||
this.#syncCoords();
|
||||
});
|
||||
this.#observer = new MutationObserver(this.#syncCoords);
|
||||
this.#observer.observe(this, { childList: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
disconnectedCallback(): void {
|
||||
this.#observer?.disconnect();
|
||||
this.#observer = undefined;
|
||||
this.removeEventListener('line-updated', this.#syncCoords);
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (isPathStyleAttr(name)) {
|
||||
updatePathStyle(this.#obj, name, val);
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
#syncCoords = () => {
|
||||
this.#obj?.setLatLngs(this.#getCoords());
|
||||
this.leafletObject?.setLatLngs(this.#coords());
|
||||
};
|
||||
|
||||
#getCoords(): [number, number][] {
|
||||
#coords(): [number, number][] {
|
||||
const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
|
||||
return lines.map((line) => line.latlng);
|
||||
}
|
||||
|
||||
@ -1,74 +1,46 @@
|
||||
import { Popup } from 'leaflet';
|
||||
import { WithProps, defineProps, num, on, buildOptions } from '../core/props.ts';
|
||||
import { numAttr } from '../core/attributes.ts';
|
||||
import { registerWithParent } from '../core/register.ts';
|
||||
import { Popup, type PopupOptions } from 'leaflet';
|
||||
import { bool, num } from '../core/props.ts';
|
||||
import { latLngProps } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
lat: num(),
|
||||
lng: num(),
|
||||
maxWidth: num(300),
|
||||
minWidth: num(50),
|
||||
maxHeight: num(),
|
||||
autoPan: on(),
|
||||
closeButton: on(),
|
||||
autoClose: on(),
|
||||
});
|
||||
export class LeafletPopup extends WithProps(
|
||||
{
|
||||
...latLngProps,
|
||||
maxWidth: num(300),
|
||||
minWidth: num(50),
|
||||
maxHeight: num(),
|
||||
autoPan: bool(true),
|
||||
closeButton: bool(true),
|
||||
autoClose: bool(true),
|
||||
},
|
||||
{ attach: 'self' },
|
||||
) {
|
||||
declare readonly leafletObject?: Popup;
|
||||
|
||||
export class LeafletPopup extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Popup;
|
||||
#observer?: MutationObserver;
|
||||
#syncing = false;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Popup({
|
||||
...buildOptions(this, PROPS, ['lat', 'lng']),
|
||||
content: this.innerHTML,
|
||||
});
|
||||
// Content is the element's markup, not an attribute. A popup only carries a
|
||||
// position of its own when it isn't bound to a parent layer.
|
||||
createLeafletObject(options: PopupOptions): Popup {
|
||||
const popup = new Popup({ ...options, content: this.innerHTML });
|
||||
if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
|
||||
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
|
||||
popup.setLatLng([this.lat, this.lng]);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj.on('move', this.#onChange, this);
|
||||
registerWithParent(this, this.#obj);
|
||||
|
||||
this.#observer = new MutationObserver(() => {
|
||||
this.#obj?.setContent(this.innerHTML);
|
||||
});
|
||||
this.#observer.observe(this, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
return popup;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj?.off('move', this.#onChange, this);
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.#observer = new MutationObserver(() => {
|
||||
this.leafletObject?.setContent(this.innerHTML);
|
||||
});
|
||||
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#observer?.disconnect();
|
||||
this.#observer = undefined;
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string) {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
if (name === 'lat' || name === 'lng') {
|
||||
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
|
||||
}
|
||||
}
|
||||
|
||||
#onChange() {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
const pos = this.#obj.getLatLng();
|
||||
if (!pos) return;
|
||||
this.#syncing = true;
|
||||
this.setAttribute('lat', `${pos.lat}`);
|
||||
this.setAttribute('lng', `${pos.lng}`);
|
||||
this.#syncing = false;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,43 +1,16 @@
|
||||
import { Rectangle } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { isPathStyleAttr, parseBoundsAttr, updatePathStyle } from '../core/attributes.ts';
|
||||
import { Rectangle, type LatLngBoundsExpression, type PolylineOptions } from 'leaflet';
|
||||
import { json, positional } from '../core/props.ts';
|
||||
import { pathProps, getBounds } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
bounds: str(),
|
||||
color: str('#3388ff'),
|
||||
weight: num(3),
|
||||
opacity: num(1.0),
|
||||
fill: on(),
|
||||
fillColor: str('#3388ff'),
|
||||
fillOpacity: num(0.2),
|
||||
});
|
||||
export class LeafletRectangle extends WithProps({
|
||||
bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })),
|
||||
...pathProps,
|
||||
}) {
|
||||
declare readonly leafletObject?: Rectangle;
|
||||
|
||||
export class LeafletRectangle extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Rectangle;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Rectangle(parseBoundsAttr(this), buildOptions(this, PROPS, ['bounds']));
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (name === 'bounds') {
|
||||
this.#obj.setBounds(parseBoundsAttr(this));
|
||||
} else if (isPathStyleAttr(name)) {
|
||||
updatePathStyle(this.#obj, name, val);
|
||||
}
|
||||
createLeafletObject(options: PolylineOptions): Rectangle {
|
||||
return new Rectangle(this.bounds, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,52 +1,27 @@
|
||||
import { SVGOverlay, LatLngBounds, LatLngExpression } from 'leaflet';
|
||||
import type { ImageOverlayOptions } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts';
|
||||
import {
|
||||
SVGOverlay,
|
||||
type CrossOrigin,
|
||||
type ImageOverlayOptions,
|
||||
type LatLngBoundsExpression,
|
||||
} from 'leaflet';
|
||||
import { bool, choice, json, num, positional, str } from '../core/props.ts';
|
||||
import { getBounds } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
bounds: str(),
|
||||
export class LeafletSVGOverlay extends WithProps({
|
||||
bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })),
|
||||
opacity: num(1.0),
|
||||
interactive: on(),
|
||||
crossOrigin: str(),
|
||||
interactive: bool(),
|
||||
crossOrigin: choice<CrossOrigin>(''),
|
||||
zIndex: num(),
|
||||
className: str(),
|
||||
});
|
||||
}) {
|
||||
declare readonly leafletObject?: SVGOverlay;
|
||||
|
||||
const PROP_BY_ATTR = buildAttrMap(PROPS);
|
||||
|
||||
export class LeafletSVGOverlay extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: SVGOverlay;
|
||||
|
||||
connectedCallback() {
|
||||
const svg = this.querySelector('svg');
|
||||
const dummy = !svg ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') : undefined;
|
||||
this.#obj = new SVGOverlay(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
svg ?? dummy!,
|
||||
parseBoundsAttr(this),
|
||||
buildOptions(this, PROPS, ['bounds']) as ImageOverlayOptions,
|
||||
);
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (name === 'bounds') {
|
||||
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
|
||||
} else {
|
||||
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
|
||||
}
|
||||
createLeafletObject(options: ImageOverlayOptions): SVGOverlay {
|
||||
const svg =
|
||||
this.querySelector('svg') ?? document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
return new SVGOverlay(svg, this.bounds, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,54 +1,51 @@
|
||||
import { TileLayer } from 'leaflet';
|
||||
import { WithProps, defineProps, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { buildAttrMap, parseAttributeValue, setLayerAttr } from '../core/attributes.ts';
|
||||
import { CRS, TileLayer, type WMSOptions, type WMSParams } from 'leaflet';
|
||||
import { bool, str, type PropDef } from '../core/props.ts';
|
||||
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
url: str(),
|
||||
layers: str(),
|
||||
styles: str(),
|
||||
format: str('image/jpeg'),
|
||||
transparent: on(),
|
||||
version: str('1.1.1'),
|
||||
uppercase: on(),
|
||||
});
|
||||
// WMS request parameters have no individual setters -- they're merged into the
|
||||
// query string through setParams().
|
||||
function param<T>(key: keyof WMSParams): (obj: TileLayer.WMS, value: T) => void {
|
||||
return (obj, value) => {
|
||||
obj.setParams({ [key]: value } as unknown as WMSParams);
|
||||
};
|
||||
}
|
||||
|
||||
const PROP_BY_ATTR = buildAttrMap(PROPS);
|
||||
// `crs` takes a CRS instance, not a primitive, so it's looked up by name from
|
||||
// Leaflet's built-in set rather than decoded like a plain value. Constructor-
|
||||
// only, like the rest of tileLayerProps -- Leaflet has no setter for it.
|
||||
const NAMED_CRS = {
|
||||
EPSG3857: CRS.EPSG3857,
|
||||
EPSG4326: CRS.EPSG4326,
|
||||
EPSG3395: CRS.EPSG3395,
|
||||
Simple: CRS.Simple,
|
||||
};
|
||||
type CrsName = keyof typeof NAMED_CRS;
|
||||
|
||||
export class LeafletTileLayerWMS extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: TileLayer.WMS;
|
||||
const crsProp: PropDef<CRS> = {
|
||||
default: CRS.EPSG3857,
|
||||
decode: (raw) => NAMED_CRS[raw as CrsName] ?? CRS.EPSG3857,
|
||||
encode: (value) =>
|
||||
value === CRS.EPSG3857
|
||||
? null
|
||||
: ((Object.keys(NAMED_CRS) as CrsName[]).find((name) => NAMED_CRS[name] === value) ?? null),
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
const url = this.getAttribute('url') ?? '';
|
||||
this.#obj = new TileLayer.WMS(url, buildOptions(this, PROPS, ['url']));
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
export class LeafletTileLayerWMS extends WithProps({
|
||||
url: urlProp,
|
||||
...tileLayerProps,
|
||||
layers: str('', { set: param('layers') }),
|
||||
styles: str('', { set: param('styles') }),
|
||||
format: str('image/jpeg', { set: param('format') }),
|
||||
transparent: bool(false, { set: param('transparent') }),
|
||||
version: str('1.1.1', { set: param('version') }),
|
||||
uppercase: bool(),
|
||||
crs: crsProp,
|
||||
}) {
|
||||
declare readonly leafletObject?: TileLayer.WMS;
|
||||
|
||||
disconnectedCallback() {
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (name === 'url') {
|
||||
if (val) this.#obj.setUrl(val);
|
||||
} else {
|
||||
const propName = PROP_BY_ATTR.get(name);
|
||||
if (!propName) return;
|
||||
const spec = PROPS[propName];
|
||||
if (!setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val)) {
|
||||
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val);
|
||||
(this.#obj.setParams as unknown as (params: Record<string, unknown>) => void)({
|
||||
[propName]: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
createLeafletObject(options: WMSOptions): TileLayer.WMS {
|
||||
return new TileLayer.WMS(this.url, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,45 +1,15 @@
|
||||
import { TileLayer } from 'leaflet';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { WithProps, defineProps, num, str, buildOptions } from '../core/props.ts';
|
||||
import { buildAttrMap, setLayerAttr } from '../core/attributes.ts';
|
||||
import { TileLayer, type TileLayerOptions } from 'leaflet';
|
||||
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
url: str(),
|
||||
attribution: str(),
|
||||
minZoom: num(),
|
||||
maxZoom: num(18),
|
||||
opacity: num(1.0),
|
||||
zIndex: num(),
|
||||
});
|
||||
export class LeafletTileLayer extends WithProps({
|
||||
url: urlProp,
|
||||
...tileLayerProps,
|
||||
}) {
|
||||
declare readonly leafletObject?: TileLayer;
|
||||
|
||||
const PROP_BY_ATTR = buildAttrMap(PROPS);
|
||||
|
||||
export class LeafletTileLayer extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: TileLayer;
|
||||
|
||||
connectedCallback() {
|
||||
const url = this.getAttribute('url') ?? '';
|
||||
this.#obj = new TileLayer(url, buildOptions(this, PROPS, ['url']));
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (name === 'url') {
|
||||
if (val) this.#obj.setUrl(val);
|
||||
} else {
|
||||
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
|
||||
}
|
||||
createLeafletObject(options: TileLayerOptions): TileLayer {
|
||||
return new TileLayer(this.url, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,75 +1,46 @@
|
||||
import { Tooltip } from 'leaflet';
|
||||
import type { TooltipOptions } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { numAttr } from '../core/attributes.ts';
|
||||
import { registerWithParent } from '../core/register.ts';
|
||||
import { Tooltip, type Direction, type PointExpression, type TooltipOptions } from 'leaflet';
|
||||
import { bool, choice, json, num, str } from '../core/props.ts';
|
||||
import { latLngProps } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
lat: num(),
|
||||
lng: num(),
|
||||
pane: str(),
|
||||
offset: str(),
|
||||
direction: str('auto'),
|
||||
permanent: on(),
|
||||
sticky: on(),
|
||||
opacity: num(1.0),
|
||||
});
|
||||
export class LeafletTooltip extends WithProps(
|
||||
{
|
||||
...latLngProps,
|
||||
pane: str(),
|
||||
offset: json<PointExpression>([0, 0]),
|
||||
direction: choice<Direction>('auto'),
|
||||
permanent: bool(),
|
||||
sticky: bool(),
|
||||
opacity: num(1.0),
|
||||
},
|
||||
{ attach: 'self' },
|
||||
) {
|
||||
declare readonly leafletObject?: Tooltip;
|
||||
|
||||
export class LeafletTooltip extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: Tooltip;
|
||||
#observer?: MutationObserver;
|
||||
#syncing = false;
|
||||
|
||||
connectedCallback() {
|
||||
this.#obj = new Tooltip({
|
||||
...buildOptions(this, PROPS, ['lat', 'lng']),
|
||||
content: this.innerHTML,
|
||||
} as unknown as TooltipOptions);
|
||||
// Content is the element's markup, not an attribute. A tooltip only carries a
|
||||
// position of its own when it isn't bound to a parent layer.
|
||||
createLeafletObject(options: TooltipOptions): Tooltip {
|
||||
const tooltip = new Tooltip({ ...options, content: this.innerHTML });
|
||||
if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
|
||||
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
|
||||
tooltip.setLatLng([this.lat, this.lng]);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj.on('move', this.#onChange, this);
|
||||
registerWithParent(this, this.#obj);
|
||||
|
||||
this.#observer = new MutationObserver(() => {
|
||||
this.#obj?.setContent(this.innerHTML);
|
||||
});
|
||||
this.#observer.observe(this, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.#obj?.off('move', this.#onChange, this);
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.#observer = new MutationObserver(() => {
|
||||
this.leafletObject?.setContent(this.innerHTML);
|
||||
});
|
||||
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#observer?.disconnect();
|
||||
this.#observer = undefined;
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string) {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
if (name === 'lat' || name === 'lng') {
|
||||
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
|
||||
}
|
||||
}
|
||||
|
||||
#onChange() {
|
||||
if (!this.#obj || this.#syncing) return;
|
||||
const pos = this.#obj.getLatLng();
|
||||
if (!pos) return;
|
||||
this.#syncing = true;
|
||||
this.setAttribute('lat', `${pos.lat}`);
|
||||
this.setAttribute('lng', `${pos.lng}`);
|
||||
this.#syncing = false;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,73 +1,48 @@
|
||||
import { VideoOverlay, LatLngBounds, LatLngExpression } from 'leaflet';
|
||||
import type { VideoOverlayOptions } from 'leaflet';
|
||||
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
|
||||
import { registerChildren, unregisterChildren } from '../core/register.ts';
|
||||
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts';
|
||||
import {
|
||||
VideoOverlay,
|
||||
type CrossOrigin,
|
||||
type LatLngBoundsExpression,
|
||||
type VideoOverlayOptions,
|
||||
} from 'leaflet';
|
||||
import { bool, choice, json, num, positional, str } from '../core/props.ts';
|
||||
import { getBounds, urlProp } from '../core/shared-props.ts';
|
||||
import { WithProps } from '../core/with-props.ts';
|
||||
|
||||
const PROPS = defineProps({
|
||||
url: str(),
|
||||
bounds: str(),
|
||||
// Playback options live on the <video> element Leaflet builds, so live updates
|
||||
// go there rather than through a Leaflet setter.
|
||||
function media(
|
||||
key: 'loop' | 'autoplay' | 'muted' | 'playsInline',
|
||||
): (obj: VideoOverlay, value: boolean) => void {
|
||||
return (obj, value) => {
|
||||
const el = obj.getElement();
|
||||
if (el) el[key] = value;
|
||||
};
|
||||
}
|
||||
|
||||
export class LeafletVideoOverlay extends WithProps({
|
||||
url: urlProp,
|
||||
bounds: positional(json<LatLngBoundsExpression, VideoOverlay>([], { get: getBounds })),
|
||||
opacity: num(1.0),
|
||||
alt: str(),
|
||||
interactive: on(),
|
||||
crossOrigin: str(),
|
||||
loop: on(),
|
||||
autoplay: on(),
|
||||
muted: on(),
|
||||
playsInline: on('playsinline'),
|
||||
});
|
||||
interactive: bool(),
|
||||
crossOrigin: choice<CrossOrigin>(''),
|
||||
loop: bool(false, { set: media('loop') }),
|
||||
autoplay: bool(false, { set: media('autoplay') }),
|
||||
muted: bool(false, { set: media('muted') }),
|
||||
playsInline: bool(false, { attribute: 'playsinline', set: media('playsInline') }),
|
||||
zIndex: num(),
|
||||
className: str(),
|
||||
keepAspectRatio: bool(true),
|
||||
errorOverlayUrl: str(),
|
||||
}) {
|
||||
declare readonly leafletObject?: VideoOverlay;
|
||||
|
||||
const PROP_BY_ATTR = buildAttrMap(PROPS);
|
||||
|
||||
export class LeafletVideoOverlay extends WithProps(HTMLElement, PROPS) {
|
||||
#obj?: VideoOverlay;
|
||||
|
||||
connectedCallback() {
|
||||
const url = this.getAttribute('url') ?? '';
|
||||
this.#obj = new VideoOverlay(
|
||||
url,
|
||||
parseBoundsAttr(this),
|
||||
buildOptions(this, PROPS, ['url', 'bounds']) as VideoOverlayOptions,
|
||||
);
|
||||
registerChildren(this, this.#obj);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
unregisterChildren(this);
|
||||
this.#obj?.remove();
|
||||
this.#obj = undefined;
|
||||
}
|
||||
|
||||
get leafletObject() {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
if (!this.#obj) return;
|
||||
if (name === 'url') {
|
||||
if (val) this.#obj.setUrl(val);
|
||||
} else if (name === 'bounds') {
|
||||
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
|
||||
} else if (
|
||||
name === 'loop' ||
|
||||
name === 'autoplay' ||
|
||||
name === 'muted' ||
|
||||
name === 'playsinline'
|
||||
) {
|
||||
const el = this.#obj.getElement();
|
||||
if (el) {
|
||||
if (name === 'loop') el.loop = val !== null;
|
||||
else if (name === 'autoplay') el.autoplay = val !== null;
|
||||
else if (name === 'muted') el.muted = val !== null;
|
||||
else el.playsInline = val !== null;
|
||||
}
|
||||
} else {
|
||||
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
|
||||
}
|
||||
createLeafletObject(options: VideoOverlayOptions): VideoOverlay {
|
||||
return new VideoOverlay(this.url, this.bounds, options);
|
||||
}
|
||||
|
||||
getElement(): HTMLVideoElement | undefined {
|
||||
return this.#obj?.getElement();
|
||||
return this.leafletObject?.getElement();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
import type { LatLngBoundsExpression } from 'leaflet';
|
||||
import { Path } from 'leaflet';
|
||||
import type { PropDef } from './props.ts';
|
||||
|
||||
// Reads a numeric attribute from an element, falling back to the default
|
||||
// value declared in the PROPS table. Handles the common pattern of
|
||||
// reading lat/lng/radius/opacity with a guaranteed number return.
|
||||
export function numAttr(el: HTMLElement, props: Record<string, PropDef>, name: string): number {
|
||||
const v = el.getAttribute(name);
|
||||
if (v !== null) return +v;
|
||||
return (props[name] as { default: number }).default;
|
||||
}
|
||||
|
||||
// Reads the `bounds` attribute from an element and parses it as JSON
|
||||
// into a LatLngBoundsExpression. Returns an empty array when no
|
||||
// attribute is set. Shared by rectangle, image-overlay, video-overlay,
|
||||
// and svg-overlay, all of which accept a `bounds` attribute.
|
||||
export function parseBoundsAttr(el: HTMLElement): LatLngBoundsExpression {
|
||||
const raw = el.getAttribute('bounds');
|
||||
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
|
||||
}
|
||||
|
||||
// Builds a reverse lookup map from HTML attribute name → PROP key.
|
||||
// For example, { fillColor: { attr: 'fill-color' } } becomes
|
||||
// { 'fill-color' → 'fillColor' }. Used by setLayerAttr to find the
|
||||
// Leaflet setter name when an attribute changes at runtime.
|
||||
export function buildAttrMap<T extends Record<string, PropDef>>(props: T): Map<string, keyof T> {
|
||||
return new Map(Object.entries(props).map(([name, spec]) => [spec.attr, name]));
|
||||
}
|
||||
|
||||
// Generic dispatcher for runtime attribute changes on Leaflet objects.
|
||||
// Given the attribute name, it looks up the PROP key, constructs the
|
||||
// matching Leaflet setter name (e.g. "opacity" → "setOpacity"), and
|
||||
// calls it with the parsed value. Returns true if a setter was found
|
||||
// and called, false otherwise (caller can fall back, as WMS does with
|
||||
// setParams). Handles bool-on props by passing the presence/absence of
|
||||
// the attribute rather than its string value.
|
||||
export function setLayerAttr(
|
||||
obj: object,
|
||||
props: Record<string, PropDef>,
|
||||
attrMap: Map<string, string>,
|
||||
name: string,
|
||||
val: string | null,
|
||||
): boolean {
|
||||
const propName = attrMap.get(name);
|
||||
if (!propName) return false;
|
||||
const spec = props[propName];
|
||||
const setter = `set${propName.charAt(0).toUpperCase()}${propName.slice(1)}`;
|
||||
const fn = (obj as Record<string, unknown>)[setter];
|
||||
if (typeof fn === 'function') {
|
||||
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val);
|
||||
(fn as (v: unknown) => void)(value);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parses an HTML attribute string into a typed JS value:
|
||||
// null → null, "true"/"false" → boolean, numeric strings → number,
|
||||
// valid JSON → parsed object/array, otherwise the raw string.
|
||||
// This is the bridge between HTML attribute strings and Leaflet options.
|
||||
export function parseAttributeValue(value: string | null): unknown {
|
||||
if (value === null) return null;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
const num = +value;
|
||||
if (!isNaN(num) && value !== '') return num;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const PATH_STYLE_ATTRS = new Set([
|
||||
'color',
|
||||
'weight',
|
||||
'opacity',
|
||||
'fill',
|
||||
'fill-color',
|
||||
'fill-opacity',
|
||||
'stroke',
|
||||
'dash-array',
|
||||
'dash-offset',
|
||||
'line-cap',
|
||||
'line-join',
|
||||
'fill-rule',
|
||||
]);
|
||||
|
||||
export function isPathStyleAttr(name: string): boolean {
|
||||
return PATH_STYLE_ATTRS.has(name);
|
||||
}
|
||||
|
||||
export function updatePathStyle(obj: Path, name: string, value: string | null) {
|
||||
const key = name.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
obj.setStyle({ [key]: parseAttributeValue(value) });
|
||||
}
|
||||
@ -1,183 +1,127 @@
|
||||
export interface NumProp<T = unknown> {
|
||||
kind: 'num';
|
||||
attr: string;
|
||||
default: number;
|
||||
mapGet?(m: T): number | undefined;
|
||||
mapSet?(m: T, v: number): void;
|
||||
viewState?: boolean;
|
||||
// Every element property is described by a PropDef: how its value encodes to
|
||||
// and decodes from an HTML attribute, and how it is pushed into (and read back
|
||||
// out of) the Leaflet object. `WithProps` in with-props.ts is the only consumer
|
||||
// -- components just declare a table of these and never touch the plumbing.
|
||||
export interface PropDef<T = unknown, TObj = unknown> {
|
||||
// Attribute name. Defaults to the kebab-cased property name. A function
|
||||
// receives the property name and returns the attribute (see `disabled`).
|
||||
attribute?: string | ((name: string) => string);
|
||||
|
||||
// The property value when the attribute is absent. Keep this equal to
|
||||
// Leaflet's own default: an absent attribute is left out of the options
|
||||
// object entirely, so it is Leaflet's default that actually takes effect.
|
||||
default: T;
|
||||
|
||||
// Set through `positional()` for values the Leaflet constructor takes as an
|
||||
// argument (coordinates, urls, bounds) rather than as an option.
|
||||
option?: false;
|
||||
|
||||
decode(raw: string): T;
|
||||
|
||||
// Returning null removes the attribute, which restores Leaflet's default.
|
||||
encode(value: T): string | null;
|
||||
|
||||
// Pushes a new value into the Leaflet object. Defaults to calling the
|
||||
// matching setter when the object has one (`opacity` -> `setOpacity`).
|
||||
set?(obj: TObj, value: T, el: HTMLElement): void;
|
||||
|
||||
// Reads the live value back out of the Leaflet object. Used by the property
|
||||
// getter, and by `event` below to write the value back to the attribute.
|
||||
get?(obj: TObj): T | undefined;
|
||||
|
||||
// Leaflet event after which `get` is re-read and synced to the attribute,
|
||||
// e.g. `move` keeps lat/lng current while a marker is dragged.
|
||||
event?: string;
|
||||
}
|
||||
|
||||
export interface StrProp {
|
||||
kind: 'str';
|
||||
attr: string;
|
||||
default: string;
|
||||
export type PropTable<T> = Record<string, PropDef<unknown, T>>;
|
||||
|
||||
// Everything a codec factory doesn't fill in for you. `option` is not here:
|
||||
// it has to come from `positional()` to be visible in PropOptionValues.
|
||||
export type PropOptions<TObj, T> = Partial<
|
||||
Omit<PropDef<T, TObj>, 'default' | 'decode' | 'encode' | 'option'>
|
||||
>;
|
||||
|
||||
// The value type of a single prop, and of a whole table.
|
||||
export type PropValue<P> = P extends { default: infer T } ? T : never;
|
||||
|
||||
export type PropValues<T> = { [K in keyof T]: PropValue<T[K]> };
|
||||
|
||||
// The options object handed to `createLeafletObject`. Partial because a prop
|
||||
// only appears when its attribute is present; `option: false` props never do.
|
||||
export type PropOptionValues<T> = Partial<{
|
||||
[K in keyof T as T[K] extends { option: false } ? never : K]: PropValue<T[K]>;
|
||||
}>;
|
||||
|
||||
// Marks a prop the Leaflet constructor takes as an argument, so it is left out
|
||||
// of the options object handed to createLeafletObject().
|
||||
export function positional<T extends PropDef>(def: T): T & { option: false } {
|
||||
return { ...def, option: false };
|
||||
}
|
||||
|
||||
export interface BoolOnProp {
|
||||
kind: 'bool-on';
|
||||
attr: string;
|
||||
export function kebab(name: string): string {
|
||||
return name.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
|
||||
}
|
||||
|
||||
export interface BoolOffProp<T = unknown> {
|
||||
kind: 'bool-off';
|
||||
attr: string;
|
||||
mapSet?(m: T, enabled: boolean): void;
|
||||
}
|
||||
|
||||
export type PropDef = NumProp | StrProp | BoolOnProp | BoolOffProp;
|
||||
|
||||
export type PropTypeOf<T extends PropDef> = T extends NumProp
|
||||
? number
|
||||
: T extends StrProp
|
||||
? string
|
||||
: boolean;
|
||||
|
||||
export type PropTypesFromTable<T extends Record<string, PropDef>> = {
|
||||
[K in keyof T]: PropTypeOf<T[K]>;
|
||||
};
|
||||
|
||||
type OptionalAttr<T> = Omit<T, 'attr'> & { attr?: string };
|
||||
|
||||
type NumPropInput<T = unknown> = OptionalAttr<NumProp<T>>;
|
||||
type StrPropInput = OptionalAttr<StrProp>;
|
||||
type BoolOnPropInput = OptionalAttr<BoolOnProp>;
|
||||
type BoolOffPropInput<T = unknown> = OptionalAttr<BoolOffProp<T>>;
|
||||
|
||||
type PropDefInput = NumPropInput | StrPropInput | BoolOnPropInput | BoolOffPropInput;
|
||||
|
||||
type ValueFor<T, K extends keyof T> = T[K];
|
||||
|
||||
type PropDefFromInput<T extends PropDefInput, Key extends string> = (T extends NumPropInput<infer U>
|
||||
? NumProp<U>
|
||||
: T extends { kind: 'str' }
|
||||
? StrProp
|
||||
: T extends BoolOffPropInput<infer U>
|
||||
? BoolOffProp<U>
|
||||
: BoolOnProp) & { attr: AttributeValue<T, Key> };
|
||||
|
||||
type AttributeValue<T extends PropDefInput, Key extends string> = T extends { attr: string }
|
||||
? ValueFor<T, 'attr'>
|
||||
: T extends BoolOffPropInput
|
||||
? AsDisableKebab<Key>
|
||||
: AsKebab<Key>;
|
||||
|
||||
type AsKebab<T extends string> = T extends `${infer L}${infer R}`
|
||||
? `${L extends Uppercase<L> ? '-' : ''}${Lowercase<L>}${AsKebab<R>}`
|
||||
: T;
|
||||
|
||||
type AsDisableKebab<T extends string> = `disable-${AsKebab<T>}`;
|
||||
|
||||
function disableCamelToKebab<S extends string>(s: S): AsDisableKebab<S> {
|
||||
return ('disable-' + s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())) as AsDisableKebab<S>;
|
||||
}
|
||||
|
||||
function camelToKebab(s: string): string {
|
||||
return s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
|
||||
}
|
||||
|
||||
export function num<T = unknown>(
|
||||
export function num<TObj = unknown>(
|
||||
def = 0,
|
||||
opts?: Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string },
|
||||
): NumPropInput<T> {
|
||||
return { kind: 'num', default: def, ...opts };
|
||||
opts?: PropOptions<TObj, number>,
|
||||
): PropDef<number, TObj> {
|
||||
return { default: def, decode: Number, encode: String, ...opts };
|
||||
}
|
||||
|
||||
export function str(def = '', attr?: string): StrPropInput {
|
||||
return { kind: 'str', default: def, ...(attr ? { attr } : {}) };
|
||||
export function str<TObj = unknown>(
|
||||
def = '',
|
||||
opts?: PropOptions<TObj, string>,
|
||||
): PropDef<string, TObj> {
|
||||
return { default: def, decode: (raw) => raw, encode: (value) => value, ...opts };
|
||||
}
|
||||
|
||||
export function on(attr?: string): BoolOnPropInput {
|
||||
return { kind: 'bool-on', ...(attr ? { attr } : {}) };
|
||||
// A string attribute whose values Leaflet types as a union -- ControlPosition,
|
||||
// CrossOrigin, tooltip Direction. Nothing is validated at runtime; this is how
|
||||
// the options object comes out with the type Leaflet's constructor expects.
|
||||
export function choice<T extends string, TObj = unknown>(
|
||||
def: T,
|
||||
opts?: PropOptions<TObj, T>,
|
||||
): PropDef<T, TObj> {
|
||||
return { default: def, decode: (raw) => raw as T, encode: (value) => value, ...opts };
|
||||
}
|
||||
|
||||
export function off<T = unknown>(mapSet?: (m: T, enabled: boolean) => void): BoolOffPropInput<T> {
|
||||
return { kind: 'bool-off', ...(mapSet ? { mapSet } : {}) };
|
||||
// A boolean attribute: present is true, `="false"` is false, absent is `def`.
|
||||
// Use `bool(true)` for options Leaflet already defaults to true, so that
|
||||
// `<leaflet-popup auto-pan="false">` can turn them off.
|
||||
export function bool<TObj = unknown>(
|
||||
def = false,
|
||||
opts?: PropOptions<TObj, boolean>,
|
||||
): PropDef<boolean, TObj> {
|
||||
return {
|
||||
default: def,
|
||||
decode: (raw) => raw !== 'false',
|
||||
encode: (value) => (value === def ? null : value ? '' : 'false'),
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
export function defineProps<T extends Record<string, PropDefInput>>(
|
||||
input: T,
|
||||
): { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> } {
|
||||
const output = {} as Record<string, PropDef>;
|
||||
for (const key of Object.keys(input)) {
|
||||
const val = input[key];
|
||||
const { attr: override, ...rest } = val as unknown as Record<string, unknown>;
|
||||
const attr =
|
||||
override ?? (rest.kind === 'bool-off' ? disableCamelToKebab(key) : camelToKebab(key));
|
||||
output[key] = { ...(rest as object), attr } as PropDef;
|
||||
}
|
||||
return output as unknown as { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> };
|
||||
// The inverse of `bool(true)`: `<leaflet-map disable-dragging>` reads as
|
||||
// `dragging === false`. Named `disable-<kebab>` unless `attribute` says else.
|
||||
export function disabled<TObj = unknown>(
|
||||
opts?: PropOptions<TObj, boolean>,
|
||||
): PropDef<boolean, TObj> {
|
||||
return {
|
||||
attribute: (name) => `disable-${kebab(name)}`,
|
||||
default: true,
|
||||
decode: (raw) => raw === 'false',
|
||||
encode: (value) => (value ? null : ''),
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Ctor<T = object> = new (...args: any[]) => T;
|
||||
|
||||
export function WithProps<TBase extends Ctor<HTMLElement>, TProps extends Record<string, PropDef>>(
|
||||
Base: TBase,
|
||||
props: TProps,
|
||||
): TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>> {
|
||||
class WithProps extends Base {
|
||||
static get observedAttributes(): string[] {
|
||||
return Object.values(props).map((s) => s.attr);
|
||||
}
|
||||
}
|
||||
definePropAccessors(WithProps.prototype, props);
|
||||
return WithProps as TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>>;
|
||||
// For attributes holding JSON: bounds, icon sizes and anchors, GeoJSON data.
|
||||
export function json<T, TObj = unknown>(def: T, opts?: PropOptions<TObj, T>): PropDef<T, TObj> {
|
||||
return {
|
||||
default: def,
|
||||
decode: (raw) => JSON.parse(raw) as T,
|
||||
encode: (value) => JSON.stringify(value),
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
function definePropAccessors(proto: object, props: Record<string, PropDef>) {
|
||||
for (const [name, spec] of Object.entries(props)) {
|
||||
Object.defineProperty(proto, name, {
|
||||
get() {
|
||||
const el = this as HTMLElement;
|
||||
const val = el.getAttribute(spec.attr);
|
||||
if (spec.kind === 'num') return val !== null ? +val : spec.default;
|
||||
if (spec.kind === 'bool-on') return el.hasAttribute(spec.attr);
|
||||
return val ?? (spec as { default: string }).default;
|
||||
},
|
||||
set(v: unknown) {
|
||||
const el = this as HTMLElement;
|
||||
if (spec.kind === 'bool-on') el.toggleAttribute(spec.attr, !!v);
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
else el.setAttribute(spec.attr, `${v}`);
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the initial Leaflet options object from the PROPS table and the
|
||||
// element's current attributes at connection time. Props listed in
|
||||
// `exclude` are skipped (they're passed separately to Leaflet
|
||||
// constructors, like coordinates or URLs). Null attributes use the
|
||||
// default from PROPS if one exists (skipping empty-string defaults to
|
||||
// avoid Leaflet rejecting them).
|
||||
//
|
||||
// The return type is derived from the PROPS table: each prop name maps
|
||||
// to its kind's value type (number for `num`, boolean for `bool-on`/`bool-off`,
|
||||
// string for `str`). The `const` type parameter makes literal exclude arrays
|
||||
// narrow correctly, so excluded keys are stripped from the return type.
|
||||
export function buildOptions<
|
||||
TProps extends Record<string, PropDef>,
|
||||
const TExclude extends readonly string[] = [],
|
||||
>(
|
||||
el: HTMLElement,
|
||||
props: TProps,
|
||||
exclude: TExclude = [] as unknown as TExclude,
|
||||
): Omit<PropTypesFromTable<TProps>, TExclude[number]> {
|
||||
const opts = {} as Record<string, unknown>;
|
||||
for (const [propName, spec] of Object.entries(props)) {
|
||||
if (exclude.includes(propName)) continue;
|
||||
const val = el.getAttribute(spec.attr);
|
||||
if (val === null) {
|
||||
if ('default' in spec && spec.default !== '') opts[propName] = spec.default;
|
||||
continue;
|
||||
}
|
||||
if (spec.kind === 'num') opts[propName] = +val;
|
||||
else if (spec.kind === 'bool-on') opts[propName] = true;
|
||||
else if (spec.kind === 'bool-off') opts[propName] = false;
|
||||
else opts[propName] = val;
|
||||
}
|
||||
return opts as Omit<PropTypesFromTable<TProps>, TExclude[number]>;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { DivIcon, Icon, Layer, LayerGroup, Popup, Tooltip } from 'leaflet';
|
||||
import { DivIcon, Icon, Layer } from 'leaflet';
|
||||
|
||||
// Custom event type for the bubbling registration protocol. Carries the
|
||||
// Leaflet object and the originating element so the nearest parent can
|
||||
@ -30,41 +30,6 @@ export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | u
|
||||
);
|
||||
}
|
||||
|
||||
// Tracks whether a child registered as a plain layer, popup, or tooltip --
|
||||
// used by the controls panel and by cleanup logic.
|
||||
type ChildEntry = 'layer' | 'popup' | 'tooltip';
|
||||
|
||||
// Builds the event handler for a given parent layer. On each
|
||||
// leaflet-register event from a descendant, it checks the Leaflet type:
|
||||
// Popup → bindPopup, Tooltip → bindTooltip, Layer → addLayer (if the
|
||||
// parent supports it). The handler stops propagation so the event
|
||||
// bubbles no further (the nearest parent claims the child).
|
||||
function createChildRegisterHandler(layer: Layer, children: Map<HTMLElement, ChildEntry>) {
|
||||
return function (e: LeafletRegisterEvent) {
|
||||
const obj = e.detail.leafletObject;
|
||||
const el = e.detail.element;
|
||||
if (obj instanceof Popup) {
|
||||
e.stopPropagation();
|
||||
layer.bindPopup(obj);
|
||||
children.set(el, 'popup');
|
||||
} else if (obj instanceof Tooltip) {
|
||||
e.stopPropagation();
|
||||
layer.bindTooltip(obj);
|
||||
children.set(el, 'tooltip');
|
||||
} else if (obj instanceof Layer && 'addLayer' in layer) {
|
||||
e.stopPropagation();
|
||||
(layer as unknown as LayerGroup).addLayer(obj);
|
||||
children.set(el, 'layer');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Module-scoped WeakMaps keyed by the parent element so we don't pollute
|
||||
// component instances with #children / #handler fields. This keeps the
|
||||
// data accessible to any caller that holds a reference to the element.
|
||||
const childrenMap = new WeakMap<HTMLElement, Map<HTMLElement, ChildEntry>>();
|
||||
const handlerMap = new WeakMap<HTMLElement, (e: LeafletRegisterEvent) => void>();
|
||||
|
||||
// Dispatches a custom `leaflet-register` event upward through the DOM
|
||||
// tree, carrying a Leaflet object and its host element. Parent components
|
||||
// (map, circles, groups, etc.) intercept this event and add the layer,
|
||||
@ -80,33 +45,3 @@ export function registerWithParent(el: HTMLElement, obj: unknown) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Called during connectedCallback: creates the child tracker and event
|
||||
// handler, wires up the listener, and registers this element with its
|
||||
// own parent (so nested structures like polygon→line→point work).
|
||||
// Returns the children map for the caller to keep a reference.
|
||||
export function registerChildren(el: HTMLElement, layer: Layer): Map<HTMLElement, ChildEntry> {
|
||||
const children = new Map<HTMLElement, ChildEntry>();
|
||||
const handler = createChildRegisterHandler(layer, children);
|
||||
childrenMap.set(el, children);
|
||||
handlerMap.set(el, handler);
|
||||
el.addEventListener('leaflet-register', handler);
|
||||
registerWithParent(el, layer);
|
||||
return children;
|
||||
}
|
||||
|
||||
// Called during disconnectedCallback: tears down the event listener and
|
||||
// removes the WeakMap entries so both the handler and children map can
|
||||
// be GC'd when the element is removed from the DOM.
|
||||
export function unregisterChildren(el: HTMLElement): void {
|
||||
const handler = handlerMap.get(el);
|
||||
if (handler) el.removeEventListener('leaflet-register', handler);
|
||||
childrenMap.delete(el);
|
||||
handlerMap.delete(el);
|
||||
}
|
||||
|
||||
// Public accessor for the children map. Used by the controls panel and
|
||||
// by any component that needs to inspect its registered descendants.
|
||||
export function getChildren(el: HTMLElement): Map<HTMLElement, ChildEntry> | undefined {
|
||||
return childrenMap.get(el);
|
||||
}
|
||||
|
||||
156
src/core/shared-props.ts
Normal file
156
src/core/shared-props.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import type {
|
||||
CrossOrigin,
|
||||
FillRule,
|
||||
LatLng,
|
||||
LatLngBounds,
|
||||
LatLngBoundsExpression,
|
||||
LineCapShape,
|
||||
LineJoinShape,
|
||||
PathOptions,
|
||||
ReferrerPolicy,
|
||||
} from 'leaflet';
|
||||
import { bool, choice, json, num, positional, str, type PropDef } from './props.ts';
|
||||
|
||||
// Leaflet classes share no common interface, so the prop fragments below
|
||||
// describe structurally what they need from the object they update.
|
||||
export interface Positioned {
|
||||
getLatLng(): LatLng | undefined;
|
||||
setLatLng(latlng: [number, number]): unknown;
|
||||
}
|
||||
|
||||
export interface Styleable {
|
||||
setStyle(style: PathOptions): unknown;
|
||||
}
|
||||
|
||||
export interface Sourced {
|
||||
setUrl(url: string): unknown;
|
||||
getUrl?(): string;
|
||||
}
|
||||
|
||||
export interface Bounded {
|
||||
getBounds(): LatLngBounds;
|
||||
}
|
||||
|
||||
// Shared `get` for any `bounds` prop backed by Leaflet's getBounds(). Returns
|
||||
// a plain [[south, west], [north, east]] pair rather than the LatLngBounds
|
||||
// instance, matching the JSON-encoded shape the attribute round-trips through.
|
||||
export function getBounds(obj: Bounded): LatLngBoundsExpression {
|
||||
const b = obj.getBounds();
|
||||
return [
|
||||
[b.getSouth(), b.getWest()],
|
||||
[b.getNorth(), b.getEast()],
|
||||
];
|
||||
}
|
||||
|
||||
interface PositionedHost extends HTMLElement {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
// A `set` for any option Leaflet only exposes through setStyle().
|
||||
export function style<T>(key: keyof PathOptions): (obj: Styleable, value: T) => void {
|
||||
return (obj, value) => {
|
||||
obj.setStyle({ [key]: value } as PathOptions);
|
||||
};
|
||||
}
|
||||
|
||||
// The source url. Passed positionally by every Leaflet constructor that takes
|
||||
// one, and ignored when blank so clearing the attribute can't request nothing.
|
||||
export const urlProp = positional(
|
||||
str<Sourced>('', {
|
||||
set(obj, value) {
|
||||
if (value) obj.setUrl(value);
|
||||
},
|
||||
// Only ImageOverlay/VideoOverlay implement getUrl(); TileLayer doesn't,
|
||||
// so this falls back to the attribute there, same as omitting `get`.
|
||||
get: (obj) => obj.getUrl?.(),
|
||||
}),
|
||||
);
|
||||
|
||||
// lat/lng travel together: both are passed positionally to the Leaflet
|
||||
// constructor rather than as options, setting either re-issues setLatLng with
|
||||
// the other's current value, and both are written back whenever the object
|
||||
// moves -- which is what keeps the attributes current while a marker is
|
||||
// dragged. Shared by marker, circle, circle-marker, popup and tooltip.
|
||||
export const latLngProps = {
|
||||
lat: positional(
|
||||
num<Positioned>(0, {
|
||||
event: 'move',
|
||||
get: (obj) => obj.getLatLng()?.lat,
|
||||
set(obj, value, el) {
|
||||
obj.setLatLng([value, (el as PositionedHost).lng]);
|
||||
},
|
||||
}),
|
||||
),
|
||||
lng: positional(
|
||||
num<Positioned>(0, {
|
||||
event: 'move',
|
||||
get: (obj) => obj.getLatLng()?.lng,
|
||||
set(obj, value, el) {
|
||||
obj.setLatLng([(el as PositionedHost).lat, value]);
|
||||
},
|
||||
}),
|
||||
),
|
||||
} as const;
|
||||
|
||||
// The style options every Path accepts. Defaults match Leaflet's own, so an
|
||||
// absent attribute and an unset option mean the same thing.
|
||||
export const pathProps = {
|
||||
stroke: bool<Styleable>(true, { set: style('stroke') }),
|
||||
color: str<Styleable>('#3388ff', { set: style('color') }),
|
||||
weight: num<Styleable>(3, { set: style('weight') }),
|
||||
opacity: num<Styleable>(1.0, { set: style('opacity') }),
|
||||
lineCap: choice<LineCapShape, Styleable>('round', { set: style('lineCap') }),
|
||||
lineJoin: choice<LineJoinShape, Styleable>('round', { set: style('lineJoin') }),
|
||||
dashArray: str<Styleable>('', { set: style('dashArray') }),
|
||||
dashOffset: str<Styleable>('', { set: style('dashOffset') }),
|
||||
fill: bool<Styleable>(true, { set: style('fill') }),
|
||||
fillColor: str<Styleable>('#3388ff', { set: style('fillColor') }),
|
||||
fillOpacity: num<Styleable>(0.2, { set: style('fillOpacity') }),
|
||||
fillRule: choice<FillRule, Styleable>('evenodd', { set: style('fillRule') }),
|
||||
// Constructor-only: Leaflet has no setter for these, so changing the
|
||||
// attribute after creation has no effect (same as leaflet-map's zoomSnap).
|
||||
className: str(),
|
||||
interactive: bool(true),
|
||||
bubblingMouseEvents: bool(true),
|
||||
pane: str('overlay'),
|
||||
} as const;
|
||||
|
||||
// The GridLayer/TileLayer options every tile source accepts, shared by
|
||||
// leaflet-tile-layer and leaflet-tile-layer-wms (WMSOptions extends
|
||||
// TileLayerOptions). Most are constructor-only -- Leaflet exposes no setter
|
||||
// for them -- so changing the attribute after creation has no effect, same as
|
||||
// leaflet-map's zoomSnap.
|
||||
export const tileLayerProps = {
|
||||
attribution: str(),
|
||||
minZoom: num(0),
|
||||
maxZoom: num(18),
|
||||
opacity: num(1.0),
|
||||
zIndex: num(1),
|
||||
subdomains: str('abc'),
|
||||
tms: bool(),
|
||||
zoomOffset: num(0),
|
||||
zoomReverse: bool(),
|
||||
detectRetina: bool(),
|
||||
crossOrigin: choice<CrossOrigin>(''),
|
||||
// Leaflet's ReferrerPolicy type has no "unset" member (unlike the DOM's),
|
||||
// so this is a plain PropDef rather than choice(), with `undefined` as the
|
||||
// fallback the property getter reports when the attribute is absent.
|
||||
referrerPolicy: {
|
||||
default: undefined,
|
||||
decode: (raw) => raw as ReferrerPolicy,
|
||||
encode: (value) => value ?? null,
|
||||
} as PropDef<ReferrerPolicy | undefined>,
|
||||
errorTileUrl: str(),
|
||||
tileSize: num(256),
|
||||
noWrap: bool(),
|
||||
bounds: json<LatLngBoundsExpression>([]),
|
||||
className: str(),
|
||||
minNativeZoom: num(),
|
||||
maxNativeZoom: num(),
|
||||
keepBuffer: num(2),
|
||||
updateWhenIdle: bool(),
|
||||
updateWhenZooming: bool(true),
|
||||
updateInterval: num(200),
|
||||
pane: str('tilePane'),
|
||||
} as const;
|
||||
307
src/core/with-props.ts
Normal file
307
src/core/with-props.ts
Normal file
@ -0,0 +1,307 @@
|
||||
import { Layer, Popup, Tooltip, type Class } from 'leaflet';
|
||||
import {
|
||||
kebab,
|
||||
type PropDef,
|
||||
type PropOptionValues,
|
||||
type PropTable,
|
||||
type PropValues,
|
||||
} from './props.ts';
|
||||
import { registerWithParent, type LeafletRegisterEvent } from './register.ts';
|
||||
|
||||
// How an element joins the component tree:
|
||||
// children -- register with the nearest parent and adopt registering
|
||||
// descendants as layers, popups and tooltips (every layer)
|
||||
// self -- register with the nearest parent only (popups, tooltips,
|
||||
// controls: they have a parent but manage no children here)
|
||||
// none -- neither (the map is the root; icons aren't part of the tree)
|
||||
export type Attach = 'children' | 'self' | 'none';
|
||||
|
||||
export interface ElementOptions {
|
||||
attach?: Attach;
|
||||
|
||||
// Rebuild the Leaflet object on every attribute change instead of calling
|
||||
// setters, for objects Leaflet gives us no way to mutate in place (icons).
|
||||
recreate?: boolean;
|
||||
}
|
||||
|
||||
// The members WithProps contributes on top of the property accessors.
|
||||
export interface LeafletElement<TObj, TProps> {
|
||||
readonly leafletObject?: TObj;
|
||||
|
||||
// The one method every component must implement. `options` holds the decoded
|
||||
// value of every prop whose attribute is present, keyed by property name, so
|
||||
// it can be handed straight to the Leaflet constructor.
|
||||
createLeafletObject(options: PropOptionValues<TProps>): TObj | undefined;
|
||||
|
||||
// Called after the object is created and after every recreate.
|
||||
leafletObjectCreated(): void;
|
||||
|
||||
recreateLeafletObject(): void;
|
||||
|
||||
connectedCallback(): void;
|
||||
disconnectedCallback(): void;
|
||||
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
||||
}
|
||||
|
||||
export type LeafletElementConstructor<TObj, TProps> = new () => HTMLElement &
|
||||
PropValues<TProps> &
|
||||
LeafletElement<TObj, TProps>;
|
||||
|
||||
interface ResolvedProp<TObj extends Class> {
|
||||
name: string;
|
||||
attribute: string;
|
||||
setter: keyof TObj;
|
||||
def: PropDef<unknown, TObj>;
|
||||
}
|
||||
|
||||
type ChildEntry = { type: 'layer' | 'popup' | 'tooltip'; object: Layer };
|
||||
|
||||
type AnyMethod = (...args: unknown[]) => unknown;
|
||||
|
||||
// Looks up a Leaflet method by name and binds it, or returns undefined. Leaflet
|
||||
// objects are duck-typed here on purpose: Layer, Control and Icon share no
|
||||
// common interface, and which setters exist varies per class.
|
||||
// function method<TObj extends Record<string, unknown>, TName extends keyof TObj>(obj: TObj, name: TName | string): TObj[TName] | undefined {
|
||||
function method<TObj extends Class, TName extends keyof TObj>(
|
||||
obj: TObj,
|
||||
name: TName | string,
|
||||
): AnyMethod | undefined {
|
||||
const value = obj[name as TName];
|
||||
return typeof value === 'function' ? value.bind(obj) : undefined;
|
||||
}
|
||||
|
||||
function resolve<TObj extends Class>(props: PropTable<TObj>): ResolvedProp<TObj>[] {
|
||||
return Object.entries(props).map(([name, def]) => ({
|
||||
name,
|
||||
attribute:
|
||||
typeof def.attribute === 'function' ? def.attribute(name) : (def.attribute ?? kebab(name)),
|
||||
setter: `set${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TObj,
|
||||
def,
|
||||
}));
|
||||
}
|
||||
|
||||
// Builds a custom element base class from a table of property definitions.
|
||||
//
|
||||
// The generated class owns the whole lifecycle: it derives observedAttributes,
|
||||
// defines two-way property accessors, builds the Leaflet options object, wires
|
||||
// the object into the component tree, keeps attributes in sync with the object
|
||||
// (in both directions, without cycles), and re-fires every Leaflet event on the
|
||||
// element as `leaflet:<type>`. Subclasses implement createLeafletObject().
|
||||
export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
|
||||
props: TProps,
|
||||
options: ElementOptions = {},
|
||||
): LeafletElementConstructor<TObj, TProps> {
|
||||
const resolved = resolve(props);
|
||||
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
|
||||
const attach = options.attach ?? 'children';
|
||||
|
||||
class WithPropsElement extends HTMLElement {
|
||||
static readonly observedAttributes = resolved.map((prop) => prop.attribute);
|
||||
|
||||
#obj?: TObj;
|
||||
#connected = false;
|
||||
#children = new Map<HTMLElement, ChildEntry>();
|
||||
#listeners: [event: string, handler: () => void][] = [];
|
||||
|
||||
// Set while writing an attribute on the object's behalf, so the resulting
|
||||
// attributeChangedCallback doesn't push the value straight back into
|
||||
// Leaflet. This is the only place a cycle could form.
|
||||
#syncing = false;
|
||||
|
||||
get leafletObject(): TObj | undefined {
|
||||
return this.#obj;
|
||||
}
|
||||
|
||||
createLeafletObject(_options: PropOptionValues<TProps>): TObj | undefined {
|
||||
throw new Error(`<${this.localName}> does not implement createLeafletObject()`);
|
||||
}
|
||||
|
||||
leafletObjectCreated(): void {
|
||||
// Overridden by components that need to react to a new Leaflet object.
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
if (this.#connected) return;
|
||||
this.#connected = true;
|
||||
this.#createObject();
|
||||
if (attach === 'children') {
|
||||
this.addEventListener('leaflet-register', this.#onChildRegister);
|
||||
}
|
||||
if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj);
|
||||
this.leafletObjectCreated();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (attach === 'children') {
|
||||
this.removeEventListener('leaflet-register', this.#onChildRegister);
|
||||
}
|
||||
this.#releaseChildren();
|
||||
this.#destroyObject();
|
||||
this.#connected = false;
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
||||
if (oldValue === newValue || this.#syncing || !this.#connected) return;
|
||||
const prop = byAttribute.get(name);
|
||||
if (!prop) return;
|
||||
if (options.recreate) {
|
||||
this.#recreateLeafletObject();
|
||||
return;
|
||||
}
|
||||
const obj = this.#obj;
|
||||
if (!obj) return;
|
||||
const value = newValue === null ? prop.def.default : prop.def.decode(newValue);
|
||||
if (prop.def.set) prop.def.set(obj, value, this);
|
||||
else method(obj, prop.setter)?.(value);
|
||||
}
|
||||
|
||||
// Throws the current object away and builds a fresh one from the current
|
||||
// attributes, leaving the element's place in the tree untouched.
|
||||
#recreateLeafletObject(): void {
|
||||
this.#destroyObject();
|
||||
this.#createObject();
|
||||
this.leafletObjectCreated();
|
||||
}
|
||||
|
||||
#createObject(): void {
|
||||
const obj = this.createLeafletObject(this.#buildOptions());
|
||||
this.#obj = obj;
|
||||
if (!obj) return;
|
||||
this.#watchObject(obj);
|
||||
this.#forwardEvents(obj);
|
||||
}
|
||||
|
||||
#destroyObject(): void {
|
||||
const obj = this.#obj;
|
||||
this.#obj = undefined;
|
||||
if (!obj) return;
|
||||
const off = method(obj, 'off');
|
||||
for (const [event, handler] of this.#listeners) off?.(event, handler);
|
||||
this.#listeners = [];
|
||||
method(obj, 'remove')?.();
|
||||
}
|
||||
|
||||
#buildOptions(): PropOptionValues<TProps> {
|
||||
const opts: Record<string, unknown> = {};
|
||||
for (const prop of resolved) {
|
||||
if (prop.def.option === false) continue;
|
||||
const raw = this.getAttribute(prop.attribute);
|
||||
if (raw !== null) opts[prop.name] = prop.def.decode(raw);
|
||||
}
|
||||
return opts as PropOptionValues<TProps>;
|
||||
}
|
||||
|
||||
// Registers one Leaflet listener per distinct event, updating every prop
|
||||
// that names it. Dragging a marker fires `move` once and writes both lat
|
||||
// and lng.
|
||||
#watchObject(obj: TObj): void {
|
||||
const on = method(obj, 'on');
|
||||
if (!on) return;
|
||||
const groups = new Map<string, ResolvedProp<TObj>[]>();
|
||||
for (const prop of resolved) {
|
||||
if (!prop.def.event || !prop.def.get) continue;
|
||||
const group = groups.get(prop.def.event) ?? [];
|
||||
group.push(prop);
|
||||
groups.set(prop.def.event, group);
|
||||
}
|
||||
for (const [event, group] of groups) {
|
||||
const handler = () => {
|
||||
for (const prop of group) this.#syncAttribute(prop);
|
||||
};
|
||||
this.#listeners.push([event, handler]);
|
||||
on(event, handler);
|
||||
}
|
||||
}
|
||||
|
||||
#syncAttribute(prop: ResolvedProp<TObj>): void {
|
||||
const obj = this.#obj;
|
||||
const value = obj && prop.def.get?.(obj);
|
||||
if (value === undefined) return;
|
||||
const raw = prop.def.encode(value);
|
||||
if (this.getAttribute(prop.attribute) === raw) return;
|
||||
this.#syncing = true;
|
||||
try {
|
||||
if (raw === null) this.removeAttribute(prop.attribute);
|
||||
else this.setAttribute(prop.attribute, raw);
|
||||
} finally {
|
||||
this.#syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Leaflet has no wildcard listener, so we wrap the instance's own `fire` --
|
||||
// it's an object we created and hold alone. Every Leaflet event becomes a
|
||||
// `leaflet:<type>` DOM event carrying the Leaflet event as its detail,
|
||||
// dispatched after Leaflet's handlers so synced attributes are current.
|
||||
// Not bubbling: Leaflet already propagates layer events up to the map, so
|
||||
// <leaflet-map> would otherwise see each of them twice.
|
||||
#forwardEvents(obj: object): void {
|
||||
const target = obj as {
|
||||
fire?: (type: string, data?: object, propagate?: boolean) => unknown;
|
||||
};
|
||||
const fire = target.fire;
|
||||
if (typeof fire !== 'function') return;
|
||||
target.fire = (type, data, propagate) => {
|
||||
const result = fire.call(obj, type, data, propagate);
|
||||
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail: data ?? {} }));
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
// Claims descendants that announce themselves with `leaflet-register`.
|
||||
// Skips our own announcement, which is dispatched on this element too.
|
||||
#onChildRegister = (e: LeafletRegisterEvent) => {
|
||||
const obj = this.#obj;
|
||||
const child = e.detail.leafletObject;
|
||||
const el = e.detail.element;
|
||||
if (!obj || el === this) return;
|
||||
if (child instanceof Popup) {
|
||||
e.stopPropagation();
|
||||
method(obj, 'bindPopup')?.(child);
|
||||
this.#children.set(el, { type: 'popup', object: child });
|
||||
} else if (child instanceof Tooltip) {
|
||||
e.stopPropagation();
|
||||
method(obj, 'bindTooltip')?.(child);
|
||||
this.#children.set(el, { type: 'tooltip', object: child });
|
||||
} else if (child instanceof Layer && 'addLayer' in obj) {
|
||||
e.stopPropagation();
|
||||
method(obj, 'addLayer')?.(child);
|
||||
this.#children.set(el, { type: 'layer', object: child });
|
||||
}
|
||||
};
|
||||
|
||||
#releaseChildren(): void {
|
||||
const obj = this.#obj;
|
||||
if (obj) {
|
||||
for (const entry of this.#children.values()) {
|
||||
if (entry.type === 'popup') method(obj, 'unbindPopup')?.();
|
||||
else if (entry.type === 'tooltip') method(obj, 'unbindTooltip')?.();
|
||||
else method(obj, 'removeLayer')?.(entry.object);
|
||||
}
|
||||
}
|
||||
this.#children.clear();
|
||||
}
|
||||
}
|
||||
|
||||
for (const prop of resolved) {
|
||||
Object.defineProperty(WithPropsElement.prototype, prop.name, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get(this: WithPropsElement) {
|
||||
const obj = this.leafletObject;
|
||||
if (obj && prop.def.get) {
|
||||
const live = prop.def.get(obj);
|
||||
if (live !== undefined) return live;
|
||||
}
|
||||
const raw = this.getAttribute(prop.attribute);
|
||||
return raw === null ? prop.def.default : prop.def.decode(raw);
|
||||
},
|
||||
set(this: WithPropsElement, value: unknown) {
|
||||
const raw = prop.def.encode(value);
|
||||
if (raw === null) this.removeAttribute(prop.attribute);
|
||||
else this.setAttribute(prop.attribute, raw);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return WithPropsElement as unknown as LeafletElementConstructor<TObj, TProps>;
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
export * from './core/attributes.ts';
|
||||
export * from './core/register.ts';
|
||||
export * from './core/props.ts';
|
||||
export { LeafletMap } from './components/leaflet-map.ts';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user