refactor: split element classes from customElements.define
Every component is now two modules sharing a basename: - src/elements/leaflet-foo.ts -- `export default class LeafletFooElement extends WithProps(...)`, the class only, no customElements.define, no side effects. src/elements/index.ts is an order-free barrel. - src/components/leaflet-foo.ts -- three lines: import the class, define the tag, re-export. Importing this (or src/index.ts) registers the tag. Plugin authors can now import a class without triggering the built-in define, to subclass it or register it under a different tag name. Package subpath exports `leaflet-components/elements`, `leaflet-components/elements/leaflet-foo.js`, and `leaflet-components/components/leaflet-foo.js` map onto dist/; jsr.json gains an `./elements` entry. The load-bearing ordering moves from the export statements in src/index.ts to its `./components/*` side-effect imports (the elements barrel carries no define, so its order is free). Classes are renamed LeafletFoo -> LeafletFooElement, including in the HTMLElementTagNameMap augmentation. Docs (CLAUDE.md, docs/, README.md) updated for the new layout.main
parent
2c77784345
commit
f9c07a263d
@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@buddy/leaflet-components",
|
"name": "@buddy/leaflet-components",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"exports": "./src/index.ts"
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./elements": "./src/elements/index.ts"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,5 @@
|
|||||||
import { CircleMarker, type CircleMarkerOptions } from 'leaflet';
|
import LeafletCircleMarkerElement from '../elements/leaflet-circle-marker.ts';
|
||||||
import { num } from '../core/props.ts';
|
|
||||||
import { latLngProps, pathProps } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { PathEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletCircleMarker extends WithProps({
|
customElements.define('leaflet-circle-marker', LeafletCircleMarkerElement);
|
||||||
...latLngProps,
|
|
||||||
radius: num<CircleMarker>(10, { get: (obj) => obj.getRadius() }),
|
|
||||||
...pathProps,
|
|
||||||
}) {
|
|
||||||
declare readonly leafletObject?: CircleMarker;
|
|
||||||
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(options: CircleMarkerOptions): CircleMarker {
|
export { LeafletCircleMarkerElement };
|
||||||
return new CircleMarker([this.lat, this.lng], options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-circle-marker', LeafletCircleMarker);
|
|
||||||
|
|||||||
@ -1,27 +1,5 @@
|
|||||||
import { Circle, type CircleOptions } from 'leaflet';
|
import LeafletCircleElement from '../elements/leaflet-circle.ts';
|
||||||
import { num, positional } from '../core/props.ts';
|
|
||||||
import { latLngProps, pathProps } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { PathEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletCircle extends WithProps({
|
customElements.define('leaflet-circle', LeafletCircleElement);
|
||||||
...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;
|
|
||||||
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(options: CircleOptions): Circle {
|
export { LeafletCircleElement };
|
||||||
return new Circle([this.lat, this.lng], { ...options, radius: this.radius });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-circle', LeafletCircle);
|
|
||||||
|
|||||||
@ -1,19 +1,5 @@
|
|||||||
import { Control, type ControlPosition } from 'leaflet';
|
import LeafletControlAttributionElement from '../elements/leaflet-control-attribution.ts';
|
||||||
import { choice, str } from '../core/props.ts';
|
|
||||||
import { WithProps } from '../core/with-props.ts';
|
|
||||||
|
|
||||||
export class LeafletControlAttribution extends WithProps(
|
customElements.define('leaflet-control-attribution', LeafletControlAttributionElement);
|
||||||
{
|
|
||||||
position: choice<ControlPosition>('bottomright'),
|
|
||||||
prefix: str(),
|
|
||||||
},
|
|
||||||
{ attach: 'self' },
|
|
||||||
) {
|
|
||||||
declare readonly leafletObject?: Control.Attribution;
|
|
||||||
|
|
||||||
createLeafletObject(options: Control.AttributionOptions): Control.Attribution {
|
export { LeafletControlAttributionElement };
|
||||||
return new Control.Attribution(options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-control-attribution', LeafletControlAttribution);
|
|
||||||
|
|||||||
@ -1,53 +1,5 @@
|
|||||||
import { Control, type ControlPosition } from 'leaflet';
|
import LeafletControlLayersElement from '../elements/leaflet-control-layers.ts';
|
||||||
import { bool, choice } from '../core/props.ts';
|
|
||||||
import { WithProps } from '../core/with-props.ts';
|
|
||||||
import type { LeafletRegisterEvent } from '../core/register.ts';
|
|
||||||
|
|
||||||
export class LeafletControlLayers extends WithProps(
|
customElements.define('leaflet-control-layers', LeafletControlLayersElement);
|
||||||
{
|
|
||||||
position: choice<ControlPosition>('topright'),
|
|
||||||
collapsed: bool(true),
|
|
||||||
autoZIndex: bool(true),
|
|
||||||
hideSingleBase: bool(),
|
|
||||||
sortLayers: bool(),
|
|
||||||
},
|
|
||||||
{ attach: 'self' },
|
|
||||||
) {
|
|
||||||
declare readonly leafletObject?: Control.Layers;
|
|
||||||
|
|
||||||
createLeafletObject(options: Control.LayersOptions): Control.Layers {
|
export { LeafletControlLayersElement };
|
||||||
return new Control.Layers({}, {}, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.addEventListener('leaflet-register', this.#onChildRegister);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
this.removeEventListener('leaflet-register', this.#onChildRegister);
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every child -- present at construction or added later -- announces
|
|
||||||
// itself this way: a parent's connectedCallback always finishes before a
|
|
||||||
// child's does (even for an already-built subtree attached in one go), so
|
|
||||||
// there is no "read the children that are already here" case to handle
|
|
||||||
// separately.
|
|
||||||
#onChildRegister = (e: LeafletRegisterEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const el = e.detail.element;
|
|
||||||
const layer = e.detail.leafletObject;
|
|
||||||
const name = el.getAttribute('name');
|
|
||||||
if (!name) return;
|
|
||||||
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 } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-control-layers', LeafletControlLayers);
|
|
||||||
|
|||||||
@ -1,22 +1,5 @@
|
|||||||
import { Control, type ControlPosition } from 'leaflet';
|
import LeafletControlScaleElement from '../elements/leaflet-control-scale.ts';
|
||||||
import { bool, choice, num } from '../core/props.ts';
|
|
||||||
import { WithProps } from '../core/with-props.ts';
|
|
||||||
|
|
||||||
export class LeafletControlScale extends WithProps(
|
customElements.define('leaflet-control-scale', LeafletControlScaleElement);
|
||||||
{
|
|
||||||
position: choice<ControlPosition>('bottomleft'),
|
|
||||||
maxWidth: num(100),
|
|
||||||
metric: bool(true),
|
|
||||||
imperial: bool(true),
|
|
||||||
updateWhenIdle: bool(),
|
|
||||||
},
|
|
||||||
{ attach: 'self' },
|
|
||||||
) {
|
|
||||||
declare readonly leafletObject?: Control.Scale;
|
|
||||||
|
|
||||||
createLeafletObject(options: Control.ScaleOptions): Control.Scale {
|
export { LeafletControlScaleElement };
|
||||||
return new Control.Scale(options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-control-scale', LeafletControlScale);
|
|
||||||
|
|||||||
@ -1,48 +1,5 @@
|
|||||||
import { DivIcon, type DivIconOptions, type PointExpression } from 'leaflet';
|
import LeafletDivIconElement from '../elements/leaflet-div-icon.ts';
|
||||||
import { json, str } from '../core/props.ts';
|
|
||||||
import { emitIconChanged } from '../core/register.ts';
|
|
||||||
import { WithProps } from '../core/with-props.ts';
|
|
||||||
|
|
||||||
// Like leaflet-icon, rebuilt on every change -- including changes to the
|
customElements.define('leaflet-div-icon', LeafletDivIconElement);
|
||||||
// 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;
|
|
||||||
|
|
||||||
#observer?: MutationObserver;
|
export { LeafletDivIconElement };
|
||||||
|
|
||||||
createLeafletObject(options: DivIconOptions): DivIcon {
|
|
||||||
return new DivIcon(this.innerHTML ? { ...options, html: this.innerHTML } : options);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
leafletObjectCreated(): void {
|
|
||||||
emitIconChanged(this, this.leafletObject);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-div-icon', LeafletDivIcon);
|
|
||||||
|
|||||||
@ -1,20 +1,5 @@
|
|||||||
import { FeatureGroup } from 'leaflet';
|
import LeafletFeatureGroupElement from '../elements/leaflet-feature-group.ts';
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { GroupEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
// Like leaflet-layer-group, but its children share events and a bounding box.
|
customElements.define('leaflet-feature-group', LeafletFeatureGroupElement);
|
||||||
export class LeafletFeatureGroup extends WithProps({}) {
|
|
||||||
declare readonly leafletObject?: FeatureGroup;
|
|
||||||
declare addEventListener: LeafletAddEventListener<GroupEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(): FeatureGroup {
|
export { LeafletFeatureGroupElement };
|
||||||
return new FeatureGroup([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-feature-group', LeafletFeatureGroup);
|
|
||||||
|
|||||||
@ -1,34 +1,5 @@
|
|||||||
import { GeoJSON, type PathOptions } from 'leaflet';
|
import LeafletGeoJSONElement from '../elements/leaflet-geojson.ts';
|
||||||
import type { GeoJsonObject } from 'geojson';
|
|
||||||
import { json, positional } from '../core/props.ts';
|
|
||||||
import { pathProps } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { GroupEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletGeoJSON extends WithProps({
|
customElements.define('leaflet-geojson', LeafletGeoJSONElement);
|
||||||
data: positional(
|
|
||||||
json<GeoJsonObject | null>(null, {
|
|
||||||
set(obj: GeoJSON, value) {
|
|
||||||
obj.clearLayers();
|
|
||||||
if (value) obj.addData(value);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
...pathProps,
|
|
||||||
}) {
|
|
||||||
declare readonly leafletObject?: GeoJSON;
|
|
||||||
declare addEventListener: LeafletAddEventListener<GroupEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
|
|
||||||
|
|
||||||
// Every prop but `data` is a style option, and GeoJSON takes those nested
|
export { LeafletGeoJSONElement };
|
||||||
// under `style` so they apply to each feature it builds.
|
|
||||||
createLeafletObject(options: PathOptions): GeoJSON {
|
|
||||||
return new GeoJSON(this.data, { style: options });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-geojson', LeafletGeoJSON);
|
|
||||||
|
|||||||
@ -1,39 +1,5 @@
|
|||||||
import { Icon, type IconOptions, type PointExpression } from 'leaflet';
|
import LeafletIconElement from '../elements/leaflet-icon.ts';
|
||||||
import { json, str } from '../core/props.ts';
|
|
||||||
import { emitIconChanged } from '../core/register.ts';
|
|
||||||
import { WithProps } from '../core/with-props.ts';
|
|
||||||
|
|
||||||
const PROPS = {
|
customElements.define('leaflet-icon', LeafletIconElement);
|
||||||
iconUrl: str(),
|
|
||||||
iconRetinaUrl: 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: json<PointExpression>([0, 0]),
|
|
||||||
shadowAnchor: json<PointExpression>([0, 0]),
|
|
||||||
className: str(),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// An Icon has no setters, so every attribute change builds a new one and the
|
export { LeafletIconElement };
|
||||||
// parent marker is told to swap it in.
|
|
||||||
export class LeafletIcon extends WithProps(PROPS, { attach: 'none', recreate: true }) {
|
|
||||||
declare readonly leafletObject?: Icon;
|
|
||||||
|
|
||||||
createLeafletObject(options: Partial<IconOptions>): Icon | undefined {
|
|
||||||
return options.iconUrl ? new Icon(options as IconOptions) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
leafletObjectCreated(): void {
|
|
||||||
emitIconChanged(this, this.leafletObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
emitIconChanged(this, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-icon', LeafletIcon);
|
|
||||||
|
|||||||
@ -1,41 +1,5 @@
|
|||||||
import {
|
import LeafletImageOverlayElement from '../elements/leaflet-image-overlay.ts';
|
||||||
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,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { PathEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletImageOverlay extends WithProps({
|
customElements.define('leaflet-image-overlay', LeafletImageOverlayElement);
|
||||||
url: urlProp,
|
|
||||||
bounds: positional(json<LatLngBoundsExpression, ImageOverlay>([], { get: getBounds })),
|
|
||||||
opacity: num(1.0),
|
|
||||||
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;
|
|
||||||
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(options: ImageOverlayOptions): ImageOverlay {
|
export { LeafletImageOverlayElement };
|
||||||
return new ImageOverlay(this.url, this.bounds, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-image-overlay', LeafletImageOverlay);
|
|
||||||
|
|||||||
@ -1,21 +1,5 @@
|
|||||||
import { LayerGroup } from 'leaflet';
|
import LeafletLayerGroupElement from '../elements/leaflet-layer-group.ts';
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { GroupEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
// A passthrough container: it has no options of its own, and children add
|
customElements.define('leaflet-layer-group', LeafletLayerGroupElement);
|
||||||
// themselves to it through the standard registration bubble.
|
|
||||||
export class LeafletLayerGroup extends WithProps({}) {
|
|
||||||
declare readonly leafletObject?: LayerGroup;
|
|
||||||
declare addEventListener: LeafletAddEventListener<GroupEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(): LayerGroup {
|
export { LeafletLayerGroupElement };
|
||||||
return new LayerGroup([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-layer-group', LeafletLayerGroup);
|
|
||||||
|
|||||||
@ -1,31 +1,5 @@
|
|||||||
import { emitLineRemove, emitLineSync } from '../core/register.ts';
|
import LeafletLineElement from '../elements/leaflet-line.ts';
|
||||||
|
|
||||||
export class LeafletLine extends HTMLElement {
|
customElements.define('leaflet-line', LeafletLineElement);
|
||||||
static get observedAttributes() {
|
|
||||||
return ['lat', 'lng'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached because disconnectedCallback fires after this is already detached
|
export { LeafletLineElement };
|
||||||
// from it -- see emitLineRemove.
|
|
||||||
#parent: ParentNode | null = null;
|
|
||||||
|
|
||||||
get latlng(): [number, number] {
|
|
||||||
return [+(this.getAttribute('lat') ?? 0), +(this.getAttribute('lng') ?? 0)];
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback(): void {
|
|
||||||
this.#parent = this.parentNode;
|
|
||||||
emitLineSync(this, this.latlng);
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeChangedCallback(): void {
|
|
||||||
emitLineSync(this, this.latlng);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
emitLineRemove(this.#parent ?? this, this);
|
|
||||||
this.#parent = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-line', LeafletLine);
|
|
||||||
|
|||||||
@ -1,238 +1,5 @@
|
|||||||
import { Icon, Map as LMap, type MapOptions } from 'leaflet';
|
import LeafletMapElement from '../elements/leaflet-map.ts';
|
||||||
import { bool, disabled, num, positional, str } from '../core/props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts';
|
|
||||||
import type { MapEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
customElements.define('leaflet-map', LeafletMapElement);
|
||||||
const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
|
||||||
|
|
||||||
interface CssHost extends HTMLElement {
|
export { LeafletMapElement };
|
||||||
applyCss(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The css-* attributes describe the stylesheet in the shadow root rather than
|
|
||||||
// anything about the Leaflet map, so a change just re-links it.
|
|
||||||
function relinkCss(_map: LMap, _value: string, el: HTMLElement): void {
|
|
||||||
(el as CssHost).applyCss();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 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),
|
|
||||||
|
|
||||||
// Options Leaflet defaults to true, turned off with disable-* attributes.
|
|
||||||
// The interaction handlers can be toggled after construction.
|
|
||||||
scrollWheelZoom: disabled<LMap>({
|
|
||||||
set: (map, on) => (on ? map.scrollWheelZoom.enable() : map.scrollWheelZoom.disable()),
|
|
||||||
}),
|
|
||||||
dragging: disabled<LMap>({
|
|
||||||
set: (map, on) => (on ? map.dragging.enable() : map.dragging.disable()),
|
|
||||||
}),
|
|
||||||
touchZoom: disabled<LMap>({
|
|
||||||
set: (map, on) => (on ? map.touchZoom.enable() : map.touchZoom.disable()),
|
|
||||||
}),
|
|
||||||
doubleClickZoom: disabled<LMap>({
|
|
||||||
set: (map, on) => (on ? map.doubleClickZoom.enable() : map.doubleClickZoom.disable()),
|
|
||||||
}),
|
|
||||||
boxZoom: disabled<LMap>({
|
|
||||||
set: (map, on) => (on ? map.boxZoom.enable() : map.boxZoom.disable()),
|
|
||||||
}),
|
|
||||||
keyboard: disabled<LMap>({
|
|
||||||
set: (map, on) => (on ? map.keyboard.enable() : map.keyboard.disable()),
|
|
||||||
}),
|
|
||||||
closePopupOnClick: disabled(),
|
|
||||||
trackResize: disabled(),
|
|
||||||
zoomControl: disabled(),
|
|
||||||
attributionControl: disabled(),
|
|
||||||
inertia: disabled(),
|
|
||||||
zoomAnimation: disabled(),
|
|
||||||
fadeAnimation: disabled(),
|
|
||||||
markerZoomAnimation: disabled(),
|
|
||||||
bounceAtZoomLimits: disabled(),
|
|
||||||
tapHold: disabled(),
|
|
||||||
|
|
||||||
// Options Leaflet defaults to false.
|
|
||||||
preferCanvas: bool(),
|
|
||||||
worldCopyJump: bool(),
|
|
||||||
|
|
||||||
// Not Leaflet options at all -- see relinkCss above.
|
|
||||||
cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
|
|
||||||
cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })),
|
|
||||||
cssCrossorigin: positional(str('', { set: relinkCss })),
|
|
||||||
},
|
|
||||||
{ attach: 'none' },
|
|
||||||
) {
|
|
||||||
declare readonly leafletObject?: LMap;
|
|
||||||
declare addEventListener: LeafletAddEventListener<MapEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<MapEvents>;
|
|
||||||
|
|
||||||
#container?: HTMLDivElement;
|
|
||||||
#cssLink?: HTMLLinkElement;
|
|
||||||
#resizeObserver?: ResizeObserver;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback(): void {
|
|
||||||
this.#buildShadowRoot();
|
|
||||||
this.applyCss();
|
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
this.#resizeObserver = new ResizeObserver(() => this.leafletObject?.invalidateSize());
|
|
||||||
this.#resizeObserver.observe(this);
|
|
||||||
|
|
||||||
this.addEventListener('leaflet-register', this.#onRegister);
|
|
||||||
this.addEventListener('leaflet-add-layer', this.#onAddLayer);
|
|
||||||
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
this.#resizeObserver?.disconnect();
|
|
||||||
this.#resizeObserver = undefined;
|
|
||||||
this.removeEventListener('leaflet-register', this.#onRegister);
|
|
||||||
this.removeEventListener('leaflet-add-layer', this.#onAddLayer);
|
|
||||||
this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer);
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
this.#cssLink?.remove();
|
|
||||||
this.#cssLink = undefined;
|
|
||||||
|
|
||||||
const customUrl = this.hasAttribute('css-url');
|
|
||||||
const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL;
|
|
||||||
|
|
||||||
let integrity: string | null = null;
|
|
||||||
if (this.hasAttribute('css-integrity')) {
|
|
||||||
integrity = this.getAttribute('css-integrity') ?? '';
|
|
||||||
} else if (!customUrl) {
|
|
||||||
integrity = DEFAULT_CSS_INTEGRITY;
|
|
||||||
}
|
|
||||||
|
|
||||||
let crossorigin: string | undefined;
|
|
||||||
if (this.hasAttribute('css-crossorigin')) {
|
|
||||||
crossorigin = this.getAttribute('css-crossorigin') ?? undefined;
|
|
||||||
} else if (integrity) {
|
|
||||||
crossorigin = 'anonymous';
|
|
||||||
}
|
|
||||||
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = url ?? '';
|
|
||||||
if (integrity) link.setAttribute('integrity', integrity);
|
|
||||||
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
|
|
||||||
root.append(link);
|
|
||||||
this.#cssLink = link;
|
|
||||||
|
|
||||||
Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
|
|
||||||
}
|
|
||||||
|
|
||||||
#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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#onRegister = (e: LeafletRegisterEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const map = this.leafletObject;
|
|
||||||
if (map) e.detail.leafletObject.addTo(map);
|
|
||||||
};
|
|
||||||
|
|
||||||
#onAddLayer = (e: LeafletLayerEvent) => {
|
|
||||||
this.leafletObject?.addLayer(e.detail.layer);
|
|
||||||
};
|
|
||||||
|
|
||||||
#onRemoveLayer = (e: LeafletLayerEvent) => {
|
|
||||||
this.leafletObject?.removeLayer(e.detail.layer);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-map', LeafletMap);
|
|
||||||
|
|||||||
@ -1,62 +1,5 @@
|
|||||||
import { Icon, Marker, type MarkerOptions } from 'leaflet';
|
import LeafletMarkerElement from '../elements/leaflet-marker.ts';
|
||||||
import { bool, num, str } from '../core/props.ts';
|
|
||||||
import { latLngProps } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { LeafletIconChangedEvent } from '../core/register.ts';
|
|
||||||
import type { MarkerEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
const PROPS = {
|
customElements.define('leaflet-marker', LeafletMarkerElement);
|
||||||
...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;
|
|
||||||
|
|
||||||
export class LeafletMarker extends WithProps(PROPS) {
|
export { LeafletMarkerElement };
|
||||||
declare readonly leafletObject?: Marker;
|
|
||||||
declare addEventListener: LeafletAddEventListener<MarkerEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<MarkerEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(options: MarkerOptions): Marker {
|
|
||||||
return new Marker([this.lat, this.lng], options);
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.addEventListener('icon-changed', this.#onIconChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
this.removeEventListener('icon-changed', this.#onIconChanged);
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
// A <leaflet-icon> or <leaflet-div-icon> child announces itself this way.
|
|
||||||
#onIconChanged = (e: LeafletIconChangedEvent) => {
|
|
||||||
this.leafletObject?.setIcon(e.detail.icon ?? new Icon.Default());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-marker', LeafletMarker);
|
|
||||||
|
|||||||
@ -1,49 +1,5 @@
|
|||||||
import { Polygon, type PolylineOptions } from 'leaflet';
|
import LeafletPolygonElement from '../elements/leaflet-polygon.ts';
|
||||||
import { pathProps } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { PathEvents } from '../core/event-types.ts';
|
|
||||||
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
|
|
||||||
import { VertexTracker } from '../core/vertex-tracker.ts';
|
|
||||||
|
|
||||||
export class LeafletPolygon extends WithProps({ ...pathProps }) {
|
customElements.define('leaflet-polygon', LeafletPolygonElement);
|
||||||
declare readonly leafletObject?: Polygon;
|
|
||||||
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
|
||||||
|
|
||||||
#vertices = new VertexTracker();
|
export { LeafletPolygonElement };
|
||||||
|
|
||||||
createLeafletObject(options: PolylineOptions): Polygon {
|
|
||||||
return new Polygon(this.#vertices.coords(), options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertices come from <leaflet-line> children rather than an attribute --
|
|
||||||
// each one announces its own position via leaflet-line-sync/-remove, we
|
|
||||||
// never read a child's state directly.
|
|
||||||
connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.addEventListener('leaflet-line-sync', this.#onLineSync);
|
|
||||||
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
|
|
||||||
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
#onLineSync = (e: LeafletLineSyncEvent) => {
|
|
||||||
this.#vertices.sync(e.detail.element, e.detail.latlng);
|
|
||||||
this.leafletObject?.setLatLngs(this.#vertices.coords());
|
|
||||||
};
|
|
||||||
|
|
||||||
#onLineRemove = (e: LeafletLineRemoveEvent) => {
|
|
||||||
this.#vertices.remove(e.detail.element);
|
|
||||||
this.leafletObject?.setLatLngs(this.#vertices.coords());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-polygon', LeafletPolygon);
|
|
||||||
|
|||||||
@ -1,56 +1,5 @@
|
|||||||
import { Polyline, type PolylineOptions } from 'leaflet';
|
import LeafletPolylineElement from '../elements/leaflet-polyline.ts';
|
||||||
import { bool, num } from '../core/props.ts';
|
|
||||||
import { pathProps, style } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { PathEvents } from '../core/event-types.ts';
|
|
||||||
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
|
|
||||||
import { VertexTracker } from '../core/vertex-tracker.ts';
|
|
||||||
|
|
||||||
export class LeafletPolyline extends WithProps({
|
customElements.define('leaflet-polyline', LeafletPolylineElement);
|
||||||
...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;
|
|
||||||
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
|
||||||
|
|
||||||
#vertices = new VertexTracker();
|
export { LeafletPolylineElement };
|
||||||
|
|
||||||
createLeafletObject(options: PolylineOptions): Polyline {
|
|
||||||
return new Polyline(this.#vertices.coords(), options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertices come from <leaflet-line> children rather than an attribute --
|
|
||||||
// each one announces its own position via leaflet-line-sync/-remove, we
|
|
||||||
// never read a child's state directly.
|
|
||||||
connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.addEventListener('leaflet-line-sync', this.#onLineSync);
|
|
||||||
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
|
|
||||||
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
#onLineSync = (e: LeafletLineSyncEvent) => {
|
|
||||||
this.#vertices.sync(e.detail.element, e.detail.latlng);
|
|
||||||
this.leafletObject?.setLatLngs(this.#vertices.coords());
|
|
||||||
};
|
|
||||||
|
|
||||||
#onLineRemove = (e: LeafletLineRemoveEvent) => {
|
|
||||||
this.#vertices.remove(e.detail.element);
|
|
||||||
this.leafletObject?.setLatLngs(this.#vertices.coords());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-polyline', LeafletPolyline);
|
|
||||||
|
|||||||
@ -1,54 +1,5 @@
|
|||||||
import { Popup, type PopupOptions } from 'leaflet';
|
import LeafletPopupElement from '../elements/leaflet-popup.ts';
|
||||||
import { bool, num } from '../core/props.ts';
|
|
||||||
import { latLngProps } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletPopup extends WithProps(
|
customElements.define('leaflet-popup', LeafletPopupElement);
|
||||||
{
|
|
||||||
...latLngProps,
|
|
||||||
maxWidth: num(300),
|
|
||||||
minWidth: num(50),
|
|
||||||
maxHeight: num(),
|
|
||||||
autoPan: bool(true),
|
|
||||||
closeButton: bool(true),
|
|
||||||
autoClose: bool(true),
|
|
||||||
},
|
|
||||||
{ attach: 'self' },
|
|
||||||
) {
|
|
||||||
declare readonly leafletObject?: Popup;
|
|
||||||
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
|
|
||||||
|
|
||||||
#observer?: MutationObserver;
|
export { LeafletPopupElement };
|
||||||
|
|
||||||
// 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')) {
|
|
||||||
popup.setLatLng([this.lat, this.lng]);
|
|
||||||
}
|
|
||||||
return popup;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-popup', LeafletPopup);
|
|
||||||
|
|||||||
@ -1,24 +1,5 @@
|
|||||||
import { Rectangle, type LatLngBoundsExpression, type PolylineOptions } from 'leaflet';
|
import LeafletRectangleElement from '../elements/leaflet-rectangle.ts';
|
||||||
import { json, positional } from '../core/props.ts';
|
|
||||||
import { pathProps, getBounds } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { PathEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletRectangle extends WithProps({
|
customElements.define('leaflet-rectangle', LeafletRectangleElement);
|
||||||
bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })),
|
|
||||||
...pathProps,
|
|
||||||
}) {
|
|
||||||
declare readonly leafletObject?: Rectangle;
|
|
||||||
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(options: PolylineOptions): Rectangle {
|
export { LeafletRectangleElement };
|
||||||
return new Rectangle(this.bounds, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-rectangle', LeafletRectangle);
|
|
||||||
|
|||||||
@ -1,35 +1,5 @@
|
|||||||
import {
|
import LeafletSVGOverlayElement from '../elements/leaflet-svg-overlay.ts';
|
||||||
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,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { PathEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletSVGOverlay extends WithProps({
|
customElements.define('leaflet-svg-overlay', LeafletSVGOverlayElement);
|
||||||
bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })),
|
|
||||||
opacity: num(1.0),
|
|
||||||
interactive: bool(),
|
|
||||||
crossOrigin: choice<CrossOrigin>(''),
|
|
||||||
zIndex: num(),
|
|
||||||
className: str(),
|
|
||||||
}) {
|
|
||||||
declare readonly leafletObject?: SVGOverlay;
|
|
||||||
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(options: ImageOverlayOptions): SVGOverlay {
|
export { LeafletSVGOverlayElement };
|
||||||
const svg =
|
|
||||||
this.querySelector('svg') ?? document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
||||||
return new SVGOverlay(svg, this.bounds, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-svg-overlay', LeafletSVGOverlay);
|
|
||||||
|
|||||||
@ -1,88 +1,5 @@
|
|||||||
import { CRS, TileLayer, type WMSOptions, type WMSParams } from 'leaflet';
|
import LeafletTileLayerWMSElement from '../elements/leaflet-tile-layer-wms.ts';
|
||||||
import { bool, str, type PropDef } from '../core/props.ts';
|
|
||||||
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { TileLayerEvents } from '../core/event-types.ts';
|
|
||||||
import type { LeafletCRSChangedEvent } from '../core/register.ts';
|
|
||||||
|
|
||||||
// WMS request parameters have no individual setters -- they're merged into the
|
customElements.define('leaflet-tile-layer-wms', LeafletTileLayerWMSElement);
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// `crs` takes a CRS instance, not a primitive. The attribute only covers the
|
export { LeafletTileLayerWMSElement };
|
||||||
// 4 CRSes Leaflet ships by name -- a CRS Leaflet doesn't ship comes from a
|
|
||||||
// nested child announcing itself via leaflet-crs-changed instead (see
|
|
||||||
// #onCrsChanged), which takes priority over this when present. Constructor-
|
|
||||||
// only, like the rest of tileLayerProps -- Leaflet has no setter for it.
|
|
||||||
const NAMED_CRS = {
|
|
||||||
EPSG3857: CRS.EPSG3857,
|
|
||||||
EPSG4326: CRS.EPSG4326,
|
|
||||||
EPSG3395: CRS.EPSG3395,
|
|
||||||
Simple: CRS.Simple,
|
|
||||||
};
|
|
||||||
type CrsName = keyof typeof NAMED_CRS;
|
|
||||||
|
|
||||||
const crsProp: PropDef<CRS> = {
|
|
||||||
default: CRS.EPSG3857,
|
|
||||||
decode: (raw) => NAMED_CRS[raw as CrsName] ?? CRS.EPSG3857,
|
|
||||||
encode: (value) =>
|
|
||||||
value === CRS.EPSG3857
|
|
||||||
? null
|
|
||||||
: ((Object.keys(NAMED_CRS) as CrsName[]).find((name) => NAMED_CRS[name] === value) ?? null),
|
|
||||||
};
|
|
||||||
|
|
||||||
export class LeafletTileLayerWMS extends WithProps({
|
|
||||||
url: urlProp,
|
|
||||||
...tileLayerProps,
|
|
||||||
layers: str('', { set: param('layers') }),
|
|
||||||
styles: str('', { set: param('styles') }),
|
|
||||||
format: str('image/jpeg', { set: param('format') }),
|
|
||||||
transparent: bool(false, { set: param('transparent') }),
|
|
||||||
version: str('1.1.1', { set: param('version') }),
|
|
||||||
uppercase: bool(),
|
|
||||||
crs: crsProp,
|
|
||||||
}) {
|
|
||||||
declare readonly leafletObject?: TileLayer.WMS;
|
|
||||||
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
|
|
||||||
|
|
||||||
// Set by a nested CRS-providing child; overrides the `crs` attribute's
|
|
||||||
// named lookup when present.
|
|
||||||
#childCrs?: CRS;
|
|
||||||
|
|
||||||
createLeafletObject(options: WMSOptions): TileLayer.WMS {
|
|
||||||
return new TileLayer.WMS(
|
|
||||||
this.url,
|
|
||||||
this.#childCrs ? { ...options, crs: this.#childCrs } : options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.addEventListener('leaflet-crs-changed', this.#onCrsChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
this.removeEventListener('leaflet-crs-changed', this.#onCrsChanged);
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
// A CRS Leaflet doesn't ship comes from any custom element nested here
|
|
||||||
// that fires this event -- no base class required, just this event shape
|
|
||||||
// (see register.ts). `crs` is constructor-only, so picking it up means
|
|
||||||
// rebuilding the whole layer.
|
|
||||||
#onCrsChanged = (e: LeafletCRSChangedEvent) => {
|
|
||||||
this.#childCrs = e.detail.crs ?? undefined;
|
|
||||||
this.recreateLeafletObject();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-tile-layer-wms', LeafletTileLayerWMS);
|
|
||||||
|
|||||||
@ -1,23 +1,5 @@
|
|||||||
import { TileLayer, type TileLayerOptions } from 'leaflet';
|
import LeafletTileLayerElement from '../elements/leaflet-tile-layer.ts';
|
||||||
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { TileLayerEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletTileLayer extends WithProps({
|
customElements.define('leaflet-tile-layer', LeafletTileLayerElement);
|
||||||
url: urlProp,
|
|
||||||
...tileLayerProps,
|
|
||||||
}) {
|
|
||||||
declare readonly leafletObject?: TileLayer;
|
|
||||||
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(options: TileLayerOptions): TileLayer {
|
export { LeafletTileLayerElement };
|
||||||
return new TileLayer(this.url, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-tile-layer', LeafletTileLayer);
|
|
||||||
|
|||||||
@ -1,54 +1,5 @@
|
|||||||
import { Tooltip, type Direction, type PointExpression, type TooltipOptions } from 'leaflet';
|
import LeafletTooltipElement from '../elements/leaflet-tooltip.ts';
|
||||||
import { bool, choice, json, num, str } from '../core/props.ts';
|
|
||||||
import { latLngProps } from '../core/shared-props.ts';
|
|
||||||
import {
|
|
||||||
WithProps,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
export class LeafletTooltip extends WithProps(
|
customElements.define('leaflet-tooltip', LeafletTooltipElement);
|
||||||
{
|
|
||||||
...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;
|
|
||||||
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
|
|
||||||
|
|
||||||
#observer?: MutationObserver;
|
export { LeafletTooltipElement };
|
||||||
|
|
||||||
// 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')) {
|
|
||||||
tooltip.setLatLng([this.lat, this.lng]);
|
|
||||||
}
|
|
||||||
return tooltip;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-tooltip', LeafletTooltip);
|
|
||||||
|
|||||||
@ -1,56 +1,5 @@
|
|||||||
import {
|
import LeafletVideoOverlayElement from '../elements/leaflet-video-overlay.ts';
|
||||||
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,
|
|
||||||
type LeafletAddEventListener,
|
|
||||||
type LeafletRemoveEventListener,
|
|
||||||
} from '../core/with-props.ts';
|
|
||||||
import type { PathEvents } from '../core/event-types.ts';
|
|
||||||
|
|
||||||
// Playback options live on the <video> element Leaflet builds, so live updates
|
customElements.define('leaflet-video-overlay', LeafletVideoOverlayElement);
|
||||||
// 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({
|
export { LeafletVideoOverlayElement };
|
||||||
url: urlProp,
|
|
||||||
bounds: positional(json<LatLngBoundsExpression, VideoOverlay>([], { get: getBounds })),
|
|
||||||
opacity: num(1.0),
|
|
||||||
alt: str(),
|
|
||||||
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;
|
|
||||||
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
|
||||||
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
|
||||||
|
|
||||||
createLeafletObject(options: VideoOverlayOptions): VideoOverlay {
|
|
||||||
return new VideoOverlay(this.url, this.bounds, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getElement(): HTMLVideoElement | undefined {
|
|
||||||
return this.leafletObject?.getElement();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('leaflet-video-overlay', LeafletVideoOverlay);
|
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
// The element classes on their own -- no `customElements.define()` runs from
|
||||||
|
// anything in this file or the modules it re-exports. Import a `components/*`
|
||||||
|
// module (or `src/index.ts`) instead when you want the tags actually
|
||||||
|
// registered; reach for these when you want to subclass an element, register
|
||||||
|
// it under a different tag name, or otherwise customise before defining.
|
||||||
|
//
|
||||||
|
// Every class is the `default` export of its own module, surfaced here under
|
||||||
|
// a name. Order is irrelevant -- nothing here has a load-time side effect.
|
||||||
|
export { default as LeafletMapElement } from './leaflet-map.ts';
|
||||||
|
export { default as LeafletControlLayersElement } from './leaflet-control-layers.ts';
|
||||||
|
export { default as LeafletLayerGroupElement } from './leaflet-layer-group.ts';
|
||||||
|
export { default as LeafletFeatureGroupElement } from './leaflet-feature-group.ts';
|
||||||
|
export { default as LeafletPolygonElement } from './leaflet-polygon.ts';
|
||||||
|
export { default as LeafletPolylineElement } from './leaflet-polyline.ts';
|
||||||
|
export { default as LeafletMarkerElement } from './leaflet-marker.ts';
|
||||||
|
export { default as LeafletCircleElement } from './leaflet-circle.ts';
|
||||||
|
export { default as LeafletCircleMarkerElement } from './leaflet-circle-marker.ts';
|
||||||
|
export { default as LeafletRectangleElement } from './leaflet-rectangle.ts';
|
||||||
|
export { default as LeafletTileLayerElement } from './leaflet-tile-layer.ts';
|
||||||
|
export { default as LeafletTileLayerWMSElement } from './leaflet-tile-layer-wms.ts';
|
||||||
|
export { default as LeafletImageOverlayElement } from './leaflet-image-overlay.ts';
|
||||||
|
export { default as LeafletVideoOverlayElement } from './leaflet-video-overlay.ts';
|
||||||
|
export { default as LeafletSVGOverlayElement } from './leaflet-svg-overlay.ts';
|
||||||
|
export { default as LeafletGeoJSONElement } from './leaflet-geojson.ts';
|
||||||
|
export { default as LeafletLineElement } from './leaflet-line.ts';
|
||||||
|
export { default as LeafletControlZoomElement } from './leaflet-control-zoom.ts';
|
||||||
|
export { default as LeafletControlAttributionElement } from './leaflet-control-attribution.ts';
|
||||||
|
export { default as LeafletControlScaleElement } from './leaflet-control-scale.ts';
|
||||||
|
export { default as LeafletPopupElement } from './leaflet-popup.ts';
|
||||||
|
export { default as LeafletTooltipElement } from './leaflet-tooltip.ts';
|
||||||
|
export { default as LeafletIconElement } from './leaflet-icon.ts';
|
||||||
|
export { default as LeafletDivIconElement } from './leaflet-div-icon.ts';
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { CircleMarker, type CircleMarkerOptions } from 'leaflet';
|
||||||
|
import { num } from '../core/props.ts';
|
||||||
|
import { latLngProps, pathProps } from '../core/shared-props.ts';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { PathEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletCircleMarkerElement extends WithProps({
|
||||||
|
...latLngProps,
|
||||||
|
radius: num<CircleMarker>(10, { get: (obj) => obj.getRadius() }),
|
||||||
|
...pathProps,
|
||||||
|
}) {
|
||||||
|
declare readonly leafletObject?: CircleMarker;
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(options: CircleMarkerOptions): CircleMarker {
|
||||||
|
return new CircleMarker([this.lat, this.lng], options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { Circle, type CircleOptions } from 'leaflet';
|
||||||
|
import { num, positional } from '../core/props.ts';
|
||||||
|
import { latLngProps, pathProps } from '../core/shared-props.ts';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { PathEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletCircleElement 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;
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(options: CircleOptions): Circle {
|
||||||
|
return new Circle([this.lat, this.lng], { ...options, radius: this.radius });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { Control, type ControlPosition } from 'leaflet';
|
||||||
|
import { choice, str } from '../core/props.ts';
|
||||||
|
import { WithProps } from '../core/with-props.ts';
|
||||||
|
|
||||||
|
export default class LeafletControlAttributionElement extends WithProps(
|
||||||
|
{
|
||||||
|
position: choice<ControlPosition>('bottomright'),
|
||||||
|
prefix: str(),
|
||||||
|
},
|
||||||
|
{ attach: 'self' },
|
||||||
|
) {
|
||||||
|
declare readonly leafletObject?: Control.Attribution;
|
||||||
|
|
||||||
|
createLeafletObject(options: Control.AttributionOptions): Control.Attribution {
|
||||||
|
return new Control.Attribution(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { Control, type ControlPosition } from 'leaflet';
|
||||||
|
import { bool, choice } from '../core/props.ts';
|
||||||
|
import { WithProps } from '../core/with-props.ts';
|
||||||
|
import type { LeafletRegisterEvent } from '../core/register.ts';
|
||||||
|
|
||||||
|
export default class LeafletControlLayersElement extends WithProps(
|
||||||
|
{
|
||||||
|
position: choice<ControlPosition>('topright'),
|
||||||
|
collapsed: bool(true),
|
||||||
|
autoZIndex: bool(true),
|
||||||
|
hideSingleBase: bool(),
|
||||||
|
sortLayers: bool(),
|
||||||
|
},
|
||||||
|
{ attach: 'self' },
|
||||||
|
) {
|
||||||
|
declare readonly leafletObject?: Control.Layers;
|
||||||
|
|
||||||
|
createLeafletObject(options: Control.LayersOptions): Control.Layers {
|
||||||
|
return new Control.Layers({}, {}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('leaflet-register', this.#onChildRegister);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.removeEventListener('leaflet-register', this.#onChildRegister);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every child -- present at construction or added later -- announces
|
||||||
|
// itself this way: a parent's connectedCallback always finishes before a
|
||||||
|
// child's does (even for an already-built subtree attached in one go), so
|
||||||
|
// there is no "read the children that are already here" case to handle
|
||||||
|
// separately.
|
||||||
|
#onChildRegister = (e: LeafletRegisterEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const el = e.detail.element;
|
||||||
|
const layer = e.detail.leafletObject;
|
||||||
|
const name = el.getAttribute('name');
|
||||||
|
if (!name) return;
|
||||||
|
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 } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { Control, type ControlPosition } from 'leaflet';
|
||||||
|
import { bool, choice, num } from '../core/props.ts';
|
||||||
|
import { WithProps } from '../core/with-props.ts';
|
||||||
|
|
||||||
|
export default class LeafletControlScaleElement extends WithProps(
|
||||||
|
{
|
||||||
|
position: choice<ControlPosition>('bottomleft'),
|
||||||
|
maxWidth: num(100),
|
||||||
|
metric: bool(true),
|
||||||
|
imperial: bool(true),
|
||||||
|
updateWhenIdle: bool(),
|
||||||
|
},
|
||||||
|
{ attach: 'self' },
|
||||||
|
) {
|
||||||
|
declare readonly leafletObject?: Control.Scale;
|
||||||
|
|
||||||
|
createLeafletObject(options: Control.ScaleOptions): Control.Scale {
|
||||||
|
return new Control.Scale(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// Like leaflet-icon, rebuilt on every change -- including changes to the
|
||||||
|
// markup, which is what the icon renders when `html` isn't set.
|
||||||
|
export default class LeafletDivIconElement 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;
|
||||||
|
|
||||||
|
#observer?: MutationObserver;
|
||||||
|
|
||||||
|
createLeafletObject(options: DivIconOptions): DivIcon {
|
||||||
|
return new DivIcon(this.innerHTML ? { ...options, html: this.innerHTML } : options);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
leafletObjectCreated(): void {
|
||||||
|
emitIconChanged(this, this.leafletObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { FeatureGroup } from 'leaflet';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { GroupEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
// Like leaflet-layer-group, but its children share events and a bounding box.
|
||||||
|
export default class LeafletFeatureGroupElement extends WithProps({}) {
|
||||||
|
declare readonly leafletObject?: FeatureGroup;
|
||||||
|
declare addEventListener: LeafletAddEventListener<GroupEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(): FeatureGroup {
|
||||||
|
return new FeatureGroup([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
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,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { GroupEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletGeoJSONElement extends WithProps({
|
||||||
|
data: positional(
|
||||||
|
json<GeoJsonObject | null>(null, {
|
||||||
|
set(obj: GeoJSON, value) {
|
||||||
|
obj.clearLayers();
|
||||||
|
if (value) obj.addData(value);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...pathProps,
|
||||||
|
}) {
|
||||||
|
declare readonly leafletObject?: GeoJSON;
|
||||||
|
declare addEventListener: LeafletAddEventListener<GroupEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
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 = {
|
||||||
|
iconUrl: str(),
|
||||||
|
iconRetinaUrl: 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: json<PointExpression>([0, 0]),
|
||||||
|
shadowAnchor: json<PointExpression>([0, 0]),
|
||||||
|
className: str(),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// An Icon has no setters, so every attribute change builds a new one and the
|
||||||
|
// parent marker is told to swap it in.
|
||||||
|
export default class LeafletIconElement extends WithProps(PROPS, {
|
||||||
|
attach: 'none',
|
||||||
|
recreate: true,
|
||||||
|
}) {
|
||||||
|
declare readonly leafletObject?: Icon;
|
||||||
|
|
||||||
|
createLeafletObject(options: Partial<IconOptions>): Icon | undefined {
|
||||||
|
return options.iconUrl ? new Icon(options as IconOptions) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
leafletObjectCreated(): void {
|
||||||
|
emitIconChanged(this, this.leafletObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
emitIconChanged(this, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
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,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { PathEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletImageOverlayElement extends WithProps({
|
||||||
|
url: urlProp,
|
||||||
|
bounds: positional(json<LatLngBoundsExpression, ImageOverlay>([], { get: getBounds })),
|
||||||
|
opacity: num(1.0),
|
||||||
|
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;
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(options: ImageOverlayOptions): ImageOverlay {
|
||||||
|
return new ImageOverlay(this.url, this.bounds, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { LayerGroup } from 'leaflet';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { GroupEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
// A passthrough container: it has no options of its own, and children add
|
||||||
|
// themselves to it through the standard registration bubble.
|
||||||
|
export default class LeafletLayerGroupElement extends WithProps({}) {
|
||||||
|
declare readonly leafletObject?: LayerGroup;
|
||||||
|
declare addEventListener: LeafletAddEventListener<GroupEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(): LayerGroup {
|
||||||
|
return new LayerGroup([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { emitLineRemove, emitLineSync } from '../core/register.ts';
|
||||||
|
|
||||||
|
export default class LeafletLineElement extends HTMLElement {
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ['lat', 'lng'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached because disconnectedCallback fires after this is already detached
|
||||||
|
// from it -- see emitLineRemove.
|
||||||
|
#parent: ParentNode | null = null;
|
||||||
|
|
||||||
|
get latlng(): [number, number] {
|
||||||
|
return [+(this.getAttribute('lat') ?? 0), +(this.getAttribute('lng') ?? 0)];
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.#parent = this.parentNode;
|
||||||
|
emitLineSync(this, this.latlng);
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(): void {
|
||||||
|
emitLineSync(this, this.latlng);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
emitLineRemove(this.#parent ?? this, this);
|
||||||
|
this.#parent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
import { Icon, Map as LMap, type MapOptions } from 'leaflet';
|
||||||
|
import { bool, disabled, num, positional, str } from '../core/props.ts';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts';
|
||||||
|
import type { MapEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||||
|
const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||||
|
|
||||||
|
interface CssHost extends HTMLElement {
|
||||||
|
applyCss(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The css-* attributes describe the stylesheet in the shadow root rather than
|
||||||
|
// anything about the Leaflet map, so a change just re-links it.
|
||||||
|
function relinkCss(_map: LMap, _value: string, el: HTMLElement): void {
|
||||||
|
(el as CssHost).applyCss();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root of the component tree. Every other component bubbles a
|
||||||
|
// `leaflet-register` event up to here, which is where it stops.
|
||||||
|
export default class LeafletMapElement 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);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
|
||||||
|
// Options Leaflet defaults to true, turned off with disable-* attributes.
|
||||||
|
// The interaction handlers can be toggled after construction.
|
||||||
|
scrollWheelZoom: disabled<LMap>({
|
||||||
|
set: (map, on) => (on ? map.scrollWheelZoom.enable() : map.scrollWheelZoom.disable()),
|
||||||
|
}),
|
||||||
|
dragging: disabled<LMap>({
|
||||||
|
set: (map, on) => (on ? map.dragging.enable() : map.dragging.disable()),
|
||||||
|
}),
|
||||||
|
touchZoom: disabled<LMap>({
|
||||||
|
set: (map, on) => (on ? map.touchZoom.enable() : map.touchZoom.disable()),
|
||||||
|
}),
|
||||||
|
doubleClickZoom: disabled<LMap>({
|
||||||
|
set: (map, on) => (on ? map.doubleClickZoom.enable() : map.doubleClickZoom.disable()),
|
||||||
|
}),
|
||||||
|
boxZoom: disabled<LMap>({
|
||||||
|
set: (map, on) => (on ? map.boxZoom.enable() : map.boxZoom.disable()),
|
||||||
|
}),
|
||||||
|
keyboard: disabled<LMap>({
|
||||||
|
set: (map, on) => (on ? map.keyboard.enable() : map.keyboard.disable()),
|
||||||
|
}),
|
||||||
|
closePopupOnClick: disabled(),
|
||||||
|
trackResize: disabled(),
|
||||||
|
zoomControl: disabled(),
|
||||||
|
attributionControl: disabled(),
|
||||||
|
inertia: disabled(),
|
||||||
|
zoomAnimation: disabled(),
|
||||||
|
fadeAnimation: disabled(),
|
||||||
|
markerZoomAnimation: disabled(),
|
||||||
|
bounceAtZoomLimits: disabled(),
|
||||||
|
tapHold: disabled(),
|
||||||
|
|
||||||
|
// Options Leaflet defaults to false.
|
||||||
|
preferCanvas: bool(),
|
||||||
|
worldCopyJump: bool(),
|
||||||
|
|
||||||
|
// Not Leaflet options at all -- see relinkCss above.
|
||||||
|
cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
|
||||||
|
cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })),
|
||||||
|
cssCrossorigin: positional(str('', { set: relinkCss })),
|
||||||
|
},
|
||||||
|
{ attach: 'none' },
|
||||||
|
) {
|
||||||
|
declare readonly leafletObject?: LMap;
|
||||||
|
declare addEventListener: LeafletAddEventListener<MapEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<MapEvents>;
|
||||||
|
|
||||||
|
#container?: HTMLDivElement;
|
||||||
|
#cssLink?: HTMLLinkElement;
|
||||||
|
#resizeObserver?: ResizeObserver;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.#buildShadowRoot();
|
||||||
|
this.applyCss();
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
this.#resizeObserver = new ResizeObserver(() => this.leafletObject?.invalidateSize());
|
||||||
|
this.#resizeObserver.observe(this);
|
||||||
|
|
||||||
|
this.addEventListener('leaflet-register', this.#onRegister);
|
||||||
|
this.addEventListener('leaflet-add-layer', this.#onAddLayer);
|
||||||
|
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.#resizeObserver?.disconnect();
|
||||||
|
this.#resizeObserver = undefined;
|
||||||
|
this.removeEventListener('leaflet-register', this.#onRegister);
|
||||||
|
this.removeEventListener('leaflet-add-layer', this.#onAddLayer);
|
||||||
|
this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
this.#cssLink?.remove();
|
||||||
|
this.#cssLink = undefined;
|
||||||
|
|
||||||
|
const customUrl = this.hasAttribute('css-url');
|
||||||
|
const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL;
|
||||||
|
|
||||||
|
let integrity: string | null = null;
|
||||||
|
if (this.hasAttribute('css-integrity')) {
|
||||||
|
integrity = this.getAttribute('css-integrity') ?? '';
|
||||||
|
} else if (!customUrl) {
|
||||||
|
integrity = DEFAULT_CSS_INTEGRITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
let crossorigin: string | undefined;
|
||||||
|
if (this.hasAttribute('css-crossorigin')) {
|
||||||
|
crossorigin = this.getAttribute('css-crossorigin') ?? undefined;
|
||||||
|
} else if (integrity) {
|
||||||
|
crossorigin = 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = url ?? '';
|
||||||
|
if (integrity) link.setAttribute('integrity', integrity);
|
||||||
|
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
|
||||||
|
root.append(link);
|
||||||
|
this.#cssLink = link;
|
||||||
|
|
||||||
|
Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#onRegister = (e: LeafletRegisterEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const map = this.leafletObject;
|
||||||
|
if (map) e.detail.leafletObject.addTo(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
#onAddLayer = (e: LeafletLayerEvent) => {
|
||||||
|
this.leafletObject?.addLayer(e.detail.layer);
|
||||||
|
};
|
||||||
|
|
||||||
|
#onRemoveLayer = (e: LeafletLayerEvent) => {
|
||||||
|
this.leafletObject?.removeLayer(e.detail.layer);
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { Icon, Marker, type MarkerOptions } from 'leaflet';
|
||||||
|
import { bool, num, str } from '../core/props.ts';
|
||||||
|
import { latLngProps } from '../core/shared-props.ts';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { LeafletIconChangedEvent } from '../core/register.ts';
|
||||||
|
import type { MarkerEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
const PROPS = {
|
||||||
|
...latLngProps,
|
||||||
|
// title and alt end up on the <img> Leaflet renders, so live updates go there.
|
||||||
|
title: str('', {
|
||||||
|
set: (obj: Marker, value) => {
|
||||||
|
const el = obj.getElement();
|
||||||
|
if (el) el.title = value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
alt: str('', {
|
||||||
|
set: (obj: Marker, value) => {
|
||||||
|
const el = obj.getElement() as HTMLImageElement | undefined;
|
||||||
|
if (el) el.alt = value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
draggable: bool(false, {
|
||||||
|
set(obj: Marker, value) {
|
||||||
|
if (value) obj.dragging?.enable();
|
||||||
|
else obj.dragging?.disable();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
opacity: num(1.0),
|
||||||
|
zIndexOffset: num(),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default class LeafletMarkerElement extends WithProps(PROPS) {
|
||||||
|
declare readonly leafletObject?: Marker;
|
||||||
|
declare addEventListener: LeafletAddEventListener<MarkerEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<MarkerEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(options: MarkerOptions): Marker {
|
||||||
|
return new Marker([this.lat, this.lng], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('icon-changed', this.#onIconChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.removeEventListener('icon-changed', this.#onIconChanged);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A <leaflet-icon> or <leaflet-div-icon> child announces itself this way.
|
||||||
|
#onIconChanged = (e: LeafletIconChangedEvent) => {
|
||||||
|
this.leafletObject?.setIcon(e.detail.icon ?? new Icon.Default());
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import { Polygon, type PolylineOptions } from 'leaflet';
|
||||||
|
import { pathProps } from '../core/shared-props.ts';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { PathEvents } from '../core/event-types.ts';
|
||||||
|
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
|
||||||
|
import { VertexTracker } from '../core/vertex-tracker.ts';
|
||||||
|
|
||||||
|
export default class LeafletPolygonElement extends WithProps({ ...pathProps }) {
|
||||||
|
declare readonly leafletObject?: Polygon;
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
|
||||||
|
#vertices = new VertexTracker();
|
||||||
|
|
||||||
|
createLeafletObject(options: PolylineOptions): Polygon {
|
||||||
|
return new Polygon(this.#vertices.coords(), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertices come from <leaflet-line> children rather than an attribute --
|
||||||
|
// each one announces its own position via leaflet-line-sync/-remove, we
|
||||||
|
// never read a child's state directly.
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('leaflet-line-sync', this.#onLineSync);
|
||||||
|
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
|
||||||
|
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
#onLineSync = (e: LeafletLineSyncEvent) => {
|
||||||
|
this.#vertices.sync(e.detail.element, e.detail.latlng);
|
||||||
|
this.leafletObject?.setLatLngs(this.#vertices.coords());
|
||||||
|
};
|
||||||
|
|
||||||
|
#onLineRemove = (e: LeafletLineRemoveEvent) => {
|
||||||
|
this.#vertices.remove(e.detail.element);
|
||||||
|
this.leafletObject?.setLatLngs(this.#vertices.coords());
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { Polyline, type PolylineOptions } from 'leaflet';
|
||||||
|
import { bool, num } from '../core/props.ts';
|
||||||
|
import { pathProps, style } from '../core/shared-props.ts';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { PathEvents } from '../core/event-types.ts';
|
||||||
|
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
|
||||||
|
import { VertexTracker } from '../core/vertex-tracker.ts';
|
||||||
|
|
||||||
|
export default class LeafletPolylineElement 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;
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
|
||||||
|
#vertices = new VertexTracker();
|
||||||
|
|
||||||
|
createLeafletObject(options: PolylineOptions): Polyline {
|
||||||
|
return new Polyline(this.#vertices.coords(), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertices come from <leaflet-line> children rather than an attribute --
|
||||||
|
// each one announces its own position via leaflet-line-sync/-remove, we
|
||||||
|
// never read a child's state directly.
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('leaflet-line-sync', this.#onLineSync);
|
||||||
|
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
|
||||||
|
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
#onLineSync = (e: LeafletLineSyncEvent) => {
|
||||||
|
this.#vertices.sync(e.detail.element, e.detail.latlng);
|
||||||
|
this.leafletObject?.setLatLngs(this.#vertices.coords());
|
||||||
|
};
|
||||||
|
|
||||||
|
#onLineRemove = (e: LeafletLineRemoveEvent) => {
|
||||||
|
this.#vertices.remove(e.detail.element);
|
||||||
|
this.leafletObject?.setLatLngs(this.#vertices.coords());
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import { Popup, type PopupOptions } from 'leaflet';
|
||||||
|
import { bool, num } from '../core/props.ts';
|
||||||
|
import { latLngProps } from '../core/shared-props.ts';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletPopupElement 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;
|
||||||
|
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
|
||||||
|
|
||||||
|
#observer?: MutationObserver;
|
||||||
|
|
||||||
|
// 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')) {
|
||||||
|
popup.setLatLng([this.lat, this.lng]);
|
||||||
|
}
|
||||||
|
return popup;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
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,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { PathEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletRectangleElement extends WithProps({
|
||||||
|
bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })),
|
||||||
|
...pathProps,
|
||||||
|
}) {
|
||||||
|
declare readonly leafletObject?: Rectangle;
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(options: PolylineOptions): Rectangle {
|
||||||
|
return new Rectangle(this.bounds, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
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,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { PathEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletSVGOverlayElement extends WithProps({
|
||||||
|
bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })),
|
||||||
|
opacity: num(1.0),
|
||||||
|
interactive: bool(),
|
||||||
|
crossOrigin: choice<CrossOrigin>(''),
|
||||||
|
zIndex: num(),
|
||||||
|
className: str(),
|
||||||
|
}) {
|
||||||
|
declare readonly leafletObject?: SVGOverlay;
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
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,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { TileLayerEvents } from '../core/event-types.ts';
|
||||||
|
import type { LeafletCRSChangedEvent } from '../core/register.ts';
|
||||||
|
|
||||||
|
// WMS request parameters have no individual setters -- they're merged into the
|
||||||
|
// query string through setParams().
|
||||||
|
function param<T>(key: keyof WMSParams): (obj: TileLayer.WMS, value: T) => void {
|
||||||
|
return (obj, value) => {
|
||||||
|
obj.setParams({ [key]: value } as unknown as WMSParams);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `crs` takes a CRS instance, not a primitive. The attribute only covers the
|
||||||
|
// 4 CRSes Leaflet ships by name -- a CRS Leaflet doesn't ship comes from a
|
||||||
|
// nested child announcing itself via leaflet-crs-changed instead (see
|
||||||
|
// #onCrsChanged), which takes priority over this when present. Constructor-
|
||||||
|
// only, like the rest of tileLayerProps -- Leaflet has no setter for it.
|
||||||
|
const NAMED_CRS = {
|
||||||
|
EPSG3857: CRS.EPSG3857,
|
||||||
|
EPSG4326: CRS.EPSG4326,
|
||||||
|
EPSG3395: CRS.EPSG3395,
|
||||||
|
Simple: CRS.Simple,
|
||||||
|
};
|
||||||
|
type CrsName = keyof typeof NAMED_CRS;
|
||||||
|
|
||||||
|
const crsProp: PropDef<CRS> = {
|
||||||
|
default: CRS.EPSG3857,
|
||||||
|
decode: (raw) => NAMED_CRS[raw as CrsName] ?? CRS.EPSG3857,
|
||||||
|
encode: (value) =>
|
||||||
|
value === CRS.EPSG3857
|
||||||
|
? null
|
||||||
|
: ((Object.keys(NAMED_CRS) as CrsName[]).find((name) => NAMED_CRS[name] === value) ?? null),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class LeafletTileLayerWMSElement 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;
|
||||||
|
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
|
||||||
|
|
||||||
|
// Set by a nested CRS-providing child; overrides the `crs` attribute's
|
||||||
|
// named lookup when present.
|
||||||
|
#childCrs?: CRS;
|
||||||
|
|
||||||
|
createLeafletObject(options: WMSOptions): TileLayer.WMS {
|
||||||
|
return new TileLayer.WMS(
|
||||||
|
this.url,
|
||||||
|
this.#childCrs ? { ...options, crs: this.#childCrs } : options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('leaflet-crs-changed', this.#onCrsChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.removeEventListener('leaflet-crs-changed', this.#onCrsChanged);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CRS Leaflet doesn't ship comes from any custom element nested here
|
||||||
|
// that fires this event -- no base class required, just this event shape
|
||||||
|
// (see register.ts). `crs` is constructor-only, so picking it up means
|
||||||
|
// rebuilding the whole layer.
|
||||||
|
#onCrsChanged = (e: LeafletCRSChangedEvent) => {
|
||||||
|
this.#childCrs = e.detail.crs ?? undefined;
|
||||||
|
this.recreateLeafletObject();
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { TileLayer, type TileLayerOptions } from 'leaflet';
|
||||||
|
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
|
||||||
|
import {
|
||||||
|
WithProps,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { TileLayerEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletTileLayerElement extends WithProps({
|
||||||
|
url: urlProp,
|
||||||
|
...tileLayerProps,
|
||||||
|
}) {
|
||||||
|
declare readonly leafletObject?: TileLayer;
|
||||||
|
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(options: TileLayerOptions): TileLayer {
|
||||||
|
return new TileLayer(this.url, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
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,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
export default class LeafletTooltipElement 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;
|
||||||
|
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
|
||||||
|
|
||||||
|
#observer?: MutationObserver;
|
||||||
|
|
||||||
|
// 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')) {
|
||||||
|
tooltip.setLatLng([this.lat, this.lng]);
|
||||||
|
}
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
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,
|
||||||
|
type LeafletAddEventListener,
|
||||||
|
type LeafletRemoveEventListener,
|
||||||
|
} from '../core/with-props.ts';
|
||||||
|
import type { PathEvents } from '../core/event-types.ts';
|
||||||
|
|
||||||
|
// Playback options live on the <video> element Leaflet builds, so live updates
|
||||||
|
// go there rather than through a Leaflet setter.
|
||||||
|
function media(
|
||||||
|
key: 'loop' | 'autoplay' | 'muted' | 'playsInline',
|
||||||
|
): (obj: VideoOverlay, value: boolean) => void {
|
||||||
|
return (obj, value) => {
|
||||||
|
const el = obj.getElement();
|
||||||
|
if (el) el[key] = value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class LeafletVideoOverlayElement extends WithProps({
|
||||||
|
url: urlProp,
|
||||||
|
bounds: positional(json<LatLngBoundsExpression, VideoOverlay>([], { get: getBounds })),
|
||||||
|
opacity: num(1.0),
|
||||||
|
alt: str(),
|
||||||
|
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;
|
||||||
|
declare addEventListener: LeafletAddEventListener<PathEvents>;
|
||||||
|
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
|
||||||
|
|
||||||
|
createLeafletObject(options: VideoOverlayOptions): VideoOverlay {
|
||||||
|
return new VideoOverlay(this.url, this.bounds, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
getElement(): HTMLVideoElement | undefined {
|
||||||
|
return this.leafletObject?.getElement();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue