1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/minimap.ts
T
karti 5bc7258753 A plan view in the corner, a night you can actually see, and three kinds of visitor
The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.

Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.

Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".

Three things this run found and fixed rather than shipped:

  - entryUrl came off the wire and went straight into an href with no scheme
    check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
    javascript: URL from navigating. One rejection point in access.ts now.
  - A 5xx from /health was the same null as "no API at all" and therefore the
    opposite conclusion. Eight seconds of tera-api restarting would have told
    every anonymous visitor they were a member. A 5xx is an answer; it fails
    closed.
  - decodeURIComponent in cookieToken was the one path in auth/index.ts that
    threw rather than returning ANONYMOUS, so one malformed cookie header from
    an unauthenticated caller turned /api/v1/session into a 500.

Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:53:30 -07:00

1247 lines
44 KiB
TypeScript

/**
* The board, seen from straight above, in a corner of the screen.
*
* The perspective view is very good at telling you what a place *looks* like
* and very bad at telling you where you are in it. Two hundred units above
* Potrero with the camera pointed north-west, the Bay Area is a wedge of
* rooftops and one bridge tower; nothing on screen says that three quarters of
* the board is behind you. This module is the answer to "where am I", and the
* part of it that earns its place is the **view footprint** — the quad the
* camera's frustum cuts out of the ground plane, drawn on the plan. Everything
* else here is context for that one shape.
*
* It is Canvas 2D, on purpose and permanently. A second WebGL context to draw
* forty filled polygons would double the driver-side cost of the page, and
* browsers cap live contexts at around sixteen — spending one of them on an
* inset that never animates a pixel of geometry is a bad trade. It also means
* this file can never accidentally become a second renderer with its own
* opinions about the sun.
*
* Two structural rules, both of which came out of the failures the rest of the
* engine already paid for:
*
* - **Nothing here knows how big a board is.** Every coordinate is derived
* from `city.bounds` through the same `World` the scene uses. `scene.ts`
* documents what the alternative cost: camera limits tuned for San
* Francisco's 230-unit board silently became a property of the engine, and
* the Bay Area's 1003 units could not be framed at all. A minimap with a
* hardcoded extent would fail the same way and look like a rendering bug.
* - **`tick()` runs inside the stage's frame loop**, so it allocates nothing
* and tessellates nothing in the steady state. The map itself — coastline,
* relief, roads — is rasterised once into an offscreen surface and blitted;
* the redraw is capped at 30 Hz and skipped entirely when neither the
* camera nor the data has moved.
*
* This module owns exactly one DOM node: the canvas it hands back. The caller
* puts it wherever it likes and styles it however it likes.
*/
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { nightFactor } from "./atmosphere.ts";
import { paletteFor } from "./terrain.ts";
import type {
Aircraft,
Chapter,
City,
LatLng,
Marker,
MarkerPalette,
ScenePalette,
} from "./types.ts";
import type { World } from "./world.ts";
/** What the pointer is currently over, for a readout line the caller owns. */
export interface MinimapHoverInfo {
lat: number;
lng: number;
/** The district under the pointer, by name, or `null` out in the flats. */
district: string | null;
}
export interface MinimapOptions {
world: World;
/**
* The pack `world` was built from. Passed separately because every other
* constructor in the engine takes the city it is drawing, and reading it off
* the world would make this the one that does not — but it must be the same
* object, or the projection and the polygons will disagree.
*/
city: City;
/** The live scene camera. Read every frame, written only by the wheel dolly. */
camera: THREE.PerspectiveCamera;
/** The live orbit controls. `controls.target` is the crosshair. */
controls: OrbitControls;
markerPalette?: MarkerPalette;
/** Fires when the user clicks, drags or commits a keyboard seek. */
onSeek?(lat: number, lng: number): void;
/** Fires on hover, and once with `null` when the pointer leaves. */
onHover?(info: MinimapHoverInfo | null): void;
/** Device-pixel-ratio ceiling. Matches `stage.ts`: above 2 the gain is not real. */
maxPixelRatio?: number;
}
export interface Minimap {
/** The widget. The caller inserts it into its own container and sizes it in CSS. */
canvas: HTMLCanvasElement;
setMarkers(markers: Marker[]): void;
setAircraft(aircraft: Aircraft[]): void;
setChapters(chapters: Chapter[], activeId: string): void;
/**
* Solar elevation in degrees, the same number `scene.setSolarElevation` gets.
* The minimap follows the day the map does; see `buildTheme` for why it does
* not simply dim the daytime palette.
*/
setSolarElevation(degrees: number): void;
/** Call from the stage tick. Cheap by construction — see the file header. */
tick(): void;
/** Re-do the backing store at the current size and re-rasterise the map. */
resize(): void;
dispose(): void;
}
/**
* The two 2D context interfaces are structurally identical for everything drawn
* here, but TypeScript will not resolve an overloaded method — `drawImage`,
* `fill` — through their union. One cast at the boundary, and the rest of the
* file is written against a single type.
*/
type Ctx = CanvasRenderingContext2D;
type Surface = HTMLCanvasElement | OffscreenCanvas;
/** Redraw ceiling. The stage runs at 60; the footprint does not need to. */
const FRAME_MS = 33;
/** How long the seek confirmation ring lives, in ms. Suppressed for reduced motion. */
const PING_MS = 420;
/** `markers.ts`'s colour of last resort, so an unmapped key looks the same in both places. */
const FALLBACK_MARKER = 0x9aa4ad;
/** The interface's accent, as `index.html` sets it. Camera, footprint, active chapter. */
const ACCENT = 0xf2b134;
export function createMinimap(options: MinimapOptions): Minimap {
const { world, city, camera, controls } = options;
const palette = options.markerPalette ?? {};
const maxPixelRatio = options.maxPixelRatio ?? 2;
const canvas = document.createElement("canvas");
canvas.className = "minimap-canvas";
canvas.tabIndex = 0;
canvas.setAttribute("role", "application");
canvas.setAttribute(
"aria-label",
`Plan of ${city.name}. Click or drag to move the view, scroll to zoom, ` +
`arrow keys to aim and Enter to go.`,
);
// Without this a drag on a touch screen scrolls the page out from under the
// pointer capture and the seek stops mid-gesture.
canvas.style.touchAction = "none";
/**
* The widget takes its size from its container, and it has to.
*
* A canvas with no CSS size falls back to its own backing store for layout,
* and `resize()` sets that backing store to `clientWidth * dpr` — so on any
* retina display the two chase each other and the map doubles in size every
* frame until it dies. Filling the container breaks the loop at the cost of
* one rule the caller cannot override from a stylesheet, which is a trade
* worth making. **The container must have a real height**; `height: auto`
* puts the same loop back.
*/
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = "100%";
const viewCtx = canvas.getContext("2d") as Ctx | null;
/**
* The map is rasterised into its own surface and blitted, rather than redrawn
* under the overlay each frame. `OffscreenCanvas` where it exists; a detached
* `<canvas>` where it does not, which is still every Safari in the field.
*/
const staticSurface: Surface =
typeof OffscreenCanvas === "function"
? new OffscreenCanvas(1, 1)
: document.createElement("canvas");
const staticCtx = staticSurface.getContext("2d") as Ctx | null;
/** The hillshade, held at lattice resolution and stretched over the board. */
const shadeSurface: Surface =
typeof OffscreenCanvas === "function"
? new OffscreenCanvas(1, 1)
: document.createElement("canvas");
const shadeCtx = shadeSurface.getContext("2d") as Ctx | null;
// ---- The board ------------------------------------------------------------
/**
* The board in scene units, from the city's own bounds. `projectZ` negates
* latitude — north is `-z` — so the northern edge is the *smaller* z and the
* corners have to be taken in that order or the map comes out upside down.
*/
const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
const boardW = eastX - westX;
const boardH = southZ - northZ;
const boardSpan = Math.max(boardW, boardH);
const latSpan = city.bounds.maxLat - city.bounds.minLat;
const lngSpan = city.bounds.maxLng - city.bounds.minLng;
// Layout, in device pixels. Everything is recomputed by `layout()`.
let dpr = 1;
let pxW = 0;
let pxH = 0;
let scale = 0;
let boardX = 0;
let boardY = 0;
let boardPxW = 0;
let boardPxH = 0;
let ready = false;
const toPxX = (x: number): number => boardX + (x - westX) * scale;
const toPxY = (z: number): number => boardY + (z - northZ) * scale;
const fromPxX = (px: number): number => westX + (px - boardX) / scale;
const fromPxZ = (py: number): number => northZ + (py - boardY) / scale;
// ---- State the caller sets ------------------------------------------------
let markers: Marker[] = [];
let aircraft: Aircraft[] = [];
let chapters: Chapter[] = city.chapters;
let activeChapterId = city.chapters[0]?.id ?? "";
let night = 0;
let renderedNight = -1;
// Laid-out geometry. Flat arrays of device pixels, rebuilt on resize and when
// the data changes, so the draw loop reads numbers and never projects.
let markerPx = new Float64Array(0);
let markerFill: string[] = [];
let markerHollow: boolean[] = [];
let chapterPx = new Float64Array(0);
let activeChapterIndex = -1;
let aircraftPx = new Float64Array(0);
let landPath = new Path2D();
let parkPath = new Path2D();
let lakePath = new Path2D();
let streetPath = new Path2D();
let freewayPath = new Path2D();
let bridgePath = new Path2D();
let districtPaths: { path: Path2D; weight: number }[] = [];
let landmarkPx = new Float64Array(0);
/** Hillshade, one signed value per lattice cell. Kept so a sunset only recolours it. */
let shadeValues: Float32Array | null = null;
let shadeCols = 0;
let shadeRows = 0;
// ---- Interaction state ----------------------------------------------------
let dirty = true;
let lastDraw = 0;
let hoverX = -1;
let hoverY = -1;
let hoverDistrict: string | null = null;
let dragging = false;
/** The keyboard's aim point, in device pixels. `null` until an arrow key is pressed. */
let pendingX = -1;
let pendingY = -1;
/** When the seek confirmation ring started, in `performance.now()` ms. 0 = not running. */
let pinging = 0;
let pingSceneX = 0;
let pingSceneZ = 0;
const motionQuery =
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
let reducedMotion = motionQuery?.matches ?? false;
// Camera state as of the last draw, for the bail-out. Compared exactly rather
// than with an epsilon: OrbitControls' damping asymptotes, so an epsilon
// freezes the footprint a few frames before the camera has actually stopped,
// and a stale footprint on a still-drifting map is exactly the kind of small
// wrongness that makes a tool feel broken.
let lastCamX = NaN;
let lastCamY = NaN;
let lastCamZ = NaN;
let lastTgtX = NaN;
let lastTgtY = NaN;
let lastTgtZ = NaN;
let lastFov = NaN;
let lastAspect = NaN;
// Scratch for the frustum corners. Four vectors, allocated once, reused every
// frame — `unproject` needs somewhere to work and the draw loop may not
// allocate.
const corners = [
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
];
/** NDC corners, bottom-left first, so the quad comes out wound consistently. */
const NDC_X = [-1, 1, 1, -1];
const NDC_Y = [-1, -1, 1, 1];
let theme = buildTheme(paletteFor(world), 0);
// ---- Layout ---------------------------------------------------------------
/**
* Fit the board inside the widget, letterboxed.
*
* Never stretched. The Bay Area board is 832 x 1003 units and SoCal's is
* 393 x 308 — one portrait, one landscape — and squeezing either into a
* square is instantly, viscerally wrong to anyone who has looked at a map of
* the place. Whatever is left over stays transparent, so the container's own
* card background shows through.
*/
function layout() {
const pad = Math.round(2 * dpr);
const availW = Math.max(1, pxW - pad * 2);
const availH = Math.max(1, pxH - pad * 2);
scale = Math.min(availW / boardW, availH / boardH);
boardPxW = boardW * scale;
boardPxH = boardH * scale;
boardX = pad + (availW - boardPxW) / 2;
boardY = pad + (availH - boardPxH) / 2;
}
function polygonPath(polys: LatLng[][]): Path2D {
const path = new Path2D();
for (const poly of polys) {
if (poly.length < 3) continue;
for (let i = 0; i < poly.length; i++) {
const point = poly[i];
if (!point) continue;
const x = toPxX(world.projectX(point[1]));
const y = toPxY(world.projectZ(point[0]));
if (i === 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
path.closePath();
}
return path;
}
function linePath(paths: LatLng[][]): Path2D {
const path = new Path2D();
for (const line of paths) {
for (let i = 0; i < line.length; i++) {
const point = line[i];
if (!point) continue;
const x = toPxX(world.projectX(point[1]));
const y = toPxY(world.projectZ(point[0]));
if (i === 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
}
return path;
}
function buildPaths() {
landPath = polygonPath(city.landmasses);
parkPath = polygonPath(city.parks);
lakePath = polygonPath(city.inlandWater);
streetPath = linePath(city.roads.filter((r) => r.kind !== "freeway").map((r) => r.path));
freewayPath = linePath(city.roads.filter((r) => r.kind === "freeway").map((r) => r.path));
bridgePath = linePath(city.bridges.map((b) => b.path));
// Districts carry their own weight rather than their own alpha, so downtown
// reads denser than the flats without anyone having to hand-tune thirty
// numbers in two city packs. It is the same fact `blocks.ts` uses to decide
// how tall to build.
districtPaths = city.districts.map((d) => ({
path: polygonPath([d.polygon]),
weight: d.palette === "downtown" ? 1.7 : d.palette === "industrial" ? 1 : 0.6,
}));
const labelled = city.landmarks.filter((l) => l.label);
landmarkPx = new Float64Array(labelled.length * 2);
labelled.forEach((l, i) => {
landmarkPx[i * 2] = toPxX(world.projectX(l.lng));
landmarkPx[i * 2 + 1] = toPxY(world.projectZ(l.lat));
});
}
/**
* Relief, as a slope-lit lattice.
*
* Without this the flats and the hills are the same colour and the board is a
* silhouette: the Diablo range, the Santa Monicas and the whole spine down the
* peninsula simply are not there. It is sampled from the world's *cached*
* heightfield rather than `elevationAt`, which is the same number and about
* two orders of magnitude cheaper — the terrain mesh has already paid to build
* that field, and re-running the hill sum, four octaves of noise and a
* distance-to-coastline for twenty thousand lattice points to get an answer
* that is already in memory would be a second and a half of nothing.
*
* The values are kept separately from the pixels because dusk recolours the
* shading but does not move a hill.
*/
function buildShade() {
const cssW = boardPxW / dpr;
const cssH = boardPxH / dpr;
// About four CSS pixels a cell. Finer than that is invisible under the blit's
// own smoothing; coarser and the ridges turn into steps.
shadeCols = Math.max(2, Math.min(160, Math.round(cssW / 4)));
shadeRows = Math.max(2, Math.min(160, Math.round(cssH / 4)));
const values = new Float32Array(shadeCols * shadeRows);
const elevation = new Float32Array(shadeCols * shadeRows);
for (let j = 0; j < shadeRows; j++) {
const lat = city.bounds.maxLat - ((j + 0.5) / shadeRows) * latSpan;
for (let i = 0; i < shadeCols; i++) {
const lng = city.bounds.minLng + ((i + 0.5) / shadeCols) * lngSpan;
elevation[j * shadeCols + i] = world.elevationSampled(lat, lng);
}
}
// Cell size in real metres. Scene x and z share a scale — `lngScale` is
// `latScale * cos(lat)` precisely so that they do — so one number covers
// both axes.
const cellM = (boardW / shadeCols) * world.metresPerUnit;
const exaggeration = city.verticalExaggeration;
// Light from the north-west and well up. Not a physical sun: the map's own
// sun swings through 360° over a day and a shaded relief that rotates with
// it is unreadable, so this one is the cartographic convention and stays put.
const lx = -0.55;
const ly = 0.62;
const lz = -0.56;
const lLen = Math.hypot(lx, ly, lz);
for (let j = 0; j < shadeRows; j++) {
for (let i = 0; i < shadeCols; i++) {
const k = j * shadeCols + i;
const west = elevation[k - (i > 0 ? 1 : 0)] ?? 0;
const east = elevation[k + (i < shadeCols - 1 ? 1 : 0)] ?? 0;
const north = elevation[k - (j > 0 ? shadeCols : 0)] ?? 0;
const south = elevation[k + (j < shadeRows - 1 ? shadeCols : 0)] ?? 0;
const dEast = ((east - west) / (2 * cellM)) * exaggeration;
const dSouth = ((south - north) / (2 * cellM)) * exaggeration;
// Surface normal of the cell, unnormalised, then Lambert against the
// fixed light. Flat ground gives exactly `ly / lLen`, so subtracting it
// leaves zero on the flats and the whole range for the slopes.
const nx = -dEast;
const nz = -dSouth;
const nLen = Math.hypot(nx, 1, nz);
const lambert = (nx * lx + ly + nz * lz) / (nLen * lLen);
values[k] = lambert - ly / lLen;
}
}
shadeValues = values;
shadeSurface.width = shadeCols;
shadeSurface.height = shadeRows;
paintShade();
}
/** Turn the cached slope values into pixels in the current palette. */
function paintShade() {
if (!shadeCtx || !shadeValues) return;
const image = new ImageData(shadeCols, shadeRows);
const data = image.data;
const lit = theme.shadeLit;
const dark = theme.shadeDark;
for (let k = 0; k < shadeValues.length; k++) {
const s = shadeValues[k] ?? 0;
const magnitude = Math.min(1, Math.abs(s) * theme.shadeGain);
const source = s >= 0 ? lit : dark;
const alpha = s >= 0 ? theme.shadeAlphaLit : theme.shadeAlphaDark;
data[k * 4] = source.r;
data[k * 4 + 1] = source.g;
data[k * 4 + 2] = source.b;
data[k * 4 + 3] = Math.round(magnitude * alpha * 255);
}
shadeCtx.putImageData(image, 0, 0);
}
function layoutMarkers() {
if (scale <= 0) return;
markerPx = new Float64Array(markers.length * 2);
markerFill = new Array<string>(markers.length);
markerHollow = new Array<boolean>(markers.length);
markers.forEach((m, i) => {
markerPx[i * 2] = toPxX(world.projectX(m.lng));
markerPx[i * 2 + 1] = toPxY(world.projectZ(m.lat));
markerFill[i] = cssHex(palette[m.colorKey] ?? FALLBACK_MARKER);
// Same tell as `markers.ts` gives a pin whose position is a guess: a
// different silhouette, not just a different opacity. A map that claims to
// be real must not quietly invent an address at any zoom.
markerHollow[i] = m.located === false;
});
}
function layoutChapters() {
if (scale <= 0) return;
chapterPx = new Float64Array(chapters.length * 2);
activeChapterIndex = -1;
chapters.forEach((c, i) => {
chapterPx[i * 2] = toPxX(world.projectX(c.focus.lng));
chapterPx[i * 2 + 1] = toPxY(world.projectZ(c.focus.lat));
if (c.id === activeChapterId) activeChapterIndex = i;
});
}
function layoutAircraft() {
if (scale <= 0) return;
aircraftPx = new Float64Array(aircraft.length * 3);
aircraft.forEach((a, i) => {
aircraftPx[i * 3] = toPxX(world.projectX(a.lng));
aircraftPx[i * 3 + 1] = toPxY(world.projectZ(a.lat));
aircraftPx[i * 3 + 2] = (a.heading * Math.PI) / 180;
});
}
// ---- The static map -------------------------------------------------------
function renderStatic() {
if (!staticCtx || !ready) return;
const ctx = staticCtx;
ctx.clearRect(0, 0, pxW, pxH);
ctx.save();
ctx.beginPath();
ctx.rect(boardX, boardY, boardPxW, boardPxH);
ctx.clip();
ctx.fillStyle = theme.water;
ctx.fillRect(boardX, boardY, boardPxW, boardPxH);
ctx.fillStyle = theme.land;
ctx.fill(landPath);
if (shadeValues) {
ctx.save();
ctx.clip(landPath);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(shadeSurface as CanvasImageSource, boardX, boardY, boardPxW, boardPxH);
ctx.restore();
}
ctx.fillStyle = theme.park;
ctx.fill(parkPath);
ctx.fillStyle = theme.lake;
ctx.fill(lakePath);
for (const d of districtPaths) {
ctx.fillStyle = rgba(theme.districtRgb, theme.districtAlpha * d.weight);
ctx.fill(d.path);
}
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = theme.street;
ctx.lineWidth = 0.8 * dpr;
ctx.stroke(streetPath);
ctx.strokeStyle = theme.freeway;
ctx.lineWidth = 1.6 * dpr;
ctx.stroke(freewayPath);
ctx.strokeStyle = theme.bridge;
ctx.lineWidth = 1.9 * dpr;
ctx.stroke(bridgePath);
/**
* The coastline, stroked over the fills.
*
* This is the line that keeps the map honest after dark. Fill contrast alone
* survives daylight and dies at 2 a.m., which is the exact complaint the 3D
* view earned: land and water both go to near-black and the shape of the bay
* disappears. A stroke does not care how dark the fills are, so it gets
* *more* alpha at night, not less.
*/
ctx.strokeStyle = theme.coast;
ctx.lineWidth = dpr;
ctx.stroke(landPath);
// Landmarks worth naming get a one-pixel tick rather than a dot. At this
// scale a dot is a marker and the eye reads it as one; a tick reads as
// notation.
ctx.strokeStyle = theme.landmark;
ctx.lineWidth = dpr;
ctx.beginPath();
for (let i = 0; i < landmarkPx.length; i += 2) {
const x = Math.round(landmarkPx[i] ?? 0) + 0.5 * dpr;
const y = landmarkPx[i + 1] ?? 0;
ctx.moveTo(x, y - 2.5 * dpr);
ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
ctx.strokeStyle = theme.frame;
ctx.lineWidth = dpr;
ctx.strokeRect(
boardX + dpr / 2,
boardY + dpr / 2,
Math.max(0, boardPxW - dpr),
Math.max(0, boardPxH - dpr),
);
renderedNight = night;
}
// ---- The overlay ----------------------------------------------------------
/**
* The footprint: where the camera's frustum meets the ground.
*
* Four corner rays, each intersected with y=0. The case that has to be right
* is the one where the horizon is on screen — then the two upper rays point
* *above* the ground plane and never meet it. Solving `t = -camY / dir.y`
* anyway gives a negative `t`, which puts those corners **behind** the camera
* and turns the trapezoid inside out: a bow-tie that flickers across the whole
* board every time you tilt up. So a ray that is not heading downward is
* clamped to a long finite distance instead, which draws the wedge running off
* toward the horizon — which is what you are actually looking at.
*/
function drawFootprint(ctx: Ctx) {
const camY = camera.position.y;
// The orbit controls will not let the camera under the ground, but an
// office swap or a pathological pose could; a footprint from below the
// plane is meaningless rather than merely wrong.
if (!(camY > 0.01)) return;
camera.updateMatrixWorld();
const maxRay = boardSpan * 4;
ctx.beginPath();
for (let i = 0; i < 4; i++) {
const v = corners[i];
if (!v) return;
v.set(NDC_X[i] ?? 0, NDC_Y[i] ?? 0, 0.5).unproject(camera).sub(camera.position);
const length = v.length();
if (!(length > 1e-6)) return;
v.multiplyScalar(1 / length);
const t = v.y < -1e-4 ? Math.min(-camY / v.y, maxRay) : maxRay;
const px = toPxX(camera.position.x + v.x * t);
const py = toPxY(camera.position.z + v.z * t);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fillStyle = theme.footprintFill;
ctx.fill();
ctx.strokeStyle = theme.footprintStroke;
ctx.lineWidth = 1.25 * dpr;
ctx.lineJoin = "round";
ctx.stroke();
}
/** A chevron at the camera, pointing the way it is looking. */
function drawCamera(ctx: Ctx) {
const x = toPxX(camera.position.x);
const y = toPxY(camera.position.z);
const dx = controls.target.x - camera.position.x;
const dz = controls.target.z - camera.position.z;
const len = Math.hypot(dx, dz);
if (!(len > 1e-6)) return;
// Scene +x is east and +z is south, and the widget is drawn the same way up,
// so the heading needs no rotation at all — only the sign flip on z that
// `toPxY` already carries.
const nx = dx / len;
const ny = dz / len;
const sx = -ny;
const sy = nx;
const s = 4.6 * dpr;
ctx.beginPath();
ctx.moveTo(x + nx * s * 1.5, y + ny * s * 1.5);
ctx.lineTo(x - nx * s * 0.7 + sx * s, y - ny * s * 0.7 + sy * s);
ctx.lineTo(x - nx * s * 0.2, y - ny * s * 0.2);
ctx.lineTo(x - nx * s * 0.7 - sx * s, y - ny * s * 0.7 - sy * s);
ctx.closePath();
ctx.fillStyle = theme.camera;
ctx.fill();
ctx.strokeStyle = theme.cameraEdge;
ctx.lineWidth = dpr;
ctx.stroke();
}
/** The orbit target, as a crosshair. Where a chapter flight lands. */
function drawTarget(ctx: Ctx) {
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
}
function crosshair(ctx: Ctx, x: number, y: number, color: string, r: number) {
ctx.strokeStyle = color;
ctx.lineWidth = dpr;
ctx.beginPath();
ctx.moveTo(x - r, y);
ctx.lineTo(x - r * 0.35, y);
ctx.moveTo(x + r * 0.35, y);
ctx.lineTo(x + r, y);
ctx.moveTo(x, y - r);
ctx.lineTo(x, y - r * 0.35);
ctx.moveTo(x, y + r * 0.35);
ctx.lineTo(x, y + r);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, r * 0.32, 0, Math.PI * 2);
ctx.stroke();
}
function drawMarkers(ctx: Ctx) {
const r = 2.3 * dpr;
ctx.lineWidth = dpr;
for (let i = 0; i < markerFill.length; i++) {
const x = markerPx[i * 2] ?? 0;
const y = markerPx[i * 2 + 1] ?? 0;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
if (markerHollow[i]) {
ctx.strokeStyle = markerFill[i] ?? theme.target;
ctx.stroke();
} else {
ctx.fillStyle = markerFill[i] ?? theme.target;
ctx.fill();
ctx.strokeStyle = theme.pinEdge;
ctx.stroke();
}
}
}
function drawChapters(ctx: Ctx) {
ctx.lineWidth = 1.2 * dpr;
for (let i = 0; i < chapters.length; i++) {
const x = chapterPx[i * 2] ?? 0;
const y = chapterPx[i * 2 + 1] ?? 0;
ctx.beginPath();
ctx.arc(x, y, 1.7 * dpr, 0, Math.PI * 2);
ctx.fillStyle = theme.chapter;
ctx.fill();
if (i === activeChapterIndex) {
ctx.beginPath();
ctx.arc(x, y, 4.6 * dpr, 0, Math.PI * 2);
ctx.strokeStyle = theme.chapterActive;
ctx.stroke();
}
}
}
function drawAircraft(ctx: Ctx) {
if (aircraftPx.length === 0) return;
const s = 3.1 * dpr;
ctx.fillStyle = theme.aircraft;
for (let i = 0; i < aircraftPx.length; i += 3) {
const x = aircraftPx[i] ?? 0;
const y = aircraftPx[i + 1] ?? 0;
const a = aircraftPx[i + 2] ?? 0;
// Heading is degrees clockwise from true north, and north on the widget is
// up, so the nose is (sin, -cos).
const nx = Math.sin(a);
const ny = -Math.cos(a);
ctx.beginPath();
ctx.moveTo(x + nx * s * 1.5, y + ny * s * 1.5);
ctx.lineTo(x - nx * s - ny * s * 0.75, y - ny * s + nx * s * 0.75);
ctx.lineTo(x - nx * s + ny * s * 0.75, y - ny * s - nx * s * 0.75);
ctx.closePath();
ctx.fill();
}
}
function drawPing(ctx: Ctx, now: number) {
if (pinging === 0) return;
const t = (now - pinging) / PING_MS;
if (t >= 1) {
pinging = 0;
return;
}
ctx.beginPath();
ctx.arc(toPxX(pingSceneX), toPxY(pingSceneZ), (3 + 13 * t) * dpr, 0, Math.PI * 2);
ctx.strokeStyle = rgba(theme.accentRgb, 0.75 * (1 - t));
ctx.lineWidth = 1.4 * dpr;
ctx.stroke();
}
function draw(now: number) {
if (!viewCtx) return;
const ctx = viewCtx;
ctx.clearRect(0, 0, pxW, pxH);
ctx.drawImage(staticSurface as CanvasImageSource, 0, 0);
ctx.save();
ctx.beginPath();
ctx.rect(boardX, boardY, boardPxW, boardPxH);
ctx.clip();
drawFootprint(ctx);
drawMarkers(ctx);
drawChapters(ctx);
drawAircraft(ctx);
drawTarget(ctx);
drawCamera(ctx);
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
if (hoverX >= 0) crosshair(ctx, hoverX, hoverY, theme.hover, 6 * dpr);
drawPing(ctx, now);
ctx.restore();
}
/** True when anything the overlay draws from the camera has changed. */
function cameraMoved(): boolean {
return (
camera.position.x !== lastCamX ||
camera.position.y !== lastCamY ||
camera.position.z !== lastCamZ ||
controls.target.x !== lastTgtX ||
controls.target.y !== lastTgtY ||
controls.target.z !== lastTgtZ ||
camera.fov !== lastFov ||
camera.aspect !== lastAspect
);
}
function recordCamera() {
lastCamX = camera.position.x;
lastCamY = camera.position.y;
lastCamZ = camera.position.z;
lastTgtX = controls.target.x;
lastTgtY = controls.target.y;
lastTgtZ = controls.target.z;
lastFov = camera.fov;
lastAspect = camera.aspect;
}
// ---- Interaction ----------------------------------------------------------
/**
* Pointer client coordinates to device pixels on the backing store.
*
* Via the bounding rect's own ratio rather than `dpr`, because the two are not
* the same number under a CSS transform or browser page zoom, and a minimap
* that seeks a few hundred metres from where you clicked is worse than one
* that does not seek at all.
*/
function eventToPx(event: PointerEvent | WheelEvent): [number, number] {
const rect = canvas.getBoundingClientRect();
const kx = rect.width > 0 ? pxW / rect.width : dpr;
const ky = rect.height > 0 ? pxH / rect.height : dpr;
return [(event.clientX - rect.left) * kx, (event.clientY - rect.top) * ky];
}
const clampX = (px: number): number => Math.min(boardX + boardPxW, Math.max(boardX, px));
const clampY = (py: number): number => Math.min(boardY + boardPxH, Math.max(boardY, py));
function latLngAt(px: number, py: number): [number, number] {
return world.unproject(fromPxX(px), fromPxZ(py));
}
function districtAt(lat: number, lng: number): string | null {
for (const d of city.districts) {
if (world.pointInPolygon(lat, lng, d.polygon)) return d.name;
}
return null;
}
function seekTo(px: number, py: number) {
if (!ready) return;
const x = clampX(px);
const y = clampY(py);
const [lat, lng] = latLngAt(x, y);
pingSceneX = fromPxX(x);
pingSceneZ = fromPxZ(y);
// The only animation in the widget, and the only thing reduced motion turns
// off. The seek itself has never been eased — where the caller puts the
// camera is the caller's business.
pinging = reducedMotion ? 0 : performance.now();
dirty = true;
options.onSeek?.(lat, lng);
}
function onPointerDown(event: PointerEvent) {
if (!ready || event.button !== 0) return;
const [px, py] = eventToPx(event);
dragging = true;
canvas.setPointerCapture(event.pointerId);
canvas.focus({ preventScroll: true });
pendingX = -1;
seekTo(px, py);
event.preventDefault();
}
function onPointerMove(event: PointerEvent) {
if (!ready) return;
const [px, py] = eventToPx(event);
const x = clampX(px);
const y = clampY(py);
if (x !== hoverX || y !== hoverY) {
hoverX = x;
hoverY = y;
dirty = true;
const [lat, lng] = latLngAt(x, y);
const district = districtAt(lat, lng);
hoverDistrict = district;
options.onHover?.({ lat, lng, district });
}
if (dragging) seekTo(px, py);
}
function endDrag(event: PointerEvent) {
if (!dragging) return;
dragging = false;
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
}
function onPointerLeave() {
// Pointer capture makes the boundary events fire at capture release rather
// than at the real edge, so a drag that runs off the widget would otherwise
// drop the readout while it is still seeking.
if (dragging) return;
if (hoverX < 0 && hoverDistrict === null) return;
hoverX = -1;
hoverY = -1;
hoverDistrict = null;
dirty = true;
options.onHover?.(null);
}
/**
* The wheel dollies the real camera along its own view vector.
*
* Written straight into `camera.position` rather than through the controls,
* which is safe because `OrbitControls.update` re-derives its spherical
* coordinates from the camera every frame. The limits are the controls' own,
* so the minimap cannot put the camera anywhere dragging the map could not.
*/
function onWheel(event: WheelEvent) {
if (!ready) return;
event.preventDefault();
// `deltaMode` 1 is lines, not pixels — Firefox reports a handful of lines
// where everyone else reports a hundred-odd pixels.
const raw = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const step = Math.exp(Math.max(-160, Math.min(160, raw)) * 0.0022);
const dx = camera.position.x - controls.target.x;
const dy = camera.position.y - controls.target.y;
const dz = camera.position.z - controls.target.z;
const distance = Math.hypot(dx, dy, dz);
if (!(distance > 1e-6)) return;
const next = Math.min(controls.maxDistance, Math.max(controls.minDistance, distance * step));
const k = next / distance;
camera.position.set(
controls.target.x + dx * k,
controls.target.y + dy * k,
controls.target.z + dz * k,
);
dirty = true;
}
/**
* Keyboard aiming. Arrows move a pending crosshair, Enter commits it.
*
* Two steps rather than one because a single arrow press that flew the camera
* would make holding a key a slideshow of chapter flights. The pending point
* starts wherever the camera is already looking, so the first press is a nudge
* and not a jump to the corner.
*/
function onKeyDown(event: KeyboardEvent) {
if (!ready) return;
const step = (event.shiftKey ? 0.06 : 0.015) * Math.max(boardPxW, boardPxH);
let dx = 0;
let dy = 0;
switch (event.key) {
case "ArrowLeft":
dx = -step;
break;
case "ArrowRight":
dx = step;
break;
case "ArrowUp":
dy = -step;
break;
case "ArrowDown":
dy = step;
break;
case "Enter":
case " ":
if (pendingX >= 0) {
seekTo(pendingX, pendingY);
event.preventDefault();
}
return;
case "Escape":
if (pendingX >= 0) {
pendingX = -1;
dirty = true;
}
return;
default:
return;
}
if (pendingX < 0) {
pendingX = clampX(toPxX(controls.target.x));
pendingY = clampY(toPxY(controls.target.z));
}
pendingX = clampX(pendingX + dx);
pendingY = clampY(pendingY + dy);
dirty = true;
event.preventDefault();
}
function onBlur() {
if (pendingX < 0) return;
pendingX = -1;
dirty = true;
}
function onMotionChange(event: MediaQueryListEvent) {
reducedMotion = event.matches;
if (reducedMotion) pinging = 0;
}
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
canvas.addEventListener("pointerleave", onPointerLeave);
canvas.addEventListener("wheel", onWheel, { passive: false });
canvas.addEventListener("keydown", onKeyDown);
canvas.addEventListener("blur", onBlur);
motionQuery?.addEventListener("change", onMotionChange);
// The widget is sized by the caller's CSS, so it has to watch its own box —
// it can be laid out long after construction, and a container that animates
// open would otherwise leave a map rasterised at the wrong size.
const observer =
typeof ResizeObserver === "function" ? new ResizeObserver(() => resize()) : null;
observer?.observe(canvas);
// ---- Lifecycle ------------------------------------------------------------
function resize() {
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
if (cssW === 0 || cssH === 0) {
ready = false;
return;
}
const nextDpr = Math.min(window.devicePixelRatio || 1, maxPixelRatio);
const w = Math.max(1, Math.round(cssW * nextDpr));
const h = Math.max(1, Math.round(cssH * nextDpr));
if (ready && w === pxW && h === pxH) return;
dpr = nextDpr;
pxW = w;
pxH = h;
canvas.width = w;
canvas.height = h;
staticSurface.width = w;
staticSurface.height = h;
ready = true;
layout();
buildPaths();
buildShade();
layoutMarkers();
layoutChapters();
layoutAircraft();
renderStatic();
dirty = true;
}
resize();
return {
canvas,
setMarkers(next) {
markers = next;
layoutMarkers();
dirty = true;
},
setAircraft(next) {
aircraft = next;
layoutAircraft();
dirty = true;
},
setChapters(next, activeId) {
chapters = next;
activeChapterId = activeId;
layoutChapters();
dirty = true;
},
setSolarElevation(degrees) {
night = nightFactor(degrees);
// Rasterising the whole map is not a per-frame cost, and the scrubber can
// move the sun a hundredth of a degree at a time. A hundredth of a night
// is invisible; a rebuild per input event is not.
if (Math.abs(night - renderedNight) < 0.01) return;
theme = buildTheme(paletteFor(world), night);
paintShade();
renderStatic();
dirty = true;
},
tick() {
if (!ready || !viewCtx) return;
const now = performance.now();
if (now - lastDraw < FRAME_MS) return;
if (!dirty && pinging === 0 && !cameraMoved()) return;
lastDraw = now;
dirty = false;
recordCamera();
draw(now);
},
resize,
dispose() {
observer?.disconnect();
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", endDrag);
canvas.removeEventListener("pointercancel", endDrag);
canvas.removeEventListener("pointerleave", onPointerLeave);
canvas.removeEventListener("wheel", onWheel);
canvas.removeEventListener("keydown", onKeyDown);
canvas.removeEventListener("blur", onBlur);
motionQuery?.removeEventListener("change", onMotionChange);
ready = false;
shadeValues = null;
canvas.remove();
},
};
}
// ---- Palette ---------------------------------------------------------------
interface Rgb {
r: number;
g: number;
b: number;
}
interface Theme {
water: string;
land: string;
park: string;
lake: string;
coast: string;
street: string;
freeway: string;
bridge: string;
landmark: string;
frame: string;
districtRgb: Rgb;
districtAlpha: number;
shadeLit: Rgb;
shadeDark: Rgb;
shadeAlphaLit: number;
shadeAlphaDark: number;
shadeGain: number;
footprintFill: string;
footprintStroke: string;
camera: string;
cameraEdge: string;
target: string;
hover: string;
pending: string;
chapter: string;
chapterActive: string;
aircraft: string;
pinEdge: string;
accentRgb: Rgb;
}
/**
* The day palette is the scene's own, so the inset and the map agree about what
* colour the bay is; the night palette is not.
*
* The obvious implementation — take the daytime colours and multiply them down
* — was tried and produces precisely the failure this whole change exists to
* fix. Sea 0x4a7a99 at 12% and flats 0x9d9c93 at 12% are two dark grey-blues
* four values apart, which is to say the coastline is gone, which is to say the
* minimap at night is a black rectangle with some pins floating in it.
*
* So night is authored, not derived. Water goes almost to black and the land
* goes *up* to a slate that is unambiguously lighter than it, the coastline
* stroke gains alpha rather than losing it, and the districts stop being a
* shadow and become the amber glow that a built-up area actually is from the
* air after dark. It is not a photograph of night. It is a map that works at
* night, which is the job.
*/
function buildTheme(pal: ScenePalette, night: number): Theme {
const t = Math.min(1, Math.max(0, night));
const accent = rgbOf(ACCENT);
return {
water: blend(pal.sea, 0x060d16, t),
land: blend(mixHex(pal.sand, pal.flats, 0.45), 0x2c333b, t),
park: blend(pal.park, 0x1b2a20, t),
lake: blend(pal.lake, 0x0a1420, t),
coast: blend(0x6c6a5f, 0x9fb6c8, t, 0.45 + 0.42 * t),
street: blend(0x8b8578, 0x474e56, t, 0.5 + 0.28 * t),
freeway: blend(0x6f6459, 0xc9a35f, t, 0.8),
bridge: blend(0xc2622c, 0xe08a3c, t, 0.9),
landmark: blend(0x4a4438, 0xd7c8a8, t, 0.6),
frame: blend(0x1b2733, 0x9fb4c6, t, 0.3),
// Downtown reads as a darkening by day and as light by night, because that
// is what a dense district does to an aerial photograph in each case.
districtRgb: mix(rgbOf(0x2b2419), accent, t),
// Lower at night than by day, which is the opposite of what "districts glow
// amber after dark" suggests and is right anyway: the districts blanket
// most of the built Bay Area, so amber at the daytime alpha does not read
// as glowing downtown, it reads as the whole board having been dipped in
// tea. Measured — 0.095 amber over the night land turned slate 44,51,59
// into khaki 62,62,58 everywhere at once. The weights carry the contrast.
districtAlpha: 0.09 - 0.04 * t,
shadeLit: mix(rgbOf(0xffffff), rgbOf(0x9db4c6), t),
// Not black at night, and the shadow side gets barely half the alpha the
// lit side does. Pure black at 0.4 over the night land was tried first and
// it turned the Santa Cruz mountains and the Diablo range into two solid
// voids — the same "everything is black" failure the 3D view is being fixed
// for, reproduced in miniature. After dark the relief is carried by the
// highlights and the shadows only hint.
shadeDark: mix(rgbOf(0x2a2418), rgbOf(0x0d131a), t),
// Tuned against Twin Peaks and the Diablo range at a 4-CSS-pixel lattice.
// Any more gain and San Francisco's hills become a chrome relief map with
// the districts and roads underneath unreadable.
shadeAlphaLit: 0.5 - 0.1 * t,
shadeAlphaDark: 0.45 - 0.24 * t,
shadeGain: 1.9,
// Kept deliberately faint, and fainter at night rather than stronger. On a
// whole-board view the footprint covers most of the widget, and the same
// alpha that is a hint over daytime sand is a colour cast over a near-black
// one: at 0.14 the lit half of the night map came out khaki and the
// unlit half slate, with a hard amber line between them, which reads as a
// rendering fault rather than as a frustum. The outline carries the shape;
// the fill only says which side of it you are on.
footprintFill: rgba(accent, 0.11 - 0.045 * t),
footprintStroke: rgba(accent, 0.8),
camera: rgba(accent, 0.95),
cameraEdge: blend(0x1a1206, 0x000000, t, 0.55),
target: blend(0x14202b, 0xe8f1f8, t, 0.8),
hover: blend(0x14202b, 0xe8f1f8, t, 0.45),
pending: rgba(accent, 0.75),
chapter: blend(0x1d2a35, 0xdfe9f1, t, 0.55),
chapterActive: rgba(accent, 0.9),
aircraft: blend(0x2b3138, 0xd8e4ee, t, 0.75),
pinEdge: blend(0x101820, 0x05090d, t, 0.6),
accentRgb: accent,
};
}
function rgbOf(hex: number): Rgb {
return { r: (hex >> 16) & 255, g: (hex >> 8) & 255, b: hex & 255 };
}
function mix(a: Rgb, b: Rgb, t: number): Rgb {
return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, b: a.b + (b.b - a.b) * t };
}
function mixHex(a: number, b: number, t: number): number {
const c = mix(rgbOf(a), rgbOf(b), t);
return (Math.round(c.r) << 16) | (Math.round(c.g) << 8) | Math.round(c.b);
}
/** Legacy comma syntax, not `rgb(r g b / a)`: canvas parsing, not CSS, is the floor here. */
function rgba(c: Rgb, alpha: number): string {
return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${alpha.toFixed(3)})`;
}
function blend(day: number, dark: number, t: number, alpha = 1): string {
return rgba(mix(rgbOf(day), rgbOf(dark), t), alpha);
}
function cssHex(hex: number): string {
return rgba(rgbOf(hex), 1);
}