SoCal, the whole bay, a moon, and gates that actually run
gates / clean-clone (push) Successful in 15s
gates / zero-config-boot (push) Successful in 9s
gates / no-binary-art (push) Successful in 4s

Six agents in parallel, and the two city packs independently reported the same
blocker: `focusRegions` and `coarseFactor` existed on the `City` type and
nothing implemented them. Uniform lattices would have been 2.9M points for
Southern California and 3.7M for the expanded bay. Both packs were unloadable
as written.

`buildAxis` is the answer, and it is honest about its limits: refinement is per
axis, not per rectangle, so a focus region sharpens its whole row *and* its
whole column. Two regions at opposite corners refine nearly everything between
them. Measured, not guessed — the bay went 0.53M points with one region and
1.64M with three, for detail nobody is looking at from a board this wide. One
region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s.

Then three things that were only ever right because San Francisco was the only
city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a
230-unit board; the bay is 1003 units across and the camera physically could
not retreat far enough to frame it. Fog distances were scene units pinned to
the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest
weather, which over ninety-four kilometres of bay correctly hides three
quarters of it — the night view was a black rectangle for a completely
reasonable reason. All three now derive from the board.

The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a
physical ratio of one to four hundred thousand. What is being reproduced is
what a moonlit night looks like on a screen in a lit room.

The CI gate caught itself, which is the part worth keeping. Port 8431 was
already held by a server from an earlier session, so the boot check polled a
healthy stranger while the process it started died on EADDRINUSE. It now
refuses to run rather than pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-05 03:13:32 -07:00
parent 8bcb391455
commit 44c5a79424
25 changed files with 9246 additions and 220 deletions
+119
View File
@@ -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
+19
View File
@@ -6,3 +6,22 @@ dist/
# runtime and cached in the browser. An Apache-2.0 repo containing them would # 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. # be relicensing marks it does not own. See ARCHITECTURE.md §3.1.
public/logos/ 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/
+12
View File
@@ -37,6 +37,16 @@
.enter:hover { background: #ffc555; } .enter:hover { background: #ffc555; }
.scrub { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.45rem; } .scrub { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.45rem; }
.scrub input { flex: 1; accent-color: #f2b134; height: 14px; } .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; .scrub button { font: inherit; font-size: 9px; text-transform: uppercase;
letter-spacing: 0.08em; padding: 0.15rem 0.35rem; cursor: pointer; border: 0; 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); } border-radius: 3px; background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.7); }
@@ -54,11 +64,13 @@
<button id="now" title="follow the wall clock">now</button> <button id="now" title="follow the wall clock">now</button>
</div> </div>
</div> </div>
<nav id="cities" class="cities"></nav>
<button id="enter" class="enter">Enter the office →</button> <button id="enter" class="enter">Enter the office →</button>
<nav id="chapters"></nav> <nav id="chapters"></nav>
<p class="card" id="blurb"></p> <p class="card" id="blurb"></p>
</div> </div>
<div class="card" id="detail" hidden></div> <div class="card" id="detail" hidden></div>
<p id="source" class="source"></p>
<p id="hint">drag to orbit · scroll to zoom</p> <p id="hint">drag to orbit · scroll to zoom</p>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
+148
View File
@@ -0,0 +1,148 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Sign in — Lumbridge Simulate</title>
<style>
/* The same stack index.html uses. No webfont, no CDN, nothing to fetch:
this page has to work on a box with no network but its own. */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0d1219;
font-family: ui-monospace, "SF Mono", Menlo, monospace; color: rgba(255,255,255,0.72); }
body { display: flex; align-items: center; justify-content: center; padding: 1rem; }
main { width: 20rem; }
h1 { margin: 0; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
color: #f2b134; }
h1 + p { margin: 0.15rem 0 1.1rem; font-size: 11px; line-height: 1.5;
color: rgba(255,255,255,0.42); }
form { display: flex; flex-direction: column; gap: 0.5rem;
background: rgba(255,255,255,0.04); border-radius: 6px; padding: 0.9rem; }
label { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
color: rgba(255,255,255,0.42); }
input { font: inherit; font-size: 13px; padding: 0.5rem 0.6rem; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(8,12,16,0.6);
color: rgba(255,255,255,0.9); }
input:focus { outline: none; border-color: #f2b134; }
button { font: inherit; font-size: 12px; font-weight: 600; margin-top: 0.35rem;
padding: 0.55rem 0.7rem; cursor: pointer; border: 0; border-radius: 6px;
color: #10161d; background: #f2b134; }
button:hover:enabled { background: #ffc555; }
button:disabled { opacity: 0.55; cursor: default; }
#note { min-height: 1.4rem; margin: 0.6rem 0 0; font-size: 11px; line-height: 1.4;
color: rgba(255,255,255,0.5); }
#note.bad { color: #ffb4a2; }
footer { margin-top: 0.9rem; font-size: 10px; color: rgba(255,255,255,0.25); }
</style>
</head>
<body>
<main>
<h1>Lumbridge Simulate</h1>
<p>Tera · sign in to reach a private office.</p>
<form id="form" autocomplete="on">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required
autocapitalize="none" autocorrect="off" spellcheck="false" />
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password"
required />
<button id="submit" type="submit">Sign in</button>
</form>
<p id="note" role="status" aria-live="polite"></p>
<footer>The session is a cookie this server signs. Nothing leaves the box.</footer>
</main>
<script type="module">
const form = document.getElementById("form");
const submit = document.getElementById("submit");
const note = document.getElementById("note");
function say(text, bad) {
note.textContent = text;
note.classList.toggle("bad", bad === true);
}
/**
* Where to go once the cookie is set. `?next=` is honoured only when it is
* a path on this origin — a redirect target taken from a query string is
* how a sign-in page becomes somebody else's phishing hop. A leading `//`
* is a protocol-relative URL to another host, which is exactly the case a
* `startsWith("/")` check on its own would wave through.
*/
function destination() {
const next = new URLSearchParams(location.search).get("next");
if (typeof next !== "string") return "/";
if (!next.startsWith("/") || next.startsWith("//")) return "/";
return next;
}
// If the cookie is already good, there is nothing to ask for. This also
// tells us whether this deployment can sign anyone in at all.
try {
const res = await fetch("/api/v1/session", { credentials: "same-origin" });
const state = res.ok ? await res.json() : null;
if (state?.authenticated === true) {
location.replace(destination());
} else if (state !== null && state.passwordLogin !== true) {
form.hidden = true;
say("This deployment does not sign people in here.");
}
} catch {
// An unreachable API is not a reason to hide the form; the submit below
// will produce a better message than a guess made before anyone typed.
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
submit.disabled = true;
say("Checking…");
try {
const res = await fetch("/api/v1/session", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({
username: form.username.value,
password: form.password.value,
}),
});
if (res.ok) {
// The token is in an HttpOnly cookie and this page never sees it,
// which is the point: a script that can read the session is a
// script that can walk off with it.
say("Signed in. Taking you back…");
location.replace(destination());
return;
}
form.password.value = "";
if (res.status === 429) {
const wait = Number(res.headers.get("retry-after"));
say(
Number.isFinite(wait) && wait > 0
? `Too many attempts. Try again in ${wait} seconds.`
: "Too many attempts. Try again shortly.",
true,
);
} else {
// Deliberately the server's single message: it does not distinguish
// a wrong password from a username that does not exist, and neither
// does this page.
say("Those credentials were not accepted.", true);
}
} catch {
say("Could not reach the server.", true);
} finally {
submit.disabled = false;
}
});
</script>
</body>
</html>
+160
View File
@@ -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(" ")}`);
+346
View File
@@ -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());
+2
View File
@@ -19,6 +19,7 @@ import { registerFlights } from "./routes/flights.ts";
import { registerHealth } from "./routes/health.ts"; import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts"; import { registerMarkers } from "./routes/markers.ts";
import { registerOffices } from "./routes/offices.ts"; import { registerOffices } from "./routes/offices.ts";
import { registerSession } from "./routes/session.ts";
import { registerWeather } from "./routes/weather.ts"; import { registerWeather } from "./routes/weather.ts";
import { createServices } from "./services.ts"; import { createServices } from "./services.ts";
import type { ErrorBody } from "../../src/server/wire.ts"; import type { ErrorBody } from "../../src/server/wire.ts";
@@ -46,6 +47,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
registerWeather(app, services); registerWeather(app, services);
registerMarkers(app, services); registerMarkers(app, services);
registerOffices(app, services); registerOffices(app, services);
registerSession(app, services);
app.setNotFoundHandler(async (_req, reply) => { app.setNotFoundHandler(async (_req, reply) => {
const body: ErrorBody = { error: "not_found", message: "No such route." }; const body: ErrorBody = { error: "not_found", message: "No such route." };
+70 -2
View File
@@ -13,13 +13,20 @@
* - **`jwt`** verifies a token here, for a deployment that would rather not make * - **`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. * 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 * `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 * answers 404 — never 403 — so the endpoint cannot be used to enumerate what
* exists. * exists.
*/ */
import { createHash } from "node:crypto"; import { createHash, createHmac } from "node:crypto";
import type { FastifyRequest } from "fastify"; import type { FastifyRequest } from "fastify";
import type { AuthConfig } from "../config.ts"; import type { AuthConfig } from "../config.ts";
import { verifyJwt } from "./jwt.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<string, unknown> = { 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 { function bearerToken(req: FastifyRequest): string | null {
const header = req.headers.authorization; const header = req.headers.authorization;
if (typeof header !== "string") return null; if (typeof header !== "string") return null;
+206
View File
@@ -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<string> {
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<boolean> {
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<boolean> {
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<Buffer | null> {
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;
}
+114 -3
View File
@@ -18,6 +18,7 @@
*/ */
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
import type { import type {
AuthMode, AuthMode,
FlightsSourceId, FlightsSourceId,
@@ -58,6 +59,23 @@ export interface MarkersConfig {
ttlSeconds: number; 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 { export interface AuthConfig {
mode: AuthMode; mode: AuthMode;
/** Where a browser sends someone to sign in. `sso` mode only. */ /** Where a browser sends someone to sign in. `sso` mode only. */
@@ -73,6 +91,8 @@ export interface AuthConfig {
jwksUrl: string; jwksUrl: string;
issuer: string; issuer: string;
audience: string; audience: string;
/** Set only by `TERA_AUTH_MODE=password`; see `PasswordLogin` and `loadPasswordLogin`. */
passwordLogin: PasswordLogin | null;
} }
export interface Config { 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 { function loadAuth(env: Env, degraded: string[]): AuthConfig {
const asked = str(env, "TERA_AUTH_MODE", "none"); 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) { if (mode === null) {
degraded.push( degraded.push(
`TERA_AUTH_MODE="${asked}" is not one of ${AUTH_MODES.join(", ")}; ` + `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 revalidateUrl = str(env, "TERA_AUTH_REVALIDATE_URL", "");
const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", ""); const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", "");
const jwksUrl = str(env, "TERA_AUTH_JWKS_URL", ""); 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 // A demotion here has teeth: it takes private offices with it, which is the
// safe direction. Unverifiable credentials must never mean "let them in". // safe direction. Unverifiable credentials must never mean "let them in".
@@ -287,6 +314,33 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
mode = "none"; 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 { return {
mode, mode,
entryUrl, entryUrl,
@@ -297,6 +351,63 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
jwksUrl, jwksUrl,
issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""), issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""),
audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""), 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$<salt>$<key>`. 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),
}; };
} }
+215
View File
@@ -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<string, Bucket>();
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);
},
};
}
+307
View File
@@ -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<string, string>) {
const config = loadConfig({ TERA_OFFICES_DIR: dir, ...env });
config.logLevel = "silent";
return buildApp(config);
}
function login(app: ReturnType<typeof buildApp>, 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<SessionBody>(), {
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<SessionBody>().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<SessionBody>(), {
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<SessionBody>(), {
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<SessionBody>(), {
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`);
}
});
});
+105
View File
@@ -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.
+333
View File
@@ -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<T> {
value: T;
live: boolean;
}
export interface MarkerFeed extends Feed<Marker[]> {
/** 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<WeatherObservation> {
attribution: string[];
}
export interface TeraClient {
/** What the deployment turned out to be, or `null` if there is no server. */
health(): Promise<HealthBody | null>;
markers(): Promise<MarkerFeed>;
weather(): Promise<WeatherFeed>;
/**
* 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<OfficeDoc | null>;
}
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<T>(path: string): Promise<T | null> {
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<HealthBody>("/health"),
async markers(): Promise<MarkerFeed> {
const body = await get<MarkersBody>("/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<WeatherFeed> {
const body = await get<WeatherBody>("/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<OfficeDoc>(`/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: <T>(path: string) => Promise<T | null>,
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<FlightsBody>("/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)));
}
+308
View File
@@ -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 },
];
+1663 -31
View File
File diff suppressed because it is too large Load Diff
+3152
View File
File diff suppressed because it is too large Load Diff
+521 -19
View File
@@ -20,11 +20,12 @@
* and touches nothing. Call it every frame or once a minute; the cost is * and touches nothing. Call it every frame or once a minute; the cost is
* the same table lookup either way. * the same table lookup either way.
* *
* The solar half is computed locally by `solar.ts` with no network, and the * The solar half is computed locally by `solar.ts` with no network, the lunar
* weather half degrades to `null` — which this file reads as a clear day with * half by `moonPosition` below, and the weather half degrades to `null` — which
* the local climatology still running. The whole engine has to work with no * this file reads as a clear day with the local climatology still running. The
* account, no key and no network, and a sky that goes flat grey the moment the * whole engine has to work with no account, no key and no network, and a sky
* wifi drops would fail that in the most visible way possible. * 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: * Wiring one to a city, in full:
* *
@@ -100,18 +101,205 @@ export interface WeatherObservation {
export interface Environment { export interface Environment {
time: Date; time: Date;
sun: SolarPosition; 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. */ /** `null` when nobody was asked. A supported state, not an error. */
weather: WeatherObservation | null; 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( export function observe(
lat: number, lat: number,
lng: number, lng: number,
when: Date, when: Date,
weather: WeatherObservation | null = null, weather: WeatherObservation | null = null,
): Environment { ): 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 -------------------------------------------------------------- // ---- Options --------------------------------------------------------------
@@ -178,6 +366,45 @@ export const PACIFIC_MARINE_LAYER: MarineLayerOptions = {
visibilityM: 5000, 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 { export interface AtmosphereOptions {
/** /**
* Observer longitude, degrees east. Needed for apparent solar time, which is * Observer longitude, degrees east. Needed for apparent solar time, which is
@@ -216,6 +443,13 @@ export interface AtmosphereOptions {
shadowFloorDeg?: number; shadowFloorDeg?: number;
/** Coastal fog model, or nothing. Off unless a city asks for it. */ /** Coastal fog model, or nothing. Off unless a city asks for it. */
marineLayer?: MarineLayerOptions | null; 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 { export interface Atmosphere {
@@ -236,6 +470,26 @@ const DEFAULT_FOG_FAR = 460;
const DEFAULT_MIN_VISIBILITY_M = 4500; const DEFAULT_MIN_VISIBILITY_M = 4500;
const DEFAULT_SHADOW_FLOOR_DEG = 7; 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 * 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 * 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 floorFar = (options.minVisibilityM ?? DEFAULT_MIN_VISIBILITY_M) / metresPerUnit;
const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG; const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG;
const marineOptions = options.marineLayer ?? null; const marineOptions = options.marineLayer ?? null;
const moonOptions = options.moonlight === undefined ? DEFAULT_MOONLIGHT : options.moonlight;
function apply(env: Environment): LightingState { function apply(env: Environment): LightingState {
const elevation = env.sun.elevation; 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 // still black, and a modifier that does not know the difference will
// cheerfully raise the small hours to a uniform slate. // cheerfully raise the small hours to a uniform slate.
const day = smoothstep(-6, 6, elevation); const day = smoothstep(-6, 6, elevation);
const night = nightFactor(elevation);
const weather = env.weather; const weather = env.weather;
const cloud = clamp(weather?.cloudCover ?? 0, 0, 1); const cloud = clamp(weather?.cloudCover ?? 0, 0, 1);
const precipitation = clamp(weather?.precipitation ?? 0, 0, 1); const precipitation = clamp(weather?.precipitation ?? 0, 0, 1);
const condition = weather?.condition ?? "clear"; 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 // 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 // free, which is what gives an offline San Francisco its summer fog, but a
// station reporting sunshine ends the argument. See `observedObscuration`. // 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 observed = weather ? observedObscuration(weather) : null;
const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0; const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0;
const obscuration = const obscuration =
observed === null ? modelled : observed === 0 ? 0 : Math.max(observed, modelled); 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); let fogFar = visibilityFar(weather, condition, clearFar, metresPerUnit);
if (weather === null || weather.visibilityKm === null) { if (weather === null || weather.visibilityKm === null) {
// Rain shortens the view; a source that measured visibility has already // 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); const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9);
return { return {
sun: { sun: combineKey(lightDirection(env.sun, shadowFloor), rig.sunColor, rig.sunIntensity, moon),
direction: lightDirection(env.sun, shadowFloor),
color: rig.sunColor,
intensity: Math.max(0, rig.sunIntensity),
},
hemisphere: { hemisphere: {
sky: rig.hemiSky, sky: rig.hemiSky,
ground: rig.hemiGround, ground: rig.hemiGround,
@@ -563,6 +828,190 @@ function lightDirection(sun: SolarPosition, floorDeg: number): [number, number,
return [dir.x, dir.y, dir.z]; 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 -------------------------------------------------------------- // ---- 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)); 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. */ /** Multiply a colour's light, not its bytes. */
function scale(hex: number, factor: number): number { function scale(hex: number, factor: number): number {
const [r, g, b] = linear(hex); 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 * 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 * the month San Francisco is warm and cloudless and every visitor is
* surprised by it. * surprised by it.
* - **03:00 PDT** (-23.7°): sun 0.01, hemisphere 0.25, ambient 0.10, and the * - **03:00 PDT** (-23.7°, with the moon down): key 0.002, hemisphere 0.32,
* light direction's `y` pinned at 0.122, which is sin 7° — the shadow * ambient 0.12, and the light direction's `y` pinned at 0.122, which is
* floor, keeping the token night sidelight from shining up through the * sin 7° — the shadow floor, keeping what is left of the token night
* ground. * 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 * - **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. * 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 * 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 * and nothing non-finite anywhere, which is the polar-night path through
* `solar.ts` arriving here intact. * `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.
*/ */
+50
View File
@@ -31,6 +31,36 @@ const PALETTES = {
industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488], industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488],
} satisfies Record<District["palette"], number[]>; } satisfies Record<District["palette"], number[]>;
/**
* 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<District["palette"], number>;
/**
* 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 { interface Box {
x: number; x: number;
z: number; z: number;
@@ -40,6 +70,7 @@ interface Box {
h: number; h: number;
rot: number; rot: number;
color: THREE.Color; color: THREE.Color;
commercial: number;
} }
function polygonBounds(poly: [number, number][]) { function polygonBounds(poly: [number, number][]) {
@@ -65,6 +96,7 @@ export function createBlocks(world: World): THREE.InstancedMesh {
seedBase += 7919; seedBase += 7919;
const palette = PALETTES[district.palette]; const palette = PALETTES[district.palette];
const commercial = COMMERCIAL[district.palette];
const angle = district.gridAngle; const angle = district.gridAngle;
const coverage = district.coverage ?? 0.88; const coverage = district.coverage ?? 0.88;
@@ -124,6 +156,8 @@ export function createBlocks(world: World): THREE.InstancedMesh {
h: world.metres(heightM), h: world.metres(heightM),
rot: angle + (rand() - 0.5) * 0.03, rot: angle + (rand() - 0.5) * 0.03,
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6), 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); const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level 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); const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
mesh.name = "blocks"; mesh.name = "blocks";
mesh.castShadow = true; mesh.castShadow = true;
+435 -27
View File
@@ -15,6 +15,7 @@
*/ */
import * as THREE from "three"; import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { Aircraft, FlightSource } from "./types.ts"; import type { Aircraft, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts"; import { seededRandom, type World } from "./world.ts";
@@ -54,18 +55,31 @@ export class SimulatedFlights implements FlightSource {
this.t += Math.min(now - this.last, 5); this.t += Math.min(now - this.last, 5);
this.last = now; this.last = now;
return this.routes.map((route, i) => { return this.routes.map((route, i) => sampleRoute(route, this.t / route.duration + (this.phase[i] ?? 0)));
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;
/**
* 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. // Ease the altitude so departures climb steeply and level off.
const ease = 1 - (1 - p) ** 2; const ease = 1 - (1 - t) ** 2;
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease; const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
const heading = const heading =
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI; (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 { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
});
}
} }
function nowSeconds(): number { function nowSeconds(): number {
@@ -125,53 +139,447 @@ interface RawAircraft {
export interface FlightLayer { export interface FlightLayer {
group: THREE.Group; 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; 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; dispose(): void;
} }
/** /**
* Aircraft as small darts with a shadow-less trail. Rendered at true altitude * How many observations a trail remembers, and how long it may hold one.
* through the world's vertical exaggeration, so a jet on approach sits visibly *
* below one at cruise. * 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 { export function createFlightLayer(world: World): FlightLayer {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = "flights"; group.name = "flights";
const geo = new THREE.ConeGeometry(0.1, 0.42, 5); const geo = dartGeometry();
geo.rotateX(Math.PI / 2); // point along +z, so heading maps to a Y rotation const materials = new Map<number, THREE.MeshLambertMaterial>();
const material = new THREE.MeshLambertMaterial({ color: 0xf2f5f8 }); const tracks = new Map<string, Track>();
const meshes = new Map<string, THREE.Mesh>();
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[]) { function update(aircraft: Aircraft[]) {
const now = nowSeconds();
const seen = new Set<string>(); const seen = new Set<string>();
for (const a of aircraft) { for (const a of aircraft) {
seen.add(a.id); 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); const [x, z] = world.project(a.lat, a.lng);
mesh.position.set(x, world.metres(a.altitude), z); const position = new THREE.Vector3(x, world.metres(a.altitude), z);
mesh.rotation.y = -(a.heading * Math.PI) / 180; 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);
} }
for (const [id, mesh] of meshes) {
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, track] of tracks) {
if (seen.has(id)) continue; if (seen.has(id)) continue;
group.remove(mesh); group.remove(track.mesh);
meshes.delete(id); 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 { return {
group, group,
update, update,
tick,
dispose() { dispose() {
geo.dispose(); geo.dispose();
material.dispose(); for (const m of materials.values()) m.dispose();
meshes.clear(); materials.clear();
trailGeo.dispose();
trailMat.dispose();
tracks.clear();
group.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;
}
+572
View File
@@ -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<string, { value: unknown }>;
/**
* 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 <common>", `#include <common>\n${VERTEX_PARS}`)
.replace("#include <project_vertex>", `#include <project_vertex>\n${VERTEX_BODY}`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\n${FRAGMENT_PARS}`)
.replace(
"#include <emissivemap_fragment>",
`#include <emissivemap_fragment>\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.
*/
+40 -6
View File
@@ -19,6 +19,7 @@
import * as THREE from "three"; import * as THREE from "three";
import { createBlocks, createLandmarks } from "./blocks.ts"; import { createBlocks, createLandmarks } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { createSceneKit, type Pose } from "./scenekit.ts"; import { createSceneKit, type Pose } from "./scenekit.ts";
@@ -63,6 +64,12 @@ export interface SceneHandle {
stageScene: StageScene; stageScene: StageScene;
/** Applies a rig computed elsewhere. The scene never works one out itself. */ /** Applies a rig computed elsewhere. The scene never works one out itself. */
setLighting(state: LightingState): void; 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; flyTo(chapterId: string): void;
current(): string; current(): string;
onChapterChange(fn: (id: string) => void): void; onChapterChange(fn: (id: string) => void): void;
@@ -78,16 +85,33 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
const stage = createStage(canvas); const stage = createStage(canvas);
const scene = new THREE.Scene(); 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({ const kit = createSceneKit({
scene, scene,
dom: stage.renderer.domElement, dom: stage.renderer.domElement,
fov: 42, fov: 42,
near: 0.1, near: 0.1,
far: 900, far: boardSpan * 3,
minDistance: 12, minDistance: Math.max(4, boardSpan * 0.02),
maxDistance: 340, maxDistance: boardSpan * 1.5,
shadowExtent: 170, shadowExtent: boardSpan * 0.75,
shadowFar: 520, shadowFar: boardSpan * 2.2,
}); });
kit.applyLighting(options.lighting ?? cityDaylight(pal)); kit.applyLighting(options.lighting ?? cityDaylight(pal));
@@ -95,10 +119,18 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
scene.add(createShorePlates(world)); scene.add(createShorePlates(world));
scene.add(createTerrain(world)); scene.add(createTerrain(world));
scene.add(createRoads(world)); scene.add(createRoads(world));
scene.add(createBlocks(world)); const blocks = createBlocks(world);
scene.add(blocks);
scene.add(createLandmarks(world)); scene.add(createLandmarks(world));
scene.add(createBridges(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 ?? {}); const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
scene.add(markerLayer.group); scene.add(markerLayer.group);
@@ -176,6 +208,7 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
dispose() { dispose() {
options.flights?.dispose?.(); options.flights?.dispose?.();
flightLayer?.dispose(); flightLayer?.dispose();
nightLights.dispose();
markerLayer.dispose(); markerLayer.dispose();
kit.dispose(); kit.dispose();
scene.traverse((obj) => { scene.traverse((obj) => {
@@ -195,6 +228,7 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
stage, stage,
stageScene, stageScene,
setLighting: (state) => kit.applyLighting(state), setLighting: (state) => kit.applyLighting(state),
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
flyTo, flyTo,
current: () => currentChapter, current: () => currentChapter,
onChapterChange(fn) { onChapterChange(fn) {
+3 -4
View File
@@ -110,8 +110,7 @@ export function createShorePlates(world: World): THREE.Mesh {
*/ */
export function createTerrain(world: World): THREE.Mesh { export function createTerrain(world: World): THREE.Mesh {
const pal = paletteFor(world); const pal = paletteFor(world);
const { latSteps, lngSteps, height, land } = world.lattice(); const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice();
const { bounds, cellLat, cellLng } = world.city;
const positions: number[] = []; const positions: number[] = [];
const colors: number[] = []; const colors: number[] = [];
@@ -126,8 +125,8 @@ export function createTerrain(world: World): THREE.Mesh {
const k = i * (lngSteps + 1) + j; const k = i * (lngSteps + 1) + j;
const existing = vertexAt[k]; const existing = vertexAt[k];
if (existing !== undefined && existing >= 0) return existing; if (existing !== undefined && existing >= 0) return existing;
const lat = bounds.minLat + i * cellLat; const lat = lats[i] as number;
const lng = bounds.minLng + j * cellLng; const lng = lngs[j] as number;
const e = height[k] ?? 0; const e = height[k] ?? 0;
const [x, z] = world.project(lat, lng); const [x, z] = world.project(lat, lng);
positions.push(x, world.metres(e) + 0.012, z); positions.push(x, world.metres(e) + 0.012, z);
+159 -24
View File
@@ -20,6 +20,8 @@ export class World {
private readonly bboxes = new WeakMap<LatLng[], Float64Array>(); private readonly bboxes = new WeakMap<LatLng[], Float64Array>();
private field: Float32Array | null = null; private field: Float32Array | null = null;
private fieldLand: Uint8Array | null = null; private fieldLand: Uint8Array | null = null;
private lats: Float64Array | null = null;
private lngs: Float64Array | null = null;
private latSteps = 0; private latSteps = 0;
private lngSteps = 0; private lngSteps = 0;
@@ -114,14 +116,26 @@ export class World {
} }
/** Shortest distance to a polygon's boundary, in degrees. */ /** Shortest distance to a polygon's boundary, in degrees. */
private distanceToEdge(lat: number, lng: number, poly: LatLng[]): number { private distanceToEdge(lat: number, lng: number, poly: LatLng[], cap = Infinity): number {
let best = Infinity; let best = cap;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const from = poly[j]; const from = poly[j];
const to = poly[i]; const to = poly[i];
if (!from || !to) continue; if (!from || !to) continue;
const [aLat, aLng] = from; const [aLat, aLng] = from;
const [bLat, bLng] = to; 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 dLat = bLat - aLat;
const dLng = bLng - aLng; const dLng = bLng - aLng;
const lenSq = dLat * dLat + dLng * dLng; const lenSq = dLat * dLat + dLng * dLng;
@@ -173,14 +187,19 @@ export class World {
/** 0 at the waterline, 1 once `coastFalloff` degrees inland. */ /** 0 at the waterline, 1 once `coastFalloff` degrees inland. */
private coastalFalloff(lat: number, lng: number): number { private coastalFalloff(lat: number, lng: number): number {
const limit = this.city.coastFalloff;
let d = Infinity; let d = Infinity;
for (const poly of this.city.landmasses) { for (const poly of this.city.landmasses) {
if (this.pointInPolygon(lat, lng, poly)) { if (!this.pointInPolygon(lat, lng, poly)) continue;
d = Math.min(d, this.distanceToEdge(lat, lng, poly)); // 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; 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); return t * t * (3 - 2 * t);
} }
@@ -193,17 +212,39 @@ export class World {
* camera target wants it again. Computed once, read back bilinearly. * camera target wants it again. Computed once, read back bilinearly.
*/ */
private buildField(): { height: Float32Array; land: Uint8Array } { 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; const { bounds, cellLat, cellLng } = this.city;
this.latSteps = Math.ceil((bounds.maxLat - bounds.minLat) / cellLat); const coarse = Math.max(1, this.city.coarseFactor ?? 1);
this.lngSteps = Math.ceil((bounds.maxLng - bounds.minLng) / cellLng); 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 w = this.lngSteps + 1;
const height = new Float32Array((this.latSteps + 1) * w); const height = new Float32Array((this.latSteps + 1) * w);
const land = new Uint8Array((this.latSteps + 1) * w); const land = new Uint8Array((this.latSteps + 1) * w);
for (let i = 0; i <= this.latSteps; i++) { 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++) { 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 k = i * w + j;
const onLand = this.isLand(lat, lng); const onLand = this.isLand(lat, lng);
land[k] = onLand ? 1 : 0; land[k] = onLand ? 1 : 0;
@@ -215,24 +256,50 @@ export class World {
return { height, land }; 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(); 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. */ /** Elevation in metres, bilinearly sampled from the cached lattice. */
elevationSampled(lat: number, lng: number): number { elevationSampled(lat: number, lng: number): number {
const { height } = this.buildField(); 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 w = this.lngSteps + 1;
const fi = (lat - bounds.minLat) / cellLat;
const fj = (lng - bounds.minLng) / cellLng; const i = cellIndex(lats, lat);
if (fi < 0 || fj < 0 || fi >= this.latSteps || fj >= this.lngSteps) return 0; const j = cellIndex(lngs, lng);
const i = Math.floor(fi); if (i < 0 || j < 0) return 0;
const j = Math.floor(fj);
const ti = fi - i; const lat0 = lats[i] as number;
const tj = fj - j; 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 a = height[i * w + j] ?? 0;
const b = height[i * w + j + 1] ?? 0; const b = height[i * w + j + 1] ?? 0;
const c = height[(i + 1) * w + j] ?? 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 ----------------------------------- // ---- 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 { function hash2(x: number, y: number): number {
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1);
return s - Math.floor(s); h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h ^= h >>> 13;
return (h >>> 0) / 4294967296;
} }
function valueNoise(x: number, y: number): number { function valueNoise(x: number, y: number): number {
+183 -100
View File
@@ -1,73 +1,55 @@
/** /**
* The standalone demo: San Francisco under a real sun, and one office you can * The demo: two cities under a real sun and moon, and one office you can step
* step into. * into.
* *
* Deliberately ships **no company data**. Markers are demonstrated using the * It ships **no real company data**. The markers are fabricated see
* city's own landmarks buildings, not businesses because company positions * `src/adapters/sample.ts`, which says so loudly because real positions are
* are geocoded and company pipeline status is private, and neither belongs in * geocoded and real pipeline status is private, and neither belongs in this
* this repo. Real markers arrive at runtime from an adapter; see `src/adapters/` * repo. When a Tera API is present the same markers arrive from it instead, and
* and ARCHITECTURE.md §3. * the UI says which of the two it is showing.
* *
* It also makes no network calls. The sun is computed locally, the traffic is * It also runs with no server at all: the sun and moon are computed locally,
* simulated and the office is a data file, so a clone of this repo runs. * 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 { 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 { daylightPhase } from "./engine/solar.ts";
import { SimulatedFlights, type SimRoute } from "./engine/flights.ts"; import type { City, Marker, MarkerPalette, View } from "./engine/types.ts";
import { createScene } from "./engine/scene.ts";
import type { Marker, MarkerPalette, View } from "./engine/types.ts";
import SAN_FRANCISCO from "./cities/sf.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 { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts";
import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts"; import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts";
/** const CITIES: { id: string; label: string; city: City }[] = [
* Bay Area traffic, roughly where it actually is: SFO sits south of frame and { id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
* its arrivals run down the peninsula, Oakland is east across the bay, and the { id: "socal", label: "SoCal", city: SOCAL },
* 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 MARKER_PALETTE: MarkerPalette = { landmark: 0xf2b134, neutral: 0x9aa4ad };
const canvas = document.querySelector<HTMLCanvasElement>("#scene"); const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing"); if (!canvas) throw new Error("#scene canvas missing");
const city = createScene(canvas, { const tera = createTeraClient();
city: SAN_FRANCISCO,
markerPalette: MARKER_PALETTE,
flights: new SimulatedFlights(ROUTES),
onMarkerPick: (marker) => showDetail(marker?.label ?? null),
});
// ---- 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<typeof createAtmosphere> | null = null;
// ---- Time -----------------------------------------------------------------
/** /**
* Real solar position for San Francisco, right now, recomputed every minute. * `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
* No network and no timezone database `solar.ts` is arithmetic so this * at while judging whether the sun is in the right place.
* 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.
*/ */
let hourOverride: number | null = null; let hourOverride: number | null = null;
@@ -80,55 +62,80 @@ function currentInstant(): Date {
} }
function updateSun() { 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.setLighting(atmosphere.apply(env));
city.setSolarElevation(env.sun.elevation);
const clock = document.querySelector<HTMLElement>("#clock"); const clock = document.querySelector<HTMLElement>("#clock");
if (!clock) return; if (!clock) return;
const el = env.sun.elevation; const el = env.sun.elevation;
const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const phase = daylightPhase(el); const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : "";
clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${phase}${hourOverride === null ? "" : " (held)"}`; clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`;
} }
const scrubber = document.querySelector<HTMLInputElement>("#hour"); // ---- Cities ---------------------------------------------------------------
scrubber?.addEventListener("input", () => {
hourOverride = Number(scrubber.value);
updateSun();
});
document.querySelector<HTMLElement>("#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 -----------------------------------------------------------
/** /**
* Built on first entry and then kept, for the same reason the city is paused * Switching city tears the old one down completely.
* rather than disposed on the way in: rebuilding either scene costs far more *
* than holding it. * Unlike the cityoffice 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; function mountCity(id: string) {
let inside = false; 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() { function enterOffice() {
if (!city || !canEnterOffice) return;
if (!office) { if (!office) {
office = createOfficeScene(LUMBRIDGE_HQ, { office = createOfficeScene(LUMBRIDGE_HQ, {
dom: city.stage.renderer.domElement, dom: city.stage.renderer.domElement,
@@ -143,6 +150,7 @@ function enterOffice() {
} }
function leaveOffice() { function leaveOffice() {
if (!city) return;
city.stage.setScene(city.stageScene); city.stage.setScene(city.stageScene);
inside = false; inside = false;
showDetail(null); showDetail(null);
@@ -156,6 +164,8 @@ const blurb = document.querySelector<HTMLElement>("#blurb");
const title = document.querySelector<HTMLElement>("#title"); const title = document.querySelector<HTMLElement>("#title");
const subtitle = document.querySelector<HTMLElement>("#subtitle"); const subtitle = document.querySelector<HTMLElement>("#subtitle");
const enterButton = document.querySelector<HTMLButtonElement>("#enter"); const enterButton = document.querySelector<HTMLButtonElement>("#enter");
const cityNav = document.querySelector<HTMLElement>("#cities");
const source = document.querySelector<HTMLElement>("#source");
function showDetail(text: string | null) { function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail"); const card = document.querySelector<HTMLElement>("#detail");
@@ -164,12 +174,25 @@ function showDetail(text: string | null) {
card.textContent = text ?? ""; card.textContent = text ?? "";
} }
/** function renderCityPicker() {
* One legend for both places. A city chapter and an office viewpoint are both if (!cityNav) return;
* `View`s, which is the whole reason that type was extracted. 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() { function renderLegend() {
if (!nav) return; renderCityPicker();
if (!nav || !city) return;
const views: View[] = inside && office ? office.views : city.chapters; const views: View[] = inside && office ? office.views : city.chapters;
const activeId = inside && office ? office.current() : city.current(); const activeId = inside && office ? office.current() : city.current();
@@ -181,7 +204,7 @@ function renderLegend() {
button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`; button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
button.addEventListener("click", () => { button.addEventListener("click", () => {
if (inside && office) office.flyTo(view.id); if (inside && office) office.flyTo(view.id);
else city.flyTo(view.id); else city?.flyTo(view.id);
}); });
nav.append(button); nav.append(button);
}); });
@@ -191,11 +214,71 @@ function renderLegend() {
blurb.textContent = active?.description ?? ""; blurb.textContent = active?.description ?? "";
blurb.hidden = !active?.description; blurb.hidden = !active?.description;
} }
if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : SAN_FRANCISCO.name; const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? "";
if (subtitle) subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate"; if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : cityLabel;
if (enterButton) enterButton.textContent = inside ? "← Back to the city" : "Enter the office →"; 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())); enterButton?.addEventListener("click", () => {
city.onChapterChange(() => renderLegend()); if (inside) leaveOffice();
renderLegend(); else if (canEnterOffice) enterOffice();
else window.location.href = "/login.html";
});
const scrubber = document.querySelector<HTMLInputElement>("#hour");
scrubber?.addEventListener("input", () => {
hourOverride = Number(scrubber.value);
updateSun();
});
document.querySelector<HTMLElement>("#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();