SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same blocker: `focusRegions` and `coarseFactor` existed on the `City` type and nothing implemented them. Uniform lattices would have been 2.9M points for Southern California and 3.7M for the expanded bay. Both packs were unloadable as written. `buildAxis` is the answer, and it is honest about its limits: refinement is per axis, not per rectangle, so a focus region sharpens its whole row *and* its whole column. Two regions at opposite corners refine nearly everything between them. Measured, not guessed — the bay went 0.53M points with one region and 1.64M with three, for detail nobody is looking at from a board this wide. One region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s. Then three things that were only ever right because San Francisco was the only city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a 230-unit board; the bay is 1003 units across and the camera physically could not retreat far enough to frame it. Fog distances were scene units pinned to the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest weather, which over ninety-four kilometres of bay correctly hides three quarters of it — the night view was a black rectangle for a completely reasonable reason. All three now derive from the board. The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a physical ratio of one to four hundred thousand. What is being reproduced is what a moonlit night looks like on a screen in a lit room. The CI gate caught itself, which is the part worth keeping. Port 8431 was already held by a server from an earlier session, so the boot check polled a healthy stranger while the process it started died on EADDRINUSE. It now refuses to run rather than pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# Adapters
|
||||
|
||||
Everything in `src/engine/` renders data and takes no position on what it means.
|
||||
A `Marker` is a point with a `colorKey`; the engine looks that key up in a
|
||||
palette the caller supplies and will never learn that `rejected` is red. A
|
||||
`FlightSource` is an interface with a `poll()` on it. A `WeatherObservation` is
|
||||
six numbers.
|
||||
|
||||
This directory is where those become somebody's actual data. It is the only part
|
||||
of the browser build that knows an API exists, and keeping it here is what lets
|
||||
one renderer serve a private career map, a public sector map and whatever anyone
|
||||
else builds without any of them being a fork. See ARCHITECTURE.md §3.3.
|
||||
|
||||
| file | what it is |
|
||||
| --- | --- |
|
||||
| `sample.ts` | fabricated demo markers and flight routes, so a fresh clone has something on it |
|
||||
| `http.ts` | the real adapter — the Tera API described in `src/server/wire.ts`, falling back to `sample.ts` |
|
||||
|
||||
## The fallback is the product, not the safety net
|
||||
|
||||
`http.ts` never throws and never leaves the map empty. No server, a 404, a
|
||||
timeout, a static host answering `/api/v1/markers` with its own `index.html` —
|
||||
all of it lands on the sample data, and the city keeps rendering.
|
||||
|
||||
That is deliberate and it is the acceptance test the whole repo is held to: a
|
||||
stranger clones this, runs one command, and gets a city, with no account, no key
|
||||
and no network (CONTRACT.md §0). `npm run build` deployed to any static host is a
|
||||
working Tera. Pointing it at a server is an upgrade.
|
||||
|
||||
Every response carries `live: boolean` so the difference is visible to the app
|
||||
above. An interface that shows invented companies exactly the way it shows real
|
||||
ones is the one failure mode this arrangement can have, and the flag is there so
|
||||
it does not have to happen.
|
||||
|
||||
```ts
|
||||
const tera = createTeraClient(); // same-origin /api/v1
|
||||
const markers = await tera.markers();
|
||||
const scene = createScene(canvas, {
|
||||
city: SAN_FRANCISCO,
|
||||
markerPalette: markers.palette,
|
||||
flights: tera.flights(),
|
||||
});
|
||||
scene.setMarkers(markers.value);
|
||||
if (!markers.live) showSampleDataNotice();
|
||||
```
|
||||
|
||||
Note the ordering: `markerPalette` is fixed when the scene is built, so the
|
||||
markers have to be awaited first. `markers.palette` is the sample palette when
|
||||
the feed is the sample set and the palette you passed in `TeraApiOptions` when it
|
||||
is real — the sample keys are not your keys.
|
||||
|
||||
## No real company data ships in this repo
|
||||
|
||||
Two separate constraints want the same thing here, which is the reason this
|
||||
arrangement is worth the indirection rather than just committing a JSON file.
|
||||
|
||||
**Privacy.** Pipeline status — who is talking to whom, and who said no — is
|
||||
private. A public repo is the wrong place for it.
|
||||
|
||||
**Licence, which is the sharper one.** Real positions are geocoded, and a
|
||||
geocoder built on OpenStreetMap returns ODbL data. ODbL is share-alike, and
|
||||
serving a snapshot of those coordinates from a public endpoint is *Publicly
|
||||
Using a Derivative Database* — §4.3 attribution and §4.4 share-alike attach to
|
||||
the served data whether or not the rows live in the repo. Keeping the table
|
||||
off-disk hides that obligation; it does not discharge it. See ARCHITECTURE.md
|
||||
§3.2 and the correction in CONTRACT.md §8.
|
||||
|
||||
So the rule is about the geocoder, not about storage:
|
||||
|
||||
- Geography in `src/cities/` is **traced by hand**. Original expression, ours,
|
||||
Apache-2.0. Never OSM, never Nominatim.
|
||||
- Real markers arrive over the API at runtime, each carrying a
|
||||
`CoordinateProvenance`, and the server refuses any row whose provenance is not
|
||||
on a non-ODbL allowlist — `us-census`, `hand-placed`, `synthetic`.
|
||||
- `http.ts` passes `refused` straight through rather than swallowing it, because
|
||||
a gate that drops rows silently is indistinguishable from an empty database.
|
||||
It does **not** re-run the gate in the browser: the allowlist is the server's,
|
||||
and a self-hoster who added their own provenance value should not watch their
|
||||
own rows vanish client-side.
|
||||
- Everything in `sample.ts` is invented. The companies do not exist, the
|
||||
positions were typed by hand from a general sense of where San Francisco's
|
||||
neighbourhoods are, and the names are absurd on purpose so that none of them
|
||||
can be mistaken for a real business.
|
||||
|
||||
## Writing your own
|
||||
|
||||
`http.ts` is one adapter, not the adapter. Anything that can produce
|
||||
`Marker[]`, a `WeatherObservation` and a `FlightSource` is one — a local JSON
|
||||
file, a Postgres query through your own backend, an SDR on the windowsill. The
|
||||
engine imports nothing from this directory, so an adapter can be deleted, forked
|
||||
or replaced without touching a line of the renderer.
|
||||
|
||||
Two things to keep if you write one:
|
||||
|
||||
**Traffic must not block the render loop.** `poll()` may be synchronous, and
|
||||
`HttpFlights` is: it answers from whatever is in hand and refreshes on the
|
||||
body's own TTL in the background. A `poll()` that awaits a slow fetch puts a
|
||||
frame behind a round trip.
|
||||
|
||||
**FlightRadar24 is not an option.** Their terms forbid scraping and forbid
|
||||
redistributing the data, so an Apache-2.0 repo containing an FR24 client would be
|
||||
publishing instructions for breaking a ToS and shipping data it has no right to
|
||||
relicense. `src/engine/flights.ts` ships a simulator and points at the open
|
||||
community ADS-B feeds instead; anything commercial belongs in an adapter in a
|
||||
private deployment. See ARCHITECTURE.md §4.
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* The real adapter: markers, weather and traffic from the Tera API, with the
|
||||
* bundled sample data underneath it.
|
||||
*
|
||||
* This is the one place in the browser build that knows the API exists. The
|
||||
* engine takes `Marker[]`, a `WeatherObservation` and a `FlightSource` and has
|
||||
* no idea where any of them came from — that boundary is what lets one renderer
|
||||
* serve a private career map and a public sector map without either being a
|
||||
* fork (ARCHITECTURE.md §3.3), and it is why this file is an adapter rather than
|
||||
* a client scattered through the scene.
|
||||
*
|
||||
* **Every call degrades instead of failing.** No server, a 404, a static host
|
||||
* answering `/api/v1/markers` with its own index.html, a network that has gone
|
||||
* away mid-session: all of it lands on the sample data in `sample.ts` and the
|
||||
* synthetic clear day below, and the map keeps rendering. That is not defensive
|
||||
* habit, it is the acceptance test the whole repo is held to — a stranger clones
|
||||
* this, runs one command, and gets a city, with no account, no key and no
|
||||
* network (CONTRACT.md §0). A `npm run build` deployed to any static host is a
|
||||
* working Tera; pointing it at a server is an upgrade, not a requirement.
|
||||
*
|
||||
* The wire types live in `src/server/wire.ts` and are types only, so importing
|
||||
* them costs the bundle nothing.
|
||||
*/
|
||||
|
||||
import type { WeatherObservation } from "../engine/atmosphere.ts";
|
||||
import { sampleRoute, SimulatedFlights, type SimRoute } from "../engine/flights.ts";
|
||||
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
|
||||
import { seededRandom } from "../engine/world.ts";
|
||||
import type {
|
||||
FlightsBody,
|
||||
FlightsPlanBody,
|
||||
HealthBody,
|
||||
MarkersBody,
|
||||
OfficeDoc,
|
||||
WeatherBody,
|
||||
} from "../server/wire.ts";
|
||||
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./sample.ts";
|
||||
|
||||
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
|
||||
const DEFAULT_BASE = "/api/v1";
|
||||
|
||||
/** How long any one request may take before the fallback is used instead. */
|
||||
const DEFAULT_TIMEOUT_MS = 4000;
|
||||
|
||||
/** How long to wait before trying the flights endpoint again after it fails. */
|
||||
const RETRY_SECONDS = 30;
|
||||
|
||||
export interface TeraApiOptions {
|
||||
/**
|
||||
* Base URL, with no trailing slash. Absolute is allowed and is what a
|
||||
* self-hoster running the browser build and the API on different origins
|
||||
* wants; the default assumes they are the same origin.
|
||||
*/
|
||||
base?: string;
|
||||
/** Injected for tests. Absent means `globalThis.fetch`. */
|
||||
fetch?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* The palette live markers are coloured by.
|
||||
*
|
||||
* The engine looks `colorKey` up and the wire does not carry colours, so
|
||||
* somebody has to supply this and it cannot be the server: what a key *means*
|
||||
* is the consuming app's business. When the API is absent this is ignored and
|
||||
* `SAMPLE_PALETTE` is used instead, because sample keys are not the caller's
|
||||
* keys.
|
||||
*/
|
||||
palette?: MarkerPalette;
|
||||
}
|
||||
|
||||
/**
|
||||
* A response, plus whether it is real.
|
||||
*
|
||||
* `live` is the field that stops a fallback from being a lie. A demo showing
|
||||
* invented companies and a deployment showing real ones must not look identical
|
||||
* to the code above them — the caller is expected to say so in the interface,
|
||||
* and cannot if the adapter quietly papers over the difference.
|
||||
*/
|
||||
export interface Feed<T> {
|
||||
value: T;
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
export interface MarkerFeed extends Feed<Marker[]> {
|
||||
/** The palette these markers are meant to be read with. */
|
||||
palette: MarkerPalette;
|
||||
/** ISO-8601 snapshot time, or `null` for the sample set, which has no date. */
|
||||
generatedAt: string | null;
|
||||
/**
|
||||
* Rows the server's public-shape gate refused, by reason and count.
|
||||
*
|
||||
* Passed through rather than swallowed. A gate that drops rows silently is
|
||||
* indistinguishable from an empty database, which is exactly the confusion
|
||||
* `MarkersBody.refused` exists to prevent — and it is the visible end of the
|
||||
* provenance rule in CONTRACT.md §8.
|
||||
*/
|
||||
refused: { reason: string; count: number }[];
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
export interface WeatherFeed extends Feed<WeatherObservation> {
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
export interface TeraClient {
|
||||
/** What the deployment turned out to be, or `null` if there is no server. */
|
||||
health(): Promise<HealthBody | null>;
|
||||
markers(): Promise<MarkerFeed>;
|
||||
weather(): Promise<WeatherFeed>;
|
||||
/**
|
||||
* The traffic source, built once. It fetches on its own schedule and never
|
||||
* blocks the render loop; see `HttpFlights`.
|
||||
*/
|
||||
flights(): FlightSource;
|
||||
/**
|
||||
* One office pack. `null` for anything the server will not serve — including
|
||||
* a private one, which answers 404 rather than 403 so the endpoint cannot be
|
||||
* used to enumerate what exists (CONTRACT.md §6).
|
||||
*
|
||||
* There is deliberately no fallback here. A missing marker can be stood in for
|
||||
* by a fictional one; a missing floorplan cannot be invented, and an app with
|
||||
* a bundled office of its own already has the better answer.
|
||||
*/
|
||||
office(id: string): Promise<OfficeDoc | null>;
|
||||
}
|
||||
|
||||
export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, "");
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
||||
|
||||
/**
|
||||
* One GET, and `null` for every way it can go wrong.
|
||||
*
|
||||
* Deliberately undiscriminating. A 404, a timeout, a CORS refusal, a static
|
||||
* host serving `index.html` with a 200 and an HTML content type — the caller's
|
||||
* response to all of them is the same, and a taxonomy of failures nobody
|
||||
* branches on is a taxonomy nobody maintains.
|
||||
*/
|
||||
async function get<T>(path: string): Promise<T | null> {
|
||||
if (!doFetch) return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await doFetch(`${base}${path}`, {
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
// Checked rather than trusted: a static host answers an unknown path with
|
||||
// the SPA shell and a 200, and `res.json()` on HTML throws where a content
|
||||
// type check just returns.
|
||||
const type = res.headers.get("content-type") ?? "";
|
||||
if (!type.includes("json")) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
let flightSource: FlightSource | null = null;
|
||||
|
||||
return {
|
||||
health: () => get<HealthBody>("/health"),
|
||||
|
||||
async markers(): Promise<MarkerFeed> {
|
||||
const body = await get<MarkersBody>("/markers");
|
||||
if (!body || !Array.isArray(body.markers)) return sampleMarkerFeed();
|
||||
return {
|
||||
// A `WireMarker` *is* a `Marker` with a provenance field on it, so this
|
||||
// is a widening and not a translation — which is the property
|
||||
// `wire.ts` chose the shape for. The provenance itself is the server's
|
||||
// to enforce and is deliberately not re-checked here: a browser
|
||||
// silently dropping rows a self-hoster explicitly allowlisted would look
|
||||
// exactly like an empty database.
|
||||
value: body.markers,
|
||||
live: true,
|
||||
palette: options.palette ?? {},
|
||||
generatedAt: body.generatedAt ?? null,
|
||||
refused: body.refused ?? [],
|
||||
attribution: body.attribution ?? [],
|
||||
};
|
||||
},
|
||||
|
||||
async weather(): Promise<WeatherFeed> {
|
||||
const body = await get<WeatherBody>("/weather");
|
||||
if (!body) return { value: CLEAR_DAY, live: false, attribution: [] };
|
||||
// `WeatherBody` is structurally a `WeatherObservation` plus fields no
|
||||
// renderer reads, which `atmosphere.ts` says in as many words. The extra
|
||||
// fields ride along harmlessly and the engine never sees them.
|
||||
return { value: body, live: !body.synthetic, attribution: body.attribution ?? [] };
|
||||
},
|
||||
|
||||
flights(): FlightSource {
|
||||
flightSource ??= new HttpFlights(get, SAMPLE_ROUTES);
|
||||
return flightSource;
|
||||
},
|
||||
|
||||
office: (id) => get<OfficeDoc>(`/offices/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The clear day a zero-config box serves, restated in the browser.
|
||||
*
|
||||
* The server does this too — a weather source configured without what it needs
|
||||
* is demoted rather than fatal, and it answers `synthetic: true` forever
|
||||
* (CONTRACT.md §5.1). This is the same answer for the case where there is no
|
||||
* server at all. Note what it does *not* do: `visibilityKm` stays null, which
|
||||
* `atmosphere.ts` reads as "nobody measured" rather than as "unlimited", so San
|
||||
* Francisco's marine layer still runs off its own climatology instead of being
|
||||
* overruled by a fact nobody observed.
|
||||
*/
|
||||
const CLEAR_DAY: WeatherObservation = {
|
||||
cloudCover: 0.1,
|
||||
precipitation: 0,
|
||||
visibilityKm: null,
|
||||
windKph: null,
|
||||
windDirDeg: null,
|
||||
condition: "clear",
|
||||
};
|
||||
|
||||
function sampleMarkerFeed(): MarkerFeed {
|
||||
return {
|
||||
value: SAMPLE_MARKERS,
|
||||
live: false,
|
||||
palette: SAMPLE_PALETTE,
|
||||
generatedAt: null,
|
||||
refused: [],
|
||||
attribution: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Traffic --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Traffic over HTTP, in whichever of the two shapes the server chose.
|
||||
*
|
||||
* `poll()` is synchronous and never awaits the network, which is the whole
|
||||
* design. `scene.ts` calls it from the render loop, and a source that returned a
|
||||
* promise resolving on a slow fetch would put a frame's aircraft update behind a
|
||||
* round trip; instead the network runs in the background on the body's own TTL
|
||||
* and `poll` answers from whatever is currently in hand.
|
||||
*
|
||||
* The two modes are not symmetrical, and `wire.ts` explains why. A *plan* — the
|
||||
* simulator's routes, a fixed epoch and a seed — is evaluated locally at one
|
||||
* request per TTL, and because the epoch is fixed rather than the server's start
|
||||
* time, two people on different machines see the same aircraft in the same
|
||||
* places. *Live* traffic has no closed form, so it arrives as positions and is
|
||||
* refetched.
|
||||
*
|
||||
* Until the first response lands, and after any failure, this is the simulator
|
||||
* over `SAMPLE_ROUTES`. An empty sky is a worse answer than an invented one, and
|
||||
* the invented one is labelled as such in `sample.ts`.
|
||||
*/
|
||||
class HttpFlights implements FlightSource {
|
||||
/**
|
||||
* One second, which is the *evaluation* cadence and not the request cadence.
|
||||
* A plan is arithmetic and wants to be evaluated every frame or close to it;
|
||||
* the network is on `nextFetchAt` and is a great deal slower.
|
||||
*/
|
||||
readonly interval = 1;
|
||||
|
||||
private readonly fallback: SimulatedFlights;
|
||||
private plan: FlightsPlanBody | null = null;
|
||||
private planPhase: number[] = [];
|
||||
private live: Aircraft[] | null = null;
|
||||
private nextFetchAt = 0;
|
||||
private fetching = false;
|
||||
|
||||
constructor(
|
||||
private readonly get: <T>(path: string) => Promise<T | null>,
|
||||
fallbackRoutes: SimRoute[],
|
||||
) {
|
||||
this.fallback = new SimulatedFlights(fallbackRoutes);
|
||||
}
|
||||
|
||||
poll(): Aircraft[] {
|
||||
this.refreshIfStale();
|
||||
if (this.plan) return evaluatePlan(this.plan, this.planPhase, Date.now());
|
||||
if (this.live) return this.live;
|
||||
return this.fallback.poll();
|
||||
}
|
||||
|
||||
private refreshIfStale(): void {
|
||||
const now = Date.now();
|
||||
if (this.fetching || now < this.nextFetchAt) return;
|
||||
this.fetching = true;
|
||||
void this.get<FlightsBody>("/flights")
|
||||
.then((body) => {
|
||||
if (!body) {
|
||||
// Hold whatever was already in hand rather than reverting to the
|
||||
// simulator: a deployment that has been showing real traffic for an
|
||||
// hour and drops one request should keep showing it, slightly stale,
|
||||
// not silently swap in fiction.
|
||||
this.nextFetchAt = now + RETRY_SECONDS * 1000;
|
||||
return;
|
||||
}
|
||||
if (body.mode === "plan") {
|
||||
this.plan = body;
|
||||
this.planPhase = phasesFor(body);
|
||||
this.live = null;
|
||||
} else {
|
||||
this.live = body.aircraft;
|
||||
this.plan = null;
|
||||
}
|
||||
this.nextFetchAt = now + Math.max(1, body.ttlSeconds) * 1000;
|
||||
})
|
||||
.finally(() => {
|
||||
this.fetching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-route phase offsets, from the seed the server sent.
|
||||
*
|
||||
* Same generator and same order as `SimulatedFlights`, which is what makes the
|
||||
* server's promise true: every viewer draws the seed once, in route order, and
|
||||
* arrives at the same sky.
|
||||
*/
|
||||
function phasesFor(plan: FlightsPlanBody): number[] {
|
||||
const rand = seededRandom(plan.seed);
|
||||
return plan.routes.map(() => rand());
|
||||
}
|
||||
|
||||
function evaluatePlan(plan: FlightsPlanBody, phase: number[], nowMs: number): Aircraft[] {
|
||||
const seconds = (nowMs - plan.t0) / 1000;
|
||||
// `WireSimRoute` is structurally `SimRoute`; the restatement in `wire.ts` is
|
||||
// there so the server can build one without importing three.js.
|
||||
return plan.routes.map((route, i) => sampleRoute(route, seconds / route.duration + (phase[i] ?? 0)));
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Fabricated demo data, so that a clone of this repo has something on it.
|
||||
*
|
||||
* **Everything in this file is invented.** The companies do not exist, have
|
||||
* never existed, and are named the way they are — Wobbegong, Nonsuch, Pennyfarthing
|
||||
* — specifically so that nobody can mistake one for a real business. The
|
||||
* coordinates were typed by hand from a general sense of where San Francisco's
|
||||
* neighbourhoods are; none of them is anybody's address, and none of them came
|
||||
* out of a geocoder.
|
||||
*
|
||||
* That last point is the licence rule and not a stylistic preference. No real
|
||||
* company data ships in this repo, for two reasons that happen to want the same
|
||||
* thing. The privacy one is obvious: pipeline status — who is talking to whom,
|
||||
* who said no — is private, and a public repo is the wrong place for it. The
|
||||
* licence one is sharper and is the subject of ARCHITECTURE.md §3.2 and
|
||||
* CONTRACT.md §8: real positions are *geocoded*, and a geocoder built on
|
||||
* OpenStreetMap returns ODbL data. Serving a snapshot of those coordinates is
|
||||
* Publicly Using a Derivative Database, which drags share-alike onto everything
|
||||
* served alongside it, whether or not the rows live in the repo. So real markers
|
||||
* arrive at runtime over the API — see `http.ts` — carrying a provenance field
|
||||
* the server checks, and the repo itself ships this: fiction, which owes nobody
|
||||
* anything.
|
||||
*
|
||||
* The demo is worth having anyway. A map with no pins on it teaches nobody what
|
||||
* the thing is for, and half the interesting behaviour in `markers.ts` — the
|
||||
* ghost treatment for a marker that has no real position yet — is invisible
|
||||
* without data that exercises it.
|
||||
*/
|
||||
|
||||
import type { SimRoute } from "../engine/flights.ts";
|
||||
import type { Marker, MarkerPalette } from "../engine/types.ts";
|
||||
|
||||
/**
|
||||
* A small pipeline, as colours.
|
||||
*
|
||||
* Five states is about the fewest that still shows why `colorKey` is opaque to
|
||||
* the engine: none of these words means anything to `markers.ts`, which looks
|
||||
* the key up here and draws whatever it finds. A public sector map would supply
|
||||
* an entirely different set against the same renderer.
|
||||
*/
|
||||
export const SAMPLE_PALETTE: MarkerPalette = {
|
||||
watching: 0x7f8b99,
|
||||
applied: 0x4f9cf2,
|
||||
talking: 0x3fbf9a,
|
||||
offer: 0xf2b134,
|
||||
closed: 0xd2544f,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the unplaced markers float: out over the bay, east of the Ferry
|
||||
* Building, in a short arc.
|
||||
*
|
||||
* A marker with `located: false` has no address yet, and the honest thing to do
|
||||
* with a position you do not have is to not pretend you have one. Open water is
|
||||
* the clearest way to say that on a map — nothing is there, the pin visibly
|
||||
* stands on nothing, and it cannot be misread as a building. `markers.ts` gives
|
||||
* these a different silhouette and a lower opacity as well, so the tell does not
|
||||
* rest on placement alone.
|
||||
*/
|
||||
const UNPLACED_LNG = -122.3665;
|
||||
|
||||
/**
|
||||
* Twenty-two invented companies across San Francisco.
|
||||
*
|
||||
* Placed by neighbourhood rather than by street: Hayes Valley and SoMa are
|
||||
* crowded because that is the fact the map is usually drawing attention to, the
|
||||
* Bayview and the Richmond have one each, and three have no position at all.
|
||||
* The clustering is the point — a marker layer that only ever gets evenly
|
||||
* scattered test data hides every overlap problem it has.
|
||||
*/
|
||||
export const SAMPLE_MARKERS: Marker[] = [
|
||||
// Hayes Valley and the bowl below Buena Vista.
|
||||
{
|
||||
id: "sample-thimbleway",
|
||||
label: "Thimbleway Systems",
|
||||
colorKey: "talking",
|
||||
lat: 37.7768,
|
||||
lng: -122.4243,
|
||||
located: true,
|
||||
blurb: "Invented. Model-serving, allegedly, in a Victorian with bad wiring.",
|
||||
},
|
||||
{
|
||||
id: "sample-nonsuch",
|
||||
label: "Nonsuch Cartography Co.",
|
||||
colorKey: "applied",
|
||||
lat: 37.7752,
|
||||
lng: -122.4262,
|
||||
located: true,
|
||||
blurb: "Invented. Maps of places that are not there.",
|
||||
},
|
||||
{
|
||||
id: "sample-marmalade",
|
||||
label: "Marmalade Interchange",
|
||||
colorKey: "watching",
|
||||
lat: 37.7781,
|
||||
lng: -122.4218,
|
||||
located: true,
|
||||
blurb: "Invented. Moves data between two formats nobody uses.",
|
||||
},
|
||||
|
||||
// SoMa, where the grid turns forty-six degrees.
|
||||
{
|
||||
id: "sample-kettle-anvil",
|
||||
label: "Kettle & Anvil Compute",
|
||||
colorKey: "offer",
|
||||
lat: 37.7805,
|
||||
lng: -122.4051,
|
||||
located: true,
|
||||
blurb: "Invented. Sells the shovels, or claims to.",
|
||||
},
|
||||
{
|
||||
id: "sample-ninth-pelican",
|
||||
label: "Ninth Pelican Labs",
|
||||
colorKey: "applied",
|
||||
lat: 37.7784,
|
||||
lng: -122.4009,
|
||||
located: true,
|
||||
blurb: "Invented. There were never eight others.",
|
||||
},
|
||||
{
|
||||
id: "sample-brassbound",
|
||||
label: "Brassbound Telemetry",
|
||||
colorKey: "closed",
|
||||
lat: 37.7822,
|
||||
lng: -122.4074,
|
||||
located: true,
|
||||
blurb: "Invented. Went quiet after the second call.",
|
||||
},
|
||||
|
||||
// The Financial District and Jackson Square.
|
||||
{
|
||||
id: "sample-grimsby-doone",
|
||||
label: "Grimsby & Doone Photonics",
|
||||
colorKey: "watching",
|
||||
lat: 37.7941,
|
||||
lng: -122.4008,
|
||||
located: true,
|
||||
blurb: "Invented. Two surnames and a laser.",
|
||||
},
|
||||
{
|
||||
id: "sample-tugboat",
|
||||
label: "Tugboat Actuarial",
|
||||
colorKey: "applied",
|
||||
lat: 37.7958,
|
||||
lng: -122.4032,
|
||||
located: true,
|
||||
blurb: "Invented. Insurance for things that have already happened.",
|
||||
},
|
||||
|
||||
// Mission Bay: landfill, then biotech.
|
||||
{
|
||||
id: "sample-fogbank",
|
||||
label: "Fogbank Freight",
|
||||
colorKey: "talking",
|
||||
lat: 37.7709,
|
||||
lng: -122.3918,
|
||||
located: true,
|
||||
blurb: "Invented. Logistics, in a building younger than most of the staff.",
|
||||
},
|
||||
{
|
||||
id: "sample-bittern",
|
||||
label: "Bittern & Sons Biologics",
|
||||
colorKey: "watching",
|
||||
lat: 37.7688,
|
||||
lng: -122.3894,
|
||||
located: true,
|
||||
blurb: "Invented. No sons.",
|
||||
},
|
||||
|
||||
// The Mission, flat and sunny.
|
||||
{
|
||||
id: "sample-unlikely-weather",
|
||||
label: "Bureau of Unlikely Weather",
|
||||
colorKey: "offer",
|
||||
lat: 37.7602,
|
||||
lng: -122.4151,
|
||||
located: true,
|
||||
blurb: "Invented. Forecasts nobody asked for.",
|
||||
},
|
||||
{
|
||||
id: "sample-perpetual-bagel",
|
||||
label: "Perpetual Bagel Works",
|
||||
colorKey: "closed",
|
||||
lat: 37.7574,
|
||||
lng: -122.4192,
|
||||
located: true,
|
||||
blurb: "Invented. The name was the whole pitch.",
|
||||
},
|
||||
|
||||
// Potrero Hill and Dogpatch, the old industrial edge.
|
||||
{
|
||||
id: "sample-wobbegong",
|
||||
label: "Wobbegong Robotics",
|
||||
colorKey: "talking",
|
||||
lat: 37.7589,
|
||||
lng: -122.4002,
|
||||
located: true,
|
||||
blurb: "Invented. Named after a carpet shark, for reasons never explained.",
|
||||
},
|
||||
{
|
||||
id: "sample-sourdough-semi",
|
||||
label: "Sourdough Semiconductor",
|
||||
colorKey: "applied",
|
||||
lat: 37.7597,
|
||||
lng: -122.3881,
|
||||
located: true,
|
||||
blurb: "Invented. A fab in a city with no fabs.",
|
||||
},
|
||||
{
|
||||
id: "sample-pennyfarthing",
|
||||
label: "Pennyfarthing Power",
|
||||
colorKey: "watching",
|
||||
lat: 37.7564,
|
||||
lng: -122.3973,
|
||||
located: true,
|
||||
blurb: "Invented. Batteries, uphill.",
|
||||
},
|
||||
|
||||
// The north side and the hills.
|
||||
{
|
||||
id: "sample-lamplighter",
|
||||
label: "Lamplighter Aerostatics",
|
||||
colorKey: "watching",
|
||||
lat: 37.8004,
|
||||
lng: -122.4086,
|
||||
located: true,
|
||||
blurb: "Invented. Airships, which are always about to come back.",
|
||||
},
|
||||
{
|
||||
id: "sample-halfpenny",
|
||||
label: "Halfpenny Optics",
|
||||
colorKey: "applied",
|
||||
lat: 37.7929,
|
||||
lng: -122.4147,
|
||||
located: true,
|
||||
blurb: "Invented. Lenses, four hundred feet above the water they look at.",
|
||||
},
|
||||
|
||||
// One each in the parts of the city the map usually forgets.
|
||||
{
|
||||
id: "sample-cormorant",
|
||||
label: "Cormorant Freight Systems",
|
||||
colorKey: "talking",
|
||||
lat: 37.7357,
|
||||
lng: -122.3908,
|
||||
located: true,
|
||||
blurb: "Invented. The only pin south of Islais Creek, which is the point.",
|
||||
},
|
||||
{
|
||||
id: "sample-tidewrack",
|
||||
label: "Tidewrack Instruments",
|
||||
colorKey: "watching",
|
||||
lat: 37.7802,
|
||||
lng: -122.4638,
|
||||
located: true,
|
||||
blurb: "Invented. Sensors, in the fog, on purpose.",
|
||||
},
|
||||
|
||||
// Three with no position yet. See `UNPLACED_LNG`.
|
||||
{
|
||||
id: "sample-quibble",
|
||||
label: "Quibble Quantum",
|
||||
colorKey: "applied",
|
||||
lat: 37.7965,
|
||||
lng: UNPLACED_LNG,
|
||||
located: false,
|
||||
blurb: "Invented, and unplaced: no address on file, so the map does not invent one.",
|
||||
},
|
||||
{
|
||||
id: "sample-antelope-foundry",
|
||||
label: "Antelope Foundry",
|
||||
colorKey: "watching",
|
||||
lat: 37.7905,
|
||||
lng: UNPLACED_LNG,
|
||||
located: false,
|
||||
blurb: "Invented, and unplaced.",
|
||||
},
|
||||
{
|
||||
id: "sample-mudlark",
|
||||
label: "Mudlark Instruments",
|
||||
colorKey: "closed",
|
||||
lat: 37.7845,
|
||||
lng: UNPLACED_LNG,
|
||||
located: false,
|
||||
blurb: "Invented, and unplaced.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sample traffic, for when the API is not there to send a flight plan.
|
||||
*
|
||||
* The corridors are roughly the real ones — arrivals down the peninsula from
|
||||
* the north, departures turning out over the Pacific, a slow light aircraft
|
||||
* crossing the bay — because that is what makes the sky read as this city's sky
|
||||
* rather than as random motion. The callsigns are not: no real operator uses
|
||||
* these prefixes, which keeps a demo from looking like a feed of actual
|
||||
* traffic. Nothing here is observed, and `flights.ts` explains at length why
|
||||
* this project ships a simulator instead of a client for somebody's live data.
|
||||
*/
|
||||
export const SAMPLE_ROUTES: SimRoute[] = [
|
||||
{ callsign: "NIMBUS 4", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 },
|
||||
{ callsign: "NIMBUS 17", from: [37.93, -122.31], to: [37.65, -122.38], fromAlt: 2100, toAlt: 450, duration: 210 },
|
||||
{ callsign: "PELICAN 2", from: [37.64, -122.39], to: [37.9, -122.62], fromAlt: 700, toAlt: 5200, duration: 165 },
|
||||
{ callsign: "PELICAN 31", from: [37.7, -122.21], to: [37.88, -122.55], fromAlt: 1800, toAlt: 6100, duration: 230 },
|
||||
{ callsign: "CORMORANT 8", from: [37.62, -122.6], to: [37.95, -122.28], fromAlt: 6800, toAlt: 8200, duration: 260 },
|
||||
{ callsign: "KESTREL 5", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 },
|
||||
{ callsign: "NIMBUS 40", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 },
|
||||
];
|
||||
Reference in New Issue
Block a user