feat(leaflet-map): fit-to-markers view
Add fit-to-markers / fit-padding / fit-max-zoom attributes to <leaflet-map>. 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.
This commit is contained in:
parent
664064bf1c
commit
cd2c8d0fc5
@ -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
|
||||
|
||||
|
||||
@ -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<number, LMap>;
|
||||
lng: Positional<number, LMap>;
|
||||
@ -70,6 +95,9 @@ const PROPS: {
|
||||
cssUrl: Positional<string>;
|
||||
cssIntegrity: Positional<string>;
|
||||
cssCrossorigin: Positional<string>;
|
||||
fitToMarkers: Positional<boolean, LMap>;
|
||||
fitPadding: Positional<number, LMap>;
|
||||
fitMaxZoom: Positional<number, LMap>;
|
||||
} = {
|
||||
// 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<LMap>(false, { set: applyFit })),
|
||||
fitPadding: positional(num<LMap>(20, { set: applyFit })),
|
||||
fitMaxZoom: positional(num<LMap>(Infinity, { set: applyFit })),
|
||||
};
|
||||
const Base: LeafletElementConstructor<LMap, typeof PROPS> = WithProps(PROPS, { attach: 'none' });
|
||||
|
||||
@ -176,7 +215,9 @@ const Base: LeafletElementConstructor<LMap, typeof PROPS> = 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 `<link>`), 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) => {
|
||||
|
||||
163
test/components/fit-to-markers.test.ts
Normal file
163
test/components/fit-to-markers.test.ts
Normal file
@ -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<void>((resolve) => {
|
||||
queueMicrotask(resolve);
|
||||
});
|
||||
|
||||
function makeMap(attrs: Record<string, string> = {}) {
|
||||
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('<leaflet-map fit-to-markers>', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user