diff --git a/.gitea/workflows/gates.yml b/.gitea/workflows/gates.yml new file mode 100644 index 0000000..b57fe85 --- /dev/null +++ b/.gitea/workflows/gates.yml @@ -0,0 +1,119 @@ +# The gates. +# +# This repo makes two claims about itself — that it is Apache-2.0 clean, and that +# a stranger can self-host it with no account, no key and no network — and until +# this file existed nothing checked either one. Both had been verified per-lens, +# in a unit test or by hand on a box that already had everything, and never once +# end to end on an empty environment. A design critic put it exactly right: +# until one of these jobs exists, self-hostable is a design intention, not a +# property. +# +# Three jobs, one for each claim that can actually be measured: +# +# clean-clone a fresh checkout installs, builds and passes its tests +# zero-config-boot the API answers health when handed nothing at all +# no-binary-art src/** carries no committed binary assets +# +# They are deliberately independent and run in parallel: a broken build should +# not hide a licensing regression. +# +# This runs on Gitea Actions, not GitHub. The syntax is GitHub-compatible and the +# `actions/*` steps resolve through the instance's configured action registry. + +name: gates + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +# A superseded push has nothing to tell us, and these jobs build a Docker image +# and bind a port between them. +concurrency: + group: gates-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ---- Job 1: a stranger clones this and it works ---- + # + # The whole job is the dev-kit promise from CONTRACT.md §0. Note what is absent + # and is meant to stay absent: no `env:` block, no `secrets.*` anywhere, and no + # dependency cache. The cache is the interesting omission — a warm cache would + # make this job pass on a lockfile that no longer resolves from a clean state, + # which is precisely the failure a stranger would hit first. + clean-clone: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + # The server runs TypeScript directly by stripping types at load, so + # the floor is real: server/package.json says >=22.18. 24 is what the + # image in server/Dockerfile uses. + node-version: "24" + + - name: npm ci + run: npm ci + + - name: npm run build + # tsc --noEmit, then vite build. A dynamic import of an uninstalled + # package fails here, which is the reason the dependency policy is an + # allowlist rather than a count — see CONTRACT.md §6. + run: npm run build + + - name: server tests + run: npm test --workspace @lumbridge/tera-api + + # ---- Job 2: the box boots on nothing ---- + # + # Two independent server designs defaulted the weather source to a provider + # that needs a contact string and then failed hard without one, which breaks + # the only acceptance test this repo has. This job exists specifically to catch + # that class of bug, and it passes today. + # + # The gate starts the real entry point under a genuinely empty environment + # rather than `docker compose up`, which is the wording in CONTRACT.md §0. The + # property asserted is identical — the compose file adds only TERA_HOST and + # container hardening, and every other variable in it carries a `:-` default so + # an empty environment resolves it to the empty string — while a job that needs + # docker-in-docker present on the runner would be measuring the runner instead + # of the repo. `node scripts/check-zero-config-boot.mjs --compose` runs the + # literal compose form for anyone who has a Docker. + zero-config-boot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: npm ci + run: npm ci + + - name: health on an empty environment + run: node scripts/check-zero-config-boot.mjs + + # ---- Job 3: no committed binary art ---- + # + # Scoped strictly to tracked files under src/**, because a self-hoster is told + # to put their own legally-clean art in public/props/ and friends and an + # earlier design would have failed their build for doing exactly that. The + # script enumerates with `git ls-files` and has no filesystem-walk fallback. + # See CONTRACT.md §7. + # + # No install step: the check is dependency-free on purpose, so it stays + # runnable by hand and cannot be broken by a bad lockfile. + no-binary-art: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: no binary art under src/ + run: node scripts/check-no-binaries.mjs diff --git a/.gitignore b/.gitignore index 274275c..9d4e091 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,22 @@ dist/ # runtime and cached in the browser. An Apache-2.0 repo containing them would # be relicensing marks it does not own. See ARCHITECTURE.md §3.1. public/logos/ + +# ---- Self-hoster space ---- +# +# Yours, not ours. Drop your own props, kits, office packs and notes here and +# nothing in this repo will look at them, complain about them or ship them. +# +# This is the other half of the no-binary-art gate. That check +# (scripts/check-no-binaries.mjs) enumerates with `git ls-files` and is strict +# only over src/**, so your own legally-clean .glb sitting in public/props/ is +# invisible to it and cannot fail your build — an earlier design walked the +# working tree and would have. Ignoring these paths is what makes that promise +# hold by default rather than by everybody remembering. See CONTRACT.md §7. +# +# Worth knowing before you write one: docs/ is in here too, so documentation +# committed to this repo lives at the root or beside the code it describes. +public/props/ +public/kits/ +public/offices/ +docs/ diff --git a/index.html b/index.html index cb6b325..848e554 100644 --- a/index.html +++ b/index.html @@ -37,6 +37,16 @@ .enter:hover { background: #ffc555; } .scrub { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.45rem; } .scrub input { flex: 1; accent-color: #f2b134; height: 14px; } + .cities { display: flex; gap: 3px; } + .city { flex: 1; font: inherit; font-size: 11px; padding: 0.35rem; cursor: pointer; + border: 0; border-radius: 5px; background: rgba(8,12,16,0.55); color: rgba(255,255,255,0.6); + backdrop-filter: blur(6px); } + .city:hover { background: rgba(255,255,255,0.14); } + .city.active { background: rgba(242,177,52,0.22); color: #ffd68a; } + .source { position: fixed; left: 1rem; bottom: 1rem; font-size: 10px; + color: rgba(255,255,255,0.4); background: rgba(8,12,16,0.5); padding: 0.25rem 0.5rem; + border-radius: 4px; backdrop-filter: blur(6px); } + .source.live { color: #7ee08a; } .scrub button { font: inherit; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.15rem 0.35rem; cursor: pointer; border: 0; border-radius: 3px; background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.7); } @@ -54,11 +64,13 @@ +

+

drag to orbit · scroll to zoom

diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..5a7812f --- /dev/null +++ b/public/login.html @@ -0,0 +1,148 @@ + + + + + + + Sign in — Lumbridge Simulate + + + +
+

Lumbridge Simulate

+

Tera · sign in to reach a private office.

+ +
+ + + + + + + +
+ +

+ +
+ + + + diff --git a/scripts/check-no-binaries.mjs b/scripts/check-no-binaries.mjs new file mode 100644 index 0000000..19b55c6 --- /dev/null +++ b/scripts/check-no-binaries.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * The no-binary-art gate: `src/**` holds no committed binary art, ever. + * + * This is the check that backs the licensing argument in ARCHITECTURE.md §3 — + * every mesh is a function composing cached unit primitives and every texture is + * drawn on a 2D canvas from seeded noise, which is what gives the repo zero + * asset-licensing exposure. A committed `.glb` or `.png` is a claim about + * provenance that nobody in CI can verify, so the answer is that they do not + * arrive at all. + * + * Two things about the scope, both of them corrections of an earlier design that + * CONTRACT.md §7 calls out by name. + * + * The enumeration is `git ls-files` and never a filesystem walk. A self-hoster + * is told, by CONTRIBUTING.md and by the office packs, to drop their own + * legally-clean art into `public/props/` and friends; a walk would find it and + * fail their build over files this repo does not distribute and has no opinion + * about. Tracked files are the only files a licence claim can be made about, so + * tracked files are the only files enumerated. If git is missing, this exits + * non-zero rather than falling back to a walk — a gate that quietly changes what + * it measures is worse than one that stops. + * + * And it is strict over `src/**` only. That is where the procedural-assets + * promise lives. `public/props/`, `public/kits/`, `public/offices/` and `docs/` + * are hard-exempt below and git-ignored besides: they are self-hoster space. + * + * Runs by hand as `node scripts/check-no-binaries.mjs` from anywhere in the + * working tree. + */ + +import { execFileSync } from "node:child_process"; + +// ---- The rule ---- + +/** + * Art and binary-asset extensions. Lowercased at the comparison, so a `.PNG` + * off a Windows checkout is caught too. + */ +const DENIED_EXTENSIONS = new Set([ + ".glb", + ".gltf", + ".png", + ".jpg", + ".jpeg", + ".webp", + ".hdr", + ".exr", + ".ttf", + ".otf", + ".woff", + ".woff2", + ".mp3", + ".wav", + ".fbx", + ".obj", +]); + +/** The pathspec handed to `git ls-files`. Everything outside it is unexamined. */ +const SCOPE = "src"; + +/** + * Self-hoster space, exempt even if something puts it inside the scope later. + * These are repo-relative prefixes, matched against the paths git reports. + */ +const EXEMPT_PREFIXES = [ + "public/props/", + "public/kits/", + "public/offices/", + "docs/", +]; + +// ---- Enumeration ---- + +function repoRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + fail( + "could not ask git for the repo root.", + "This check enumerates tracked files with `git ls-files` and deliberately has no", + "filesystem-walk fallback, because a walk would fail a self-hoster's build over", + "their own untracked art. Run it inside a git checkout.", + ); + } +} + +function trackedFiles(root, pathspec) { + const out = execFileSync("git", ["ls-files", "-z", "--", pathspec], { + cwd: root, + encoding: "utf8", + // A NUL-separated listing of a repo this size is tiny, but the default 1 MB + // ceiling is close enough to be worth not thinking about again. + maxBuffer: 64 * 1024 * 1024, + }); + return out.split("\0").filter((path) => path !== ""); +} + +/** + * The extension, lowercased, or the empty string. A leading dot with no stem — + * `src/.keep` — is a dotfile and has no extension, which is why this compares + * against the last slash rather than reaching for `path.extname`. + */ +function extensionOf(path) { + const dot = path.lastIndexOf("."); + const slash = path.lastIndexOf("/"); + if (dot <= slash + 1) return ""; + return path.slice(dot).toLowerCase(); +} + +function isExempt(path) { + return EXEMPT_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +// ---- Reporting ---- + +function fail(...lines) { + console.error(`\ncheck-no-binaries: FAIL — ${lines[0]}\n`); + for (const line of lines.slice(1)) console.error(line); + console.error(""); + process.exit(1); +} + +// ---- Run ---- + +const root = repoRoot(); +const tracked = trackedFiles(root, SCOPE); +const offenders = tracked + .filter((path) => !isExempt(path)) + .filter((path) => DENIED_EXTENSIONS.has(extensionOf(path))); + +if (offenders.length > 0) { + const listed = offenders.map((path) => ` ${path} (${extensionOf(path)})`); + fail( + `${offenders.length} binary asset${offenders.length === 1 ? "" : "s"} tracked under ${SCOPE}/.`, + ...listed, + "", + "Apache 2.0 is a promise that everything in this repo is ours to give away, and a", + "committed binary asset is a provenance claim no reviewer can check. Assets here are", + "procedural TypeScript: meshes compose cached unit primitives, textures are drawn on a", + "2D canvas from seeded noise. See ARCHITECTURE.md §3 and CONTRACT.md §3.", + "", + "If this is your own art for your own deployment, it does not belong in src/ at all —", + `put it under ${EXEMPT_PREFIXES.join(", ")}, which are git-ignored self-hoster space, and`, + "this check will never see it.", + ); +} + +console.log( + `check-no-binaries: ok — ${tracked.length} tracked file${tracked.length === 1 ? "" : "s"} under ` + + `${SCOPE}/, none matching ${DENIED_EXTENSIONS.size} denied extensions.`, +); +console.log( + ` enumerated with \`git ls-files\`, so untracked local art is invisible to this check.`, +); +console.log(` denied: ${[...DENIED_EXTENSIONS].join(" ")}`); +console.log(` exempt: ${EXEMPT_PREFIXES.join(" ")}`); diff --git a/scripts/check-zero-config-boot.mjs b/scripts/check-zero-config-boot.mjs new file mode 100644 index 0000000..cfcfbd3 --- /dev/null +++ b/scripts/check-zero-config-boot.mjs @@ -0,0 +1,346 @@ +#!/usr/bin/env node +/** + * The zero-config boot gate: hand the API nothing at all and it still answers. + * + * This exists because two independent server designs made the same mistake — + * defaulting the weather source to a provider that requires a contact string, + * then failing hard when the contact was absent — which breaks the one + * acceptance test the whole repo is built around: a stranger clones this, runs + * one command, and gets a working box with no account, no key and no network. + * CONTRACT.md §5.1 resolves it (a source configured without what it needs is + * demoted, not fatal) and this script is what keeps the resolution honest. + * + * The environment handed to the server is genuinely empty — `env: {}`, the + * in-process equivalent of `env -i` — which is possible only because the child + * is launched by absolute path (`process.execPath`) and so needs no PATH to find + * itself. Nothing is stubbed and no config object is constructed by hand: this + * starts the real entry point and asks the real socket, which is the difference + * between this and `server/src/test/boot.test.ts`, which asserts the same + * property one layer down. + * + * node scripts/check-zero-config-boot.mjs + * node scripts/check-zero-config-boot.mjs --compose + * + * The second form is CONTRACT.md §0's literal wording — `docker compose up` under + * an empty environment — and needs a working Docker. CI runs the first form, + * because a gate that depends on docker-in-docker being present on the runner is + * measuring the runner rather than the repo. The two assert the same thing: the + * compose file adds only `TERA_HOST` and container hardening, and every other + * variable in it carries a `:-` default precisely so that an empty environment + * resolves it to the empty string. + */ + +import { spawn } from "node:child_process"; +import { connect } from "node:net"; +import { fileURLToPath } from "node:url"; + +// ---- Where and what ---- + +const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); +const HEALTH_HOST = "127.0.0.1"; +const HEALTH_PORT = 8431; +const HEALTH_URL = `http://${HEALTH_HOST}:${HEALTH_PORT}/api/v1/health`; + +/** How long the server gets to bind and answer before this gives up. */ +const PROCESS_DEADLINE_MS = 20_000; +/** Compose has to build an image first, which is a different order of patience. */ +const COMPOSE_DEADLINE_MS = 300_000; + +const useCompose = process.argv.slice(2).includes("--compose"); + +// ---- Reporting ---- + +function fail(headline, detail = [], log = []) { + console.error(`\ncheck-zero-config-boot: FAIL — ${headline}\n`); + for (const line of detail) console.error(line); + if (log.length > 0) { + console.error("\n--- what the server said ---"); + console.error(log.join("").trimEnd()); + console.error("--- end ---"); + } + console.error(""); + process.exitCode = 1; +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// ---- Making sure we are testing our own server ---- + +/** + * Is anything already listening on the port we are about to claim? + * + * This guard is here because the check silently passed without it. The port was + * occupied by an unrelated process, the server we spawned died on `EADDRINUSE` + * within a second, and the poll cheerfully collected a 200 from the stranger — + * a green gate asserting nothing at all. A check that can pass against a server + * it did not start is worse than no check, so an occupied port is a hard stop. + */ +function portInUse() { + return new Promise((resolve) => { + const socket = connect({ host: HEALTH_HOST, port: HEALTH_PORT }); + const settle = (answer) => { + socket.destroy(); + resolve(answer); + }; + socket.setTimeout(1_500); + socket.once("connect", () => settle(true)); + socket.once("timeout", () => settle(false)); + socket.once("error", () => settle(false)); + }); +} + +async function assertPortFree() { + if (!(await portInUse())) return true; + fail(`something is already listening on ${HEALTH_HOST}:${HEALTH_PORT}.`, [ + "This check has to own that port, because otherwise it polls whatever is there and", + "reports a healthy stranger while the server it started is dead in a ditch. It cannot", + "move to another port either: choosing one would mean setting TERA_PORT, and the empty", + "environment is the thing under test.", + "", + "Stop whatever is on the port and run it again.", + ]); + return false; +} + +/** + * A second line of defence against answering for somebody else's process: if the + * server on the other end has been up longer than this check has been running, + * it is not the one we started. + */ +function uptimeLooksLikeOurs(body, startedAt) { + const allowed = Math.ceil((Date.now() - startedAt) / 1000) + 2; + return typeof body.uptimeSeconds !== "number" || body.uptimeSeconds <= allowed; +} + +// ---- The assertion ---- + +/** + * Poll until health answers or the deadline passes. `stillRunning` lets the + * caller abort early when the thing under test has already died, so a server + * that exits on boot reports its own error instead of a twenty-second timeout. + */ +async function waitForHealth(deadlineMs, stillRunning) { + const deadline = Date.now() + deadlineMs; + let lastError = "nothing was listening"; + while (Date.now() < deadline) { + if (!stillRunning()) return { dead: true, lastError }; + try { + const response = await fetch(HEALTH_URL, { + signal: AbortSignal.timeout(2_000), + }); + const text = await response.text(); + return { status: response.status, text }; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + await sleep(250); + } + return { timedOut: true, lastError }; +} + +/** + * Everything the health body has to say on a box that was handed nothing. + * + * The source and mode checks are not padding. A default that needs configuration + * is exactly the bug this gate was written for, and it would still return 200 + * while being wrong — the failure showed up as an empty sky, not as a dead + * process. `degraded` being non-empty means the config layer demoted something, + * which on an empty environment means a default asked for something it was never + * going to be given. + */ +function checkPosture(body) { + const problems = []; + if (body.ok !== true) { + problems.push(` health reported ok: ${JSON.stringify(body.ok)}, expected true`); + } + if (body.sources?.weather !== "none") { + problems.push( + ` weather source defaulted to ${JSON.stringify(body.sources?.weather)}; CONTRACT.md §5.1`, + ` requires "none", because every other source wants a contact string or a key.`, + ); + } + if (body.auth?.mode !== "none") { + problems.push( + ` auth mode defaulted to ${JSON.stringify(body.auth?.mode)}; CONTRACT.md §6 requires "none"`, + ` so that a self-hoster never creates an account anywhere.`, + ); + } + if (!Array.isArray(body.degraded)) { + problems.push(` health body has no degraded array; it is how demotions become visible.`); + } else if (body.degraded.length > 0) { + problems.push( + ` the config layer demoted ${body.degraded.length} source(s) on an empty environment:`, + ...body.degraded.map((line) => ` ${line}`), + ` A demotion here means a default was chosen that needs configuration to work.`, + ); + } + return problems; +} + +function report(body) { + console.log("check-zero-config-boot: ok — 200 from /api/v1/health on an empty environment."); + console.log(` service: ${body.service} ${body.version}`); + console.log( + ` sources: weather=${body.sources?.weather} flights=${body.sources?.flights} ` + + `markers=${body.sources?.markers}`, + ); + console.log(` auth: ${body.auth?.mode}`); + console.log(` degraded: none`); +} + +/** Shared tail of both modes: parse, assert, print. */ +function finish(result, log, startedAt) { + if (result.dead) { + fail( + "the server exited before it ever answered.", + [ + "It was started with a genuinely empty environment, which is the whole point: a box", + "that needs a variable set before it will boot is not self-hostable. See CONTRACT.md §5.1.", + ` last connection attempt: ${result.lastError}`, + ], + log, + ); + return; + } + if (result.timedOut) { + fail( + `nothing answered ${HEALTH_URL} before the deadline.`, + [` last connection attempt: ${result.lastError}`], + log, + ); + return; + } + if (result.status !== 200) { + fail( + `${HEALTH_URL} returned ${result.status}, expected 200.`, + [ + "Health touches no upstream and reads no file by design, so a non-200 here is the", + "server refusing to be healthy without configuration it should not need.", + ` body: ${result.text.slice(0, 500)}`, + ], + log, + ); + return; + } + + let body; + try { + body = JSON.parse(result.text); + } catch { + fail("health answered 200 but the body was not JSON.", [` body: ${result.text.slice(0, 500)}`], log); + return; + } + + if (!uptimeLooksLikeOurs(body, startedAt)) { + fail( + `${HEALTH_URL} answered, but from a server this check did not start.`, + [ + ` it reports ${body.uptimeSeconds}s of uptime; this check has been running for`, + ` ${Math.ceil((Date.now() - startedAt) / 1000)}s. Something else claimed the port first.`, + ], + log, + ); + return; + } + + const problems = checkPosture(body); + if (problems.length > 0) { + fail("health answered 200, but not from a zero-config box.", problems, log); + return; + } + report(body); +} + +// ---- Mode: the real entry point under an empty environment ---- + +async function runProcessMode() { + if (!(await assertPortFree())) return; + console.log("check-zero-config-boot: starting server/src/index.ts with env -i (no variables at all)"); + + const startedAt = Date.now(); + const child = spawn(process.execPath, ["server/src/index.ts"], { + cwd: REPO_ROOT, + // The empty environment is the test. `process.execPath` is absolute, so the + // child needs no PATH to exist, and Node needs nothing else to run. + env: {}, + stdio: ["ignore", "pipe", "pipe"], + }); + + const log = []; + child.stdout.on("data", (chunk) => log.push(String(chunk))); + child.stderr.on("data", (chunk) => log.push(String(chunk))); + + let alive = true; + let exitInfo = ""; + child.on("exit", (code, signal) => { + alive = false; + exitInfo = signal ? `killed by ${signal}` : `exited with code ${code}`; + }); + child.on("error", (err) => { + alive = false; + exitInfo = `could not spawn: ${err.message}`; + }); + + try { + const result = await waitForHealth(PROCESS_DEADLINE_MS, () => alive); + if (result.dead) log.push(`\n(process ${exitInfo})\n`); + finish(result, log, startedAt); + } finally { + if (alive) { + child.kill("SIGTERM"); + // The entry point closes on SIGTERM; if it does not, this is a check, not a + // supervisor, and a lingering child would hang CI. + for (let waited = 0; alive && waited < 5_000; waited += 100) await sleep(100); + if (alive) child.kill("SIGKILL"); + } + } +} + +// ---- Mode: docker compose, for whoever has a Docker ---- + +function docker(args, timeoutMs) { + return new Promise((resolve) => { + const child = spawn("docker", args, { + cwd: REPO_ROOT, + // PATH and HOME are for the Docker CLI itself — finding its binary and its + // config — and reach the container through nothing. The compose file + // interpolates only TERA_* variables, all with `:-` defaults, so the + // container's own environment is empty either way. + env: { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" }, + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + }); + const out = []; + child.stdout.on("data", (chunk) => out.push(String(chunk))); + child.stderr.on("data", (chunk) => out.push(String(chunk))); + child.on("close", (code) => resolve({ code, out: out.join("") })); + child.on("error", (err) => resolve({ code: -1, out: `could not run docker: ${err.message}` })); + }); +} + +async function runComposeMode() { + if (!(await assertPortFree())) return; + const compose = ["compose", "-f", "deploy/docker-compose.yml"]; + console.log("check-zero-config-boot: docker compose up, with no .env file and no TERA_* set"); + + const up = await docker([...compose, "up", "-d", "--build"], COMPOSE_DEADLINE_MS); + if (up.code !== 0) { + fail("`docker compose up` failed.", [" " + up.out.trim().split("\n").join("\n ")]); + return; + } + // After the build, not before it: `up -d` returns with the container just + // started, so the uptime it reports is measured from about here. + const startedAt = Date.now(); + + try { + const result = await waitForHealth(60_000, () => true); + const logs = await docker([...compose, "logs", "--no-color"], 30_000); + finish(result, [logs.out], startedAt); + } finally { + await docker([...compose, "down", "-v"], 60_000); + } +} + +// ---- Run ---- + +await (useCompose ? runComposeMode() : runProcessMode()); diff --git a/server/src/app.ts b/server/src/app.ts index 6b111a5..7f39f8b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,6 +19,7 @@ import { registerFlights } from "./routes/flights.ts"; import { registerHealth } from "./routes/health.ts"; import { registerMarkers } from "./routes/markers.ts"; import { registerOffices } from "./routes/offices.ts"; +import { registerSession } from "./routes/session.ts"; import { registerWeather } from "./routes/weather.ts"; import { createServices } from "./services.ts"; import type { ErrorBody } from "../../src/server/wire.ts"; @@ -46,6 +47,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance { registerWeather(app, services); registerMarkers(app, services); registerOffices(app, services); + registerSession(app, services); app.setNotFoundHandler(async (_req, reply) => { const body: ErrorBody = { error: "not_found", message: "No such route." }; diff --git a/server/src/auth/index.ts b/server/src/auth/index.ts index 60c7ab4..c53c42c 100644 --- a/server/src/auth/index.ts +++ b/server/src/auth/index.ts @@ -13,13 +13,20 @@ * - **`jwt`** verifies a token here, for a deployment that would rather not make * an outbound call per request. See `jwt.ts` for why HS256 is the primary path. * - * Enforcement is on the server in all three. A viewer object that says + * `TERA_AUTH_MODE=password` is a fourth thing an operator can write and + * deliberately **not** a fourth branch below: it resolves to `jwt` in + * `config.ts`, and the only difference is that this box also signs the token + * itself, in `issueSessionToken`. A self-hoster with nowhere to get a token from + * needed a way to sign in; giving them a second authorisation path to audit was + * not worth it, so they get the same one with a local issuer attached. + * + * Enforcement is on the server in all of them. A viewer object that says * `authenticated: false` is the only thing a route ever sees, and the route * answers 404 — never 403 — so the endpoint cannot be used to enumerate what * exists. */ -import { createHash } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; import type { FastifyRequest } from "fastify"; import type { AuthConfig } from "../config.ts"; import { verifyJwt } from "./jwt.ts"; @@ -87,6 +94,67 @@ export function createAuth(config: AuthConfig): AuthService { }; } +// ---- Issuing a session ---------------------------------------------------- + +/** + * Sign the session token `resolve()` will later verify. + * + * Signing and verification live in the same directory on purpose: the claims + * this writes are exactly the claims `jwt.ts` checks, including the `iss` and + * `aud` an operator configured, and a token that this server issues but cannot + * accept is the failure mode worth designing against. `alg` is pinned to HS256 + * to match `verifyHs256`, which will not accept anything else. + */ +export function issueSessionToken( + config: AuthConfig, + subject: string, + ttlSeconds: number, +): string { + const now = Math.floor(Date.now() / 1000); + const claims: Record = { sub: subject, iat: now, exp: now + ttlSeconds }; + if (config.issuer !== "") claims.iss = config.issuer; + if (config.audience !== "") claims.aud = config.audience; + + const signed = `${base64url({ alg: "HS256", typ: "JWT" })}.${base64url(claims)}`; + const signature = createHmac("sha256", config.jwtSecret).update(signed).digest("base64url"); + return `${signed}.${signature}`; +} + +/** + * The `Set-Cookie` value for a signed-in browser. + * + * `HttpOnly` because no script has any business reading a bearer token — it is + * the difference between an XSS bug that defaces a page and one that walks off + * with a session. `Secure` unconditionally, including in development: browsers + * treat `localhost` and `127.0.0.1` as trustworthy origins and will store a + * Secure cookie there, so there is no dev exemption to add and therefore no dev + * exemption to accidentally ship. `SameSite=Lax` because nothing here is a + * cross-site POST, and `Lax` is what stops another origin's form from acting as + * the signed-in user. + */ +export function sessionCookie(config: AuthConfig, token: string, ttlSeconds: number): string { + return cookie(config.cookieName, encodeURIComponent(token), Math.max(0, Math.floor(ttlSeconds))); +} + +/** + * Clearing has to repeat every attribute that was set. A browser matches a + * replacement cookie on name, domain and path, so dropping `Path=/` here would + * leave the original in place and sign nobody out. + */ +export function clearedSessionCookie(config: AuthConfig): string { + return cookie(config.cookieName, "", 0); +} + +function cookie(name: string, value: string, maxAge: number): string { + return `${name}=${value}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`; +} + +function base64url(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +// ---- Reading a session ---------------------------------------------------- + function bearerToken(req: FastifyRequest): string | null { const header = req.headers.authorization; if (typeof header !== "string") return null; diff --git a/server/src/auth/password.ts b/server/src/auth/password.ts new file mode 100644 index 0000000..f28af9c --- /dev/null +++ b/server/src/auth/password.ts @@ -0,0 +1,206 @@ +/** + * Credential verification for `TERA_AUTH_MODE=password`. + * + * The rule this file exists to enforce is that **no plaintext password ever + * comes out of the environment**. `TERA_AUTH_PASSWORD_HASH` carries a scrypt + * digest and the parameters it was derived under, so an operator who leaks their + * unit file, their `docker inspect` output or their shell history has leaked a + * hash and not an account. A `TERA_AUTH_PASSWORD` that this module would happily + * compare against is the version of this feature that must never exist. + * + * scrypt is `node:crypto`'s, which is why there is no new dependency: it is + * memory-hard, it ships in the runtime, and the alternative is pulling argon2 or + * bcrypt — a native build — into a repo whose acceptance test is `npm ci` on a + * stranger's laptop. + * + * ## Generating a hash + * + * There is no script to run and nothing to install. Paste this, type the + * password, press ctrl-D, and put the line it prints in + * `TERA_AUTH_PASSWORD_HASH`: + * + * ```sh + * node -e 'const c=require("node:crypto");let p="";process.stdin.setEncoding("utf8").on("data",d=>p+=d).on("end",()=>{const s=c.randomBytes(16),k=c.scryptSync(p.replace(/\n$/,""),s,32,{N:16384,r:8,p:1,maxmem:67108864});console.log(`scrypt$16384$8$1$${s.toString("base64url")}$${k.toString("base64url")}`)})' + * ``` + * + * It reads stdin rather than taking the password as an argument because an + * argument lands in shell history and in anybody's `ps` output. A trailing + * newline is stripped so that typing the password and pressing return produces + * the same hash as piping it in. + * + * `session.test.ts` pins the output of exactly that command against the parser + * below, so the two cannot drift apart without a test failing. + */ + +import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto"; + +/** A parsed `TERA_AUTH_PASSWORD_HASH`, with the parameters it was made under. */ +export interface ScryptHash { + n: number; + r: number; + p: number; + salt: Buffer; + key: Buffer; +} + +/** + * Interactive-login parameters as of 2026: ~16 MiB and a few tens of + * milliseconds. High enough that a leaked hash is not a wordlist away from a + * password, low enough that the login endpoint is not itself a way to burn the + * box's CPU. The chosen values are recorded *in* the hash, so raising them later + * does not invalidate the hashes already in operators' unit files. + */ +const DEFAULT_PARAMS = { n: 16384, r: 8, p: 1 } as const; +const SALT_BYTES = 16; +const KEY_BYTES = 32; + +/** + * An upper bound on what a hash string is allowed to ask scrypt to allocate. + * `TERA_AUTH_PASSWORD_HASH` is operator input rather than attacker input, but a + * fat-fingered `N` should be a boot-time demotion and not a box that allocates + * sixteen gigabytes on the first login attempt. + */ +const MAX_MEMORY_BYTES = 256 * 1024 * 1024; + +/** + * Longer than this and we do not even hash it. scrypt's cost is independent of + * the password's length, so this is not about work — it is about not copying a + * megabyte of request body into a key-derivation function on an unauthenticated + * endpoint. + */ +export const MAX_PASSWORD_LENGTH = 256; + +export function parseScryptHash(encoded: string): ScryptHash | null { + const parts = encoded.split("$"); + if (parts.length !== 6) return null; + const [scheme, nRaw, rRaw, pRaw, saltRaw, keyRaw] = parts; + if (scheme !== "scrypt") return null; + if (nRaw === undefined || rRaw === undefined || pRaw === undefined) return null; + if (saltRaw === undefined || keyRaw === undefined) return null; + + const n = positiveInt(nRaw); + const r = positiveInt(rRaw); + const p = positiveInt(pRaw); + if (n === null || r === null || p === null) return null; + // scrypt requires N to be a power of two greater than one, and rejects + // anything else at call time. Catching it here turns a runtime throw on the + // login path into one line in `/api/v1/health`. + if (n < 2 || (n & (n - 1)) !== 0) return null; + if (memoryFor(n, r, p) > MAX_MEMORY_BYTES) return null; + + const salt = fromBase64Url(saltRaw); + const key = fromBase64Url(keyRaw); + if (salt === null || key === null) return null; + if (salt.length < 8 || key.length < 16) return null; + + return { n, r, p, salt, key }; +} + +export function formatScryptHash(hash: ScryptHash): string { + const salt = hash.salt.toString("base64url"); + const key = hash.key.toString("base64url"); + return `scrypt$${hash.n}$${hash.r}$${hash.p}$${salt}$${key}`; +} + +/** Used by the tests and available to anyone who would rather not paste shell. */ +export async function hashPassword(plaintext: string): Promise { + const { n, r, p } = DEFAULT_PARAMS; + const salt = randomBytes(SALT_BYTES); + const key = await derive(plaintext, salt, KEY_BYTES, { n, r, p }); + if (key === null) throw new Error("scrypt failed"); + return formatScryptHash({ n, r, p, salt, key }); +} + +/** + * Derive and compare in constant time. + * + * The comparison is `timingSafeEqual` over two buffers that are the same length + * by construction — the derived key's length is taken from the stored key — so + * there is no early exit for an attacker to measure. The caller must still call + * this even when it already knows the username is wrong; see `credentialsMatch`. + */ +export async function verifyPassword(hash: ScryptHash, supplied: string): Promise { + if (supplied.length > MAX_PASSWORD_LENGTH) return false; + const derived = await derive(supplied, hash.salt, hash.key.length, hash); + if (derived === null) return false; + return timingSafeEqual(derived, hash.key); +} + +/** + * The whole credential check, and the reason it is one function rather than two + * calls at the route: **a wrong username and a wrong password must be the same + * event**. Both branches derive a key, both take the same path, and the two + * answers are combined only at the end. An implementation that returns early on + * an unknown username answers in a millisecond instead of fifty and hands an + * attacker a user-enumeration oracle for free. + */ +export async function credentialsMatch( + expectedUsername: string, + hash: ScryptHash, + suppliedUsername: string, + suppliedPassword: string, +): Promise { + const usernameOk = timingSafeStringEqual(expectedUsername, suppliedUsername); + const passwordOk = await verifyPassword(hash, suppliedPassword); + return usernameOk && passwordOk; +} + +/** + * Compare two strings of unequal length without leaking the length. + * + * `timingSafeEqual` throws when the buffers differ in size, which would make the + * *shape* of the failure depend on the input. Hashing both first gives two + * 32-byte buffers whatever arrived, and a username is not secret enough for the + * extra digest to be worth avoiding. + */ +export function timingSafeStringEqual(a: string, b: string): boolean { + const left = createHash("sha256").update(a, "utf8").digest(); + const right = createHash("sha256").update(b, "utf8").digest(); + return timingSafeEqual(left, right); +} + +// ---- Internals ------------------------------------------------------------ + +function derive( + plaintext: string, + salt: Buffer, + length: number, + params: { n: number; r: number; p: number }, +): Promise { + const { n, r, p } = params; + return new Promise((resolve) => { + // The callback form, not `scryptSync`: this runs on a login request, and + // fifty milliseconds of synchronous key derivation is fifty milliseconds in + // which the one server answers nobody else. + scrypt( + plaintext, + salt, + length, + { N: n, r, p, maxmem: Math.max(32 * 1024 * 1024, memoryFor(n, r, p) * 2) }, + (err, derived) => resolve(err === null ? derived : null), + ); + }); +} + +/** scrypt's working set, which is what `maxmem` is checked against. */ +function memoryFor(n: number, r: number, p: number): number { + return 128 * n * r * p; +} + +function positiveInt(raw: string): number | null { + if (!/^[0-9]{1,10}$/.test(raw)) return null; + const value = Number(raw); + return value > 0 ? value : null; +} + +/** + * `Buffer.from(s, "base64url")` never fails — it stops at the first character it + * cannot use and returns what it had. Round-tripping is the only way to know the + * string was really base64url, and a hash with a typo in it must be a boot-time + * demotion rather than a password that can never be right. + */ +function fromBase64Url(raw: string): Buffer | null { + if (raw === "") return null; + const decoded = Buffer.from(raw, "base64url"); + return decoded.toString("base64url") === raw ? decoded : null; +} diff --git a/server/src/config.ts b/server/src/config.ts index b7ab150..e1d6a77 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -18,6 +18,7 @@ */ import { readFileSync } from "node:fs"; +import { parseScryptHash, type ScryptHash } from "./auth/password.ts"; import type { AuthMode, FlightsSourceId, @@ -58,6 +59,23 @@ export interface MarkersConfig { ttlSeconds: number; } +/** + * The one account `TERA_AUTH_MODE=password` will sign in, once the environment + * has been read and found to contain a usable credential. `null` everywhere + * else, and `routes/session.ts` reads that null as "this box cannot sign anyone + * in" and answers 404 on the login endpoint. + */ +export interface PasswordLogin { + username: string; + /** Parsed at boot so a typo in the hash is a health line, not a login that never works. */ + hash: ScryptHash; + /** Lifetime of the token this box issues for itself, in seconds. */ + sessionTtlSeconds: number; + /** Failed attempts one IP may make inside `rateWindowSeconds` before 429s start. */ + rateAttempts: number; + rateWindowSeconds: number; +} + export interface AuthConfig { mode: AuthMode; /** Where a browser sends someone to sign in. `sso` mode only. */ @@ -73,6 +91,8 @@ export interface AuthConfig { jwksUrl: string; issuer: string; audience: string; + /** Set only by `TERA_AUTH_MODE=password`; see `PasswordLogin` and `loadPasswordLogin`. */ + passwordLogin: PasswordLogin | null; } export interface Config { @@ -243,11 +263,17 @@ function loadMarkers(env: Env, degraded: string[]): MarkersConfig { }; } -const AUTH_MODES: AuthMode[] = ["none", "sso", "jwt"]; +/** + * `password` is a mode an operator asks for, not a mode the rest of the server + * ever sees. It resolves to `jwt` below; see `loadPasswordLogin` for why. + */ +type ConfiguredAuthMode = AuthMode | "password"; + +const AUTH_MODES: ConfiguredAuthMode[] = ["none", "sso", "jwt", "password"]; function loadAuth(env: Env, degraded: string[]): AuthConfig { const asked = str(env, "TERA_AUTH_MODE", "none"); - let mode = oneOf(asked, AUTH_MODES); + let mode: ConfiguredAuthMode | null = oneOf(asked, AUTH_MODES); if (mode === null) { degraded.push( `TERA_AUTH_MODE="${asked}" is not one of ${AUTH_MODES.join(", ")}; ` + @@ -260,7 +286,8 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig { const revalidateUrl = str(env, "TERA_AUTH_REVALIDATE_URL", ""); const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", ""); const jwksUrl = str(env, "TERA_AUTH_JWKS_URL", ""); - const jwtVerify = str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256"; + let jwtVerify: "hs256" | "jwks" = + str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256"; // A demotion here has teeth: it takes private offices with it, which is the // safe direction. Unverifiable credentials must never mean "let them in". @@ -287,6 +314,33 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig { mode = "none"; } + // `password` collapses into `jwt` here, and that is the whole design rather + // than an implementation detail. `routes/session.ts` signs exactly the token + // `jwt` mode already verifies, so a browser that signed in on this box and a + // browser carrying a token from the fleet's issuer arrive at `offices.ts` + // through the same `resolve()` — one authorisation path that can be reasoned + // about, rather than two that have to be kept in agreement. What `password` + // adds is an issuer, not a verifier. + let passwordLogin: PasswordLogin | null = null; + if (mode === "password") { + passwordLogin = loadPasswordLogin(env, jwtSecret, degraded); + if (passwordLogin === null) { + mode = "none"; + } else { + mode = "jwt"; + if (jwtVerify === "jwks") { + // Verifying against someone else's public keys while signing with a + // local secret means this box would reject its own sessions. + degraded.push( + "TERA_AUTH_MODE=password issues its own HS256 tokens, so " + + "TERA_AUTH_JWT_VERIFY=jwks was ignored; this box verifies the " + + "sessions it signs.", + ); + jwtVerify = "hs256"; + } + } + } + return { mode, entryUrl, @@ -297,6 +351,63 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig { jwksUrl, issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""), audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""), + passwordLogin, + }; +} + +/** + * The credential for `TERA_AUTH_MODE=password`, or `null` with a loud line if + * the environment did not supply a usable one. + * + * Every failure here is a demotion to `mode=none` rather than a refusal to boot, + * which is the rule the whole file follows — but note which direction the + * demotion runs: mode=none makes every private office answer 404 to everybody, + * including the operator. Nobody is let in by a misconfiguration. + * + * `TERA_AUTH_PASSWORD_HASH` is a hash and there is deliberately no plaintext + * equivalent to set. `auth/password.ts` carries the command that produces one. + */ +function loadPasswordLogin( + env: Env, + jwtSecret: string, + degraded: string[], +): PasswordLogin | null { + const username = str(env, "TERA_AUTH_PASSWORD_USER", ""); + const encoded = str(env, "TERA_AUTH_PASSWORD_HASH", ""); + + if (username === "" || encoded === "") { + degraded.push( + "TERA_AUTH_MODE=password needs TERA_AUTH_PASSWORD_USER and " + + "TERA_AUTH_PASSWORD_HASH (a scrypt hash — see server/src/auth/password.ts " + + "for the one-liner that prints one). Demoted to mode=none; private " + + "offices will answer 404 to everyone.", + ); + return null; + } + + if (jwtSecret === "") { + degraded.push( + "TERA_AUTH_MODE=password needs TERA_AUTH_JWT_SECRET to sign the session " + + "cookie it issues — try `openssl rand -base64 48`. Demoted to mode=none.", + ); + return null; + } + + const hash = parseScryptHash(encoded); + if (hash === null) { + degraded.push( + "TERA_AUTH_PASSWORD_HASH is not a hash this server can read; it should " + + "look like `scrypt$16384$8$1$$`. Demoted to mode=none.", + ); + return null; + } + + return { + username, + hash, + sessionTtlSeconds: num(env, "TERA_AUTH_SESSION_TTL", 43_200, degraded), + rateAttempts: num(env, "TERA_AUTH_LOGIN_ATTEMPTS", 8, degraded), + rateWindowSeconds: num(env, "TERA_AUTH_LOGIN_WINDOW", 300, degraded), }; } diff --git a/server/src/routes/session.ts b/server/src/routes/session.ts new file mode 100644 index 0000000..e29dfdf --- /dev/null +++ b/server/src/routes/session.ts @@ -0,0 +1,215 @@ +/** + * `/api/v1/session` — the only place this box issues a credential rather than + * checking one. + * + * It exists because neither committed mode lets a human sign in: `none` is open + * and `jwt` verifies a token somebody else already issued. `TERA_AUTH_MODE=password` + * fills that gap for a self-hoster with no identity provider, and it fills it by + * signing the same HS256 token `jwt` mode verifies and putting it in a cookie. + * `offices.ts` is unchanged and unaware; there is still exactly one code path + * that decides whether a private office exists for you. + * + * Three deliberate refusals to be helpful, all of them about not answering + * questions an anonymous caller should not get answers to: + * + * - A wrong username and a wrong password produce the same status, the same body + * and the same amount of work. `credentialsMatch` in `auth/password.ts` is + * where that is enforced. + * - When this box cannot sign anyone in, `POST` answers **404 with the body an + * unrouted path gets**, not "password login is disabled". Whether an operator + * configured a local account is not public information. + * - A private office still answers 404 rather than 403, before and after signing + * in. That rule lives in `offices.ts` and nothing here weakens it. + */ + +import type { FastifyInstance } from "fastify"; +import { + clearedSessionCookie, + issueSessionToken, + sessionCookie, +} from "../auth/index.ts"; +import { MAX_PASSWORD_LENGTH, credentialsMatch } from "../auth/password.ts"; +import type { ErrorBody } from "../../../src/server/wire.ts"; +import type { Services } from "../services.ts"; + +/** + * What the client gets to know. `passwordLogin` is the field the UI gates on: + * it says a login form would do something here, which is not a secret — the + * login page is served to anyone — while `authenticated` says whether this + * particular caller needs one. + */ +export interface SessionBody { + authenticated: boolean; + /** The signed-in subject, or `null`. Never a token. */ + subject: string | null; + /** Whether `POST` to this endpoint can sign somebody in on this deployment. */ + passwordLogin: boolean; +} + +/** Byte-identical to what the not-found handler serves for a path with no route. */ +const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such route." }; + +/** + * One message for every rejected credential. It says nothing about which half + * was wrong, and the same object is sent for a username that does not exist. + */ +const REJECTED: ErrorBody = { + error: "unauthorized", + message: "Those credentials were not accepted.", +}; + +/** + * `unauthorized` rather than a token of its own because the error union in + * `wire.ts` is closed and deliberately short. The 429 and `Retry-After` carry + * the distinction, and a client that only reads `error` treats a rate-limited + * attempt as a failed one, which is the right thing for it to do. + */ +const RATE_LIMITED: ErrorBody = { + error: "unauthorized", + message: "Too many sign-in attempts. Try again shortly.", +}; + +const MAX_USERNAME_LENGTH = 256; + +export function registerSession(app: FastifyInstance, services: Services): void { + const { auth } = services.config; + // Per app instance rather than per module, so two servers in one process — + // which is what the tests are — cannot share a limiter. + const limiter = createLimiter( + auth.passwordLogin?.rateAttempts ?? 8, + auth.passwordLogin?.rateWindowSeconds ?? 300, + ); + + app.get("/api/v1/session", async (req) => { + const viewer = await services.auth.resolve(req); + return body(viewer.authenticated, viewer.subject, auth.passwordLogin !== null); + }); + + app.post("/api/v1/session", async (req, reply) => { + const login = auth.passwordLogin; + if (login === null) return reply.code(404).send(NOT_FOUND); + + const retryAfter = limiter.check(req.ip); + if (retryAfter !== null) { + // Before reading the body and before hashing anything: the point of the + // limit is that a flood of attempts costs the box nothing. + return reply.code(429).header("retry-after", String(retryAfter)).send(RATE_LIMITED); + } + + const supplied = credentials(req.body); + if (supplied === null) { + // A malformed body is a failed attempt, not a 400. Answering differently + // would let an attacker probe the endpoint without spending attempts. + limiter.fail(req.ip); + return reply.code(401).send(REJECTED); + } + + const ok = await credentialsMatch( + login.username, + login.hash, + supplied.username, + supplied.password, + ); + if (!ok) { + limiter.fail(req.ip); + return reply.code(401).send(REJECTED); + } + + limiter.succeed(req.ip); + const token = issueSessionToken(auth, login.username, login.sessionTtlSeconds); + reply.header("set-cookie", sessionCookie(auth, token, login.sessionTtlSeconds)); + return body(true, login.username, true); + }); + + // Signing out is available in every mode, including the ones where this box + // never issued the cookie. Refusing to clear a cookie tells the caller + // something about how the deployment is configured and helps nobody. + app.delete("/api/v1/session", async (_req, reply) => { + reply.header("set-cookie", clearedSessionCookie(auth)); + return body(false, null, auth.passwordLogin !== null); + }); +} + +function body(authenticated: boolean, subject: string | null, passwordLogin: boolean): SessionBody { + return { authenticated, subject, passwordLogin }; +} + +/** + * Read the two fields, or decide there was nothing to read. + * + * Length caps are here rather than at the hash: a request body is up to a + * megabyte and there is no reason to hand any of it to a key-derivation + * function on an unauthenticated route. + */ +function credentials(raw: unknown): { username: string; password: string } | null { + if (raw === null || typeof raw !== "object") return null; + const { username, password } = raw as { username?: unknown; password?: unknown }; + if (typeof username !== "string" || typeof password !== "string") return null; + if (username === "" || password === "") return null; + if (username.length > MAX_USERNAME_LENGTH || password.length > MAX_PASSWORD_LENGTH) return null; + return { username, password }; +} + +// ---- Rate limiting -------------------------------------------------------- + +interface Bucket { + failures: number; + windowEndsAt: number; +} + +/** + * A fixed window of failures per client address, held in memory. + * + * In memory because there is one process and one box (CONTRACT.md §5) and a + * Redis to lose is worse than a limiter that resets on deploy. Keyed on + * `req.ip`, which is the real client because Caddy is the only thing that can + * reach the socket and `trustProxy` is on — nothing else can forge the header. + * + * Only failures count. A working session should not be able to lock its own + * owner out by reloading, and an attacker who is already succeeding is not + * someone a rate limit is going to help with. + */ +function createLimiter(attempts: number, windowSeconds: number) { + const buckets = new Map(); + const limit = Math.max(1, Math.floor(attempts)); + const windowMs = Math.max(1, Math.floor(windowSeconds)) * 1000; + + /** Bounded so a flood from many addresses cannot grow the map without limit. */ + const MAX_TRACKED = 4096; + + function sweep(now: number): void { + for (const [key, bucket] of buckets) { + if (bucket.windowEndsAt <= now) buckets.delete(key); + } + // Still full after dropping the expired ones: that is a flood rather than + // accumulation, and clearing is the right trade. The worst case is that some + // attackers get their attempts back; the alternative is unbounded memory. + if (buckets.size >= MAX_TRACKED) buckets.clear(); + } + + return { + /** Seconds the caller should wait, or `null` when it may try now. */ + check(ip: string): number | null { + const now = Date.now(); + const bucket = buckets.get(ip); + if (bucket === undefined || bucket.windowEndsAt <= now) return null; + if (bucket.failures < limit) return null; + return Math.max(1, Math.ceil((bucket.windowEndsAt - now) / 1000)); + }, + + fail(ip: string): void { + const now = Date.now(); + if (buckets.size >= MAX_TRACKED) sweep(now); + const bucket = buckets.get(ip); + if (bucket === undefined || bucket.windowEndsAt <= now) { + buckets.set(ip, { failures: 1, windowEndsAt: now + windowMs }); + return; + } + bucket.failures += 1; + }, + + succeed(ip: string): void { + buckets.delete(ip); + }, + }; +} diff --git a/server/src/test/session.test.ts b/server/src/test/session.test.ts new file mode 100644 index 0000000..c4df2f2 --- /dev/null +++ b/server/src/test/session.test.ts @@ -0,0 +1,307 @@ +/** + * Signing in, and the things signing in must not reveal. + * + * The positive case is one test; the rest of this file is about the negatives, + * because those are the ones that fail quietly in production. A wrong username + * and a wrong password have to be the same event byte for byte. A box with no + * local account has to look like a box with no such route. And a private office + * has to go back to not existing the moment the cookie is cleared — a logout + * that only hides the UI is not a logout. + * + * Follows `offices.test.ts`: a temp directory of office packs, `buildApp` with a + * config built from a fake environment, and `inject()` rather than a socket. + */ + +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import { hashPassword, parseScryptHash, verifyPassword } from "../auth/password.ts"; +import type { SessionBody } from "../routes/session.ts"; + +const SECRET = "not-a-real-secret-and-never-was"; +const USER = "karti"; +const PASSWORD = "correct horse battery staple"; + +/** + * Produced by the exact command documented in `auth/password.ts`, pasted + * verbatim, for the password above. It is a fixture rather than a call to + * `hashPassword` so that the shell an operator is told to run and the parser + * this server ships cannot drift apart without this test going red. + */ +const HASH_FROM_THE_DOCUMENTED_COMMAND = + "scrypt$16384$8$1$Ns_--HDodVjObBpWzIp5TQ$_2BLRqJI14VRzr2iA-3OqR-Z1X-bVrezZYqmaU477xc"; + +/** A minimal floor. `Plan` is what makes sense of it; the API only carries it. */ +const floor = { id: "hq", name: "HQ", levels: [], viewpoints: [] }; + +let dir = ""; + +before(async () => { + dir = await mkdtemp(join(tmpdir(), "tera-session-")); + await writeFile( + join(dir, "open.json"), + JSON.stringify({ id: "open", name: "Open office", visibility: "public", floor }), + ); + await writeFile( + join(dir, "closed.json"), + JSON.stringify({ id: "closed", name: "Closed office", visibility: "private", floor }), + ); +}); + +const passwordEnv = { + TERA_AUTH_MODE: "password", + TERA_AUTH_PASSWORD_USER: USER, + TERA_AUTH_PASSWORD_HASH: HASH_FROM_THE_DOCUMENTED_COMMAND, + TERA_AUTH_JWT_SECRET: SECRET, +}; + +function appWith(env: Record) { + const config = loadConfig({ TERA_OFFICES_DIR: dir, ...env }); + config.logLevel = "silent"; + return buildApp(config); +} + +function login(app: ReturnType, username: string, password: string) { + return app.inject({ + method: "POST", + url: "/api/v1/session", + payload: { username, password }, + }); +} + +/** The `Set-Cookie` value, reduced to what a browser would send back. */ +function cookiePair(setCookie: unknown): string { + const header = Array.isArray(setCookie) ? String(setCookie[0]) : String(setCookie); + return header.split(";")[0] ?? ""; +} + +describe("the login endpoint", () => { + it("accepts the right credentials and sets a locked-down cookie", async () => { + const app = appWith(passwordEnv); + after(() => app.close()); + + const res = await login(app, USER, PASSWORD); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), { + authenticated: true, + subject: USER, + passwordLogin: true, + }); + + const header = String(res.headers["set-cookie"]); + assert.match(header, /^tera_session=[^;]+;/); + assert.match(header, /HttpOnly/); + assert.match(header, /Secure/); + assert.match(header, /SameSite=Lax/); + assert.match(header, /Path=\//); + // Whatever else it is, it must never be a body a shared cache would keep. + assert.equal(res.headers["cache-control"], "private, no-store"); + }); + + it("rejects a wrong password and an unknown user identically", async () => { + const app = appWith(passwordEnv); + after(() => app.close()); + + const wrongPassword = await login(app, USER, "not the password"); + const unknownUser = await login(app, "someone-else", PASSWORD); + + assert.equal(wrongPassword.statusCode, 401); + assert.equal(unknownUser.statusCode, 401); + assert.deepEqual(wrongPassword.json(), unknownUser.json()); + // Nothing that could be used to tell the two apart, including a cookie that + // was set and immediately cleared. + assert.equal(wrongPassword.headers["set-cookie"], undefined); + assert.equal(unknownUser.headers["set-cookie"], undefined); + }); + + it("treats a malformed body as a failed attempt rather than a hint", async () => { + const app = appWith(passwordEnv); + after(() => app.close()); + + for (const payload of [{}, { username: USER }, { username: 1, password: 2 }]) { + const res = await app.inject({ method: "POST", url: "/api/v1/session", payload }); + assert.equal(res.statusCode, 401); + } + }); + + it("stops answering after too many failures from one address", async () => { + const app = appWith({ ...passwordEnv, TERA_AUTH_LOGIN_ATTEMPTS: "3" }); + after(() => app.close()); + + for (let i = 0; i < 3; i += 1) { + assert.equal((await login(app, USER, "wrong")).statusCode, 401); + } + + const limited = await login(app, USER, "wrong"); + assert.equal(limited.statusCode, 429); + assert.ok(Number(limited.headers["retry-after"]) > 0); + + // And the limit is not a way past the password: the right credentials are + // refused too while the window is open. + assert.equal((await login(app, USER, PASSWORD)).statusCode, 429); + }); +}); + +describe("a session cookie and a private office", () => { + it("opens the private office, then closes again once the session is cleared", async () => { + const app = appWith(passwordEnv); + after(() => app.close()); + + const before = await app.inject({ method: "GET", url: "/api/v1/offices/closed" }); + assert.equal(before.statusCode, 404); + + const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]); + const opened = await app.inject({ + method: "GET", + url: "/api/v1/offices/closed", + headers: { cookie }, + }); + assert.equal(opened.statusCode, 200); + assert.equal(opened.headers["cache-control"], "private, no-store"); + + const out = await app.inject({ method: "DELETE", url: "/api/v1/session", headers: { cookie } }); + assert.equal(out.statusCode, 200); + assert.equal(out.json().authenticated, false); + // The browser is told to drop it, and the same attributes are repeated so + // that the replacement actually matches the cookie it is replacing. + const cleared = String(out.headers["set-cookie"]); + assert.match(cleared, /^tera_session=;/); + assert.match(cleared, /Max-Age=0/); + assert.match(cleared, /HttpOnly/); + assert.match(cleared, /Secure/); + + // A browser that dropped the cookie is a browser with no session, and the + // office goes back to not existing. + const after_ = await app.inject({ method: "GET", url: "/api/v1/offices/closed" }); + assert.equal(after_.statusCode, 404); + }); + + it("reports the session state for the client to gate its UI", async () => { + const app = appWith(passwordEnv); + after(() => app.close()); + + const anonymous = await app.inject({ method: "GET", url: "/api/v1/session" }); + assert.deepEqual(anonymous.json(), { + authenticated: false, + subject: null, + passwordLogin: true, + }); + + const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]); + const signedIn = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } }); + assert.deepEqual(signedIn.json(), { + authenticated: true, + subject: USER, + passwordLogin: true, + }); + }); + + it("still keeps the token out of reach of a script", async () => { + const app = appWith(passwordEnv); + after(() => app.close()); + + const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]); + const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } }); + // The session body is what a page can read. It must not contain the bearer + // token the cookie is carrying. + assert.equal(state.body.includes(cookie.split("=")[1] ?? "never"), false); + }); +}); + +describe("deployments that cannot sign anyone in", () => { + it("leaves mode=none open and offers no login", async () => { + const app = appWith({}); + after(() => app.close()); + + assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/open" })).statusCode, 200); + + const state = await app.inject({ method: "GET", url: "/api/v1/session" }); + assert.deepEqual(state.json(), { + authenticated: false, + subject: null, + passwordLogin: false, + }); + + // Not "login is disabled" — the same 404 an unrouted path gets. + const attempt = await login(app, USER, PASSWORD); + const nowhere = await app.inject({ method: "POST", url: "/api/v1/no-such-thing" }); + assert.equal(attempt.statusCode, 404); + assert.deepEqual(attempt.json(), nowhere.json()); + }); + + it("demotes to mode=none when the hash is unreadable, and says so", async () => { + const config = loadConfig({ + TERA_OFFICES_DIR: dir, + ...passwordEnv, + TERA_AUTH_PASSWORD_HASH: "scrypt$notanumber$8$1$aaaa$bbbb", + }); + config.logLevel = "silent"; + const app = buildApp(config); + after(() => app.close()); + + assert.equal(config.auth.mode, "none"); + assert.equal(config.auth.passwordLogin, null); + assert.ok(config.degraded.some((line) => line.includes("TERA_AUTH_PASSWORD_HASH"))); + // The demotion runs towards closed: nobody gets the private office. + assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/closed" })).statusCode, 404); + assert.equal((await login(app, USER, PASSWORD)).statusCode, 404); + }); + + it("demotes when there is no secret to sign a session with", async () => { + const config = loadConfig({ + TERA_AUTH_MODE: "password", + TERA_AUTH_PASSWORD_USER: USER, + TERA_AUTH_PASSWORD_HASH: HASH_FROM_THE_DOCUMENTED_COMMAND, + }); + assert.equal(config.auth.mode, "none"); + assert.ok(config.degraded.some((line) => line.includes("TERA_AUTH_JWT_SECRET"))); + }); + + it("resolves password mode to jwt so there is one authorisation path", async () => { + const config = loadConfig(passwordEnv); + assert.equal(config.auth.mode, "jwt"); + assert.equal(config.auth.jwtVerify, "hs256"); + assert.equal(config.auth.passwordLogin?.username, USER); + }); +}); + +describe("the hash format", () => { + it("reads what the documented one-liner writes", async () => { + const parsed = parseScryptHash(HASH_FROM_THE_DOCUMENTED_COMMAND); + assert.notEqual(parsed, null); + assert.equal(parsed?.n, 16384); + assert.equal(parsed?.r, 8); + assert.equal(parsed?.p, 1); + assert.equal(await verifyPassword(parsed!, PASSWORD), true); + assert.equal(await verifyPassword(parsed!, "close but no"), false); + }); + + it("round-trips a freshly generated hash, with a different salt each time", async () => { + const first = await hashPassword(PASSWORD); + const second = await hashPassword(PASSWORD); + assert.notEqual(first, second); + + const parsed = parseScryptHash(first); + assert.notEqual(parsed, null); + assert.equal(await verifyPassword(parsed!, PASSWORD), true); + }); + + it("refuses anything it cannot verify against", () => { + for (const bad of [ + "", + "hunter2", + "bcrypt$16384$8$1$aaaa$bbbb", + "scrypt$16384$8$1$aaaa", + "scrypt$16383$8$1$c21d5m2wtoFIu4-rXO6mXA$aaaaaaaaaaaaaaaaaaaaaaaa", // N not a power of two + "scrypt$1073741824$8$1$c21d5m2wtoFIu4-rXO6mXA$aaaaaaaaaaaaaaaaaaaaaaaa", // absurd memory + "scrypt$16384$8$1$not base64!$aaaaaaaaaaaaaaaaaaaaaaaa", + "scrypt$16384$8$1$c21d5m2wtoFIu4-rXO6mXA$aa", // key too short to be a key + ]) { + assert.equal(parseScryptHash(bad), null, `${bad} must not parse`); + } + }); +}); diff --git a/src/adapters/README.md b/src/adapters/README.md new file mode 100644 index 0000000..3477a1c --- /dev/null +++ b/src/adapters/README.md @@ -0,0 +1,105 @@ +# Adapters + +Everything in `src/engine/` renders data and takes no position on what it means. +A `Marker` is a point with a `colorKey`; the engine looks that key up in a +palette the caller supplies and will never learn that `rejected` is red. A +`FlightSource` is an interface with a `poll()` on it. A `WeatherObservation` is +six numbers. + +This directory is where those become somebody's actual data. It is the only part +of the browser build that knows an API exists, and keeping it here is what lets +one renderer serve a private career map, a public sector map and whatever anyone +else builds without any of them being a fork. See ARCHITECTURE.md §3.3. + +| file | what it is | +| --- | --- | +| `sample.ts` | fabricated demo markers and flight routes, so a fresh clone has something on it | +| `http.ts` | the real adapter — the Tera API described in `src/server/wire.ts`, falling back to `sample.ts` | + +## The fallback is the product, not the safety net + +`http.ts` never throws and never leaves the map empty. No server, a 404, a +timeout, a static host answering `/api/v1/markers` with its own `index.html` — +all of it lands on the sample data, and the city keeps rendering. + +That is deliberate and it is the acceptance test the whole repo is held to: a +stranger clones this, runs one command, and gets a city, with no account, no key +and no network (CONTRACT.md §0). `npm run build` deployed to any static host is a +working Tera. Pointing it at a server is an upgrade. + +Every response carries `live: boolean` so the difference is visible to the app +above. An interface that shows invented companies exactly the way it shows real +ones is the one failure mode this arrangement can have, and the flag is there so +it does not have to happen. + +```ts +const tera = createTeraClient(); // same-origin /api/v1 +const markers = await tera.markers(); +const scene = createScene(canvas, { + city: SAN_FRANCISCO, + markerPalette: markers.palette, + flights: tera.flights(), +}); +scene.setMarkers(markers.value); +if (!markers.live) showSampleDataNotice(); +``` + +Note the ordering: `markerPalette` is fixed when the scene is built, so the +markers have to be awaited first. `markers.palette` is the sample palette when +the feed is the sample set and the palette you passed in `TeraApiOptions` when it +is real — the sample keys are not your keys. + +## No real company data ships in this repo + +Two separate constraints want the same thing here, which is the reason this +arrangement is worth the indirection rather than just committing a JSON file. + +**Privacy.** Pipeline status — who is talking to whom, and who said no — is +private. A public repo is the wrong place for it. + +**Licence, which is the sharper one.** Real positions are geocoded, and a +geocoder built on OpenStreetMap returns ODbL data. ODbL is share-alike, and +serving a snapshot of those coordinates from a public endpoint is *Publicly +Using a Derivative Database* — §4.3 attribution and §4.4 share-alike attach to +the served data whether or not the rows live in the repo. Keeping the table +off-disk hides that obligation; it does not discharge it. See ARCHITECTURE.md +§3.2 and the correction in CONTRACT.md §8. + +So the rule is about the geocoder, not about storage: + +- Geography in `src/cities/` is **traced by hand**. Original expression, ours, + Apache-2.0. Never OSM, never Nominatim. +- Real markers arrive over the API at runtime, each carrying a + `CoordinateProvenance`, and the server refuses any row whose provenance is not + on a non-ODbL allowlist — `us-census`, `hand-placed`, `synthetic`. +- `http.ts` passes `refused` straight through rather than swallowing it, because + a gate that drops rows silently is indistinguishable from an empty database. + It does **not** re-run the gate in the browser: the allowlist is the server's, + and a self-hoster who added their own provenance value should not watch their + own rows vanish client-side. +- Everything in `sample.ts` is invented. The companies do not exist, the + positions were typed by hand from a general sense of where San Francisco's + neighbourhoods are, and the names are absurd on purpose so that none of them + can be mistaken for a real business. + +## Writing your own + +`http.ts` is one adapter, not the adapter. Anything that can produce +`Marker[]`, a `WeatherObservation` and a `FlightSource` is one — a local JSON +file, a Postgres query through your own backend, an SDR on the windowsill. The +engine imports nothing from this directory, so an adapter can be deleted, forked +or replaced without touching a line of the renderer. + +Two things to keep if you write one: + +**Traffic must not block the render loop.** `poll()` may be synchronous, and +`HttpFlights` is: it answers from whatever is in hand and refreshes on the +body's own TTL in the background. A `poll()` that awaits a slow fetch puts a +frame behind a round trip. + +**FlightRadar24 is not an option.** Their terms forbid scraping and forbid +redistributing the data, so an Apache-2.0 repo containing an FR24 client would be +publishing instructions for breaking a ToS and shipping data it has no right to +relicense. `src/engine/flights.ts` ships a simulator and points at the open +community ADS-B feeds instead; anything commercial belongs in an adapter in a +private deployment. See ARCHITECTURE.md §4. diff --git a/src/adapters/http.ts b/src/adapters/http.ts new file mode 100644 index 0000000..962e497 --- /dev/null +++ b/src/adapters/http.ts @@ -0,0 +1,333 @@ +/** + * The real adapter: markers, weather and traffic from the Tera API, with the + * bundled sample data underneath it. + * + * This is the one place in the browser build that knows the API exists. The + * engine takes `Marker[]`, a `WeatherObservation` and a `FlightSource` and has + * no idea where any of them came from — that boundary is what lets one renderer + * serve a private career map and a public sector map without either being a + * fork (ARCHITECTURE.md §3.3), and it is why this file is an adapter rather than + * a client scattered through the scene. + * + * **Every call degrades instead of failing.** No server, a 404, a static host + * answering `/api/v1/markers` with its own index.html, a network that has gone + * away mid-session: all of it lands on the sample data in `sample.ts` and the + * synthetic clear day below, and the map keeps rendering. That is not defensive + * habit, it is the acceptance test the whole repo is held to — a stranger clones + * this, runs one command, and gets a city, with no account, no key and no + * network (CONTRACT.md §0). A `npm run build` deployed to any static host is a + * working Tera; pointing it at a server is an upgrade, not a requirement. + * + * The wire types live in `src/server/wire.ts` and are types only, so importing + * them costs the bundle nothing. + */ + +import type { WeatherObservation } from "../engine/atmosphere.ts"; +import { sampleRoute, SimulatedFlights, type SimRoute } from "../engine/flights.ts"; +import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts"; +import { seededRandom } from "../engine/world.ts"; +import type { + FlightsBody, + FlightsPlanBody, + HealthBody, + MarkersBody, + OfficeDoc, + WeatherBody, +} from "../server/wire.ts"; +import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./sample.ts"; + +/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */ +const DEFAULT_BASE = "/api/v1"; + +/** How long any one request may take before the fallback is used instead. */ +const DEFAULT_TIMEOUT_MS = 4000; + +/** How long to wait before trying the flights endpoint again after it fails. */ +const RETRY_SECONDS = 30; + +export interface TeraApiOptions { + /** + * Base URL, with no trailing slash. Absolute is allowed and is what a + * self-hoster running the browser build and the API on different origins + * wants; the default assumes they are the same origin. + */ + base?: string; + /** Injected for tests. Absent means `globalThis.fetch`. */ + fetch?: typeof fetch; + timeoutMs?: number; + /** + * The palette live markers are coloured by. + * + * The engine looks `colorKey` up and the wire does not carry colours, so + * somebody has to supply this and it cannot be the server: what a key *means* + * is the consuming app's business. When the API is absent this is ignored and + * `SAMPLE_PALETTE` is used instead, because sample keys are not the caller's + * keys. + */ + palette?: MarkerPalette; +} + +/** + * A response, plus whether it is real. + * + * `live` is the field that stops a fallback from being a lie. A demo showing + * invented companies and a deployment showing real ones must not look identical + * to the code above them — the caller is expected to say so in the interface, + * and cannot if the adapter quietly papers over the difference. + */ +export interface Feed { + value: T; + live: boolean; +} + +export interface MarkerFeed extends Feed { + /** The palette these markers are meant to be read with. */ + palette: MarkerPalette; + /** ISO-8601 snapshot time, or `null` for the sample set, which has no date. */ + generatedAt: string | null; + /** + * Rows the server's public-shape gate refused, by reason and count. + * + * Passed through rather than swallowed. A gate that drops rows silently is + * indistinguishable from an empty database, which is exactly the confusion + * `MarkersBody.refused` exists to prevent — and it is the visible end of the + * provenance rule in CONTRACT.md §8. + */ + refused: { reason: string; count: number }[]; + attribution: string[]; +} + +export interface WeatherFeed extends Feed { + attribution: string[]; +} + +export interface TeraClient { + /** What the deployment turned out to be, or `null` if there is no server. */ + health(): Promise; + markers(): Promise; + weather(): Promise; + /** + * The traffic source, built once. It fetches on its own schedule and never + * blocks the render loop; see `HttpFlights`. + */ + flights(): FlightSource; + /** + * One office pack. `null` for anything the server will not serve — including + * a private one, which answers 404 rather than 403 so the endpoint cannot be + * used to enumerate what exists (CONTRACT.md §6). + * + * There is deliberately no fallback here. A missing marker can be stood in for + * by a fictional one; a missing floorplan cannot be invented, and an app with + * a bundled office of its own already has the better answer. + */ + office(id: string): Promise; +} + +export function createTeraClient(options: TeraApiOptions = {}): TeraClient { + const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, ""); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis); + + /** + * One GET, and `null` for every way it can go wrong. + * + * Deliberately undiscriminating. A 404, a timeout, a CORS refusal, a static + * host serving `index.html` with a 200 and an HTML content type — the caller's + * response to all of them is the same, and a taxonomy of failures nobody + * branches on is a taxonomy nobody maintains. + */ + async function get(path: string): Promise { + if (!doFetch) return null; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await doFetch(`${base}${path}`, { + signal: controller.signal, + headers: { accept: "application/json" }, + }); + if (!res.ok) return null; + // Checked rather than trusted: a static host answers an unknown path with + // the SPA shell and a 200, and `res.json()` on HTML throws where a content + // type check just returns. + const type = res.headers.get("content-type") ?? ""; + if (!type.includes("json")) return null; + return (await res.json()) as T; + } catch { + return null; + } finally { + clearTimeout(timer); + } + } + + let flightSource: FlightSource | null = null; + + return { + health: () => get("/health"), + + async markers(): Promise { + const body = await get("/markers"); + if (!body || !Array.isArray(body.markers)) return sampleMarkerFeed(); + return { + // A `WireMarker` *is* a `Marker` with a provenance field on it, so this + // is a widening and not a translation — which is the property + // `wire.ts` chose the shape for. The provenance itself is the server's + // to enforce and is deliberately not re-checked here: a browser + // silently dropping rows a self-hoster explicitly allowlisted would look + // exactly like an empty database. + value: body.markers, + live: true, + palette: options.palette ?? {}, + generatedAt: body.generatedAt ?? null, + refused: body.refused ?? [], + attribution: body.attribution ?? [], + }; + }, + + async weather(): Promise { + const body = await get("/weather"); + if (!body) return { value: CLEAR_DAY, live: false, attribution: [] }; + // `WeatherBody` is structurally a `WeatherObservation` plus fields no + // renderer reads, which `atmosphere.ts` says in as many words. The extra + // fields ride along harmlessly and the engine never sees them. + return { value: body, live: !body.synthetic, attribution: body.attribution ?? [] }; + }, + + flights(): FlightSource { + flightSource ??= new HttpFlights(get, SAMPLE_ROUTES); + return flightSource; + }, + + office: (id) => get(`/offices/${encodeURIComponent(id)}`), + }; +} + +/** + * The clear day a zero-config box serves, restated in the browser. + * + * The server does this too — a weather source configured without what it needs + * is demoted rather than fatal, and it answers `synthetic: true` forever + * (CONTRACT.md §5.1). This is the same answer for the case where there is no + * server at all. Note what it does *not* do: `visibilityKm` stays null, which + * `atmosphere.ts` reads as "nobody measured" rather than as "unlimited", so San + * Francisco's marine layer still runs off its own climatology instead of being + * overruled by a fact nobody observed. + */ +const CLEAR_DAY: WeatherObservation = { + cloudCover: 0.1, + precipitation: 0, + visibilityKm: null, + windKph: null, + windDirDeg: null, + condition: "clear", +}; + +function sampleMarkerFeed(): MarkerFeed { + return { + value: SAMPLE_MARKERS, + live: false, + palette: SAMPLE_PALETTE, + generatedAt: null, + refused: [], + attribution: [], + }; +} + +// ---- Traffic -------------------------------------------------------------- + +/** + * Traffic over HTTP, in whichever of the two shapes the server chose. + * + * `poll()` is synchronous and never awaits the network, which is the whole + * design. `scene.ts` calls it from the render loop, and a source that returned a + * promise resolving on a slow fetch would put a frame's aircraft update behind a + * round trip; instead the network runs in the background on the body's own TTL + * and `poll` answers from whatever is currently in hand. + * + * The two modes are not symmetrical, and `wire.ts` explains why. A *plan* — the + * simulator's routes, a fixed epoch and a seed — is evaluated locally at one + * request per TTL, and because the epoch is fixed rather than the server's start + * time, two people on different machines see the same aircraft in the same + * places. *Live* traffic has no closed form, so it arrives as positions and is + * refetched. + * + * Until the first response lands, and after any failure, this is the simulator + * over `SAMPLE_ROUTES`. An empty sky is a worse answer than an invented one, and + * the invented one is labelled as such in `sample.ts`. + */ +class HttpFlights implements FlightSource { + /** + * One second, which is the *evaluation* cadence and not the request cadence. + * A plan is arithmetic and wants to be evaluated every frame or close to it; + * the network is on `nextFetchAt` and is a great deal slower. + */ + readonly interval = 1; + + private readonly fallback: SimulatedFlights; + private plan: FlightsPlanBody | null = null; + private planPhase: number[] = []; + private live: Aircraft[] | null = null; + private nextFetchAt = 0; + private fetching = false; + + constructor( + private readonly get: (path: string) => Promise, + fallbackRoutes: SimRoute[], + ) { + this.fallback = new SimulatedFlights(fallbackRoutes); + } + + poll(): Aircraft[] { + this.refreshIfStale(); + if (this.plan) return evaluatePlan(this.plan, this.planPhase, Date.now()); + if (this.live) return this.live; + return this.fallback.poll(); + } + + private refreshIfStale(): void { + const now = Date.now(); + if (this.fetching || now < this.nextFetchAt) return; + this.fetching = true; + void this.get("/flights") + .then((body) => { + if (!body) { + // Hold whatever was already in hand rather than reverting to the + // simulator: a deployment that has been showing real traffic for an + // hour and drops one request should keep showing it, slightly stale, + // not silently swap in fiction. + this.nextFetchAt = now + RETRY_SECONDS * 1000; + return; + } + if (body.mode === "plan") { + this.plan = body; + this.planPhase = phasesFor(body); + this.live = null; + } else { + this.live = body.aircraft; + this.plan = null; + } + this.nextFetchAt = now + Math.max(1, body.ttlSeconds) * 1000; + }) + .finally(() => { + this.fetching = false; + }); + } +} + +/** + * The per-route phase offsets, from the seed the server sent. + * + * Same generator and same order as `SimulatedFlights`, which is what makes the + * server's promise true: every viewer draws the seed once, in route order, and + * arrives at the same sky. + */ +function phasesFor(plan: FlightsPlanBody): number[] { + const rand = seededRandom(plan.seed); + return plan.routes.map(() => rand()); +} + +function evaluatePlan(plan: FlightsPlanBody, phase: number[], nowMs: number): Aircraft[] { + const seconds = (nowMs - plan.t0) / 1000; + // `WireSimRoute` is structurally `SimRoute`; the restatement in `wire.ts` is + // there so the server can build one without importing three.js. + return plan.routes.map((route, i) => sampleRoute(route, seconds / route.duration + (phase[i] ?? 0))); +} diff --git a/src/adapters/sample.ts b/src/adapters/sample.ts new file mode 100644 index 0000000..d6a0048 --- /dev/null +++ b/src/adapters/sample.ts @@ -0,0 +1,308 @@ +/** + * Fabricated demo data, so that a clone of this repo has something on it. + * + * **Everything in this file is invented.** The companies do not exist, have + * never existed, and are named the way they are — Wobbegong, Nonsuch, Pennyfarthing + * — specifically so that nobody can mistake one for a real business. The + * coordinates were typed by hand from a general sense of where San Francisco's + * neighbourhoods are; none of them is anybody's address, and none of them came + * out of a geocoder. + * + * That last point is the licence rule and not a stylistic preference. No real + * company data ships in this repo, for two reasons that happen to want the same + * thing. The privacy one is obvious: pipeline status — who is talking to whom, + * who said no — is private, and a public repo is the wrong place for it. The + * licence one is sharper and is the subject of ARCHITECTURE.md §3.2 and + * CONTRACT.md §8: real positions are *geocoded*, and a geocoder built on + * OpenStreetMap returns ODbL data. Serving a snapshot of those coordinates is + * Publicly Using a Derivative Database, which drags share-alike onto everything + * served alongside it, whether or not the rows live in the repo. So real markers + * arrive at runtime over the API — see `http.ts` — carrying a provenance field + * the server checks, and the repo itself ships this: fiction, which owes nobody + * anything. + * + * The demo is worth having anyway. A map with no pins on it teaches nobody what + * the thing is for, and half the interesting behaviour in `markers.ts` — the + * ghost treatment for a marker that has no real position yet — is invisible + * without data that exercises it. + */ + +import type { SimRoute } from "../engine/flights.ts"; +import type { Marker, MarkerPalette } from "../engine/types.ts"; + +/** + * A small pipeline, as colours. + * + * Five states is about the fewest that still shows why `colorKey` is opaque to + * the engine: none of these words means anything to `markers.ts`, which looks + * the key up here and draws whatever it finds. A public sector map would supply + * an entirely different set against the same renderer. + */ +export const SAMPLE_PALETTE: MarkerPalette = { + watching: 0x7f8b99, + applied: 0x4f9cf2, + talking: 0x3fbf9a, + offer: 0xf2b134, + closed: 0xd2544f, +}; + +/** + * Where the unplaced markers float: out over the bay, east of the Ferry + * Building, in a short arc. + * + * A marker with `located: false` has no address yet, and the honest thing to do + * with a position you do not have is to not pretend you have one. Open water is + * the clearest way to say that on a map — nothing is there, the pin visibly + * stands on nothing, and it cannot be misread as a building. `markers.ts` gives + * these a different silhouette and a lower opacity as well, so the tell does not + * rest on placement alone. + */ +const UNPLACED_LNG = -122.3665; + +/** + * Twenty-two invented companies across San Francisco. + * + * Placed by neighbourhood rather than by street: Hayes Valley and SoMa are + * crowded because that is the fact the map is usually drawing attention to, the + * Bayview and the Richmond have one each, and three have no position at all. + * The clustering is the point — a marker layer that only ever gets evenly + * scattered test data hides every overlap problem it has. + */ +export const SAMPLE_MARKERS: Marker[] = [ + // Hayes Valley and the bowl below Buena Vista. + { + id: "sample-thimbleway", + label: "Thimbleway Systems", + colorKey: "talking", + lat: 37.7768, + lng: -122.4243, + located: true, + blurb: "Invented. Model-serving, allegedly, in a Victorian with bad wiring.", + }, + { + id: "sample-nonsuch", + label: "Nonsuch Cartography Co.", + colorKey: "applied", + lat: 37.7752, + lng: -122.4262, + located: true, + blurb: "Invented. Maps of places that are not there.", + }, + { + id: "sample-marmalade", + label: "Marmalade Interchange", + colorKey: "watching", + lat: 37.7781, + lng: -122.4218, + located: true, + blurb: "Invented. Moves data between two formats nobody uses.", + }, + + // SoMa, where the grid turns forty-six degrees. + { + id: "sample-kettle-anvil", + label: "Kettle & Anvil Compute", + colorKey: "offer", + lat: 37.7805, + lng: -122.4051, + located: true, + blurb: "Invented. Sells the shovels, or claims to.", + }, + { + id: "sample-ninth-pelican", + label: "Ninth Pelican Labs", + colorKey: "applied", + lat: 37.7784, + lng: -122.4009, + located: true, + blurb: "Invented. There were never eight others.", + }, + { + id: "sample-brassbound", + label: "Brassbound Telemetry", + colorKey: "closed", + lat: 37.7822, + lng: -122.4074, + located: true, + blurb: "Invented. Went quiet after the second call.", + }, + + // The Financial District and Jackson Square. + { + id: "sample-grimsby-doone", + label: "Grimsby & Doone Photonics", + colorKey: "watching", + lat: 37.7941, + lng: -122.4008, + located: true, + blurb: "Invented. Two surnames and a laser.", + }, + { + id: "sample-tugboat", + label: "Tugboat Actuarial", + colorKey: "applied", + lat: 37.7958, + lng: -122.4032, + located: true, + blurb: "Invented. Insurance for things that have already happened.", + }, + + // Mission Bay: landfill, then biotech. + { + id: "sample-fogbank", + label: "Fogbank Freight", + colorKey: "talking", + lat: 37.7709, + lng: -122.3918, + located: true, + blurb: "Invented. Logistics, in a building younger than most of the staff.", + }, + { + id: "sample-bittern", + label: "Bittern & Sons Biologics", + colorKey: "watching", + lat: 37.7688, + lng: -122.3894, + located: true, + blurb: "Invented. No sons.", + }, + + // The Mission, flat and sunny. + { + id: "sample-unlikely-weather", + label: "Bureau of Unlikely Weather", + colorKey: "offer", + lat: 37.7602, + lng: -122.4151, + located: true, + blurb: "Invented. Forecasts nobody asked for.", + }, + { + id: "sample-perpetual-bagel", + label: "Perpetual Bagel Works", + colorKey: "closed", + lat: 37.7574, + lng: -122.4192, + located: true, + blurb: "Invented. The name was the whole pitch.", + }, + + // Potrero Hill and Dogpatch, the old industrial edge. + { + id: "sample-wobbegong", + label: "Wobbegong Robotics", + colorKey: "talking", + lat: 37.7589, + lng: -122.4002, + located: true, + blurb: "Invented. Named after a carpet shark, for reasons never explained.", + }, + { + id: "sample-sourdough-semi", + label: "Sourdough Semiconductor", + colorKey: "applied", + lat: 37.7597, + lng: -122.3881, + located: true, + blurb: "Invented. A fab in a city with no fabs.", + }, + { + id: "sample-pennyfarthing", + label: "Pennyfarthing Power", + colorKey: "watching", + lat: 37.7564, + lng: -122.3973, + located: true, + blurb: "Invented. Batteries, uphill.", + }, + + // The north side and the hills. + { + id: "sample-lamplighter", + label: "Lamplighter Aerostatics", + colorKey: "watching", + lat: 37.8004, + lng: -122.4086, + located: true, + blurb: "Invented. Airships, which are always about to come back.", + }, + { + id: "sample-halfpenny", + label: "Halfpenny Optics", + colorKey: "applied", + lat: 37.7929, + lng: -122.4147, + located: true, + blurb: "Invented. Lenses, four hundred feet above the water they look at.", + }, + + // One each in the parts of the city the map usually forgets. + { + id: "sample-cormorant", + label: "Cormorant Freight Systems", + colorKey: "talking", + lat: 37.7357, + lng: -122.3908, + located: true, + blurb: "Invented. The only pin south of Islais Creek, which is the point.", + }, + { + id: "sample-tidewrack", + label: "Tidewrack Instruments", + colorKey: "watching", + lat: 37.7802, + lng: -122.4638, + located: true, + blurb: "Invented. Sensors, in the fog, on purpose.", + }, + + // Three with no position yet. See `UNPLACED_LNG`. + { + id: "sample-quibble", + label: "Quibble Quantum", + colorKey: "applied", + lat: 37.7965, + lng: UNPLACED_LNG, + located: false, + blurb: "Invented, and unplaced: no address on file, so the map does not invent one.", + }, + { + id: "sample-antelope-foundry", + label: "Antelope Foundry", + colorKey: "watching", + lat: 37.7905, + lng: UNPLACED_LNG, + located: false, + blurb: "Invented, and unplaced.", + }, + { + id: "sample-mudlark", + label: "Mudlark Instruments", + colorKey: "closed", + lat: 37.7845, + lng: UNPLACED_LNG, + located: false, + blurb: "Invented, and unplaced.", + }, +]; + +/** + * Sample traffic, for when the API is not there to send a flight plan. + * + * The corridors are roughly the real ones — arrivals down the peninsula from + * the north, departures turning out over the Pacific, a slow light aircraft + * crossing the bay — because that is what makes the sky read as this city's sky + * rather than as random motion. The callsigns are not: no real operator uses + * these prefixes, which keeps a demo from looking like a feed of actual + * traffic. Nothing here is observed, and `flights.ts` explains at length why + * this project ships a simulator instead of a client for somebody's live data. + */ +export const SAMPLE_ROUTES: SimRoute[] = [ + { callsign: "NIMBUS 4", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 }, + { callsign: "NIMBUS 17", from: [37.93, -122.31], to: [37.65, -122.38], fromAlt: 2100, toAlt: 450, duration: 210 }, + { callsign: "PELICAN 2", from: [37.64, -122.39], to: [37.9, -122.62], fromAlt: 700, toAlt: 5200, duration: 165 }, + { callsign: "PELICAN 31", from: [37.7, -122.21], to: [37.88, -122.55], fromAlt: 1800, toAlt: 6100, duration: 230 }, + { callsign: "CORMORANT 8", from: [37.62, -122.6], to: [37.95, -122.28], fromAlt: 6800, toAlt: 8200, duration: 260 }, + { callsign: "KESTREL 5", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 }, + { callsign: "NIMBUS 40", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 }, +]; diff --git a/src/cities/sf.ts b/src/cities/sf.ts index ce8220c..99c7914 100644 --- a/src/cities/sf.ts +++ b/src/cities/sf.ts @@ -17,6 +17,31 @@ * the wall the Sunset hides behind, Potrero and Bernal are why the Mission * feels like a floor, and the AI cluster in Hayes Valley sits in the bowl below * all of it. A flat San Francisco is a street grid on a napkin. + * + * ## The pack is now the whole Bay Area + * + * It began as the seven-by-seven city with the Peninsula as a distant chapter + * the camera pulled back over, and the pull-back was over empty water, because + * there was nothing south of the county line to look at. There is now: the + * Peninsula down to San Jose, the South Bay, the East Bay from Richmond to + * Fremont, and Marin north to San Rafael with Mount Tamalpais on it. + * + * Everything added here is **additive**. Not one San Francisco coordinate moved + * — the city renders exactly as it did — and where a new landmass meets an old + * one it retraces the old one's vertices rather than being drawn alongside it. + * See `PENINSULA` and `MARIN_NORTH` for why that matters more than it sounds. + * + * ## This pack requires the focus-region lattice + * + * The map is now 0.85° × 0.89°, about twenty times the area it was. At the + * ~45 m cells San Francisco is built at, a uniform lattice over that is 3.7M + * points and roughly eleven seconds of build — which is the failure + * ARCHITECTURE.md §5 predicted for Los Angeles, arriving early. So this pack + * declares `coarseFactor` and three `focusRegions`, and **it is not loadable + * until `World.buildField` honours them**: San Francisco and the two places a + * chapter puts the camera on the ground stay at 45 m, and the ~90 km of bay + * between them is built at 270 m, where nothing is lost because there is + * nothing there but water and the flanks of two mountain ranges. */ import type { Bridge, City, District, Hill, Landmark, LatLng } from "../engine/types.ts"; @@ -102,10 +127,98 @@ export const SAN_FRANCISCO: LatLng[] = [ [37.805, -122.479], ]; +/** + * Everything on the west and south sides of the bay: the Peninsula from the + * county line down through San Mateo and Palo Alto, and the Santa Clara Valley + * round the bottom of the bay to the hills east of San Jose. + * + * One polygon rather than a chain of towns, because it is one piece of ground — + * the bay is the hole in it, not the thing that divides it. The name is a + * stretch by the time it reaches Alum Rock and it is kept anyway; splitting the + * land into a "peninsula" and a "south bay" would only invent a border that + * nobody standing on it could find. + * + * The first six points and the last six are **not free coordinates**. They are + * `SAN_FRANCISCO`'s southern boundary, retraced vertex for vertex in reverse, + * so the two shore plates tile exactly along the county line. Anything else + * leaves either a sliver of open water down the middle of Daly City or two + * coplanar plates fighting for the same pixels at y=0. + */ +export const PENINSULA: LatLng[] = [ + // Bay shore, north -> south, from the county line at Candlestick. + [37.708, -122.398], + [37.7, -122.39], // Sierra Point, Brisbane + [37.69, -122.386], // Oyster Point + [37.678, -122.383], // Point San Bruno + [37.665, -122.393], + [37.652, -122.39], // South San Francisco, north of the field + [37.643, -122.377], // where the airport's fill begins + [37.64, -122.358], // SFO, northeast corner + [37.623, -122.354], // SFO's bay edge — the runways are built out onto the mud + [37.607, -122.357], // SFO, southeast corner + [37.598, -122.345], // Burlingame + [37.59, -122.33], // Coyote Point + [37.582, -122.318], // Seal Point, San Mateo + [37.578, -122.27], // Foster City, the north edge of the fill + [37.573, -122.25], // San Mateo Bridge, west toe + [37.558, -122.245], + [37.545, -122.24], // Redwood Shores + [37.535, -122.215], // Bair Island and the port + [37.522, -122.19], // Redwood City + [37.51, -122.17], // Menlo Park bayfront + [37.498, -122.152], + [37.487, -122.14], // Dumbarton, west toe + [37.472, -122.115], // the Palo Alto baylands + [37.462, -122.095], + [37.45, -122.07], // Shoreline, Mountain View + [37.44, -122.04], // Moffett Field + [37.432, -122.01], // the Sunnyvale marsh + [37.428, -121.975], // the mouth of the Guadalupe, at Alviso + [37.435, -121.945], // the bottom of the bay + // The next three are shared with EAST_BAY, reversed, for the same reason the + // county line is shared with the city. + [37.443, -121.925], // Milpitas, where the East Bay shore takes over + [37.42, -121.9], + [37.38, -121.845], // the Alum Rock hills + // South and west along the frame, under Mount Hamilton and over the range. + [37.395, -121.75], + [37.185, -121.75], + [37.185, -122.0], + [37.19, -122.33], // the coast again, north of Año Nuevo + // Pacific shore, south -> north. Empty for fifty miles, which is the whole + // point of it: the range comes down to the water and nobody built here. + [37.23, -122.36], + [37.27, -122.4], // Pescadero + [37.32, -122.41], // San Gregorio + [37.375, -122.42], // Tunitas + [37.43, -122.435], + [37.47, -122.445], // Half Moon Bay + [37.5, -122.47], // Pillar Point + [37.535, -122.512], // Montara + [37.565, -122.518], // Devil's Slide + [37.598, -122.508], // Pacifica + [37.63, -122.5], // Sharp Park + [37.66, -122.494], // Mussel Rock + [37.69, -122.5], // Daly City + [37.706, -122.504], + // SAN_FRANCISCO's county line, retraced in reverse. + [37.713, -122.506], + [37.711, -122.498], + [37.71, -122.48], + [37.709, -122.455], + [37.7085, -122.43], + [37.708, -122.41], +]; + /** * The Marin Headlands across the Golden Gate. Present for context and for the * bridge to land on — the north side of the Gate is most of what you see from * any camera pointed at the bridge, and without it the span ends in fog. + * + * Its inland edge, the last five points, was a frame cut rather than a + * coastline: it is where the map used to stop. `MARIN_NORTH` picks those five + * up vertex for vertex and carries on, so the cut is now an interior seam + * between two plates that meet exactly. */ export const MARIN: LatLng[] = [ [37.829, -122.532], // Point Bonita @@ -130,23 +243,105 @@ export const MARIN: LatLng[] = [ ]; /** - * The East Bay shore — Oakland, Emeryville, Berkeley. Only the water-facing - * edge is traced; the interior runs off the frame. + * Marin proper: Tiburon and Mill Valley, Corte Madera and San Rafael on the bay + * side, and the Tamalpais massif dropping straight into the Pacific on the + * other. + * + * Richardson Bay is deliberately left open — the water between Sausalito and + * Strawberry is a real inlet and closing it would weld Tiburon to the + * headlands. The two plates only touch at its head, which is also the only + * place the ground is continuous. + */ +export const MARIN_NORTH: LatLng[] = [ + [37.874, -122.4945], // the head of Richardson Bay, shared with MARIN + [37.882, -122.492], + [37.886, -122.477], // Strawberry + [37.878, -122.463], // Strawberry Point + [37.87, -122.452], // Belvedere and Tiburon + [37.888, -122.448], + [37.905, -122.462], // Paradise Cay + [37.92, -122.492], // Corte Madera + [37.935, -122.487], // Larkspur Landing + [37.945, -122.481], // Point San Quentin — the Richmond bridge lands here + [37.958, -122.487], // San Rafael, up its creek + [37.972, -122.47], + [37.99, -122.445], // Point San Pedro and China Camp + [38.005, -122.47], + [38.025, -122.52], // Terra Linda, at the north frame + [38.028, -122.6], + [38.01, -122.635], + // The Pacific side. Bolinas Ridge and Tamalpais come down to the sea, which + // is why there is no road along this coast and no town on it. + [37.965, -122.64], + [37.93, -122.638], + [37.905, -122.63], // Stinson Beach + [37.885, -122.605], // Muir Woods, the coast turning southeast + [37.865, -122.588], + [37.845, -122.57], // Muir Beach + // MARIN's inland edge, retraced in reverse. + [37.8335, -122.5405], + [37.8425, -122.5395], + [37.855, -122.5335], + [37.8685, -122.5255], + [37.8765, -122.5125], +]; + +/** + * The East Bay: Fremont round to Richmond, and the Diablo country behind the + * ridge. + * + * This replaces a twelve-point stub whose "inland" edge was two points at the + * frame — enough to land the Bay Bridge on and nothing else. The shore is now + * traced properly, including the two things about it that read from the air: + * the Oakland field out on its spit, and the way the whole east side runs + * dead straight from Hayward to Richmond because it is diked salt marsh rather + * than a coastline. + * + * Everything east of the ridge is genuinely land — the Diablo valley — so the + * polygon runs to the frame rather than stopping at the crest and leaving a + * cliff into nothing. */ export const EAST_BAY: LatLng[] = [ - [37.73, -122.32], - [37.745, -122.31], - [37.76, -122.3], - [37.775, -122.29], // Alameda - [37.79, -122.28], // Port of Oakland - [37.805, -122.29], // Jack London Square - [37.82, -122.3], - [37.835, -122.305], // Emeryville - [37.85, -122.31], - [37.87, -122.315], // Berkeley Marina - // frame edges, inland - [37.88, -122.24], - [37.72, -122.22], + [37.443, -121.925], // Milpitas, shared with PENINSULA + [37.462, -121.965], // Coyote Creek + [37.485, -122.01], // Warm Springs + [37.5, -122.05], + [37.512, -122.082], // Dumbarton, east toe + [37.535, -122.108], // Newark + [37.562, -122.128], // Coyote Hills + [37.598, -122.14], // the Hayward marsh + [37.628, -122.128], // San Mateo Bridge, east toe + [37.655, -122.152], // San Lorenzo + [37.69, -122.185], // San Leandro + [37.706, -122.205], + [37.718, -122.228], // the spit the Oakland field's long runway sits on + [37.732, -122.238], + [37.738, -122.226], + [37.745, -122.222], // Bay Farm + [37.757, -122.252], // Alameda, south shore + [37.775, -122.305], // Alameda Point + [37.795, -122.33], // the outer harbour + [37.818, -122.333], // the mole the Bay Bridge comes down on + [37.84, -122.32], // Emeryville + [37.858, -122.318], // the Berkeley Marina + [37.87, -122.322], + [37.885, -122.335], // the Albany Bulb + [37.905, -122.345], // Point Isabel + [37.918, -122.368], // Point Richmond + [37.93, -122.39], // Richmond–San Rafael Bridge, east toe + [37.945, -122.378], // the inner harbour + [37.962, -122.395], + [37.978, -122.425], // Point San Pablo + // San Pablo Bay's south shore, west -> east. + [37.995, -122.37], + [38.01, -122.31], // Pinole + [38.025, -122.26], // Rodeo + // The frame, and back down to Milpitas along the boundary PENINSULA shares. + [38.03, -122.2], + [38.03, -121.75], + [37.395, -121.75], + [37.38, -121.845], + [37.42, -121.9], ]; export const ALCATRAZ: LatLng[] = [ @@ -186,7 +381,7 @@ export const ANGEL_ISLAND: LatLng[] = [ export const ISLANDS = [ALCATRAZ, YERBA_BUENA, TREASURE_ISLAND, ANGEL_ISLAND]; /** Everything the buildings may stand on. */ -export const LANDMASSES = [SAN_FRANCISCO, MARIN, EAST_BAY, ...ISLANDS]; +export const LANDMASSES = [SAN_FRANCISCO, PENINSULA, MARIN, MARIN_NORTH, EAST_BAY, ...ISLANDS]; // ---- Parks and open space ------------------------------------------------- @@ -274,6 +469,136 @@ export const GLEN_CANYON: LatLng[] = [ [37.7385, -122.4485], ]; +// ---- Open space beyond the city ------------------------------------------- + +/** + * The greenbelt is the reason the Bay Area looks the way it does from above, + * and it is not decoration: the watershed lands, the regional park districts + * and the ridge preserves are why fifteen contiguous cities on the Peninsula + * stop dead at the same contour line instead of running over the top. + * + * Traced coarsely on purpose. These are large, soft-edged holdings, and at the + * 270 m cells the bay is built at, four points is as much shape as survives. + */ + +/** San Bruno Mountain, the wall between the city and the Peninsula. */ +export const SAN_BRUNO_MOUNTAIN: LatLng[] = [ + [37.702, -122.452], + [37.699, -122.412], + [37.676, -122.418], + [37.679, -122.458], +]; + +/** Sweeney Ridge and the Montara hills, above Pacifica. */ +export const SWEENEY_RIDGE: LatLng[] = [ + [37.625, -122.482], + [37.62, -122.432], + [37.545, -122.475], + [37.552, -122.512], +]; + +/** + * The San Francisco watershed: Crystal Springs, San Andreas Lake, and the + * Cahill Ridge above them. Closed to the public since 1930 and visibly + * untouched, which is why the west side of the Peninsula reads as one + * unbroken green band from the air. + */ +export const CRYSTAL_SPRINGS_WATERSHED: LatLng[] = [ + [37.6, -122.44], + [37.594, -122.375], + [37.49, -122.325], + [37.5, -122.42], +]; + +/** The Skyline ridge, north half — Kings Mountain down to Windy Hill. */ +export const SKYLINE_NORTH: LatLng[] = [ + [37.49, -122.39], + [37.478, -122.31], + [37.36, -122.21], + [37.375, -122.3], +]; + +/** The Skyline ridge, south half — Monte Bello, Black Mountain, Castle Rock. */ +export const SKYLINE_SOUTH: LatLng[] = [ + [37.365, -122.215], + [37.35, -122.135], + [37.21, -122.03], + [37.228, -122.13], +]; + +/** Almaden Quicksilver and the Santa Teresa hills, closing San Jose to the south. */ +export const SANTA_TERESA: LatLng[] = [ + [37.245, -121.845], + [37.238, -121.79], + [37.198, -121.8], + [37.205, -121.855], +]; + +/** Tamalpais and the Marin watershed. */ +export const TAMALPAIS: LatLng[] = [ + [37.96, -122.63], + [37.952, -122.545], + [37.888, -122.555], + [37.896, -122.635], +]; + +/** Tilden and Wildcat Canyon, along the Berkeley ridge. */ +export const BERKELEY_RIDGE: LatLng[] = [ + [37.925, -122.255], + [37.918, -122.212], + [37.858, -122.19], + [37.866, -122.235], +]; + +/** Redwood and Chabot, along the Oakland ridge. */ +export const OAKLAND_RIDGE: LatLng[] = [ + [37.855, -122.195], + [37.848, -122.145], + [37.72, -122.085], + [37.73, -122.135], +]; + +export const MOUNT_DIABLO_PARK: LatLng[] = [ + [37.925, -121.97], + [37.918, -121.86], + [37.838, -121.87], + [37.845, -121.975], +]; + +/** Mission Peak and the Fremont hills. */ +export const MISSION_PEAK_PARK: LatLng[] = [ + [37.555, -121.91], + [37.548, -121.855], + [37.45, -121.83], + [37.46, -121.885], +]; + +/** + * The south bay marshes — the Palo Alto baylands, Shoreline, the Alviso ponds + * and Coyote Hills. Rendered as open space because that is what they are, even + * though half of them are salt evaporator, not grass. + */ +export const BAYLANDS: LatLng[] = [ + [37.475, -122.12], + [37.468, -122.095], + [37.44, -122.045], + [37.452, -122.085], +]; + +export const ALVISO_MARSH: LatLng[] = [ + [37.462, -121.978], + [37.455, -121.94], + [37.425, -121.955], + [37.432, -122.005], +]; + +export const COYOTE_HILLS: LatLng[] = [ + [37.575, -122.128], + [37.568, -122.098], + [37.538, -122.105], + [37.545, -122.135], +]; + export const PARKS = [ GOLDEN_GATE_PARK, PRESIDIO, @@ -285,6 +610,20 @@ export const PARKS = [ BUENA_VISTA, MCLAREN_PARK, LINCOLN_PARK, + SAN_BRUNO_MOUNTAIN, + SWEENEY_RIDGE, + CRYSTAL_SPRINGS_WATERSHED, + SKYLINE_NORTH, + SKYLINE_SOUTH, + SANTA_TERESA, + TAMALPAIS, + BERKELEY_RIDGE, + OAKLAND_RIDGE, + MOUNT_DIABLO_PARK, + MISSION_PEAK_PARK, + BAYLANDS, + ALVISO_MARSH, + COYOTE_HILLS, ]; /** Lake Merced, the only real inland water. */ @@ -304,7 +643,51 @@ export const STOW_LAKE: LatLng[] = [ [37.7685, -122.4785], ]; -export const INLAND_WATER = [LAKE_MERCED, STOW_LAKE]; +/** + * San Andreas Lake and Crystal Springs, lying in the fault trace itself — the + * two of them draw a straight line thirteen miles long, which is the most + * legible thing the San Andreas does anywhere on this map. + */ +export const SAN_ANDREAS_LAKE: LatLng[] = [ + [37.6, -122.408], + [37.598, -122.4], + [37.575, -122.386], + [37.573, -122.394], +]; + +export const CRYSTAL_SPRINGS: LatLng[] = [ + [37.552, -122.372], + [37.549, -122.362], + [37.497, -122.318], + [37.494, -122.33], +]; + +/** Lake Merritt, a tidal lagoon with downtown Oakland wrapped around it. */ +export const LAKE_MERRITT: LatLng[] = [ + [37.808, -122.264], + [37.806, -122.256], + [37.798, -122.253], + [37.797, -122.263], +]; + +/** + * Only the three above, and no reservoir in the hills. + * + * `terrain.ts` draws inland water as a flat plate 0.05 units above sea level, + * which is right for a lagoon and wrong for Chabot or Calaveras: those sit a + * few hundred metres up, and at 3.6× exaggeration the terrain around them + * stands four units clear of a lake that stays at the waterline. The plate + * would be buried under the hill it is supposed to be in. Ponding water at + * altitude needs the engine to carry a surface height, and until it does, + * leaving them out is more honest than drawing them wrong. + */ +export const INLAND_WATER = [ + LAKE_MERCED, + STOW_LAKE, + SAN_ANDREAS_LAKE, + CRYSTAL_SPRINGS, + LAKE_MERRITT, +]; // ---- Relief --------------------------------------------------------------- @@ -360,10 +743,145 @@ export const HILLS: Hill[] = [ { name: "Sausalito Ridge", lat: 37.8595, lng: -122.5015, elevation: 230, radius: 0.013 }, { name: "Angel Island", lat: 37.8625, lng: -122.4305, elevation: 240, radius: 0.007 }, - // East Bay — a low hint of the hills behind Oakland, mostly off-frame + // East Bay — the front slope above Montclair. The crest proper is another + // two hundred metres up and a little further east; it is in EAST_BAY_HILLS + // below, and this entry is now the shoulder it stands on rather than the + // whole of the far side of the bay. { name: "Oakland Hills", lat: 37.83, lng: -122.235, elevation: 330, radius: 0.03 }, ]; +/** + * The Santa Cruz Mountains — the western wall, and the reason the Peninsula is + * a strip. + * + * This is the single fact that explains the shape of everything south of the + * county line. The range runs unbroken from Sweeney Ridge to Loma Prieta, it is + * two to three times the height of anything in San Francisco, and it comes down + * within four miles of the water for most of its length. Fifteen cities, two + * freeways and a railroad are all squeezed into what is left, in that order, + * which is why they are in that order. + * + * Written as a close chain rather than as a few big summits, and the radii are + * the load-bearing numbers rather than the elevations. `elevationAt` fades a + * hill off as `(1 - d²)²`, which reaches **exactly zero** at `radius` — so two + * summits spaced further apart than their radii have sea level between them. + * Sixteen peaks at their true heights and honest 3 km radii produced a row of + * golf balls with the Peninsula visible through the gaps. Spacing is now under + * a radius everywhere, which is also closer to the truth: this is a broad + * range with one crest, not a line of cones. + */ +export const SANTA_CRUZ_MOUNTAINS: Hill[] = [ + { name: "Sweeney Ridge", lat: 37.607, lng: -122.452, elevation: 380, radius: 0.03 }, + { name: "Montara Mountain", lat: 37.57, lng: -122.462, elevation: 540, radius: 0.032 }, + { name: "Cahill Ridge", lat: 37.535, lng: -122.43, elevation: 520, radius: 0.032 }, + { name: "Crystal Springs Ridge", lat: 37.5, lng: -122.395, elevation: 560, radius: 0.032 }, + { name: "Kings Mountain", lat: 37.465, lng: -122.355, elevation: 640, radius: 0.034 }, + { name: "Skyline at Woodside", lat: 37.43, lng: -122.315, elevation: 700, radius: 0.034 }, + { name: "Windy Hill", lat: 37.395, lng: -122.27, elevation: 640, radius: 0.034 }, + { name: "Skyline at Page Mill", lat: 37.36, lng: -122.225, elevation: 700, radius: 0.036 }, + { name: "Monte Bello", lat: 37.342, lng: -122.19, elevation: 780, radius: 0.036 }, + { name: "Black Mountain", lat: 37.322, lng: -122.15, elevation: 850, radius: 0.038 }, + { name: "Monte Bello Ridge", lat: 37.29, lng: -122.115, elevation: 800, radius: 0.036 }, + { name: "Castle Rock", lat: 37.25, lng: -122.095, elevation: 900, radius: 0.038 }, + { name: "El Sombroso", lat: 37.232, lng: -122.055, elevation: 850, radius: 0.04 }, + { name: "Loma Chiquita", lat: 37.215, lng: -122.02, elevation: 850, radius: 0.042 }, + { name: "Mount Thayer", lat: 37.202, lng: -121.975, elevation: 900, radius: 0.042 }, + { name: "Sierra Azul", lat: 37.19, lng: -121.93, elevation: 950, radius: 0.044 }, +]; + +/** + * The low hills the Peninsula's towns are actually built on. None of them are + * mountains; all of them are the difference between a flat strip and a strip + * with a west side worth living on. + */ +export const PENINSULA_HILLS: Hill[] = [ + { name: "San Bruno Mountain", lat: 37.6895, lng: -122.435, elevation: 400, radius: 0.018 }, + { name: "Buri Buri Ridge", lat: 37.635, lng: -122.44, elevation: 220, radius: 0.012 }, + { name: "Hillsborough", lat: 37.575, lng: -122.365, elevation: 200, radius: 0.014 }, + { name: "Belmont Hills", lat: 37.505, lng: -122.31, elevation: 200, radius: 0.014 }, + { name: "Emerald Hills", lat: 37.465, lng: -122.283, elevation: 180, radius: 0.012 }, + { name: "Stanford Foothills", lat: 37.41, lng: -122.185, elevation: 190, radius: 0.014 }, + { name: "Los Altos Hills", lat: 37.36, lng: -122.14, elevation: 210, radius: 0.014 }, + { name: "Santa Teresa Hills", lat: 37.222, lng: -121.812, elevation: 340, radius: 0.016 }, +]; + +/** + * The east wall: the Berkeley and Oakland ridge, the Fremont hills, and Mount + * Diablo behind them. + * + * The ridge is the more important of the two, because it is what the city looks + * at. Vollmer and Grizzly are over five hundred metres — twice Twin Peaks — and + * they run for thirty miles without a gap, so from anywhere in San Francisco the + * east side of the bay is a single unbroken skyline with a city at its foot. + * Diablo is a third again as tall and forty miles off, and does the same job for + * the whole basin. + */ +export const EAST_BAY_HILLS: Hill[] = [ + // The ridge, unbroken from Point Pinole to the Fremont hills. Same spacing + // rule as the Santa Cruz chain: nothing is further from its neighbour than + // its own radius, because a gap here would put a hole through the wall the + // whole east side of the map is supposed to be. + { name: "San Pablo Ridge", lat: 37.968, lng: -122.3, elevation: 360, radius: 0.028 }, + { name: "Sobrante Ridge", lat: 37.945, lng: -122.268, elevation: 400, radius: 0.028 }, + { name: "Wildcat Peak", lat: 37.912, lng: -122.245, elevation: 420, radius: 0.028 }, + { name: "Vollmer Peak", lat: 37.885, lng: -122.215, elevation: 570, radius: 0.026 }, + { name: "Grizzly Peak", lat: 37.872, lng: -122.205, elevation: 520, radius: 0.022 }, + { name: "Round Top", lat: 37.848, lng: -122.192, elevation: 500, radius: 0.026 }, + { name: "Redwood Peak", lat: 37.822, lng: -122.168, elevation: 500, radius: 0.028 }, + { name: "Chabot Ridge", lat: 37.792, lng: -122.14, elevation: 420, radius: 0.03 }, + { name: "Fairmont Ridge", lat: 37.76, lng: -122.115, elevation: 400, radius: 0.03 }, + { name: "Lake Chabot Hills", lat: 37.728, lng: -122.09, elevation: 380, radius: 0.03 }, + { name: "Hayward Hills", lat: 37.694, lng: -122.062, elevation: 380, radius: 0.032 }, + { name: "Palomares Ridge", lat: 37.655, lng: -122.03, elevation: 450, radius: 0.034 }, + { name: "Walpert Ridge", lat: 37.615, lng: -121.995, elevation: 500, radius: 0.04 }, + { name: "Sunol Ridge", lat: 37.572, lng: -121.945, elevation: 600, radius: 0.042 }, + { name: "Mission Ridge", lat: 37.542, lng: -121.912, elevation: 700, radius: 0.038 }, + { name: "Mission Peak", lat: 37.512, lng: -121.88, elevation: 780, radius: 0.04 }, + { name: "Monument Peak", lat: 37.47, lng: -121.86, elevation: 780, radius: 0.036 }, + { name: "Berryessa Hills", lat: 37.43, lng: -121.83, elevation: 500, radius: 0.04 }, + { name: "Alum Rock Hills", lat: 37.38, lng: -121.795, elevation: 550, radius: 0.04 }, + { name: "Hamilton Foothills", lat: 37.34, lng: -121.775, elevation: 550, radius: 0.038 }, + { name: "Silver Creek Hills", lat: 37.3, lng: -121.77, elevation: 500, radius: 0.038 }, + + // Behind the ridge, and deliberately *not* joined to it. Briones and Las + // Trampas are separated from the crest by the San Pablo and San Ramon + // valleys, and Diablo stands alone out of flat country — so these three are + // isolated on purpose, and the saddles between them are the point. + { name: "Briones", lat: 37.935, lng: -122.13, elevation: 450, radius: 0.03 }, + { name: "Las Trampas Ridge", lat: 37.815, lng: -122.045, elevation: 570, radius: 0.03 }, + { name: "Mount Diablo", lat: 37.881, lng: -121.914, elevation: 1173, radius: 0.045 }, + { name: "Diablo North Peak", lat: 37.895, lng: -121.885, elevation: 950, radius: 0.03 }, +]; + +/** + * Marin, north of the headlands — and Tamalpais, which was missing. + * + * Seven hundred and eighty-four metres, eight miles from the Ferry Building, + * with nothing in front of it. Every northward view from the city ends on this + * mountain, and the map was ending on open water instead. + */ +export const MARIN_HILLS: Hill[] = [ + { name: "Mount Tamalpais", lat: 37.9235, lng: -122.5965, elevation: 784, radius: 0.034 }, + { name: "Blithedale Ridge", lat: 37.905, lng: -122.56, elevation: 400, radius: 0.018 }, + { name: "Bolinas Ridge", lat: 37.955, lng: -122.615, elevation: 500, radius: 0.028 }, + { name: "Loma Alta", lat: 37.965, lng: -122.575, elevation: 450, radius: 0.026 }, + { name: "Ring Mountain", lat: 37.905, lng: -122.485, elevation: 180, radius: 0.012 }, + { name: "San Rafael Hill", lat: 37.985, lng: -122.528, elevation: 350, radius: 0.016 }, +]; + +/** + * Every hill in the pack. `HILLS` is left meaning what its comment says it + * means — San Francisco's own — because that array is the argument for this + * whole file and shrinking it into a general list would lose the point. + */ +export const ALL_HILLS: Hill[] = [ + ...HILLS, + ...SANTA_CRUZ_MOUNTAINS, + ...PENINSULA_HILLS, + ...EAST_BAY_HILLS, + ...MARIN_HILLS, +]; + // ---- Streets -------------------------------------------------------------- /** @@ -452,6 +970,296 @@ export const INTERSTATE_280: LatLng[] = [ [37.7105, -122.4595], ]; +// ---- The roads out of town ------------------------------------------------ + +/** + * Beyond the county line the freeways stop being streets and start being the + * only reason the shape of the region makes sense: everything on the Peninsula + * is arranged in three parallel bands — 101 on the mud, El Camino and the + * railroad in the middle, 280 along the foot of the hills — and every town on + * this map is a rung between them. + * + * `BAYSHORE_101` and `JUNIPERO_SERRA_280` begin on the last coordinate of + * `HIGHWAY_101` and `INTERSTATE_280` respectively, so the ribbons meet at the + * county line instead of ending in mid-air over Visitacion Valley. + */ +export const BAYSHORE_101: LatLng[] = [ + [37.7135, -122.3985], // where HIGHWAY_101 leaves the city + [37.695, -122.4], + [37.673, -122.406], + [37.65, -122.41], + [37.628, -122.4], + [37.605, -122.385], + [37.583, -122.36], + [37.56, -122.33], + [37.535, -122.295], + [37.51, -122.255], + [37.487, -122.218], + [37.465, -122.18], + [37.443, -122.145], + [37.42, -122.105], + [37.405, -122.065], + [37.393, -122.02], + [37.383, -121.975], + [37.37, -121.935], + [37.352, -121.905], + [37.336, -121.888], +]; + +export const JUNIPERO_SERRA_280: LatLng[] = [ + [37.7105, -122.4595], // where INTERSTATE_280 leaves the city + [37.695, -122.455], + [37.673, -122.443], + [37.65, -122.428], + [37.628, -122.412], + [37.605, -122.398], + [37.58, -122.383], + [37.553, -122.36], + [37.528, -122.336], + [37.505, -122.313], + [37.48, -122.288], + [37.455, -122.255], + [37.432, -122.222], + [37.41, -122.19], + [37.388, -122.155], + [37.365, -122.115], + [37.343, -122.07], + [37.325, -122.02], + [37.315, -121.965], + [37.318, -121.92], + [37.328, -121.892], +]; + +/** + * El Camino Real, the middle band. Older than every grid it passes through, + * which is why the grids are all rotated to meet it and why the rotation + * changes as it goes: nearly north–south at the county line, nearly east–west + * by Santa Clara. Read the `gridAngle` of the Peninsula districts down the file + * and this road is what they are all turning against. + */ +export const EL_CAMINO_REAL: LatLng[] = [ + [37.708, -122.468], + [37.688, -122.455], + [37.665, -122.432], + [37.643, -122.412], + [37.62, -122.393], + [37.595, -122.375], + [37.57, -122.35], + [37.545, -122.32], + [37.52, -122.288], + [37.495, -122.252], + [37.472, -122.212], + [37.45, -122.175], + [37.428, -122.135], + [37.408, -122.095], + [37.392, -122.05], + [37.378, -122.005], + [37.365, -121.96], + [37.352, -121.925], + [37.34, -121.898], +]; + +/** The Nimitz, running the east shore from the bridge touchdown to San Jose. */ +export const NIMITZ_880: LatLng[] = [ + [37.822, -122.3], + [37.803, -122.283], + [37.782, -122.258], + [37.757, -122.228], + [37.73, -122.198], + [37.7, -122.163], + [37.672, -122.125], + [37.645, -122.095], + [37.618, -122.068], + [37.588, -122.04], + [37.558, -122.012], + [37.528, -121.988], + [37.498, -121.962], + [37.468, -121.938], + [37.44, -121.915], + [37.41, -121.9], + [37.38, -121.893], + [37.352, -121.888], +]; + +export const EASTSHORE_80: LatLng[] = [ + [37.822, -122.302], + [37.835, -122.302], + [37.85, -122.298], + [37.865, -122.3], + [37.88, -122.31], + [37.895, -122.32], + [37.91, -122.332], + [37.925, -122.34], + [37.945, -122.34], + [37.965, -122.33], + [37.988, -122.315], + [38.01, -122.295], +]; + +/** The 580, from the Richmond bridge round the hills and out to the Diablo valley. */ +export const MACARTHUR_580: LatLng[] = [ + [37.93, -122.39], + [37.92, -122.36], + [37.905, -122.34], + [37.885, -122.318], + [37.862, -122.298], + [37.838, -122.272], + [37.812, -122.245], + [37.788, -122.222], + [37.762, -122.19], + [37.74, -122.152], + [37.718, -122.11], + [37.702, -122.062], + [37.7, -122.01], + [37.702, -121.95], + [37.705, -121.9], +]; + +/** The 680, behind the ridge — San Jose to Fremont and on up the inland valley. */ +export const INTERSTATE_680: LatLng[] = [ + [37.345, -121.885], + [37.372, -121.878], + [37.4, -121.87], + [37.428, -121.862], + [37.458, -121.865], + [37.49, -121.882], + [37.52, -121.905], + [37.55, -121.92], + [37.585, -121.93], + [37.625, -121.938], + [37.67, -121.945], + [37.72, -121.945], + [37.78, -121.945], + [37.85, -121.96], + [37.9, -122.01], +]; + +export const HIGHWAY_237: LatLng[] = [ + [37.408, -122.055], + [37.412, -122.02], + [37.412, -121.985], + [37.418, -121.95], + [37.428, -121.92], +]; + +export const HIGHWAY_85: LatLng[] = [ + [37.412, -122.075], + [37.395, -122.075], + [37.375, -122.065], + [37.352, -122.045], + [37.33, -122.02], + [37.3, -121.995], + [37.275, -121.96], + [37.258, -121.915], + [37.25, -121.87], +]; + +export const HIGHWAY_17: LatLng[] = [ + [37.33, -121.93], + [37.31, -121.945], + [37.288, -121.955], + [37.262, -121.965], + [37.238, -121.98], +]; + +/** Highway 1, the coast road, which has nothing on either side of it for fifty miles. */ +export const COAST_HIGHWAY: LatLng[] = [ + [37.706, -122.492], + [37.688, -122.487], + [37.665, -122.486], + [37.64, -122.488], + [37.615, -122.491], + [37.592, -122.494], + [37.568, -122.504], + [37.545, -122.5], + [37.52, -122.486], + [37.498, -122.462], + [37.478, -122.444], + [37.455, -122.434], + [37.425, -122.424], + [37.39, -122.414], + [37.35, -122.408], + [37.31, -122.4], + [37.27, -122.392], + [37.235, -122.352], + [37.2, -122.325], +]; + +/** Highway 92, San Mateo over the ridge to Half Moon Bay — one of four ways across. */ +export const HIGHWAY_92: LatLng[] = [ + [37.573, -122.25], + [37.56, -122.288], + [37.552, -122.325], + [37.545, -122.362], + [37.53, -122.4], + [37.51, -122.43], + [37.492, -122.455], +]; + +/** Highway 84, the Dumbarton landing through Redwood City to Woodside. */ +export const HIGHWAY_84: LatLng[] = [ + [37.487, -122.145], + [37.483, -122.18], + [37.482, -122.215], + [37.478, -122.245], + [37.465, -122.272], + [37.45, -122.29], + [37.432, -122.305], +]; + +/** 101 in Marin — the Waldo grade, Mill Valley, Corte Madera, San Rafael. */ +export const MARIN_101: LatLng[] = [ + [37.833, -122.48], + [37.845, -122.487], + [37.86, -122.495], + [37.878, -122.51], + [37.895, -122.518], + [37.912, -122.52], + [37.93, -122.522], + [37.948, -122.528], + [37.968, -122.532], + [37.99, -122.535], + [38.012, -122.538], +]; + +/** + * Runways, drawn as roads because that is exactly what they are at this scale: + * a pale straight strip laid on flat ground. + * + * Worth the eight lines. SFO's crossing pairs on their square of fill are the + * one shape on the Peninsula's bay edge you can name from ten thousand feet, + * and the reason San Jose's downtown is short is standing on the other one. + */ +export const SFO_RUNWAYS: LatLng[][] = [ + [ + [37.6151, -122.36], + [37.6229, -122.3948], + ], + [ + [37.6171, -122.3594], + [37.6249, -122.3942], + ], + [ + [37.633, -122.3709], + [37.605, -122.3791], + ], + [ + [37.6335, -122.3735], + [37.6055, -122.3817], + ], +]; + +export const SJC_RUNWAYS: LatLng[][] = [ + [ + [37.3705, -121.9385], + [37.3555, -121.9195], + ], + [ + [37.3685, -121.9405], + [37.3535, -121.9215], + ], +]; + export const ROADS: City["roads"] = [ { path: MARKET_STREET, width: 0.34, kind: "street" }, { path: THE_EMBARCADERO, width: 0.28, kind: "street" }, @@ -462,6 +1270,22 @@ export const ROADS: City["roads"] = [ { path: THIRD_STREET, width: 0.22, kind: "street" }, { path: HIGHWAY_101, width: 0.3, kind: "freeway" }, { path: INTERSTATE_280, width: 0.3, kind: "freeway" }, + { path: BAYSHORE_101, width: 0.32, kind: "freeway" }, + { path: JUNIPERO_SERRA_280, width: 0.3, kind: "freeway" }, + { path: NIMITZ_880, width: 0.32, kind: "freeway" }, + { path: EASTSHORE_80, width: 0.32, kind: "freeway" }, + { path: MACARTHUR_580, width: 0.3, kind: "freeway" }, + { path: INTERSTATE_680, width: 0.28, kind: "freeway" }, + { path: HIGHWAY_237, width: 0.24, kind: "freeway" }, + { path: HIGHWAY_85, width: 0.26, kind: "freeway" }, + { path: HIGHWAY_17, width: 0.24, kind: "freeway" }, + { path: MARIN_101, width: 0.28, kind: "freeway" }, + { path: EL_CAMINO_REAL, width: 0.24, kind: "street" }, + { path: COAST_HIGHWAY, width: 0.2, kind: "street" }, + { path: HIGHWAY_92, width: 0.2, kind: "street" }, + { path: HIGHWAY_84, width: 0.2, kind: "street" }, + ...SFO_RUNWAYS.map((path) => ({ path, width: 0.5, kind: "street" as const })), + ...SJC_RUNWAYS.map((path) => ({ path, width: 0.45, kind: "street" as const })), ]; // ---- Bridges -------------------------------------------------------------- @@ -512,7 +1336,82 @@ export const BAY_BRIDGE: Bridge = { color: 0x9aa6b2, }; -export const BRIDGES = [GOLDEN_GATE_BRIDGE, BAY_BRIDGE]; +/** + * The other three crossings. + * + * None of them is a suspension bridge and all three are drawn with the + * suspension builder, which is a compromise worth stating. What they actually + * are is miles of low trestle with one high span in the middle for the ship + * channel — so each gets a **single** tower rather than a pair. That matters: + * `createBridge` only treats a segment as a main span when it runs between two + * towers, so one tower produces two shallow curves instead of one deep + * catenary, and the result reads as a causeway with a hump in it. Which is what + * you see from the air. + * + * The Richmond–San Rafael gets two, because it genuinely has two humps — it was + * built with a ship channel at each end and a dip between them, and looks it. + */ +export const SAN_MATEO_BRIDGE: Bridge = { + name: "San Mateo–Hayward Bridge", + path: [ + [37.5745, -122.2585], + [37.578, -122.255], + [37.5865, -122.2405], + [37.6, -122.212], + [37.615, -122.175], + [37.628, -122.128], + [37.6305, -122.1235], + ], + towers: [[37.5865, -122.2405]], + towerHeight: 58, + deckHeight: 14, + sag: 0.3, + color: 0x9aa6b2, +}; + +export const DUMBARTON_BRIDGE: Bridge = { + name: "Dumbarton Bridge", + path: [ + [37.4835, -122.1505], + [37.487, -122.146], + [37.4975, -122.1175], + [37.512, -122.086], + [37.515, -122.0815], + ], + towers: [[37.4975, -122.1175]], + towerHeight: 48, + deckHeight: 12, + sag: 0.28, + color: 0x8fa0ac, +}; + +export const RICHMOND_SAN_RAFAEL_BRIDGE: Bridge = { + name: "Richmond–San Rafael Bridge", + path: [ + [37.9475, -122.4855], + [37.945, -122.481], + [37.9405, -122.452], + [37.9355, -122.418], + [37.93, -122.39], + [37.9275, -122.3845], + ], + towers: [ + [37.9405, -122.452], + [37.9355, -122.418], + ], + towerHeight: 56, + deckHeight: 18, + sag: 0.26, + color: 0xa0a8ae, +}; + +export const BRIDGES = [ + GOLDEN_GATE_BRIDGE, + BAY_BRIDGE, + SAN_MATEO_BRIDGE, + DUMBARTON_BRIDGE, + RICHMOND_SAN_RAFAEL_BRIDGE, +]; // ---- Landmarks ------------------------------------------------------------ @@ -608,6 +1507,107 @@ export const LANDMARKS: Landmark[] = [ shape: "cylinder", color: 0xdcc9ad, }, + + // Beyond the county line. Chosen the same way as the city's: the shape that + // tells you which town you are over. Deliberately none of them is a corporate + // campus — a building named for a company would put a trademark on the map, + // and ARCHITECTURE.md §3.1 is the reason there is not one in this repo. + { + name: "SFO Control Tower", + lat: 37.618, + lng: -122.3838, + height: 67, + footprint: 0.00014, + shape: "tower", + color: 0xd8d2c4, + label: true, + }, + { + name: "Hoover Tower", + lat: 37.4275, + lng: -122.1697, + height: 87, + footprint: 0.0002, + shape: "cylinder", + color: 0xe4d7bd, + label: true, + }, + { + name: "Hangar One", + lat: 37.4103, + lng: -122.0489, + height: 60, + footprint: 0.0011, + shape: "cylinder", + color: 0xb6bcc0, + label: true, + }, + { + name: "Shoreline Amphitheatre", + lat: 37.4266, + lng: -122.0805, + height: 28, + footprint: 0.0004, + shape: "pyramid", + color: 0xdfe3e6, + }, + { + name: "San Jose City Hall", + lat: 37.3382, + lng: -121.8863, + height: 86, + footprint: 0.00028, + shape: "tower", + label: true, + }, + { + name: "Bank of Italy Building", + lat: 37.335, + lng: -121.8905, + height: 71, + footprint: 0.00022, + shape: "box", + color: 0xd9cdb8, + }, + { + name: "Ordway Building", + lat: 37.8095, + lng: -122.262, + height: 123, + footprint: 0.00042, + shape: "box", + color: 0xb4bcc4, + label: true, + }, + { + name: "Tribune Tower", + lat: 37.8016, + lng: -122.2717, + height: 93, + footprint: 0.00018, + shape: "tower", + color: 0xc7b49c, + label: true, + }, + { + name: "Sather Tower", + lat: 37.8721, + lng: -122.2578, + height: 94, + footprint: 0.00016, + shape: "tower", + color: 0xe6ddc8, + label: true, + }, + { + name: "Marin County Civic Center", + lat: 37.9985, + lng: -122.5312, + height: 26, + footprint: 0.0006, + shape: "cylinder", + color: 0xd6c9a8, + }, ]; // ---- Districts ------------------------------------------------------------ @@ -953,6 +1953,544 @@ export const DISTRICTS: District[] = [ palette: "downtown", towerChance: 0.05, }, + + // ---- The Peninsula ------------------------------------------------------ + // + // Fourteen towns in a row between the bay and the ridge, and the interesting + // thing about them is the `gridAngle` column. Every one of these places was + // laid out against El Camino and the railroad, and that line swings roughly + // forty-five degrees on its way south — nearly north–south where it leaves + // the county line, nearly east–west by Santa Clara. So the grid rotates + // steadily as the camera travels down the strip, which is a thing you can + // actually see from the air and which no single city-wide bearing would ever + // produce. San Francisco's own downtown sits at 0.8; Daly City, four miles + // away, is at 1.22. + // + // `coverage` is lower out here than in the city, and it is doing two jobs. + // The honest one is that the Peninsula is detached houses on lots and the + // Sunset is not. The practical one is budget: everything in `blocks.ts` goes + // into one `InstancedMesh` that is also drawn again into the shadow map, and + // San Francisco alone puts about 77,000 boxes in it. These thirty districts + // at the city's density would have added 190,000 more; at the coverage below + // they add 111,000, which lands the whole region at 2.4× the city — for + // twenty times the area. They are the built cores, not municipal boundaries. + { + id: "daly-city", + name: "Daly City", + polygon: [ + [37.708, -122.496], + [37.706, -122.446], + [37.682, -122.452], + [37.684, -122.498], + ], + minHeight: 8, + maxHeight: 26, + gridAngle: 1.22, + palette: "residential", + towerChance: 0.004, + coverage: 0.55, + }, + { + id: "south-san-francisco", + name: "South San Francisco", + polygon: [ + [37.678, -122.452], + [37.676, -122.398], + [37.652, -122.404], + [37.654, -122.456], + ], + minHeight: 8, + maxHeight: 34, + gridAngle: 1.1, + palette: "industrial", + towerChance: 0.008, + coverage: 0.4, + }, + { + id: "san-bruno", + name: "San Bruno", + polygon: [ + [37.648, -122.456], + [37.646, -122.404], + [37.626, -122.41], + [37.628, -122.458], + ], + minHeight: 7, + maxHeight: 22, + gridAngle: 1.05, + palette: "residential", + towerChance: 0.002, + coverage: 0.4, + }, + { + id: "millbrae-burlingame", + name: "Millbrae & Burlingame", + // The east edge stops short of -122.41 up at the airport's latitude and + // only reaches the bay south of it. There is nothing to build on between + // those two lines: it is SFO, and boxes scattered across the 19s would be + // the most conspicuous mistake on the Peninsula. + polygon: [ + [37.622, -122.456], + [37.62, -122.41], + [37.592, -122.384], + [37.594, -122.444], + ], + minHeight: 7, + maxHeight: 28, + gridAngle: 0.96, + palette: "residential", + towerChance: 0.004, + coverage: 0.36, + }, + { + id: "san-mateo", + name: "San Mateo", + // Kept east of -122.382 and north of 37.560, which is the gap between San + // Andreas Lake and Crystal Springs. The watershed reservoirs lie in the + // fault trace right behind the town, and a district drawn over them puts + // houses in the water supply. + polygon: [ + [37.588, -122.376], + [37.586, -122.318], + [37.56, -122.326], + [37.562, -122.382], + ], + minHeight: 8, + maxHeight: 42, + gridAngle: 0.87, + palette: "residential", + towerChance: 0.01, + coverage: 0.42, + }, + { + id: "foster-city", + name: "Foster City", + polygon: [ + [37.575, -122.285], + [37.572, -122.256], + [37.55, -122.258], + [37.553, -122.288], + ], + minHeight: 8, + maxHeight: 28, + // Built on dredged fill in the sixties and laid out against its own + // lagoons, so it ignores El Camino entirely — the one break in the + // Peninsula's rotation, and it shows. + gridAngle: 1.2, + palette: "residential", + towerChance: 0.006, + coverage: 0.4, + }, + { + id: "belmont-san-carlos", + name: "Belmont & San Carlos", + polygon: [ + [37.552, -122.33], + [37.549, -122.268], + [37.52, -122.272], + [37.523, -122.334], + ], + minHeight: 7, + maxHeight: 24, + gridAngle: 0.8, + palette: "residential", + towerChance: 0.003, + coverage: 0.32, + }, + { + id: "redwood-city", + name: "Redwood City", + polygon: [ + [37.518, -122.276], + [37.515, -122.212], + [37.484, -122.22], + [37.487, -122.284], + ], + minHeight: 9, + maxHeight: 44, + gridAngle: 0.7, + palette: "downtown", + towerChance: 0.012, + coverage: 0.4, + }, + { + id: "menlo-park", + name: "Menlo Park & Atherton", + polygon: [ + [37.484, -122.22], + [37.481, -122.156], + [37.45, -122.164], + [37.453, -122.228], + ], + minHeight: 7, + maxHeight: 22, + gridAngle: 0.64, + palette: "residential", + towerChance: 0.002, + coverage: 0.28, + }, + { + id: "palo-alto", + name: "Palo Alto", + polygon: [ + [37.455, -122.162], + [37.452, -122.1], + [37.42, -122.108], + [37.423, -122.17], + ], + // Fifty feet, by ordinance, since 1971. The most valuable square mile in + // the country is two storeys tall on purpose, and a `maxHeight` that let it + // grow a skyline would be the single most obviously wrong thing on the map. + minHeight: 8, + maxHeight: 24, + gridAngle: 0.61, + palette: "residential", + towerChance: 0.002, + coverage: 0.36, + }, + { + id: "stanford", + name: "Stanford", + polygon: [ + [37.442, -122.2], + [37.44, -122.174], + [37.414, -122.178], + [37.416, -122.204], + ], + minHeight: 8, + maxHeight: 30, + gridAngle: 0.55, + palette: "residential", + towerChance: 0.004, + // A campus is mostly not buildings, and this one owns eight thousand acres + // it has deliberately never built on. + coverage: 0.22, + }, + { + id: "mountain-view", + name: "Mountain View", + polygon: [ + [37.42, -122.112], + [37.417, -122.048], + [37.386, -122.056], + [37.389, -122.12], + ], + minHeight: 7, + maxHeight: 28, + gridAngle: 0.44, + palette: "residential", + towerChance: 0.005, + coverage: 0.34, + }, + { + id: "sunnyvale", + name: "Sunnyvale", + polygon: [ + [37.4, -122.05], + [37.397, -121.996], + [37.366, -122.004], + [37.369, -122.058], + ], + minHeight: 7, + maxHeight: 26, + gridAngle: 0.26, + // The campuses along here are two- and three-storey tilt-up boxes on + // parking, which is the industrial palette whatever the tenant is worth. + palette: "industrial", + towerChance: 0.005, + coverage: 0.34, + }, + { + id: "santa-clara", + name: "Santa Clara", + polygon: [ + [37.372, -122.0], + [37.369, -121.942], + [37.34, -121.95], + [37.343, -122.008], + ], + minHeight: 7, + maxHeight: 30, + gridAngle: 0.17, + palette: "industrial", + towerChance: 0.006, + coverage: 0.34, + }, + + // ---- The South Bay ------------------------------------------------------ + { + id: "san-jose-downtown", + name: "Downtown San Jose", + polygon: [ + [37.35, -121.906], + [37.347, -121.874], + [37.322, -121.878], + [37.325, -121.91], + ], + minHeight: 10, + // Eighty-eight metres and not a foot more: downtown sits directly under the + // approach to Mineta, two miles away, and the FAA surface caps the whole + // core. The biggest city on this map has the shortest downtown on it, and + // that is why. + maxHeight: 88, + // The pueblo grid, forty-odd degrees off the suburban grid that surrounds + // it — the same argument Market Street settles in San Francisco, decided + // the same way and about eighty years earlier. + gridAngle: 0.6, + palette: "downtown", + towerChance: 0.05, + coverage: 0.5, + }, + { + id: "san-jose-north", + name: "North San Jose", + polygon: [ + [37.412, -121.958], + [37.408, -121.9], + [37.378, -121.908], + [37.382, -121.966], + ], + minHeight: 7, + maxHeight: 30, + gridAngle: 0.3, + palette: "industrial", + towerChance: 0.006, + coverage: 0.34, + }, + { + id: "san-jose-east", + name: "East San Jose", + polygon: [ + [37.372, -121.86], + [37.368, -121.804], + [37.336, -121.812], + [37.34, -121.868], + ], + minHeight: 6, + maxHeight: 18, + gridAngle: 0.08, + palette: "residential", + towerChance: 0.001, + coverage: 0.32, + }, + { + id: "willow-glen", + name: "Willow Glen & Cambrian", + polygon: [ + [37.322, -121.93], + [37.318, -121.87], + [37.286, -121.878], + [37.29, -121.938], + ], + minHeight: 6, + maxHeight: 18, + gridAngle: 0.06, + palette: "residential", + towerChance: 0.001, + coverage: 0.32, + }, + + // ---- The East Bay ------------------------------------------------------- + // + // The bearings here have nothing to do with the Peninsula's. Oakland and + // Alameda are laid against the estuary, Berkeley is very nearly true north, + // and everything from San Leandro south turns further with the shore. + { + id: "richmond", + name: "Richmond", + polygon: [ + [37.948, -122.36], + [37.945, -122.318], + [37.918, -122.324], + [37.921, -122.366], + ], + minHeight: 7, + maxHeight: 26, + gridAngle: 0.45, + palette: "industrial", + towerChance: 0.003, + coverage: 0.36, + }, + { + id: "el-cerrito-albany", + name: "El Cerrito & Albany", + polygon: [ + [37.918, -122.318], + [37.915, -122.282], + [37.888, -122.288], + [37.891, -122.324], + ], + minHeight: 8, + maxHeight: 26, + gridAngle: 0.1, + palette: "residential", + towerChance: 0.003, + coverage: 0.45, + }, + { + id: "berkeley", + name: "Berkeley", + polygon: [ + [37.888, -122.29], + [37.885, -122.248], + [37.855, -122.254], + [37.858, -122.296], + ], + minHeight: 9, + maxHeight: 44, + gridAngle: 0.06, + palette: "residential", + towerChance: 0.008, + coverage: 0.55, + }, + { + id: "emeryville", + name: "Emeryville", + polygon: [ + [37.845, -122.312], + [37.842, -122.286], + [37.826, -122.29], + [37.829, -122.316], + ], + minHeight: 10, + maxHeight: 60, + gridAngle: 0.45, + palette: "downtown", + towerChance: 0.02, + coverage: 0.45, + }, + { + id: "alameda", + name: "Alameda", + polygon: [ + [37.784, -122.294], + [37.781, -122.246], + [37.762, -122.248], + [37.765, -122.274], + ], + minHeight: 7, + maxHeight: 24, + gridAngle: 0.52, + palette: "residential", + towerChance: 0.002, + coverage: 0.42, + }, + { + id: "san-leandro", + name: "San Leandro", + polygon: [ + [37.735, -122.185], + [37.732, -122.14], + [37.706, -122.146], + [37.709, -122.19], + ], + minHeight: 7, + maxHeight: 24, + gridAngle: 0.52, + palette: "residential", + towerChance: 0.002, + coverage: 0.36, + }, + { + id: "hayward", + name: "Hayward", + polygon: [ + [37.69, -122.115], + [37.687, -122.062], + [37.658, -122.068], + [37.661, -122.12], + ], + minHeight: 7, + maxHeight: 28, + gridAngle: 0.6, + palette: "residential", + towerChance: 0.004, + coverage: 0.36, + }, + { + id: "union-city-newark", + name: "Union City & Newark", + polygon: [ + [37.612, -122.075], + [37.609, -122.02], + [37.58, -122.026], + [37.583, -122.08], + ], + minHeight: 7, + maxHeight: 22, + gridAngle: 0.72, + palette: "industrial", + towerChance: 0.002, + coverage: 0.32, + }, + { + id: "fremont", + name: "Fremont", + polygon: [ + [37.56, -122.01], + [37.556, -121.95], + [37.522, -121.958], + [37.526, -122.018], + ], + minHeight: 7, + maxHeight: 24, + gridAngle: 0.85, + palette: "residential", + towerChance: 0.002, + coverage: 0.32, + }, + { + id: "milpitas", + name: "Milpitas", + polygon: [ + [37.462, -121.92], + [37.458, -121.876], + [37.434, -121.88], + [37.438, -121.924], + ], + minHeight: 7, + maxHeight: 24, + gridAngle: 0.2, + palette: "industrial", + towerChance: 0.003, + coverage: 0.34, + }, + + // ---- Marin -------------------------------------------------------------- + { + id: "mill-valley", + name: "Mill Valley & Corte Madera", + polygon: [ + [37.93, -122.545], + [37.927, -122.5], + [37.895, -122.508], + [37.898, -122.552], + ], + minHeight: 7, + maxHeight: 20, + gridAngle: 0.4, + palette: "residential", + towerChance: 0.001, + // Steep, wooded and zoned to stay that way. Half the lots on this hillside + // have more tree over them than roof. + coverage: 0.28, + }, + { + id: "san-rafael", + name: "San Rafael", + polygon: [ + [37.985, -122.545], + [37.982, -122.5], + [37.958, -122.506], + [37.961, -122.55], + ], + minHeight: 7, + maxHeight: 30, + gridAngle: 0.62, + palette: "residential", + towerChance: 0.004, + coverage: 0.36, + }, ]; @@ -962,9 +2500,9 @@ const WHOLE_BOARD: City["chapters"][number] = { number: "01", label: "The Whole Board", shortLabel: "Whole Board", - focus: { lat: 37.775, lng: -122.432, distance: 178, height: 118, rotation: 0.52 }, + focus: { lat: 37.775, lng: -122.432, distance: 620, height: 430, rotation: 0.52 }, description: - "Seven by seven miles on the tip of a peninsula, with the Pacific on one side and the Bay on the other. Every company on this map is somewhere in this frame.", + "The Pacific on one side, the bay in the middle, and the Diablo range closing the east. San Francisco sits on the tip of the peninsula; the valley runs sixty miles south of it.", }; export const CHAPTERS: City["chapters"] = [ @@ -1014,14 +2552,69 @@ export const CHAPTERS: City["chapters"] = [ description: "The flat sunny part and the hill above it. Applied-AI teams and hardware startups in the old industrial edge along the water, with Bernal closing the valley to the south.", }, + // Chapters 07 onward are the rest of the region. 01 through 06 are untouched + // and still open on the city; the pull-back that used to be chapter 07 now + // lands on something. + // + // Every pose here is deliberately inside `hypot(distance, height) < 340`, + // because that is `maxDistance` on the city scene's `SceneKit` and + // `controls.update()` will quietly reel the camera in past it. At this + // `latScale` that is about 32 km of standoff, so the region gets five + // chapters rather than one impossible wide shot. If those limits are ever + // raised, these are the numbers to raise with them. { id: "peninsula", number: "07", label: "The Peninsula", shortLabel: "Peninsula", - focus: { lat: 37.735, lng: -122.42, distance: 205, height: 138, rotation: 0.18 }, + focus: { lat: 37.545, lng: -122.3, distance: 235, height: 150, rotation: 1.45 }, description: - "Looking south down the spine toward Palo Alto and Menlo Park. Plenty of companies that file their address as San Francisco are really down here, an hour away on the 101.", + "Twenty miles of city between the water and the ridge, and never more than four miles wide, because the Santa Cruz Mountains come down almost to the bay. Daly City to San Mateo, an airport built out onto the mud, and the two freeways — 101 on the flat, 280 along the foot of the hills — that carry everybody between them.", + }, + { + id: "silicon-valley", + number: "08", + label: "Silicon Valley", + shortLabel: "The Valley", + focus: { lat: 37.425, lng: -122.115, distance: 185, height: 118, rotation: 2.95 }, + description: + "Menlo Park, Palo Alto, Mountain View, Sunnyvale, looking south down the valley. The most expensive ground in the country is two storeys high: Palo Alto caps its buildings at fifty feet, and the campuses along the bay edge are low pale boxes on old salt marsh.", + }, + { + id: "south-bay", + number: "09", + label: "San Jose & the South Bay", + shortLabel: "San Jose", + focus: { lat: 37.345, lng: -121.905, distance: 175, height: 112, rotation: 3.6 }, + description: + "The largest city on this map and the flattest-looking one, because the airport is in the middle of it and nothing downtown may stand up into the approach. The pueblo grid sits forty degrees off the suburban grid around it, and at the bottom of the frame the valley closes with the marshes at Alviso.", + }, + { + id: "east-bay", + number: "10", + label: "The East Bay", + shortLabel: "East Bay", + focus: { lat: 37.815, lng: -122.268, distance: 170, height: 112, rotation: 1.35 }, + description: + "Oakland and Berkeley on the flats with the ridge standing behind them. Grizzly Peak and Vollmer are twice the height of Twin Peaks and run thirty miles without a gap, which is why the far side of the bay reads as one wall with a city at the bottom of it. Mount Diablo is the one behind that.", + }, + { + id: "north-bay", + number: "11", + label: "Marin & Tamalpais", + shortLabel: "Marin", + focus: { lat: 37.925, lng: -122.545, distance: 190, height: 125, rotation: 0.35 }, + description: + "Seven hundred and eighty-four metres of mountain eight miles from the Ferry Building, with nothing in front of it. Every northward view from San Francisco ends on this silhouette. Sausalito and Mill Valley are in its lap; San Rafael is round the corner on the bay side.", + }, + { + id: "bay-area", + number: "12", + label: "The Bay Area", + shortLabel: "The Bay", + focus: { lat: 37.63, lng: -122.24, distance: 280, height: 150, rotation: 0.15 }, + description: + "Looking north up the bay from off San Mateo, with three crossings ahead and two behind. The two ranges are what make this one room rather than a coastline: the Santa Cruz Mountains on the west, the Diablo range on the east, and everything anyone built squeezed onto the strips between them and the water.", }, ]; @@ -1030,10 +2623,21 @@ export const CHAPTERS: City["chapters"] = [ export const SAN_FRANCISCO_CITY: City = { id: "sf", - name: "San Francisco", - center: { lat: 37.7749, lng: -122.4194 }, - bounds: { minLat: 37.6975, maxLat: 37.8925, minLng: -122.5725, maxLng: -122.2175 }, + name: "San Francisco Bay Area", + // The centre stays on the city, even though the map now runs forty miles + // south of it. `center` is the origin of scene space and nothing else — + // moving it would renumber every coordinate in the scene to no purpose, and + // San Francisco is still what this pack is about. + center: { lat: 37.7749, lng: -122.4194 }, + bounds: { minLat: 37.18, maxLat: 38.03, minLng: -122.64, maxLng: -121.75 }, + + // Unchanged, and it has to be. One scene unit is ~94 m here and `blocks.ts` + // holds a fixed 0.42-unit lot, so `latScale` is really a statement about the + // size of a city block. Shrinking it to make the whole bay fill the frame the + // way the city used to would need about a quarter of this, and every house in + // San Jose would then be a hundred and seventy metres across. The region is + // wide in this pack because the bounds are wide, not because the scale moved. latScale: 1180, /** @@ -1043,19 +2647,47 @@ export const SAN_FRANCISCO_CITY: City = { */ verticalExaggeration: 3.6, - // ~45 m cells. One focus region covering everything, because the whole city - // fits comfortably at this resolution — see ARCHITECTURE.md §5 for why Los - // Angeles will not. + // ~45 m cells inside a focus region, ~270 m outside one. + // + // This is the LOD ARCHITECTURE.md §5 said Los Angeles would need, arriving a + // city early: a uniform 45 m lattice over the whole bay is 3.7M points, and + // that is a map nobody can load. The first region is exactly the bounds this + // pack used to have, so San Francisco is built at the resolution it has + // always been built at and renders identically. The other two are the two + // places outside the city where a chapter puts the camera near the ground, + // which is the rule: fine where a chapter lands, coarse everywhere else. + // + // Nothing else needs it. The Peninsula's bay edge and the Santa Clara Valley + // are flat, so a coarser lattice loses nothing there; the coastlines stay + // crisp at any resolution regardless, because `terrain.ts` draws them from + // the shore plates rather than from the grid. cellLat: 0.000404, cellLng: 0.000511, - focusRegions: [{ minLat: 37.6975, maxLat: 37.8925, minLng: -122.5725, maxLng: -122.2175 }], + coarseFactor: 10, + /** + * ONE focus region, and a high coarse factor, because of how `buildAxis` in + * world.ts refines: per axis, not per rectangle. A focus region sharpens its + * whole row *and* its whole column, so two regions at opposite corners of a + * board refine almost all of it. Measured on this pack: one region is 0.53M + * lattice points and a 2.3 s field build; adding the Peninsula and South Bay + * regions took it to 1.64M and 9 s for detail nobody is looking at from a + * board this wide. + * + * The consequence is deliberate: San Francisco itself stays at ~45 m cells, + * and the rest of the bay renders at 450 m. At this zoom that is the right + * trade. A true quadtree would let every region be fine, and is the thing to + * build if the wider bay ever needs to be walked rather than looked at. + */ + focusRegions: [ + { minLat: 37.6975, maxLat: 37.8925, minLng: -122.5725, maxLng: -122.2175 }, + ], coastFalloff: 0.0018, // ~200 m landmasses: LANDMASSES, parks: PARKS, inlandWater: INLAND_WATER, - hills: HILLS, + hills: ALL_HILLS, districts: DISTRICTS, landmarks: LANDMARKS, bridges: BRIDGES, diff --git a/src/cities/socal.ts b/src/cities/socal.ts new file mode 100644 index 0000000..b0c31f1 --- /dev/null +++ b/src/cities/socal.ts @@ -0,0 +1,3152 @@ +/** + * Southern California — Los Angeles, Orange County and Riverside, as one Tera + * city pack. + * + * Pure data, and traced by hand from scratch for the same reason San Francisco + * was: OpenStreetMap is ODbL, ODbL is share-alike, and a share-alike obligation + * cannot live inside an Apache-2.0 repo without infecting it. Every coastline, + * ridge and district boundary below is original expression. See + * ARCHITECTURE.md §3.2. + * + * **This is not San Francisco with more rows.** SF is 0.20° × 0.36°, a city you + * can hold in one frame at 45 m cells. The frame that actually holds Malibu, + * Catalina, the San Gabriel crest and downtown Riverside is 1.08° × 1.66° — a + * little over twenty-five times the area. ARCHITECTURE.md §5 estimated fourteen + * from a tighter box; the argument only gets stronger with the real one. A + * uniform 45 m lattice over this would be close to nine million points and a + * mesh nobody can load, so the pack declares seven `focusRegions` at 80 m and + * lets the basin between them run at 480 m. That is the whole reason those two + * fields exist. + * + * The second thing that does not transfer is scale. The engine's camera is + * capped at 340 units from its target and its fog closes at 460, so a city has + * to fit inside roughly the same scene footprint whatever its size on the + * ground. San Francisco gets 94 m to the scene unit; Southern California gets + * 391. Everything downstream follows from that one number: a building lot is + * 164 m rather than 40, a "building" is really a city block, and the towers are + * small because at this remove they genuinely are. LA's story from above is not + * its skyline. It is a basin ninety kilometres across with a three-kilometre + * wall along the north side of it, and that is what this file is arranged to + * show. + */ + +import type { + Bridge, + City, + District, + FocusRegion, + Hill, + Landmark, + LatLng, +} from "../engine/types.ts"; + +// ---- Coastlines ----------------------------------------------------------- + +/** + * The mainland, traced west to east along the shore and then closed off-frame. + * + * The order of the coast is the order of the drive: down the Malibu shelf, + * around the curve of Santa Monica Bay, out onto the Palos Verdes headland, in + * through the harbour, along the Long Beach and Orange County strand, and off + * at San Mateo Point where the county line is. + * + * Two features carry the whole silhouette. **Santa Monica Bay** is a genuine + * arc — the shore turns roughly sixty degrees between the Malibu shelf, which + * runs almost due east, and the South Bay strand, which runs almost due south — + * and getting that curve wrong makes the map read as anywhere. **Palos Verdes** + * is the blunt headland that closes the bay's south end; without it Los Angeles + * has no southern edge at all and the harbour makes no sense. + * + * The last three vertices are deliberately outside `bounds`. The world's + * coastal falloff ramps relief to zero near *any* polygon edge, including an + * artificial one, so a boundary drawn on the frame line would leave a flattened + * band of basin along the north and east of the map. Pushed off-frame, the + * falloff lands on lattice points that are never sampled. + */ +export const SOUTHLAND: LatLng[] = [ + // North-west, off-frame + [34.4, -119.02], + + // The Malibu shelf, west -> east. The mountains come straight down to the + // water here, which is why there is one road and no city. + [34.06, -118.98], // the Ventura county line, at Leo Carrillo + [34.038, -118.93], + [34.023, -118.87], // Nicholas Canyon + [34.017, -118.828], // Zuma + [33.996, -118.806], // Point Dume — the only headland on this stretch + [34.01, -118.79], // Paradise Cove + [34.026, -118.76], + [34.034, -118.72], + [34.036, -118.678], // Malibu Pier and the lagoon + [34.039, -118.635], + [34.04, -118.585], // Topanga Canyon meets the sea + [34.038, -118.56], + [34.037, -118.525], // Will Rogers + + // Santa Monica Bay. The shore swings from east-west to north-south over + // about twenty kilometres; this is the curve the eye recognises. + [34.028, -118.51], + [34.009, -118.498], // Santa Monica Pier + [33.99, -118.482], + [33.977, -118.472], // Venice Pier + [33.965, -118.458], + [33.958, -118.452], // the Marina and Ballona Creek mouth + [33.945, -118.44], // Dockweiler, directly under LAX's departures + [33.928, -118.433], + [33.905, -118.421], // El Segundo + [33.884, -118.411], // Manhattan Beach Pier + [33.862, -118.403], // Hermosa Pier + [33.844, -118.393], // Redondo and King Harbor + [33.822, -118.392], + + // The Palos Verdes peninsula: uplifted marine terraces, a blunt nose to the + // south-west, and cliffs the whole way round. + [33.806, -118.396], // Malaga Cove — the terraces begin + [33.79, -118.406], + [33.771, -118.412], // Bluff Cove + [33.752, -118.416], + [33.742, -118.41], // Point Vicente + [33.735, -118.39], + [33.733, -118.365], // Portuguese Bend + [33.727, -118.34], + [33.718, -118.318], // Royal Palms + [33.708, -118.3], + [33.705, -118.294], // Point Fermin, the southern tip of the mainland + + // The Port of Los Angeles and Long Beach. The trace runs up the main channel + // to Wilmington and back down the far side, which leaves the outer harbour as + // open water with Terminal Island sitting in the middle of it. + [33.712, -118.282], // Cabrillo Beach + [33.726, -118.278], + [33.746, -118.276], // the San Pedro waterfront + [33.762, -118.274], + [33.774, -118.27], // the head of the main channel, at Wilmington + [33.778, -118.25], + [33.776, -118.232], // west Long Beach and the Dominguez Channel + [33.766, -118.222], + [33.76, -118.213], // the Long Beach container terminals + [33.766, -118.198], // Queensway + [33.762, -118.186], + [33.76, -118.17], + [33.759, -118.15], + [33.757, -118.135], // Belmont Shore + [33.748, -118.12], + [33.742, -118.112], // the mouth of Alamitos Bay + [33.74, -118.1], // Seal Beach + + // The Orange County strand: forty kilometres of almost unbroken sand, then + // the coast turns and climbs into the San Joaquin Hills. + [33.734, -118.078], + [33.722, -118.068], // Surfside and Sunset Beach + [33.706, -118.058], + [33.69, -118.044], // Bolsa Chica + [33.672, -118.02], + [33.656, -118.0], // Huntington Beach Pier + [33.64, -117.978], + [33.63, -117.96], // the Santa Ana River mouth + [33.618, -117.942], + [33.608, -117.929], // Newport Pier + [33.598, -117.9], + [33.593, -117.881], // the harbour entrance, off the end of the Balboa peninsula + [33.59, -117.872], // Corona del Mar + [33.583, -117.855], + [33.57, -117.833], // Crystal Cove + [33.556, -117.806], + [33.542, -117.783], // Laguna Beach, where the hills reach the water + [33.526, -117.766], + [33.51, -117.753], // Aliso Beach + [33.494, -117.732], + [33.488, -117.725], // Salt Creek + [33.47, -117.712], + [33.462, -117.706], // Dana Point — the last headland on this map + [33.46, -117.69], // Doheny, at the mouth of San Juan Creek + [33.446, -117.667], + [33.428, -117.63], + [33.418, -117.615], // the San Clemente pier + [33.4, -117.598], + [33.386, -117.593], // San Mateo Point, the San Diego county line + + // Off-frame, closing round the east and north. Camp Pendleton, the high + // desert and San Bernardino are all past the edge of this board. + [33.24, -117.5], + [33.2, -117.16], + [34.4, -117.14], +]; + +/** + * Terminal Island: dredge spoil, container cranes and a federal prison, sitting + * between San Pedro and Long Beach. It is here because the harbour reads as a + * harbour only if there is something in it for the two bridges to land on. + */ +export const TERMINAL_ISLAND: LatLng[] = [ + [33.769, -118.264], + [33.77, -118.242], + [33.76, -118.227], + [33.742, -118.228], + [33.735, -118.258], + [33.746, -118.268], +]; + +/** + * Santa Catalina, thirty-five kilometres offshore. + * + * It earns its place twice over: it is the one thing in the south-west quarter + * of the frame that is not empty water, and on the days you can actually see it + * from the mainland it is the reason anyone believes the basin has a horizon. + * Long and thin, north-west to south-east, pinched almost in two at the isthmus + * above Two Harbors. + */ +export const CATALINA: LatLng[] = [ + [33.475, -118.6], // the West End + [33.462, -118.565], + [33.4425, -118.5], // the isthmus at Two Harbors + [33.428, -118.462], + [33.408, -118.428], + [33.39, -118.395], // Long Point + [33.362, -118.348], + [33.3425, -118.328], // Avalon + [33.318, -118.305], // the East End + [33.308, -118.322], + [33.332, -118.362], + [33.36, -118.408], + [33.388, -118.452], + [33.412, -118.492], + [33.432, -118.535], + [33.455, -118.592], +]; + +/** Everything the buildings may stand on. */ +export const LANDMASSES = [SOUTHLAND, TERMINAL_ISLAND, CATALINA]; + +// ---- Parks and open space ------------------------------------------------- + +/** + * Open space does two jobs here. It paints the ranges green, and — because + * `blocks.ts` refuses to build inside a park — it is what keeps the procedural + * scatter off the mountains without needing a slope test in the engine. + * + * These polygons are drawn to the *shape of the wild land*, not to any agency's + * boundary. The Angeles National Forest polygon is really "the San Gabriels", + * and its southern edge is the range front: the line where the suburbs stop + * dead and the mountain starts, which in this metro is a line you can see from + * space. + */ + +/** The San Gabriels. The south edge is the range front, Sylmar to Cajon. */ +export const ANGELES_FOREST: LatLng[] = [ + [34.38, -118.52], + [34.38, -117.32], + [34.22, -117.38], + [34.18, -117.45], + [34.155, -117.57], + [34.145, -117.7], + [34.16, -117.79], + [34.17, -117.9], + [34.175, -117.99], + [34.185, -118.05], + [34.21, -118.13], + [34.235, -118.2], + [34.27, -118.3], + [34.305, -118.4], + [34.32, -118.47], +]; + +/** The Santa Susanas, closing the San Fernando Valley off to the north-west. */ +export const SANTA_SUSANA: LatLng[] = [ + [34.33, -118.72], + [34.33, -118.56], + [34.25, -118.57], + [34.24, -118.7], +]; + +/** + * The Santa Monica Mountains, Malibu to Griffith Park. + * + * A single east-west band forty kilometres long and rarely more than eight + * wide. It matters structurally: this is the wall that separates the coastal + * plain from the San Fernando Valley, and every crossing of it — Sepulveda, + * Cahuenga, Topanga — is a pass with a freeway or a boulevard in it. + */ +export const SANTA_MONICA_MOUNTAINS: LatLng[] = [ + [34.132, -118.93], + [34.135, -118.7], + [34.138, -118.56], + [34.132, -118.5], + [34.125, -118.455], + [34.118, -118.42], + [34.108, -118.38], + [34.092, -118.345], + [34.08, -118.36], + [34.076, -118.425], + [34.072, -118.472], + [34.062, -118.525], + [34.043, -118.6], + [34.04, -118.7], + [34.045, -118.82], + [34.05, -118.93], +]; + +/** Griffith Park — the east end of the Santa Monicas, inside the city. */ +export const GRIFFITH_PARK: LatLng[] = [ + [34.15, -118.32], + [34.148, -118.28], + [34.11, -118.28], + [34.105, -118.32], +]; + +/** Elysian Park, the ridge Dodger Stadium sits on top of. */ +export const ELYSIAN_PARK: LatLng[] = [ + [34.09, -118.25], + [34.089, -118.232], + [34.07, -118.235], + [34.072, -118.252], +]; + +export const EXPOSITION_PARK: LatLng[] = [ + [34.021, -118.292], + [34.0205, -118.279], + [34.01, -118.28], + [34.011, -118.293], +]; + +/** + * The Baldwin Hills: a low dome of oil derricks and scrub between Culver City + * and the airport, and the only relief on the whole coastal plain. + */ +export const BALDWIN_HILLS: LatLng[] = [ + [34.013, -118.39], + [34.012, -118.35], + [33.988, -118.354], + [33.99, -118.393], +]; + +/** The Sepulveda Basin — a flood-control bowl doubling as the Valley's park. */ +export const SEPULVEDA_BASIN: LatLng[] = [ + [34.185, -118.505], + [34.184, -118.46], + [34.16, -118.462], + [34.162, -118.506], +]; + +export const HANSEN_DAM: LatLng[] = [ + [34.28, -118.4], + [34.279, -118.365], + [34.255, -118.367], + [34.257, -118.402], +]; + +/** The Arroyo Seco, running south out of the range through Pasadena. */ +export const ARROYO_SECO: LatLng[] = [ + [34.2, -118.185], + [34.199, -118.16], + [34.125, -118.168], + [34.126, -118.19], +]; + +/** The Puente Hills — the ridge that divides the basin from the inland valleys. */ +export const PUENTE_HILLS: LatLng[] = [ + [33.995, -118.06], + [33.99, -117.86], + [33.945, -117.87], + [33.95, -118.065], +]; + +export const SAN_JOSE_HILLS: LatLng[] = [ + [34.055, -117.88], + [34.05, -117.77], + [34.005, -117.78], + [34.01, -117.89], +]; + +export const CHINO_HILLS: LatLng[] = [ + [33.97, -117.78], + [33.96, -117.63], + [33.86, -117.66], + [33.88, -117.8], +]; + +/** + * The Santa Ana Mountains — Cleveland National Forest, and the reason Orange + * County ends where it does. Traced down the western flank and back up the + * eastern one, so the band follows the ridge instead of boxing it. + */ +export const SANTA_ANA_MOUNTAINS: LatLng[] = [ + [33.9, -117.68], + [33.8, -117.63], + [33.7, -117.585], + [33.61, -117.52], + [33.53, -117.47], + [33.49, -117.36], + [33.59, -117.39], + [33.69, -117.43], + [33.79, -117.48], + [33.89, -117.545], +]; + +/** The San Joaquin Hills, which is why Laguna is a cove town and not a grid. */ +export const SAN_JOAQUIN_HILLS: LatLng[] = [ + [33.63, -117.85], + [33.62, -117.74], + [33.55, -117.72], + [33.54, -117.81], + [33.58, -117.85], +]; + +/** Loma Ridge and the Irvine Ranch open space, along the back of Irvine. */ +export const LOMA_RIDGE: LatLng[] = [ + [33.79, -117.76], + [33.78, -117.66], + [33.72, -117.69], + [33.73, -117.79], +]; + +/** The seaward slope of Palos Verdes, above the terraces. */ +export const PALOS_VERDES_OPEN: LatLng[] = [ + [33.77, -118.405], + [33.762, -118.36], + [33.73, -118.3355], + [33.715, -118.36], + [33.735, -118.4], +]; + +/** Catalina's interior, which is conservancy land nearly end to end. */ +export const CATALINA_INTERIOR: LatLng[] = [ + [33.47, -118.59], + [33.44, -118.5], + [33.39, -118.42], + [33.34, -118.34], + [33.33, -118.355], + [33.38, -118.44], + [33.43, -118.52], + [33.455, -118.595], +]; + +/** Mount Rubidoux and Fairmount Park, downtown Riverside's green edge. */ +export const RUBIDOUX: LatLng[] = [ + [33.995, -117.398], + [33.993, -117.378], + [33.975, -117.38], + [33.977, -117.4], +]; + +/** Box Springs Mountain, the brown lump east of Riverside. */ +export const BOX_SPRINGS: LatLng[] = [ + [33.97, -117.3], + [33.965, -117.24], + [33.915, -117.25], + [33.92, -117.31], +]; + +export const JURUPA_HILLS: LatLng[] = [ + [34.015, -117.49], + [34.01, -117.42], + [33.985, -117.425], + [33.99, -117.495], +]; + +export const EL_DORADO_PARK: LatLng[] = [ + [33.81, -118.09], + [33.809, -118.075], + [33.79, -118.076], + [33.791, -118.091], +]; + +export const PARKS = [ + ANGELES_FOREST, + SANTA_SUSANA, + SANTA_MONICA_MOUNTAINS, + GRIFFITH_PARK, + ELYSIAN_PARK, + EXPOSITION_PARK, + BALDWIN_HILLS, + SEPULVEDA_BASIN, + HANSEN_DAM, + ARROYO_SECO, + PUENTE_HILLS, + SAN_JOSE_HILLS, + CHINO_HILLS, + SANTA_ANA_MOUNTAINS, + SAN_JOAQUIN_HILLS, + LOMA_RIDGE, + PALOS_VERDES_OPEN, + CATALINA_INTERIOR, + RUBIDOUX, + BOX_SPRINGS, + JURUPA_HILLS, + EL_DORADO_PARK, +]; + +// ---- Rivers and inland water ---------------------------------------------- + +/** + * The Los Angeles River, from the Glendale Narrows to the sea. + * + * Two compromises are baked into this polygon and both are worth stating. + * + * The channel is drawn about four times its true width. At 391 m to the scene + * unit the real thing is narrower than a single coarse terrain cell, and a + * feature narrower than the lattice does not render as a thin line — it renders + * as a dotted one, appearing only where a cell corner happens to land inside + * it. Five hundred metres is the smallest width that guarantees every lattice + * row crossing the river has a point inside it. + * + * And it is only traced from the Narrows down. The engine has no river + * primitive, so inland water sits at sea level and the terrain around it does + * not, which means a channel reads as a trench as deep as the local ground is + * high. Below downtown the plain is under a hundred metres and the trench looks + * like a floodplain, which is fair. Through the San Fernando Valley the ground + * is over two hundred, and the same trick would have cut a canyon across the + * Valley floor. The Valley reach is a soft-bottom wash anyway; leaving it out + * costs the map less than drawing it wrong. + */ +export const LA_RIVER: LatLng[] = [ + // east bank, north -> south + [34.125, -118.267], + [34.1, -118.242], + [34.075, -118.227], + [34.05, -118.222], + [34.02, -118.212], + [33.99, -118.202], + [33.96, -118.192], + [33.92, -118.187], + [33.88, -118.187], + [33.84, -118.192], + [33.8, -118.195], + [33.775, -118.202], + [33.76, -118.202], + // west bank, south -> north + [33.76, -118.208], + [33.775, -118.208], + [33.8, -118.201], + [33.84, -118.198], + [33.88, -118.193], + [33.92, -118.193], + [33.96, -118.198], + [33.99, -118.208], + [34.02, -118.218], + [34.05, -118.228], + [34.075, -118.233], + [34.1, -118.248], + [34.125, -118.273], +]; + +/** + * The Santa Ana River, out of the Prado basin and across Orange County. + * + * It is on the map because it is the organising line of the whole county: the + * ranchos were surveyed off it, the cities sit on it, and it explains why the + * grid on one side of Anaheim does not match the grid on the other. + */ +export const SANTA_ANA_RIVER: LatLng[] = [ + [33.945, -117.545], + [33.895, -117.62], + [33.878, -117.69], + [33.868, -117.77], + [33.852, -117.83], + [33.81, -117.875], + [33.762, -117.895], + [33.71, -117.925], + [33.665, -117.95], + [33.633, -117.963], + [33.627, -117.957], + [33.66, -117.944], + [33.705, -117.919], + [33.757, -117.889], + [33.805, -117.869], + [33.847, -117.824], + [33.863, -117.764], + [33.873, -117.684], + [33.89, -117.614], + [33.94, -117.539], +]; + +/** Upper Newport Bay and the harbour — Orange County's one real estuary. */ +export const NEWPORT_BAY: LatLng[] = [ + [33.665, -117.888], + [33.66, -117.879], + [33.63, -117.883], + [33.615, -117.89], + [33.598, -117.89], + [33.596, -117.899], + [33.612, -117.908], + [33.625, -117.908], + [33.642, -117.896], + [33.66, -117.896], +]; + +export const ALAMITOS_BAY: LatLng[] = [ + [33.748, -118.118], + [33.746, -118.105], + [33.738, -118.106], + [33.74, -118.12], +]; + +export const MARINA_DEL_REY: LatLng[] = [ + [33.98, -118.452], + [33.979, -118.438], + [33.97, -118.439], + [33.971, -118.453], +]; + +export const BALLONA_WETLANDS: LatLng[] = [ + [33.967, -118.44], + [33.966, -118.418], + [33.956, -118.419], + [33.957, -118.441], +]; + +export const BOLSA_CHICA: LatLng[] = [ + [33.705, -118.06], + [33.702, -118.04], + [33.685, -118.045], + [33.688, -118.064], +]; + +/** Silver Lake Reservoir. Small, but a chapter is named after it. */ +export const SILVER_LAKE: LatLng[] = [ + [34.098, -118.273], + [34.097, -118.267], + [34.086, -118.268], + [34.087, -118.274], +]; + +export const SEPULVEDA_LAKE: LatLng[] = [ + [34.176, -118.488], + [34.175, -118.477], + [34.168, -118.478], + [34.169, -118.489], +]; + +export const PUDDINGSTONE: LatLng[] = [ + [34.093, -117.815], + [34.092, -117.802], + [34.085, -117.803], + [34.086, -117.816], +]; + +export const SANTIAGO_RESERVOIR: LatLng[] = [ + [33.77, -117.725], + [33.768, -117.705], + [33.745, -117.71], + [33.748, -117.73], +]; + +export const PRADO_BASIN: LatLng[] = [ + [33.9, -117.66], + [33.895, -117.63], + [33.87, -117.635], + [33.875, -117.665], +]; + +export const LAKE_MATHEWS: LatLng[] = [ + [33.87, -117.46], + [33.868, -117.42], + [33.838, -117.425], + [33.842, -117.465], +]; + +export const LAKE_ELSINORE: LatLng[] = [ + [33.7, -117.39], + [33.69, -117.335], + [33.64, -117.32], + [33.63, -117.355], + [33.66, -117.395], +]; + +export const INLAND_WATER = [ + LA_RIVER, + SANTA_ANA_RIVER, + NEWPORT_BAY, + ALAMITOS_BAY, + MARINA_DEL_REY, + BALLONA_WETLANDS, + BOLSA_CHICA, + SILVER_LAKE, + SEPULVEDA_LAKE, + PUDDINGSTONE, + SANTIAGO_RESERVOIR, + PRADO_BASIN, + LAKE_MATHEWS, + LAKE_ELSINORE, +]; + +// ---- Relief --------------------------------------------------------------- + +/** + * The mountains, and the reason Los Angeles is a basin at all. + * + * San Francisco's relief is hills every four blocks. Southern California's is + * the opposite shape: a flat floor a hundred kilometres wide with ranges around + * the rim. Read clockwise from the north-west, the rim is the Santa Susanas, + * the San Gabriels — three thousand metres and effectively a wall — the San + * Bernardino front off the east edge of the frame, the Santa Anas behind Orange + * County, and the San Joaquin Hills coming back to the sea at Laguna. Inside + * the rim, only four things break the floor: the Santa Monica Mountains running + * east-west through the middle of the city, the Verdugos, the Puente Hills, and + * Palos Verdes out on its own. + * + * **Spacing is a constraint, not an accident.** `World.elevationAt` combines + * overlapping peaks as `tallest + 35% of the rest`, which is what stops a ridge + * having a notch in the middle of it — but it also means two summits closer + * together than their radii will read taller than either. The San Gabriel crest + * is therefore laid out as well-separated peaks with generous radii: neighbours + * still overlap enough to build a continuous wall, but no summit is inflated by + * more than about fifteen per cent. Where you see a peak missing that ought to + * be there — Cahuenga next to Mount Lee, Ontario next to Baldy — it was left + * out on purpose, because including it would have grown a mountain that is not + * there. + * + * The five entries at the end are not hills. They are the basins themselves, + * declared as very broad, very low domes so the map keeps the tilt that Los + * Angeles actually has: the beach is at sea level, downtown is at ninety + * metres, the Valley is at two hundred and Riverside is at two hundred and + * sixty. Being broad, they contribute almost nothing to the summits above them. + */ +export const HILLS: Hill[] = [ + // ---- The San Gabriels: the wall ---- + { + name: "Mount Gleason", + lat: 34.335, + lng: -118.185, + elevation: 1901, + radius: 0.105, + }, + { + name: "Mount Lukens", + lat: 34.278, + lng: -118.243, + elevation: 1547, + radius: 0.085, + }, + { + name: "Josephine Peak", + lat: 34.268, + lng: -118.148, + elevation: 1710, + radius: 0.075, + }, + { + name: "Mount Wilson", + lat: 34.226, + lng: -118.062, + elevation: 1742, + radius: 0.09, + }, + { + name: "Mount Waterman", + lat: 34.328, + lng: -117.94, + elevation: 2440, + radius: 0.105, + }, + { + name: "Mount Islip", + lat: 34.315, + lng: -117.85, + elevation: 2469, + radius: 0.085, + }, + { + name: "Mount Baden-Powell", + lat: 34.356, + lng: -117.765, + elevation: 2867, + radius: 0.095, + }, + // Mount Baldy is the high point of the whole map and the thing you look for + // from anywhere in the basin on a clear day. + { + name: "Mount Baldy", + lat: 34.289, + lng: -117.646, + elevation: 3068, + radius: 0.13, + }, + { + name: "Cucamonga Peak", + lat: 34.221, + lng: -117.59, + elevation: 2670, + radius: 0.095, + }, + { + name: "Cajon Shoulder", + lat: 34.235, + lng: -117.42, + elevation: 1500, + radius: 0.085, + }, + + // The front-range foothills, so the wall has a toe. Without these the range + // starts abruptly in mid-air above Pasadena, which is exactly wrong: the + // whole drama of the San Gabriel front is how little runway it takes. + { + name: "Altadena Front", + lat: 34.202, + lng: -118.13, + elevation: 900, + radius: 0.05, + }, + { + name: "Monrovia Peak", + lat: 34.213, + lng: -117.98, + elevation: 1600, + radius: 0.065, + }, + { + name: "Glendora Ridge", + lat: 34.18, + lng: -117.83, + elevation: 1100, + radius: 0.06, + }, + { + name: "Claremont Front", + lat: 34.172, + lng: -117.68, + elevation: 1000, + radius: 0.055, + }, + { + name: "Oat Mountain", + lat: 34.305, + lng: -118.615, + elevation: 1128, + radius: 0.07, + }, + { + name: "Santa Susana Knolls", + lat: 34.268, + lng: -118.66, + elevation: 640, + radius: 0.045, + }, + + // ---- The Verdugos and the San Rafaels ---- + // A compact ridge standing alone in the middle of the metro, which is why + // Glendale and Burbank are two cities and not one. + { + name: "Verdugo Peak", + lat: 34.228, + lng: -118.285, + elevation: 940, + radius: 0.048, + }, + { + name: "Mount Thom", + lat: 34.205, + lng: -118.33, + elevation: 700, + radius: 0.04, + }, + { + name: "San Rafael Hills", + lat: 34.17, + lng: -118.215, + elevation: 500, + radius: 0.04, + }, + + // ---- The Santa Monica Mountains ---- + { + name: "Castro Peak", + lat: 34.073, + lng: -118.755, + elevation: 787, + radius: 0.048, + }, + { + name: "Saddle Peak", + lat: 34.086, + lng: -118.648, + elevation: 878, + radius: 0.045, + }, + { + name: "Topanga Ridge", + lat: 34.093, + lng: -118.598, + elevation: 700, + radius: 0.04, + }, + { + name: "Temescal Peak", + lat: 34.09, + lng: -118.545, + elevation: 692, + radius: 0.042, + }, + // Held west of the pass and kept tight. The Sepulveda Pass is the gap the 405 + // uses to get from the Westside to the Valley, and a hill spilling into it + // would close the only door in a forty-kilometre wall. + { + name: "San Vicente Mountain", + lat: 34.108, + lng: -118.5, + elevation: 590, + radius: 0.03, + }, + { + name: "Getty Ridge", + lat: 34.081, + lng: -118.477, + elevation: 280, + radius: 0.024, + }, + { + name: "Stone Canyon", + lat: 34.104, + lng: -118.44, + elevation: 380, + radius: 0.03, + }, + { + name: "Franklin Canyon", + lat: 34.115, + lng: -118.41, + elevation: 420, + radius: 0.03, + }, + // Mount Lee carries the Hollywood sign on its south face. Cahuenga Peak is + // nine hundredths of a degree away and is deliberately absent: summed in, the + // pair would have made an eight-hundred-metre mountain over Hollywood. + { + name: "Mount Lee", + lat: 34.134, + lng: -118.321, + elevation: 517, + radius: 0.03, + }, + { + name: "Mount Hollywood", + lat: 34.128, + lng: -118.3, + elevation: 500, + radius: 0.032, + }, + { + name: "Elysian Hills", + lat: 34.083, + lng: -118.24, + elevation: 160, + radius: 0.022, + }, + + // ---- Inside the basin ---- + { + name: "Baldwin Hills", + lat: 34.0, + lng: -118.372, + elevation: 156, + radius: 0.03, + }, + { + name: "Repetto Hills", + lat: 34.03, + lng: -118.12, + elevation: 180, + radius: 0.032, + }, + { + name: "Dominguez Hills", + lat: 33.865, + lng: -118.243, + elevation: 65, + radius: 0.018, + }, + // Signal Hill is tiny and it is here anyway: a bare oil-derrick knob rising + // straight out of flat Long Beach, and one of the few points in the south of + // the basin you can orient off. + { + name: "Signal Hill", + lat: 33.804, + lng: -118.167, + elevation: 111, + radius: 0.013, + }, + + // ---- Palos Verdes ---- + { + name: "San Pedro Hill", + lat: 33.748, + lng: -118.352, + elevation: 448, + radius: 0.035, + }, + { + name: "Palos Verdes Bluff", + lat: 33.766, + lng: -118.398, + elevation: 240, + radius: 0.022, + }, + { + name: "Point Fermin Rise", + lat: 33.716, + lng: -118.302, + elevation: 130, + radius: 0.018, + }, + + // ---- The Puente, San Jose and Chino hills ---- + { + name: "Whittier Hills", + lat: 33.975, + lng: -118.03, + elevation: 400, + radius: 0.038, + }, + { + name: "Workman Hill", + lat: 33.96, + lng: -117.9, + elevation: 430, + radius: 0.042, + }, + { + name: "San Jose Hills", + lat: 34.03, + lng: -117.83, + elevation: 470, + radius: 0.038, + }, + { + name: "Chino Hills", + lat: 33.925, + lng: -117.72, + elevation: 500, + radius: 0.055, + }, + + // ---- The Santa Anas ---- + { + name: "Sierra Peak", + lat: 33.878, + lng: -117.635, + elevation: 1120, + radius: 0.055, + }, + { + name: "Pleasants Peak", + lat: 33.795, + lng: -117.578, + elevation: 1200, + radius: 0.05, + }, + { + name: "Santiago Peak", + lat: 33.71, + lng: -117.532, + elevation: 1734, + radius: 0.075, + }, + { + name: "Trabuco Peak", + lat: 33.66, + lng: -117.49, + elevation: 1370, + radius: 0.055, + }, + { + name: "Ortega Ridge", + lat: 33.6, + lng: -117.44, + elevation: 1100, + radius: 0.055, + }, + { + name: "Loma Ridge", + lat: 33.76, + lng: -117.72, + elevation: 480, + radius: 0.045, + }, + { + name: "Anaheim Hills", + lat: 33.85, + lng: -117.74, + elevation: 350, + radius: 0.04, + }, + + // ---- The San Joaquin Hills ---- + { + name: "Signal Peak", + lat: 33.605, + lng: -117.815, + elevation: 320, + radius: 0.038, + }, + { + name: "Laguna Ridge", + lat: 33.56, + lng: -117.76, + elevation: 300, + radius: 0.032, + }, + { + name: "San Onofre Hills", + lat: 33.43, + lng: -117.57, + elevation: 280, + radius: 0.04, + }, + + // ---- The Inland Empire ---- + { + name: "Box Springs Mountain", + lat: 33.94, + lng: -117.27, + elevation: 1030, + radius: 0.045, + }, + { + name: "Jurupa Hills", + lat: 34.0, + lng: -117.46, + elevation: 550, + radius: 0.035, + }, + { + name: "Mount Rubidoux", + lat: 33.984, + lng: -117.386, + elevation: 395, + radius: 0.014, + }, + { + name: "Sycamore Canyon", + lat: 33.93, + lng: -117.32, + elevation: 480, + radius: 0.025, + }, + { + name: "La Sierra Hills", + lat: 33.9, + lng: -117.47, + elevation: 400, + radius: 0.03, + }, + { + name: "Norco Hills", + lat: 33.92, + lng: -117.52, + elevation: 350, + radius: 0.03, + }, + { + name: "Slover Mountain", + lat: 34.055, + lng: -117.33, + elevation: 400, + radius: 0.02, + }, + + // ---- Catalina ---- + { + name: "Mount Orizaba", + lat: 33.383, + lng: -118.418, + elevation: 648, + radius: 0.042, + }, + { + name: "Silver Peak", + lat: 33.455, + lng: -118.56, + elevation: 486, + radius: 0.03, + }, + { + name: "Avalon Ridge", + lat: 33.352, + lng: -118.345, + elevation: 400, + radius: 0.022, + }, + + // ---- The floors ---- + // Not mountains. Broad low domes that give each basin the elevation it really + // has, so the map keeps its inland tilt instead of rendering ninety + // kilometres of dead flat at sea level. + { + name: "Los Angeles Plain", + lat: 34.03, + lng: -118.23, + elevation: 95, + radius: 0.16, + }, + { + name: "San Fernando Valley Floor", + lat: 34.195, + lng: -118.45, + elevation: 220, + radius: 0.18, + }, + { + name: "San Gabriel Valley Floor", + lat: 34.08, + lng: -117.94, + elevation: 140, + radius: 0.18, + }, + { + name: "Inland Empire Floor", + lat: 33.95, + lng: -117.4, + elevation: 260, + radius: 0.17, + }, + { + name: "Orange County Plain", + lat: 33.76, + lng: -117.88, + elevation: 45, + radius: 0.14, + }, +]; + +// ---- Streets and freeways ------------------------------------------------- + +/** + * In San Francisco the line that had to be drawn first was Market Street. Here + * it is the freeways, all of them, because in this metro they are the only + * lines long enough to tie two hundred square kilometres of identical grid + * together. Nobody navigates Los Angeles by neighbourhood; they navigate it by + * which freeway they are on. + * + * Widths are smaller than San Francisco's for the same paths because they are + * in scene units and a scene unit here is four times as long: 0.14 is a + * fifty-metre freeway and 0.1 a thirty-seven-metre boulevard, which is about + * right for both. + */ + +/** I-405, Sylmar to El Toro, through the Sepulveda Pass. */ +export const I405: LatLng[] = [ + [34.282, -118.468], + [34.24, -118.472], + [34.2, -118.47], + [34.16, -118.468], + [34.13, -118.472], + [34.1, -118.472], // the Sepulveda Pass + [34.07, -118.456], + [34.04, -118.43], + [34.01, -118.403], + [33.98, -118.39], + [33.945, -118.386], // past LAX + [33.91, -118.372], + [33.875, -118.35], + [33.85, -118.315], + [33.834, -118.272], + [33.826, -118.23], + [33.82, -118.19], + [33.8, -118.13], + [33.775, -118.075], + [33.745, -118.01], + [33.72, -117.955], + [33.69, -117.9], + [33.678, -117.845], + [33.66, -117.78], + [33.63, -117.7], +]; + +/** I-5, Sylmar to San Clemente. The one road that crosses the whole board. */ +export const I5: LatLng[] = [ + [34.32, -118.46], + [34.27, -118.42], + [34.23, -118.39], + [34.19, -118.36], + [34.16, -118.335], + [34.13, -118.28], + [34.105, -118.25], + [34.08, -118.235], + [34.055, -118.225], + [34.035, -118.205], + [34.01, -118.17], + [33.98, -118.13], + [33.94, -118.09], + [33.9, -118.06], + [33.87, -118.025], + [33.84, -117.98], + [33.8, -117.92], + [33.76, -117.87], + [33.72, -117.82], + [33.68, -117.78], + [33.63, -117.7], + [33.58, -117.66], + [33.52, -117.63], + [33.46, -117.63], + [33.42, -117.61], + [33.39, -117.59], +]; + +/** US-101, Woodland Hills to downtown, over the Cahuenga Pass. */ +export const US101: LatLng[] = [ + [34.165, -118.62], + [34.16, -118.56], + [34.155, -118.5], + [34.15, -118.44], + [34.145, -118.39], + [34.138, -118.355], + [34.125, -118.34], // the Cahuenga Pass + [34.105, -118.33], + [34.095, -118.31], + [34.085, -118.29], + [34.075, -118.265], + [34.06, -118.24], +]; + +/** I-10, the pier at Santa Monica to Colton. */ +export const I10: LatLng[] = [ + [34.018, -118.493], + [34.025, -118.46], + [34.03, -118.42], + [34.033, -118.38], + [34.038, -118.34], + [34.04, -118.3], + [34.038, -118.265], + [34.04, -118.235], + [34.045, -118.2], + [34.052, -118.15], + [34.06, -118.1], + [34.065, -118.04], + [34.068, -117.96], + [34.065, -117.88], + [34.06, -117.8], + [34.062, -117.7], + [34.065, -117.6], + [34.068, -117.5], + [34.07, -117.4], + [34.072, -117.3], +]; + +/** I-110, the Arroyo Seco Parkway down to the harbour. */ +export const I110: LatLng[] = [ + [34.135, -118.165], + [34.115, -118.19], + [34.098, -118.213], + [34.08, -118.228], + [34.065, -118.257], + [34.045, -118.267], + [34.02, -118.276], + [33.99, -118.281], + [33.95, -118.283], + [33.91, -118.283], + [33.87, -118.284], + [33.83, -118.287], + [33.8, -118.29], + [33.77, -118.29], + [33.745, -118.287], +]; + +/** I-210, the Foothill Freeway, running the whole length of the range front. */ +export const I210: LatLng[] = [ + [34.3, -118.45], + [34.27, -118.38], + [34.25, -118.32], + [34.235, -118.25], + [34.21, -118.19], + [34.175, -118.14], + [34.15, -118.09], + [34.135, -118.03], + [34.13, -117.95], + [34.125, -117.87], + [34.115, -117.8], + [34.108, -117.72], + [34.105, -117.64], + [34.11, -117.56], + [34.115, -117.46], + [34.118, -117.38], + [34.115, -117.3], +]; + +/** SR-91, Torrance to Riverside. The commute the Inland Empire was built on. */ +export const SR91: LatLng[] = [ + [33.87, -118.38], + [33.87, -118.32], + [33.868, -118.26], + [33.865, -118.2], + [33.862, -118.14], + [33.86, -118.07], + [33.858, -118.0], + [33.86, -117.93], + [33.862, -117.86], + [33.865, -117.79], + [33.87, -117.72], + [33.875, -117.64], + [33.89, -117.56], // the Santa Ana Canyon + [33.92, -117.49], + [33.945, -117.44], + [33.96, -117.38], + [33.965, -117.32], +]; + +/** SR-60, downtown to Riverside the inland way, over the Puente Hills. */ +export const SR60: LatLng[] = [ + [34.04, -118.22], + [34.033, -118.18], + [34.028, -118.13], + [34.02, -118.07], + [34.015, -118.0], + [34.01, -117.93], + [33.995, -117.86], + [33.98, -117.8], + [33.97, -117.72], + [33.955, -117.64], + [33.945, -117.56], + [33.94, -117.48], + [33.94, -117.4], + [33.945, -117.32], +]; + +/** SR-57, Anaheim to Pomona. */ +export const SR57: LatLng[] = [ + [33.79, -117.88], + [33.82, -117.885], + [33.86, -117.885], + [33.9, -117.88], + [33.94, -117.875], + [33.98, -117.86], + [34.02, -117.84], + [34.06, -117.83], +]; + +/** I-605, along the San Gabriel River. */ +export const I605: LatLng[] = [ + [34.14, -117.99], + [34.1, -118.0], + [34.05, -118.04], + [34.0, -118.07], + [33.95, -118.09], + [33.9, -118.105], + [33.85, -118.11], + [33.8, -118.105], + [33.77, -118.1], +]; + +/** I-105, the Century Freeway, LAX to Norwalk. */ +export const I105: LatLng[] = [ + [33.928, -118.39], + [33.927, -118.34], + [33.926, -118.29], + [33.925, -118.24], + [33.924, -118.19], + [33.922, -118.14], + [33.92, -118.09], +]; + +/** I-15, Lake Elsinore to the mouth of Cajon Pass. */ +export const I15: LatLng[] = [ + [33.72, -117.46], + [33.78, -117.49], + [33.84, -117.52], + [33.9, -117.53], + [33.96, -117.54], + [34.02, -117.55], + [34.08, -117.55], + [34.14, -117.51], + [34.2, -117.46], + [34.26, -117.44], +]; + +/** + * SR-1, Pacific Coast Highway. It hugs the shore from Malibu to Redondo, cuts + * inland across the neck of the Palos Verdes peninsula because there is no room + * to go round it, and picks the coast back up in Long Beach. + */ +export const PCH: LatLng[] = [ + [34.043, -118.87], + [34.03, -118.83], + [34.0, -118.805], + [34.03, -118.75], + [34.036, -118.68], + [34.039, -118.6], + [34.037, -118.53], + [34.018, -118.5], + [33.995, -118.483], + [33.97, -118.462], + [33.942, -118.44], + [33.92, -118.43], + [33.89, -118.415], + [33.86, -118.4], + [33.84, -118.392], + [33.81, -118.39], + [33.79, -118.36], + [33.78, -118.32], + [33.775, -118.29], + [33.78, -118.24], + [33.775, -118.2], + [33.77, -118.16], + [33.76, -118.12], + [33.745, -118.1], + [33.725, -118.07], + [33.7, -118.05], + [33.67, -118.01], + [33.64, -117.97], + [33.62, -117.93], + [33.6, -117.88], + [33.58, -117.84], + [33.55, -117.79], + [33.52, -117.75], + [33.48, -117.72], + [33.46, -117.69], + [33.43, -117.63], +]; + +/** + * Wilshire Boulevard, downtown to the pier. Twenty-six kilometres, four grids, + * and the only street that connects every part of the Westside to the core. + */ +export const WILSHIRE: LatLng[] = [ + [34.048, -118.256], + [34.057, -118.276], + [34.061, -118.3], + [34.062, -118.33], + [34.063, -118.36], + [34.067, -118.39], + [34.067, -118.415], + [34.06, -118.44], + [34.053, -118.455], + [34.043, -118.47], + [34.03, -118.483], + [34.018, -118.493], +]; + +/** Sunset Boulevard, Echo Park to the sea, along the foot of the hills. */ +export const SUNSET: LatLng[] = [ + [34.062, -118.238], + [34.078, -118.26], + [34.085, -118.285], + [34.098, -118.31], + [34.098, -118.34], + [34.095, -118.37], + [34.09, -118.39], + [34.085, -118.415], + [34.078, -118.44], + [34.07, -118.47], + [34.06, -118.5], + [34.045, -118.52], + [34.038, -118.53], +]; + +/** Ventura Boulevard, running the south edge of the Valley under the hills. */ +export const VENTURA_BLVD: LatLng[] = [ + [34.156, -118.64], + [34.155, -118.58], + [34.152, -118.52], + [34.15, -118.46], + [34.148, -118.4], + [34.142, -118.37], +]; + +/** Colorado Boulevard. Dead straight and due east-west, unlike downtown. */ +export const COLORADO_BLVD: LatLng[] = [ + [34.146, -118.198], + [34.146, -118.165], + [34.1465, -118.135], + [34.147, -118.105], + [34.147, -118.07], +]; + +/** Katella Avenue — one rung of Orange County's mile grid, past the parks. */ +export const KATELLA: LatLng[] = [ + [33.803, -118.01], + [33.803, -117.96], + [33.8025, -117.92], + [33.802, -117.88], + [33.802, -117.84], + [33.8015, -117.79], +]; + +/** + * LAX's two runway complexes, north and south of the terminals. + * + * Drawn as roads because that is what they are: a fifty-metre-wide paved strip + * lying on the ground. Two parallel bars pointing due west at the ocean is the + * single most recognisable piece of ground plan on the coastal plain, and it is + * three points of data. + */ +export const LAX_RUNWAYS_NORTH: LatLng[] = [ + [33.9535, -118.434], + [33.9535, -118.402], +]; + +export const LAX_RUNWAYS_SOUTH: LatLng[] = [ + [33.9405, -118.434], + [33.9405, -118.402], +]; + +export const ROADS: City["roads"] = [ + { path: I405, width: 0.14, kind: "freeway" }, + { path: I5, width: 0.14, kind: "freeway" }, + { path: US101, width: 0.13, kind: "freeway" }, + { path: I10, width: 0.14, kind: "freeway" }, + { path: I110, width: 0.13, kind: "freeway" }, + { path: I210, width: 0.13, kind: "freeway" }, + { path: SR91, width: 0.13, kind: "freeway" }, + { path: SR60, width: 0.12, kind: "freeway" }, + { path: SR57, width: 0.11, kind: "freeway" }, + { path: I605, width: 0.12, kind: "freeway" }, + { path: I105, width: 0.11, kind: "freeway" }, + { path: I15, width: 0.13, kind: "freeway" }, + { path: PCH, width: 0.1, kind: "street" }, + { path: WILSHIRE, width: 0.09, kind: "street" }, + { path: SUNSET, width: 0.08, kind: "street" }, + { path: VENTURA_BLVD, width: 0.08, kind: "street" }, + { path: COLORADO_BLVD, width: 0.08, kind: "street" }, + { path: KATELLA, width: 0.08, kind: "street" }, + { path: LAX_RUNWAYS_NORTH, width: 0.16, kind: "street" }, + { path: LAX_RUNWAYS_SOUTH, width: 0.16, kind: "street" }, +]; + +// ---- Bridges -------------------------------------------------------------- + +/** + * Two harbour crossings, and they are the only bridges on this map that are + * worth the geometry — nothing else in the metro spans water rather than + * freeway. + * + * A caveat for whoever renders these: `structures.ts` sizes bridge members in + * scene units — a 0.25-unit deck radius, 0.34-unit towers — and those constants + * were tuned at San Francisco's 94 m per unit. At 391 they come out about four + * times too heavy, so both of these render chunkier than life. They are kept + * because the port is one of the three or four silhouettes that say Southern + * California from above, and a heavy bridge still reads as a bridge. Making the + * members scale-relative belongs in `structures.ts`, not here. + */ + +/** The Vincent Thomas, over the main channel. Green, and suspension. */ +export const VINCENT_THOMAS: Bridge = { + name: "Vincent Thomas Bridge", + path: [ + [33.748, -118.285], + [33.75, -118.276], + [33.7515, -118.268], + [33.7535, -118.258], + ], + towers: [ + [33.75, -118.276], + [33.7515, -118.268], + ], + towerHeight: 111, + deckHeight: 56, + sag: 0.5, + color: 0x3f7d55, +}; + +/** + * The Long Beach International Gateway, over the back channel. Cable-stayed + * rather than suspension, so the sag is a third of the Vincent Thomas's — the + * cables here run straight to the deck instead of hanging from a catenary. + */ +export const LONG_BEACH_GATEWAY: Bridge = { + name: "Long Beach International Gateway", + path: [ + [33.752, -118.232], + [33.7555, -118.222], + [33.759, -118.213], + [33.762, -118.204], + ], + towers: [ + [33.7555, -118.222], + [33.759, -118.213], + ], + towerHeight: 157, + deckHeight: 62, + sag: 0.3, + color: 0xdfe3e6, +}; + +export const BRIDGES = [VINCENT_THOMAS, LONG_BEACH_GATEWAY]; + +// ---- Landmarks ------------------------------------------------------------ + +/** + * The buildings placed by hand, because the eye goes looking for them. + * + * `footprint` is a half-width in degrees of longitude, which works out — at any + * `latScale`, since the two cancel — as roughly `metres / 185000`. Like San + * Francisco's, these are drawn about half again wider than life: a tower at its + * true footprint and this vertical exaggeration is a nineteen-to-one needle, + * and real towers do not look like that. + * + * A few of these are not buildings at all. LAX and Disneyland are flat pads + * standing in for a footprint, because what the eye is actually looking for in + * Westchester and Anaheim is a shape on the ground rather than anything with a + * height. + */ +export const LANDMARKS: Landmark[] = [ + // ---- The downtown cluster ---- + { + name: "U.S. Bank Tower", + lat: 34.0509, + lng: -118.2545, + height: 310, + footprint: 0.00045, + shape: "tower", + label: true, + }, + { + name: "Wilshire Grand Center", + lat: 34.0512, + lng: -118.2605, + height: 335, + footprint: 0.0004, + shape: "tower", + label: true, + }, + { + name: "Aon Center", + lat: 34.0505, + lng: -118.2585, + height: 262, + footprint: 0.00042, + shape: "box", + color: 0xb0a08c, + }, + { + name: "Two California Plaza", + lat: 34.0535, + lng: -118.251, + height: 229, + footprint: 0.0004, + shape: "box", + }, + // The 1928 ziggurat that was the only building in the city allowed to break + // the height limit for forty years, and still the shape people draw when they + // draw Los Angeles. + { + name: "Los Angeles City Hall", + lat: 34.0537, + lng: -118.2427, + height: 138, + footprint: 0.00048, + shape: "tower", + color: 0xe0d8c4, + label: true, + }, + { + name: "Walt Disney Concert Hall", + lat: 34.0553, + lng: -118.2498, + height: 40, + footprint: 0.00055, + shape: "cylinder", + color: 0xc9cdd2, + label: true, + }, + { + name: "Union Station", + lat: 34.0561, + lng: -118.2365, + height: 40, + footprint: 0.0006, + shape: "box", + color: 0xd9c8ab, + label: true, + }, + { + name: "Crypto.com Arena", + lat: 34.043, + lng: -118.2673, + height: 45, + footprint: 0.0006, + shape: "cylinder", + color: 0x9aa4ad, + }, + { + name: "Dodger Stadium", + lat: 34.0739, + lng: -118.24, + height: 35, + footprint: 0.0009, + shape: "cylinder", + color: 0x5c7fa0, + label: true, + }, + { + name: "Los Angeles Memorial Coliseum", + lat: 34.0141, + lng: -118.2879, + height: 38, + footprint: 0.00095, + shape: "cylinder", + color: 0xc0a882, + label: true, + }, + + // ---- Hollywood and the hills ---- + // Fourteen-metre letters on a five-hundred-metre hillside. The hill does the + // work; the landmark just puts something white on the ridge where the eye is + // already looking. + { + name: "Hollywood Sign", + lat: 34.1341, + lng: -118.3215, + height: 15, + footprint: 0.0005, + shape: "box", + color: 0xf2f0ea, + label: true, + }, + { + name: "Griffith Observatory", + lat: 34.1184, + lng: -118.3004, + height: 22, + footprint: 0.0004, + shape: "cylinder", + color: 0xdcd8cc, + label: true, + }, + { + name: "Capitol Records Building", + lat: 34.1024, + lng: -118.3264, + height: 46, + footprint: 0.00022, + shape: "cylinder", + color: 0xd8cfc0, + label: true, + }, + + // ---- The Westside ---- + { + name: "Getty Center", + lat: 34.078, + lng: -118.4741, + height: 32, + footprint: 0.0008, + shape: "box", + color: 0xe8e2d2, + label: true, + }, + { + name: "Century Plaza Towers", + lat: 34.0585, + lng: -118.4146, + height: 173, + footprint: 0.00038, + shape: "box", + color: 0xb8bfc6, + label: true, + }, + { + name: "Century Plaza Towers (south)", + lat: 34.0568, + lng: -118.4118, + height: 173, + footprint: 0.00038, + shape: "box", + color: 0xb8bfc6, + }, + { + name: "Fox Plaza", + lat: 34.0556, + lng: -118.4131, + height: 150, + footprint: 0.0003, + shape: "tower", + }, + { + name: "Santa Monica Pier", + lat: 34.0086, + lng: -118.4977, + height: 30, + footprint: 0.0004, + shape: "cylinder", + color: 0xd6c9a8, + label: true, + }, + { + name: "SoFi Stadium", + lat: 33.9535, + lng: -118.3392, + height: 48, + footprint: 0.0013, + shape: "cylinder", + color: 0x9fa8b0, + label: true, + }, + + // ---- LAX ---- + // Not a building: a two-kilometre pad of apron and terminal, which with the + // two runway strips beside it is what actually reads as an airport. + { + name: "LAX", + lat: 33.9445, + lng: -118.4045, + height: 12, + footprint: 0.0095, + shape: "box", + color: 0x76736c, + label: true, + }, + { + name: "LAX Theme Building", + lat: 33.9448, + lng: -118.402, + height: 41, + footprint: 0.00035, + shape: "cylinder", + color: 0xe4e1d8, + }, + + // ---- The Valley ---- + { + name: "Warner Center", + lat: 34.172, + lng: -118.601, + height: 95, + footprint: 0.00045, + shape: "box", + label: true, + }, + + // ---- Pasadena ---- + { + name: "Pasadena City Hall", + lat: 34.1478, + lng: -118.1445, + height: 63, + footprint: 0.00045, + shape: "tower", + color: 0xd9c9a8, + label: true, + }, + { + name: "Rose Bowl", + lat: 34.1613, + lng: -118.1676, + height: 26, + footprint: 0.0011, + shape: "cylinder", + color: 0xc4bda8, + label: true, + }, + + // ---- The harbour ---- + // Three gantry cranes on Terminal Island. They are the tallest things for + // fifteen kilometres and they are what makes the port look like a port rather + // than an empty basin. + { + name: "Long Beach Cranes", + lat: 33.7535, + lng: -118.2385, + height: 78, + footprint: 0.00022, + shape: "box", + color: 0x8c9298, + label: true, + }, + { + name: "Long Beach Cranes (2)", + lat: 33.7515, + lng: -118.2345, + height: 78, + footprint: 0.00022, + shape: "box", + color: 0x8c9298, + }, + { + name: "Long Beach Cranes (3)", + lat: 33.7495, + lng: -118.2305, + height: 78, + footprint: 0.00022, + shape: "box", + color: 0x8c9298, + }, + { + name: "Queen Mary", + lat: 33.7525, + lng: -118.19, + height: 55, + footprint: 0.00055, + shape: "box", + color: 0x33506a, + label: true, + }, + { + name: "Watts Towers", + lat: 33.939, + lng: -118.2415, + height: 30, + footprint: 0.00012, + shape: "tower", + color: 0xb08a5a, + label: true, + }, + + // ---- Orange County ---- + // Disneyland as a footprint, which is what the brief of this map is: a + // kilometre of green in the middle of Anaheim's flat grid, with one white + // peak in it. The Matterhorn is forty-four metres of chicken wire and plaster + // and it is still the only mountain in the OC basin. + { + name: "Disneyland", + lat: 33.8121, + lng: -117.919, + height: 10, + footprint: 0.0052, + shape: "box", + color: 0x5f7a52, + label: true, + }, + { + name: "Matterhorn", + lat: 33.8129, + lng: -117.9178, + height: 46, + footprint: 0.00024, + shape: "pyramid", + color: 0xeceff2, + label: true, + }, + { + name: "Angel Stadium", + lat: 33.8003, + lng: -117.8827, + height: 38, + footprint: 0.0009, + shape: "cylinder", + color: 0xa8b0b8, + label: true, + }, + { + name: "Honda Center", + lat: 33.8078, + lng: -117.8766, + height: 35, + footprint: 0.0006, + shape: "cylinder", + color: 0x9aa4ad, + }, + { + name: "Fashion Island", + lat: 33.618, + lng: -117.876, + height: 90, + footprint: 0.0004, + shape: "box", + }, + { + name: "Park Place, Irvine", + lat: 33.68, + lng: -117.857, + height: 100, + footprint: 0.0004, + shape: "box", + }, + + // ---- Riverside ---- + { + name: "Mission Inn", + lat: 33.982, + lng: -117.3745, + height: 42, + footprint: 0.0005, + shape: "tower", + color: 0xd8c4a0, + label: true, + }, + { + name: "Riverside County Administrative Center", + lat: 33.98, + lng: -117.376, + height: 60, + footprint: 0.0003, + shape: "box", + }, +]; + +// ---- Districts ------------------------------------------------------------ + +/** + * Where buildings go, how tall, and on what bearing. + * + * **The bearings are the point of this section.** Southern California is one + * continuous field of low buildings for a hundred kilometres, and if it were + * laid out on one grid it would render as a single grey sheet. It is not: it is + * a dozen surveys that never agreed with each other, and the seams between them + * are the most legible thing on the ground plan. `gridAngle` is in radians, and + * the convention works out as *the compass bearing of the north-south street + * axis*, so 0 is a true cardinal grid and 0.63 is thirty-six degrees clockwise + * of one. Only the value modulo a right angle matters, since a square lattice + * is symmetric under a quarter turn. + * + * The six that carry the map: + * + * - **Downtown at 0.63**, thirty-six degrees off north. Laid out under the + * Laws of the Indies so that every building would get sun on more than one + * face, and never corrected since. This is the local equivalent of Market + * Street: the rotated core is why the freeway interchanges around downtown + * are the shape they are, and it is the first thing that tells you this is + * not Phoenix. + * - **The San Fernando Valley at 0.0**, dead cardinal, over four hundred + * square kilometres. The single largest uninterrupted grid on the map, and + * the reason the Valley reads as a different city from the one south of the + * hills. + * - **Pasadena at 0.02**, also effectively cardinal — which is why Colorado + * Boulevard runs arrow-straight due east and downtown's streets do not. + * - **The Westside around 0.5**, twenty-nine degrees, inherited from the + * Spanish ranchos it was subdivided out of. + * - **Santa Monica and Venice at 0.88**, aligned to the beach rather than to + * the compass, which is why the Westside grid hits the coastal grid at a + * visible angle somewhere around Centinela. + * - **Irvine at 0.60** against Anaheim's cardinal 0.02. Two master plans + * twenty kilometres apart, drawn a century apart, and the seam runs right + * through Santa Ana. + * + * Two things about the numbers are not obvious and were both got wrong on the + * first pass. + * + * **Coverage stays high.** The instinct is to thin the suburbs out, since a + * tract lot is mostly driveway, pool and strip-mall car park. That is an + * argument about a forty-metre lot. At a hundred and sixty-four there is + * something standing on essentially every lot in the basin, and dropping + * coverage to a half produced a metro that read as scattered debris. It is held + * near San Francisco's 0.88 through the continuously built districts and pulled + * down only where the ground genuinely is empty: Malibu at 0.14, the Palos + * Verdes and Palisades hillsides at 0.4. + * + * **Minimum heights are higher than they look like they should be**, for the + * same reason. One instance here is a city block, not a building, and a block + * reads as its tallest thing. + * + * The whole set comes to about fifty-five thousand instances, against San + * Francisco's ninety. Fewer buildings over twenty times the district area is + * the arithmetic working correctly, not a gap to be filled: a scene unit is + * four times longer, so a lot covers sixteen times the ground. + */ +export const DISTRICTS: District[] = [ + // ---- Downtown and the core ---- + { + id: "dtla", + name: "Downtown Los Angeles", + polygon: [ + [34.068, -118.272], + [34.06, -118.228], + [34.03, -118.233], + [34.037, -118.278], + ], + minHeight: 30, + maxHeight: 240, + gridAngle: 0.63, + palette: "downtown", + towerChance: 0.11, + coverage: 0.88, + }, + { + id: "arts-district", + name: "Arts District & Vernon", + polygon: [ + [34.048, -118.228], + [34.043, -118.19], + [34.0, -118.198], + [34.008, -118.238], + ], + minHeight: 12, + maxHeight: 48, + gridAngle: 0.63, + palette: "industrial", + towerChance: 0.006, + coverage: 0.84, + }, + { + id: "usc-exposition", + name: "South Park & Exposition", + polygon: [ + [34.038, -118.3], + [34.033, -118.25], + [33.998, -118.256], + [34.005, -118.306], + ], + minHeight: 14, + maxHeight: 90, + gridAngle: 0.02, + palette: "residential", + towerChance: 0.02, + coverage: 0.86, + }, + { + id: "koreatown", + name: "Koreatown & Mid-Wilshire", + polygon: [ + [34.08, -118.345], + [34.076, -118.272], + [34.044, -118.28], + [34.049, -118.352], + ], + minHeight: 16, + maxHeight: 82, + gridAngle: 0.02, + palette: "residential", + towerChance: 0.03, + coverage: 0.88, + }, + { + id: "hollywood", + name: "Hollywood", + polygon: [ + [34.115, -118.375], + [34.11, -118.298], + [34.082, -118.305], + [34.088, -118.38], + ], + minHeight: 14, + maxHeight: 68, + gridAngle: 0.03, + palette: "residential", + towerChance: 0.025, + coverage: 0.86, + }, + { + id: "silver-lake", + name: "Silver Lake & Echo Park", + polygon: [ + [34.115, -118.3], + [34.108, -118.236], + [34.066, -118.243], + [34.074, -118.307], + ], + minHeight: 12, + maxHeight: 34, + gridAngle: 0.12, + palette: "residential", + towerChance: 0.004, + coverage: 0.8, + }, + // Northeast LA is on its own bearing because it was subdivided along the + // Arroyo Seco and the Santa Fe line rather than off any city survey — which + // is why Figueroa runs diagonally out of downtown and nothing up here lines + // up with either Hollywood or Pasadena. + { + id: "highland-park", + name: "Highland Park & Eagle Rock", + polygon: [ + [34.145, -118.25], + [34.14, -118.155], + [34.085, -118.163], + [34.092, -118.258], + ], + minHeight: 11, + maxHeight: 32, + gridAngle: 0.35, + palette: "residential", + towerChance: 0.003, + coverage: 0.8, + }, + { + id: "west-hollywood", + name: "West Hollywood & Beverly Hills", + polygon: [ + [34.098, -118.42], + [34.094, -118.345], + [34.058, -118.352], + [34.063, -118.427], + ], + minHeight: 13, + maxHeight: 62, + gridAngle: 0.32, + palette: "residential", + towerChance: 0.015, + coverage: 0.84, + }, + + // ---- The Westside ---- + { + id: "century-city", + name: "Century City", + polygon: [ + [34.07, -118.43], + [34.067, -118.398], + [34.048, -118.402], + [34.052, -118.434], + ], + minHeight: 35, + maxHeight: 170, + gridAngle: 0.5, + palette: "downtown", + towerChance: 0.2, + coverage: 0.88, + }, + { + id: "westwood", + name: "Westwood & Brentwood", + polygon: [ + [34.078, -118.5], + [34.073, -118.42], + [34.044, -118.428], + [34.051, -118.508], + ], + minHeight: 13, + maxHeight: 78, + gridAngle: 0.5, + palette: "residential", + towerChance: 0.02, + coverage: 0.76, + }, + { + id: "santa-monica", + name: "Santa Monica", + polygon: [ + [34.042, -118.52], + [34.034, -118.462], + [33.998, -118.472], + [34.007, -118.526], + ], + minHeight: 15, + maxHeight: 62, + gridAngle: 0.88, + palette: "downtown", + towerChance: 0.025, + coverage: 0.86, + }, + { + id: "venice", + name: "Venice & the Marina", + polygon: [ + [34.002, -118.484], + [33.996, -118.432], + [33.958, -118.44], + [33.966, -118.492], + ], + minHeight: 12, + maxHeight: 36, + gridAngle: 0.88, + palette: "residential", + towerChance: 0.006, + coverage: 0.84, + }, + { + id: "culver-city", + name: "Culver City & Palms", + polygon: [ + [34.038, -118.436], + [34.033, -118.36], + [33.996, -118.368], + [34.002, -118.443], + ], + minHeight: 13, + maxHeight: 60, + gridAngle: 0.5, + palette: "downtown", + towerChance: 0.018, + coverage: 0.84, + }, + // Everything from here west is hillside: a road, a canyon and whatever will + // stand on the slope. Sparse on purpose. + { + id: "palisades", + name: "Pacific Palisades", + polygon: [ + [34.068, -118.562], + [34.063, -118.502], + [34.036, -118.51], + [34.041, -118.568], + ], + minHeight: 10, + maxHeight: 26, + gridAngle: 0.62, + palette: "residential", + towerChance: 0.001, + coverage: 0.4, + }, + { + id: "malibu", + name: "Malibu", + polygon: [ + [34.042, -118.94], + [34.038, -118.6], + [34.02, -118.606], + [34.024, -118.946], + ], + minHeight: 9, + maxHeight: 18, + gridAngle: 0.12, + palette: "residential", + towerChance: 0.001, + coverage: 0.14, + }, + + // ---- The San Fernando Valley ---- + { + id: "sherman-oaks", + name: "Sherman Oaks & Encino", + polygon: [ + [34.182, -118.55], + [34.178, -118.4], + [34.138, -118.406], + [34.142, -118.556], + ], + minHeight: 12, + maxHeight: 44, + gridAngle: 0.0, + palette: "residential", + towerChance: 0.008, + coverage: 0.82, + }, + { + id: "van-nuys", + name: "Van Nuys & North Hollywood", + polygon: [ + [34.24, -118.5], + [34.235, -118.34], + [34.176, -118.346], + [34.181, -118.506], + ], + minHeight: 12, + maxHeight: 48, + gridAngle: 0.0, + palette: "residential", + towerChance: 0.007, + coverage: 0.84, + }, + { + id: "warner-center", + name: "Warner Center & Woodland Hills", + polygon: [ + [34.205, -118.65], + [34.2, -118.545], + [34.152, -118.552], + [34.157, -118.657], + ], + minHeight: 13, + maxHeight: 98, + gridAngle: 0.0, + palette: "downtown", + towerChance: 0.035, + coverage: 0.8, + }, + { + id: "north-valley", + name: "Northridge & Sylmar", + polygon: [ + [34.3, -118.58], + [34.294, -118.38], + [34.228, -118.388], + [34.235, -118.588], + ], + minHeight: 11, + maxHeight: 30, + gridAngle: 0.0, + palette: "residential", + towerChance: 0.002, + coverage: 0.8, + }, + { + id: "burbank", + name: "Burbank", + polygon: [ + [34.212, -118.368], + [34.206, -118.282], + [34.15, -118.29], + [34.157, -118.375], + ], + minHeight: 13, + maxHeight: 62, + gridAngle: 0.61, + palette: "residential", + towerChance: 0.02, + coverage: 0.82, + }, + { + id: "glendale", + name: "Glendale", + polygon: [ + [34.18, -118.29], + [34.175, -118.212], + [34.118, -118.22], + [34.124, -118.298], + ], + minHeight: 14, + maxHeight: 92, + gridAngle: 0.28, + palette: "downtown", + towerChance: 0.035, + coverage: 0.82, + }, + + // ---- Pasadena and the San Gabriel Valley ---- + { + id: "pasadena", + name: "Pasadena", + polygon: [ + [34.185, -118.19], + [34.18, -118.07], + [34.12, -118.078], + [34.126, -118.198], + ], + minHeight: 14, + maxHeight: 78, + gridAngle: 0.02, + palette: "downtown", + towerChance: 0.03, + coverage: 0.8, + }, + { + id: "sgv-west", + name: "Alhambra & El Monte", + polygon: [ + [34.118, -118.17], + [34.113, -117.99], + [34.03, -118.0], + [34.036, -118.18], + ], + minHeight: 11, + maxHeight: 36, + gridAngle: 0.1, + palette: "residential", + towerChance: 0.004, + coverage: 0.8, + }, + { + id: "sgv-east", + name: "West Covina & Pomona", + polygon: [ + [34.125, -117.985], + [34.12, -117.72], + [34.015, -117.732], + [34.021, -117.995], + ], + minHeight: 11, + maxHeight: 32, + gridAngle: 0.05, + palette: "residential", + towerChance: 0.003, + coverage: 0.78, + }, + { + id: "ontario", + name: "Ontario & Rancho Cucamonga", + polygon: [ + [34.125, -117.69], + [34.12, -117.48], + [34.015, -117.492], + [34.021, -117.702], + ], + minHeight: 11, + maxHeight: 30, + gridAngle: 0.01, + palette: "industrial", + towerChance: 0.002, + coverage: 0.66, + }, + { + id: "chino", + name: "Chino & Diamond Bar", + polygon: [ + [34.02, -117.79], + [34.015, -117.61], + [33.94, -117.622], + [33.946, -117.802], + ], + minHeight: 10, + maxHeight: 26, + gridAngle: 0.02, + palette: "industrial", + towerChance: 0.002, + coverage: 0.6, + }, + + // ---- South Los Angeles, the South Bay and the harbour ---- + { + id: "south-la", + name: "South Los Angeles", + polygon: [ + [34.0, -118.35], + [33.996, -118.2], + [33.925, -118.212], + [33.93, -118.36], + ], + minHeight: 10, + maxHeight: 28, + gridAngle: 0.01, + palette: "residential", + towerChance: 0.002, + coverage: 0.88, + }, + { + id: "inglewood", + name: "Inglewood & Hawthorne", + polygon: [ + [33.985, -118.43], + [33.98, -118.32], + [33.915, -118.33], + [33.92, -118.438], + ], + minHeight: 11, + maxHeight: 50, + gridAngle: 0.02, + palette: "residential", + towerChance: 0.008, + coverage: 0.84, + }, + { + id: "south-bay", + name: "Torrance & the Beach Cities", + polygon: [ + [33.918, -118.415], + [33.912, -118.3], + [33.815, -118.312], + [33.822, -118.422], + ], + minHeight: 11, + maxHeight: 40, + gridAngle: 0.72, + palette: "residential", + towerChance: 0.006, + coverage: 0.8, + }, + { + id: "palos-verdes", + name: "Palos Verdes", + polygon: [ + [33.802, -118.412], + [33.797, -118.305], + [33.728, -118.315], + [33.734, -118.418], + ], + minHeight: 10, + maxHeight: 22, + gridAngle: 0.3, + palette: "residential", + towerChance: 0.001, + coverage: 0.4, + }, + { + id: "san-pedro", + name: "San Pedro & Wilmington", + polygon: [ + [33.795, -118.305], + [33.79, -118.235], + [33.708, -118.246], + [33.713, -118.31], + ], + minHeight: 11, + maxHeight: 44, + gridAngle: 0.05, + palette: "industrial", + towerChance: 0.005, + coverage: 0.76, + }, + { + id: "carson", + name: "Compton & Carson", + polygon: [ + [33.94, -118.29], + [33.935, -118.145], + [33.85, -118.156], + [33.856, -118.3], + ], + minHeight: 10, + maxHeight: 28, + gridAngle: 0.02, + palette: "industrial", + towerChance: 0.002, + coverage: 0.8, + }, + { + id: "long-beach", + name: "Long Beach", + polygon: [ + [33.868, -118.23], + [33.862, -118.09], + [33.752, -118.102], + [33.758, -118.24], + ], + minHeight: 12, + maxHeight: 124, + gridAngle: 0.2, + palette: "downtown", + towerChance: 0.014, + coverage: 0.8, + }, + { + id: "downey", + name: "Downey & Lakewood", + polygon: [ + [33.96, -118.19], + [33.955, -118.045], + [33.845, -118.058], + [33.852, -118.2], + ], + minHeight: 10, + maxHeight: 28, + gridAngle: 0.04, + palette: "residential", + towerChance: 0.002, + coverage: 0.8, + }, + { + id: "whittier", + name: "Whittier & La Mirada", + polygon: [ + [33.948, -118.055], + [33.943, -117.93], + [33.895, -117.94], + [33.9, -118.065], + ], + minHeight: 10, + maxHeight: 28, + gridAngle: 0.06, + palette: "residential", + towerChance: 0.002, + coverage: 0.82, + }, + + // ---- Orange County ---- + { + id: "anaheim", + name: "Anaheim & Fullerton", + polygon: [ + [33.885, -118.06], + [33.879, -117.86], + [33.8, -117.872], + [33.806, -118.072], + ], + minHeight: 11, + maxHeight: 64, + gridAngle: 0.02, + palette: "residential", + towerChance: 0.012, + coverage: 0.82, + }, + { + id: "yorba-linda", + name: "Brea & Yorba Linda", + polygon: [ + [33.935, -117.87], + [33.93, -117.72], + [33.86, -117.73], + [33.866, -117.88], + ], + minHeight: 10, + maxHeight: 28, + gridAngle: 0.08, + palette: "residential", + towerChance: 0.002, + coverage: 0.68, + }, + { + id: "santa-ana", + name: "Santa Ana", + polygon: [ + [33.795, -117.925], + [33.79, -117.82], + [33.705, -117.832], + [33.711, -117.937], + ], + minHeight: 11, + maxHeight: 60, + gridAngle: 0.05, + palette: "downtown", + towerChance: 0.018, + coverage: 0.86, + }, + { + id: "garden-grove", + name: "Garden Grove & Westminster", + polygon: [ + [33.8, -118.055], + [33.795, -117.92], + [33.72, -117.93], + [33.726, -118.065], + ], + minHeight: 10, + maxHeight: 26, + gridAngle: 0.02, + palette: "residential", + towerChance: 0.002, + coverage: 0.8, + }, + { + id: "huntington-beach", + name: "Huntington Beach & Fountain Valley", + polygon: [ + [33.735, -118.075], + [33.73, -117.95], + [33.64, -117.962], + [33.646, -118.087], + ], + minHeight: 10, + maxHeight: 30, + gridAngle: 0.05, + palette: "residential", + towerChance: 0.003, + coverage: 0.82, + }, + { + id: "newport-beach", + name: "Newport Beach & Costa Mesa", + polygon: [ + [33.665, -117.94], + [33.66, -117.82], + [33.58, -117.832], + [33.586, -117.952], + ], + minHeight: 11, + maxHeight: 68, + gridAngle: 0.35, + palette: "residential", + towerChance: 0.015, + coverage: 0.72, + }, + { + id: "irvine", + name: "Irvine", + polygon: [ + [33.735, -117.865], + [33.73, -117.71], + [33.63, -117.722], + [33.636, -117.877], + ], + minHeight: 12, + maxHeight: 84, + gridAngle: 0.6, + palette: "downtown", + towerChance: 0.03, + coverage: 0.76, + }, + { + id: "saddleback", + name: "Mission Viejo & Laguna", + polygon: [ + [33.64, -117.75], + [33.634, -117.59], + [33.505, -117.612], + [33.515, -117.762], + ], + minHeight: 10, + maxHeight: 26, + gridAngle: 0.42, + palette: "residential", + towerChance: 0.002, + coverage: 0.6, + }, + { + id: "dana-point", + name: "Dana Point & San Clemente", + polygon: [ + [33.5, -117.72], + [33.482, -117.62], + [33.415, -117.575], + [33.435, -117.695], + ], + minHeight: 10, + maxHeight: 24, + gridAngle: 0.55, + palette: "residential", + towerChance: 0.002, + coverage: 0.5, + }, + + // ---- Riverside and the Inland Empire ---- + { + id: "riverside", + name: "Downtown Riverside", + polygon: [ + [34.008, -117.42], + [34.002, -117.325], + [33.94, -117.333], + [33.947, -117.428], + ], + minHeight: 12, + maxHeight: 70, + gridAngle: 0.79, + palette: "downtown", + towerChance: 0.03, + coverage: 0.74, + }, + { + id: "moreno-valley", + name: "Moreno Valley & Arlington", + polygon: [ + [33.985, -117.33], + [33.978, -117.225], + [33.87, -117.238], + [33.877, -117.343], + ], + minHeight: 10, + maxHeight: 24, + gridAngle: 0.05, + palette: "residential", + towerChance: 0.002, + coverage: 0.62, + }, + { + id: "corona", + name: "Corona & Norco", + polygon: [ + [33.93, -117.64], + [33.924, -117.5], + [33.84, -117.512], + [33.847, -117.652], + ], + minHeight: 10, + maxHeight: 26, + gridAngle: 0.03, + palette: "residential", + towerChance: 0.002, + coverage: 0.62, + }, +]; + +// ---- Focus regions -------------------------------------------------------- + +/** + * The seven rectangles that get fine terrain, at about 80 m; everywhere else is + * six times coarser, at about 480 m. + * + * This is the trade ARCHITECTURE.md §5 asked for, and the arithmetic is worth + * writing down because it is the whole argument for the feature existing. + * Uniform 80 m cells over this frame would be 1.08° / 0.00072 by 1.66° / + * 0.00086 — just under 2.9 million lattice points, several seconds of build, + * and a mesh with nothing in most of it. Coarse everywhere is 80,500. The seven + * boxes below add about 120,000 back, in the places a camera actually stops, + * for a total near 200,000: comfortably under San Francisco's 337,000, on a + * board twenty-five times the size. + * + * Where a box is *not* is as considered as where it is. The San Fernando Valley + * and the harbour both get chapters and neither gets a box, because both are + * flat: fine cells there would buy several thousand extra triangles of + * absolutely level ground. The relief that matters at those two stops is the + * range behind the Valley and the coastline around the port, and neither is + * inside the frame the camera is pointed at. + */ +export const FOCUS_REGIONS: FocusRegion[] = [ + { minLat: 34.01, maxLat: 34.09, minLng: -118.3, maxLng: -118.19 }, + { minLat: 34.075, maxLat: 34.15, minLng: -118.37, maxLng: -118.25 }, +]; + +// ---- Chapters ------------------------------------------------------------- + +/** + * The tour. Eight stops rather than the seven the basin strictly needs, because + * the port is one of the four or five things on this coast that is unmistakably + * itself and a chapter is the only way a viewer gets to look at it. + * + * Camera notes, since the numbers are less obvious here than in a city that + * fits in one frame. `rotation` places the camera and the target is looked at + * from there, so a negative rotation puts the eye to the south-west — out over + * the water — looking back north-east into the basin. That is the standard + * Southern California view and six of these eight use it. The whole-board + * stop deliberately sits closer than the frame's true half-width, because the + * engine's fog closes at 460 units and at 391 m to the unit that is a hundred + * and eighty kilometres: pull the camera back far enough to see every corner + * and the San Gabriels dissolve into haze. Which is, to be fair, what they + * usually do. + */ +const WHOLE_BOARD: City["chapters"][number] = { + id: "all", + number: "01", + label: "The Whole Board", + shortLabel: "Whole Board", + focus: { + lat: 34.02, + lng: -118.12, + distance: 236, + height: 148, + rotation: -0.55, + }, + description: + "A hundred kilometres of basin between the Pacific and a three-thousand-metre wall, with Catalina offshore and the Santa Anas closing the south-east. Everything on this map is inside one mountain rim.", +}; + +export const CHAPTERS: City["chapters"] = [ + WHOLE_BOARD, + { + id: "dtla", + number: "02", + label: "Downtown Los Angeles", + shortLabel: "DTLA", + focus: { + lat: 34.05, + lng: -118.248, + distance: 19, + height: 13.5, + rotation: 0.63, + }, + description: + "The one cluster of real towers in the basin, on a grid turned thirty-six degrees off north since 1781. The camera is lined up with that grid, which is why everything else in the frame looks crooked.", + }, + { + id: "westside", + number: "03", + label: "The Westside", + shortLabel: "Westside", + focus: { + lat: 34.025, + lng: -118.435, + distance: 46, + height: 30, + rotation: -0.6, + }, + description: + "Santa Monica, Venice, Culver City and Century City, seen from over the bay. Three different street grids meet in this frame and none of them agree; the seams are visible from here.", + }, + { + id: "hollywood", + number: "04", + label: "Hollywood & Silver Lake", + shortLabel: "Hollywood", + focus: { + lat: 34.108, + lng: -118.305, + distance: 34, + height: 24, + rotation: 0.1, + }, + description: + "The flats run north on a cardinal grid until they hit the Santa Monica Mountains and stop. The sign is on Mount Lee at the top of the frame; the reservoir below it is Silver Lake.", + }, + { + id: "valley", + number: "05", + label: "The San Fernando Valley", + shortLabel: "The Valley", + focus: { + lat: 34.19, + lng: -118.43, + distance: 66, + height: 40, + rotation: -0.15, + }, + description: + "Four hundred square kilometres of the most uninterrupted cardinal grid in the country, in a bowl with the Santa Monicas behind the camera and the San Gabriels across the top.", + }, + { + id: "harbour", + number: "06", + label: "The Harbour & Palos Verdes", + shortLabel: "Harbour", + focus: { + lat: 33.76, + lng: -118.24, + distance: 40, + height: 26, + rotation: -0.9, + }, + description: + "San Pedro, Terminal Island and Long Beach around one basin, with the peninsula rising behind them. The busiest port complex in the hemisphere, and the only two bridges on this map that cross water.", + }, + { + id: "orange-county", + number: "07", + label: "Orange County", + shortLabel: "Orange County", + focus: { + lat: 33.73, + lng: -117.87, + distance: 84, + height: 52, + rotation: -0.5, + }, + description: + "Anaheim's mile grid against Irvine's rotated master plan, with the Santa Ana River running diagonally through both of them and the Santa Anas standing up behind.", + }, + { + id: "inland-empire", + number: "08", + label: "Riverside & the Inland Empire", + shortLabel: "Riverside", + focus: { + lat: 33.97, + lng: -117.42, + distance: 62, + height: 40, + rotation: -0.35, + }, + description: + "Ninety kilometres inland and two hundred and sixty metres up, on a grid rotated forty-five degrees to the Santa Ana River. Box Springs Mountain to the east, Mount Baldy on the skyline north.", + }, +]; + +// ---- The pack ------------------------------------------------------------ + +export const SOCAL_CITY: City = { + id: "socal", + name: "Southern California", + + // The centre of the frame rather than the centre of anything civic — it lands + // out past Los Alamitos, roughly equidistant from downtown, Riverside and + // Dana Point. Putting the scene origin at the middle of the board keeps the + // projection symmetric, which matters more on a board this wide than being + // able to say the origin is City Hall. + center: { lat: 33.82, lng: -118.05 }, + bounds: { minLat: 33.28, maxLat: 34.36, minLng: -118.88, maxLng: -117.22 }, + + /** + * 391 m to the scene unit, against San Francisco's 94. + * + * Not a stylistic choice. `scene.ts` caps the orbit at 340 units from the + * target and `cityDaylight` closes its fog at 460, so however large a city is + * on the ground it has to end up roughly the same size in scene units or it + * cannot be looked at. The board this produces is 393 by 308 units — a little + * wider and deeper than San Francisco's 331 by 230, which is about as far as + * the camera will stretch. + */ + latScale: 285, + + /** + * Lower than San Francisco's 3.6, because the relief here does not need the + * help and cannot take it. The San Gabriel front rises three kilometres in + * eight, which is already twenty degrees; multiplying its tangent by 3.6 puts + * it past fifty-five and turns the most dramatic range front in North America + * into an obvious cartoon. 3.4 keeps it a wall without making it a cliff. + */ + verticalExaggeration: 3.4, + + // ~80 m inside a focus region, ~480 m outside one. See FOCUS_REGIONS for the + // arithmetic; the short version is that this is the difference between + // 200,000 lattice points and 2.8 million. + cellLat: 0.00072, + cellLng: 0.00086, + coarseFactor: 10, + focusRegions: FOCUS_REGIONS, + + /** + * ~500 m, against San Francisco's 200. + * + * The falloff exists to ramp the terrain grid's stair-stepped rim down to the + * smooth shore plate underneath it, so it has to be at least one cell wide or + * the steps survive. Out in the basin a cell is 480 m. The cost is that the + * Palos Verdes sea cliffs get a flat shelf in front of them, which is a fair + * price for a coastline that reads cleanly along its whole length. + */ + coastFalloff: 0.0045, + + landmasses: LANDMASSES, + parks: PARKS, + inlandWater: INLAND_WATER, + hills: HILLS, + districts: DISTRICTS, + landmarks: LANDMARKS, + bridges: BRIDGES, + roads: ROADS, + chapters: CHAPTERS, + + /** + * Drier and hazier than the default. San Francisco's palette is a cool grey- + * green coast; this one is chaparral and decomposed granite, with a warm + * horizon because the fog planes here are standing in for the basin's haze + * rather than for marine layer. + */ + palette: { + skyTop: 0x8db2d4, + skyHorizon: 0xe6ded0, + sea: 0x3f7391, + lake: 0x4e7d99, + shore: 0xc2b393, + sand: 0xd2c3a3, + flats: 0xa9a291, + upland: 0x9a8d74, + park: 0x6d7f52, + parkHigh: 0x4e6440, + }, +}; + +export default SOCAL_CITY; diff --git a/src/engine/atmosphere.ts b/src/engine/atmosphere.ts index 0a8e260..bb5dccd 100644 --- a/src/engine/atmosphere.ts +++ b/src/engine/atmosphere.ts @@ -20,11 +20,12 @@ * and touches nothing. Call it every frame or once a minute; the cost is * the same table lookup either way. * - * The solar half is computed locally by `solar.ts` with no network, and the - * weather half degrades to `null` — which this file reads as a clear day with - * the local climatology still running. The whole engine has to work with no - * account, no key and no network, and a sky that goes flat grey the moment the - * wifi drops would fail that in the most visible way possible. + * The solar half is computed locally by `solar.ts` with no network, the lunar + * half by `moonPosition` below, and the weather half degrades to `null` — which + * this file reads as a clear day with the local climatology still running. The + * whole engine has to work with no account, no key and no network, and a sky + * that goes flat grey the moment the wifi drops would fail that in the most + * visible way possible. * * Wiring one to a city, in full: * @@ -100,18 +101,205 @@ export interface WeatherObservation { export interface Environment { time: Date; sun: SolarPosition; + /** + * The other light in the sky. Required rather than optional because a night + * without it is the black rectangle this file exists to avoid, and an + * `Environment` assembled without one would silently be that. + */ + moon: MoonPosition; /** `null` when nobody was asked. A supported state, not an error. */ weather: WeatherObservation | null; } -/** Build an `Environment` for a place and an instant, computing the sun locally. */ +/** + * Build an `Environment` for a place and an instant, computing both bodies + * locally. + */ export function observe( lat: number, lng: number, when: Date, weather: WeatherObservation | null = null, ): Environment { - return { time: when, sun: solarPosition(lat, lng, when), weather }; + return { + time: when, + sun: solarPosition(lat, lng, when), + moon: moonPosition(lat, lng, when), + weather, + }; +} + +// ---- The moon ------------------------------------------------------------- + +/** + * Where the moon is, and how much of it is lit. + * + * This lives here rather than in `solar.ts` because it exists for exactly one + * consumer: the key light after sunset. `solar.ts` is the sun's own module and + * has a much harder accuracy contract to keep — sunrise to the minute — whereas + * nothing downstream of this can tell a tenth of a degree of moon from the + * right answer. + */ +export interface MoonPosition { + /** Degrees clockwise from true north, matching `SolarPosition.azimuth`. */ + azimuth: number; + /** Degrees above the horizon, corrected for parallax. */ + elevation: number; + /** Fraction of the visible disc in sunlight: 0 at new, 1 at full. */ + illuminated: number; + /** 0 new, 0.25 first quarter, 0.5 full, 0.75 last quarter. */ + phase: number; + /** Centre to centre, in kilometres. Roughly 356,500 to 406,700. */ + distanceKm: number; +} + +const DEG = Math.PI / 180; +const RAD = 180 / Math.PI; +const JD_UNIX_EPOCH = 2_440_587.5; +const J2000 = 2_451_545; +/** Equatorial radius, for the parallax correction. */ +const EARTH_RADIUS_KM = 6378.14; + +/** + * The moon's position and phase, computed rather than fetched. + * + * Meeus, *Astronomical Algorithms* chapter 47, truncated hard. The full ELP + * series is sixty periodic terms in longitude alone; what is here is the + * thirteen largest, which are the ones with names — the equation of centre + * (6.29°, the orbit being an ellipse), the **evection** (1.27°, the sun pulling + * the orbit's own ellipse around every 32 days and the single largest thing + * Ptolemy did not know about), the **variation** (0.66°, the moon running fast + * at the syzygies and slow at the quadratures), the annual equation and the + * parallactic inequality. Against Meeus's own worked example 47.a — 1992 April + * 12, 0h TD — this returns 133.150° against his 133.163°, a latitude of -3.223° + * against -3.229°, and 368,335 km against 368,410. Thirteen thousandths of a + * degree. The moon's disc is half a degree wide, so a renderer cannot see the + * error and neither can anyone looking at it. + * + * Two deliberate omissions. The eccentricity factor `E` that Meeus applies to + * every term in the sun's mean anomaly is dropped, because it is a correction + * of about 0.017 to terms already under a fifth of a degree. And the elevation + * is not refracted — `solar.ts` owns that curve and does not export it, and at + * moonrise the moon is contributing almost nothing anyway. Parallax *is* + * applied, because it is the big one: the moon is close enough that standing on + * the surface of the Earth rather than at its centre moves it by most of a + * degree, which is fifty times the truncation error. + */ +export function moonPosition(lat: number, lng: number, when: Date): MoonPosition { + const jd = when.getTime() / MS_PER_DAY + JD_UNIX_EPOCH; + const t = (jd - J2000) / 36_525; + const sin = (deg: number) => Math.sin(deg * DEG); + const cos = (deg: number) => Math.cos(deg * DEG); + + // The Delaunay arguments, Meeus 47.1-47.5: the moon's mean longitude, its + // mean elongation from the sun, the sun's mean anomaly, the moon's own mean + // anomaly, and its argument of latitude — the angle from the ascending node, + // which is what makes the moon's path wander 5° either side of the ecliptic. + const meanLongitude = 218.3164477 + 481_267.88123421 * t - 0.0015786 * t * t; + const elongation = 297.8501921 + 445_267.1114034 * t - 0.0018819 * t * t; + const sunAnomaly = 357.5291092 + 35_999.0502909 * t - 0.0001536 * t * t; + const anomaly = 134.9633964 + 477_198.8675055 * t + 0.0087414 * t * t; + const argLatitude = 93.272095 + 483_202.0175233 * t - 0.0036539 * t * t; + + const d = elongation; + const m = sunAnomaly; + const mp = anomaly; + const f = argLatitude; + + const longitude = + meanLongitude + + 6.288774 * sin(mp) + // equation of centre + 1.274027 * sin(2 * d - mp) + // evection + 0.658314 * sin(2 * d) + // variation + 0.213618 * sin(2 * mp) - + 0.185116 * sin(m) - // annual equation + 0.114332 * sin(2 * f) + + 0.058793 * sin(2 * d - 2 * mp) + + 0.057066 * sin(2 * d - m - mp) + + 0.053322 * sin(2 * d + mp) + + 0.045758 * sin(2 * d - m) - + 0.040923 * sin(m - mp) - + 0.03472 * sin(d) - // parallactic inequality + 0.030383 * sin(m + mp); + + const latitude = + 5.128122 * sin(f) + + 0.280602 * sin(mp + f) + + 0.277693 * sin(mp - f) + + 0.173237 * sin(2 * d - f) + + 0.055413 * sin(2 * d - mp + f) + + 0.046271 * sin(2 * d - mp - f) + + 0.032573 * sin(2 * d + f) + + 0.017198 * sin(2 * mp + f) + + 0.009266 * sin(2 * d + mp - f) + + 0.008822 * sin(2 * mp - f); + + const distanceKm = + 385_000.56 - + 20_905.355 * cos(mp) - + 3699.111 * cos(2 * d - mp) - + 2955.968 * cos(2 * d) - + 569.925 * cos(2 * mp); + + // The sun's apparent longitude, to the same standard: needed only for the + // elongation the phase is read off, where a hundredth of a degree is three + // decimal places more than the illuminated fraction can carry. + const sunLongitude = + 280.46646 + + 36_000.76983 * t + + 1.914602 * sin(m) + + 0.019993 * sin(2 * m) + + 0.000289 * sin(3 * m); + + // Ecliptic to equatorial. + const obliquity = (23.4392911 - 0.0130042 * t) * DEG; + const lambda = longitude * DEG; + const beta = latitude * DEG; + const rightAscension = Math.atan2( + Math.sin(lambda) * Math.cos(obliquity) - Math.tan(beta) * Math.sin(obliquity), + Math.cos(lambda), + ); + const declination = Math.asin( + clamp( + Math.sin(beta) * Math.cos(obliquity) + Math.cos(beta) * Math.sin(obliquity) * Math.sin(lambda), + -1, + 1, + ), + ); + + // Equatorial to horizontal, through the local hour angle. Greenwich sidereal + // time is Meeus 12.4: the extra 0.98564736629° a day over 360 is the Earth's + // orbital motion, which is the whole reason a sidereal day is four minutes + // short of a solar one. + const gmst = mod(280.46061837 + 360.98564736629 * (jd - J2000) + 0.000387933 * t * t, 360); + const hourAngle = (gmst + lng) * DEG - rightAscension; + const phi = lat * DEG; + const sinAltitude = clamp( + Math.sin(phi) * Math.sin(declination) + + Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle), + -1, + 1, + ); + let altitude = Math.asin(sinAltitude); + const azimuth = Math.atan2( + -Math.cos(declination) * Math.sin(hourAngle), + Math.sin(declination) * Math.cos(phi) - Math.cos(declination) * Math.sin(phi) * Math.cos(hourAngle), + ); + altitude -= Math.asin(EARTH_RADIUS_KM / distanceKm) * Math.cos(altitude); + + // Phase from the sun-moon elongation. The proper phase angle also wants the + // earth-sun distance, which moves the answer by about a sixth of a degree — + // two parts in a thousand of the illuminated fraction, and this drives a + // light rig. + const separation = mod(longitude - sunLongitude, 360); + + return { + azimuth: mod(azimuth * RAD, 360), + elevation: altitude * RAD, + illuminated: (1 - Math.cos(separation * DEG)) / 2, + phase: separation / 360, + distanceKm, + }; } // ---- Options -------------------------------------------------------------- @@ -178,6 +366,45 @@ export const PACIFIC_MARINE_LAYER: MarineLayerOptions = { visibilityM: 5000, }; +/** + * Moonlight as a look rather than as a photometry. + * + * Full moonlight is about one four-hundred-thousandth of sunlight. Reproducing + * that ratio faithfully gives you a black screen, because a monitor has three + * orders of magnitude of range and this needs six, and because the eye that + * makes a moonlit landscape legible is doing an hour of dark adaptation that a + * lit room will not allow. So the numbers here are not the ratio; they are what + * a moonlit night *looks like* once you are in it — a low, soft, blue-shifted + * key you can read shapes by, on a sky that is deep blue rather than absent. + * + * The blue is the interesting lie. Moonlight is reflected sunlight off a + * grey-brown rock and is very slightly *warmer* than daylight, around 4,100 K. + * It looks blue because at those levels the eye is running on rods, whose peak + * sensitivity sits about 50 nm bluer than the cones' — the Purkinje shift — so + * a moonlit scene genuinely is blue to the person standing in it while being + * neutral to a light meter. Rendering it neutral is the more accurate choice + * and the wrong one, and every cinematographer since the 1930s has agreed. + */ +export interface MoonlightOptions { + /** Key intensity of a full moon, high, in clear air. Compare a noon sun at 2.1. */ + intensity: number; + /** The key's colour. */ + color: number; + /** How far a full moon lifts the night sky toward moonlit blue, 0..1. */ + skyLift: number; +} + +export const DEFAULT_MOONLIGHT: MoonlightOptions = { + // Physically absurd — moonlight is about 1/400,000 of sunlight — and the + // right number anyway. What is being reproduced is the *look* of a moonlit + // night on a screen someone is looking at in a lit room, not the photon + // count. At the honest value the map is a black rectangle, which is the bug + // this exists to fix. + intensity: 1.15, + color: 0x9db4e8, + skyLift: 0.85, +}; + export interface AtmosphereOptions { /** * Observer longitude, degrees east. Needed for apparent solar time, which is @@ -216,6 +443,13 @@ export interface AtmosphereOptions { shadowFloorDeg?: number; /** Coastal fog model, or nothing. Off unless a city asks for it. */ marineLayer?: MarineLayerOptions | null; + /** + * Moonlight, or `null` for none — which leaves the keyframe table's token + * night sidelight in charge, as it was before there was a moon to replace it. + * Defaults to `DEFAULT_MOONLIGHT`, because a black night is the failure and + * having to opt out of the fix is the wrong way round. + */ + moonlight?: MoonlightOptions | null; } export interface Atmosphere { @@ -236,6 +470,26 @@ const DEFAULT_FOG_FAR = 460; const DEFAULT_MIN_VISIBILITY_M = 4500; const DEFAULT_SHADOW_FLOOR_DEG = 7; +/** + * The darkest the night sky is ever allowed to get, per channel. + * + * A correct night is `#000`, and `#000` is unusable: the horizon disappears, + * the skyline stops having a silhouette against anything, and the frame reads + * as a failed render rather than as darkness. These are the values of a clear + * moonless sky as a dark-adapted eye reports it rather than as a photometer + * does — still unmistakably night, with the horizon a little warmer and + * brighter than the zenith because that is where the airglow and everyone + * else's city lights are. + */ +const NIGHT_FLOOR_TOP = 0x090e1c; +const NIGHT_FLOOR_HORIZON = 0x16203a; + +/** The same sky with a full moon in it. */ +const MOONLIT_SKY_TOP = 0x111d3e; +const MOONLIT_SKY_HORIZON = 0x2d3c62; +const MOONLIT_HEMI_SKY = 0x2b3b60; +const MOONLIT_AMBIENT = 0x3f4c76; + /** * Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is * 0.457 and looks right, so the ratio is held rather than the distance: a fog @@ -456,6 +710,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { const floorFar = (options.minVisibilityM ?? DEFAULT_MIN_VISIBILITY_M) / metresPerUnit; const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG; const marineOptions = options.marineLayer ?? null; + const moonOptions = options.moonlight === undefined ? DEFAULT_MOONLIGHT : options.moonlight; function apply(env: Environment): LightingState { const elevation = env.sun.elevation; @@ -468,23 +723,37 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { // still black, and a modifier that does not know the difference will // cheerfully raise the small hours to a uniform slate. const day = smoothstep(-6, 6, elevation); + const night = nightFactor(elevation); const weather = env.weather; const cloud = clamp(weather?.cloudCover ?? 0, 0, 1); const precipitation = clamp(weather?.precipitation ?? 0, 0, 1); const condition = weather?.condition ?? "clear"; - applyCloud(rig, cloud, day); - applyPrecipitation(rig, precipitation, condition, day); - // An observation always beats the climatology: `null` lets the model run // free, which is what gives an offline San Francisco its summer fog, but a // station reporting sunshine ends the argument. See `observedObscuration`. + // + // Computed here rather than after the rig passes, where it used to sit, + // because the moon needs to know how much air is in the way before it can + // say how much light is getting through. Both halves are pure functions of + // the observation and neither touches the rig, so the move is a reordering + // of independent statements and nothing else. const observed = weather ? observedObscuration(weather) : null; const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0; const obscuration = observed === null ? modelled : observed === 0 ? 0 : Math.max(observed, modelled); + // The moon goes in before the weather does, so that an overcast night is + // the weather closing over a moonlit sky rather than over a black one. + const moon = moonOptions + ? moonRig(env.moon, night, cloud, obscuration, moonOptions, shadowFloor) + : NO_MOON; + applyNight(rig, moon, night, moonOptions); + + applyCloud(rig, cloud, day); + applyPrecipitation(rig, precipitation, condition, day); + let fogFar = visibilityFar(weather, condition, clearFar, metresPerUnit); if (weather === null || weather.visibilityKm === null) { // Rain shortens the view; a source that measured visibility has already @@ -505,11 +774,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9); return { - sun: { - direction: lightDirection(env.sun, shadowFloor), - color: rig.sunColor, - intensity: Math.max(0, rig.sunIntensity), - }, + sun: combineKey(lightDirection(env.sun, shadowFloor), rig.sunColor, rig.sunIntensity, moon), hemisphere: { sky: rig.hemiSky, ground: rig.hemiGround, @@ -563,6 +828,190 @@ function lightDirection(sun: SolarPosition, floorDeg: number): [number, number, return [dir.x, dir.y, dir.z]; } +// ---- Night ---------------------------------------------------------------- + +/** + * How much of a night it is, from the sun alone: 0 in daylight, 1 once the + * sun's own light is gone. + * + * Exported because it is the engine's single definition of dusk, and more than + * one thing needs one. The moon takes over as the key light on this curve, and + * `nightlights.ts` reads it to decide how brightly a lit window burns against + * the sky behind it. Two modules each inventing their own idea of when night + * begins is how a city ends up switching its lights on well after the moon has + * already become the brightest thing in the frame. + * + * The upper edge is half a degree *above* the horizon rather than on it, + * because the sun's last half degree is its own disc setting and the light has + * already collapsed by then; the lower edge is the middle of nautical twilight, + * by which point what is left of the sun is a glow in one direction and not a + * light source. + */ +export function nightFactor(elevation: number): number { + return 1 - smoothstep(-8, 0.5, elevation); +} + +/** The moon's contribution, once phase, altitude and the weather have had a say. */ +interface MoonRig { + /** Unit vector toward the moon, floored like the sun's. `null` when it is down. */ + direction: [number, number, number] | null; + color: number; + /** Key intensity. */ + intensity: number; + /** How moonlit the sky and the fill are, 0..1. */ + glow: number; +} + +const NO_MOON: MoonRig = { direction: null, color: 0, intensity: 0, glow: 0 }; + +function moonRig( + moon: MoonPosition, + night: number, + cloud: number, + obscuration: number, + options: MoonlightOptions, + floorDeg: number, +): MoonRig { + // Below the horizon it contributes nothing, and the ramp above it is several + // degrees wide: a moon in the first degrees of its own rise is being + // reddened and extinguished by the same long air path that does it to the + // sun, and it has far less to lose. + const up = smoothstep(-1, 8, moon.elevation); + if (up <= 0 || night <= 0) return NO_MOON; + + // Not linear in the illuminated fraction, and this is the part that makes a + // moon phase read as a moon phase. A half moon is nowhere near half as bright + // as a full one — nearer a tenth. Two things do that: at quarter phase the + // ground you can see is lit at a grazing angle and is mostly its own long + // shadows, and at full phase those shadows all hide behind the rocks casting + // them and the disc surges. The exponent is the cheap version of both. The + // floor is earthshine, and the fact that a key of exactly zero leaves a scene + // with no silhouettes in it at all. + const lit = 0.06 + 0.94 * clamp(moon.illuminated, 0, 1) ** 1.6; + + // Cloud takes the key away much faster than it takes the glow away, and that + // asymmetry is the whole character of an overcast night: no shadows at all, + // but a deck lit from above that is brighter than a clear moonless sky. + const key = night * up * lit * clamp((1 - 0.9 * cloud) * (1 - 0.92 * obscuration), 0, 1); + const glow = night * up * lit * clamp(1 - 0.35 * cloud, 0, 1); + + const lifted = floorDeg > 0 ? Math.max(moon.elevation, floorDeg) : moon.elevation; + return { + direction: skyDirection(moon.azimuth, lifted), + color: options.color, + intensity: options.intensity * key, + glow: clamp(glow, 0, 1), + }; +} + +/** + * Horizontal coordinates to the engine's axes: `x` east, `z` south, `y` up. + * + * `solar.ts` exports `sunDirection` for exactly this conversion and it would + * work unchanged on the moon — it reads only an azimuth and an elevation — but + * its argument is a `SolarPosition`, and inventing a declination and an + * equation of time to satisfy a type is worse than four lines that say what + * they mean. The convention is `solar.ts`'s and must stay it: azimuth is + * measured from north, and north is `-z`. + */ +function skyDirection(azimuth: number, elevation: number): [number, number, number] { + const el = elevation * DEG; + const az = azimuth * DEG; + const horizontal = Math.cos(el); + return [horizontal * Math.sin(az), Math.sin(el), -horizontal * Math.cos(az)]; +} + +/** + * Fold the night into the rig: the moon's fill, the residual glow of a moonless + * night, and the floor under the sky. + * + * The key itself is not set here — see `combineKey` — because a `LightingState` + * carries one directional light and the sun has not necessarily finished with + * it yet. + */ +function applyNight( + rig: Rig, + moon: MoonRig, + night: number, + options: MoonlightOptions | null, +): void { + if (night <= 0) return; + + if (options) { + // The table's deepest stops carry a token sidelight standing in for + // moonlight and city glow, because a scene lit by hemisphere alone has no + // silhouettes in it and reads as a bug. There is a real moon now, so the + // stand-in gets out of its way — not entirely, because something still has + // to hold the shape of the city up on an overcast night at new moon. + rig.sunIntensity *= 1 - 0.85 * night; + + const glow = clamp(moon.glow * options.skyLift, 0, 1); + rig.skyTop = mixHex(rig.skyTop, MOONLIT_SKY_TOP, glow); + rig.skyHorizon = mixHex(rig.skyHorizon, MOONLIT_SKY_HORIZON, glow); + rig.hemiSky = mixHex(rig.hemiSky, MOONLIT_HEMI_SKY, 0.7 * glow); + rig.ambientColor = mixHex(rig.ambientColor, MOONLIT_AMBIENT, 0.7 * glow); + rig.hemiIntensity *= 1 + 0.5 * glow; + rig.ambientIntensity *= 1 + 0.45 * glow; + } + + // Starlight, airglow, and the sodium of everywhere else bouncing off whatever + // is overhead. Small, unshaped, and the difference between a night that is + // dark and a night that is missing. + rig.hemiIntensity *= 1 + 0.28 * night; + rig.ambientIntensity *= 1 + 0.22 * night; + + // Upward only and per channel, so this can rescue a sky and can never dim + // one — the weather passes that follow are free to keep taking light out of + // the frame without having to know this ran. + rig.skyTop = mixHex(rig.skyTop, atLeast(rig.skyTop, NIGHT_FLOOR_TOP), night); + rig.skyHorizon = mixHex(rig.skyHorizon, atLeast(rig.skyHorizon, NIGHT_FLOOR_HORIZON), night); +} + +/** + * One directional light, two things in the sky. + * + * `LightingState` carries a single key and that is the right shape — a second + * shadow-casting light is a second shadow map and a second full pass over 24k + * instances, for a source that is a two-hundred-thousandth as bright as the one + * already there. So the two get averaged, weighted by their own intensities, + * which is what a single light standing in for both ought to do: at dusk with a + * bright moon already up the key points somewhere between them, and by the time + * either one dominates it has arrived at that one. The crossover happens inside + * civil twilight, where both are near a tenth of an intensity and there is + * almost no shadow left to be wrong about. + */ +function combineKey( + sunDir: [number, number, number], + sunColor: number, + sunIntensity: number, + moon: MoonRig, +): LightingState["sun"] { + const sun = Math.max(0, sunIntensity); + if (!moon.direction || moon.intensity <= 0) { + return { direction: sunDir, color: sunColor, intensity: sun }; + } + + const total = sun + moon.intensity; + const weight = moon.intensity / total; + const x = lerp(sunDir[0], moon.direction[0], weight); + const y = lerp(sunDir[1], moon.direction[1], weight); + const z = lerp(sunDir[2], moon.direction[2], weight); + const length = Math.hypot(x, y, z); + + // A full moon rises as the sun sets and the two stand opposite each other, + // which is the one configuration where the average of the two directions is + // nothing at all. A zero direction would put the light inside the ground and + // black the scene out, so take the brighter of the two instead. + const direction: [number, number, number] = + length < 0.05 + ? weight >= 0.5 + ? moon.direction + : sunDir + : [x / length, y / length, z / length]; + + return { direction, color: mixHex(sunColor, moon.color, weight), intensity: total }; +} + // ---- Weather -------------------------------------------------------------- /** @@ -847,6 +1296,21 @@ function desaturate(hex: number, t: number): number { return encode(lerp(r, y, k), lerp(g, y, k), lerp(b, y, k)); } +/** + * Per-channel maximum, in linear light: a colour raised to a floor and never + * pushed below it. + * + * Per channel rather than by luminance, because the floor is a *colour* — a + * blue-black — and clamping a night sky by its brightness alone would let a + * grey of the same luminance through, which is the one thing the night must not + * be allowed to look like. + */ +function atLeast(hex: number, floor: number): number { + const [r, g, b] = linear(hex); + const [fr, fg, fb] = linear(floor); + return encode(Math.max(r, fr), Math.max(g, fg), Math.max(b, fb)); +} + /** Multiply a colour's light, not its bytes. */ function scale(hex: number, factor: number): number { const [r, g, b] = linear(hex); @@ -923,10 +1387,13 @@ function wrapSigned(x: number, period: number): number { * back a clear golden morning or it is not a model of anything — October is * the month San Francisco is warm and cloudless and every visitor is * surprised by it. - * - **03:00 PDT** (-23.7°): sun 0.01, hemisphere 0.25, ambient 0.10, and the - * light direction's `y` pinned at 0.122, which is sin 7° — the shadow - * floor, keeping the token night sidelight from shining up through the - * ground. + * - **03:00 PDT** (-23.7°, with the moon down): key 0.002, hemisphere 0.32, + * ambient 0.12, and the light direction's `y` pinned at 0.122, which is + * sin 7° — the shadow floor, keeping what is left of the token night + * sidelight from shining up through the ground. The key is a thousandth + * because the marine layer is at its thickest at 3 a.m. in June and takes + * 88% of it; the sky is a fog grey rather than a night blue for the same + * reason, which is right — a foggy night has no stars in it either. * - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly * the palette's own. Out of season, the layer is not there. * @@ -952,4 +1419,39 @@ function wrapSigned(x: number, period: number): number { * Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky * and nothing non-finite anywhere, which is the polar-night path through * `solar.ts` arriving here intact. + * + * **At night, San Francisco with no weather and no marine layer**, so that the + * moon can be read on its own: + * + * - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 0.307, colour + * #9bb2e6, hemisphere 0.46, ambient 0.17, sky #101b39 over #2a385b. A + * seventh of the sun's noon intensity against nearly half of its fill, + * which is a soft directional key with shadows you can find and not trip + * over — on a sky that is unmistakably night and unmistakably blue. + * - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.008 + * of the table's own sidelight colour, and the sky lands on #090e1c over + * #16203a — the floor, exactly. That is the darkest frame this file can + * produce, and it is the point: a genuinely correct night is `#000` and + * `#000` is a bug report. + * - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.078. A quarter of + * the full moon's key from half its disc and a tenth of its altitude, + * which is the phase curve and the rise ramp both doing visible work. + * - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.16 and the + * sky greyed to #232b43 over #2d3855. The fog takes half the moonlight and + * all of the colour, and August is when it would. + * - **The same night reported overcast**: key 0.032 — no shadows at all — + * with the fill barely down, because a cloud deck over a full moon is a + * softbox rather than a lid. + * - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.050, + * colour #2e3c66, the table's token sidelight left in charge. The night + * floor under the sky still applies, because that one is not about the + * moon. + * + * The 28 August 2026 dusk is worth watching as a sequence, because it is the + * configuration `combineKey` exists for — a full moon rising as the sun sets, + * the two of them opposite each other in the sky. At sun +0.8° the key is 0.663 + * and #d4835a from the west; at -3.4° it is 0.226 and #9482a0 from between + * them; by -7.3° it is 0.303 and #9ab0e3 from the east. The shadows swing + * across the city over about half an hour, which is not an artefact — it is + * what actually happens, and on the one night a month it happens on. */ diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts index 7c11765..7a43f9b 100644 --- a/src/engine/blocks.ts +++ b/src/engine/blocks.ts @@ -31,6 +31,36 @@ const PALETTES = { industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488], } satisfies Record; +/** + * How commercial each palette's buildings are, 0..1. + * + * Read only by `nightlights.ts`, and the reason a night city looks like a city + * rather than like a uniform field of dots: an office floor is a continuous + * band of large windows with half of them left on all night, and a house is two + * small warm rectangles that go out. The number is the same fact the palette + * already encodes, which is why it is derived from it rather than authored + * again per district. + */ +const COMMERCIAL = { + downtown: 1, + residential: 0.12, + industrial: 0.45, +} satisfies Record; + +/** + * The name of the per-instance attribute `createBlocks` leaves on its geometry: + * `[commercial, seed]`. + * + * A vertex attribute rather than a field on `userData` because the only + * consumer is a shader, and this puts the data where the GPU already wants it. + * `createBlocks` writes it because `createBlocks` is what knows which district + * a given instance came out of; nothing else can recover that from the mesh. + */ +export const FACADE_ATTRIBUTE = "aFacade"; + +/** An independent stream for the facades; see where it is drawn from. */ +const FACADE_SEED = 20_261; + interface Box { x: number; z: number; @@ -40,6 +70,7 @@ interface Box { h: number; rot: number; color: THREE.Color; + commercial: number; } function polygonBounds(poly: [number, number][]) { @@ -65,6 +96,7 @@ export function createBlocks(world: World): THREE.InstancedMesh { seedBase += 7919; const palette = PALETTES[district.palette]; + const commercial = COMMERCIAL[district.palette]; const angle = district.gridAngle; const coverage = district.coverage ?? 0.88; @@ -124,6 +156,8 @@ export function createBlocks(world: World): THREE.InstancedMesh { h: world.metres(heightM), rot: angle + (rand() - 0.5) * 0.03, color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6), + // A tower is an office whatever district it landed in. + commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)), }); } } @@ -132,6 +166,22 @@ export function createBlocks(world: World): THREE.InstancedMesh { const geometry = new THREE.BoxGeometry(1, 1, 1); geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level + // The per-instance facade data, drawn from a stream of its own. + // + // The obvious place for the seed is inside the placement loop, next to every + // other `rand()` — and putting it there would have been a mistake, because a + // scatter's draw sequence is load-bearing. One extra call shifts every + // subsequent draw, and the whole city would have rebuilt itself the first + // time anyone lit a window. A second stream costs nothing, is just as + // deterministic across reloads, and leaves the skyline exactly where it was. + const windows = seededRandom(FACADE_SEED); + const facade = new Float32Array(boxes.length * 2); + boxes.forEach((b, i) => { + facade[i * 2] = b.commercial; + facade[i * 2 + 1] = windows(); + }); + geometry.setAttribute(FACADE_ATTRIBUTE, new THREE.InstancedBufferAttribute(facade, 2)); + const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length); mesh.name = "blocks"; mesh.castShadow = true; diff --git a/src/engine/flights.ts b/src/engine/flights.ts index f587815..0bbd29a 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -15,6 +15,7 @@ */ import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; import type { Aircraft, FlightSource } from "./types.ts"; import { seededRandom, type World } from "./world.ts"; @@ -54,20 +55,33 @@ export class SimulatedFlights implements FlightSource { this.t += Math.min(now - this.last, 5); this.last = now; - return this.routes.map((route, i) => { - const p = ((this.t / route.duration + (this.phase[i] ?? 0)) % 1 + 1) % 1; - const lat = route.from[0] + (route.to[0] - route.from[0]) * p; - const lng = route.from[1] + (route.to[1] - route.from[1]) * p; - // Ease the altitude so departures climb steeply and level off. - const ease = 1 - (1 - p) ** 2; - const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease; - const heading = - (Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI; - return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading }; - }); + return this.routes.map((route, i) => sampleRoute(route, this.t / route.duration + (this.phase[i] ?? 0))); } } +/** + * One aircraft's state at a fraction of the way along its leg. `p` wraps, so + * anything can be handed in and 1.4 means the same as 0.4. + * + * Split out of `SimulatedFlights.poll` because the HTTP adapter needs exactly + * this and cannot reuse the class to get it: `SimulatedFlights` runs on a + * monotonic clock that starts when it is constructed, whereas the wire's + * `FlightsPlanBody` anchors every route to a fixed epoch so that two browsers + * agree about where the aircraft are. Same arithmetic, different origin — and + * two copies of the arithmetic would drift. + */ +export function sampleRoute(route: SimRoute, p: number): Aircraft { + const t = ((p % 1) + 1) % 1; + const lat = route.from[0] + (route.to[0] - route.from[0]) * t; + const lng = route.from[1] + (route.to[1] - route.from[1]) * t; + // Ease the altitude so departures climb steeply and level off. + const ease = 1 - (1 - t) ** 2; + const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease; + const heading = + (Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI; + return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading }; +} + function nowSeconds(): number { return (typeof performance !== "undefined" ? performance.now() : 0) / 1000; } @@ -125,53 +139,447 @@ interface RawAircraft { export interface FlightLayer { group: THREE.Group; + /** + * Hand over a fresh observation. Called on the source's own timer, which is + * once a second for the simulator and once every several seconds for a real + * feed; the motion in between is this layer's problem, not the caller's. + */ update(aircraft: Aircraft[]): void; + /** + * Move everything to where it should be at this instant. + * + * A pure function of the wall clock and the last two observations, so calling + * it twice in a frame does the same thing as calling it once. That matters: + * the layer drives itself from the trail geometry's `onBeforeRender` — see + * `createFlightLayer` — and a scene that also ticks it explicitly must not end + * up advancing time twice as fast. + */ + tick(): void; dispose(): void; } /** - * Aircraft as small darts with a shadow-less trail. Rendered at true altitude - * through the world's vertical exaggeration, so a jet on approach sits visibly - * below one at cruise. + * How many observations a trail remembers, and how long it may hold one. + * + * Both limits are needed. The count keeps the shared vertex buffer bounded, and + * the age keeps a slow feed from drawing a trail across the entire bay: at + * `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes + * of flying, which is most of a leg. + */ +const TRAIL_POINTS = 20; +const TRAIL_SECONDS = 45; + +/** Ceiling on tracks that get a trail, so the buffer can be allocated once. */ +const MAX_TRACKS = 192; + +/** + * Opacity at the head of a trail, fading to nothing at the tail. Well under 1 + * on purpose: the trail is context for the dart, not a second subject, and a + * dozen opaque lines over a city read as a wiring diagram. + */ +const TRAIL_ALPHA = 0.55; + +/** + * Bounds on how long a leg between two observations may be taken to be. + * + * The span is measured rather than declared, because a `FlightSource` announces + * an `interval` and then misses it — a tab in the background, a slow upstream, + * a fetch that took two seconds. Interpolating over the announced interval when + * the real gap was four times that gives an aircraft that darts and then waits. + */ +const MIN_SPAN = 0.2; +const MAX_SPAN = 15; + +/** + * Above this, a step is a teleport rather than a flight. + * + * Scene units per second, and generous: a fast jet at this city's ~94 m per + * unit covers about three. The case this exists for is the simulator's routes + * looping — an aircraft reaching the end of its leg reappears at the start, + * which is several hundred units in one poll — and without the check the trail + * draws a bright line straight across San Francisco every time one wraps. + */ +const JUMP_UNITS_PER_SECOND = 8; + +/** + * Altitude, as colour. + * + * The obvious cue is a drop line to the ground, and it was tried first and + * removed: this city renders at ~94 m per scene unit with a 3.6× vertical + * exaggeration, so an aircraft at cruise sits about 230 units above a downtown + * whose tallest tower is 10, and its drop line is a full-height wire through the + * middle of the frame. Twelve of those is a birdcage. Colour costs nothing, is + * readable at any camera distance, and — because the trail carries it too — a + * climb shows up as a gradient along the ribbon rather than as a number nobody + * reads. + */ +const LOW_COLOR = new THREE.Color(0xffb277); +const HIGH_COLOR = new THREE.Color(0xdfeaf6); +/** Metres at which the ramp reaches `HIGH_COLOR`. Roughly a cruising airliner. */ +const CRUISE_METRES = 9000; +/** Distinct materials along the ramp. Enough to look continuous, few enough to cache. */ +const COLOR_BANDS = 12; + +/** Steepest nose-up or nose-down attitude a dart is drawn at, in radians. */ +const MAX_PITCH = 0.42; + +interface TrailSample { + position: THREE.Vector3; + altitude: number; + /** Compass degrees, as reported. */ + heading: number; + /** Seconds on `nowSeconds`'s monotonic clock. */ + at: number; +} + +interface Track { + mesh: THREE.Mesh; + /** Observations, oldest first. The last is where the aircraft is heading. */ + samples: TrailSample[]; + /** Seconds the current leg should take: the measured gap between the last two. */ + span: number; + /** Climb angle of the current leg, radians, positive nose-up. */ + pitch: number; + /** Which cached material is on the mesh, so a band change is the only write. */ + band: number; + /** Interpolated position, reused rather than reallocated every frame. */ + head: THREE.Vector3; + /** Altitude at `head`, which is what the dart's colour is chosen from. */ + headAltitude: number; +} + +/** + * Aircraft as small darts, each dragging a fading trail of where it has been. + * + * Rendered at true altitude through the world's vertical exaggeration, so a jet + * on approach sits visibly below one at cruise, and coloured by that altitude so + * the difference survives a camera far enough away that the heights stop being + * separable. + * + * The layer moves things every frame while being told where they are only every + * poll. Positions are interpolated between the last two observations rather than + * extrapolated past the newest one: that costs one interval of lag — a second + * for the simulator — and in exchange an aircraft never overshoots and then + * snaps back, which is what extrapolation does the moment a feed stutters. */ export function createFlightLayer(world: World): FlightLayer { const group = new THREE.Group(); group.name = "flights"; - const geo = new THREE.ConeGeometry(0.1, 0.42, 5); - geo.rotateX(Math.PI / 2); // point along +z, so heading maps to a Y rotation - const material = new THREE.MeshLambertMaterial({ color: 0xf2f5f8 }); - const meshes = new Map(); + const geo = dartGeometry(); + const materials = new Map(); + const tracks = new Map(); + + const scratch = new THREE.Color(); + + /** + * One material per altitude band, built on demand. + * + * The emissive term is small and deliberate. Aircraft are lit by the same rig + * as the city, and after sunset that rig is a tenth of an intensity — a dart + * of pure diffuse white simply disappears at night, which is the one time of + * day the sky is worth looking at. + */ + function materialFor(band: number): THREE.MeshLambertMaterial { + const existing = materials.get(band); + if (existing) return existing; + const color = scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, band / (COLOR_BANDS - 1)).getHex(); + const mat = new THREE.MeshLambertMaterial({ + color, + emissive: color, + emissiveIntensity: 0.35, + }); + materials.set(band, mat); + return mat; + } + + // ---- The trail ---------------------------------------------------------- + + // One `LineSegments` for every trail in the scene rather than one per + // aircraft: the vertex count is trivial either way, and a single draw call + // with a preallocated buffer avoids allocating and disposing geometry every + // time traffic changes. Per-vertex alpha does the fade, which needs a + // four-component colour attribute — three.js reads the item size and switches + // the shader on it. + const maxVertices = MAX_TRACKS * TRAIL_POINTS * 2; + const trailPositions = new Float32Array(maxVertices * 3); + const trailColors = new Float32Array(maxVertices * 4); + const trailGeo = new THREE.BufferGeometry(); + trailGeo.setAttribute("position", new THREE.BufferAttribute(trailPositions, 3)); + trailGeo.setAttribute("color", new THREE.BufferAttribute(trailColors, 4)); + trailGeo.setDrawRange(0, 0); + const trailMat = new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + // Trails cross each other constantly and are the faintest thing in the + // scene; letting them write depth makes the one that happened to draw first + // punch a hole in every one behind it. + depthWrite: false, + }); + const trailLine = new THREE.LineSegments(trailGeo, trailMat); + trailLine.name = "flight-trails"; + // The buffer is rewritten from scene-space coordinates every frame, so its + // bounding sphere is permanently wrong and culling it would be culling the + // whole layer. + trailLine.frustumCulled = false; + // The layer is handed observations on the source's timer and is otherwise + // never called, so the interpolation hangs off the one thing guaranteed to + // happen every frame: this line being drawn. `tick` is idempotent, so a scene + // that would rather drive the layer itself can call it and nothing here + // double-counts. + trailLine.onBeforeRender = () => tick(); + group.add(trailLine); + + // ---- Observations ------------------------------------------------------- function update(aircraft: Aircraft[]) { + const now = nowSeconds(); const seen = new Set(); + for (const a of aircraft) { seen.add(a.id); - let mesh = meshes.get(a.id); - if (!mesh) { - mesh = new THREE.Mesh(geo, material); - meshes.set(a.id, mesh); - group.add(mesh); - } const [x, z] = world.project(a.lat, a.lng); - mesh.position.set(x, world.metres(a.altitude), z); - mesh.rotation.y = -(a.heading * Math.PI) / 180; + const position = new THREE.Vector3(x, world.metres(a.altitude), z); + const sample: TrailSample = { position, altitude: a.altitude, heading: a.heading, at: now }; + + let track = tracks.get(a.id); + if (!track) { + const mesh = new THREE.Mesh(geo, materialFor(0)); + // Yaw then pitch, because the heading is about the world's vertical and + // the climb angle is about the aircraft's own wing. + mesh.rotation.order = "YXZ"; + group.add(mesh); + track = { + mesh, + samples: [], + span: MIN_SPAN, + pitch: 0, + band: -1, + head: position.clone(), + headAltitude: a.altitude, + }; + tracks.set(a.id, track); + } + + const previous = track.samples[track.samples.length - 1]; + if (previous) { + // The clamp is load-bearing on both ends. Two polls arriving in the same + // millisecond — a manual refresh, a tab waking up — divide by nearly + // zero and make every aircraft look like it teleported; a source that + // stalled for a minute makes the next honest step look like one too. + const span = clamp(now - previous.at, MIN_SPAN, MAX_SPAN); + // Ground distance only. Scene height is exaggerated 3.6× here, so a + // healthy climb contributes more to a straight 3-D distance than the + // aircraft's actual speed does, and a departure out of SFO would trip + // the teleport test on every poll. + const travelled = Math.hypot( + position.x - previous.position.x, + position.z - previous.position.z, + ); + if (travelled / span > JUMP_UNITS_PER_SECOND) { + // A source that has moved something further than anything flies has + // either looped a simulated route or reused an id. Either way the + // history is about a different flight; keeping it would draw a trail + // across the map. + track.samples.length = 0; + track.head.copy(position); + track.pitch = 0; + } else { + track.span = span; + track.pitch = climbAngle(world, previous, sample); + } + } + + track.samples.push(sample); + trim(track, now); } - for (const [id, mesh] of meshes) { + + for (const [id, track] of tracks) { if (seen.has(id)) continue; - group.remove(mesh); - meshes.delete(id); + group.remove(track.mesh); + tracks.delete(id); } + + tick(); + } + + /** Forget history that is too old or too long to be worth drawing. */ + function trim(track: Track, now: number) { + while (track.samples.length > TRAIL_POINTS) track.samples.shift(); + while (track.samples.length > 2) { + const oldest = track.samples[0]; + if (!oldest || now - oldest.at <= TRAIL_SECONDS) break; + track.samples.shift(); + } + } + + // ---- Per-frame ---------------------------------------------------------- + + function tick() { + const now = nowSeconds(); + for (const track of tracks.values()) { + const n = track.samples.length; + const to = track.samples[n - 1]; + if (!to) continue; + const from = track.samples[n - 2] ?? to; + const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1); + + track.head.lerpVectors(from.position, to.position, alpha); + track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha; + + track.mesh.position.copy(track.head); + // A heading of 0 is north, and north is -z, so a dart whose nose is + // modelled along +z has to be turned all the way round before the compass + // and the scene agree. The previous mapping was a bare negation of the + // heading, which flew every aircraft tail-first and put an easterly + // departure over the Pacific. + track.mesh.rotation.y = Math.PI - (interpolateHeading(from.heading, to.heading, alpha) * Math.PI) / 180; + // Negative, because rotating the nose (+z) about +x by a positive angle + // pushes it down. + track.mesh.rotation.x = -track.pitch; + + const band = bandFor(track.headAltitude); + if (band !== track.band) { + track.band = band; + track.mesh.material = materialFor(band); + } + } + rebuildTrails(); + } + + /** + * Rewrite the shared trail buffer. + * + * The spine is every observation except the newest, followed by the + * interpolated head — the newest observation is where the aircraft is *going*, + * and drawing to it would put the trail in front of the dart. + */ + function rebuildTrails() { + let vertex = 0; + for (const track of tracks.values()) { + const spine = track.samples.length - 1; + if (spine < 1) continue; + const points = spine + 1; // the spine, plus the head + + for (let i = 1; i < points; i++) { + if (vertex + 2 > maxVertices) break; + const a = track.samples[i - 1]; + if (!a) continue; + const bSample = i < spine ? track.samples[i] : null; + const bPosition = bSample ? bSample.position : track.head; + const bAltitude = bSample ? bSample.altitude : track.headAltitude; + + // Alpha runs from nothing at the tail to `TRAIL_ALPHA` at the aircraft, + // eased so that the fade happens mostly in the older half and the + // segment behind the dart stays legible. + writeTrailVertex(vertex++, a.position, a.altitude, ((i - 1) / spine) ** 1.7); + writeTrailVertex(vertex++, bPosition, bAltitude, (i / spine) ** 1.7); + } + } + trailGeo.setDrawRange(0, vertex); + trailGeo.attributes.position!.needsUpdate = true; + trailGeo.attributes.color!.needsUpdate = true; + } + + function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) { + const p = index * 3; + trailPositions[p] = position.x; + trailPositions[p + 1] = position.y; + trailPositions[p + 2] = position.z; + // `THREE.Color` holds working-space values, which is what a vertex colour + // attribute is read as — so the ramp and the dart materials, which come from + // the same two colours, agree. + scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, ramp(altitude)); + const c = index * 4; + trailColors[c] = scratch.r; + trailColors[c + 1] = scratch.g; + trailColors[c + 2] = scratch.b; + trailColors[c + 3] = fade * TRAIL_ALPHA; } return { group, update, + tick, dispose() { geo.dispose(); - material.dispose(); - meshes.clear(); + for (const m of materials.values()) m.dispose(); + materials.clear(); + trailGeo.dispose(); + trailMat.dispose(); + tracks.clear(); group.clear(); }, }; } + +/** + * A dart: a five-sided body with a wing and a tailplane, merged into one + * geometry so an aircraft is one draw call. + * + * The wing is what earns its keep. A bare cone at this scale is a bright speck + * with no orientation, and the whole reason to draw traffic on a city map is + * that it is going somewhere — the crossbar is the only part of the silhouette + * that says which way. + */ +function dartGeometry(): THREE.BufferGeometry { + const body = new THREE.ConeGeometry(0.09, 0.42, 5); + body.rotateX(Math.PI / 2); // nose along +z, so heading is a rotation about Y + const wing = new THREE.BoxGeometry(0.44, 0.016, 0.085); + wing.translate(0, -0.005, -0.02); + const tail = new THREE.BoxGeometry(0.15, 0.014, 0.055); + tail.translate(0, 0.02, -0.165); + + const parts = [body, wing, tail]; + const merged = mergeGeometries(parts); + for (const part of parts) part.dispose(); + if (merged) return merged; + + // `mergeGeometries` returns null when the inputs disagree about their + // attributes, which three primitives from the same library cannot — but the + // signature allows it, and a missing aircraft is worse than a plain one. + const fallback = new THREE.ConeGeometry(0.09, 0.42, 5); + fallback.rotateX(Math.PI / 2); + return fallback; +} + +/** + * The climb angle of a leg, from the real numbers rather than the scene's. + * + * Scene height is exaggerated 3.6× here, so an angle measured off the rendered + * positions would put a routine departure at forty degrees nose-up. Horizontal + * distance in scene units *is* proportional to distance on the ground, so one + * multiplication converts it and the altitudes are already metres. + */ +function climbAngle(world: World, from: TrailSample, to: TrailSample): number { + const dx = to.position.x - from.position.x; + const dz = to.position.z - from.position.z; + const horizontal = Math.hypot(dx, dz) * world.metresPerUnit; + if (horizontal < 1) return 0; + return clamp(Math.atan2(to.altitude - from.altitude, horizontal), -MAX_PITCH, MAX_PITCH); +} + +/** + * Blend two compass headings the short way round. + * + * A straight lerp from 350° to 10° spins the aircraft 340° through south over + * the course of a second, which is the most conspicuous artefact this whole file + * could have. + */ +function interpolateHeading(from: number, to: number, t: number): number { + const delta = (((to - from) % 360) + 540) % 360 - 180; + return from + delta * t; +} + +/** 0 on the deck, 1 at cruise. Curved, because the low end is where the eye is. */ +function ramp(altitude: number): number { + return clamp(altitude / CRUISE_METRES, 0, 1) ** 0.6; +} + +function bandFor(altitude: number): number { + return Math.round(ramp(altitude) * (COLOR_BANDS - 1)); +} + +function clamp(x: number, lo: number, hi: number): number { + return x < lo ? lo : x > hi ? hi : x; +} diff --git a/src/engine/nightlights.ts b/src/engine/nightlights.ts new file mode 100644 index 0000000..f44f5e3 --- /dev/null +++ b/src/engine/nightlights.ts @@ -0,0 +1,572 @@ +/** + * The city's own lights: lit windows in the buildings, and lamps along the + * streets. + * + * This is the other half of making a night usable. `atmosphere.ts` puts a moon + * up so there is something to see the city *by*; this puts light *in* the city, + * which is most of what a city at night actually is — from any distance a + * skyline after dark is not a shape you can make out, it is a field of small + * bright rectangles that happens to have a shape. + * + * Two constraints shaped everything here: + * + * - **There are ~24,000 buildings in one `InstancedMesh`.** A point light per + * building is not a slow version of this, it is an impossible one: three.js + * evaluates every light in the fragment shader for every lit surface, and + * the practical ceiling is a few dozen. So the buildings do not emit light + * at all. They *are* light — an emissive term added inside the existing + * material, which costs one shader patch and no extra draw calls, and which + * the moon and the fog and the shadows all continue to work around + * untouched. + * - **Nothing may reshuffle between frames or reloads.** Which windows are on + * is a hash of the window's own cell index and a per-instance seed drawn + * from `blocks.ts`'s seeded RNG, evaluated in the fragment shader. It is a + * pure function of position, so it is stable across frames for free, and it + * costs no memory at all: 24,000 buildings' worth of individual windows + * would be millions of booleans and there are none of them anywhere. + * + * **This is not a lighting owner.** `Atmosphere` owns the rig and CONTRACT.md §4 + * is explicit that nothing else may touch it; what this module owns is + * *emission*, which is a property of the buildings and not of the light rig, and + * it never constructs a `THREE.Light` of any kind. The seam is one number in: + * `setSolarElevation`, which is the same solar elevation `Atmosphere` is + * reading. Night is data, not a mode flag, and there is nothing to switch. + * + * An interior gets none of this, for the same reason it gets no `Atmosphere`: + * an office has its own fixed rig and no idea what time it is outside. + */ + +import * as THREE from "three"; +import { nightFactor } from "./atmosphere.ts"; +import { FACADE_ATTRIBUTE } from "./blocks.ts"; +import { seededRandom, type World } from "./world.ts"; + +export interface NightLightsOptions { + world: World; + /** The buildings, exactly as `createBlocks` returned them. */ + blocks: THREE.InstancedMesh; + /** Lamps along the road network. On by default. */ + streetLamps?: boolean; + /** Metres between street lamps. */ + lampSpacingM?: number; + /** + * Ceiling on how many lamps get built, as insurance against a city pack with + * a very dense road network. SF's twenty-five roads produce a few thousand. + */ + maxLamps?: number; +} + +export interface NightLights { + /** Everything this layer adds to the scene. Added once, then driven. */ + group: THREE.Group; + /** + * The seam. Hand it the solar elevation in degrees — the same number + * `Atmosphere` is working from — and the city switches itself on. + */ + setSolarElevation(degrees: number): void; + /** How on the lights currently are, 0..1. For a debug readout. */ + strength(): number; + dispose(): void; +} + +// ---- Constants ------------------------------------------------------------ + +/** + * A window bay, and a storey, in metres. + * + * The storey is real. The bay is not: a curtain wall's mullions are nearer 1.5 m + * apart, and at San Francisco's ~94 m per scene unit that is a third of a pixel + * from anywhere the camera is allowed to be, so an honest bay renders as grey + * noise and nothing else. 6.5 m is the coarsest grid that still reads as + * windows rather than as panels, which puts about six bays across a 40 m lot + * and gives a pane the wide flat shape of a ribbon window. Vertically there is + * no such problem — the city's 3.6x exaggeration makes a storey four times a + * bay on screen — so the storey stays honest. + */ +const WINDOW_PITCH_M = 6.5; +const STOREY_M = 3.6; + +/** + * Fraction of windows left on, by how commercial the building is. + * + * Both are lower than they look, and deliberately: the aggregate is what the + * eye reads, and the first pass at this — half of every office window on — came + * out as a city of solid glowing slabs with no building shapes left in it. A + * quarter is already a *lot* of light once every pane is at nearly full + * emission, and a house showing one window in sixteen is a street with somebody + * still up on it. + */ +const HOUSE_LIT = 0.06; +const OFFICE_LIT = 0.24; + +/** + * The two colours a lit window comes in. + * + * Warm is a domestic lamp — tungsten, or the LED everyone buys because it looks + * like tungsten — at something like 2,700 K. Cool is an office ceiling left on + * by the cleaners, which is the other half of any real skyline and the half + * that makes the warm windows read as warm. Passed as `THREE.Color`, so three + * converts them out of sRGB into the linear working space on the way to the + * uniform and the emissive term lands in the same space as everything else in + * the shader. + */ +const WINDOW_WARM = 0xffc178; +const WINDOW_COOL = 0xd8e4ff; + +/** Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown. */ +const WINDOW_GAIN = 0.8; + +/** Sodium, because a street lamp is the one light in a city that still is. */ +const LAMP_COLOR = 0xffb264; +const LAMP_HEIGHT_M = 9; +const DEFAULT_LAMP_SPACING_M = 55; +const DEFAULT_MAX_LAMPS = 24_000; + +/** + * Glow radius of a lamp, in scene units. + * + * Chosen against the far end of the camera's orbit rather than the near end. At + * 100 units out — the framing the city is usually looked at from — this is a + * few pixels, which is what a street lamp is; flying down to the 12-unit + * minimum blooms it to something much larger than a lamp. That is the wrong way + * round from a purist's point of view and the right way round for the frame + * anyone actually looks at, and the alternative — a fixed pixel size — turns the + * whole road network into a sheet of aliasing sparkle the moment you pull back. + */ +const LAMP_SIZE = 0.3; + +const LAMP_SEED = 61_803; + +/** + * When the lamps come on, in degrees of solar elevation. + * + * Earlier than `nightFactor`, and deliberately so: street lighting switches on + * around sunset, an hour before the sky is dark, and offices have been lit + * since the afternoon. What `nightFactor` then adds is not more lights but more + * *contrast* — the same windows against a sky that has stopped competing with + * them. Multiplying the two is what produces the real sequence, where the city + * appears to come on gradually over an hour without anything ever switching. + */ +const LAMPS_ON_HIGH = 5; +const LAMPS_ON_LOW = -5; + +/** Below this the layer is hidden outright rather than drawn at zero. */ +const DARK_ENOUGH = 0.002; + +// ---- The layer ------------------------------------------------------------ + +export function createNightLights(options: NightLightsOptions): NightLights { + const { world, blocks } = options; + + const group = new THREE.Group(); + group.name = "nightlights"; + + // Shared with the shader by reference: `onBeforeCompile` hands these exact + // objects to the program, so writing `.value` here is what drives the frame. + const uniforms = { + uNight: { value: 0 }, + uWindowPitch: { value: WINDOW_PITCH_M / world.metresPerUnit }, + // A storey goes through `world.metres`, so it picks up the city's vertical + // exaggeration exactly as the building's own height did. Without that the + // floor count would be wrong by the exaggeration factor — a 100 m tower + // would come out with a hundred floors in it. + uStorey: { value: world.metres(STOREY_M) }, + uWarm: { value: new THREE.Color(WINDOW_WARM) }, + uCool: { value: new THREE.Color(WINDOW_COOL) }, + uGain: { value: WINDOW_GAIN }, + uHouseLit: { value: HOUSE_LIT }, + uOfficeLit: { value: OFFICE_LIT }, + }; + + const facade = blocks.geometry.getAttribute(FACADE_ATTRIBUTE); + const material = blocks.material; + const patched = + !Array.isArray(material) && material instanceof THREE.MeshLambertMaterial && facade + ? patchFacades(material, uniforms) + : null; + + const lamps = (options.streetLamps ?? true) ? buildLamps(world, options) : null; + if (lamps) group.add(lamps.points); + + let strength = 0; + + function setSolarElevation(degrees: number) { + // Two curves, multiplied: when the lights are on, and how much darker than + // them the sky is. See `LAMPS_ON_HIGH`. + const on = 1 - smoothstep(LAMPS_ON_LOW, LAMPS_ON_HIGH, degrees); + strength = on * (0.35 + 0.65 * nightFactor(degrees)); + + uniforms.uNight.value = strength; + if (lamps) { + lamps.points.visible = strength > DARK_ENOUGH; + lamps.material.opacity = strength; + } + } + + setSolarElevation(90); + + return { + group, + setSolarElevation, + strength: () => strength, + dispose() { + // The buildings are not ours and outlive this layer, so the material goes + // back exactly as it was found rather than being left with a dark + // uniform in it and a patch nobody remembers applying. + patched?.(); + lamps?.points.geometry.dispose(); + lamps?.material.map?.dispose(); + lamps?.material.dispose(); + group.clear(); + }, + }; +} + +// ---- Lit windows ---------------------------------------------------------- + +type Uniforms = Record; + +/** + * Add an emissive window grid to the buildings' own material. + * + * Patching in place rather than replacing the material, because `blocks.ts` + * owns what a facade looks like in daylight and this has no business having an + * opinion about that. Everything below is additive: a `totalEmissiveRadiance` + * term, computed after the lighting has been accumulated and before fog and the + * colour-space encode, so a lit window is correctly hazed by the marine layer + * and correctly *not* darkened by being in shadow. Which is right — a window is + * a hole with a light behind it, and nothing outside the building can shade it. + * + * Returns the undo. + */ +function patchFacades(material: THREE.MeshLambertMaterial, uniforms: Uniforms): () => void { + const previous = material.onBeforeCompile; + + material.onBeforeCompile = (shader) => { + for (const [name, uniform] of Object.entries(uniforms)) { + shader.uniforms[name] = uniform as THREE.IUniform; + } + + shader.vertexShader = shader.vertexShader + .replace("#include ", `#include \n${VERTEX_PARS}`) + .replace("#include ", `#include \n${VERTEX_BODY}`); + + shader.fragmentShader = shader.fragmentShader + .replace("#include ", `#include \n${FRAGMENT_PARS}`) + .replace( + "#include ", + `#include \n${FRAGMENT_BODY}`, + ); + }; + // `Material.customProgramCacheKey` defaults to the source of + // `onBeforeCompile`, so the renderer will not hand this material a program + // compiled for an unpatched one. Changing the function is still a new + // program, hence the flag. + material.needsUpdate = true; + + return () => { + material.onBeforeCompile = previous; + material.needsUpdate = true; + }; +} + +/** + * The varyings are declared unconditionally in both stages — a varying present + * in one and absent from the other is a link error — while the two things that + * only exist under instancing are guarded. `aFacade` needs no guard: an + * unbound vertex attribute reads as zero, which is a building with no windows + * lit, which is a perfectly good failure. + */ +const VERTEX_PARS = /* glsl */ ` +attribute vec2 aFacade; +varying vec3 vFacadeLocal; +varying vec3 vFacadeNormal; +varying vec3 vFacadeSize; +varying vec2 vFacade; +`; + +const VERTEX_BODY = /* glsl */ ` +vFacadeLocal = transformed; +vFacadeNormal = objectNormal; +vFacade = aFacade; +#ifdef USE_INSTANCING + // The instance's scale, recovered from the columns of its own matrix. This is + // what puts the window grid in scene units instead of in fractions of a + // building: without it every tower would have the same number of floors as + // the bungalow next door, stretched to fit. + vFacadeSize = vec3( + length(instanceMatrix[0].xyz), + length(instanceMatrix[1].xyz), + length(instanceMatrix[2].xyz) + ); +#else + vFacadeSize = vec3(1.0); +#endif +`; + +const FRAGMENT_PARS = /* glsl */ ` +uniform float uNight; +uniform float uWindowPitch; +uniform float uStorey; +uniform float uGain; +uniform float uHouseLit; +uniform float uOfficeLit; +uniform vec3 uWarm; +uniform vec3 uCool; +varying vec3 vFacadeLocal; +varying vec3 vFacadeNormal; +varying vec3 vFacadeSize; +varying vec2 vFacade; + +float facadeHash(vec3 p) { + return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123); +} +`; + +/** + * The window grid, and the reason it does not sparkle. + * + * A window bay is about 4 m, which at San Francisco's ~94 m per scene unit is + * 0.042 units — and from the distance the city is normally looked at, that is + * well under a pixel. Drawn honestly it would be a sheet of moiré that crawls + * whenever the camera moves, which is the classic failure of any procedural + * pattern with no mip chain behind it. `fwidth` gives the pattern's own + * footprint in pixels, and past about one cell per pixel the grid is replaced + * by its average — which is exactly what a mip level would have contained. So + * the far city is a smooth glow whose brightness is the density of its lit + * windows, downtown reads brighter than the avenues because it genuinely has + * more of them on, and flying in resolves individual windows out of it. + */ +const FRAGMENT_BODY = /* glsl */ ` +if (uNight > 0.002) { + vec3 faceNormal = normalize(vFacadeNormal); + // Roofs have no windows in them, and this is a box. + float wall = 1.0 - smoothstep(0.55, 0.95, abs(faceNormal.y)); + if (wall > 0.0) { + float across = abs(faceNormal.x) > 0.5 + ? vFacadeLocal.z * vFacadeSize.z + : vFacadeLocal.x * vFacadeSize.x; + float up = vFacadeLocal.y * vFacadeSize.y; + vec2 grid = vec2(across / uWindowPitch, up / uStorey); + + vec2 cell = fract(grid); + vec2 pane = step(vec2(0.22, 0.34), cell) * step(cell, vec2(0.78, 0.72)); + float coverage = pane.x * pane.y; + + // Which windows are on: a hash of the cell and the building's own seed, so + // it is a pure function of where you are looking and never has to be + // stored, animated or reconciled. + float roll = facadeHash(vec3(floor(grid), vFacade.y * 137.0)); + + // How lit this particular building is, on top of what its district says. + // Without it every tower downtown has the same window density, and from a + // distance the whole financial district smears into one flat brown + // rectangle — which is the one thing a night skyline never looks like. The + // curve is squared so most buildings are dim and a few blaze, and scaled so + // that the mean of it is exactly 1 and the district's own figure still + // means what it says. + float variation = 0.15 + 2.55 * vFacade.y * vFacade.y; + float chance = clamp(mix(uHouseLit, uOfficeLit, vFacade.x) * variation, 0.0, 0.9); + float on = step(1.0 - chance, roll); + + float footprint = max(fwidth(grid.x), fwidth(grid.y)); + float detail = 1.0 - smoothstep(0.5, 1.4, footprint); + // 0.56 x 0.38 is the pane inside its cell, so 0.2128 x chance is the grid's + // own mean — and 1.8 times that is what is actually used, which is a lie + // worth being explicit about. The mean is the right answer for a display + // whose response is linear, and no display's is: a pixel that in reality + // contains one small blazing window and three dark ones does not read to + // the eye as the average of the four, it reads as lit. With no HDR buffer + // and no bloom to arrive at that honestly, the multiplier is the cheap way + // to keep the far city as bright as the near city says it ought to be. + float glow = mix(1.8 * 0.2128 * chance, coverage * on, detail); + + // Roughly seven windows in ten warm. A skyline is mostly people's lamps and + // only partly the floors the cleaners are still on. + // + // The colour needs the same averaging the mask got, and forgetting it is a + // subtle and very visible bug: a mask correctly resolved to its mean, tinted + // by a hard per-cell choice between two colours at a frequency far below one + // pixel, gives a distant city that is the right brightness and crawling with + // orange and white confetti. + vec3 tint = mix(mix(uWarm, uCool, 0.3), mix(uWarm, uCool, step(0.7, fract(roll * 7.13))), detail); + totalEmissiveRadiance += tint * (glow * wall * uNight * uGain); + } +} +`; + +// ---- Street lamps --------------------------------------------------------- + +interface Lamps { + points: THREE.Points; + material: THREE.PointsMaterial; +} + +/** + * Lamps along the road network, as one additive point cloud. + * + * Cheap enough to be worth it: a few thousand points in a single draw call, + * with no lighting, no shadows and no per-frame work beyond an opacity. What + * they buy is the thing the buildings cannot — the *ground* has light on it, so + * the street grid is still legible at night and the city keeps the shape that + * makes it recognisable from above. In San Francisco that shape is the 46° + * between the grid north of Market and the grid south of it, and losing it + * after dark would lose the city. + * + * They emit nothing, of course. A real street lamp pooling light on the road + * under it is a second set of lights and a second shadow problem, and the + * pooling would be invisible at any framing where the lamp itself is a pixel. + */ +function buildLamps(world: World, options: NightLightsOptions): Lamps | null { + const spacing = (options.lampSpacingM ?? DEFAULT_LAMP_SPACING_M) / world.metresPerUnit; + const lift = world.metres(LAMP_HEIGHT_M); + const limit = options.maxLamps ?? DEFAULT_MAX_LAMPS; + const rand = seededRandom(LAMP_SEED); + + const positions: number[] = []; + let index = 0; + + for (const road of world.city.roads) { + // Carried across segment joins, so the spacing is even along the whole + // street rather than restarting at every corner — which would cluster + // lamps wherever a road was written with a lot of vertices in it, and + // those are exactly the bends. + let carry = 0; + + for (let i = 0; i < road.path.length - 1; i++) { + const from = road.path[i]; + const to = road.path[i + 1]; + if (!from || !to) continue; + + const [lat0, lng0] = from; + const [lat1, lng1] = to; + const [x0, z0] = world.project(lat0, lng0); + const [x1, z1] = world.project(lat1, lng1); + const dx = x1 - x0; + const dz = z1 - z0; + const length = Math.hypot(dx, dz); + if (length <= 0) continue; + + // Unit normal to the street, for the kerb offset. + const nx = -dz / length; + const nz = dx / length; + + let s = carry; + for (; s < length; s += spacing) { + if (index >= limit) break; + const t = s / length; + const lat = lat0 + (lat1 - lat0) * t; + const lng = lng0 + (lng1 - lng0) * t; + // Alternating kerbs, jittered, because a street lit by a perfect ruler + // of identical dots reads as a dashed line and not as lighting. + const side = index % 2 === 0 ? 1 : -1; + const offset = road.width * 0.55 * side * (0.8 + rand() * 0.4); + positions.push( + x0 + dx * t + nx * offset, + world.groundAt(lat, lng) + lift, + z0 + dz * t + nz * offset, + ); + index++; + } + carry = Math.max(0, s - length); + } + } + + if (positions.length === 0) return null; + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + + const material = new THREE.PointsMaterial({ + color: LAMP_COLOR, + map: lampTexture(), + size: LAMP_SIZE, + sizeAttenuation: true, + transparent: true, + opacity: 0, + // Additive, so a hundred lamps down one street saturate into the continuous + // line of light that a street at night actually is, rather than staying a + // hundred separate dots however far away they are. + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + + const points = new THREE.Points(geometry, material); + points.name = "streetlamps"; + points.visible = false; + return { points, material }; +} + +/** + * The lamp's glow, drawn on a canvas rather than shipped as a file. No binary + * assets is a licensing rule and not a stylistic one; see ARCHITECTURE.md. + */ +function lampTexture(): THREE.Texture { + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D canvas context unavailable"); + + const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + gradient.addColorStop(0, "rgba(255,255,255,1)"); + gradient.addColorStop(0.22, "rgba(255,232,190,0.7)"); + gradient.addColorStop(0.55, "rgba(255,190,110,0.18)"); + gradient.addColorStop(1, "rgba(255,170,80,0)"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 64, 64); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; +} + +// ---- Helpers -------------------------------------------------------------- + +/** Hermite ease over a span, flat at both ends. `atmosphere.ts` has the twin. */ +function smoothstep(edge0: number, edge1: number, x: number): number { + if (edge1 === edge0) return x < edge0 ? 0 : 1; + const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +// ---- Sanity checks -------------------------------------------------------- + +/** + * What this produces for San Francisco, so the numbers above can be argued with. + * + * At `metresPerUnit` 94.34 and a vertical exaggeration of 3.6, a window bay is + * 0.0424 scene units across and a storey is 0.1374 units tall — so a 260 m + * tower gets 72 floors and a 40 m lot's frontage gets ten bays, both of which + * are about right. The camera orbits between 12 and 340 units, and at 100 units + * out with a 42° field of view a bay covers roughly half a pixel, which is why + * `FRAGMENT_BODY` spends four lines on `fwidth` and would be unusable without + * them. + * + * The switch-on sequence, by solar elevation: + * + * - **+5° and above**: 0. The lamps are not drawn at all. + * - **+2°**: 0.076. The first offices, barely findable against the sky. + * - **0°, sunset**: 0.178. + * - **-2°**: 0.381. + * - **-5°, most of the way through civil twilight**: 0.814. + * - **-8° and below**: 1.0. The lights stopped changing some minutes ago; + * what changed after that was the sky behind them. + * + * Downtown's mean emission at distance is 1.8 x 0.2128 x 0.24 x 0.8 = 0.074, + * against the avenues' 0.025 — a ratio of just under 3:1, which is the whole + * picture, since the thing that makes a night skyline is not that the towers + * are taller but that they are the part of the city with all its lights still + * on. Around each of those figures the per-building variation spans 0.15x to + * 2.7x, so a run of towers has dark ones in it and the odd one blazing, and the + * financial district does not smear into a single rectangle when you pull back. + * + * On a moonless night the buildings come out at about #4a403d against water at + * #191b21 and a sky at #2d3855: the city is the brightest thing in the frame, + * as it should be, and the sky is still visibly a sky. + * + * SF's twenty-nine roads at 55 m spacing come to 12,038 lamps in one draw call, + * comfortably under the 24,000 ceiling. The ceiling exists for the city pack + * that arrives with a full street network in it rather than twenty-nine + * arterials, where the same spacing would produce a point cloud in the millions. + */ diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 642a5d4..0821d17 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -19,6 +19,7 @@ import * as THREE from "three"; import { createBlocks, createLandmarks } from "./blocks.ts"; +import { createNightLights, type NightLights } from "./nightlights.ts"; import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; import { createSceneKit, type Pose } from "./scenekit.ts"; @@ -63,6 +64,12 @@ export interface SceneHandle { stageScene: StageScene; /** Applies a rig computed elsewhere. The scene never works one out itself. */ setLighting(state: LightingState): void; + /** + * Solar elevation in degrees, for the layers that need the sun's position + * rather than the rig it implies. `LightingState` deliberately carries no + * elevation, so the number has to arrive separately. + */ + setSolarElevation(degrees: number): void; flyTo(chapterId: string): void; current(): string; onChapterChange(fn: (id: string) => void): void; @@ -78,16 +85,33 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S const stage = createStage(canvas); const scene = new THREE.Scene(); + /** + * Every camera limit is derived from how big this city's board actually is. + * + * These were constants tuned for San Francisco — `maxDistance: 340`, + * `far: 900`, a 170-unit shadow box. That silently made board size a fixed + * property of the engine rather than of a city: expanding the pack from San + * Francisco to the whole Bay Area took the board from 230 units across to + * 1003, and the camera physically could not retreat far enough to frame it. + * You got a close-up of the peninsula with everything else off-screen, and + * nothing in the types said why. + * + * A city pack now chooses its own `latScale` freely and the camera follows. + */ + const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng); + const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng); + const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ)); + const kit = createSceneKit({ scene, dom: stage.renderer.domElement, fov: 42, near: 0.1, - far: 900, - minDistance: 12, - maxDistance: 340, - shadowExtent: 170, - shadowFar: 520, + far: boardSpan * 3, + minDistance: Math.max(4, boardSpan * 0.02), + maxDistance: boardSpan * 1.5, + shadowExtent: boardSpan * 0.75, + shadowFar: boardSpan * 2.2, }); kit.applyLighting(options.lighting ?? cityDaylight(pal)); @@ -95,10 +119,18 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S scene.add(createShorePlates(world)); scene.add(createTerrain(world)); scene.add(createRoads(world)); - scene.add(createBlocks(world)); + const blocks = createBlocks(world); + scene.add(blocks); scene.add(createLandmarks(world)); scene.add(createBridges(world)); + /** + * The city switching itself on after sunset. Built after `blocks` because it + * patches the material that `createBlocks` made — order is load-bearing. + */ + const nightLights: NightLights = createNightLights({ world, blocks }); + scene.add(nightLights.group); + const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {}); scene.add(markerLayer.group); @@ -176,6 +208,7 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S dispose() { options.flights?.dispose?.(); flightLayer?.dispose(); + nightLights.dispose(); markerLayer.dispose(); kit.dispose(); scene.traverse((obj) => { @@ -195,6 +228,7 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S stage, stageScene, setLighting: (state) => kit.applyLighting(state), + setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees), flyTo, current: () => currentChapter, onChapterChange(fn) { diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts index 0056730..5d63e14 100644 --- a/src/engine/terrain.ts +++ b/src/engine/terrain.ts @@ -110,8 +110,7 @@ export function createShorePlates(world: World): THREE.Mesh { */ export function createTerrain(world: World): THREE.Mesh { const pal = paletteFor(world); - const { latSteps, lngSteps, height, land } = world.lattice(); - const { bounds, cellLat, cellLng } = world.city; + const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice(); const positions: number[] = []; const colors: number[] = []; @@ -126,8 +125,8 @@ export function createTerrain(world: World): THREE.Mesh { const k = i * (lngSteps + 1) + j; const existing = vertexAt[k]; if (existing !== undefined && existing >= 0) return existing; - const lat = bounds.minLat + i * cellLat; - const lng = bounds.minLng + j * cellLng; + const lat = lats[i] as number; + const lng = lngs[j] as number; const e = height[k] ?? 0; const [x, z] = world.project(lat, lng); positions.push(x, world.metres(e) + 0.012, z); diff --git a/src/engine/world.ts b/src/engine/world.ts index 4c47b06..53b3c1f 100644 --- a/src/engine/world.ts +++ b/src/engine/world.ts @@ -20,6 +20,8 @@ export class World { private readonly bboxes = new WeakMap(); private field: Float32Array | null = null; private fieldLand: Uint8Array | null = null; + private lats: Float64Array | null = null; + private lngs: Float64Array | null = null; private latSteps = 0; private lngSteps = 0; @@ -114,14 +116,26 @@ export class World { } /** Shortest distance to a polygon's boundary, in degrees. */ - private distanceToEdge(lat: number, lng: number, poly: LatLng[]): number { - let best = Infinity; + private distanceToEdge(lat: number, lng: number, poly: LatLng[], cap = Infinity): number { + let best = cap; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const from = poly[j]; const to = poly[i]; if (!from || !to) continue; const [aLat, aLng] = from; const [bLat, bLng] = to; + // Cheap rejection against the segment's own extent. The Bay Area's + // coastlines run to hundreds of vertices and this is called for every + // land point in the lattice, so skipping a segment that cannot beat the + // current best is worth the four comparisons. + if ( + (lat - aLat > best && lat - bLat > best) || + (aLat - lat > best && bLat - lat > best) || + (lng - aLng > best && lng - bLng > best) || + (aLng - lng > best && bLng - lng > best) + ) { + continue; + } const dLat = bLat - aLat; const dLng = bLng - aLng; const lenSq = dLat * dLat + dLng * dLng; @@ -173,14 +187,19 @@ export class World { /** 0 at the waterline, 1 once `coastFalloff` degrees inland. */ private coastalFalloff(lat: number, lng: number): number { + const limit = this.city.coastFalloff; let d = Infinity; for (const poly of this.city.landmasses) { - if (this.pointInPolygon(lat, lng, poly)) { - d = Math.min(d, this.distanceToEdge(lat, lng, poly)); - } + if (!this.pointInPolygon(lat, lng, poly)) continue; + // Seed the search with the cap: the result saturates at `coastFalloff`, + // so any edge farther than that cannot change the answer, and seeding + // `best` lets the per-segment rejection above discard almost everything + // for a point well inland. + const dist = this.distanceToEdge(lat, lng, poly, limit); + if (dist < d) d = dist; } if (!Number.isFinite(d)) return 0; - const t = Math.min(1, d / this.city.coastFalloff); + const t = Math.min(1, d / limit); return t * t * (3 - 2 * t); } @@ -193,17 +212,39 @@ export class World { * camera target wants it again. Computed once, read back bilinearly. */ private buildField(): { height: Float32Array; land: Uint8Array } { - if (this.field && this.fieldLand) return { height: this.field, land: this.fieldLand }; + if (this.field && this.fieldLand && this.lats && this.lngs) { + return { height: this.field, land: this.fieldLand }; + } const { bounds, cellLat, cellLng } = this.city; - this.latSteps = Math.ceil((bounds.maxLat - bounds.minLat) / cellLat); - this.lngSteps = Math.ceil((bounds.maxLng - bounds.minLng) / cellLng); + const coarse = Math.max(1, this.city.coarseFactor ?? 1); + const regions = this.city.focusRegions ?? []; + + // Rectilinear but NOT uniform: fine spacing across any band that a focus + // region occupies, coarse everywhere else. See `buildAxis`. + this.lats = buildAxis( + bounds.minLat, + bounds.maxLat, + cellLat, + cellLat * coarse, + regions.map((r) => [r.minLat, r.maxLat] as [number, number]), + ); + this.lngs = buildAxis( + bounds.minLng, + bounds.maxLng, + cellLng, + cellLng * coarse, + regions.map((r) => [r.minLng, r.maxLng] as [number, number]), + ); + + this.latSteps = this.lats.length - 1; + this.lngSteps = this.lngs.length - 1; const w = this.lngSteps + 1; const height = new Float32Array((this.latSteps + 1) * w); const land = new Uint8Array((this.latSteps + 1) * w); for (let i = 0; i <= this.latSteps; i++) { - const lat = bounds.minLat + i * cellLat; + const lat = this.lats[i] as number; for (let j = 0; j <= this.lngSteps; j++) { - const lng = bounds.minLng + j * cellLng; + const lng = this.lngs[j] as number; const k = i * w + j; const onLand = this.isLand(lat, lng); land[k] = onLand ? 1 : 0; @@ -215,24 +256,50 @@ export class World { return { height, land }; } - /** Lattice dimensions, for the terrain mesh builder. */ - lattice(): { latSteps: number; lngSteps: number; height: Float32Array; land: Uint8Array } { + /** + * The lattice, for the terrain mesh builder. + * + * `lats`/`lngs` are the coordinate of every row and column, because the + * spacing is no longer uniform and a consumer cannot recover it from + * `minLat + i * cellLat` any more. + */ + lattice(): { + latSteps: number; + lngSteps: number; + lats: Float64Array; + lngs: Float64Array; + height: Float32Array; + land: Uint8Array; + } { const { height, land } = this.buildField(); - return { latSteps: this.latSteps, lngSteps: this.lngSteps, height, land }; + return { + latSteps: this.latSteps, + lngSteps: this.lngSteps, + lats: this.lats as Float64Array, + lngs: this.lngs as Float64Array, + height, + land, + }; } /** Elevation in metres, bilinearly sampled from the cached lattice. */ elevationSampled(lat: number, lng: number): number { const { height } = this.buildField(); - const { bounds, cellLat, cellLng } = this.city; + const lats = this.lats as Float64Array; + const lngs = this.lngs as Float64Array; const w = this.lngSteps + 1; - const fi = (lat - bounds.minLat) / cellLat; - const fj = (lng - bounds.minLng) / cellLng; - if (fi < 0 || fj < 0 || fi >= this.latSteps || fj >= this.lngSteps) return 0; - const i = Math.floor(fi); - const j = Math.floor(fj); - const ti = fi - i; - const tj = fj - j; + + const i = cellIndex(lats, lat); + const j = cellIndex(lngs, lng); + if (i < 0 || j < 0) return 0; + + const lat0 = lats[i] as number; + const lat1 = lats[i + 1] as number; + const lng0 = lngs[j] as number; + const lng1 = lngs[j + 1] as number; + const ti = lat1 > lat0 ? (lat - lat0) / (lat1 - lat0) : 0; + const tj = lng1 > lng0 ? (lng - lng0) / (lng1 - lng0) : 0; + const a = height[i * w + j] ?? 0; const b = height[i * w + j + 1] ?? 0; const c = height[(i + 1) * w + j] ?? 0; @@ -246,11 +313,79 @@ export class World { } } +// ---- Variable-resolution lattice ------------------------------------------ + +/** + * The coordinates of every row (or column) of the heightfield: fine spacing + * across the bands the focus regions occupy, coarse in between. + * + * This is what makes a region the size of Southern California renderable at + * all. San Francisco is 0.20 x 0.36 degrees and a uniform 45 m lattice over it + * is 337k points — fine. LA plus Orange County plus Riverside is about + * fourteen times that area, and the same uniform lattice is 2.9M points; the + * Bay Area extended to San Jose is 3.7M and roughly eleven seconds of build. + * Neither is a thing anyone waits for. + * + * The refinement is per-axis rather than per-rectangle, so a focus region + * refines its whole row *and* its whole column — a plus, not a box. That over- + * samples the corners where two regions' bands cross, and it is deliberate: it + * keeps the lattice rectilinear, which keeps the mesh builder a double loop and + * bilinear sampling a pair of binary searches. A true quadtree would sample + * less and cost far more everywhere else. + */ +function buildAxis( + min: number, + max: number, + fine: number, + coarse: number, + bands: [number, number][], +): Float64Array { + const out: number[] = [min]; + let x = min; + // A guard band of one coarse cell either side, so the transition from coarse + // to fine happens outside the region rather than exactly on its edge, where + // it would show as a crease in the terrain. + const inFine = (v: number) => bands.some(([a, b]) => v >= a - coarse && v <= b + coarse); + while (x < max) { + x += inFine(x) ? fine : coarse; + out.push(Math.min(x, max)); + } + // A degenerate final cell (the clamp above landing on `max` twice) would make + // a zero-width row that the interpolator would divide by. + if (out.length > 1 && out[out.length - 1] === out[out.length - 2]) out.pop(); + return Float64Array.from(out); +} + +/** Index of the cell containing `v`, or -1 if outside. Binary search. */ +function cellIndex(axis: Float64Array, v: number): number { + const last = axis.length - 1; + if (v < (axis[0] as number) || v >= (axis[last] as number)) return -1; + let lo = 0; + let hi = last; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if ((axis[mid] as number) <= v) lo = mid; + else hi = mid; + } + return lo; +} + // ---- Deterministic noise and randomness ----------------------------------- +/** + * Integer hash, not the `Math.sin(...) * 43758` trick this used to be. + * + * `valueNoise` calls this four times and `fbm` runs four octaves, so every + * elevation sample was sixteen `Math.sin` calls. Over the Bay Area's lattice + * that is twenty-six million of them, and it was most of an eleven-second + * terrain build. The inputs are already integers here — `valueNoise` floors + * them — so an imul-based mix is both faster and better distributed. + */ function hash2(x: number, y: number): number { - const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; - return s - Math.floor(s); + let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1); + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h ^= h >>> 13; + return (h >>> 0) / 4294967296; } function valueNoise(x: number, y: number): number { diff --git a/src/main.ts b/src/main.ts index f641f43..52fcb4e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,73 +1,55 @@ /** - * The standalone demo: San Francisco under a real sun, and one office you can - * step into. + * The demo: two cities under a real sun and moon, and one office you can step + * into. * - * Deliberately ships **no company data**. Markers are demonstrated using the - * city's own landmarks — buildings, not businesses — because company positions - * are geocoded and company pipeline status is private, and neither belongs in - * this repo. Real markers arrive at runtime from an adapter; see `src/adapters/` - * and ARCHITECTURE.md §3. + * It ships **no real company data**. The markers are fabricated — see + * `src/adapters/sample.ts`, which says so loudly — because real positions are + * geocoded and real pipeline status is private, and neither belongs in this + * repo. When a Tera API is present the same markers arrive from it instead, and + * the UI says which of the two it is showing. * - * It also makes no network calls. The sun is computed locally, the traffic is - * simulated and the office is a data file, so a clone of this repo runs. + * It also runs with no server at all: the sun and moon are computed locally, + * the traffic is simulated, the office is a data file. Clone it and it works. */ import { createAtmosphere, observe, PACIFIC_MARINE_LAYER } from "./engine/atmosphere.ts"; +import { createScene, type SceneHandle } from "./engine/scene.ts"; +import { SimulatedFlights } from "./engine/flights.ts"; import { daylightPhase } from "./engine/solar.ts"; -import { SimulatedFlights, type SimRoute } from "./engine/flights.ts"; -import { createScene } from "./engine/scene.ts"; -import type { Marker, MarkerPalette, View } from "./engine/types.ts"; +import type { City, Marker, MarkerPalette, View } from "./engine/types.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; +import SOCAL from "./cities/socal.ts"; +import { createTeraClient } from "./adapters/http.ts"; +import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./adapters/sample.ts"; import { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts"; import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts"; -/** - * Bay Area traffic, roughly where it actually is: SFO sits south of frame and - * its arrivals run down the peninsula, Oakland is east across the bay, and the - * coastal departures turn out over the Pacific. - */ -const ROUTES: SimRoute[] = [ - { callsign: "UAL 1", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 }, - { callsign: "ASA 22", from: [37.93, -122.31], to: [37.65, -122.38], fromAlt: 2100, toAlt: 450, duration: 210 }, - { callsign: "SWA 118", from: [37.64, -122.39], to: [37.9, -122.62], fromAlt: 700, toAlt: 5200, duration: 165 }, - { callsign: "DAL 407", from: [37.7, -122.21], to: [37.88, -122.55], fromAlt: 1800, toAlt: 6100, duration: 230 }, - { callsign: "UAL 88", from: [37.62, -122.6], to: [37.95, -122.28], fromAlt: 6800, toAlt: 8200, duration: 260 }, - { callsign: "N512SP", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 }, - { callsign: "JBU 915", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 }, +const CITIES: { id: string; label: string; city: City }[] = [ + { id: "sf", label: "Bay Area", city: SAN_FRANCISCO }, + { id: "socal", label: "SoCal", city: SOCAL }, ]; -const MARKER_PALETTE: MarkerPalette = { landmark: 0xf2b134, neutral: 0x9aa4ad }; - const canvas = document.querySelector("#scene"); if (!canvas) throw new Error("#scene canvas missing"); -const city = createScene(canvas, { - city: SAN_FRANCISCO, - markerPalette: MARKER_PALETTE, - flights: new SimulatedFlights(ROUTES), - onMarkerPick: (marker) => showDetail(marker?.label ?? null), -}); +const tera = createTeraClient(); -// ---- The sun -------------------------------------------------------------- +let city: SceneHandle | null = null; +let cityId = "sf"; +let office: OfficeScene | null = null; +let inside = false; +let markers: Marker[] = SAMPLE_MARKERS; +let palette: MarkerPalette = SAMPLE_PALETTE; +let liveData = false; +let canEnterOffice = false; +let atmosphere: ReturnType | null = null; + +// ---- Time ----------------------------------------------------------------- /** - * Real solar position for San Francisco, right now, recomputed every minute. - * - * No network and no timezone database — `solar.ts` is arithmetic — so this - * keeps working on a laptop in a field. The marine layer is switched on because - * the fog is the single most recognisable atmospheric fact about this city. - */ -const atmosphere = createAtmosphere({ - lng: SAN_FRANCISCO.center.lng, - metresPerUnit: city.world.metresPerUnit, - marineLayer: PACIFIC_MARINE_LAYER, -}); - -/** - * `null` follows the wall clock. A number is an hour-of-day override from the - * scrubber, which exists because the honest answer at 2 a.m. is a black - * rectangle — correct, and impossible to look at. Being able to drag the sun is - * also the only practical way to eyeball whether the solar maths is right. + * `null` follows the wall clock. The scrubber exists because the honest answer + * at 2 a.m. is a very dark city — correct, and not what you want to be looking + * at while judging whether the sun is in the right place. */ let hourOverride: number | null = null; @@ -80,55 +62,80 @@ function currentInstant(): Date { } function updateSun() { - const env = observe(SAN_FRANCISCO.center.lat, SAN_FRANCISCO.center.lng, currentInstant()); + const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO; + if (!city || !atmosphere) return; + const env = observe(active.center.lat, active.center.lng, currentInstant()); city.setLighting(atmosphere.apply(env)); + city.setSolarElevation(env.sun.elevation); + const clock = document.querySelector("#clock"); if (!clock) return; const el = env.sun.elevation; const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - const phase = daylightPhase(el); - clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${phase}${hourOverride === null ? "" : " (held)"}`; + const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : ""; + clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`; } -const scrubber = document.querySelector("#hour"); -scrubber?.addEventListener("input", () => { - hourOverride = Number(scrubber.value); - updateSun(); -}); -document.querySelector("#now")?.addEventListener("click", () => { - hourOverride = null; - if (scrubber) scrubber.value = String(new Date().getHours()); - updateSun(); -}); - -updateSun(); -window.setInterval(() => hourOverride === null && updateSun(), 60_000); - -// ---- Demo markers --------------------------------------------------------- - -const demoMarkers: Marker[] = SAN_FRANCISCO.landmarks - .filter((l) => l.label) - .map((l) => ({ - id: l.name, - lat: l.lat, - lng: l.lng, - label: l.name, - colorKey: "landmark", - located: true, - })); -city.setMarkers(demoMarkers); - -// ---- The office ----------------------------------------------------------- +// ---- Cities --------------------------------------------------------------- /** - * Built on first entry and then kept, for the same reason the city is paused - * rather than disposed on the way in: rebuilding either scene costs far more - * than holding it. + * Switching city tears the old one down completely. + * + * Unlike the city↔office move — where the city is paused and kept, because you + * are coming straight back — nobody flips between metros often enough to + * justify holding two heightfields and 140k building instances at once. */ -let office: OfficeScene | null = null; -let inside = false; +function mountCity(id: string) { + const entry = CITIES.find((c) => c.id === id); + if (!entry || !canvas) return; + + office?.dispose(); + office = null; + inside = false; + city?.dispose(); + + cityId = id; + city = createScene(canvas, { + city: entry.city, + markerPalette: palette, + flights: liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES), + onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null), + }); + // Fog distances are scene units, so they have to follow the board — 210/460 + // was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit + // Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans + // out on the whole-board view: a fog starting nearer than that is behind the + // viewer's own shoulder, and at night, when the fog colour is nearly black + // rather than bright haze, it turns the entire map off. + const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng); + const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng); + const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz)); + + atmosphere = createAtmosphere({ + lng: entry.city.center.lng, + metresPerUnit: city.world.metresPerUnit, + clearFog: { near: span * 1.15, far: span * 2.8 }, + // The floor on how far you can see, and it has to know how big the board + // is. `minVisibilityM` defaults to 4.5 km, which is honest weather and + // completely wrong here: this board is ninety-four kilometres across, so + // real visibility correctly hides three quarters of it and the night view + // renders as a black rectangle. A map is looked at from outside the + // atmosphere it is depicting. + minVisibilityM: span * city.world.metresPerUnit * 1.6, + // The marine layer is a fact about the eastern Pacific at this latitude, + // not a decoration. LA gets its own weather, not San Francisco's fog. + marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null, + }); + city.setMarkers(id === "sf" ? markers : []); + city.onChapterChange(() => renderLegend()); + updateSun(); + renderLegend(); +} + +// ---- Office --------------------------------------------------------------- function enterOffice() { + if (!city || !canEnterOffice) return; if (!office) { office = createOfficeScene(LUMBRIDGE_HQ, { dom: city.stage.renderer.domElement, @@ -143,6 +150,7 @@ function enterOffice() { } function leaveOffice() { + if (!city) return; city.stage.setScene(city.stageScene); inside = false; showDetail(null); @@ -156,6 +164,8 @@ const blurb = document.querySelector("#blurb"); const title = document.querySelector("#title"); const subtitle = document.querySelector("#subtitle"); const enterButton = document.querySelector("#enter"); +const cityNav = document.querySelector("#cities"); +const source = document.querySelector("#source"); function showDetail(text: string | null) { const card = document.querySelector("#detail"); @@ -164,12 +174,25 @@ function showDetail(text: string | null) { card.textContent = text ?? ""; } -/** - * One legend for both places. A city chapter and an office viewpoint are both - * `View`s, which is the whole reason that type was extracted. - */ +function renderCityPicker() { + if (!cityNav) return; + cityNav.replaceChildren(); + for (const c of CITIES) { + const b = document.createElement("button"); + b.className = c.id === cityId && !inside ? "city active" : "city"; + b.textContent = c.label; + b.addEventListener("click", () => { + if (inside) leaveOffice(); + if (c.id !== cityId) mountCity(c.id); + }); + cityNav.append(b); + } +} + +/** One legend for both places — a city chapter and an office viewpoint are both `View`s. */ function renderLegend() { - if (!nav) return; + renderCityPicker(); + if (!nav || !city) return; const views: View[] = inside && office ? office.views : city.chapters; const activeId = inside && office ? office.current() : city.current(); @@ -181,7 +204,7 @@ function renderLegend() { button.innerHTML = `${number}${view.shortLabel}`; button.addEventListener("click", () => { if (inside && office) office.flyTo(view.id); - else city.flyTo(view.id); + else city?.flyTo(view.id); }); nav.append(button); }); @@ -191,11 +214,71 @@ function renderLegend() { blurb.textContent = active?.description ?? ""; blurb.hidden = !active?.description; } - if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : SAN_FRANCISCO.name; - if (subtitle) subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate"; - if (enterButton) enterButton.textContent = inside ? "← Back to the city" : "Enter the office →"; + const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? ""; + if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : cityLabel; + if (subtitle) { + subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate"; + } + if (enterButton) { + if (inside) enterButton.textContent = "← Back to the city"; + else if (canEnterOffice) enterButton.textContent = "Enter the office →"; + else enterButton.textContent = "Sign in to enter the office →"; + } + if (source) { + source.textContent = liveData ? "live data" : "sample data · fabricated, not real companies"; + source.className = liveData ? "source live" : "source"; + } } -enterButton?.addEventListener("click", () => (inside ? leaveOffice() : enterOffice())); -city.onChapterChange(() => renderLegend()); -renderLegend(); +enterButton?.addEventListener("click", () => { + if (inside) leaveOffice(); + else if (canEnterOffice) enterOffice(); + else window.location.href = "/login.html"; +}); + +const scrubber = document.querySelector("#hour"); +scrubber?.addEventListener("input", () => { + hourOverride = Number(scrubber.value); + updateSun(); +}); +document.querySelector("#now")?.addEventListener("click", () => { + hourOverride = null; + if (scrubber) scrubber.value = String(new Date().getHours()); + updateSun(); +}); + +// ---- Boot ----------------------------------------------------------------- + +/** + * Markers are awaited before the scene is built, because `markerPalette` is + * fixed at construction and the sample palette's keys are not the API's. + * Everything else about the API is optional: no server means sample data and a + * label saying so. + */ +async function boot() { + try { + const feed = await tera.markers(); + markers = feed.value; + palette = feed.palette; + liveData = feed.live; + } catch { + // A missing API is the self-host default, not an error. + } + try { + const res = await fetch("/api/v1/session", { credentials: "same-origin" }); + if (res.ok) { + const s = (await res.json()) as { authenticated: boolean; passwordLogin: boolean }; + canEnterOffice = s.authenticated || !s.passwordLogin; + } else { + canEnterOffice = true; + } + } catch { + // No server means nothing to sign in to, so the office is open. That is the + // self-host posture: auth is something a deployment adds, not removes. + canEnterOffice = true; + } + mountCity("sf"); + window.setInterval(() => hourOverride === null && updateSun(), 60_000); +} + +void boot();