src/lib/map-camera.browser.test.ts
Rama main · réplica de lectura del repositorio original.
import { afterEach, describe, expect, it } from "vitest";
import {
Map as MapLibreMap,
MercatorCoordinate,
setWorkerUrl,
type LngLatBoundsLike,
} from "maplibre-gl";
import {
MAX_FIT_ZOOM,
MAX_ZOOM,
MIN_ZOOM,
unionBounds,
wrapLongitude,
type LatLonBounds,
} from "@/lib/basemap";
import {
bandIn,
FIT_PADDING,
mapCanvas,
placedLocations,
type MappableLocation,
} from "@/lib/map-frame";
import { frameCamera, type Camera, type MapFrame } from "./map-camera";
// The web's fit against GL JS's own `cameraForBounds`, given the paddings a
// live backdrop's fit would pass it for the same frame: the extents over the
// frame, the pins over the band, both kept in from the sides by the frame's
// inset, and the pins' middle as the centre. A real browser because GL JS needs
// WebGL2 to build a map at all.
setWorkerUrl("/maplibre/maplibre-gl-worker.mjs");
const EMPTY_STYLE = { version: 8 as const, sources: {}, layers: [] };
const corners = (bounds: LatLonBounds): LngLatBoundsLike => [
[bounds.west, bounds.south],
[bounds.east, bounds.north],
];
let map: MapLibreMap | null = null;
afterEach(() => {
map?.remove();
map = null;
document.body.replaceChildren();
});
// GL JS's camera for `locations` in a map the size of `frame`.
function expectedCamera(
locations: MappableLocation[],
frame: MapFrame,
): Camera {
const container = document.createElement("div");
container.style.width = `${frame.width}px`;
container.style.height = `${frame.height}px`;
document.body.append(container);
map = new MapLibreMap({
container,
style: EMPTY_STYLE,
interactive: false,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
attributionControl: false,
});
const placed = placedLocations(locations);
const bounds = unionBounds(placed.map((location) => location.bounds))!;
const pins = unionBounds(
placed.map(({ latitude, longitude }) => ({
south: latitude,
north: latitude,
west: longitude,
east: longitude,
})),
)!;
const sides = { left: frame.inset, right: frame.inset };
const zoom = Math.min(
map.cameraForBounds(corners(bounds), {
padding: { ...sides, top: FIT_PADDING, bottom: FIT_PADDING },
maxZoom: MAX_FIT_ZOOM,
})?.zoom ?? MAX_FIT_ZOOM,
map.cameraForBounds(corners(pins), {
padding: { ...sides, ...frame.band },
maxZoom: MAX_FIT_ZOOM,
})?.zoom ?? MAX_FIT_ZOOM,
);
const southWest = MercatorCoordinate.fromLngLat([pins.west, pins.south]);
const northEast = MercatorCoordinate.fromLngLat([pins.east, pins.north]);
const center = new MercatorCoordinate(
(southWest.x + northEast.x) / 2,
(southWest.y + northEast.y) / 2,
).toLngLat();
return {
center: { latitude: center.lat, longitude: wrapLongitude(center.lng) },
zoom,
};
}
// A card's frame, as `MapBackdrop` measures one: a credit 20 px tall over its
// top edge, and the details over its foot.
const card = (width: number, coveredBottom: number): MapFrame => ({
width,
height: 236,
band: bandIn(236, 20, coveredBottom),
inset: mapCanvas(width, false).inset,
});
// A hero's, under its top row and over its details, at a window's width.
const hero = (width: number): MapFrame => ({
width,
height: 320,
band: bandIn(320, 52, 140),
inset: mapCanvas(width, true).inset,
});
const FRAMES: [string, MapFrame][] = [
["the narrowest dive card, outlined", card(252, 144)],
["the narrowest trip card", card(252, 104)],
["a list's one column just below `lg`", card(975, 104)],
["a hero on a phone", hero(390)],
["a hero on a desktop window", hero(1600)],
];
const PLACES: [string, MappableLocation[]][] = [
["a lone site", [{ latitude: 28.5721, longitude: 34.5372 }]],
[
"a site with entry and exit fixes a few hundred metres apart",
[
{ latitude: 28.5721, longitude: 34.5372 },
{ latitude: 28.5702, longitude: 34.5391, variant: "fix" },
{ latitude: 28.5689, longitude: 34.5355, variant: "fix" },
],
],
[
"sites far enough apart to open further out than a lone one",
[
{ latitude: 28.5721, longitude: 34.5372 },
{ latitude: 27.9158, longitude: 34.3299 },
],
],
[
"a country-sized trip part",
[
{
latitude: 26.82,
longitude: 30.8,
bbox_south: 22,
bbox_north: 31.67,
bbox_west: 24.7,
bbox_east: 36.9,
},
],
],
[
// Wide rather than tall, so the frame's width decides the zoom.
"a trip along an island chain",
[
{ latitude: -8.4095, longitude: 115.1889 },
{ latitude: -8.8742, longitude: 125.7275 },
],
],
[
"a trip across the antimeridian",
[
{ latitude: -18.1416, longitude: 178.4419 },
{ latitude: -13.8333, longitude: -171.7667 },
],
],
];
const CASES = FRAMES.flatMap(([frameName, frame]) =>
PLACES.map(([placesName, locations]) => ({
name: `${placesName}, in ${frameName}`,
frame,
locations,
})),
);
describe("frameCamera", () => {
it.each(CASES)(
"fits $name as GL JS's own camera does",
({ frame, locations }) => {
const expected = expectedCamera(locations, frame);
const actual = frameCamera(placedLocations(locations), frame);
expect(Math.abs(actual.zoom - expected.zoom)).toBeLessThan(0.01);
expect(actual.center.latitude).toBeCloseTo(expected.center.latitude, 9);
expect(actual.center.longitude).toBeCloseTo(expected.center.longitude, 9);
},
);
// Not a parity check but the reason for one: a trip across the antimeridian
// that fitted the long way round would open on the whole world.
it("keeps a trip across the antimeridian narrow", () => {
const { zoom } = frameCamera(placedLocations(PLACES[5][1]), card(252, 104));
expect(zoom).toBeGreaterThan(2);
});
});