// 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 => 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) }), }; }