src/map-renderer/engine.ts
Rama main · réplica de lectura del repositorio original.
// The drawing itself: a MapLibre Native map per tile, and the WebP the API
// stores.
import sharp from "sharp";
import { withLabelLanguage } from "@/lib/basemap";
import { tileCenter, TILE_SIZE } from "@/lib/map-grid";
import type { LabelLanguage, Theme, TilePayload } from "./payload";
import type { Resources } from "./resources";
import { loadStyle, tileStyle, type RendererConfig } from "./style";
// Ratio 2, so a tile is sharp on the screens that show most of them: a 512 CSS
// px square, 1024 pixels across.
export const PIXEL_RATIO = 2;
// From the moment a draw starts, not from when it was queued. Long past a slow
// cold render; a map still drawing by then is a map that is not going to finish.
export const RENDER_DEADLINE_MS = 30_000;
// A style, and a source's TileJSON, are read again after an hour. OpenFreeMap
// names each week's tiles by date in its TileJSON, and a renderer holding last
// month's names would draw empty land as if the sea had risen.
const DESCRIPTOR_MAX_AGE_MS = 60 * 60 * 1000;
const RETRY_MS = 30_000;
// MapLibre Native's `Resource::Kind` for a source's TileJSON.
const SOURCE_KIND = 2;
// What this file uses of `@maplibre/maplibre-gl-native`. Its own typings lack
// `cancel` and the module's event emitter, so this says what is relied on
// rather than trusting a declaration that is incomplete.
interface NativeRequest {
url: string;
kind: number;
}
interface NativeMap {
load(style: object): void;
render(
options: {
zoom: number;
center: [number, number];
width: number;
height: number;
},
callback: (error: Error | null | undefined, pixels?: Uint8Array) => void,
): void;
cancel(): void;
release(): void;
}
export interface NativeModule {
Map: new (options: {
request: (
request: NativeRequest,
callback: (error?: Error | null, response?: { data: Buffer }) => void,
) => void;
ratio: number;
mode: "tile";
}) => NativeMap;
on(
event: "message",
listener: (message: {
severity: string;
class: string;
text: string;
}) => void,
): void;
}
export class RenderError extends Error {}
export interface Engine {
/** Whether a tile can be drawn: both themes' styles load into a map. */
isReady(): boolean;
/** One tile, as WebP - or a thrown error, and never a partial image. */
draw(payload: TilePayload): Promise<Buffer>;
/** The style behind a draw in this theme and language, as a live map has it. */
style(theme: Theme, language?: LabelLanguage): Promise<object>;
close(): void;
}
export interface EngineOptions {
native: NativeModule;
config: RendererConfig;
resources: Resources;
log: (message: string) => void;
deadlineMs?: number;
}
type StyleDocument = Awaited<ReturnType<typeof loadStyle>>;
export function createEngine({
native,
config,
resources,
log,
deadlineMs = RENDER_DEADLINE_MS,
}: EngineOptions): Engine {
// Encoding one tile at a time is all a render at a time leaves room for, and
// libvips' operation cache would only hold tiles the API never asks for twice.
sharp.cache(false);
sharp.concurrency(1);
// A warning said once: MapLibre repeats one for every tile it meets it in.
const warned = new Set<string>();
native.on("message", ({ severity, class: kind, text }) => {
const line = `MapLibre ${severity.toLowerCase()} (${kind}): ${text}`;
if (severity === "ERROR") {
log(line);
} else if (severity === "WARNING" && !warned.has(line)) {
if (warned.size < 100) warned.add(line);
log(line);
}
});
const styles = new Map<Theme, StyleDocument>();
let ready = false;
let retry: NodeJS.Timeout | undefined;
let closed = false;
const drawing = new Set<NativeMap>();
function request(
{ url, kind }: NativeRequest,
callback: (error?: Error | null, response?: { data: Buffer }) => void,
) {
resources
.get(
url,
kind === SOURCE_KIND ? { maxAgeMs: DESCRIPTOR_MAX_AGE_MS } : undefined,
)
.then(
// No arguments at all is MapLibre's "no content", which draws nothing
// for that source tile rather than failing the render.
(data) => (data ? callback(null, { data }) : callback()),
(error: Error) => callback(error),
);
}
// A style that loaded once and fails to load again is kept: a remote style
// host having a bad minute is no reason to stop drawing.
async function styleFor(theme: Theme): Promise<StyleDocument> {
try {
const style = await loadStyle(config, theme, resources, {
maxAgeMs: DESCRIPTOR_MAX_AGE_MS,
});
styles.set(theme, style);
return style;
} catch (error) {
const kept = styles.get(theme);
if (!kept) throw error;
log(`Drawing on the last ${theme} style: ${(error as Error).message}`);
return kept;
}
}
async function labelledStyle(theme: Theme, language?: LabelLanguage) {
return withLabelLanguage(await styleFor(theme), language);
}
// **A map per tile, released as soon as it is drawn.** MapLibre Native keeps
// the source tiles of every place a map has drawn, up to a cache sized for a
// map that pans, and offers no way to bound it: a map kept across distinct
// places grows past a gigabyte. A fresh one costs little once Mesa has
// compiled its shaders, which `main.ts` lets it keep on disk, and the bytes
// of every source tile are in `Resources` anyway.
//
// `tile` mode renders one square of the grid, and places the labels that
// cross its edges so that the squares beside it agree.
//
// It also means a map whose glyph request once failed - which hangs on its
// next render (maplibre-native#3169) - is never asked again.
function newMap(style: object): NativeMap {
const map = new native.Map({ request, ratio: PIXEL_RATIO, mode: "tile" });
map.load(style);
return map;
}
function release(map: NativeMap) {
drawing.delete(map);
try {
map.cancel();
} catch {
// Nothing was rendering, which is the ordinary case.
}
try {
map.release();
} catch {
// Already released.
}
}
// Ready once both themes' styles have loaded and been parsed by a map.
async function warm() {
try {
for (const theme of ["light", "dark"] as const) {
release(newMap(await styleFor(theme)));
}
ready = true;
} catch (error) {
log(`Could not load the basemap: ${(error as Error).message}`);
if (!closed) retry = setTimeout(warm, RETRY_MS);
}
}
void warm();
// The square at its own zoom, centred on its middle: exactly one tile of the
// vector source, whose tiles are 512 px too. The language and what a tile
// makes of a style are applied to the theme's style per draw, since every draw
// makes its own map anyway.
async function render({
theme,
z,
x,
y,
language,
}: TilePayload): Promise<Uint8Array> {
const map = newMap(tileStyle(await labelledStyle(theme, language), z));
const center = tileCenter(z, x, y);
drawing.add(map);
try {
return await new Promise<Uint8Array>((resolve, reject) => {
const deadline = setTimeout(() => {
reject(
new RenderError(
`The render passed its ${deadlineMs / 1000} s deadline`,
),
);
// Cancelling answers the render's callback, after the rejection.
release(map);
}, deadlineMs);
map.render(
{
zoom: z,
center: [center.longitude, center.latitude],
width: TILE_SIZE,
height: TILE_SIZE,
},
(error, pixels) => {
clearTimeout(deadline);
if (error || !pixels) {
reject(new RenderError(error?.message ?? "No image was drawn"));
} else {
resolve(pixels);
}
},
);
});
} finally {
release(map);
}
}
return {
isReady: () => ready,
async draw(payload) {
const pixels = await render(payload);
// Premultiplied, as MapLibre Native hands its pixels back: read as plain
// RGBA, a translucent edge would come out darker than drawn.
return sharp(
Buffer.from(pixels.buffer, pixels.byteOffset, pixels.byteLength),
{
raw: {
width: TILE_SIZE * PIXEL_RATIO,
height: TILE_SIZE * PIXEL_RATIO,
channels: 4,
premultiplied: true,
},
},
)
.webp({ quality: 90 })
.toBuffer();
},
style: labelledStyle,
close() {
closed = true;
clearTimeout(retry);
for (const map of drawing) release(map);
},
};
}