906 lines
47 KiB
TypeScript
906 lines
47 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.
|
||
*/
|
||
export function divisionSvg(nodes: DivisionView[]): 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
|
||
*
|
||
* SEATING. Players sit around a table, so the route is laid out the way they do: one row alone,
|
||
* two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop
|
||
* — trains enter at one Division Point and leave at the other — so the shape is deliberately left
|
||
* open, with the two ends drawn as buffer stops facing each other across a marked gap. 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 };
|
||
const CH = 58;
|
||
const GAP = 6;
|
||
/**
|
||
* 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 SIDE_GAP = 34;
|
||
|
||
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';
|
||
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;
|
||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||
regions: number;
|
||
w: number;
|
||
x: number;
|
||
y: number;
|
||
};
|
||
const cells: Cell[] = [];
|
||
const sides: number[][] = [];
|
||
let side: number[] = [];
|
||
|
||
const push = (c: Omit<Cell, 'x' | 'y'>): void => {
|
||
side.push(cells.length);
|
||
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();
|
||
for (const rc of n.running ?? []) {
|
||
const isOffice = rc.kind === 'office';
|
||
push({
|
||
kind: 'run',
|
||
label: rc.label,
|
||
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
|
||
/**
|
||
* A train standing at the Office occupies an A/D track, which is where it is — but it is
|
||
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
|
||
* drawn twice. Reported as two T10 chips on one Office.
|
||
*/
|
||
trains: isOffice
|
||
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
|
||
: rc.trains,
|
||
cap: isOffice ? cap : null,
|
||
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
||
seat: n.seat ?? null,
|
||
// No regions inside 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: CW.run,
|
||
});
|
||
}
|
||
// A crew below the Running Track has no position ON it, so it is reported against the
|
||
// district rather than drawn somewhere it is not.
|
||
const below = n.switching ?? [];
|
||
if (below.length > 0) {
|
||
const last = cells[cells.length - 1];
|
||
if (last) last.sub = `${below.length} switching below`;
|
||
}
|
||
sides.push(side);
|
||
side = [];
|
||
continue;
|
||
}
|
||
const dp = n.kind === 'dp';
|
||
push({
|
||
kind: dp ? 'dp' : 'ml',
|
||
label: n.label,
|
||
sub: n.capacity === null ? 'no limit — trains queue' : `${Math.max(0, n.capacity - n.trains.flat().length)} of ${n.capacity} free`,
|
||
trains: n.trains.flat(),
|
||
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(' · ')}` : ''}`,
|
||
seat: null,
|
||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||
regions: dp ? 1 : (n.regions ?? 0),
|
||
w: dp ? CW.dp : CW.ml,
|
||
});
|
||
}
|
||
if (side.length > 0) sides.push(side);
|
||
|
||
// Each player's side carries their district and the Mainline card leading into it; whatever is
|
||
// left over (the last Mainline and the East DP) joins the final side.
|
||
const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length));
|
||
const lanes: number[][] = [];
|
||
for (let i = 0; i < seats; i++) lanes.push([]);
|
||
sides.forEach((grp, i) => {
|
||
const target = Math.min(i, seats - 1);
|
||
for (const idx of grp) lanes[target]!.push(idx);
|
||
});
|
||
|
||
// -- lay the sides out around the table -------------------------------------------------------
|
||
// top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a
|
||
// horseshoe open to the west, and a square broken at the same place.
|
||
const dir: ('top' | 'right' | 'bottom' | 'left')[] =
|
||
seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left'];
|
||
|
||
const runLen = (idxs: number[]): number =>
|
||
idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP);
|
||
const widest = Math.max(...lanes.map((l) => runLen(l)), 200);
|
||
const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH;
|
||
|
||
const vertCount = dir.filter((d) => d === 'right' || d === 'left').length;
|
||
const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0);
|
||
const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30;
|
||
|
||
lanes.forEach((idxs, i) => {
|
||
const d = dir[i]!;
|
||
if (d === 'top' || d === 'bottom') {
|
||
const y = d === 'top' ? PAD : boardH - PAD - CH - 22;
|
||
const order = d === 'bottom' ? [...idxs].reverse() : idxs;
|
||
let x = PAD;
|
||
for (const idx of order) {
|
||
const c = cells[idx]!;
|
||
c.x = x;
|
||
c.y = y;
|
||
x += c.w + GAP;
|
||
}
|
||
} else {
|
||
const x = d === 'right' ? boardW - PAD - CW.run : PAD;
|
||
const order = d === 'left' ? [...idxs].reverse() : idxs;
|
||
let y = PAD + CH + SIDE_GAP;
|
||
for (const idx of order) {
|
||
const c = cells[idx]!;
|
||
c.x = x;
|
||
c.y = y;
|
||
c.w = CW.run;
|
||
y += CH + GAP;
|
||
}
|
||
}
|
||
});
|
||
|
||
// -- 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;
|
||
};
|
||
|
||
// The same rail turned through ninety degrees, for the sides of the table.
|
||
const railV = (x: number, y1: number, y2: number): string => {
|
||
let o =
|
||
`<line class="bs-rail" x1="${x - 2.5}" y1="${y1}" x2="${x - 2.5}" y2="${y2}"/>` +
|
||
`<line class="bs-rail" x1="${x + 2.5}" y1="${y1}" x2="${x + 2.5}" y2="${y2}"/>`;
|
||
const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9));
|
||
for (let i = 0; i <= n; i++) {
|
||
const ty = y1 + ((y2 - y1) * i) / n;
|
||
o += `<line class="bs-tie" x1="${x - 4.5}" y1="${ty}" x2="${x + 4.5}" y2="${ty}"/>`;
|
||
}
|
||
return o;
|
||
};
|
||
|
||
let out = `<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" 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]!;
|
||
const sameRow = Math.abs(a.y - b.y) < 1;
|
||
const sameCol = Math.abs(a.x - b.x) < 1;
|
||
if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x);
|
||
else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x);
|
||
else if (sameCol) {
|
||
// Stacked down one side of the table: still one straight run of track, not a turn.
|
||
const top = Math.min(a.y + CH, b.y + CH);
|
||
const bot = Math.max(a.y, b.y);
|
||
out += railV(a.x + a.w / 2, top, bot);
|
||
} else {
|
||
// A turn between sides: an elbow, so the route is visibly continuous around the table.
|
||
const ax = a.x + a.w / 2;
|
||
const bx = b.x + b.w / 2;
|
||
const ay = a.y + CH;
|
||
const by = b.y;
|
||
out += `<path class="bs-turn" d="M${ax} ${ay} L${ax} ${(ay + by) / 2} L${bx} ${(ay + by) / 2} L${bx} ${by}"/>`;
|
||
}
|
||
}
|
||
|
||
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"/>`;
|
||
out += `<text class="bs-name" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label)}</text>`;
|
||
out += rail(c.x + 6, c.y + 32, 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. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage.
|
||
// The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them,
|
||
// so a 60 card is crossed in one Stage and a slow train on a 30 takes three.
|
||
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 + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
c.trains.forEach((t, k) => {
|
||
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 w = Math.min(c.w - 8, label.length * 6.6 + 12);
|
||
// 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 slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
|
||
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
|
||
const tx = slotX + spread;
|
||
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` +
|
||
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
|
||
' train takes over them depends on the card speed and the train)'
|
||
: '';
|
||
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)}">` +
|
||
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
||
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(label)}</text>`;
|
||
out += '</g>';
|
||
});
|
||
out += '</g>';
|
||
});
|
||
|
||
// THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is
|
||
// what stops a seated layout being read as a loop.
|
||
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 }[] = [],
|
||
): 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;
|
||
};
|
||
|
||
// 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 };
|
||
};
|
||
|
||
let out = `<svg class="bs" viewBox="0 0 ${width} ${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';
|
||
|
||
// 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)}" 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 the card prints it: along the centre line from the east or west edge
|
||
* to the FROG, then out at exactly 45° through the middle of the north or south edge.
|
||
*
|
||
* The frog is `H/2` from the centre because a 45° run climbing half the card's height
|
||
* travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2)
|
||
* — below the rail on the assumption everything diverged downward — which drew a leg
|
||
* reaching north as a hook that dropped past the rail and came back up, and drew nothing at
|
||
* any angle the matching rule cares about.
|
||
*/
|
||
const side = vertical === from ? to : from;
|
||
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
|
||
const edge = port(vertical);
|
||
out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y);
|
||
}
|
||
}
|
||
|
||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||
|
||
/**
|
||
* A/D TRACKS, as pips.
|
||
*
|
||
* The Office tooltip said "3 A/D tracks" and the card showed nothing — so the number that
|
||
* decides whether the next arrival is an automatic collision (§8.3) was invisible on the card it
|
||
* belongs to. There is no room to draw more rails, and the count is what matters: one pip per
|
||
* track, filled for taken.
|
||
*/
|
||
if (cell.ad) {
|
||
for (let i = 0; i < cell.ad.of; i++) {
|
||
out += `<circle class="bs-ad${i < cell.ad.used ? ' bs-occ' : ''}" cx="${W - 9 - i * 9}" cy="10" r="3"/>`;
|
||
}
|
||
}
|
||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||
|
||
/**
|
||
* 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, on the siding drawn below.
|
||
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';
|
||
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>`;
|
||
}
|
||
}
|
||
|
||
// Standing room, drawn as the printed squares: filled means occupied.
|
||
//
|
||
// `Math.max(1, …)` applies only to an industry, whose track is at least one car long. A Passenger
|
||
// Facility has NO industry track — passengers board straight off the platform — so a Depot was
|
||
// being drawn a siding slot it cannot spot a car on.
|
||
const spots = cell.facility
|
||
? cell.facility.kind === 'passenger'
|
||
? cell.facility.trackCap
|
||
: Math.max(1, cell.facility.trackCap)
|
||
: cell.cars.length;
|
||
for (let i = 0; i < Math.min(spots, 4); i++) {
|
||
const filled = i < cell.cars.length;
|
||
const carTip = filled
|
||
? `${cell.cars[i]} standing here`
|
||
: 'an empty space on this track — a car may be spotted here';
|
||
/**
|
||
* 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 = filled ? ` 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.
|
||
*/
|
||
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2" data-tip="${esc(carTip)}"/>`;
|
||
if (filled) {
|
||
out += `<text class="bs-carlab${loaded ? ' bs-lab-ld' : ''}" x="${21 + i * 30}" 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 (cell.train) {
|
||
/**
|
||
* THE 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.
|
||
*/
|
||
const t = cell.train;
|
||
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.row},${g.col}</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>`;
|
||
}
|
||
|
||
// 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}
|
||
.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 pip per A/D track, filled when a train is holding on it. A full Office is why the next
|
||
arrival collides, so this is the count worth seeing without hovering. */
|
||
.bs-ad{fill:none;stroke:#4d6fa8;stroke-width:1.2}
|
||
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
|
||
.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-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}
|
||
.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}
|
||
/* 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. */
|
||
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
|
||
g[data-cell].bs-from .bs-card{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}
|
||
`;
|