3471d40576
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The finished-game export now works identically on every platform: the client mints a signed relative URL (game.export_url) carrying its date locale, IANA time zone and localized non-play labels, resolves it against its own origin and hands it to the platform's native download — Telegram downloadFile, VKWebAppDownloadFile, or a plain browser anchor (also the desktop VK iframe). Both artifacts ride the route: the .gcg text (no more clipboard mode, except the legacy pre-8.0 Telegram fallback) and the PNG of the final position. The PNG is rasterized by a new internal 'renderer' sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the web project unit-tests, bundled at image build time — one renderer, no drift; the browser no longer draws or delivers bytes itself. The backend rebuilds the render payload from the journal + engine.AlphabetTable, verifies the HMAC (10-minute TTL, BACKEND_EXPORT_SIGN_KEY, constant-time, uniform 404s) on its public group, and streams the artifact as a named attachment; the gateway forwards /dl/* behind the per-IP public rate limiter (caddy @gateway matcher extended — the landing catch-all trap). Deploy: renderer service (compose + prod overlay + roll before backend + prod push list), EXPORT_SIGN_KEY env (TEST_/PROD_ secrets), CI runs the sidecar smoke in the ui job. Docs: ARCHITECTURE export-delivery section, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README.
465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
|
||
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
|
||
// Canvas 2D — no dependencies. The module is the SHARED source of truth for the export
|
||
// image: the render sidecar bundles it (renderer/src/entry.ts) and rasterizes on
|
||
// skia-canvas, while this ui project keeps the pure parts unit-tested (the browser app
|
||
// itself no longer imports it — delivery is the server's signed URL).
|
||
//
|
||
// buildScoresheet and layoutImage are pure (vitest-covered); drawGameImage is the thin
|
||
// canvas pass exercised by the sidecar's node test.
|
||
|
||
import type { GameView, MoveRecord, Variant } from './model';
|
||
import { replay } from './board';
|
||
import { historyGrid, type HistoryCell } from './history';
|
||
import { premiumGrid, centre, BOARD_SIZE } from './premiums';
|
||
import { valueForLetter } from './alphabet';
|
||
|
||
// The light-theme palette, mirrored from ui/src/app.css :root — the export is always light
|
||
// regardless of the app theme, and a canvas cannot resolve CSS custom properties, so the
|
||
// values are pinned here. Keep in sync with app.css.
|
||
const C = {
|
||
paper: '#ffffff',
|
||
text: '#14181f',
|
||
muted: '#6b7280',
|
||
border: '#d8dce2',
|
||
cell: '#e7ece8',
|
||
cellDark: '#d9deda', // color-mix(in srgb, --cell-bg 94%, #000)
|
||
tile: '#f4e2b8',
|
||
tileEdge: '#d8c190',
|
||
tileText: '#2a2113',
|
||
premText: '#2a2113',
|
||
prem: { TW: '#e06a5b', DW: '#efa6a0', TL: '#4f8fd6', DL: '#a8cdec' } as Record<string, string>,
|
||
};
|
||
|
||
const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif';
|
||
|
||
/** One scoresheet cell: a play (classic coordinate + words + points), a localized non-play
|
||
* action, or an empty filler keeping the seat columns aligned. */
|
||
export type SheetCell =
|
||
| { kind: 'play'; coord: string; words: string[]; score: number }
|
||
| { kind: 'action'; label: string }
|
||
| { kind: 'empty' };
|
||
|
||
/** Scoresheet is the pure content of the image's right column: one column per seat. */
|
||
export interface Scoresheet {
|
||
/** Per seat: display name, final (adjusted) score, winner flag. */
|
||
header: { name: string; score: number; winner: boolean }[];
|
||
/** Row-major move grid, one column per seat (row k = each seat's k-th move). */
|
||
rows: SheetCell[][];
|
||
/** Per-seat endgame rack settlement: final Seat.score minus the seat's last running
|
||
* total. The journal carries no adjustment move (engine applyEndAdjustment folds it
|
||
* into the final score only), so the sheet shows it as a closing ± row. */
|
||
adjustments: number[];
|
||
hasAdjustments: boolean;
|
||
/** Bottom line: "<hostname> · <finish date>". */
|
||
footer: string;
|
||
}
|
||
|
||
/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
|
||
* and time zone (regional conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
|
||
* Both ride in from the client on a server render; an invalid zone falls back to the
|
||
* process default rather than failing the whole image. */
|
||
export function formatFinishedDate(unixSec: number, dateLocale?: string, timeZone?: string): string {
|
||
const opts: Intl.DateTimeFormatOptions = {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
};
|
||
try {
|
||
return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone }).format(new Date(unixSec * 1000));
|
||
} catch {
|
||
return new Intl.DateTimeFormat(dateLocale, opts).format(new Date(unixSec * 1000));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* buildScoresheet lays the finished game's journal out as the export scoresheet.
|
||
* actionLabel localizes a non-play move (the caller passes the same mapping the in-game
|
||
* history uses); hostname/dateLocale feed the footer (tests pin them, production uses
|
||
* location.hostname and the device locale).
|
||
*/
|
||
export function buildScoresheet(
|
||
game: GameView,
|
||
moves: MoveRecord[],
|
||
actionLabel: (action: string) => string,
|
||
opts?: { hostname?: string; dateLocale?: string; timeZone?: string },
|
||
): Scoresheet {
|
||
const seats = game.seats;
|
||
const grid = historyGrid(moves, seats.length, null);
|
||
const rows = grid.map((row) =>
|
||
row.map((c: HistoryCell): SheetCell => {
|
||
if (c.kind === 'play') {
|
||
return { kind: 'play', coord: c.coord ?? '', words: c.words ?? [], score: c.score ?? 0 };
|
||
}
|
||
if (c.kind === 'action') return { kind: 'action', label: actionLabel(c.action ?? '') };
|
||
return { kind: 'empty' };
|
||
}),
|
||
);
|
||
// The last running total each seat reached in the journal; the final Seat.score differs
|
||
// from it exactly by the endgame rack settlement (resignations freeze scores — zero diff).
|
||
const lastTotal = seats.map((s) => {
|
||
for (let i = moves.length - 1; i >= 0; i--) {
|
||
if (moves[i].player === s.seat) return moves[i].total;
|
||
}
|
||
return 0;
|
||
});
|
||
const adjustments = seats.map((s, i) => s.score - lastTotal[i]);
|
||
const host = opts?.hostname ?? (typeof location !== 'undefined' ? location.hostname : '');
|
||
return {
|
||
header: seats.map((s) => ({ name: s.displayName, score: s.score, winner: s.isWinner })),
|
||
rows,
|
||
adjustments,
|
||
hasAdjustments: adjustments.some((a) => a !== 0),
|
||
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale, opts?.timeZone)}`,
|
||
};
|
||
}
|
||
|
||
// Fixed scoresheet typography (@1x logical px). The column format never changes with game
|
||
// length — the board stretches instead (layoutImage).
|
||
const MARGIN = 22;
|
||
const AXIS = 20; // top letters / left numbers gutter
|
||
const GAP = 22; // board ↔ scoresheet
|
||
const COL_W = 158; // one seat column
|
||
const COL_GAP = 14; // between seat columns
|
||
const HEAD_NAME_H = 20;
|
||
const HEAD_SCORE_H = 24;
|
||
const HEAD_RULE_H = 12;
|
||
const ROW_H = 17;
|
||
const SUB_ROW_H = 13; // extra-words line under a multi-word play
|
||
const ADJ_ROW_H = 20;
|
||
const FOOTER_H = 30;
|
||
/** The board never shrinks below this side (@1x) even for a two-move game. */
|
||
export const MIN_SIDE = 620;
|
||
|
||
/** ImageLayout is the pure geometry of the export image (@1x; the canvas scales it). */
|
||
export interface ImageLayout {
|
||
w: number;
|
||
h: number;
|
||
board: { x: number; y: number; side: number; cell: number };
|
||
hist: { x: number; y: number; w: number; colW: number; colGap: number };
|
||
/** Per move-row height (a row with any multi-word play gets the extra sub-line). */
|
||
rowHeights: number[];
|
||
headNameH: number;
|
||
headScoreH: number;
|
||
headRuleH: number;
|
||
rowH: number;
|
||
subRowH: number;
|
||
adjRowH: number;
|
||
footerH: number;
|
||
axis: number;
|
||
margin: number;
|
||
}
|
||
|
||
/**
|
||
* layoutImage computes the export geometry from the scoresheet: the fixed-typography
|
||
* history column height decides the board side (never below MIN_SIDE), so the image
|
||
* carries no dead space under the board and a long game simply grows both.
|
||
*/
|
||
export function layoutImage(sheet: Scoresheet): ImageLayout {
|
||
const seats = sheet.header.length;
|
||
const rowHeights = sheet.rows.map(
|
||
(row) => ROW_H + (row.some((c) => c.kind === 'play' && c.words.length > 1) ? SUB_ROW_H : 0),
|
||
);
|
||
const histContentH =
|
||
HEAD_NAME_H +
|
||
HEAD_SCORE_H +
|
||
HEAD_RULE_H +
|
||
rowHeights.reduce((a, b) => a + b, 0) +
|
||
(sheet.hasAdjustments ? ADJ_ROW_H : 0);
|
||
const side = Math.max(MIN_SIDE, Math.ceil(histContentH));
|
||
const histW = seats * COL_W + (seats - 1) * COL_GAP;
|
||
return {
|
||
w: MARGIN + AXIS + side + GAP + histW + MARGIN,
|
||
h: MARGIN + AXIS + side + FOOTER_H + MARGIN,
|
||
board: { x: MARGIN + AXIS, y: MARGIN + AXIS, side, cell: side / BOARD_SIZE },
|
||
hist: { x: MARGIN + AXIS + side + GAP, y: MARGIN + AXIS, w: histW, colW: COL_W, colGap: COL_GAP },
|
||
rowHeights,
|
||
headNameH: HEAD_NAME_H,
|
||
headScoreH: HEAD_SCORE_H,
|
||
headRuleH: HEAD_RULE_H,
|
||
rowH: ROW_H,
|
||
subRowH: SUB_ROW_H,
|
||
adjRowH: ADJ_ROW_H,
|
||
footerH: FOOTER_H,
|
||
axis: AXIS,
|
||
margin: MARGIN,
|
||
};
|
||
}
|
||
|
||
/** RenderOptions carries the presentation the caller resolves from app state. */
|
||
export interface RenderOptions {
|
||
/** Localizes a non-play move for the scoresheet (pass/exchange/resign/timeout). */
|
||
actionLabel: (action: string) => string;
|
||
/** Footer host override (defaults to location.hostname) — for tests/harnesses. */
|
||
hostname?: string;
|
||
/** Footer date locale override (defaults to the device locale) — for tests. */
|
||
dateLocale?: string;
|
||
/** Footer IANA time zone (the device zone on a server render; defaults to local). */
|
||
timeZone?: string;
|
||
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
|
||
scale?: number;
|
||
}
|
||
|
||
/** CanvasLike is the minimal canvas surface drawGameImage needs — satisfied by both the
|
||
* browser HTMLCanvasElement and the render sidecar's skia-canvas Canvas, so the drawing
|
||
* code is shared verbatim between the two runtimes. */
|
||
export interface CanvasLike {
|
||
width: number;
|
||
height: number;
|
||
getContext(contextId: '2d'): CanvasRenderingContext2D | null;
|
||
}
|
||
|
||
/**
|
||
* drawGameImage sizes the canvas and draws the finished game onto it: final board with
|
||
* classic axes on the left, the scoresheet on the right, the site + finish date at the
|
||
* bottom. Light theme always; no last-move highlight (an archival artifact, not a live
|
||
* frame). The caller owns the canvas and its PNG encoding (toBlob in the browser,
|
||
* toBuffer in the sidecar).
|
||
*/
|
||
export function drawGameImage(canvas: CanvasLike, game: GameView, moves: MoveRecord[], opts: RenderOptions): void {
|
||
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
|
||
hostname: opts.hostname,
|
||
dateLocale: opts.dateLocale,
|
||
timeZone: opts.timeZone,
|
||
});
|
||
const layout = layoutImage(sheet);
|
||
const scale = opts.scale ?? 2;
|
||
canvas.width = Math.round(layout.w * scale);
|
||
canvas.height = Math.round(layout.h * scale);
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) throw new Error('gameimage: no 2d context');
|
||
ctx.scale(scale, scale);
|
||
|
||
ctx.fillStyle = C.paper;
|
||
ctx.fillRect(0, 0, layout.w, layout.h);
|
||
drawAxes(ctx, layout);
|
||
drawBoard(ctx, layout, game.variant, moves);
|
||
drawSheet(ctx, layout, sheet);
|
||
|
||
ctx.fillStyle = C.muted;
|
||
ctx.font = `11px ${FONT}`;
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'alphabetic';
|
||
ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
|
||
}
|
||
|
||
|
||
function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
|
||
const { x, y, cell } = l.board;
|
||
ctx.fillStyle = C.muted;
|
||
ctx.font = `${Math.max(10, cell * 0.28)}px ${FONT}`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
for (let c = 0; c < BOARD_SIZE; c++) {
|
||
ctx.fillText(String.fromCharCode(65 + c), x + c * cell + cell / 2, y - l.axis / 2);
|
||
}
|
||
for (let r = 0; r < BOARD_SIZE; r++) {
|
||
ctx.fillText(String(r + 1), x - l.axis / 2, y + r * cell + cell / 2);
|
||
}
|
||
}
|
||
|
||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function drawBoard(
|
||
ctx: CanvasRenderingContext2D,
|
||
l: ImageLayout,
|
||
variant: Variant,
|
||
moves: MoveRecord[],
|
||
): void {
|
||
const { x, y, cell, side } = l.board;
|
||
const board = replay(moves);
|
||
const prem = premiumGrid(variant);
|
||
const ctr = centre(variant);
|
||
|
||
// Squares first (premium colours + the alternating plain-cell tint, as in game). Bonus
|
||
// squares carry NO text labels on the export — the fill alone reads universally, with the
|
||
// coordinate axes carrying the location.
|
||
for (let r = 0; r < BOARD_SIZE; r++) {
|
||
for (let c = 0; c < BOARD_SIZE; c++) {
|
||
const p = prem[r][c];
|
||
ctx.fillStyle = p ? C.prem[p] : (r + c) % 2 === 1 && !board[r][c] ? C.cellDark : C.cell;
|
||
ctx.fillRect(x + c * cell, y + r * cell, cell + 0.5, cell + 0.5);
|
||
}
|
||
}
|
||
|
||
// The centre star on an uncovered centre square (rare in a finished game).
|
||
if (!board[ctr.row][ctr.col]) {
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = C.premText;
|
||
ctx.globalAlpha = 0.7;
|
||
ctx.font = `${cell * 0.54}px ${FONT}`;
|
||
ctx.fillText('★', x + ctr.col * cell + cell / 2, y + ctr.row * cell + cell / 2);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
// Tiles: rounded face, inset bottom bevel and a soft right-side shadow, letter top-left,
|
||
// value bottom-right — the in-game look (Board.svelte), light palette.
|
||
const rad = cell * 0.1;
|
||
for (let r = 0; r < BOARD_SIZE; r++) {
|
||
for (let c = 0; c < BOARD_SIZE; c++) {
|
||
const t = board[r][c];
|
||
if (!t) continue;
|
||
const tx = x + c * cell;
|
||
const ty = y + r * cell;
|
||
ctx.save();
|
||
ctx.shadowColor = 'rgba(0,0,0,0.4)';
|
||
ctx.shadowOffsetX = cell * 0.05;
|
||
ctx.shadowBlur = cell * 0.07;
|
||
ctx.fillStyle = C.tile;
|
||
roundRect(ctx, tx, ty, cell, cell, rad);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
// Inset bottom bevel: a tile-edge strip clipped to the rounded face.
|
||
ctx.save();
|
||
roundRect(ctx, tx, ty, cell, cell, rad);
|
||
ctx.clip();
|
||
ctx.fillStyle = C.tileEdge;
|
||
ctx.fillRect(tx, ty + cell - cell * 0.05, cell, cell * 0.05);
|
||
ctx.restore();
|
||
|
||
ctx.fillStyle = C.tileText;
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
ctx.font = `700 ${cell * 0.63}px ${FONT}`;
|
||
ctx.fillText(t.letter, tx + cell * 0.08, ty + cell * 0.07);
|
||
const isErudit = variant === 'erudit_ru';
|
||
ctx.textAlign = 'right';
|
||
ctx.textBaseline = 'alphabetic';
|
||
if (t.blank && isErudit) {
|
||
// The Erudit blank marker (✻) in the value corner, as in game (usesStarBlank).
|
||
ctx.font = `${cell * 0.32}px ${FONT}`;
|
||
ctx.fillText('✻', tx + cell * 0.9, ty + cell * 0.9);
|
||
} else if (!t.blank) {
|
||
ctx.font = `600 ${cell * 0.28}px ${FONT}`;
|
||
ctx.fillText(String(valueForLetter(variant, t.letter)), tx + cell * 0.9, ty + cell * 0.9);
|
||
}
|
||
}
|
||
}
|
||
|
||
// A hairline frame so the pale board edge reads against the paper.
|
||
ctx.strokeStyle = C.border;
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(x - 0.5, y - 0.5, side + 1, side + 1);
|
||
}
|
||
|
||
/** fitText shrinks the font size until text fits maxW (floor 8.5px), returning the size. */
|
||
function fitText(ctx: CanvasRenderingContext2D, text: string, weight: string, size: number, maxW: number): number {
|
||
let s = size;
|
||
ctx.font = `${weight} ${s}px ${FONT}`;
|
||
while (s > 8.5 && ctx.measureText(text).width > maxW) {
|
||
s -= 0.5;
|
||
ctx.font = `${weight} ${s}px ${FONT}`;
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
|
||
if (ctx.measureText(text).width <= maxW) return text;
|
||
let t = text;
|
||
while (t.length > 1 && ctx.measureText(t + '…').width > maxW) t = t.slice(0, -1);
|
||
return t + '…';
|
||
}
|
||
|
||
function drawSheet(ctx: CanvasRenderingContext2D, l: ImageLayout, sheet: Scoresheet): void {
|
||
const { x, y, colW, colGap } = l.hist;
|
||
const seats = sheet.header.length;
|
||
const colX = (i: number) => x + i * (colW + colGap);
|
||
|
||
// Header: per-seat name (ellipsized) over the final score; 🏆 marks the winner.
|
||
for (let i = 0; i < seats; i++) {
|
||
const h = sheet.header[i];
|
||
const cx = colX(i) + colW / 2;
|
||
ctx.fillStyle = C.text;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.font = `600 13px ${FONT}`;
|
||
ctx.fillText(ellipsize(ctx, h.name, colW - 8), cx, y + l.headNameH / 2);
|
||
ctx.font = `700 16px ${FONT}`;
|
||
const score = h.winner ? `${h.score} 🏆` : String(h.score);
|
||
ctx.fillText(score, cx, y + l.headNameH + l.headScoreH / 2);
|
||
}
|
||
// Rule under the header, spanning the sheet.
|
||
const ruleY = y + l.headNameH + l.headScoreH + l.headRuleH / 2;
|
||
ctx.strokeStyle = C.border;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, ruleY);
|
||
ctx.lineTo(x + l.hist.w, ruleY);
|
||
ctx.stroke();
|
||
|
||
// Move rows: "<coord> <WORD>" left, points right; extra words on a small second line.
|
||
let rowY = y + l.headNameH + l.headScoreH + l.headRuleH;
|
||
for (let r = 0; r < sheet.rows.length; r++) {
|
||
const row = sheet.rows[r];
|
||
const rh = l.rowHeights[r];
|
||
for (let i = 0; i < seats; i++) {
|
||
const cell = row[i];
|
||
const bx = colX(i);
|
||
const baseline = rowY + l.rowH / 2;
|
||
if (cell.kind === 'play') {
|
||
const scoreText = String(cell.score);
|
||
ctx.textAlign = 'right';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.font = `600 11.5px ${FONT}`;
|
||
ctx.fillStyle = C.text;
|
||
ctx.fillText(scoreText, bx + colW, baseline);
|
||
const scoreW = ctx.measureText(scoreText).width + 6;
|
||
|
||
ctx.textAlign = 'left';
|
||
ctx.fillStyle = C.muted;
|
||
ctx.font = `600 10.5px ${FONT}`;
|
||
ctx.fillText(cell.coord, bx, baseline);
|
||
const coordW = ctx.measureText('D15').width + 5; // fixed slot so words align
|
||
|
||
const wordMax = colW - coordW - scoreW;
|
||
const word = cell.words[0] ?? '';
|
||
ctx.fillStyle = C.text;
|
||
const size = fitText(ctx, word, '500', 11.5, wordMax);
|
||
ctx.font = `500 ${size}px ${FONT}`;
|
||
ctx.fillText(ellipsize(ctx, word, wordMax), bx + coordW, baseline);
|
||
|
||
if (cell.words.length > 1) {
|
||
ctx.fillStyle = C.muted;
|
||
ctx.font = `9.5px ${FONT}`;
|
||
ctx.fillText(
|
||
ellipsize(ctx, `+ ${cell.words.slice(1).join(', ')}`, colW - coordW),
|
||
bx + coordW,
|
||
rowY + l.rowH + l.subRowH / 2 - 1,
|
||
);
|
||
}
|
||
} else if (cell.kind === 'action') {
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = C.muted;
|
||
ctx.font = `italic 10.5px ${FONT}`;
|
||
ctx.fillText(ellipsize(ctx, cell.label, colW), bx, baseline);
|
||
}
|
||
}
|
||
rowY += rh;
|
||
}
|
||
|
||
// Endgame rack settlement (when any seat has one): a closing ± row per column.
|
||
if (sheet.hasAdjustments) {
|
||
for (let i = 0; i < seats; i++) {
|
||
const a = sheet.adjustments[i];
|
||
if (a === 0) continue;
|
||
ctx.textAlign = 'right';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = C.muted;
|
||
ctx.font = `600 10.5px ${FONT}`;
|
||
ctx.fillText(a > 0 ? `+${a}` : String(a), colX(i) + colW, rowY + l.adjRowH / 2);
|
||
}
|
||
}
|
||
}
|