feat: make WMS crs extensible via a nested provider element, not a registry
Replaces the mutable registerCRS() registry (never released beyond this
branch) with a web-components-first design: any custom element, no base
class required, can provide a CRS Leaflet doesn't ship by nesting inside
<leaflet-tile-layer-wms> and firing a bubbling leaflet-crs-changed event
(detail: { crs: CRS | null }, mirroring icon-changed's { icon: null }
pattern for "revert to default"). The plain `crs="EPSG4326"` attribute
stays as a convenience shortcut for the 4 CRSes Leaflet itself ships; a
nested provider takes priority over it when both are present.
This surfaced a real, previously-undiscovered bug in with-props.ts:
recreateLeafletObject() was declared on the public LeafletElement
interface but only ever implemented as a private #recreateLeafletObject()
-- calling it would throw "is not a function" at runtime despite
type-checking cleanly. Fixed by making it public, and it now also
re-dispatches registerWithParent() for attach !== 'none' components,
which it never did before (previously only safe for attach: 'none'
components like icons, since recreate would otherwise rebuild a layer
without ever re-adding it to whatever registered it the first time --
exactly what leaflet-tile-layer-wms needs when a nested provider's crs
arrives after it already constructed itself with the default).
This commit is contained in:
parent
b90616d3c5
commit
ef7828128b
21
README.md
21
README.md
@ -286,6 +286,27 @@ All `<leaflet-tile-layer>` attributes above apply (a WMS layer is a tile layer),
|
||||
| `uppercase` | — | Use uppercase WMS parameter names |
|
||||
| `crs` | — | Coordinate reference system by name: `'EPSG3857'`, `'EPSG4326'`, `'EPSG3395'`, or `'Simple'` |
|
||||
|
||||
For a CRS Leaflet doesn't ship (a custom projection from a plugin, say), nest a CRS-providing element instead of using the `crs` attribute. It's just a custom element that fires a `leaflet-crs-changed` event on itself, bubbling, carrying the actual `L.CRS` value — no base class required:
|
||||
|
||||
```js
|
||||
class MyCRSProvider extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('leaflet-crs-changed', { bubbles: true, detail: { crs: myPluginCRS } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
customElements.define('my-crs-provider', MyCRSProvider);
|
||||
```
|
||||
|
||||
```html
|
||||
<leaflet-tile-layer-wms url="..." layers="...">
|
||||
<my-crs-provider></my-crs-provider>
|
||||
</leaflet-tile-layer-wms>
|
||||
```
|
||||
|
||||
A nested provider takes priority over the `crs` attribute when both are present. `crs` is construction-only (Leaflet has no live setter for it), so picking one up — from either source — rebuilds the whole layer; place the provider so it connects before you need the correct projection to take effect, since there's nothing to revert to partway through a request.
|
||||
|
||||
### `<leaflet-marker>`
|
||||
|
||||
| Attribute | Default | Description |
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
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().
|
||||
@ -16,8 +17,10 @@ function param<T>(key: keyof WMSParams): (obj: TileLayer.WMS, value: T) => void
|
||||
};
|
||||
}
|
||||
|
||||
// `crs` takes a CRS instance, not a primitive, so it's looked up by name from
|
||||
// Leaflet's built-in set rather than decoded like a plain value. Constructor-
|
||||
// `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,
|
||||
@ -51,9 +54,35 @@ export class LeafletTileLayerWMS extends WithProps({
|
||||
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, options);
|
||||
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,4 +1,4 @@
|
||||
import { DivIcon, Icon, Layer } from 'leaflet';
|
||||
import { DivIcon, Icon, Layer, type CRS } from 'leaflet';
|
||||
|
||||
// Custom event type for the bubbling registration protocol. Carries the
|
||||
// Leaflet object and the originating element so the nearest parent can
|
||||
@ -21,6 +21,15 @@ export type LeafletLineSyncEvent = CustomEvent<{ element: HTMLElement; latlng: [
|
||||
// Fired by <leaflet-line> on disconnect, so a listening parent can drop it.
|
||||
export type LeafletLineRemoveEvent = CustomEvent<{ element: HTMLElement }>;
|
||||
|
||||
// A CRS is a plain value (methods + a couple of properties, see Leaflet's
|
||||
// own `CRS` interface), not a Layer -- it doesn't fit the leaflet-register
|
||||
// protocol above. Any custom element nested inside a component that accepts
|
||||
// a `crs` (currently just leaflet-tile-layer-wms) can provide one by firing
|
||||
// this itself, bubbling, on connect -- no base class required, just this
|
||||
// event shape. `crs: null` (e.g. on disconnect) reverts to that component's
|
||||
// own default, mirroring icon-changed's `icon: null`.
|
||||
export type LeafletCRSChangedEvent = CustomEvent<{ crs: CRS | null }>;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
'leaflet-register': LeafletRegisterEvent;
|
||||
@ -29,6 +38,7 @@ declare global {
|
||||
'icon-changed': LeafletIconChangedEvent;
|
||||
'leaflet-line-sync': LeafletLineSyncEvent;
|
||||
'leaflet-line-remove': LeafletLineRemoveEvent;
|
||||
'leaflet-crs-changed': LeafletCRSChangedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -178,7 +178,7 @@ export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
|
||||
const prop = byAttribute.get(name);
|
||||
if (!prop) return;
|
||||
if (options.recreate) {
|
||||
this.#recreateLeafletObject();
|
||||
this.recreateLeafletObject();
|
||||
return;
|
||||
}
|
||||
const obj = this.#obj;
|
||||
@ -189,10 +189,14 @@ export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
|
||||
}
|
||||
|
||||
// Throws the current object away and builds a fresh one from the current
|
||||
// attributes, leaving the element's place in the tree untouched.
|
||||
#recreateLeafletObject(): void {
|
||||
// attributes, leaving the element's place in the tree untouched. Also
|
||||
// re-registers the new object with the parent (attach !== 'none') --
|
||||
// registerWithParent only fires once from connectedCallback otherwise,
|
||||
// so without this a recreated layer would never get (re-)added anywhere.
|
||||
recreateLeafletObject(): void {
|
||||
this.#destroyObject();
|
||||
this.#createObject();
|
||||
if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj);
|
||||
this.leafletObjectCreated();
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
import { TileLayer } from 'leaflet';
|
||||
import { CRS, TileLayer } from 'leaflet';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import '../../src/components/leaflet-tile-layer.ts';
|
||||
import '../../src/components/leaflet-tile-layer-wms.ts';
|
||||
|
||||
// Stands in for a plugin's CRS-providing element: a plain custom element,
|
||||
// no base class from this package, that fires leaflet-crs-changed on
|
||||
// connect -- exactly the shape a third party would write.
|
||||
class TestCRSProvider extends HTMLElement {
|
||||
crs?: CRS;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('leaflet-crs-changed', { bubbles: true, detail: { crs: this.crs ?? null } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
customElements.define('test-crs-provider', TestCRSProvider);
|
||||
|
||||
describe('leaflet-tile-layer', () => {
|
||||
it('creates a TileLayer from the url attribute with tileLayerProps options', () => {
|
||||
const el = document.createElement('leaflet-tile-layer');
|
||||
@ -45,8 +59,7 @@ describe('leaflet-tile-layer-wms', () => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it('resolves crs by name to the matching Leaflet CRS instance', async () => {
|
||||
const { CRS } = await import('leaflet');
|
||||
it('resolves crs by name to the matching Leaflet CRS instance', () => {
|
||||
const el = document.createElement('leaflet-tile-layer-wms');
|
||||
el.setAttribute('url', 'https://wms.example/service');
|
||||
el.setAttribute('layers', 'basic');
|
||||
@ -56,4 +69,44 @@ describe('leaflet-tile-layer-wms', () => {
|
||||
expect(el.leafletObject?.options.crs).toBe(CRS.EPSG4326);
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it('picks up a CRS Leaflet does not ship from a nested provider element', () => {
|
||||
const pluginCrs = { ...CRS.EPSG3857 } as CRS;
|
||||
|
||||
const el = document.createElement('leaflet-tile-layer-wms');
|
||||
el.setAttribute('url', 'https://wms.example/service');
|
||||
el.setAttribute('layers', 'basic');
|
||||
document.body.append(el);
|
||||
// No attribute and no provider yet -- Leaflet's own default (null, falls
|
||||
// back to the map's crs internally) applies, nothing of ours sets it.
|
||||
expect(el.leafletObject?.options.crs).toBeNull();
|
||||
|
||||
const provider = document.createElement('test-crs-provider') as TestCRSProvider;
|
||||
provider.crs = pluginCrs;
|
||||
const objectBeforeRecreate = el.leafletObject;
|
||||
// Connecting fires leaflet-crs-changed, bubbling to el.
|
||||
el.append(provider);
|
||||
|
||||
// crs is construction-only: picking it up means a rebuilt object.
|
||||
expect(el.leafletObject).not.toBe(objectBeforeRecreate);
|
||||
expect(el.leafletObject?.options.crs).toBe(pluginCrs);
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it('a nested provider overrides the crs attribute', () => {
|
||||
const pluginCrs = { ...CRS.EPSG3857 } as CRS;
|
||||
|
||||
const el = document.createElement('leaflet-tile-layer-wms');
|
||||
el.setAttribute('url', 'https://wms.example/service');
|
||||
el.setAttribute('layers', 'basic');
|
||||
el.setAttribute('crs', 'EPSG4326');
|
||||
|
||||
const provider = document.createElement('test-crs-provider') as TestCRSProvider;
|
||||
provider.crs = pluginCrs;
|
||||
el.append(provider);
|
||||
document.body.append(el);
|
||||
|
||||
expect(el.leafletObject?.options.crs).toBe(pluginCrs);
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@ -254,6 +254,41 @@ describe('WithProps: recreate option', () => {
|
||||
expect(created).toHaveBeenCalledTimes(2);
|
||||
node.remove();
|
||||
});
|
||||
|
||||
it('re-registers the new object with the parent (recreateLeafletObject is a public method)', () => {
|
||||
const props = { radius: num<FakeObj>(5) } as const;
|
||||
class El extends WithProps<FakeObj, typeof props>(props) {
|
||||
createLeafletObject(): FakeObj {
|
||||
return new FakeObj();
|
||||
}
|
||||
}
|
||||
const tag = 'wp-recreate-reregisters';
|
||||
customElements.define(tag, El);
|
||||
|
||||
const parent = document.createElement('div');
|
||||
document.body.append(parent);
|
||||
const registrations: unknown[] = [];
|
||||
parent.addEventListener('leaflet-register', (e: LeafletRegisterEvent) => {
|
||||
registrations.push(e.detail.leafletObject);
|
||||
});
|
||||
|
||||
const node = document.createElement(tag) as HTMLElement & {
|
||||
leafletObject?: FakeObj;
|
||||
recreateLeafletObject(): void;
|
||||
};
|
||||
parent.append(node);
|
||||
expect(registrations).toEqual([node.leafletObject]);
|
||||
|
||||
const first = node.leafletObject;
|
||||
// recreateLeafletObject is declared on the public LeafletElement interface
|
||||
// but, until fixed, only a private implementation existed -- this call
|
||||
// would have thrown "is not a function" at runtime despite type-checking.
|
||||
node.recreateLeafletObject();
|
||||
|
||||
expect(node.leafletObject).not.toBe(first);
|
||||
expect(registrations).toEqual([first, node.leafletObject]);
|
||||
parent.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WithProps: property accessors', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user