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:
@@ -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; }
|
||||
`;
|
||||
Reference in New Issue
Block a user