src/lib/basemap.ts
Rama main · réplica de lectura del repositorio original.
// What basemap this instance draws, and what the browser has to be allowed to
// fetch to draw it.
//
// The renderer is MapLibre GL JS, which speaks one language for both modes: a
// style. A vector style is a document naming sources, glyphs, sprites and 111
// layers of cartography; a raster tile server is the same document with one
// source and one layer, which is what `rasterStyle` builds. So the escape hatch
// is not a second renderer, it is a smaller style.
//
// See DECISIONS.md, "The basemap is a MapLibre style, and raster is the escape
// hatch".
import type { MapOptions } from "maplibre-gl";
/**
* What MapLibre will accept as a style: a parsed document, or a URL it fetches
* for itself.
*
* Derived from `MapOptions` rather than imported as `StyleSpecification`, which
* `maplibre-gl` re-exports from `@maplibre/maplibre-gl-style-spec` but does not
* export itself - naming that package here would be a dependency on a
* transitive one, for a type.
*/
export type BasemapStyle = NonNullable<MapOptions["style"]>;
type StyleDocument = Exclude<BasemapStyle, string>;
/** A position in decimal degrees, which is how this app carries one everywhere. */
export interface LatLon {
latitude: number;
longitude: number;
}
/**
* A rectangular extent in degrees, as a geocoder reports a place's footprint.
*
* `west` may be greater than `east`: a box straddling the antimeridian is not
* malformed, and the API deliberately does not reject one. A single point is
* the degenerate case where both pairs are equal, which is what lets a place
* with a position but no footprint go through the same path.
*/
export interface LatLonBounds {
south: number;
north: number;
west: number;
east: number;
}
/**
* Where a map opens with nothing on it yet, at `MIN_ZOOM`: the whole world,
* centred a little north of the equator because that is where the land - and
* most of the world's diving - is. Shared by the site picker and the read-only
* map so the two open on the same view by construction, rather than on two
* literals that agree until somebody edits one.
*/
export const WORLD_CENTER: LatLon = { latitude: 20, longitude: 0 };
// Web Mercator is undefined at the poles and conventionally cut here, which is
// what makes the projected world square: this is the latitude whose projected y
// equals the map's own width. MapLibre owns the camera now and clamps its own,
// but this is still what keeps a position this app *emits* inside the range the
// form's parser and the API both accept.
export const MAX_LATITUDE = 85.0511287798066;
/**
* Latitude cut to the range Web Mercator can represent.
*/
export function clampLatitude(latitude: number): number {
return Math.min(MAX_LATITUDE, Math.max(-MAX_LATITUDE, latitude));
}
/**
* Longitude folded back into [-180, 180), so panning past the antimeridian
* yields a coordinate a diver (and the API's `ge=-180, le=180` bound) accepts.
*
* Exactly 180 comes back as -180. They are the same meridian, and picking one
* representative is what keeps the range half-open and the folding idempotent.
*/
export function wrapLongitude(longitude: number): number {
return ((((longitude + 180) % 360) + 360) % 360) - 180;
}
/**
* The credit the keyless raster default owed, kept as the raster escape hatch's
* fallback: an operator who points `MAP_TILE_URL` at OpenStreetMap's own tiles
* and sets no attribution still ships a credit rather than nothing.
*
* Written in the `[label](url)` syntax `parseAttribution` understands, so the
* credit can point at the licence rather than merely naming it. The OSMF tile
* policy asks for it plainly visible, which is also why it is not behind a
* toggle.
*/
export const DEFAULT_TILE_ATTRIBUTION =
"[© OpenStreetMap contributors](https://www.openstreetmap.org/copyright)";
/**
* The styles this app ships, served from `public/basemap/`.
*
* OpenFreeMap's Liberty, vendored rather than fetched from
* `tiles.openfreemap.org/styles/...` on purpose: the vendored copy is what keeps
* the map looking the same after an upstream restyle, and a basemap that changes
* appearance under the app is a regression nobody committed.
* `scripts/generate-basemaps.mjs` recolours its water teal and derives the dark
* style from it by recolouring alone, so both themes label a place the same
* way. They are also the one part of this an operator can replace without a
* rebuild - they are static files, not bundle contents.
*/
export const DEFAULT_STYLE_URL = "/basemap/liberty.json";
export const DEFAULT_STYLE_URL_DARK = "/basemap/dark.json";
/**
* The one host the vendored pair fetches from.
*
* Every asset both styles name - the vector source, the Natural Earth raster
* underlay, the glyph ranges - is on this origin; only the sprite set travels
* with us. `basemap.test.ts` reads the shipped JSON and fails if a re-vendor
* ever introduces a second host, which is the check that keeps this constant
* honest rather than merely current.
*/
export const DEFAULT_BASEMAP_ORIGIN = "https://tiles.openfreemap.org";
/**
* The credit the vendored pair owes.
*
* OpenMapTiles and OpenStreetMap are the required half; OpenFreeMap's own name
* is optional and named anyway, because an operator reading their own map's
* corner should be able to tell where the tiles come from. Written in the
* `[label](url)` syntax `parseAttribution` understands - MapLibre's own
* `AttributionControl` is switched off, since it renders HTML and
* `react/no-danger` is an error here.
*/
export const DEFAULT_BASEMAP_ATTRIBUTION =
"[OpenFreeMap](https://openfreemap.org/) " +
"© [OpenMapTiles](https://openmaptiles.org/) " +
"/ [OpenStreetMap](https://www.openstreetmap.org/copyright)";
/**
* Zoom, in MapLibre's units.
*
* MapLibre measures zoom against a **512 px** tile - its transform's `worldSize`
* is `512 * 2 ** zoom` - while Leaflet and the whole slippy convention measure
* against 256, as this app's own hand-rolled renderer did. The same view is
* therefore one number *lower* here than in the numbers this app used before the
* renderer changed, which is why these are not the constants they replace:
*
* | view | slippy | MapLibre |
* | ----------------- | ------ | -------- |
* | widest useful | 1 | 0 |
* | street level | 18 | 17 |
* | a lone place | 10 | 9 |
* | a placed pin | 12 | 11 |
*
* Carried across as the same integers they would each open one level too deep.
* `basemap.test.ts` pins the relationship against the slippy figures, which are
* written out there now that the module holding them is gone, so that moving one
* without the other fails rather than merely looking odd. The fourth row is
* `PLACED_ZOOM` in `components/map/map-picker.tsx`, which is the one that never
* lived here.
*/
export const MIN_ZOOM = 0;
export const MAX_ZOOM = 17;
/**
* How far in a fit is allowed to go for a lone place, which would otherwise fit
* at any zoom you like and open at the deepest.
*
* A single pin opens where the surrounding coast is recognisable rather than at
* street level, where a lone marker on a grid of house numbers says nothing -
* and, for the half of dive sites that are offshore, on nothing but open water.
*/
export const MAX_FIT_ZOOM = 9;
/** What an operator configured about the basemap, before defaults are applied. */
export interface BasemapConfig {
/** A complete MapLibre style. Wins over the raster template outright. */
styleUrl?: string;
/** Falls back to `styleUrl`, never to the style this app ships. */
styleUrlDark?: string;
/** A `{z}/{x}/{y}` raster template - the escape hatch. */
tileUrl?: string;
/** Falls back to `tileUrl`, never to a stranger's. */
tileUrlDark?: string;
/**
* Rendered over whichever basemap is active. Deliberately not part of either
* mode's group of variables: an operator who configures a style must be able
* to credit it, and there is exactly one credit on screen at a time.
*/
attribution?: string;
/** Substituted for `{key}` in either raster template. */
apiKey?: string;
}
/** The basemap this instance draws, with the defaults applied. */
export interface Basemap {
/**
* `vector` means `light`/`dark` are MapLibre style URLs; `raster` means they
* are `{z}/{x}/{y}` templates that `rasterStyle` wraps into one.
*/
mode: "vector" | "raster";
/**
* Whether `light`/`dark` name the styles this app ships. It decides two
* things a URL alone cannot: that the style JSON needs its relative `sprite`
* resolved before MapLibre sees it, and that `DEFAULT_BASEMAP_ORIGIN` is what
* the policy has to allow.
*/
vendored: boolean;
light: string;
dark: string;
attribution: string;
}
// The placeholder a keyed provider's template carries. Every one of them spells
// the parameter differently - CARTO `?key=`, Stadia `?api_key=` - so the query
// string belongs to the operator's template and only the value comes from here.
const API_KEY = "{key}";
function withApiKey(template: string, apiKey?: string): string {
if (!template.includes(API_KEY)) return template;
if (!apiKey) {
console.warn(
`[basemap] Tile template wants an API key but MAP_TILE_API_KEY is ` +
`unset: ${template}. Tiles will be requested with an empty key.`,
);
}
// split/join rather than a `/g` regex: `.test()` on a global regex advances
// its own lastIndex, so the same template can answer differently on the
// second call.
return template.split(API_KEY).join(apiKey ?? "");
}
/**
* The message a style configured without a credit fails with.
*
* Exported so the test asserts the text an operator actually reads rather than
* a substring of it - this is the whole of the diagnostic for a startup that
* refuses to serve.
*/
export const MISSING_ATTRIBUTION_MESSAGE =
"[basemap] MAP_STYLE_URL is set but MAP_ATTRIBUTION is not. Every basemap " +
"worth pointing at carries a licence condition, and this app renders the " +
"credit itself rather than the style's own HTML - so it cannot work out " +
"what yours requires. Set MAP_ATTRIBUTION to the credit your style asks " +
"for, or unset MAP_STYLE_URL to use the basemap this app ships.";
/**
* The basemap to draw, or a thrown configuration error.
*
* Three states, and the precedence between them is documented rather than
* emergent: a style URL wins outright and leaves the raster variables inert, a
* raster template without one is the escape hatch, and neither is the pair this
* app ships.
*
* **A style URL without an attribution throws**, and that is the only
* combination that does. It cannot default to "whatever the active basemap
* requires", because both ways of learning that are shut: this runs in the
* request path and may not fetch and parse a remote style, and the app renders
* the credit as text rather than as the style's HTML. Of the three answers
* available, defaulting to this app's own credit ships a false statement and a
* licence breach for any style that is not OpenFreeMap's, and rendering nothing
* breaches the licence of essentially every OSM-derived source. Refusing is the
* only one that is neither wrong nor silent.
*
* It throws here - once, at config resolution - rather than per map. Note what
* that does *not* mean: `runtimeConfig()` is lazy and memoized, so the process
* starts normally and the throw lands on the first request that renders a
* layout or passes through middleware. Every page then 500s while `/healthz`,
* which sits outside the middleware matcher and reads no configuration, goes on
* answering 200 - so the container's own healthcheck reports it healthy. Loud,
* but only to somebody opening a page.
*/
export function resolveBasemap(config: BasemapConfig = {}): Basemap {
const { styleUrl, styleUrlDark, tileUrl, tileUrlDark, attribution, apiKey } =
config;
if (styleUrl) {
if (!attribution) throw new Error(MISSING_ATTRIBUTION_MESSAGE);
return {
mode: "vector",
vendored: false,
light: styleUrl,
// "Use my style", not "use mine by day and a stranger's by night" - the
// same rule the raster templates have always followed.
dark: styleUrlDark || styleUrl,
attribution,
};
}
// A dark style with no light one to belong to would mix this app's
// cartography with somebody else's on a theme toggle, and would credit one of
// them for both. Ignored rather than promoted, and said out loud because a
// variable that silently does nothing is worse than one that errors.
if (styleUrlDark) {
console.warn(
`[basemap] Ignoring MAP_STYLE_URL_DARK, which is set while ` +
`MAP_STYLE_URL is not. Set both, or neither.`,
);
}
if (tileUrl) {
const light = withApiKey(tileUrl, apiKey);
return {
mode: "raster",
vendored: false,
light,
dark: tileUrlDark ? withApiKey(tileUrlDark, apiKey) : light,
attribution: attribution || DEFAULT_TILE_ATTRIBUTION,
};
}
return {
mode: "vector",
vendored: true,
light: DEFAULT_STYLE_URL,
dark: DEFAULT_STYLE_URL_DARK,
attribution: attribution || DEFAULT_BASEMAP_ATTRIBUTION,
};
}
/**
* What MapLibre spells the high-density placeholder.
*
* This app's own templates have always written it `{r}`, as Leaflet and
* OpenLayers do and as `.env.example`'s two keyed blocks still say. MapLibre
* resolves `{ratio}` instead, from `map.getPixelRatio()`, to exactly the same
* `@2x`/empty pair - so the escape hatch is a rename here and no density
* plumbing of our own. Without it a keyed template would request a literal
* `{r}` and 404, which is precisely the two configurations this mode exists to
* preserve.
*/
export function withRatioToken(template: string): string {
return template.replace(/\{r\}/g, "{ratio}");
}
/**
* A minimal MapLibre style around one raster tile template.
*
* `tileSize` is stated rather than left to the spec, whose default is 512: every
* template this app documents serves 256 px tiles, and taking the default would
* draw each of them over four tiles' worth of ground.
*/
export function rasterStyle(template: string): StyleDocument {
return {
version: 8,
sources: {
basemap: {
type: "raster",
tiles: [withRatioToken(template)],
tileSize: 256,
},
},
layers: [{ id: "basemap", type: "raster", source: "basemap" }],
};
}
/**
* The style to hand MapLibre for one theme.
*
* Three shapes, and only one of them needs us in the middle. A configured style
* URL is handed over as a URL, because MapLibre fetching it itself is both
* fewer moving parts and how a remote style's own relative references resolve. A
* raster template becomes the generated document above. The vendored pair is
* fetched *here*, for one reason:
*
* **MapLibre will not resolve a relative `sprite`.** `normalizeSpriteURL` runs
* the value through `new URL(value)` with no base and throws "must be absolute"
* on anything else - unlike a style's *sources*, which do go through
* `browser.resolveURL`. So a vendored style that also vendors its sprite cannot
* express that relationship in the file, and the absolute form cannot be baked
* in either: a published image runs on whatever domain a self-hoster gives it.
* Resolving it against this document's own origin at load time is what is left,
* and it is one line.
*/
export async function basemapStyle(
basemap: Basemap,
theme: "light" | "dark",
): Promise<BasemapStyle> {
const value = theme === "dark" ? basemap.dark : basemap.light;
if (basemap.mode === "raster") return rasterStyle(value);
if (!basemap.vendored) return value;
const response = await fetch(value);
if (!response.ok) {
throw new Error(
`[basemap] Could not load the bundled style ${value}: ` +
`${response.status} ${response.statusText}. It is served from ` +
`public/basemap/, so a 404 here means the file did not reach the image.`,
);
}
const style = (await response.json()) as StyleDocument;
if (typeof style.sprite === "string") {
style.sprite = new URL(style.sprite, window.location.origin).toString();
}
return style;
}
/**
* The English line each name label of the vendored pair draws: the English
* name, else the latin-script one, else the local one. Mirrored by
* `scripts/generate-basemaps.mjs`, which writes it into both files.
*/
export const ENGLISH_NAME = [
"coalesce",
["get", "name_en"],
["get", "name:latin"],
["get", "name"],
];
/**
* `style` with each name label given `language`'s name as a second line, where
* a place has one that differs from its English line - beside it along a line,
* as a road name runs, and under it at a point. Without a language, `style`
* itself; with one the tiles carry no name in, a style that draws English.
*
* Only labels drawing exactly `ENGLISH_NAME` are touched, so an operator's own
* style is drawn as written, whatever is asked.
*/
export function withLabelLanguage(
style: StyleDocument,
language: string | undefined,
): StyleDocument {
// A bare subtag, so a value nobody checked against a list cannot reach an
// expression as anything but a field name the tiles may or may not have.
if (!language || !/^[a-z]{2,3}$/.test(language)) return style;
const english = JSON.stringify(ENGLISH_NAME);
const local = ["get", `name:${language}`];
return {
...style,
layers: style.layers.map((layer) => {
if (
layer.type !== "symbol" ||
JSON.stringify(layer.layout?.["text-field"]) !== english
) {
return layer;
}
const separator =
layer.layout?.["symbol-placement"] === "line" ? " " : "\n";
const field = [
"let",
"english",
ENGLISH_NAME,
[
"case",
[
"all",
["has", `name:${language}`],
["!=", local, ["var", "english"]],
],
["concat", ["var", "english"], separator, local],
["var", "english"],
],
];
return {
...layer,
layout: { ...layer.layout, "text-field": field },
} as typeof layer;
}),
};
}
/**
* The language a map labels in beside English, from a BCP 47 tag such as
* `navigator.language`: its primary subtag, lowercased, with Norwegian's two
* written forms read as the `no` the tiles carry. English, or no tag, is none.
*
* Not checked against the renderer's list: a live map stores nothing, and a
* language the tiles hold no name in draws English by itself.
*/
export function labelLanguage(tag: string | undefined): string | undefined {
const primary = tag?.split(/[-_]/)[0].toLowerCase();
if (!primary || primary === "en") return undefined;
return primary === "nb" || primary === "nn" ? "no" : primary;
}
/**
* The origins the browser will contact to draw this basemap, for the CSP
* `connect-src` in `proxy.ts`.
*
* `connect-src` and not `img-src`, in both modes, and that is not a choice:
* MapLibre's image decoder takes an `ArrayBuffer` and goes `Blob` ->
* `createImageBitmap`, so even raster tile *bytes* arrive by `fetch`. It holds
* because `refreshExpiredTiles` defaults to `true` - reading the cache header
* requires the fetch path - which is why nothing here sets that option. Treat it
* as part of the policy rather than as a tuning knob.
*
* A malformed value yields no origin rather than throwing. This runs in
* middleware, in the request path, so a typo'd variable must not take the site
* down over the map.
*/
export function basemapOrigins(basemap: Basemap): string[] {
// The vendored styles are same-origin documents, but everything they name -
// tiles, glyphs, the Natural Earth underlay - is not.
if (basemap.vendored) return [DEFAULT_BASEMAP_ORIGIN];
const origins = [basemap.light, basemap.dark].flatMap((value) => {
try {
const { origin, protocol } = new URL(value);
// `new URL` only throws when there is no parseable scheme at all. A typo
// like "htp://" parses happily as a non-special scheme and yields the
// opaque origin "null", which reaches the policy as the literal token
// `null` - discarded by browsers as an invalid source with nothing said
// about why.
if (protocol !== "http:" && protocol !== "https:")
throw new Error(protocol);
return [origin];
} catch {
// A relative value is the one shape that is *not* a mistake: an operator
// may point MAP_STYLE_URL at a style they serve themselves, which `'self'`
// already covers. Everything else earns the diagnostic, because the
// failure it prevents is a blocked request with a console line nobody
// reads rather than a visibly wrong URL.
if (!value.startsWith("/")) {
console.warn(
`[basemap] Ignoring malformed basemap URL: ${value}. Give an ` +
`absolute URL, or the map will be blocked by the ` +
`Content-Security-Policy.`,
);
}
return [];
}
});
return [...new Set(origins)];
}
/**
* The extent covering every one of `boxes`, with longitudes unwrapped so a union
* straddling the antimeridian stays the short way round.
*
* Each box after the first is moved to whichever copy of the repeating world
* sits nearest the first, the way `nearestWrappedX` picks a marker's nearest
* copy. A trip to Fiji and Samoa spans about six degrees across the
* antimeridian, and unioning the raw coordinates instead describes the 354
* degrees of ocean going the other way round the planet - which fits at exactly
* one zoom: the whole world, with both pins at opposite edges of it.
*
* `west` and `east` may come back outside [-180, 180]. That is the point: the
* pair describes one interval, and MapLibre's `Map.fitBounds` reads it as one.
*
* **MapLibre does not do this for you, and checking it with two places will not
* reveal that.** `LngLatBounds.extend()` unions with plain `Math.min`/`Math.max`
* on raw longitude, and `Map.fitBounds` documents that the caller owns the
* ordering. What rescues the two-place case is `cameraForBounds` calling
* `adjustAntiMeridian()` on the *finished* box, which cannot see that the union
* was built the long way round on the way there - so a two-point check passes
* whether or not this function survived. Hence three places in its tests.
*/
export function unionBounds(boxes: LatLonBounds[]): LatLonBounds | null {
if (boxes.length === 0) return null;
let south = MAX_LATITUDE;
let north = -MAX_LATITUDE;
let west = Infinity;
let east = -Infinity;
// The first box's west edge, which every later box is unwrapped against.
let reference = 0;
boxes.forEach((box, index) => {
// Min/max rather than trusting the order: this also renders form state on
// its way to the API, so it meets boxes the API's validation has not seen.
south = Math.min(south, clampLatitude(Math.min(box.south, box.north)));
north = Math.max(north, clampLatitude(Math.max(box.south, box.north)));
// The box's width taken as a signed span first, so the antimeridian case
// (east folding back behind west) stays one interval instead of becoming a
// negative one, while a genuinely zero-width point stays a point.
const signed = box.east - box.west;
const span = signed >= 0 ? Math.min(signed, 360) : signed + 360;
let boxWest = wrapLongitude(box.west);
if (index === 0) {
reference = boxWest;
} else {
boxWest += Math.round((reference - boxWest) / 360) * 360;
}
west = Math.min(west, boxWest);
east = Math.max(east, boxWest + span);
});
// Past a full turn there is no short way round left to preserve, and an extent
// wider than the world is not one any camera can be asked to fit.
if (east - west >= 360) {
west = -180;
east = 180;
}
return { south, north, west, east };
}