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.main
parent
664064bf1c
commit
cd2c8d0fc5
@ -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