611 lines
30 KiB
TypeScript
611 lines
30 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 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
|
||
`;
|