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:
+183
-100
@@ -1,73 +1,55 @@
|
||||
/**
|
||||
* The standalone demo: San Francisco under a real sun, and one office you can
|
||||
* step into.
|
||||
* The demo: two cities under a real sun and moon, and one office you can step
|
||||
* into.
|
||||
*
|
||||
* Deliberately ships **no company data**. Markers are demonstrated using the
|
||||
* city's own landmarks — buildings, not businesses — because company positions
|
||||
* are geocoded and company pipeline status is private, and neither belongs in
|
||||
* this repo. Real markers arrive at runtime from an adapter; see `src/adapters/`
|
||||
* and ARCHITECTURE.md §3.
|
||||
* It ships **no real company data**. The markers are fabricated — see
|
||||
* `src/adapters/sample.ts`, which says so loudly — because real positions are
|
||||
* geocoded and real pipeline status is private, and neither belongs in this
|
||||
* repo. When a Tera API is present the same markers arrive from it instead, and
|
||||
* the UI says which of the two it is showing.
|
||||
*
|
||||
* It also makes no network calls. The sun is computed locally, the traffic is
|
||||
* simulated and the office is a data file, so a clone of this repo runs.
|
||||
* It also runs with no server at all: the sun and moon are computed locally,
|
||||
* the traffic is simulated, the office is a data file. Clone it and it works.
|
||||
*/
|
||||
|
||||
import { createAtmosphere, observe, PACIFIC_MARINE_LAYER } from "./engine/atmosphere.ts";
|
||||
import { createScene, type SceneHandle } from "./engine/scene.ts";
|
||||
import { SimulatedFlights } from "./engine/flights.ts";
|
||||
import { daylightPhase } from "./engine/solar.ts";
|
||||
import { SimulatedFlights, type SimRoute } from "./engine/flights.ts";
|
||||
import { createScene } from "./engine/scene.ts";
|
||||
import type { Marker, MarkerPalette, View } from "./engine/types.ts";
|
||||
import type { City, Marker, MarkerPalette, View } from "./engine/types.ts";
|
||||
import SAN_FRANCISCO from "./cities/sf.ts";
|
||||
import SOCAL from "./cities/socal.ts";
|
||||
import { createTeraClient } from "./adapters/http.ts";
|
||||
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./adapters/sample.ts";
|
||||
import { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts";
|
||||
import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts";
|
||||
|
||||
/**
|
||||
* Bay Area traffic, roughly where it actually is: SFO sits south of frame and
|
||||
* its arrivals run down the peninsula, Oakland is east across the bay, and the
|
||||
* coastal departures turn out over the Pacific.
|
||||
*/
|
||||
const ROUTES: SimRoute[] = [
|
||||
{ callsign: "UAL 1", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 },
|
||||
{ callsign: "ASA 22", from: [37.93, -122.31], to: [37.65, -122.38], fromAlt: 2100, toAlt: 450, duration: 210 },
|
||||
{ callsign: "SWA 118", from: [37.64, -122.39], to: [37.9, -122.62], fromAlt: 700, toAlt: 5200, duration: 165 },
|
||||
{ callsign: "DAL 407", from: [37.7, -122.21], to: [37.88, -122.55], fromAlt: 1800, toAlt: 6100, duration: 230 },
|
||||
{ callsign: "UAL 88", from: [37.62, -122.6], to: [37.95, -122.28], fromAlt: 6800, toAlt: 8200, duration: 260 },
|
||||
{ callsign: "N512SP", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 },
|
||||
{ callsign: "JBU 915", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 },
|
||||
const CITIES: { id: string; label: string; city: City }[] = [
|
||||
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
|
||||
{ id: "socal", label: "SoCal", city: SOCAL },
|
||||
];
|
||||
|
||||
const MARKER_PALETTE: MarkerPalette = { landmark: 0xf2b134, neutral: 0x9aa4ad };
|
||||
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
|
||||
if (!canvas) throw new Error("#scene canvas missing");
|
||||
|
||||
const city = createScene(canvas, {
|
||||
city: SAN_FRANCISCO,
|
||||
markerPalette: MARKER_PALETTE,
|
||||
flights: new SimulatedFlights(ROUTES),
|
||||
onMarkerPick: (marker) => showDetail(marker?.label ?? null),
|
||||
});
|
||||
const tera = createTeraClient();
|
||||
|
||||
// ---- The sun --------------------------------------------------------------
|
||||
let city: SceneHandle | null = null;
|
||||
let cityId = "sf";
|
||||
let office: OfficeScene | null = null;
|
||||
let inside = false;
|
||||
let markers: Marker[] = SAMPLE_MARKERS;
|
||||
let palette: MarkerPalette = SAMPLE_PALETTE;
|
||||
let liveData = false;
|
||||
let canEnterOffice = false;
|
||||
let atmosphere: ReturnType<typeof createAtmosphere> | null = null;
|
||||
|
||||
// ---- Time -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Real solar position for San Francisco, right now, recomputed every minute.
|
||||
*
|
||||
* No network and no timezone database — `solar.ts` is arithmetic — so this
|
||||
* keeps working on a laptop in a field. The marine layer is switched on because
|
||||
* the fog is the single most recognisable atmospheric fact about this city.
|
||||
*/
|
||||
const atmosphere = createAtmosphere({
|
||||
lng: SAN_FRANCISCO.center.lng,
|
||||
metresPerUnit: city.world.metresPerUnit,
|
||||
marineLayer: PACIFIC_MARINE_LAYER,
|
||||
});
|
||||
|
||||
/**
|
||||
* `null` follows the wall clock. A number is an hour-of-day override from the
|
||||
* scrubber, which exists because the honest answer at 2 a.m. is a black
|
||||
* rectangle — correct, and impossible to look at. Being able to drag the sun is
|
||||
* also the only practical way to eyeball whether the solar maths is right.
|
||||
* `null` follows the wall clock. The scrubber exists because the honest answer
|
||||
* at 2 a.m. is a very dark city — correct, and not what you want to be looking
|
||||
* at while judging whether the sun is in the right place.
|
||||
*/
|
||||
let hourOverride: number | null = null;
|
||||
|
||||
@@ -80,55 +62,80 @@ function currentInstant(): Date {
|
||||
}
|
||||
|
||||
function updateSun() {
|
||||
const env = observe(SAN_FRANCISCO.center.lat, SAN_FRANCISCO.center.lng, currentInstant());
|
||||
const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;
|
||||
if (!city || !atmosphere) return;
|
||||
const env = observe(active.center.lat, active.center.lng, currentInstant());
|
||||
city.setLighting(atmosphere.apply(env));
|
||||
city.setSolarElevation(env.sun.elevation);
|
||||
|
||||
const clock = document.querySelector<HTMLElement>("#clock");
|
||||
if (!clock) return;
|
||||
const el = env.sun.elevation;
|
||||
const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const phase = daylightPhase(el);
|
||||
clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${phase}${hourOverride === null ? "" : " (held)"}`;
|
||||
const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : "";
|
||||
clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`;
|
||||
}
|
||||
|
||||
const scrubber = document.querySelector<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();
|
||||
});
|
||||
|
||||
updateSun();
|
||||
window.setInterval(() => hourOverride === null && updateSun(), 60_000);
|
||||
|
||||
// ---- Demo markers ---------------------------------------------------------
|
||||
|
||||
const demoMarkers: Marker[] = SAN_FRANCISCO.landmarks
|
||||
.filter((l) => l.label)
|
||||
.map((l) => ({
|
||||
id: l.name,
|
||||
lat: l.lat,
|
||||
lng: l.lng,
|
||||
label: l.name,
|
||||
colorKey: "landmark",
|
||||
located: true,
|
||||
}));
|
||||
city.setMarkers(demoMarkers);
|
||||
|
||||
// ---- The office -----------------------------------------------------------
|
||||
// ---- Cities ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Built on first entry and then kept, for the same reason the city is paused
|
||||
* rather than disposed on the way in: rebuilding either scene costs far more
|
||||
* than holding it.
|
||||
* Switching city tears the old one down completely.
|
||||
*
|
||||
* Unlike the city↔office move — where the city is paused and kept, because you
|
||||
* are coming straight back — nobody flips between metros often enough to
|
||||
* justify holding two heightfields and 140k building instances at once.
|
||||
*/
|
||||
let office: OfficeScene | null = null;
|
||||
let inside = false;
|
||||
function mountCity(id: string) {
|
||||
const entry = CITIES.find((c) => c.id === id);
|
||||
if (!entry || !canvas) return;
|
||||
|
||||
office?.dispose();
|
||||
office = null;
|
||||
inside = false;
|
||||
city?.dispose();
|
||||
|
||||
cityId = id;
|
||||
city = createScene(canvas, {
|
||||
city: entry.city,
|
||||
markerPalette: palette,
|
||||
flights: liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES),
|
||||
onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null),
|
||||
});
|
||||
// Fog distances are scene units, so they have to follow the board — 210/460
|
||||
// was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
|
||||
// Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
|
||||
// out on the whole-board view: a fog starting nearer than that is behind the
|
||||
// viewer's own shoulder, and at night, when the fog colour is nearly black
|
||||
// rather than bright haze, it turns the entire map off.
|
||||
const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
|
||||
const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
|
||||
const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));
|
||||
|
||||
atmosphere = createAtmosphere({
|
||||
lng: entry.city.center.lng,
|
||||
metresPerUnit: city.world.metresPerUnit,
|
||||
clearFog: { near: span * 1.15, far: span * 2.8 },
|
||||
// The floor on how far you can see, and it has to know how big the board
|
||||
// is. `minVisibilityM` defaults to 4.5 km, which is honest weather and
|
||||
// completely wrong here: this board is ninety-four kilometres across, so
|
||||
// real visibility correctly hides three quarters of it and the night view
|
||||
// renders as a black rectangle. A map is looked at from outside the
|
||||
// atmosphere it is depicting.
|
||||
minVisibilityM: span * city.world.metresPerUnit * 1.6,
|
||||
// The marine layer is a fact about the eastern Pacific at this latitude,
|
||||
// not a decoration. LA gets its own weather, not San Francisco's fog.
|
||||
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
|
||||
});
|
||||
city.setMarkers(id === "sf" ? markers : []);
|
||||
city.onChapterChange(() => renderLegend());
|
||||
updateSun();
|
||||
renderLegend();
|
||||
}
|
||||
|
||||
// ---- Office ---------------------------------------------------------------
|
||||
|
||||
function enterOffice() {
|
||||
if (!city || !canEnterOffice) return;
|
||||
if (!office) {
|
||||
office = createOfficeScene(LUMBRIDGE_HQ, {
|
||||
dom: city.stage.renderer.domElement,
|
||||
@@ -143,6 +150,7 @@ function enterOffice() {
|
||||
}
|
||||
|
||||
function leaveOffice() {
|
||||
if (!city) return;
|
||||
city.stage.setScene(city.stageScene);
|
||||
inside = false;
|
||||
showDetail(null);
|
||||
@@ -156,6 +164,8 @@ const blurb = document.querySelector<HTMLElement>("#blurb");
|
||||
const title = document.querySelector<HTMLElement>("#title");
|
||||
const subtitle = document.querySelector<HTMLElement>("#subtitle");
|
||||
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
|
||||
const cityNav = document.querySelector<HTMLElement>("#cities");
|
||||
const source = document.querySelector<HTMLElement>("#source");
|
||||
|
||||
function showDetail(text: string | null) {
|
||||
const card = document.querySelector<HTMLElement>("#detail");
|
||||
@@ -164,12 +174,25 @@ function showDetail(text: string | null) {
|
||||
card.textContent = text ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* One legend for both places. A city chapter and an office viewpoint are both
|
||||
* `View`s, which is the whole reason that type was extracted.
|
||||
*/
|
||||
function renderCityPicker() {
|
||||
if (!cityNav) return;
|
||||
cityNav.replaceChildren();
|
||||
for (const c of CITIES) {
|
||||
const b = document.createElement("button");
|
||||
b.className = c.id === cityId && !inside ? "city active" : "city";
|
||||
b.textContent = c.label;
|
||||
b.addEventListener("click", () => {
|
||||
if (inside) leaveOffice();
|
||||
if (c.id !== cityId) mountCity(c.id);
|
||||
});
|
||||
cityNav.append(b);
|
||||
}
|
||||
}
|
||||
|
||||
/** One legend for both places — a city chapter and an office viewpoint are both `View`s. */
|
||||
function renderLegend() {
|
||||
if (!nav) return;
|
||||
renderCityPicker();
|
||||
if (!nav || !city) return;
|
||||
const views: View[] = inside && office ? office.views : city.chapters;
|
||||
const activeId = inside && office ? office.current() : city.current();
|
||||
|
||||
@@ -181,7 +204,7 @@ function renderLegend() {
|
||||
button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
|
||||
button.addEventListener("click", () => {
|
||||
if (inside && office) office.flyTo(view.id);
|
||||
else city.flyTo(view.id);
|
||||
else city?.flyTo(view.id);
|
||||
});
|
||||
nav.append(button);
|
||||
});
|
||||
@@ -191,11 +214,71 @@ function renderLegend() {
|
||||
blurb.textContent = active?.description ?? "";
|
||||
blurb.hidden = !active?.description;
|
||||
}
|
||||
if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : SAN_FRANCISCO.name;
|
||||
if (subtitle) subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
|
||||
if (enterButton) enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
|
||||
const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? "";
|
||||
if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : cityLabel;
|
||||
if (subtitle) {
|
||||
subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
|
||||
}
|
||||
if (enterButton) {
|
||||
if (inside) enterButton.textContent = "← Back to the city";
|
||||
else if (canEnterOffice) enterButton.textContent = "Enter the office →";
|
||||
else enterButton.textContent = "Sign in to enter the office →";
|
||||
}
|
||||
if (source) {
|
||||
source.textContent = liveData ? "live data" : "sample data · fabricated, not real companies";
|
||||
source.className = liveData ? "source live" : "source";
|
||||
}
|
||||
}
|
||||
|
||||
enterButton?.addEventListener("click", () => (inside ? leaveOffice() : enterOffice()));
|
||||
city.onChapterChange(() => renderLegend());
|
||||
renderLegend();
|
||||
enterButton?.addEventListener("click", () => {
|
||||
if (inside) leaveOffice();
|
||||
else if (canEnterOffice) enterOffice();
|
||||
else window.location.href = "/login.html";
|
||||
});
|
||||
|
||||
const scrubber = document.querySelector<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