/** * 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 * `` 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(markers.length); markerHollow = new Array(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); }