Compare commits

..

4 Commits

Author SHA1 Message Date
Buddy 6ff1b75ed3 0.2.0 2 weeks ago
Buddy 9c724ef586 chore: add package description for JSR score
jsr.json had no description field, costing the 'Has a description' point on
the JSR score. Add one and match package.json to it.
2 weeks ago
Buddy 36e1f5fe49 refactor(leaflet-map): rename syncFit -> applyFit
Mirrors the sibling applyCss(): both run on connect and on every relevant
attribute change. The prop set-handler that calls it becomes reapplyFit, the
same relinkCss -> applyCss shape.
2 weeks ago
Buddy cd2c8d0fc5 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.
2 weeks ago

@ -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,6 +1,7 @@
{
"name": "@buddy/leaflet-components",
"version": "0.1.1",
"version": "0.2.0",
"description": "Leaflet.js as native Web Components — one custom element per Leaflet object.",
"license": "MIT",
"exports": {
".": "./src/index.ts",

4
package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "leaflet-web-components",
"version": "0.1.1",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "leaflet-web-components",
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.16",

@ -1,7 +1,7 @@
{
"name": "leaflet-web-components",
"version": "0.1.1",
"description": "LeafletJS as Web Components",
"version": "0.2.0",
"description": "Leaflet.js as native Web Components — one custom element per Leaflet object.",
"main": "dist/index.npm.js",
"module": "dist/index.npm.js",
"types": "dist/index.npm.d.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 {
applyFit(): 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 reapplyFit(_map: LMap, _value: unknown, el: HTMLElement): void {
(el as FitHost).applyFit();
}
// 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 applyFit() 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: reapplyFit })),
fitPadding: positional(num<LMap>(20, { set: reapplyFit })),
fitMaxZoom: positional(num<LMap>(Infinity, { set: reapplyFit })),
};
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 `applyFit()`.
*/
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.applyFit();
}
disconnectedCallback(): void {
this.#fitActive = false;
this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined;
this.removeEventListener('leaflet-register', this.#onRegister);
@ -257,6 +303,48 @@ export default class LeafletMapElement extends Base {
Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
}
// Called on connect and whenever a fit-* attribute changes -- the sibling of
// applyCss() above. 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.
applyFit(): 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 +365,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) => {

@ -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…
Cancel
Save