From cd2c8d0fc59d9cc3caf8a1481e7df126328e7bc6 Mon Sep 17 00:00:00 2001 From: Buddy Date: Wed, 9 Sep 2026 08:24:19 -0700 Subject: [PATCH] feat(leaflet-map): fit-to-markers view Add fit-to-markers / fit-padding / fit-max-zoom attributes to . When fit-to-markers is set the map ignores lat/lng/zoom and fitBounds()es a box around every locatable layer (getLatLng / getBounds); tile layers and open popups/tooltips are skipped. The only reframe trigger is a component registering into the tree -- initial load and later additions. Panning, zooming, opening a popup and removing a marker all leave the view untouched. Reframes coalesce onto a microtask so load produces one fitBounds call, not one per marker. --- docs/05-special-cases.md | 19 +++ src/elements/leaflet-map.ts | 98 ++++++++++++++- test/components/fit-to-markers.test.ts | 163 +++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 test/components/fit-to-markers.test.ts diff --git a/docs/05-special-cases.md b/docs/05-special-cases.md index 172d889..5796b56 100644 --- a/docs/05-special-cases.md +++ b/docs/05-special-cases.md @@ -25,6 +25,25 @@ prop table alone. [07](./07-tooling-and-build.md)). - As tree root it also listens for `leaflet-add-layer` / `leaflet-remove-layer` (used by group internals) alongside `leaflet-register`. +- **`fit-to-markers` / `fit-padding` / `fit-max-zoom`** are not Leaflet options. + With `fit-to-markers` present the map ignores `lat`/`lng`/`zoom` and instead + `fitBounds()`es a box around every layer it can locate — anything with a + `getLatLng()` (markers, circles) or `getBounds()` (rectangles, image/video + overlays); tile layers have neither and are skipped, as are open + popups/tooltips (a map layer with a `getLatLng()`). **The only reframe + trigger is a component registering** — initial page load and any child added + later. Panning, zooming, opening a popup and removing a marker all leave the + view exactly where it is. `#onRegister` calls `#scheduleFit()`, which + coalesces the burst of registrations during page load onto one microtask, so + it's a single `fitBounds` call, not one per marker. `fit-padding` (default + `20`) is the pixel gutter left around the bounds; `fit-max-zoom` (default + none) caps the zoom, which matters when a single marker would otherwise snap + to max zoom. `setView()` still runs once at construction so the map has a + valid view before the first frame. +- Panning / zooming always writes the live centre and zoom back to the + `lat` / `lng` / `zoom` attributes (`moveend` / `zoomend`, the standard `event:` + write-back in the prop table) — including the view `fitBounds()` itself lands + on. That write-back is one-way here: it never re-triggers a fit. ## `leaflet-polygon` / `leaflet-polyline` — vertices from children diff --git a/src/elements/leaflet-map.ts b/src/elements/leaflet-map.ts index bfd66ac..a184ae7 100644 --- a/src/elements/leaflet-map.ts +++ b/src/elements/leaflet-map.ts @@ -1,4 +1,14 @@ -import { Icon, Map as LMap, type MapOptions, version } from 'leaflet'; +import { + Icon, + latLngBounds, + Map as LMap, + Popup, + Tooltip, + type LatLng, + type LatLngBounds, + type MapOptions, + version, +} from 'leaflet'; import { bool, disabled, @@ -31,6 +41,21 @@ function relinkCss(_map: LMap, _value: string, el: HTMLElement): void { (el as CssHost).applyCss(); } +interface FitHost extends HTMLElement { + syncFit(): void; +} + +// The fit-* attributes aren't Leaflet options either: they ask the element to +// frame every point/bounds layer itself. Any change just re-runs that. +function applyFit(_map: LMap, _value: unknown, el: HTMLElement): void { + (el as FitHost).syncFit(); +} + +// Anything with a position we can fold into a bounding box -- markers, +// circles (getLatLng), rectangles, image/video overlays (getBounds). Tile +// layers have neither and are skipped. +type Locatable = { getLatLng?: () => LatLng; getBounds?: () => LatLngBounds }; + const PROPS: { lat: Positional; lng: Positional; @@ -70,6 +95,9 @@ const PROPS: { cssUrl: Positional; cssIntegrity: Positional; cssCrossorigin: Positional; + fitToMarkers: Positional; + fitPadding: Positional; + fitMaxZoom: Positional; } = { // View state. Not constructor options -- the map is positioned with setView // once it exists -- and written back whenever the user pans or zooms. @@ -168,6 +196,17 @@ const PROPS: { cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })), cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })), cssCrossorigin: positional(str('', { set: relinkCss })), + + // Not Leaflet options -- see syncFit() below. With `fit-to-markers` present + // the map ignores lat/lng/zoom and frames every point/bounds layer that has + // registered. It reframes only when a new component registers (initial load + // and later additions) -- panning, zooming, opening a popup and removing a + // marker all leave the view untouched. `fit-padding` is the pixel gutter + // kept around the bounds; `fit-max-zoom` caps how far it zooms in (useful + // when a single marker would otherwise snap to max zoom). + fitToMarkers: positional(bool(false, { set: applyFit })), + fitPadding: positional(num(20, { set: applyFit })), + fitMaxZoom: positional(num(Infinity, { set: applyFit })), }; const Base: LeafletElementConstructor = WithProps(PROPS, { attach: 'none' }); @@ -176,7 +215,9 @@ const Base: LeafletElementConstructor = WithProps(PROPS, { a * other component bubbles a `leaflet-register` event up to here, where it stops * (`layer.addTo(this.map)`). Builds its own shadow root (container + Leaflet * CSS ``), runs a `ResizeObserver` → `invalidateSize()`, and treats its - * `css-*` attributes as describing the shadow stylesheet. + * `css-*` attributes as describing the shadow stylesheet. With `fit-to-markers` + * set it frames every registered point/bounds layer instead of honouring + * `lat`/`lng`/`zoom` — see `syncFit()`. */ export default class LeafletMapElement extends Base { declare readonly leafletObject?: LMap; @@ -186,6 +227,8 @@ export default class LeafletMapElement extends Base { #container?: HTMLDivElement; #cssLink?: HTMLLinkElement; #resizeObserver?: ResizeObserver; + #fitActive = false; + #fitScheduled = false; createLeafletObject(options: MapOptions): LMap { const map = new LMap(this.#container ?? this.#buildShadowRoot(), options); @@ -207,9 +250,12 @@ export default class LeafletMapElement extends Base { this.addEventListener('leaflet-register', this.#onRegister); this.addEventListener('leaflet-add-layer', this.#onAddLayer); this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer); + + this.syncFit(); } disconnectedCallback(): void { + this.#fitActive = false; this.#resizeObserver?.disconnect(); this.#resizeObserver = undefined; this.removeEventListener('leaflet-register', this.#onRegister); @@ -257,6 +303,47 @@ export default class LeafletMapElement extends Base { Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/'); } + // Called on connect and whenever a fit-* attribute changes. Records whether + // framing is on (read by #onRegister) and requests an immediate (re)frame + // when it is. No Leaflet event subscriptions: the only reframe trigger is a + // new component registering -- see #onRegister. + syncFit(): void { + if (!this.leafletObject) return; + this.#fitActive = this.fitToMarkers; + if (this.#fitActive) this.#scheduleFit(); + } + + // Coalesce the burst of registrations that fires as children connect during + // page load into a single fitBounds on the next microtask. + #scheduleFit = (): void => { + if (this.#fitScheduled) return; + this.#fitScheduled = true; + queueMicrotask(() => { + this.#fitScheduled = false; + this.#fitNow(); + }); + }; + + #fitNow(): void { + const map = this.leafletObject; + if (!map || !this.fitToMarkers) return; + const bounds = latLngBounds([]); + map.eachLayer((layer) => { + // An open popup/tooltip is a map layer with a getLatLng(); it shouldn't + // pull on the frame. + if (layer instanceof Popup || layer instanceof Tooltip) return; + const l = layer as Locatable; + if (typeof l.getBounds === 'function') bounds.extend(l.getBounds()); + else if (typeof l.getLatLng === 'function') bounds.extend(l.getLatLng()); + }); + if (!bounds.isValid()) return; + const maxZoom = this.fitMaxZoom; + map.fitBounds(bounds, { + padding: [this.fitPadding, this.fitPadding], + maxZoom: Number.isFinite(maxZoom) ? maxZoom : undefined, + }); + } + #buildShadowRoot(): HTMLDivElement { if (this.#container) return this.#container; @@ -277,7 +364,12 @@ export default class LeafletMapElement extends Base { #onRegister = (e: LeafletRegisterEvent) => { e.stopPropagation(); const map = this.leafletObject; - if (map) e.detail.leafletObject.addTo(map); + if (!map) return; + e.detail.leafletObject.addTo(map); + // A new component joined the tree -- reframe if we're fitting. This is the + // only reframe trigger, so panning, zooming and opening a popup all leave + // the view alone, and removing a marker doesn't pull it back in either. + if (this.#fitActive) this.#scheduleFit(); }; #onAddLayer = (e: LeafletLayerEvent) => { diff --git a/test/components/fit-to-markers.test.ts b/test/components/fit-to-markers.test.ts new file mode 100644 index 0000000..de91734 --- /dev/null +++ b/test/components/fit-to-markers.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Popup, type LatLngBounds } from 'leaflet'; +import '../../src/components/leaflet-map.ts'; +import '../../src/components/leaflet-tile-layer.ts'; +import '../../src/components/leaflet-marker.ts'; + +// The initial (and every re-)frame is deferred to a microtask so the burst of +// `layeradd` events during load collapses into one `fitBounds`. Awaiting a +// freshly-queued microtask flushes the pending one first. +const flush = () => + new Promise((resolve) => { + queueMicrotask(resolve); + }); + +function makeMap(attrs: Record = {}) { + const map = document.createElement('leaflet-map'); + for (const [k, v] of Object.entries(attrs)) map.setAttribute(k, v); + return map; +} + +function marker(lat: number, lng: number) { + const m = document.createElement('leaflet-marker'); + m.setAttribute('lat', String(lat)); + m.setAttribute('lng', String(lng)); + return m; +} + +describe('', () => { + it('frames every marker once on load instead of honouring lat/lng/zoom', async () => { + const map = makeMap({ 'fit-to-markers': '', lat: '0', lng: '0', zoom: '2' }); + map.append(marker(34.0537, -118.2427), marker(33.9416, -118.4085), marker(34.1341, -118.3215)); + document.body.append(map); + + // Spy after connect but before the microtask flush, so the initial frame is + // still pending and gets captured. + const spy = vi.spyOn(map.leafletObject!, 'fitBounds'); + await flush(); + + expect(spy).toHaveBeenCalledTimes(1); + const bounds = spy.mock.calls[0]![0] as LatLngBounds; + expect(bounds.contains([34.0537, -118.2427])).toBe(true); + expect(bounds.contains([33.9416, -118.4085])).toBe(true); + expect(bounds.contains([34.1341, -118.3215])).toBe(true); + expect(spy.mock.calls[0]![1]).toMatchObject({ padding: [20, 20] }); + + map.remove(); + }); + + it('passes fit-padding and fit-max-zoom through to fitBounds', async () => { + const map = makeMap({ 'fit-to-markers': '', 'fit-padding': '50', 'fit-max-zoom': '12' }); + map.append(marker(1, 2), marker(3, 4)); + document.body.append(map); + + const spy = vi.spyOn(map.leafletObject!, 'fitBounds'); + await flush(); + + expect(spy.mock.calls[0]![1]).toMatchObject({ padding: [50, 50], maxZoom: 12 }); + map.remove(); + }); + + it('re-frames when a marker is added later', async () => { + const map = makeMap({ 'fit-to-markers': '' }); + map.append(marker(10, 10), marker(20, 20)); + document.body.append(map); + await flush(); + + const spy = vi.spyOn(map.leafletObject!, 'fitBounds'); + map.append(marker(40, -100)); + await flush(); + + expect(spy).toHaveBeenCalledTimes(1); + expect((spy.mock.calls[0]![0] as LatLngBounds).contains([40, -100])).toBe(true); + map.remove(); + }); + + it('activates when the attribute is toggled on after connect', async () => { + const map = makeMap({ lat: '0', lng: '0', zoom: '3' }); + map.append(marker(34, -118), marker(35, -119)); + document.body.append(map); + + const spy = vi.spyOn(map.leafletObject!, 'fitBounds'); + await flush(); + expect(spy).not.toHaveBeenCalled(); + + map.setAttribute('fit-to-markers', ''); + await flush(); + expect(spy).toHaveBeenCalledTimes(1); + map.remove(); + }); + + it('does not reframe when the user pans or zooms', async () => { + const map = makeMap({ 'fit-to-markers': '' }); + map.append(marker(10, 10), marker(20, 20)); + document.body.append(map); + await flush(); + + const spy = vi.spyOn(map.leafletObject!, 'fitBounds'); + // Simulate a manual pan + zoom. + map.leafletObject!.setView([0, 0], 6); + map.leafletObject!.fire('moveend'); + map.leafletObject!.fire('zoomend'); + await flush(); + + expect(spy).not.toHaveBeenCalled(); + map.remove(); + }); + + it('does not reframe when a popup is added to / opened on the map', async () => { + const map = makeMap({ 'fit-to-markers': '' }); + map.append(marker(34, -118), marker(35, -119)); + document.body.append(map); + await flush(); + + const spy = vi.spyOn(map.leafletObject!, 'fitBounds'); + new Popup().setLatLng([34, -118]).setContent('hi').openOn(map.leafletObject!); + await flush(); + + expect(spy).not.toHaveBeenCalled(); + map.remove(); + }); + + it('does nothing without the attribute', async () => { + const map = makeMap({ lat: '10', lng: '20', zoom: '5' }); + map.append(marker(1, 2), marker(3, 4)); + document.body.append(map); + + const spy = vi.spyOn(map.leafletObject!, 'fitBounds'); + await flush(); + + expect(spy).not.toHaveBeenCalled(); + expect(map.leafletObject!.getZoom()).toBe(5); + map.remove(); + }); + + it('leaves the view alone when no layer has a position', async () => { + const map = makeMap({ 'fit-to-markers': '', lat: '5', lng: '6', zoom: '4' }); + const tiles = document.createElement('leaflet-tile-layer'); + tiles.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png'); + map.append(tiles); + document.body.append(map); + + const spy = vi.spyOn(map.leafletObject!, 'fitBounds'); + await flush(); + + expect(spy).not.toHaveBeenCalled(); + expect(map.leafletObject!.getZoom()).toBe(4); + map.remove(); + }); + + it('stops re-framing after disconnect', async () => { + const map = makeMap({ 'fit-to-markers': '' }); + map.append(marker(1, 1), marker(2, 2)); + document.body.append(map); + await flush(); + + const mapObj = map.leafletObject!; + const spy = vi.spyOn(mapObj, 'fitBounds'); + map.remove(); + await flush(); + + expect(spy).not.toHaveBeenCalled(); + }); +});