src/lib/map-frame.ts
Rama main · réplica de lectura del repositorio original.
// What every map this app draws has to agree on: how a place becomes an extent,
// the band of a frame its pins are kept to, and the canvas a page head's map is
// drawn across.
import type { LatLonBounds } from "@/lib/basemap";
// How wide a detail page's hero draws its map, in CSS pixels: a canvas this wide
// centred in the frame, which a window wider than it shows dissolving into the
// page at its sides, and a narrower one shows the middle of. The header's
// `max-w-6xl` column, so the map spans the menu bar above it.
export const HERO_CANVAS_WIDTH = 1152;
// How far in from each side a hero's canvas dissolves into the page. A quarter
// of it: wide enough that the eye finds no edge, and leaves the middle half for
// the pins.
export const SIDE_FADE_WIDTH = HERO_CANVAS_WIDTH / 4;
// Breathing room between the outermost place and the edge of the frame, so a
// pin never sits on the border where half of its context is cropped away.
export const FIT_PADDING = 24;
// Where the places have to land, as padding from a frame's top and bottom:
// clear of whatever the caller covers the foot with, and of a backdrop's credit
// over the top edge. Never so small that nothing fits: a fit then has no room
// and gives up.
export function bandIn(
height: number,
creditBottom: number,
coveredBottom: number,
): { top: number; bottom: number } {
const top = FIT_PADDING + creditBottom;
return {
top,
bottom: Math.min(FIT_PADDING + coveredBottom, height - top - FIT_PADDING),
};
}
/**
* The part of a frame its map is drawn across, as an offset and a width, and
* how far in from the frame's sides its places are kept.
*
* A card's map is the whole frame, its places `FIT_PADDING` in. A hero's is a
* canvas `HERO_CANVAS_WIDTH` wide centred in the frame - wider than a phone's
* frame, which shows its middle, and narrower than a desktop window's - with
* its places kept out of the side fades as well as in from the frame's edges.
*/
export function mapCanvas(
frameWidth: number,
hero: boolean,
): { left: number; width: number; inset: number } {
if (!hero) return { left: 0, width: frameWidth, inset: FIT_PADDING };
const left = (frameWidth - HERO_CANVAS_WIDTH) / 2;
return {
left,
width: HERO_CANVAS_WIDTH,
inset: Math.max(FIT_PADDING, left + SIDE_FADE_WIDTH),
};
}
/**
* As much of a place as a map needs: its position, its footprint where it has
* one, and a name for a pointer and a screen reader. Loose enough to take a trip
* location - both what the API returns and what the form holds while it is
* being edited - as well as a dive site, which carries the same fields under the
* same names.
*/
export interface MappableLocation {
name?: string;
latitude?: number | null;
longitude?: number | null;
bbox_south?: number | null;
bbox_north?: number | null;
bbox_west?: number | null;
bbox_east?: number | null;
/**
* How the marker is drawn: the default solid dot for a place somebody chose,
* or a hollow ring for a `"fix"` - a position a device recorded, which is not
* the same claim at all. A dive's entry and exit fixes are the only ones so
* far, and a mis-pinned site or a fix a kilometre off the site is the thing
* the two shapes make visible at a glance.
*
* Deliberately not `kind: "site" | "gps"` or anything else domain-shaped:
* a map knows about positions and names, and one optional styling field is
* what keeps it that way.
*/
variant?: "pin" | "fix";
}
export interface PlacedLocation {
// Blank where the place had none, which every reader of it already skips.
name: string;
latitude: number;
longitude: number;
variant: "pin" | "fix";
bounds: LatLonBounds;
}
// A geocoder sends a place across the antimeridian as a box nearly or wholly
// round the world: Fiji at -180 to 180, New Zealand 358 degrees wide. From this
// wide up, the box says nothing about longitude. The iOS app holds the same
// threshold, so a place frames alike on both.
const WORLD_BOX_SPAN = 350;
/**
* What a place's box asks a map to fit. A box that circles the world keeps its
* south and north and takes a width equal to that height, centred on the place's
* own longitude, so a place really that wide shows around its point and not
* whole. The extent may pass 180, which `unionBounds` takes. Its span is
* `unionBounds`' own, repeated here because the renderer's bundle hashes
* `basemap.ts` whole and any edit there has every stored tile drawn again.
*/
function boxExtent(box: LatLonBounds, longitude: number): LatLonBounds {
const signed = box.east - box.west;
const span = signed >= 0 ? Math.min(signed, 360) : signed + 360;
if (span < WORLD_BOX_SPAN) return box;
// Absolute because form state reaches here unvalidated, as in `unionBounds`.
const half = Math.abs(box.north - box.south) / 2;
return {
south: box.south,
north: box.north,
west: longitude - half,
east: longitude + half,
};
}
/**
* The places that can actually be drawn, with the extent each one asks for.
*
* A place typed in by hand has no position and is skipped - the trip form's
* part rows say so ("Not on the map") rather than leaving its absence here
* unexplained.
* A place the geocoder gave a footprint for is fitted by that footprint, which
* is what keeps a country from opening at the zoom of its centroid; anything
* else is fitted as the degenerate box of its own point. The exception is a box
* that circles the world, which `boxExtent` reads around the place's point.
*/
export function placedLocations(
locations: readonly MappableLocation[],
): PlacedLocation[] {
const placed: PlacedLocation[] = [];
for (const location of locations) {
const { latitude, longitude } = location;
if (latitude == null || longitude == null) continue;
const { bbox_south, bbox_north, bbox_west, bbox_east } = location;
// All four or none: the API validates that, and half a box is not an
// extent, so the point is the safer reading of a broken one.
const hasBox =
bbox_south != null &&
bbox_north != null &&
bbox_west != null &&
bbox_east != null;
placed.push({
name: location.name ?? "",
latitude,
longitude,
variant: location.variant ?? "pin",
bounds: hasBox
? boxExtent(
{
south: bbox_south,
north: bbox_north,
west: bbox_west,
east: bbox_east,
},
longitude,
)
: {
south: latitude,
north: latitude,
west: longitude,
east: longitude,
},
});
}
return placed;
}