src/map-renderer/payload.ts
Rama main · réplica de lectura del repositorio original.
// What `POST /render` accepts: one tile of the Web Mercator grid, in a theme,
// labelled in English and optionally one more language.
//
// Nothing of any record: the API stores a tile once for every account whose
// map shows it, so the renderer is never told whose map that is. Anything
// beyond these keys is refused rather than ignored - the API stores what it is
// handed under exactly these coordinates, and a field this side read but the
// API left out of the key would be a tile stored under the wrong name.
import { MAX_FIT_ZOOM } from "@/lib/basemap";
export type Theme = "light" | "dark";
/**
* The languages a tile's labels can carry beside English: every `name:xx` the
* basemap's vector tiles hold except English itself and the script variants.
* Mirrored in the API (`opendiving-api` `services/map_tiles.py`), which falls
* back to English alone for anything else - so the tiles it stores stay
* bounded whatever language a client asks in.
*/
export const LABEL_LANGUAGES = [
"af",
"am",
"ar",
"az",
"be",
"bg",
"bn",
"br",
"bs",
"ca",
"co",
"cs",
"cy",
"da",
"de",
"el",
"eo",
"es",
"et",
"eu",
"fa",
"fi",
"fr",
"fy",
"ga",
"gd",
"he",
"hi",
"hr",
"hu",
"hy",
"id",
"is",
"it",
"ja",
"ka",
"kk",
"kn",
"ko",
"ku",
"la",
"lb",
"lt",
"lv",
"mk",
"ml",
"mt",
"nl",
"no",
"oc",
"pa",
"pl",
"pnb",
"pt",
"rm",
"ro",
"ru",
"sk",
"sl",
"sq",
"sr",
"sv",
"ta",
"te",
"th",
"tok",
"tr",
"uk",
"ur",
"vi",
"zh",
] as const;
export type LabelLanguage = (typeof LABEL_LANGUAGES)[number];
export interface TilePayload {
kind: "tile";
theme: Theme;
z: number;
x: number;
y: number;
/** The second line of each label; absent, a label is its English line. */
language?: LabelLanguage;
}
/** What `POST /render` or `GET /style/{theme}` refuses: the 400. */
export class PayloadError extends Error {}
const KEYS = ["kind", "theme", "z", "x", "y"] as const;
const OPTIONAL_KEYS = ["language"] as const;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
function parseTheme(theme: unknown): Theme {
if (theme !== "light" && theme !== "dark") {
throw new PayloadError('theme must be "light" or "dark"');
}
return theme;
}
// Present means one of the list: the API sends nothing for English alone, so a
// `null`, an empty value or an unknown code is a client this side does not
// understand.
function parseLanguage(language: unknown): LabelLanguage {
if (!(LABEL_LANGUAGES as readonly unknown[]).includes(language)) {
throw new PayloadError("language must be one of the label languages");
}
return language as LabelLanguage;
}
// An integer from 0 to `max`, both included.
function within(value: unknown, max: number, what: string): number {
if (!Number.isInteger(value)) {
throw new PayloadError(`${what} must be an integer`);
}
const number = value as number;
if (number < 0 || number > max) {
throw new PayloadError(`${what} must be from 0 to ${max}`);
}
return number;
}
/**
* The body, checked against the contract, or a thrown `PayloadError`.
*
* The zoom stops at `MAX_FIT_ZOOM`, the deepest any of the web's maps is
* fitted at: the API refuses a deeper one before it gets here, and this side
* refuses it too, so nothing makes the renderer a street-level tile server.
*/
export function parsePayload(body: unknown): TilePayload {
if (!isObject(body)) throw new PayloadError("the body must be an object");
const missing = KEYS.filter((key) => !(key in body));
const extra = Object.keys(body).filter(
(key) => !([...KEYS, ...OPTIONAL_KEYS] as readonly string[]).includes(key),
);
if (missing.length > 0) {
throw new PayloadError(`the body is missing ${missing.join(", ")}`);
}
if (extra.length > 0) {
throw new PayloadError(`the body has unexpected ${extra.join(", ")}`);
}
if (body.kind !== "tile") throw new PayloadError('kind must be "tile"');
const theme = parseTheme(body.theme);
const z = within(body.z, MAX_FIT_ZOOM, "z");
const last = 2 ** z - 1;
const payload: TilePayload = {
kind: "tile",
theme,
z,
x: within(body.x, last, "x"),
y: within(body.y, last, "y"),
};
if ("language" in body) payload.language = parseLanguage(body.language);
return payload;
}
/**
* The theme and language `GET /style/{theme}` was asked for, by the checks a
* tile answers to, or a thrown `PayloadError`. `language` is the query's value,
* `null` when it has none.
*/
export function parseStyleRequest(
theme: string,
language: string | null,
): { theme: Theme; language?: LabelLanguage } {
return {
theme: parseTheme(theme),
...(language !== null && { language: parseLanguage(language) }),
};
}