diff --git a/README.md b/README.md index 0a23308..34bf86e 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,27 @@ All `` 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 + + + +``` + +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. + ### `` | Attribute | Default | Description | diff --git a/src/components/leaflet-tile-layer-wms.ts b/src/components/leaflet-tile-layer-wms.ts index 1e2f3da..3205b1b 100644 --- a/src/components/leaflet-tile-layer-wms.ts +++ b/src/components/leaflet-tile-layer-wms.ts @@ -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(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; declare removeEventListener: LeafletRemoveEventListener; + // 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); diff --git a/src/core/register.ts b/src/core/register.ts index 7b56ce6..f86d2ae 100644 --- a/src/core/register.ts +++ b/src/core/register.ts @@ -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 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; } } diff --git a/src/core/with-props.ts b/src/core/with-props.ts index 8c56964..e135c20 100644 --- a/src/core/with-props.ts +++ b/src/core/with-props.ts @@ -178,7 +178,7 @@ export function WithProps>( 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>( } // 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(); } diff --git a/test/components/tile-layers.test.ts b/test/components/tile-layers.test.ts index c9d4e60..e2c11a5 100644 --- a/test/components/tile-layers.test.ts +++ b/test/components/tile-layers.test.ts @@ -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(); + }); }); diff --git a/test/core/with-props.test.ts b/test/core/with-props.test.ts index e46129e..08ea7b5 100644 --- a/test/core/with-props.test.ts +++ b/test/core/with-props.test.ts @@ -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(5) } as const; + class El extends WithProps(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', () => {