SoCal, the whole bay, a moon, and gates that actually run
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:
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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(" ")}`);
|
||||||
@@ -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());
|
||||||
@@ -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." };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+3152
File diff suppressed because it is too large
Load Diff
+521
-19
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 city↔office move — where the city is paused and kept, because you
|
||||||
|
* are coming straight back — nobody flips between metros often enough to
|
||||||
|
* justify holding two heightfields and 140k building instances at once.
|
||||||
*/
|
*/
|
||||||
let office: OfficeScene | null = null;
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user