1
0

Real weather, real aircraft, a heightfield off the main thread, and instruments

Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:25:31 -07:00
parent a6f6a91813
commit e41c90fe8d
39 changed files with 8482 additions and 503 deletions
+1830
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
/**
* `src/tools/` — the instruments. Everything in here is for the person building
* this map, not for the person looking at it.
*
* There is one rule and this file exists to hold it: **nothing under
* `src/tools/` may be reached by a static import from the app.** `main.ts` loads
* it like this, and only like this:
*
* ```ts
* if (access.can.debug) {
* const { createGodmode } = await import("./tools/index.ts");
* godmode = createGodmode({ ... });
* }
* ```
*
* A static `import` would put the panel in the entry chunk, and the entry chunk
* is 772 kB before anybody has done anything — the whole thing arrives, parses
* and runs for every anonymous visitor who will never see a single control in
* it. Behind a dynamic import Vite gives the tools a chunk of their own, and a
* visitor who is not god does not download the code at all. That is also the
* strongest available reading of "nothing here may run for a non-god visitor":
* not a hidden panel, not a disabled panel, no panel.
*
* The corollary is that the arrow points one way. A tool may import from
* `engine/`, `access.ts` or `adapters/`; nothing outside `src/tools/` may import
* from inside it except through an `await import()`, or the split silently stops
* happening and nobody notices until the bundle is measured again.
*
* Type-only imports are the exception and are free: `import type { Godmode }` is
* erased at build time and creates no chunk edge. `main.ts` needs one to hold
* the handle in a nullable field.
*/
export { createGodmode } from "./godmode.ts";
export type { Godmode, GodmodeOptions, GodmodePlace } from "./godmode.ts";
+958
View File
@@ -0,0 +1,958 @@
/**
* Fly the camera somewhere, like what you see, and get back the five numbers
* that put it there.
*
* Adding a city to this repo means hand-authoring `chapters: Chapter[]`, and
* every chapter carries a `focus` — `{ lat, lng, distance, height, rotation }`
* — which is only knowable by flying somewhere, liking the frame, and then
* working out what the numbers were. The loop that produced the twelve poses in
* `cities/sf.ts` was: guess five numbers, rebuild, look, guess again. New York
* is on the roadmap and it needs a dozen more of them.
*
* ## This is the inverse of `chapterPose`, and the round trip is measured
*
* `scene.ts` turns a focus into a camera with
*
* target = (x, groundAt(lat,lng), z) where [x,z] = project(lat,lng)
* position = target + (sin(rot)·distance, height, cos(rot)·distance)
*
* which is invertible in one direction and *not* in the other, and the part
* that is not is the whole reason this file measures itself. Going backwards:
* `lat`/`lng` come from `unproject` of the orbit target's x and z, `distance`
* and `rotation` are the polar form of the horizontal offset from target to
* camera, and `height` is the camera's y above the ground under the target. The
* camera position comes back exactly. The *target* does not, because
* `chapterPose` pins target.y to the ground and OrbitControls does not: pan the
* view and the target lifts off the terrain, and no chapter can express that.
* The panel therefore shows the lift and the aim error it causes rather than
* quietly emitting a pose that frames something else. See `measure`.
*
* ## The maths is duplicated from `scene.ts` on purpose, and it is a liability
*
* `chapterPose` is a closure inside `createScene` and is not exported, so
* `poseOf` below is a copy of it. That is the one thing in this file that can
* rot silently: change the pose convention in `scene.ts` and this tool will go
* on confidently emitting the old one. The fix is for `scene.ts` to export the
* conversion and for this file to import it; until then, the two blocks are
* written to look identical so a diff between them is obvious.
*
* ## Precision: five decimals of degree, two of unit, five of radian
*
* The point of the tool is a block you can paste, so the numbers have to be
* short enough to read and long enough to reproduce the frame. Measured over
* every authored chapter in both packs plus eight jittered poses around each,
* worst case:
*
* - **What the packs carry today** (4 dp of degree, integer distance and
* height, 2 dp of radian, all typed by hand from a map): the camera lands
* 265 m from where it was on the Bay Area board and 466 m out on SoCal, and
* the view direction is 0.7°–1.2° off — about thirty pixels across a
* 1600-pixel frame. Fine for a pose a human invented at those digits;
* useless for reproducing one a human found by flying.
* - **This scheme**: camera position within 0.0066 scene units (0.62 m) on
* the Bay Area and 0.0063 units (2.44 m) on SoCal, orbit target within
* 0.74 m and 0.89 m, and the aim within 0.0098° — 0.024% of a 42° frame,
* which is a third of a pixel at 1600 wide.
*
* Going finer buys nothing anyone can see and costs a digit in a file people
* read. Trailing zeros are trimmed, so a pose that happens to be round emits
* `rotation: 0.4`, exactly as `sf.ts` already has it.
*
* **The order of the quantisation is load-bearing.** Round `lat`/`lng` *first*,
* then solve `distance`, `height` and `rotation` against the ground under the
* rounded point. The obvious order — take all five numbers off the live camera,
* round all five — feeds the terrain's own slope into the camera: `height` is
* measured from the ground under the exact target and re-applied over the
* ground under the rounded one, and with `verticalExaggeration` at 3.6 a metre
* of horizontal rounding on the side of Twin Peaks is several units of altitude.
* Measured, that order costs 0.98 m instead of 0.62 m at these digits, and
* 7.8 m instead of 3.0 m at the packs' four decimals.
*
* ## Shape
*
* Same self-contained imperative handle as `engine/minimap.ts`, and the same
* two rules: the caller supplies a container and owns where it goes, and
* `tick()` runs inside a frame loop so it compares a timestamp and eight
* numbers and returns. Unlike the minimap it never writes to the camera at all
* — the only way this tool moves anything is by handing a `Pose` to the kit's
* own `flyTo`.
*
* God tier only. `access.ts` is clear that a browser-side check is theatre
* against anyone with a console, so the gate here is not security; it is a
* loaded gun pointed away from the ninety-nine percent of sessions that have no
* business seeing an authoring instrument at all.
*/
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { Pose } from "../engine/scenekit.ts";
import type { Chapter } from "../engine/types.ts";
import type { World } from "../engine/world.ts";
export interface PoseEditorOptions {
/**
* Where the panel mounts. The tool appends exactly one element to it and
* owns everything below that; the caller owns the box, its position and its
* size.
*/
container: HTMLElement;
/**
* Pass `access.can.debug`. Construction throws when it is false — see the
* file header for why that is a thrown programming error and not a hidden
* no-op.
*/
allowed: boolean;
/** The same `World` the scene is drawing. `groundAt` must agree, or nothing does. */
world: World;
/** The live scene camera. Read every tick, written never. */
camera: THREE.PerspectiveCamera;
/** The live orbit controls. `controls.target` is the pose's target. */
controls: OrbitControls;
/**
* The kit's flight, `SceneKit.flyTo`. The only channel through which this
* tool is allowed to move the camera, and the reason it takes a callback
* rather than the controls: a tool that wrote `camera.position` directly
* would be a second thing with opinions about where the camera is, which is
* the failure CONTRACT.md §1 splits `Stage` and `SceneKit` to avoid.
*/
flyTo(pose: Pose): void;
/**
* `SceneKit.flying`. Capture is refused mid-flight: a frame sampled halfway
* through an eased interpolation is a pose nobody chose, and it looks
* plausible enough in the readout to get pasted.
*/
flying?(): boolean;
/**
* The chapters already in the pack, for the starting number and for the
* duplicate-id warning. `chapterById` in `scene.ts` is built from an object
* literal, so two chapters sharing an id means one of them silently is not
* in the tour.
*/
existingChapters?: readonly Chapter[];
}
/** How far the emitted block lands from the camera it was taken off. */
export interface PoseResidual {
/** Camera position error, in scene units. */
position: number;
/** Orbit target error, in scene units. Carries the ground lock as well as rounding. */
target: number;
/** Angle between the live view direction and the emitted one, in degrees. */
aim: number;
/** `aim` as a fraction of the camera's vertical field of view. */
frame: number;
/**
* How far the live orbit target floats above the terrain under it, in scene
* units. Measured against the ground under the *unrounded* target, so it is
* a statement about the camera and not about the emission precision: it is
* non-zero exactly when the view has been panned, and that is the one part
* of a pose a `Chapter` cannot carry.
*/
lift: number;
}
export interface CapturedPose {
chapter: Chapter;
residual: PoseResidual;
}
export interface PoseEditor {
/** Read the live camera, append a chapter to the session list, select it. */
capture(): CapturedPose | null;
poses(): readonly CapturedPose[];
/** The whole list as a paste-ready `chapters` array. */
code(): string;
/** Call from the frame loop. Cheap by construction; see `tick`. */
tick(): void;
setVisible(visible: boolean): void;
destroy(): void;
}
// ---- Precision --------------------------------------------------------------
const LATLNG_DP = 5;
const SPAN_DP = 2;
const ROT_DP = 5;
const TAU = Math.PI * 2;
/** Where the packs wrap. Not enforced by a formatter in this repo; matched by eye. */
const COLUMNS = 100;
/** Live readout ceiling. The stage runs at 60 and none of these digits need it. */
const FRAME_MS = 120;
/**
* Aim error, in degrees, above which the panel stops calling a pose clean.
* A tenth of a degree is four pixels across a 1600-pixel frame — under it
* nothing on screen moves, over it the pasted chapter is framing something
* slightly different from what was approved.
*/
const AIM_WARN = 0.1;
export function createPoseEditor(options: PoseEditorOptions): PoseEditor {
if (!options.allowed) {
throw new Error("poseEditor is a god-tier instrument and was constructed without the tier");
}
const { world, camera, controls } = options;
const existing = options.existingChapters ?? [];
// ---- The conversion -------------------------------------------------------
/**
* A copy of `chapterPose` in `engine/scene.ts`. Kept character-for-character
* where it can be, so that a diff between the two files reads as a diff. See
* the file header: this duplication is the one thing here that can rot.
*
* It writes into scratch vectors rather than allocating, because `measure`
* calls it from the frame loop. `SceneKit.flyTo` copies out of the pose it is
* given, so handing it the scratch is safe — and if that ever stops being
* true this is where it breaks.
*/
const scratch: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
function poseOf(focus: Chapter["focus"], into: Pose = scratch): Pose {
const [x, z] = world.project(focus.lat, focus.lng);
const groundY = world.groundAt(focus.lat, focus.lng);
into.target.set(x, groundY, z);
into.position.set(
x + Math.sin(focus.rotation) * focus.distance,
groundY + focus.height,
z + Math.cos(focus.rotation) * focus.distance,
);
return into;
}
/**
* The live camera, as a `Chapter["focus"]`, already at emission precision.
*
* Quantised in the order the file header argues for: the target first,
* everything else against where the target landed. The bearing convention is
* `chapterPose`'s — `atan2(dx, dz)`, so zero is due south of the target and
* the angle opens toward the east, which is not the compass bearing anyone
* expects and is what the packs are already written in.
*/
function readFocus(): Chapter["focus"] {
const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z);
const lat = round(rawLat, LATLNG_DP);
const lng = round(rawLng, LATLNG_DP);
const [x, z] = world.project(lat, lng);
const groundY = world.groundAt(lat, lng);
const dx = camera.position.x - x;
const dz = camera.position.z - z;
const distance = Math.hypot(dx, dz);
// Directly overhead the bearing is undefined, and `atan2` does not say so:
// on a pair of negative zeros it answers -π, which the wrap below turns
// into a confident π. The pose still round-trips either way — sin and cos
// of anything times a zero distance is a zero offset — but
// `rotation: 3.14159, distance: 0` in a city pack is a riddle, so straight
// down is written as zero.
let rotation = distance < 1e-6 ? 0 : Math.atan2(dx, dz);
if (rotation < 0) rotation += TAU;
rotation = round(rotation, ROT_DP);
// Rounding up through a full turn: a bearing a hair below due south comes
// out of the wrap as 6.283185…, which at five decimals is 6.28319, which is
// larger than a turn. Zero is the same pose and reads like one.
if (rotation >= TAU) rotation = 0;
return {
lat,
lng,
distance: round(distance, SPAN_DP),
height: round(camera.position.y - groundY, SPAN_DP),
rotation,
};
}
const probe: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
const liveDir = new THREE.Vector3();
const emitDir = new THREE.Vector3();
/**
* What the emitted block costs, against the camera it was taken off.
*
* This is the correctness claim of the whole tool and it is checked at
* capture rather than asserted in a comment: the focus goes back through the
* local copy of `chapterPose` and the two poses are differenced. Three
* separate numbers because they fail separately — rounding moves the camera,
* the ground lock moves the target, and only the angle between the two view
* vectors says whether any of it is visible.
*/
function measure(focus: Chapter["focus"]): PoseResidual {
poseOf(focus, probe);
liveDir.subVectors(controls.target, camera.position);
emitDir.subVectors(probe.target, probe.position);
const degrees =
liveDir.lengthSq() > 0 && emitDir.lengthSq() > 0
? (liveDir.angleTo(emitDir) * 180) / Math.PI
: 0;
// Against the ground under the live target rather than under the rounded
// one. The difference is sub-millimetre and it is still worth the second
// sample: `probe.target.y` folds the terrain's slope across a metre of
// rounding into a number the panel presents as "you panned", and a warning
// that fires on its own rounding is a warning people learn to ignore.
const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z);
return {
position: probe.position.distanceTo(camera.position),
target: probe.target.distanceTo(controls.target),
aim: degrees,
frame: camera.fov > 0 ? degrees / camera.fov : 0,
lift: controls.target.y - world.groundAt(rawLat, rawLng),
};
}
// ---- The session list -----------------------------------------------------
interface Entry {
chapter: Chapter;
residual: PoseResidual;
/** False once the id has been typed, so a later label edit stops overwriting it. */
idAuto: boolean;
row: HTMLElement;
name: HTMLElement;
note: HTMLElement;
}
const entries: Entry[] = [];
let selected: Entry | null = null;
let visible = true;
let destroyed = false;
// ---- DOM ------------------------------------------------------------------
/**
* The panel lives in a shadow root.
*
* `index.html` is the entire stylesheet of this application and it belongs to
* whoever is integrating this tool, not to the tool. A shadow root is the
* only way to ship a widget with its own styling that neither reads from nor
* writes to that file. Custom properties still cross the boundary, which is
* the useful half of the isolation: `var(--amber)` below picks up the app's
* own accent when the panel is mounted inside it and falls back to the same
* literal when it is mounted anywhere else.
*/
const host = document.createElement("div");
host.className = "tera-pose-editor";
const root = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = CSS;
root.append(style);
const panel = el("div", "panel");
root.append(panel);
panel.append(el("div", "hd", "Chapter pose"));
// The live readout.
const live = el("div", "live");
const liveLatLng = el("div", "row mono");
const liveFocus = el("div", "row mono");
const liveMeta = el("div", "row sub");
const liveWarn = el("div", "warn");
liveWarn.hidden = true;
live.append(liveLatLng, liveFocus, liveMeta, liveWarn);
panel.append(live);
const captureBtn = el("button", "btn primary", "Capture pose") as HTMLButtonElement;
captureBtn.type = "button";
captureBtn.addEventListener("click", () => {
capture();
});
panel.append(captureBtn);
// The naming form. It edits whichever pose is selected rather than being a
// form you fill in before capturing: you find the frame first and work out
// what to call it second, which is the order the job actually happens in.
const form = el("div", "form");
const fId = field(form, "id", "kebab-case, unique in the pack");
const fNumber = field(form, "number", '"13"');
const fLabel = field(form, "label", "Mission Bay");
const fShort = field(form, "shortLabel", "Mission Bay");
const fDesc = area(form, "description", "A sentence about why it is on the map.");
panel.append(form);
const list = el("div", "list");
panel.append(list);
const actions = el("div", "actions");
const copyOne = el("button", "btn", "Copy chapter") as HTMLButtonElement;
const copyAll = el("button", "btn", "Copy all") as HTMLButtonElement;
copyOne.type = "button";
copyAll.type = "button";
copyOne.addEventListener("click", () => {
if (selected) void copy(emitChapter(selected.chapter));
});
copyAll.addEventListener("click", () => {
if (entries.length > 0) void copy(code());
});
actions.append(copyOne, copyAll);
panel.append(actions);
const msg = el("div", "msg");
panel.append(msg);
/**
* The clipboard fallback.
*
* `navigator.clipboard` needs a secure context, and a self-hoster running
* this off `http://` on a LAN address has none — which is a documented
* deployment in `deploy/STATIC.md`, not an edge case. So the failure path is
* a textarea with the text already selected, and the user presses their own
* copy key.
*
* `document.execCommand("copy")` is deliberately not tried in between. Inside
* a shadow root the selection it copies is not reliably the one you just
* made, and it reports success either way; a button that says "Copied" and
* copied nothing is worse than a button that says it could not.
*/
const out = el("textarea", "out") as HTMLTextAreaElement;
out.readOnly = true;
out.spellcheck = false;
out.hidden = true;
panel.append(out);
/**
* Keystrokes stop at the shadow boundary.
*
* `main.ts` binds the application's shortcuts to `window` and guards them by
* testing whether `event.target` is an input or a textarea. **That guard does
* not work through a shadow root**: the event is retargeted on its way out, so
* by the time it reaches `window` the target is this host `<div>` and the
* guard passes. Typing "Mission Bay" into the label field would fly the camera
* to chapter one on the "1", toggle the plan view on the "m" and open an
* office on the "o" — which is to say a tool whose entire premise is that it
* does not perturb the scene would be the only thing in the app that did.
*
* Everything is swallowed rather than only the keys that currently mean
* something, because the alternative is this list going stale the first time
* somebody adds a shortcut. Escape is the one that also does something here:
* it puts the clipboard fallback away.
*/
function containKeys(event: Event) {
event.stopPropagation();
if (!(event instanceof KeyboardEvent) || event.key !== "Escape") return;
if (out.hidden) return;
out.hidden = true;
say("");
}
root.addEventListener("keydown", containKeys);
root.addEventListener("keyup", containKeys);
options.container.append(host);
// ---- Form wiring ----------------------------------------------------------
function bindField(input: HTMLInputElement | HTMLTextAreaElement, apply: (v: string) => void) {
input.addEventListener("input", () => {
if (!selected) return;
apply(input.value);
refreshRow(selected);
warnDuplicate();
});
}
bindField(fId, (v) => {
if (!selected) return;
selected.chapter.id = v;
// Typing an id takes it off the leash; a later label edit stops rewriting
// it. Clearing the field puts it back, which is the only way to undo that
// without a reset button nobody would find.
selected.idAuto = v.trim() === "";
if (selected.idAuto) selected.chapter.id = slug(selected.chapter.label);
});
bindField(fNumber, (v) => {
if (selected) selected.chapter.number = v;
});
bindField(fLabel, (v) => {
if (!selected) return;
selected.chapter.label = v;
if (selected.idAuto) {
selected.chapter.id = slug(v);
fId.value = selected.chapter.id;
}
});
bindField(fShort, (v) => {
if (selected) selected.chapter.shortLabel = v;
});
bindField(fDesc, (v) => {
if (selected) selected.chapter.description = v;
});
// ---- Capture and the list -------------------------------------------------
function capture(): CapturedPose | null {
if (options.flying?.()) {
say("Still flying — wait for the camera to land.");
return null;
}
const focus = readFocus();
const residual = measure(focus);
const number = pad(existing.length + entries.length + 1);
const label = `Untitled ${number}`;
const chapter: Chapter = {
id: slug(label),
number,
label,
shortLabel: label,
focus,
description: "",
};
const row = el("div", "item");
const name = el("button", "name") as HTMLButtonElement;
name.type = "button";
const note = el("div", "res mono");
const fly = el("button", "ico", "fly") as HTMLButtonElement;
const drop = el("button", "ico", "×") as HTMLButtonElement;
fly.type = "button";
drop.type = "button";
fly.title = "Fly to the emitted pose — the rounded numbers, not the live camera";
drop.title = "Forget this pose";
const head = el("div", "item-head");
head.append(name, fly, drop);
row.append(head, note);
const entry: Entry = { chapter, residual, idAuto: true, row, name, note };
name.addEventListener("click", () => select(entry));
fly.addEventListener("click", () => {
// Through the kit's own flight, and to the *emitted* pose rather than the
// captured one, because the emitted pose is what the paste will produce
// and the whole point is to see it before trusting it.
options.flyTo(poseOf(entry.chapter.focus));
});
drop.addEventListener("click", () => remove(entry));
entries.push(entry);
list.append(row);
refreshRow(entry);
select(entry);
warnDuplicate();
return { chapter, residual };
}
function remove(entry: Entry) {
const at = entries.indexOf(entry);
if (at < 0) return;
entries.splice(at, 1);
entry.row.remove();
if (selected === entry) select(entries[Math.min(at, entries.length - 1)] ?? null);
warnDuplicate();
}
function select(entry: Entry | null) {
if (selected) selected.row.classList.remove("sel");
selected = entry;
if (entry) entry.row.classList.add("sel");
form.classList.toggle("off", entry === null);
copyOne.disabled = entry === null;
copyAll.disabled = entries.length === 0;
fId.value = entry?.chapter.id ?? "";
fNumber.value = entry?.chapter.number ?? "";
fLabel.value = entry?.chapter.label ?? "";
fShort.value = entry?.chapter.shortLabel ?? "";
fDesc.value = entry?.chapter.description ?? "";
}
function refreshRow(entry: Entry) {
const r = entry.residual;
entry.name.textContent = `${entry.chapter.number} · ${entry.chapter.label || "—"}`;
entry.note.textContent =
`pos ${r.position.toFixed(4)}u · aim ${r.aim.toFixed(4)}° · ` +
`${(r.frame * 100).toFixed(3)}% of frame`;
entry.note.classList.toggle("bad", r.aim > AIM_WARN);
}
/**
* Two chapters with the same id is not a lint, it is a missing chapter:
* `scene.ts` keys its flights off an object built from the list, so the
* second one wins and the first is unreachable from the legend.
*/
function warnDuplicate() {
const seen = new Set(existing.map((c) => c.id));
const clashes: string[] = [];
for (const e of entries) {
if (seen.has(e.chapter.id)) clashes.push(e.chapter.id);
seen.add(e.chapter.id);
}
if (clashes.length > 0) say(`Duplicate id: ${clashes.join(", ")}`, true);
else if (msg.classList.contains("bad")) say("");
}
function say(text: string, bad = false) {
msg.textContent = text;
msg.classList.toggle("bad", bad && text !== "");
}
async function copy(text: string) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
say("Copied.");
return;
} catch {
// Permission refused, or a context the API decided was not secure
// enough after all. Fall through to the textarea.
}
}
out.hidden = false;
out.value = text;
out.focus({ preventScroll: true });
out.select();
say("Clipboard unavailable — press Ctrl/Cmd-C.");
}
function code(): string {
return emitChapters(entries.map((e) => e.chapter));
}
// ---- The live readout -----------------------------------------------------
let lastDraw = 0;
// Compared exactly rather than with an epsilon, for the reason `minimap.ts`
// spells out: OrbitControls' damping asymptotes, and an epsilon freezes the
// readout a few frames before the camera has actually stopped.
let lastCamX = NaN;
let lastCamY = NaN;
let lastCamZ = NaN;
let lastTgtX = NaN;
let lastTgtY = NaN;
let lastTgtZ = NaN;
function moved(): boolean {
return (
camera.position.x !== lastCamX ||
camera.position.y !== lastCamY ||
camera.position.z !== lastCamZ ||
controls.target.x !== lastTgtX ||
controls.target.y !== lastTgtY ||
controls.target.z !== lastTgtZ
);
}
function paintLive() {
const focus = readFocus();
const r = measure(focus);
liveLatLng.textContent = `lat ${num(focus.lat)} lng ${num(focus.lng)}`;
liveFocus.textContent =
`dist ${num(focus.distance)} height ${num(focus.height)} rot ${num(focus.rotation)}`;
// The 3D standoff against the controls' own ceiling, because a pose outside
// it is one `controls.update()` away from being quietly reeled in — the
// trap `sf.ts` has a paragraph about above its regional chapters.
const radius = Math.hypot(focus.distance, focus.height);
const ceiling = controls.maxDistance;
const groundM = world.unitsToMetres(controls.target.y - r.lift);
liveMeta.textContent =
`ground ${groundM.toFixed(0)} m · standoff ${radius.toFixed(1)} of ${ceiling.toFixed(0)}`;
const tight = ceiling > 0 && radius > ceiling * 0.99;
if (Math.abs(r.lift) > 1e-4 && r.aim > AIM_WARN) {
liveWarn.hidden = false;
liveWarn.textContent =
`Target is ${r.lift.toFixed(2)}u off the ground — a chapter cannot carry that, ` +
`so the emitted pose aims ${r.aim.toFixed(2)}° elsewhere. Re-fly a chapter to reset it.`;
} else if (tight) {
liveWarn.hidden = false;
liveWarn.textContent = "At the orbit ceiling — the pose may be reeled in on arrival.";
} else if (r.aim > AIM_WARN) {
liveWarn.hidden = false;
liveWarn.textContent = `Round trip is ${r.aim.toFixed(3)}° out.`;
} else {
liveWarn.hidden = true;
}
}
function tick() {
if (destroyed || !visible) return;
const now = performance.now();
if (now - lastDraw < FRAME_MS) return;
if (!moved()) return;
lastDraw = now;
lastCamX = camera.position.x;
lastCamY = camera.position.y;
lastCamZ = camera.position.z;
lastTgtX = controls.target.x;
lastTgtY = controls.target.y;
lastTgtZ = controls.target.z;
paintLive();
}
select(null);
paintLive();
return {
capture,
poses: () => entries.map((e) => ({ chapter: e.chapter, residual: e.residual })),
code,
tick,
setVisible(next) {
visible = next;
host.hidden = !next;
// The readout is stale by however long the panel was shut, and `moved()`
// will say nothing changed if the camera happens to be back where it was.
if (next) paintLive();
},
destroy() {
if (destroyed) return;
destroyed = true;
root.removeEventListener("keydown", containKeys);
root.removeEventListener("keyup", containKeys);
// Everything else this file listens to is on a node inside `host`, so
// removing it takes the listeners with it. The entries hold DOM that is
// inside `host` too; dropping the array is what stops them being reachable.
host.remove();
entries.length = 0;
selected = null;
},
};
}
// ---- Emission ---------------------------------------------------------------
/**
* One chapter, in the shape `cities/sf.ts` already has: two-space indent, key
* order `id, number, label, shortLabel, focus, description`, and a trailing
* comma, because what you are pasting is an element of the `CHAPTERS` array
* rather than a standalone declaration.
*/
export function emitChapter(chapter: Chapter, indent = " "): string {
const inner = `${indent} `;
return [
`${indent}{`,
`${inner}id: ${quote(chapter.id)},`,
`${inner}number: ${quote(chapter.number)},`,
`${inner}label: ${quote(chapter.label)},`,
`${inner}shortLabel: ${quote(chapter.shortLabel)},`,
emitFocus(chapter.focus, inner),
emitDescription(chapter.description, inner),
`${indent}},`,
].join("\n");
}
/** The session list as the declaration a city pack ends with. */
export function emitChapters(chapters: readonly Chapter[]): string {
const body = chapters.map((c) => emitChapter(c)).join("\n");
return `export const CHAPTERS: City["chapters"] = [\n${body}\n];\n`;
}
/**
* The focus, on one line where it fits and one key per line where it does not.
*
* Every focus in both packs is on one line today, but they were typed at four
* decimals and integer distances; a captured pose at five and two runs to about
* 97 columns and a long one goes over. This is the only formatting rule in the
* file and it is the one the rest of the tree follows by eye — there is no
* formatter in `devDependencies` to defer to.
*/
function emitFocus(focus: Chapter["focus"], indent: string): string {
const pairs = [
`lat: ${num(focus.lat)}`,
`lng: ${num(focus.lng)}`,
`distance: ${num(focus.distance)}`,
`height: ${num(focus.height)}`,
`rotation: ${num(focus.rotation)}`,
];
const flat = `${indent}focus: { ${pairs.join(", ")} },`;
if (flat.length <= COLUMNS) return flat;
return [`${indent}focus: {`, ...pairs.map((p) => `${indent} ${p},`), `${indent}},`].join("\n");
}
function emitDescription(description: string, indent: string): string {
// Collapsed to one line rather than escaped as `\n`. The field is a sentence
// in a legend; a textarea that has been typed into with the Enter key still
// means one paragraph, and a literal newline inside the quotes would not
// compile.
const text = quote(description.replace(/\s+/g, " ").trim());
const flat = `${indent}description: ${text},`;
if (flat.length <= COLUMNS) return flat;
return `${indent}description:\n${indent} ${text},`;
}
/**
* Already-rounded numbers, printed short. `String` drops the trailing zeros
* `toFixed` would leave, so a pose that lands on 0.4 emits `0.4` and matches
* what is in the packs; nothing here reaches the magnitude where JavaScript
* switches to exponent notation.
*/
function num(value: number): string {
return String(value === 0 ? 0 : value);
}
function quote(text: string): string {
return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function round(value: number, dp: number): number {
return Number(value.toFixed(dp));
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function slug(label: string): string {
const s = label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return s === "" ? "chapter" : s;
}
// ---- DOM helpers ------------------------------------------------------------
function el(tag: string, className: string, text?: string): HTMLElement {
const node = document.createElement(tag);
node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function field(parent: HTMLElement, name: string, placeholder: string): HTMLInputElement {
const wrap = el("label", "f");
wrap.append(el("span", "k", name));
const input = document.createElement("input");
input.type = "text";
input.placeholder = placeholder;
input.spellcheck = false;
wrap.append(input);
parent.append(wrap);
return input;
}
function area(parent: HTMLElement, name: string, placeholder: string): HTMLTextAreaElement {
const wrap = el("label", "f col");
wrap.append(el("span", "k", name));
const input = document.createElement("textarea");
input.rows = 3;
input.placeholder = placeholder;
wrap.append(input);
parent.append(wrap);
return input;
}
/**
* The panel's stylesheet.
*
* Written against the application's custom properties with the literal as the
* fallback, so the tool looks like it belongs when it is mounted inside Tera
* and still looks deliberate when it is mounted in a bare page. `:host` sets no
* `all: initial` on purpose — that would reset the custom properties along with
* everything else and the fallbacks would be all anyone ever saw.
*/
const CSS = `
:host { display: block; }
:host([hidden]) { display: none; }
* { box-sizing: border-box; }
.panel {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
line-height: 1.5;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-strong, rgba(9, 13, 18, 0.86));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r, 8px);
padding: var(--s3, 12px);
display: flex;
flex-direction: column;
gap: var(--s2, 8px);
}
.hd {
text-transform: uppercase;
letter-spacing: 0.09em;
font-size: 10px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.live {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s2, 8px);
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
border-radius: var(--r-sm, 5px);
}
.row { white-space: pre; overflow-x: auto; }
.sub { color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.warn {
margin-top: var(--s1, 4px);
color: var(--amber-ink, #ffd68a);
white-space: normal;
}
.btn {
font: inherit;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: var(--s2, 8px);
cursor: pointer;
transition: background var(--t, 150ms ease);
}
.btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn.primary { color: #14202b; background: var(--amber, #f2b134); border-color: transparent; }
.btn.primary:hover { background: var(--amber-lit, #ffc555); }
.form { display: flex; flex-direction: column; gap: var(--s1, 4px); }
.form.off { opacity: 0.35; pointer-events: none; }
.f { display: flex; align-items: center; gap: var(--s2, 8px); }
.f.col { align-items: flex-start; }
.k {
flex: 0 0 74px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
padding-top: 3px;
}
input, textarea {
font: inherit;
flex: 1 1 auto;
min-width: 0;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: 3px 6px;
resize: vertical;
}
input:focus, textarea:focus { outline: 1px solid var(--amber, #f2b134); }
.list { display: flex; flex-direction: column; gap: 2px; max-height: 34vh; overflow-y: auto; }
.item {
padding: var(--s1, 4px) var(--s2, 8px);
border-radius: var(--r-sm, 5px);
border: 1px solid transparent;
}
.item.sel {
border-color: var(--amber, #f2b134);
background: var(--glass-inset, rgba(255, 255, 255, 0.05));
}
.item-head { display: flex; align-items: center; gap: var(--s1, 4px); }
.name {
font: inherit;
flex: 1 1 auto;
min-width: 0;
text-align: left;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ico {
font: inherit;
color: var(--ink-2, rgba(255, 255, 255, 0.56));
background: none;
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
padding: 0 5px;
cursor: pointer;
}
.ico:hover { color: var(--amber-ink, #ffd68a); }
.res { color: var(--ink-4, rgba(255, 255, 255, 0.26)); font-size: 10px; }
.bad { color: var(--amber-ink, #ffd68a); }
.actions { display: flex; gap: var(--s2, 8px); }
.actions .btn { flex: 1 1 0; }
.msg { min-height: 1.5em; color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.out {
width: 100%;
height: 18vh;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
.mono { font-variant-numeric: tabular-nums; }
`;