Files
station-master/src/sim/board-svg.ts
T

611 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 AND their labels. "trains enter" hangs off the west end and "trains
* leave" off the east, so a padding sized for the stop alone clipped both to their middle three
* letters — the west end read "TER" and the east "tra".
*/
const PAD = 92;
const SIDE_GAP = 34;
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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[]; region?: number; direction?: string }[];
cap: number | null;
tip: string;
owner: 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.
trains: isOffice ? [...rc.trains, ...ad] : 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'}`,
owner: n.owner ?? 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 — trains enter and leave the Division here, and queue without limit'
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
owner: 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}"/>`;
}
c.trains.forEach((t, k) => {
const w = 30 + Math.min(t.consist.length, 4) * 8;
// 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' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}${
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
}">` +
`<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(t.label)}</text></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];
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 - 12}" y="${first.y + CH + 12}" text-anchor="end">trains enter</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 + 12}" y="${last.y + CH + 12}">trains leave</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 }[] = [],
): string {
const W = 166;
const H = 96;
const PAD = 3;
const RAIL = H / 2;
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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>`;
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.
*
* A load crosses green -> MEN | AT | WORK -> the spotted car, and that journey is the whole of
* freight. It was drawn only in the side panel, so advancing a load changed nothing on the card
* the player was looking at. The printed cards put these squares under the track for exactly
* this reason.
*/
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;
// Green: what is waiting to be worked.
for (let i = 0; i < Math.max(1, f.greenCap); i++) {
out += box(bx, by, i < f.green.length, 'bs-green');
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
bx += 8;
// MEN | AT | WORK — a load sits in one of these while it is being worked.
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;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
bx += 8;
// Red: what has come off an arriving car and is waiting to be cleared.
for (let i = 0; i < Math.max(1, f.redCap); i++) {
out += box(bx, by, i < f.red.length, 'bs-red');
bx += cellW + 2;
}
}
// Standing room, drawn as the printed squares: filled means occupied.
const spots = cell.facility ? 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;
/**
* 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'
: '';
const loaded = /^loaded/.test(car);
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ''}` : '';
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
if (filled) {
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${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.
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
}
if (cell.tray) {
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
`<text class="bs-tlab" x="${W / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(cell.tray.split(' ')[0] ?? 'crew')}</text></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>`;
}
// 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{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL. An industry takes one commodity and nothing else, so the
type has to be readable at a glance; and a loaded car and an empty one of the same type are
opposite halves of the same job. Empty rides as an outline, loaded as a solid face. */
.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-car-box.bs-loaded{fill:rgba(192,138,74,.42)}
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.42)}
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.42)}
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.42)}
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.42)}
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
.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}
.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}
.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}
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}
`;