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

906 lines
47 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. 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) => ({ '&': '&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[];
/** 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) => ({ '&': '&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>`;
/**
* 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}
`;