Nothing was wrong with the ordering. What had gone wrong is that eight
items were done or superseded in one place and still open in another,
which reads as live work — worse than no entry at all. Found by
reviewing the file against the Gitea tracker and the git log, not by
reading it.
NEXT WAS 43 CHRONOLOGICAL ENTRIES, 26 OF THEM STRUCK THROUGH, and the
live work had stopped being visible in it. It is grouped by what each
item is WAITING ON now — blocked on a table, the screen, waiting on
something outside the code, unscheduled, standing practice — with the
closed entries moved to a Settled block at the end of the section.
EVERY NUMBER IS KEPT AS A STABLE ID. The rest of the file refers to
items by number ("decide with item 15", "same drawing pass as 19-21"),
so renumbering would have silently broken every cross-reference.
Numbers are ids, not positions, and the file now says so. The one
reference that pointed at a settled item (item 22, the dead centre of
the Division map) is rewritten to name the principle that outlived it
rather than the item.
EIGHT STALE ITEMS CLOSED. The Heavy Grade orientation was fixed in
cf018b4 and never checked off. The Fedora is TODO #29, done in this
same unshipped v0.7.9. The remaining six were the Division-map drawing
pass, all answered by Gitea#18 (closed 2026-08-26) replacing the layout
rather than fixing the drawing — 167 lines describing a wrapped map
that no longer exists, collapsed to 32 that keep the two ideas which
outlived their items: "SHARED in the middle, YOURS on the right", now
Gitea#20's territory, and why rotating the map to seat a viewer at the
bottom was refused (it moves the open gap between the buffer stops out
of the eye's path, and that gap is the only thing saying the Division
is a line and not a loop).
65 open items to 57; 2,400 lines to 2,179.
TWO PIECES OF DEAD CODE GITEA#18 LEFT BEHIND, found while confirming
the map really is one row before closing the items that say it is.
SIDE_GAP was declared and never read. And divisionSvg's header comment
still described "one row alone, two rows facing, a horseshoe of three,
a square of four" — contradicting the layout comment 200 lines below
it, which explains why that layout was dropped. A comment that
survives the code it describes is how the next reader gets it wrong.
No behaviour change. 878 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
1326 lines
72 KiB
TypeScript
1326 lines
72 KiB
TypeScript
/**
|
||
* Drawing the board.
|
||
*
|
||
* Two renderers, chosen because they fail in opposite places (docs/design/board-layout-studies.html):
|
||
*
|
||
* - `officeSvg` — the district stays a MAP. Cards on a grid with the rails actually drawn, so a
|
||
* join is rail meeting rail rather than two descriptions that happen to agree.
|
||
* - `divisionSvg`— the Division is not a map, it is a queue of sections with hard capacities, so
|
||
* it is drawn as a dispatcher's diagram: one line per track, count them to know
|
||
* what fits.
|
||
*
|
||
* SELF-CONTAINED ON PURPOSE. Both functions reference nothing outside their own bodies — no imports,
|
||
* no module-level helpers. The playable app imports them normally; the replay is a single HTML file
|
||
* with an inline script and cannot import anything, so it embeds these via `Function.toString()`.
|
||
* That keeps ONE implementation: a second copy would eventually draw a different board from the same
|
||
* state, which is the exact failure a track diagram exists to prevent.
|
||
*
|
||
* Rails come from `CellView.links`, which is the engine's own `connectionsFor`. A drawn rail can
|
||
* therefore never claim a connection the rules do not have.
|
||
*/
|
||
|
||
import type { CellView, DivisionView } from './view.ts';
|
||
|
||
/** Anything the renderers need to know about a train standing somewhere. */
|
||
export type BoardTrain = { label: string; consist: string[] };
|
||
|
||
/**
|
||
* The Division as a dispatcher would see it: one continuous line per running track, sections
|
||
* separated by thin seams, capacity legible because the lines can be counted.
|
||
*/
|
||
/**
|
||
* Who is at the table, so an Office can be labelled with its owner rather than only its tier.
|
||
*
|
||
* Passed in rather than read off the nodes because a `DivisionView` knows its seat and nothing
|
||
* about people — the roster lives on the `Frame`, keyed by player, and `seat` is what joins them.
|
||
* Optional so the standalone replay (`replay.ts`, which serialises this function by `toString()`)
|
||
* keeps working unchanged.
|
||
*/
|
||
export type DivisionRoster = {
|
||
players: { index: number; seat: number; name: string }[];
|
||
/** The player whose move it is, or null in an automatic phase. A PLAYER index, not a seat. */
|
||
actor: number | null;
|
||
/** The player this map is being drawn for. */
|
||
viewer: number;
|
||
};
|
||
|
||
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
|
||
/**
|
||
* THE WHOLE DIVISION, west to east, as one continuous route.
|
||
*
|
||
* It used to draw one box per node, which collapsed every player's district into a single
|
||
* "Office" tile — so the thing a train actually runs along, the Running Track between the Limits,
|
||
* was invisible on the only map that shows where trains are. Now an Office expands into its
|
||
* Running Track, Limits to Limits, and the route reads end to end:
|
||
*
|
||
* West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
||
*
|
||
* ONE ROW AT EVERY SEAT COUNT (Gitea#18). It used to be laid out the way players sit — two rows
|
||
* facing, a horseshoe of three, a square of four — and the reasoning for dropping that is at the
|
||
* layout itself below. The Division is a LINE and not a loop — trains enter at one Division Point
|
||
* and leave at the other — so the row is deliberately left open, with the two ends drawn as
|
||
* buffer stops facing outward. Closing it into a ring would promise a connection the rules do
|
||
* not have.
|
||
*
|
||
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||
* anything outside its own body.
|
||
*/
|
||
const CW = { dp: 118, ml: 152, run: 78 };
|
||
/**
|
||
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
|
||
* Was 58, when a cell held one row of trains.
|
||
*/
|
||
const CH = 76;
|
||
/**
|
||
* EVERY DISTRICT THE SAME WIDTH, sized for four chips two-by-two and NOT for its A/D count.
|
||
*
|
||
* Measured over 60 games: one office area holds at most 4 distinct trains, and up to 3 of those
|
||
* can be crews switching below the Running Track — which do not occupy A/D tracks at all. So a
|
||
* Whistle Post, with its single A/D track, can still have four trains to show, and sizing the cell
|
||
* by capacity would overflow it. Sizing by OCCUPANCY is worse still: that is what "The Roster
|
||
* Pass" fixed, because the cell then resizes as trains come and go and shoves the rest of the map
|
||
* sideways. A fixed two-by-two block holds the map still all game, upgrades included.
|
||
*/
|
||
const OFFICE_W = 2 * 54 + 12;
|
||
/**
|
||
* THE VERTICAL ANATOMY OF A CELL, so the two chip registers and the rail cannot drift apart.
|
||
* The rail sits above centre; A/D chips straddle it, and the district register hangs below —
|
||
* which is where those trains are on the real board (Gitea#18).
|
||
*/
|
||
const RAIL_Y = 34;
|
||
const CHIP_Y = RAIL_Y - 10;
|
||
const BELOW_Y = RAIL_Y + 13;
|
||
const GAP = 6;
|
||
/**
|
||
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
|
||
* them without spilling onto its neighbours (docs/plans/switching-paths.md — "The Roster Pass").
|
||
* Sized by CAPACITY, not by how many are occupied right now: a cell drawn for the trains it HAS
|
||
* holds still as they come and go, where sizing by occupancy moved the East Division Point (and
|
||
* everything past it) sideways every time an A/D track filled or cleared.
|
||
*/
|
||
const CHIP_W = 54;
|
||
/**
|
||
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
|
||
*
|
||
* They used to hang off the ends — "west end · in and out" anchored outside the first cell — which
|
||
* needed about 126px of padding to fit and had 92, so both ends were clipped mid-word. Widening
|
||
* the padding to fit them would have spent that space on two captions instead of on the map.
|
||
* They now sit UNDER their own Division Point, where they are neither clipped nor in the way, and
|
||
* this is back to what a buffer stop actually needs.
|
||
*/
|
||
const PAD = 22;
|
||
|
||
const esc = (t: string): string =>
|
||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||
|
||
// -- flatten the route into drawable cells, remembering which belong to whose district --------
|
||
type Cell = {
|
||
kind: 'dp' | 'ml' | 'run';
|
||
label: string;
|
||
sub: string;
|
||
trains: {
|
||
label: string;
|
||
consist: string[];
|
||
/** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */
|
||
cars?: string[];
|
||
engineAt?: number;
|
||
/** East or west, always — a Division runs east and west and so does its rolling stock. */
|
||
facing?: 'e' | 'w';
|
||
/** The train's printed card, so a train out on the Mainline can still be asked what it is. */
|
||
what?: string;
|
||
region?: number;
|
||
direction?: string;
|
||
stagesLeft?: number;
|
||
}[];
|
||
cap: number | null;
|
||
tip: string;
|
||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||
seat: number | null;
|
||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||
owner?: { name: string; isTurn: boolean; isYou: boolean } | null;
|
||
/**
|
||
* Office cells only: trains in the district that are NOT holding an A/D track — a crew switching
|
||
* below the Running Track, or a train standing on it away from the Office. Drawn in a second
|
||
* register under the rail (Gitea#18).
|
||
*/
|
||
below?: Cell['trains'];
|
||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||
regions: number;
|
||
/**
|
||
* Which way a Heavy Grade climbs, or null on every other card. Drawn as a wedge, because the
|
||
* tooltip said "climbs east" and the card itself showed nothing — so the one card whose
|
||
* orientation the PLAYER chooses was the one card you had to hover to read (Jesse, 2026-08-30).
|
||
*/
|
||
gradeUp?: string | null;
|
||
w: number;
|
||
x: number;
|
||
y: number;
|
||
};
|
||
const cells: Cell[] = [];
|
||
|
||
const push = (c: Omit<Cell, 'x' | 'y'>): void => {
|
||
cells.push({ ...c, x: 0, y: 0 });
|
||
};
|
||
|
||
for (const n of nodes) {
|
||
if (n.kind === 'office') {
|
||
const cap = n.capacity;
|
||
const ad = n.trains.flat();
|
||
/**
|
||
* THE NAME IS THE HEADLINE, the tier is the detail.
|
||
*
|
||
* "Where does Bob sit?" is the question this map could not answer: an Office was labelled
|
||
* with its tier, which every player's Office also has, so four districts read the same. The
|
||
* owner's name takes the headline and the tier moves down beside the A/D count, because the
|
||
* name is what is being looked for and the tier is what is being referred to once found.
|
||
*/
|
||
const seatOwner =
|
||
roster && n.seat !== null ? (roster.players.find((p) => p.seat === n.seat) ?? null) : null;
|
||
const owner = seatOwner
|
||
? {
|
||
name: seatOwner.name,
|
||
isTurn: roster!.actor === seatOwner.index,
|
||
isYou: roster!.viewer === seatOwner.index,
|
||
}
|
||
: null;
|
||
|
||
/**
|
||
* ONE CELL PER DISTRICT — NO OFFICE-AREA DETAIL ON THIS MAP (Gitea#18).
|
||
*
|
||
* An Office used to expand into its whole Running Track, Limits to Limits, so this map carried
|
||
* every straight, turnout, facility and Limits sign of every district. Two things were wrong
|
||
* with that. It is the OFFICE map's job, and it draws all of it properly, with the rails; and
|
||
* it made the Division map grow sideways as districts were built, shoving everything east of a
|
||
* district along every time somebody laid a card.
|
||
*
|
||
* TRAINS STAY. "Trains within the office area should definitely be represented on the division
|
||
* map" — at a glance the number and which way it is pointing, and the consist on the tooltip.
|
||
* They are split into two registers, because a train holding an A/D track and a crew switching
|
||
* in the district are not the same thing: A/D occupancy is a hard capacity that causes
|
||
* collisions, switching is not. The split is drawn as POSITION rather than colour — A/D on the
|
||
* rail, the rest below it — which is where those trains actually are.
|
||
*/
|
||
const seen = new Set(ad.map((t) => t.label));
|
||
const below: typeof ad = [];
|
||
for (const t of [...(n.running ?? []).flatMap((rc) => rc.trains), ...(n.switching ?? [])]) {
|
||
if (seen.has(t.label)) continue;
|
||
seen.add(t.label);
|
||
below.push(t);
|
||
}
|
||
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
|
||
push({
|
||
kind: 'run',
|
||
label: owner ? owner.name : n.label,
|
||
owner,
|
||
sub: [owner ? n.label : '', adLabel, below.length > 0 ? `${below.length} switching` : '']
|
||
.filter(Boolean)
|
||
.join(' \u00b7 '),
|
||
trains: ad,
|
||
below,
|
||
cap,
|
||
tip:
|
||
(owner ? `${owner.name}'s ${n.label}` : n.label) +
|
||
(owner?.isYou ? ' — this is your railroad' : '') +
|
||
// "their move" is wrong when the reader is the one being waited on.
|
||
(owner?.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') +
|
||
`\n\nThe district itself is drawn on the Office map — this cell is the whole of it, with the ` +
|
||
`trains standing in it: those holding an A/D track on the rail, and any crew switching in ` +
|
||
`the district below it.`,
|
||
seat: n.seat ?? null,
|
||
// No regions in a district: a crew moves by Moves there, not by Stages, so it occupies a
|
||
// card outright rather than a part of one.
|
||
regions: 0,
|
||
w: OFFICE_W,
|
||
});
|
||
continue;
|
||
}
|
||
const dp = n.kind === 'dp';
|
||
/**
|
||
* A train in the Interchange's yard is drawn on the card but counted against nothing.
|
||
*
|
||
* It is not on the running line — that is the whole distinction §7 rests on — so it cannot take
|
||
* the card's capacity. It still has to be SEEN: an Extra made up here would otherwise be a train
|
||
* the player just placed that appears nowhere on the map.
|
||
*/
|
||
const inYard = n.yard ?? [];
|
||
const onRoad = n.trains.flat();
|
||
const free = n.capacity === null ? '' : `${Math.max(0, n.capacity - onRoad.length)} of ${n.capacity} free`;
|
||
push({
|
||
kind: dp ? 'dp' : 'ml',
|
||
label: n.label,
|
||
sub: n.capacity === null
|
||
? 'no limit — trains queue'
|
||
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
|
||
trains: [...onRoad, ...inYard],
|
||
cap: n.capacity,
|
||
tip: dp
|
||
? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit'
|
||
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}` +
|
||
(inYard.length > 0
|
||
? `\n\n${inYard.length} train${inYard.length === 1 ? '' : 's'} standing in the yard, not on the running line — waiting to highball onto this card`
|
||
: '') +
|
||
// What the card actually DOES. The name alone left Hilly and Uncontrolled Siding as
|
||
// words with no gameplay attached — reported exactly that way.
|
||
(n.what ? `\n\n${n.what}` : ''),
|
||
seat: null,
|
||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||
regions: dp ? 1 : (n.regions ?? 0),
|
||
gradeUp: dp ? null : (n.gradeUp ?? null),
|
||
w: dp ? CW.dp : CW.ml,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* ONE ROW, WEST TO EAST (Gitea#18). The West Division Point is at the far left, the East at the
|
||
* far right, and nothing wraps.
|
||
*
|
||
* IT USED TO BE LAID OUT AROUND A TABLE — one row for a single seat, two facing rows for two, a
|
||
* horseshoe for three, a square for four — on the reasoning that players sit around a table so the
|
||
* route should too. That cost more than it bought, and three separate reports came out of it: the
|
||
* buffer stops pointed the wrong way once the route turned a corner, and, the one that decided it,
|
||
* **east stopped being to the right**. A player's east could be drawn south, west or north
|
||
* depending on which lane their district landed in, on a map whose whole job is saying which way
|
||
* a train is going.
|
||
*
|
||
* A row is wider than a square — roughly 1,580px at four players against 842 — and that is
|
||
* accepted: the map scrolls and zooms, and being able to rely on east meaning right is worth the
|
||
* scroll.
|
||
*/
|
||
let x = PAD;
|
||
for (const c of cells) {
|
||
c.x = x;
|
||
c.y = PAD;
|
||
x += c.w + GAP;
|
||
}
|
||
const boardW = x - GAP + PAD;
|
||
const boardH = PAD * 2 + CH + 30;
|
||
|
||
// -- draw -------------------------------------------------------------------------------------
|
||
const rail = (x1: number, y: number, x2: number): string => {
|
||
let o =
|
||
`<line class="bs-rail" x1="${x1}" y1="${y - 2.5}" x2="${x2}" y2="${y - 2.5}"/>` +
|
||
`<line class="bs-rail" x1="${x1}" y1="${y + 2.5}" x2="${x2}" y2="${y + 2.5}"/>`;
|
||
const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9));
|
||
for (let i = 0; i <= n; i++) {
|
||
const tx = x1 + ((x2 - x1) * i) / n;
|
||
o += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
|
||
}
|
||
return o;
|
||
};
|
||
|
||
/**
|
||
* DRAWN AT ITS OWN SIZE, SO IT SCROLLS RATHER THAN SHRINKING (Gitea#18).
|
||
*
|
||
* An SVG has a viewBox and a drawn size, and the browser scales one to the other. `.bs` is
|
||
* `width:100%`, so the map is drawn at whatever the panel is wide — which was harmless while the
|
||
* Division was 842px and wrapped around a table, and is not now that a single row is 1,580px. At
|
||
* that width in an 800px panel every label renders at half size, on the map that needs reading
|
||
* most. Setting the width to the viewBox width makes one unit one pixel, and the containers
|
||
* already scroll (`#division`, `#vdivision`).
|
||
*
|
||
* THE PLAYABLE PAGE DOES NOT NEED THIS — `applyZoom` (`main.ts`) sets exactly the same width from
|
||
* the same viewBox after every render, and overrides this when the zoom is not 100%. THE REPLAYS
|
||
* DO: neither `replays.ts` nor the standalone `replay.ts` calls it, so without this they get the
|
||
* `width:100%` shrink. It is inline rather than in `BOARD_CSS` because only this function knows
|
||
* how wide the row came out.
|
||
*
|
||
* `flex:none` because `#division` is a flex container and a flex item may be shrunk below an
|
||
* explicit width; there is no point pinning it and then letting the panel squeeze it anyway.
|
||
*/
|
||
let out =
|
||
`<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" ` +
|
||
`style="width:${Math.ceil(boardW)}px;flex:none" ` +
|
||
`preserveAspectRatio="xMinYMin meet">`;
|
||
|
||
// The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join
|
||
// that crosses from one player's side to the next is drawn heavier and labelled: that boundary is
|
||
// the Mainline card two districts share, and it is the thing a table needs to see.
|
||
for (let i = 0; i + 1 < cells.length; i++) {
|
||
const a = cells[i]!;
|
||
const b = cells[i + 1]!;
|
||
out += rail(a.x + a.w, a.y + RAIL_Y, b.x);
|
||
}
|
||
|
||
cells.forEach((c) => {
|
||
const full = c.cap !== null && c.trains.length >= c.cap;
|
||
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
|
||
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
|
||
/**
|
||
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
|
||
* than by a legend. Amber is the same "it is happening here" the action panel uses; "(you)"
|
||
* is spelled out because a colour alone cannot say which of four railroads is the reader's,
|
||
* and that is the first thing anybody wants to know at a table they just sat down at.
|
||
*/
|
||
const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : '';
|
||
const suffix = c.owner?.isYou ? ' (you)' : '';
|
||
out += `<text class="bs-name${mark}" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label + suffix)}</text>`;
|
||
out += rail(c.x + 6, c.y + RAIL_Y, c.x + c.w - 6);
|
||
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
|
||
|
||
// REGIONS. A Mainline card is 1 to 3 of them (Gitea#3) and a train advances one per Stage. The
|
||
// bars are the card's DISTANCE and never vary; where a train STARTS is what does.
|
||
const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0;
|
||
for (let r = 0; r < c.regions; r++) {
|
||
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + RAIL_Y - 14}" x2="${c.x + 6 + RW * r}" y2="${c.y + RAIL_Y + 10}"/>`;
|
||
}
|
||
|
||
/**
|
||
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
|
||
*
|
||
* The tooltip has said "climbs east" since the Frame carried `gradeUp`, and the card showed
|
||
* nothing — so the one Mainline card whose orientation the PLAYER sets, and the one where a
|
||
* Helpers or Brakeman modifier means opposite things at opposite ends, was the one you had to
|
||
* hover to read (Jesse, 2026-08-30).
|
||
*
|
||
* A wedge rising toward the climb, with an arrow up its slope. Two cues rather than one: the
|
||
* wedge alone asks the reader to judge which end is taller, which at eleven pixels of rise is a
|
||
* comparison rather than a glance. Bottom-right, clear of the left-aligned capacity line and
|
||
* below the region bars, so it never lands under a train chip.
|
||
*
|
||
* `east` is RIGHT on this map and always has been (Gitea#18) — that is what makes a wedge
|
||
* readable without a compass, and it is why the row layout is worth its width.
|
||
*/
|
||
if (c.gradeUp === 'east' || c.gradeUp === 'west') {
|
||
const s = c.gradeUp === 'east' ? 1 : -1;
|
||
const GW = 58;
|
||
const RISE = 24;
|
||
const gx1 = c.x + c.w - 9 - GW;
|
||
const gx2 = c.x + c.w - 9;
|
||
const yb = c.y + CH - 6;
|
||
const peakX = s === 1 ? gx2 : gx1;
|
||
out += `<polygon class="bs-grade" points="${gx1},${yb} ${gx2},${yb} ${peakX},${yb - RISE}"/>`;
|
||
|
||
/**
|
||
* THE ARROW LIES ALONG THE WEDGE'S OWN SLOPE, CENTRED IN IT (Jesse, 2026-08-30).
|
||
*
|
||
* Parallel to the hypotenuse is the shape that fits: the perpendicular gap to the slope is
|
||
* then CONSTANT along the whole arrow, instead of closing at one end the way a steeper line
|
||
* does. The earlier 30° pass had to be tucked into the fat half to survive, because 30° is
|
||
* steeper than this wedge climbs — at 58×24 the slope is 22.5°, and the arrow simply lies on
|
||
* it.
|
||
*
|
||
* Centred on the TRIANGLE'S CENTROID (2/3 along the base, 1/3 up), which is the balance point
|
||
* of the form rather than of its bounding box — centring on the box would push the arrow into
|
||
* the thin corner where there is no height for it.
|
||
*
|
||
* The wedge grew 50×22 → 58×24 to pay for that: the centroid sits only ~7px from the
|
||
* hypotenuse, so a centred arrow has less room than an off-centre one and needs a bigger form
|
||
* to keep it. Sizes are the best fit found by search, clearing every edge by 2.88px;
|
||
* `web.test.ts` re-derives it and fails under 2px.
|
||
*
|
||
* Worked in the wedge's own frame — `u` along the base from the thin corner, `h` up from it —
|
||
* so a westward climb is one sign on `u` rather than a second set of coordinates.
|
||
*/
|
||
const L = 20;
|
||
const HL = 6;
|
||
const HW = 3.5;
|
||
const T = 1.4;
|
||
const A = Math.atan2(RISE, GW);
|
||
const cos = Math.cos(A);
|
||
const sin = Math.sin(A);
|
||
const cu = (2 * GW) / 3;
|
||
const ch = RISE / 3;
|
||
const pt = (lx: number, ly: number): string => {
|
||
const u = cu + lx * cos - ly * sin;
|
||
const h = ch + lx * sin + ly * cos;
|
||
return `${s === 1 ? gx1 + u : gx2 - u},${yb - h}`;
|
||
};
|
||
const H = L / 2;
|
||
out +=
|
||
`<polygon class="bs-gradeup" points="${pt(-H, -T)} ${pt(H - HL, -T)} ${pt(H - HL, -HW)} ` +
|
||
`${pt(H, 0)} ${pt(H - HL, HW)} ${pt(H - HL, T)} ${pt(-H, T)}"/>`;
|
||
}
|
||
|
||
/**
|
||
* ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars.
|
||
*
|
||
* It was drawn as a full consist here, matching the Office Area card, and reported as too large
|
||
* and hard to read: a Mainline cell is 152px and a Running Track cell 78px, against a district
|
||
* card's 166px, and a four-car train plus its engine has to squeeze into a fifth of that. The
|
||
* Office card is where the consist is worth drawing, because that is where the switching
|
||
* decisions are made and where there is room to read it.
|
||
*
|
||
* So this keeps the two things the Division map is actually for — where a train is and which way
|
||
* it is going — and leaves the cars to the tooltip and to the district.
|
||
*/
|
||
/**
|
||
* A TRAIN IS A CHIP — its number, which way it points, and how many cars.
|
||
*
|
||
* It was drawn as a full consist here, matching the Office Area card, and reported as too large
|
||
* and hard to read. The Office card is where a consist is worth drawing, because that is where
|
||
* the switching decisions are made and where there is room to read it. So this keeps the two
|
||
* things the Division map is for — where a train is and which way it is going — and leaves the
|
||
* cars to the tooltip and to the district.
|
||
*/
|
||
const chip = (t: NonNullable<Cell['trains']>[number], tx: number, ty: number, w: number): void => {
|
||
const cars = t.cars ?? [];
|
||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
||
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||
const stages =
|
||
typeof t.stagesLeft === 'number'
|
||
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card`
|
||
: '';
|
||
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||
cars.length ? ` (${loaded} loaded)` : ''
|
||
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''}${esc(stages)}${
|
||
// What the card prints. A train on the Mainline is exactly where "why did that leave without
|
||
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
||
t.what ? `\n\n${esc(t.what)}` : ''
|
||
}">` +
|
||
`<rect x="${tx - w / 2}" y="${ty}" width="${w}" height="19" rx="3"/>` +
|
||
`<text class="bs-tlab" x="${tx}" y="${ty + 13}" text-anchor="middle">${esc(label)}</text>`;
|
||
out += '</g>';
|
||
};
|
||
|
||
const textW = (t: NonNullable<Cell['trains']>[number]): number =>
|
||
(`${t.label} \u25b6${(t.cars ?? []).length || ''}`).length * 6.6 + 12;
|
||
|
||
/**
|
||
* TWO CHIPS TO A REGISTER ON A DISTRICT, in fixed slots — never a centre spread.
|
||
*
|
||
* Centred spreading pushes its outer chips outward as more trains arrive, which is how two chips
|
||
* at a Station once landed outside the cell that held them. Fixed slots cannot overflow, because
|
||
* the cell was sized for exactly that many (`OFFICE_W`).
|
||
*/
|
||
const isDistrict = c.kind === 'run';
|
||
const SLOTS = 2;
|
||
const slotW = (c.w - 12) / SLOTS;
|
||
c.trains.forEach((t, k) => {
|
||
if (isDistrict) {
|
||
// Row-major within the A/D register: two across, then wrap under. A district can hold four
|
||
// trains and only two fit across it.
|
||
const col = k % SLOTS;
|
||
const row = Math.floor(k / SLOTS);
|
||
chip(t, c.x + 6 + slotW * (col + 0.5), c.y + CHIP_Y + row * 21, Math.min(slotW - 4, textW(t)));
|
||
return;
|
||
}
|
||
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||
const w = Math.min(c.w - 8, textW(t));
|
||
const tx = (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) +
|
||
(inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4));
|
||
chip(t, tx, c.y + CHIP_Y, w);
|
||
});
|
||
|
||
/**
|
||
* THE SECOND REGISTER, under the rail: trains in the district that hold no A/D track (Gitea#18).
|
||
*
|
||
* A crew switching below the Running Track and a train standing at an A/D track are different
|
||
* things — A/D occupancy is a hard capacity that causes collisions, switching is not — and the
|
||
* difference is drawn as POSITION rather than as a colour to learn, because below the rail is
|
||
* where those trains actually are.
|
||
*/
|
||
(c.below ?? []).forEach((t, k) => {
|
||
const col = k % SLOTS;
|
||
const row = Math.floor(k / SLOTS);
|
||
chip(t, c.x + 6 + slotW * (col + 0.5), c.y + BELOW_Y + row * 21, Math.min(slotW - 4, textW(t)));
|
||
});
|
||
|
||
out += '</g>';
|
||
});
|
||
|
||
// THE ENDS. The route stops at both Division Points, and the buffer stops say so — a Division is
|
||
// a LINE, not a loop. With a single row (Gitea#18) they simply face outward at the two ends, west
|
||
// on the left and east on the right, which is the bug reported twice against the wrapped layout.
|
||
const first = cells[0];
|
||
const last = cells[cells.length - 1];
|
||
/**
|
||
* UNDER THE DIVISION POINT, NOT BESIDE IT.
|
||
*
|
||
* Both labels used to hang off the outside of the end cells, anchored away from the board — which
|
||
* needed padding wider than the caption to survive, did not get it, and so printed clipped at both
|
||
* ends while spending the space anyway. Centred under their own cell they are neither cut off nor
|
||
* competing with the map for width.
|
||
*
|
||
* BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
|
||
* Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said the
|
||
* Division ran one way, which it does not — the buffer stops only mean it is a LINE, not a loop.
|
||
*/
|
||
if (first) {
|
||
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
|
||
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
|
||
out += `<text class="bs-end" x="${first.x + first.w / 2}" y="${first.y + CH + 13}" text-anchor="middle">west end · in and out</text>`;
|
||
}
|
||
if (last && last !== first) {
|
||
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
|
||
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
|
||
out += `<text class="bs-end" x="${last.x + last.w / 2}" y="${last.y + CH + 13}" text-anchor="middle">east end · in and out</text>`;
|
||
}
|
||
|
||
out += '</svg>';
|
||
return out;
|
||
}
|
||
/**
|
||
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
|
||
*
|
||
* THE PRINTED CARD, and nothing else (docs/tracks.png). The through rail runs east-west across the
|
||
* DEAD CENTRE of every card, so abutting cards produce one unbroken line and a gap is visibly a
|
||
* gap. Everything that leaves through the north or south edge does so at 45°, through the middle of
|
||
* that edge — which is what lets two stacked cards read as one continuous diagonal.
|
||
*
|
||
* The card is 166 x 96, the printed sheet's 259:150 proportions. The aspect is not decoration: the
|
||
* frog where a 45° leg meets the through track sits H/2 from the centre, so a squarer card pushes
|
||
* it almost to the edge and a turnout's two routes stop being tellable apart.
|
||
*/
|
||
export function officeSvg(
|
||
cells: CellView[],
|
||
runningRow: number,
|
||
ghosts: { row: number; col: number }[] = [],
|
||
/**
|
||
* Legal squares that already HAVE a card on them, each with what playing there would do.
|
||
*
|
||
* An empty legal square gets a whole ghost card saying "place here"; an occupied one used to get a
|
||
* blue outline and nothing else, so the two most important placements in the game — extending the
|
||
* Running Track at a Limits sign, and attaching an Enhancement to a card already down — were
|
||
* offered without a word. Reported as the Depot lighting up for no visible reason.
|
||
*/
|
||
legal: { row: number; col: number; label: string }[] = [],
|
||
/**
|
||
* The columns the Limits signs stand in, drawn as the district's east and west edges.
|
||
*
|
||
* Omitted by the previews and by the replay viewers' single-card renders, which have no district
|
||
* to bound. Where it is given, the boundary is drawn down the WHOLE canvas: track may not be laid
|
||
* outside these columns at any row (§2.1), and two signs sitting on one row could not say that —
|
||
* a player looking at open ground past a sign had no way to know nothing of his could ever go
|
||
* there until the square failed to light up.
|
||
*/
|
||
limits: { west: number; east: number } | null = null,
|
||
/**
|
||
* Which train's consist is drawn at the rail, on an Office card holding more than one — the value
|
||
* the "Which train are you switching?" picker already carries. Defaults to the first train on the
|
||
* card, so every call site that has never heard of this feature keeps working unchanged.
|
||
*/
|
||
selectedTrayId: string | null = null,
|
||
): string {
|
||
const W = 166;
|
||
const H = 96;
|
||
const PAD = 3;
|
||
const RAIL = H / 2;
|
||
|
||
const esc = (t: string): string =>
|
||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||
|
||
/**
|
||
* ONE COORDINATE SYSTEM, AND ONE CANVAS.
|
||
*
|
||
* Empty squares a card may be placed on used to be drawn by a separate `ghostSvg` and spliced in
|
||
* afterwards. It computed its own origin over cells PLUS spots while this function sized itself
|
||
* over cells alone, so the two disagreed the moment a legal square lay outside the played cards —
|
||
* and every legal square that extends the district does. Measured on seed 555: three placements
|
||
* offered, the one empty square drawn at y=99 on a canvas 103 tall, i.e. off it entirely. The
|
||
* list said three, the board showed two, and the third could not be clicked.
|
||
*
|
||
* Sizing over cells AND ghosts together is what makes that class of bug impossible.
|
||
*/
|
||
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...ghosts];
|
||
if (all.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
|
||
const rows = all.map((c) => c.row);
|
||
const cols = all.map((c) => c.col);
|
||
const r0 = Math.min(...rows);
|
||
const r1 = Math.max(...rows);
|
||
const c0 = Math.min(...cols);
|
||
const c1 = Math.max(...cols);
|
||
const width = (c1 - c0 + 1) * (W + PAD);
|
||
const height = (r1 - r0 + 1) * (H + PAD) + 4;
|
||
|
||
// Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest
|
||
// and the district hangs beneath it, as the rules describe it.
|
||
const px = (col: number): number => (col - c0) * (W + PAD);
|
||
const py = (row: number): number => (r1 - row) * (H + PAD);
|
||
|
||
const rail = (x1: number, y1: number, x2: number, y2: number): string => {
|
||
const dx = x2 - x1;
|
||
const dy = y2 - y1;
|
||
const len = Math.hypot(dx, dy);
|
||
const nx = (-dy / len) * 2.5;
|
||
const ny = (dx / len) * 2.5;
|
||
let out =
|
||
`<line class="bs-rail" x1="${x1 + nx}" y1="${y1 + ny}" x2="${x2 + nx}" y2="${y2 + ny}"/>` +
|
||
`<line class="bs-rail" x1="${x1 - nx}" y1="${y1 - ny}" x2="${x2 - nx}" y2="${y2 - ny}"/>`;
|
||
const n = Math.max(2, Math.floor(len / 9));
|
||
for (let i = 0; i <= n; i++) {
|
||
const tx = x1 + (dx * i) / n;
|
||
const ty = y1 + (dy * i) / n;
|
||
out += `<line class="bs-tie" x1="${tx + nx * 1.8}" y1="${ty + ny * 1.8}" x2="${tx - nx * 1.8}" y2="${ty - ny * 1.8}"/>`;
|
||
}
|
||
return out;
|
||
};
|
||
|
||
/**
|
||
* A 45° LEG, drawn as a smooth curve rather than two straight segments meeting at a hard corner.
|
||
*
|
||
* It is an EASEMENT, not an arbitrary curve: tangent to horizontal at `p0` (the east or west edge),
|
||
* so an abutting straight card's through-rail still reads as one unbroken line, and tangent to
|
||
* exactly 45° at `p3` (the north or south edge), so two stacked curves still read as one continuous
|
||
* diagonal and the edge-crossing angle the matching rule depends on is unchanged. `p1`/`p2` are
|
||
* cubic-Bezier control points chosen to hit those two tangents — see the call site.
|
||
*
|
||
* Sampled as a short polyline rather than left as one SVG path command, because the two parallel
|
||
* rails and the tie marks all need points evenly spaced ALONG THE CURVE with the local tangent at
|
||
* each one — `rail()`'s straight-line version does the same sampling, just on a line.
|
||
*/
|
||
const curvedRail = (
|
||
p0: { x: number; y: number },
|
||
p1: { x: number; y: number },
|
||
p2: { x: number; y: number },
|
||
p3: { x: number; y: number },
|
||
): string => {
|
||
const at = (t: number): { x: number; y: number } => {
|
||
const u = 1 - t;
|
||
return {
|
||
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
|
||
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
|
||
};
|
||
};
|
||
const tangentAt = (t: number): { x: number; y: number } => {
|
||
const u = 1 - t;
|
||
return {
|
||
x: 3 * u * u * (p1.x - p0.x) + 6 * u * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x),
|
||
y: 3 * u * u * (p1.y - p0.y) + 6 * u * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y),
|
||
};
|
||
};
|
||
const N = 24;
|
||
const left: string[] = [];
|
||
const right: string[] = [];
|
||
let ties = '';
|
||
for (let i = 0; i <= N; i++) {
|
||
const t = i / N;
|
||
const pt = at(t);
|
||
const tan = tangentAt(t);
|
||
const tlen = Math.hypot(tan.x, tan.y) || 1;
|
||
const nx = (-tan.y / tlen) * 2.5;
|
||
const ny = (tan.x / tlen) * 2.5;
|
||
left.push(`${pt.x + nx} ${pt.y + ny}`);
|
||
right.push(`${pt.x - nx} ${pt.y - ny}`);
|
||
// Every third sample — the same rough 9px spacing `rail()` uses for a straight run of similar
|
||
// length, not tied to `N` itself.
|
||
if (i % 3 === 0) {
|
||
ties += `<line class="bs-tie" x1="${pt.x + nx * 1.8}" y1="${pt.y + ny * 1.8}" x2="${pt.x - nx * 1.8}" y2="${pt.y - ny * 1.8}"/>`;
|
||
}
|
||
}
|
||
return (
|
||
`<path class="bs-rail" fill="none" d="M${left.join(' L')}"/>` +
|
||
`<path class="bs-rail" fill="none" d="M${right.join(' L')}"/>` +
|
||
ties
|
||
);
|
||
};
|
||
|
||
// Where each port meets the card edge. East and west sit at the rail height — the card's middle —
|
||
// so a straight run stays straight across the whole row; north and south are centred on the edge.
|
||
const port = (p: string): { x: number; y: number } => {
|
||
if (p === 'e') return { x: W, y: RAIL };
|
||
if (p === 'w') return { x: 0, y: RAIL };
|
||
if (p === 'n') return { x: W / 2, y: 0 };
|
||
return { x: W / 2, y: H };
|
||
};
|
||
|
||
/**
|
||
* ROOM FOR THE BOUNDARY. The Limits edge is drawn just outside the sign's own column, and the west
|
||
* sign is usually the leftmost card on the canvas — so at x = −1.5 the line lands outside a viewBox
|
||
* starting at 0 and is simply not there. The margin is added only when there is a boundary to draw.
|
||
*/
|
||
const margin = limits ? 3 : 0;
|
||
let out =
|
||
`<svg class="bs" viewBox="${-margin} 0 ${width + margin * 2} ${height}" ` +
|
||
`preserveAspectRatio="xMinYMin meet">`;
|
||
for (const cell of cells) {
|
||
const x = px(cell.col);
|
||
const y = py(cell.row);
|
||
const kind =
|
||
cell.kind === 'office'
|
||
? 'office'
|
||
: cell.kind === 'limits'
|
||
? 'lim'
|
||
: cell.kind === 'facility'
|
||
? 'fac'
|
||
: cell.kind === 'modifier'
|
||
? 'mod'
|
||
: 'trk';
|
||
|
||
// An Office card's roster band (below) takes the y-band the BOARDS + ALIGHTS flow label used to
|
||
// print in, so that explanation folds into the card's own tooltip instead of being drawn — it
|
||
// prints identically on every Office of every tier and is already implied by the card name and
|
||
// the Porter pill, so nothing is lost by reading it on the card rather than beside it.
|
||
const isOfficeRoster = cell.adTracks !== null;
|
||
const flowFold =
|
||
isOfficeRoster && cell.facility
|
||
? '\n\nBOARDS + ALIGHTS: passengers only — no freight is handled here. They wait in a green ' +
|
||
'box, are boarded onto a coach by Porters, and arrive into a red one.'
|
||
: '';
|
||
|
||
// Addressable so a caller can outline a square as a legal destination without re-rendering.
|
||
// The explanation rides on the group as a tooltip rather than being printed: it is reference
|
||
// detail, read once, and printing it costs the space the board itself needs.
|
||
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}${esc(flowFold)}" transform="translate(${x},${y})">`;
|
||
out += `<rect class="bs-card bs-${kind}${cell.running ? ' bs-run' : ''}" x="0" y="0" width="${W}" height="${H}" rx="4"/>`;
|
||
|
||
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
|
||
// routed through it, and the picture should say so.
|
||
for (const link of cell.links) {
|
||
const from = link[0] ?? 'e';
|
||
const to = link[1] ?? 'w';
|
||
const vertical = from === 'n' || from === 's' ? from : to === 'n' || to === 's' ? to : '';
|
||
if (vertical === '') {
|
||
// The through track: edge to edge along the middle of the card.
|
||
out += rail(port(from).x, port(from).y, port(to).x, port(to).y);
|
||
} else {
|
||
/**
|
||
* A 45° LEG — drawn as a smooth curve (v0.5.0) that still passes through the same FROG the
|
||
* old two-segment version bent at, so the underlying geometry (and the `frog` position used
|
||
* elsewhere for the actual joining/matching rules) is unchanged; only the picture is.
|
||
*
|
||
* The frog is `H/2` from the centre because a 45° run climbing half the card's height
|
||
* travels half its height sideways. The control points sit halfway from each endpoint to the
|
||
* frog, which is what gives the curve a horizontal tangent at the e/w edge (matching an
|
||
* abutting straight card's through-rail) and an exact 45° tangent at the n/s edge (matching
|
||
* the card above or below) — see `curvedRail`.
|
||
*/
|
||
const side = vertical === from ? to : from;
|
||
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
|
||
const start = port(side);
|
||
const edge = port(vertical);
|
||
const c1 = { x: start.x + 0.5 * (frog.x - start.x), y: start.y };
|
||
const c2 = { x: edge.x + 0.5 * (frog.x - edge.x), y: edge.y + 0.5 * (frog.y - edge.y) };
|
||
out += curvedRail(start, c1, c2, edge);
|
||
}
|
||
}
|
||
|
||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||
|
||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.col},${cell.row}</text>`;
|
||
|
||
/**
|
||
* THE ROSTER, one chip per A/D track — not one per train.
|
||
*
|
||
* The Office is the one square where more than one train may legally stand at once (its A/D
|
||
* tracks, §2.1), and the old single `cell.train` field only ever showed whichever tray
|
||
* `s.trays` happened to yield first: a second train at a busy Station was counted in the old
|
||
* A/D pips and never drawn at all. `adTracks` of them, always, whether or not a train is
|
||
* holding there — the cell is drawn for the tracks it HAS, so a free one reads a dashed,
|
||
* dimmed chip saying "free" rather than simply vanishing.
|
||
*
|
||
* Selected train's chip is lit the same amber the crew strip and action buttons already use,
|
||
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries
|
||
* `data-crew`, so the play page can wire a click to switch which train that is.
|
||
*
|
||
* COMPUTED FOR EVERY CARD, NOT JUST THE OFFICE. This used to sit inside the `adTracks !== null`
|
||
* branch below, so it was only ever assigned on the Office card — a train standing anywhere else
|
||
* in the district (which is everywhere it stands while actually being switched) got no chip at
|
||
* all: no label, no engine arrow, and no cars drawn, though the game state never lost it. REPORTED:
|
||
* dropping a car at the Freight House made "the train vanish off the face of the earth" — it had
|
||
* simply left the one square this code drew a crew on. The Office is the only square that can hold
|
||
* more than one train, so `cell.trains[0]` is exactly right everywhere else too.
|
||
*/
|
||
const selectedTrain: CellView['trains'][number] | null =
|
||
cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
|
||
if (cell.adTracks !== null) {
|
||
const adTracks = cell.adTracks;
|
||
const gaps = Math.max(0, adTracks - 1);
|
||
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
|
||
for (let i = 0; i < adTracks; i++) {
|
||
const train = cell.trains[i] ?? null;
|
||
const cx = 6 + i * (chipW + 4);
|
||
if (!train) {
|
||
out +=
|
||
`<g class="bs-adchip mt"><rect x="${cx}" y="20" width="${chipW}" height="14" rx="3"/>` +
|
||
`<text x="${cx + chipW / 2}" y="30.5" text-anchor="middle">free</text></g>`;
|
||
continue;
|
||
}
|
||
const arrow = train.facing === 'w' ? '◀' : '▶';
|
||
const chipLabel = chipW >= 40 ? `${train.label} ${arrow}${train.cars.length}` : `${train.label}${arrow}`;
|
||
const consistWords = train.cars.length === 0 ? 'no cars' : train.cars.join(', ');
|
||
const on = train.trayId === selectedTrain?.trayId;
|
||
out +=
|
||
`<g class="bs-adchip${on ? ' on' : ''}" data-crew="${esc(train.trayId)}" ` +
|
||
`data-tip="${esc(`${train.label} — carrying ${consistWords}`)}">` +
|
||
`<rect x="${cx}" y="20" width="${chipW}" height="14" rx="3"/>` +
|
||
`<text x="${cx + chipW / 2}" y="30.5" text-anchor="middle">${esc(chipLabel)}</text></g>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS.
|
||
*
|
||
* §9.3 runs loading Green → MEN → AT → WORK → the spotted car, and unloading the other way
|
||
* entirely: car → WORK → AT → MEN → red. So green and red BOTH belong beside MEN, and the car
|
||
* belongs beside WORK.
|
||
*
|
||
* This drew green, then MEN|AT|WORK, then red — putting the red box at the far right, which is
|
||
* exactly where the car is. An unload therefore appeared to run backwards across the whole row
|
||
* and then land on the end it had just come from. Reported after unloading at a warehouse:
|
||
* "it went W, A, M, and then to the red box at the far right".
|
||
*
|
||
* Only the boxes an industry actually uses are drawn, and the arrows point the way its freight
|
||
* really moves.
|
||
*/
|
||
const f = cell.facility;
|
||
if (f) {
|
||
const cellW = 13;
|
||
const box = (bx: number, by: number, filled: boolean, cls: string, label?: string): string =>
|
||
`<rect class="bs-wb ${cls}${filled ? ' bs-occ' : ''}" x="${bx}" y="${by}" width="${cellW}" height="12" rx="2"/>` +
|
||
(label ? `<text class="bs-wl" x="${bx + cellW / 2}" y="${by + 9}" text-anchor="middle">${esc(label)}</text>` : '');
|
||
|
||
let bx = 7;
|
||
const by = H - 42;
|
||
/**
|
||
* The MEN | AT | WORK sign is a FREIGHT fitting, so a Passenger Facility has none — `maw` comes
|
||
* back empty for one. The arrows below point into and out of that sign, so they go with it:
|
||
* without the sign they were chevrons aimed at nothing. A Depot keeps its green and red boxes,
|
||
* because passengers really do wait in one and arrive into the other.
|
||
*/
|
||
const hasSign = f.maw.length > 0;
|
||
// Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows
|
||
// point opposite ways. An industry that only receives has no green boxes at all.
|
||
if (f.allowsOut) {
|
||
for (let i = 0; i < Math.max(1, f.greenCap); i++) {
|
||
out += box(bx, by, i < f.green.length, 'bs-green');
|
||
bx += cellW + 2;
|
||
}
|
||
if (hasSign) {
|
||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||
bx += 9;
|
||
}
|
||
}
|
||
if (f.allowsIn) {
|
||
for (let i = 0; i < Math.max(1, f.redCap); i++) {
|
||
out += box(bx, by, i < f.red.length, 'bs-red');
|
||
bx += cellW + 2;
|
||
}
|
||
if (hasSign) {
|
||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
|
||
bx += 9;
|
||
}
|
||
}
|
||
// MEN | AT | WORK — one physical sign, worked left to right loading and right to left
|
||
// unloading. The spotted car sits past WORK, standing on the card itself.
|
||
for (let i = 0; i < f.maw.length; i++) {
|
||
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
|
||
bx += cellW + 2;
|
||
}
|
||
if (hasSign) {
|
||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
|
||
}
|
||
|
||
/**
|
||
* WHICH WAY THIS INDUSTRY RUNS, IN WORDS.
|
||
*
|
||
* A Refinery ships out and a Grocer's Warehouse receives, and the drawing told them apart by
|
||
* the STROKE COLOUR of one 13x12px box and the direction of a 10px chevron. Asked directly in
|
||
* playtesting whether the two were supposed to look the same: they were not, and effectively
|
||
* they did. A word costs almost nothing and cannot be misread.
|
||
*/
|
||
// A Passenger Facility is not an industry and must not borrow an industry's word for it: a
|
||
// Depot read "SHIPS + RECEIVES", which says freight. Passengers board and alight instead.
|
||
const passenger = f.kind === 'passenger';
|
||
const flow = passenger
|
||
? 'BOARDS + ALIGHTS'
|
||
: f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
||
const flowCls = passenger || (f.allowsOut && f.allowsIn) ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
|
||
// An Office card's roster band now occupies this y-band; its tooltip already folded into the
|
||
// card's own data-tip above, so the label is not printed twice.
|
||
if (!isOfficeRoster) {
|
||
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
|
||
passenger
|
||
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
|
||
: f.allowsOut && f.allowsIn
|
||
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
|
||
: f.allowsOut
|
||
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
|
||
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
|
||
)}">${esc(flow)}</text>`;
|
||
}
|
||
|
||
/**
|
||
* THE WORKERS, ON THE CARD.
|
||
*
|
||
* "How do I see the number of laborers in an industry card?" — you could not. The number that
|
||
* decides every Cargo phase lived only in a side panel, so a Modifier's +1 Laborer landed
|
||
* where the player was not looking and an Oil Depot appeared to do nothing at all. Porters
|
||
* were worse: nothing on the page drew them anywhere.
|
||
*
|
||
* FREE OVER TOTAL, because free is what can be spent right now. A facility has one kind of
|
||
* worker or the other — Laborers work freight, Porters work passengers — so the card carries
|
||
* whichever one it actually has.
|
||
*/
|
||
const isPorter = f.laborers.startsWith('0/0');
|
||
const crew = isPorter ? f.porters : f.laborers;
|
||
if (!crew.startsWith('0/0')) {
|
||
const free = Number(crew.split('/')[0]);
|
||
out += `<g data-tip="${esc(
|
||
isPorter
|
||
? `${crew} Porters free this Stage. A Porter boards or detrains passengers — one action each, and the Revenue arrives in that single action.`
|
||
: `${crew} Laborers free this Stage. One Laborer moves one load one square along MEN | AT | WORK, so this is the whole of what this industry can do before the Stage turns over.`,
|
||
)}">` +
|
||
`<rect class="bs-crewpill${free > 0 ? ' bs-ready' : ''}" x="${W - 44}" y="${by - 1}" width="38" height="14" rx="7"/>` +
|
||
`<text class="bs-crewlab" x="${W - 25}" y="${by + 9}" text-anchor="middle">${esc(isPorter ? 'P' : 'L')} ${esc(crew)}</text></g>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* THE CARS STANDING HERE — NO PRINTED SIDING BEHIND THEM.
|
||
*
|
||
* An industry card used to draw one empty square per unit of its capacity, which read as a
|
||
* siding of a particular length printed on the card. No industry card prints one. Worse, the
|
||
* length came from the box count, so a one-box industry drew a single square and looked like it
|
||
* could only ever hold one car — which is exactly what the engine was wrongly enforcing.
|
||
*
|
||
* Every card, industry or not, now draws what is actually standing on it and nothing more. Four
|
||
* is the ceiling everywhere (`MAX_CONSIST`), so the row cannot overflow the card.
|
||
*
|
||
* The 4 is written out rather than imported: this function's SOURCE is serialized into the
|
||
* standalone replay page, where module imports are not in scope. Referencing the constant threw
|
||
* `MAX_CONSIST is not defined` in the rendered HTML while typechecking perfectly here.
|
||
*/
|
||
/**
|
||
* WHICH SIDE OF THE ENGINE THE CUT IS ON.
|
||
*
|
||
* Reported from play: "right after dropping my cars I need to be able to see if those cars are
|
||
* ahead or behind the train." They were drawn as one strip starting at the left edge whether a
|
||
* train was on the card or not, so the picture could not say.
|
||
*
|
||
* `cars` runs WEST TO EAST and `standingWest` is how many of them are west of the train, so the
|
||
* row is split there: the west cars stay left-aligned, the east cars are pushed over to the
|
||
* right-hand end, and the gap between them is where the engine is standing. Whether that gap
|
||
* means "ahead" or "behind" is the engine's facing, which the tooltip says in words rather than
|
||
* leaving the player to work out from the compass.
|
||
*/
|
||
const hasTrain = cell.trains.length > 0;
|
||
const split = hasTrain ? Math.max(0, Math.min(cell.cars.length, cell.standingWest)) : 0;
|
||
const shown = Math.min(cell.cars.length, 4);
|
||
for (let i = 0; i < shown; i++) {
|
||
// With no train on the card there is no gap to leave, so the whole cut stays left-aligned.
|
||
const westOfEngine = hasTrain ? i < split : true;
|
||
const side = !selectedTrain
|
||
? 'standing here'
|
||
: (selectedTrain.facing === 'e') === westOfEngine
|
||
? 'standing BEHIND the engine — it would couple onto the tail backing up'
|
||
: 'standing AHEAD of the engine — it would couple onto the nose pulling forward';
|
||
const carTip = `${cell.cars[i]} ${side}`;
|
||
/**
|
||
* WHICH CAR IS STANDING HERE, not just that one is.
|
||
*
|
||
* The switching game is entirely about getting the RIGHT car to the right industry, so "a car
|
||
* is here" is not enough — a Grocer's Warehouse wants a boxcar or a reefer and nothing else
|
||
* will do. This printed the first three characters of the label, which for "loaded hopper" and
|
||
* "loaded boxcar" alike is `loa`: every car on the board looked the same.
|
||
*
|
||
* Type by colour and by three letters, loaded by a filled slot and empty by an outlined one —
|
||
* the same distinction the printed game makes with coloured tokens.
|
||
*/
|
||
const car = cell.cars[i] ?? '';
|
||
const type = /caboose/.test(car)
|
||
? 'cab'
|
||
: /hopper/.test(car)
|
||
? 'hop'
|
||
: /boxcar/.test(car)
|
||
? 'box'
|
||
: /reefer/.test(car)
|
||
? 'rfr'
|
||
: /tank/.test(car)
|
||
? 'tnk'
|
||
: /coach/.test(car)
|
||
? 'cch'
|
||
: '';
|
||
// A caboose is never "loaded" in the freight sense (carLabel says so), so it is drawn solid:
|
||
// it is a car that is doing its job, not an empty waiting for one.
|
||
const loaded = /^loaded/.test(car) || /caboose/.test(car);
|
||
const cls = ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ' bs-mt'}`;
|
||
/**
|
||
* LOADED OR EMPTY, TOLD THE SAME WAY THE TRAY TELLS IT.
|
||
*
|
||
* REPORTED: "I left a car in a siding and cannot remember whether it was loaded. In the train
|
||
* tray it is obvious; on the office cards it is hard to tell." It was: an occupied slot took a
|
||
* generic blue fill first and only a LOADED car overrode it with its type colour, so empty
|
||
* read as blue-ish and loaded as brown-ish at 26x15px, and neither said which it was.
|
||
*
|
||
* Now the card matches the tray — solid face for loaded, hollow for empty — and every slot
|
||
* carries the car's own words as a tooltip, so "loaded hopper" is one hover away.
|
||
*/
|
||
// West cars run rightwards from the left edge; east cars run leftwards from the right edge, so
|
||
// the engine's place in the row is the gap left between them.
|
||
const sx = westOfEngine ? 8 + i * 30 : W - 34 - (shown - 1 - i) * 30;
|
||
out += `<rect class="bs-slot${cls}" x="${sx}" y="${H - 26}" width="26" height="15" rx="2" data-tip="${esc(carTip)}"/>`;
|
||
out += `<text class="bs-carlab${loaded ? ' bs-lab-ld' : ''}" x="${sx + 13}" y="${H - 15}" text-anchor="middle" data-tip="${esc(carTip)}">${esc(type)}</text>`;
|
||
}
|
||
|
||
if (cell.enhancements.length > 0) {
|
||
// ABOVE the rail. An `onCard` enhancement can land on a facility, so it has to clear both the
|
||
// load pipeline below and the through track — and with the rail now down the middle of the
|
||
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
||
// Hover text per enhancement, from the card catalogue — an Interlocking used to be a bare word
|
||
// on the card with nothing to say what it did, or that it does not do it yet.
|
||
out += `<text class="bs-enh" x="6" y="26" data-tip="${esc(
|
||
(cell.enhancementsWhat ?? []).join(' · '),
|
||
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||
}
|
||
if (selectedTrain) {
|
||
/**
|
||
* THE SELECTED TRAIN, DRAWN AS IT SITS IN THE TRAY.
|
||
*
|
||
* The badge used to be a name and nothing more, which makes the switching game unplayable:
|
||
* every decision is about car ORDER — which car comes off next, which end a cut couples onto —
|
||
* and none of it was on the board. Now the engine and its cars are drawn in sequence, nose
|
||
* first, with an arrow for which way the engine points, so "set out the caboose off the back"
|
||
* is a thing you can see before you choose it. Where an Office holds more than one train, only
|
||
* this one is drawn here — the rest are the roster chips above, each with its own tooltip.
|
||
*/
|
||
const t = selectedTrain;
|
||
const items: { label: string; kind: string; what: string }[] = t.cars.map((c) => ({
|
||
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
|
||
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
|
||
kind: /^loaded/.test(c) || /caboose/.test(c) ? 'ld' : 'mt',
|
||
what: c,
|
||
}));
|
||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng', what: 'the engine' });
|
||
/**
|
||
* WEST ON THE LEFT, AND THE NOSE POINTING THE WAY THE ENGINE FACES.
|
||
*
|
||
* `consist` is ordered NOSE FIRST (state.ts), and this drew index 0 leftmost whatever the
|
||
* train was doing \u2014 so a westbound train came out right and an eastbound one came out
|
||
* MIRRORED. Reported from seed 270861860: Train 10 running east arrived at the Whistle Post
|
||
* drawn engine-first at the WEST end, which reads as an engine shoving four cars ahead of it.
|
||
*
|
||
* The board is a map, so the drawing has to obey the map: reverse the seating order for an
|
||
* east-facing train and its nose lands at the east end, where it is.
|
||
*
|
||
* A crew on a north-south spur is drawn east-west like every other train, because `facing` is
|
||
* now always east or west (`railFacingOf`). It used to keep its compass port and draw \u25b2 or \u25bc
|
||
* with the consist pinned nose-left, which meant the one thing the picture is for \u2014 which end
|
||
* the engine is on \u2014 flipped its convention the moment a crew turned a corner. Playtested and
|
||
* reported as more confusing than a strip drawn the same way every time.
|
||
*/
|
||
const laid = t.facing === 'e' ? [...items].reverse() : items;
|
||
const cw = 17;
|
||
const tw = Math.min(W - 8, laid.length * cw + 30);
|
||
const tx = W / 2 - tw / 2;
|
||
const facingWord = t.facing === 'e' ? 'east' : 'west';
|
||
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
|
||
out += `<g class="bs-crew" data-tip="${esc(
|
||
`${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''),
|
||
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
||
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
|
||
laid.forEach((it, n) => {
|
||
const bx = tx + 28 + n * cw;
|
||
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2" data-tip="${esc(it.what)}"/>` +
|
||
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle" data-tip="${esc(it.what)}">${esc(it.label)}</text>`;
|
||
});
|
||
out += '</g>';
|
||
}
|
||
out += '</g>';
|
||
}
|
||
|
||
// A legal EMPTY square has no card to outline, so it gets a target drawn in the same frame.
|
||
for (const g of ghosts) {
|
||
out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
|
||
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
||
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.col},${g.row}</text>` +
|
||
`</g>`;
|
||
}
|
||
|
||
/**
|
||
* A LEGAL SQUARE THAT ALREADY HAS A CARD ON IT, captioned like the empty ones.
|
||
*
|
||
* The two are the same question — you have picked a card, now pick where it goes — and they were
|
||
* answered in two different registers: a full ghost card reading "place here" for an empty square,
|
||
* and a 2px blue outline for an occupied one. So the Limits sign (which is how a district grows at
|
||
* all) and the Depot (which is where an Enhancement attaches) both lit up saying nothing.
|
||
*
|
||
* Drawn as a band across the card rather than over its middle: there is a rail, a train and a load
|
||
* pipeline under here, and the caption must not sit on top of them.
|
||
*/
|
||
for (const l of legal) {
|
||
out += `<g class="bs-legalcap" transform="translate(${px(l.col)},${py(l.row)})">` +
|
||
`<rect x="6" y="${H - 46}" width="${W - 12}" height="14" rx="7"/>` +
|
||
`<text x="${W / 2}" y="${H - 36}" text-anchor="middle">${esc(l.label)}</text></g>`;
|
||
}
|
||
|
||
/**
|
||
* THE LIMITS, AS THE EDGE OF THE BUILDABLE DISTRICT.
|
||
*
|
||
* Drawn just OUTSIDE the sign's own column, because the sign stands on the boundary rather than
|
||
* beyond it: a siding may run under the sign, and the line has to agree with the rule or it
|
||
* teaches the wrong one. It moves outward on its own as the Running Track is extended, which is
|
||
* the reward for extending made visible.
|
||
*/
|
||
if (limits) {
|
||
const edge = (x: number, side: string): string =>
|
||
`<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` +
|
||
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 4}" ` +
|
||
`text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`;
|
||
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
|
||
}
|
||
|
||
// Mark the Running Track so the spine of the district is unmistakable.
|
||
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
|
||
out += '</svg>';
|
||
return out;
|
||
}
|
||
|
||
/** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */
|
||
export const BOARD_CSS = `
|
||
.bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible}
|
||
.bs-rail{stroke:#cfd6e0;stroke-width:1.6}
|
||
.bs-tie{stroke:#8d97a5;stroke-width:1.1}
|
||
.bs-open .bs-rail{stroke:#6b7686;stroke-dasharray:6 4}
|
||
.bs-open .bs-tie{stroke:#59626f}
|
||
.bs-card{fill:#161b21;stroke:#39424e;stroke-width:1}
|
||
.bs-card.bs-run{fill:#1d232c}
|
||
.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
|
||
.bs-lim{fill:#241d1d;stroke:#8a5a5a}
|
||
.bs-fac{fill:#201c26;stroke:#7a5f9a}
|
||
.bs-mod{fill:#231d2b;stroke:#5c4a70;stroke-dasharray:4 3}
|
||
.bs-sec{fill:#161b21;stroke:#39424e}
|
||
.bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
|
||
.bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
|
||
/* THE DIVISION, drawn as the route rather than as a row of boxes.
|
||
Three kinds of cell, and they must be told apart at a glance: a Division Point is where trains
|
||
come from and go to, a Mainline card is shared ground between two districts, and a Running Track
|
||
card belongs to one player's Office Area. */
|
||
.bs-dcell rect{fill:#161b21;stroke:#39424e;stroke-width:1}
|
||
.bs-ddp rect{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
|
||
.bs-dml rect{fill:#1d222b;stroke:#6b7686;stroke-width:1.6}
|
||
.bs-drun rect{fill:#1b2534;stroke:#4d6fa8}
|
||
.bs-dcell.bs-full rect{stroke:#e0a060;stroke-width:2}
|
||
/* The elbow where the route turns from one player's side of the table to the next. Drawn heavier
|
||
than a plain join because that turn is the boundary two districts share. */
|
||
.bs-turn{fill:none;stroke:#7f8b9c;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
|
||
/* Buffer stops at both ends. The Division is a line, not a loop: trains enter at one Division Point
|
||
and leave at the other, and a seated layout must not be read as a ring. */
|
||
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
|
||
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
|
||
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
||
train is measured against, not something to look at instead of the train. */
|
||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||
/* THE HEAVY GRADE WEDGE. Terrain, so it is coloured as terrain rather than as a warning.
|
||
SOLID BROWN, fill and border the same (Jesse, 2026-08-30) — the first pass paired a desaturated
|
||
fill with an amber arrow and the pair read reddish, which on a map that spends amber on "it is
|
||
happening here" made a fixed piece of landscape look like a live alert. One flat brown recedes
|
||
into scenery; the arrow is bone so the DIRECTION, which is the fact being reported, is the part
|
||
that carries. */
|
||
.bs-grade{fill:#6b5334;stroke:#6b5334;stroke-width:1}
|
||
.bs-gradeup{fill:#f2e8d5}
|
||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
|
||
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
|
||
are the same fact about the same car. An occupied slot used to take a generic blue fill and only
|
||
a loaded car overrode it, so a 26x15px square said "a car" and left "which car, carrying what?"
|
||
to be worked out. Empty is now hollow with a dark face, loaded a solid one, exactly as in the
|
||
tray; the type colour is the stroke either way. */
|
||
.bs-slot.bs-car-box{stroke:#c08a4a}
|
||
.bs-slot.bs-car-hop{stroke:#8d97a5}
|
||
.bs-slot.bs-car-tnk{stroke:#7a6ab0}
|
||
.bs-slot.bs-car-rfr{stroke:#6fb5a0}
|
||
.bs-slot.bs-car-cch{stroke:#5aa9e6}
|
||
.bs-slot.bs-car-cab{stroke:#c05a5a}
|
||
.bs-slot.bs-occ.bs-mt{fill:#181d24}
|
||
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.85)}
|
||
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.85)}
|
||
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.85)}
|
||
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.85)}
|
||
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
|
||
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
|
||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
|
||
engine is the one that carries the arrow — which is what makes "reverse" mean something. */
|
||
.bs-tcar{stroke:#3a2f14;stroke-width:.8}
|
||
.bs-tcar.bs-t-ld{fill:#e8dcc0}
|
||
.bs-tcar.bs-t-mt{fill:#6b5a2e}
|
||
.bs-tcar.bs-t-eng{fill:#2b2313;stroke:#e0c060;stroke-width:1.4}
|
||
.bs-tcarlab{font:8px ui-monospace,monospace;fill:#2b2313;font-weight:700}
|
||
/* One chip per A/D track, not one per train — a full Office is why the next arrival collides, so
|
||
the count is worth seeing without hovering, and a free track reads "free" rather than vanishing.
|
||
The selected train's chip is lit the same amber the crew strip and action buttons use. */
|
||
.bs-adchip rect{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||
.bs-adchip text{fill:#cfd6e0;font:600 8px ui-monospace,monospace}
|
||
.bs-adchip.on rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.4}
|
||
.bs-adchip.on text{fill:#fff4d6}
|
||
.bs-adchip.mt rect{fill:none;stroke:#3d4650;stroke-dasharray:3 2}
|
||
.bs-adchip.mt text{fill:#6b7480}
|
||
.bs-adchip[data-crew]{cursor:pointer}
|
||
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
|
||
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
||
/* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face,
|
||
light on an empty one's hollow. */
|
||
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
|
||
.bs-carlab.bs-lab-ld{fill:#12161b;font-weight:700}
|
||
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||
.bs-wb.bs-green{stroke:#3f7a52}
|
||
.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
|
||
.bs-wb.bs-maw{stroke:#8a6d1f}
|
||
.bs-wb.bs-maw.bs-occ{fill:rgba(190,150,40,.65)}
|
||
.bs-wb.bs-red{stroke:#8a5a5a}
|
||
.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
|
||
.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
|
||
/* WHICH WAY THE INDUSTRY RUNS. Green for ships-out, red for receives, in the same hues as the boxes
|
||
they describe — the word and the colour saying the same thing twice. */
|
||
.bs-flow{font:700 8px ui-monospace,monospace;letter-spacing:.06em;cursor:help}
|
||
.bs-flow-out{fill:#5f9b74}
|
||
.bs-flow-in{fill:#b07272}
|
||
.bs-flow-both{fill:#8b94a3}
|
||
/* THE WORKERS. Free over total, on the card, because a Laborer that is already spent this Stage is
|
||
not a Laborer you can use. Lit while any remain, dim once they are all gone. */
|
||
.bs-crewpill{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||
.bs-crewpill.bs-ready{fill:rgba(190,150,40,.22);stroke:#8a6d1f}
|
||
.bs-crewlab{fill:#cfd6e0;font:600 9px ui-monospace,monospace}
|
||
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
|
||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||
.bs-name.bs-you{fill:#5aa9e6}
|
||
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
|
||
seconds and which railroad is yours never does.
|
||
|
||
NO WEIGHT BUMP. This was 700 and the name came out fuzzy to the point of being unreadable: the
|
||
base is already 600, so at 11px a monospace face has to be synthesised the rest of the way, and
|
||
the extra ink lands as blur rather than as weight. Amber against #e6e9ee is the distinction; it
|
||
does not need help. */
|
||
.bs-name.bs-turn{fill:#f0b64a}
|
||
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
|
||
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
|
||
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
||
.bs-mod{font:10px ui-monospace,monospace}
|
||
text.bs-mod{fill:#c8a04a}
|
||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||
/* THE EDGE OF THE DISTRICT. Deliberately quiet — it is a boundary, not an action — and dashed, so it
|
||
reads as a line on the table rather than as rail. Nothing is laid outside it (§2.1). */
|
||
.bs-limitline{stroke:#5f6b7a;stroke-width:1.5;stroke-dasharray:3 5;opacity:.7}
|
||
.bs-limitlab{fill:#5f6b7a;font:600 8px ui-monospace,monospace;letter-spacing:.12em}
|
||
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
|
||
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
|
||
/* The caption on a legal square that already carries a card. Same blue as the ghost squares and the
|
||
same words in kind, because it is the same question: you have picked a card, now pick where. */
|
||
.bs-legalcap rect{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-width:1}
|
||
.bs-legalcap text{fill:#cfe0f5;font:700 8px ui-monospace,monospace;letter-spacing:.07em}
|
||
.bs-legalcap{pointer-events:none}
|
||
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
|
||
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
|
||
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
|
||
/* THE SQUARE THE ACTION UNDER THE CURSOR MEANS. White, and brighter than anything else the board
|
||
marks, because it answers a question being asked RIGHT NOW rather than describing a standing
|
||
state — and it has to win when it lands on a square that is already legal, focused or blocked. */
|
||
g[data-cell].bs-point .bs-card,g[data-ghost].bs-point rect{stroke:#f2f5f8;stroke-width:3.5;stroke-dasharray:none}
|
||
g[data-cell].bs-point .bs-card{fill:#2b3b52}
|
||
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
|
||
card reads the same way an action button does. bs-from is where it is standing now — marked on
|
||
the crew STRIP rather than the whole card, because the Office is the one square where a second
|
||
train may share that card: outlining the card would claim it belongs to both. */
|
||
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
|
||
.bs-crew.bs-from rect:first-of-type{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
|
||
/* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that
|
||
is merely uninteresting. The reason rides on the card's own tooltip. */
|
||
g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3}
|
||
/* NOT THE SAME THING. A turnout carries no wheel icon (§A.1): a train runs through it freely and
|
||
simply cannot finish a Move on it. Drawn as a faint amber dash — related to the reachable amber,
|
||
because it IS reachable, just not a place to stop. */
|
||
g[data-cell].bs-nostop .bs-card{stroke:#6a5f3a;stroke-width:1.6;stroke-dasharray:2 4}
|
||
g[data-cell].bs-blocked,g[data-cell].bs-nostop{cursor:help}
|
||
`;
|