1import { actionMatches, keycapPressFrame, stepRollFrame } from './motion.js';2import { BOARD_COLORS } from './board/model.js';34function keycapOptions(align, options) {5 if (align && typeof align === 'object') return { ...align, align: align.align || 'left' };6 return { ...(options || {}), align: align || 'left' };7}89function keycapPress(options, action) {10 const press = options?.press || options?.keycapPress;11 if (press && typeof press === 'object' && !actionMatches(action, press.action)) return { press: 0, offsetY: 0 };12 if (typeof press === 'number') return { press: press, offsetY: press * 4 };13 if (press && typeof press === 'object') return keycapPressFrame({14 now: options.now,15 startedAt: press.startedAt ?? press.start,16 reducedMotion: options.reducedMotion,17 });18 if (options.pressStart !== undefined && actionMatches(action, options.pressAction)) return keycapPressFrame({19 now: options.now,20 startedAt: options.pressStart,21 reducedMotion: options.reducedMotion,22 });23 return { press: 0, offsetY: 0 };24}2526export function drawKeycap(ctx, key, label, x, y, align = "left", options = {}) {27 const settings = keycapOptions(align, options);28 const action = settings.action || label;29 const frame = keycapPress(settings, action);30 ctx.save();31 ctx.textBaseline = "middle";32 ctx.font = "800 10px Recursive, monospace";33 const keyWidth = Math.max(24, ctx.measureText(key).width + 12);34 ctx.font = "600 10px Recursive, monospace";35 const labelWidth = ctx.measureText(label).width;36 const left = settings.align === "right" ? x - keyWidth - 8 - labelWidth : x;37 const pressY = frame.offsetY || 0;38 ctx.fillStyle = "#000000";39 if (!frame.press) ctx.fillRect(left + 4, y - 6, keyWidth, 20);40 ctx.fillStyle = "#F2EEE6";41 ctx.fillRect(left, y - 10 + pressY, keyWidth, 20);42 ctx.fillStyle = "#0F0E0D";43 ctx.textAlign = "left";44 ctx.font = "800 10px Recursive, monospace";45 ctx.fillText(key, left + 6, y + 1 + pressY);46 ctx.font = "600 10px Recursive, monospace";47 ctx.fillStyle = "#8E877C";48 ctx.fillText(label, left + keyWidth + 8, y + 1 + pressY);49 ctx.restore();50}5152export function drawStepCounter(ctx, value, x, y, options = {}) {53 const roll = options.roll || null;54 const frame = roll55 ? stepRollFrame({ ...roll, now: options.now, reducedMotion: options.reducedMotion })56 : stepRollFrame({ from: value, to: value, reducedMotion: true });57 const digits = frame.newDigits;58 ctx.save();59 ctx.textAlign = 'right';60 ctx.textBaseline = 'middle';61 const fontSize = options.size || 40;62 ctx.font = `800 ${fontSize}px Recursive, monospace`;63 const digitWidth = Math.max(20, ctx.measureText('0').width);64 const digitHeight = options.digitHeight || (fontSize === 40 ? 34 : fontSize + 2);65 const right = x;66 const top = y - digitHeight / 2;67 for (let index = 0; index < digits.length; index += 1) {68 const digitX = right - (digits.length - index - 0.5) * digitWidth;69 ctx.save();70 ctx.beginPath();71 ctx.rect(digitX - digitWidth / 2, top, digitWidth, digitHeight);72 ctx.clip();73 if (frame.changed[index] && frame.progress < 1) {74 const travel = digitHeight * frame.eased;75 const oldY = y - frame.direction * travel;76 const newY = y + frame.direction * (digitWidth - travel);77 ctx.textAlign = 'center';78 ctx.fillText(frame.oldDigits[index], digitX, oldY);79 ctx.fillText(digits[index], digitX, newY);80 } else {81 ctx.textAlign = 'center';82 ctx.fillText(digits[index], digitX, y);83 }84 ctx.restore();85 }86 ctx.restore();87 return frame;88}8990export function drawInlineActions(ctx, items, centerX, y, options = {}) {91 ctx.save();92 ctx.font = "800 10px Recursive, monospace";93 const metrics = items.map(({ key, label, action }) => {94 const keyWidth = Math.max(24, ctx.measureText(key).width + 12);95 ctx.font = "600 10px Recursive, monospace";96 const labelWidth = ctx.measureText(label).width;97 ctx.font = "800 10px Recursive, monospace";98 return {99 key,100 label,101 action,102 keyWidth,103 labelWidth,104 width: keyWidth + 8 + labelWidth,105 };106 });107 const gap = 18;108 const total =109 metrics.reduce((sum, item) => sum + item.width, 0) +110 gap * (metrics.length - 1);111 let x = centerX - total / 2;112 for (const item of metrics) {113 drawKeycap(ctx, item.key, item.label, x, y, 'left', {114 ...options,115 action: item.action || item.label,116 });117 x += item.width + gap;118 }119 ctx.restore();120}121122export function drawStackedAction(ctx, key, label, centerX, y) {123 ctx.save();124 ctx.font = "800 10px Recursive, monospace";125 const keyWidth = Math.max(24, ctx.measureText(key).width + 12);126 ctx.fillStyle = "#F2EEE6";127 ctx.fillRect(centerX - keyWidth / 2, y - 10, keyWidth, 20);128 ctx.fillStyle = "#0F0E0D";129 ctx.textAlign = "center";130 ctx.textBaseline = "middle";131 ctx.fillText(key, centerX, y + 1);132 ctx.fillStyle = "#8E877C";133 ctx.font = "600 9px Recursive, monospace";134 ctx.fillText(label, centerX, y + 27);135 ctx.restore();136}137138export function drawTouchControls(139 ctx,140 width,141 height,142 force = false,143 hasAutomata = false,144) {145 if (width > 760 && !force) return;146 const y = height - 30;147 const controls = [148 { label: "UNDO", key: "↶", x: 18 },149 { label: "RESTART", key: "⟲", x: width / 2, center: true },150 { label: "PAUSE", key: "☰", x: width - 18, right: true },151 ];152 ctx.save();153 ctx.textBaseline = "middle";154 for (const control of controls) {155 const text = `${control.key} ${control.label}`;156 ctx.font = "700 10px Recursive, monospace";157 const boxWidth = ctx.measureText(text).width + 18;158 const left = control.center159 ? control.x - boxWidth / 2160 : control.right161 ? control.x - boxWidth162 : control.x;163 ctx.fillStyle = "#232120";164 ctx.strokeStyle = "#4A4640";165 ctx.lineWidth = 1;166 ctx.fillRect(left, y - 14, boxWidth, 28);167 ctx.strokeRect(left + 0.5, y - 13.5, boxWidth - 1, 27);168 ctx.fillStyle = "#F2EEE6";169 ctx.textAlign = "center";170 ctx.fillText(text, left + boxWidth / 2, y + 1);171 }172 ctx.fillStyle = "#8E877C";173 ctx.font = "500 10px Recursive, monospace";174 ctx.textAlign = "center";175 if (!hasAutomata) ctx.fillText("SWIPE TO WALK", width / 2, y - 22);176 if (hasAutomata) {177 const waitText = "WAIT";178 ctx.font = "700 10px Recursive, monospace";179 const waitWidth = Math.max(96, ctx.measureText(waitText).width + 18);180 const waitLeft = width / 2 - waitWidth / 2;181 ctx.fillStyle = "#232120";182 ctx.strokeStyle = "#4A4640";183 ctx.fillRect(waitLeft, height - 84, waitWidth, 32);184 ctx.strokeRect(waitLeft + 0.5, height - 83.5, waitWidth - 1, 31);185 ctx.fillStyle = "#F2EEE6";186 ctx.fillText(waitText, width / 2, height - 68);187 }188 ctx.restore();189}190191function automataGlyph(subject, palette = {}, glyphs = {}) {192 const entry = palette?.[String(subject || "").toUpperCase()];193 if (entry && typeof entry === "object" && entry.glyph) return entry.glyph;194 if (typeof entry === "string" && entry.length) return entry;195 return glyphs?.[String(subject || "").toUpperCase()] || "⚙️";196}197198function automataCue(action) {199 // Beat cells stay a compact visual vocabulary. A blocked move keeps its200 // intended direction; the board nudge carries the blocked feedback.201 if (action?.action === "wait") return "‖";202 if (action?.dx === 1) return "→";203 if (action?.dx === -1) return "←";204 if (action?.dy === 1) return "↓";205 if (action?.dy === -1) return "↑";206 return "‖";207}208209export function drawAutomataStrip(210 ctx,211 automata,212 x,213 y,214 maxWidth,215 rules = [],216 options = {},217) {218 const viewportWidth = options.viewportWidth ?? maxWidth;219 const palette = options.palette || {};220 const glyphs = options.glyphs || {};221 const rows = Array.isArray(automata)222 ? automata.filter((item) => item?.pattern?.length)223 : [];224 if (!rows.length) return { bottom: y, height: 0 };225 const visibleRows = rows.slice(0, viewportWidth <= 760 ? 2 : 3);226 const rowHeight = viewportWidth <= 760 ? 24 : 30;227 const actorWidth = 28;228 const actorGap = 6;229 const labelGap = 10;230 const beatGap = 5;231 const maxBeats = Math.max(4, Math.floor((maxWidth - actorWidth - actorGap - 76 - labelGap) / (22 + beatGap)));232 ctx.save();233 ctx.textBaseline = "middle";234 visibleRows.forEach((system, rowIndex) => {235 const rowY = y + rowIndex * rowHeight;236 const phase = Number.isInteger(system.phase)237 ? system.phase % system.pattern.length238 : 0;239 const derivedSubject =240 system.subject ||241 rules.find(242 (rule) =>243 String(rule.predicate || "").toUpperCase() ===244 String(system.property || "").toUpperCase(),245 )?.subject;246 const beatIndexes =247 system.pattern.length <= maxBeats248 ? system.pattern.map((_, index) => index)249 : [250 ...new Set([251 phase,252 (phase + 1) % system.pattern.length,253 (phase + system.pattern.length - 1) % system.pattern.length,254 (phase + 2) % system.pattern.length,255 ]),256 ];257 const omitted = system.pattern.length - beatIndexes.length;258 const glyph = automataGlyph(derivedSubject, palette, glyphs);259 ctx.fillStyle = "#F2EEE6";260 ctx.font = `28px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;261 ctx.textAlign = "left";262 ctx.fillText(glyph, x, rowY + rowHeight / 2);263 const beatStart = x + actorWidth + actorGap;264 beatIndexes.forEach((patternIndex, beatIndex) => {265 const action = system.pattern[patternIndex];266 const beatX = beatStart + beatIndex * (22 + beatGap);267 const current = patternIndex === phase;268 const played = patternIndex === (phase + system.pattern.length - 1) % system.pattern.length;269 ctx.globalAlpha = current || played ? 1 : 0.4;270 ctx.fillStyle = current ? "#C6F432" : "#191816";271 ctx.strokeStyle = current || played ? "#C6F432" : "#4A4640";272 ctx.lineWidth = current ? 2 : 1;273 ctx.fillRect(beatX, rowY + 1, 22, 22);274 if (!current) ctx.strokeRect(beatX + 0.5, rowY + 1.5, 21, 21);275 const cue = automataCue(action);276 ctx.fillStyle = current ? "#0F0E0D" : played ? "#F2EEE6" : "#8E877C";277 ctx.font = `${current ? "800" : "600"} 14px Recursive, monospace`;278 ctx.textAlign = "center";279 ctx.fillText(cue, beatX + 11, rowY + 13);280 ctx.globalAlpha = 1;281 if (omitted && beatIndex === beatIndexes.length - 1) {282 ctx.fillStyle = "#8E877C";283 ctx.font = "600 8px Recursive, monospace";284 ctx.fillText(285 `+${omitted}`,286 beatX + 11,287 rowY + rowHeight - 2,288 );289 }290 });291 const labelX = beatStart + beatIndexes.length * (22 + beatGap) - beatGap + labelGap;292 ctx.fillStyle = "#8E877C";293 ctx.font = "600 9px Recursive, monospace";294 ctx.textAlign = "left";295 ctx.fillText(296 `${String(derivedSubject || "AUTOMATA").toUpperCase()} IS ${String(system.property || "TICK").toUpperCase()}`,297 labelX,298 rowY + rowHeight / 2,299 );300 });301 if (rows.length > visibleRows.length) {302 ctx.fillStyle = "#8E877C";303 ctx.font = "600 9px Recursive, monospace";304 ctx.textAlign = "right";305 ctx.fillText(306 `+${rows.length - visibleRows.length} automata`,307 x + maxWidth,308 y + visibleRows.length * rowHeight - 4,309 );310 }311 ctx.restore();312 return {313 bottom: y + visibleRows.length * rowHeight,314 height: visibleRows.length * rowHeight,315 };316}317318export function drawMiniTile(319 ctx,320 text,321 x,322 y,323 role = "noun",324 size = 18,325 opacity = 1,326) {327 const fills = {328 noun: "#F2EEE6",329 YOU: BOARD_COLORS.YOU,330 WIN: BOARD_COLORS.WIN,331 PUSH: BOARD_COLORS.PUSH,332 STOP: BOARD_COLORS.STOP,333 TICK: BOARD_COLORS.TICK,334 SINK: BOARD_COLORS.SINK,335 DEFEAT: BOARD_COLORS.DEFEAT,336 IS: "#F2EEE6",337 AND: "#F2EEE6",338 };339 ctx.save();340 ctx.globalAlpha = opacity;341 ctx.font = `800 ${size}px Recursive, monospace`;342 const width = Math.max(size * 1.6, ctx.measureText(text).width + size);343 if (role === "IS" || role === "AND") {344 ctx.strokeStyle = "#8E877C";345 ctx.lineWidth = 1;346 ctx.beginPath();347 if (ctx.roundRect) ctx.roundRect(x, y + 1, width, size * 1.2, size * 0.6);348 else ctx.rect(x, y + 1, width, size * 1.2);349 ctx.stroke();350 } else {351 ctx.fillStyle = "#000000";352 ctx.fillRect(x + 3, y + 3, width, size * 1.5);353 ctx.fillStyle = fills[role] || fills.noun;354 ctx.fillRect(x, y, width, size * 1.5);355 }356 ctx.fillStyle = role === "IS" || role === "AND" ? "#F2EEE6" : "#0F0E0D";357 ctx.textAlign = "center";358 ctx.textBaseline = "middle";359 ctx.fillText(text, x + width / 2, y + size * 0.78);360 ctx.restore();361 return width;362}363364export function drawSentenceTiles(365 ctx,366 sentence,367 x,368 y,369 maxWidth,370 size = 16,371 opacity = 1,372) {373 const words = String(sentence || "")374 .split(/\s+/)375 .filter(Boolean);376 let cursor = x;377 for (const word of words) {378 const value = word.toUpperCase();379 const role = ["YOU", "WIN", "PUSH", "STOP", "TICK", "SINK", "DEFEAT", "IS", "AND"].includes(380 value,381 )382 ? value383 : "noun";384 const width = drawMiniTile(ctx, value, cursor, y, role, size, opacity);385 cursor += width + Math.max(5, size * 0.4);386 if (cursor > x + maxWidth) break;387 }388 return cursor;389}390391function drawLedgerTile(ctx, text, x, y, role, progress = 1, exitProgress = 0) {392 const stamp = Math.max(0, Math.min(1, Number(progress) || 0));393 const exit = Math.max(0, Math.min(1, Number(exitProgress) || 0));394 if (stamp <= 0 || exit >= 1) return;395 const scale = 1 + 0.3 * (1 - stamp);396 const opacity = 1 - exit;397 const size = 10;398 const width = Math.max(size * 1.6, ctx.measureText(String(text).toUpperCase()).width + size);399 const centerX = x + width / 2;400 const centerY = y + size * 0.75 + exit * 8;401 ctx.save();402 ctx.translate(centerX, centerY);403 ctx.scale(scale, scale);404 ctx.translate(-centerX, -centerY);405 drawMiniTile(ctx, text, x, y + exit * 8, role, size, opacity);406 ctx.restore();407 return width;408}409410function ledgerRole(value) {411 const upper = String(value || '').toUpperCase();412 return ['YOU', 'WIN', 'PUSH', 'STOP', 'TICK', 'SINK', 'DEFEAT', 'IS', 'AND'].includes(upper)413 ? upper414 : 'noun';415}416417export function drawLedger(ctx, rules, x, y, maxWidth, options = {}) {418 const lineHeight = 26;419 const sentenceGap = 26;420 const tileGap = 7;421 const animated = Array.isArray(options.ledger) && options.ledger.some(422 entry => Array.isArray(entry?.tokens) && entry.tokens.length > 0,423 );424 const entries = animated425 ? options.ledger426 : (rules || []).map(rule => ({427 ids: [],428 tokens: String(rule.text || rule).split(/\s+/).filter(Boolean),429 stampProgress: [],430 exitProgress: 0,431 }));432 let cursor = x;433 let lineY = y;434 for (const entry of entries) {435 const words = (entry.tokens || []).map(word => String(word || '').toUpperCase()).filter(Boolean);436 if (!words.length) continue;437 ctx.font = "800 10px Recursive, monospace";438 const sentenceWidth =439 words.reduce(440 (total, word) =>441 total + Math.max(16, ctx.measureText(word.toUpperCase()).width + 10),442 0,443 ) +444 Math.max(0, words.length - 1) * tileGap;445 if (446 cursor !== x &&447 sentenceWidth <= maxWidth &&448 cursor + sentenceWidth > x + maxWidth449 ) {450 lineY += lineHeight;451 cursor = x;452 }453 const strikeSegments = [];454 let segmentStart = cursor;455 let segmentRight = cursor;456 let segmentY = lineY;457 for (const [index, word] of words.entries()) {458 ctx.font = "800 10px Recursive, monospace";459 const tileWidth = Math.max(460 16,461 ctx.measureText(word.toUpperCase()).width + 10,462 );463 if (cursor !== x && cursor + tileWidth > x + maxWidth) {464 strikeSegments.push({ x: segmentStart, right: segmentRight, y: segmentY });465 lineY += lineHeight;466 cursor = x;467 segmentStart = cursor;468 segmentRight = cursor;469 segmentY = lineY;470 }471 const value = word.toUpperCase();472 const stampProgress = entry.stampProgress?.[index] ?? 1;473 const exitProgress = entry.exitProgress ?? 0;474 drawLedgerTile(ctx, value, cursor, lineY, ledgerRole(value), stampProgress, exitProgress);475 segmentRight = cursor + tileWidth;476 cursor += tileWidth + tileGap;477 }478 strikeSegments.push({ x: segmentStart, right: segmentRight, y: segmentY });479 if ((entry.strikeProgress || 0) > 0) {480 for (const segment of strikeSegments) {481 if (segment.right <= segment.x) continue;482 ctx.save();483 ctx.strokeStyle = "#FF4B3E";484 ctx.globalAlpha = Math.min(1, entry.strikeProgress);485 ctx.lineWidth = 2;486 ctx.beginPath();487 ctx.moveTo(segment.x, segment.y + 8);488 ctx.lineTo(segment.right, segment.y + 8);489 ctx.stroke();490 ctx.restore();491 }492 }493 cursor += sentenceGap;494 }495 lineY += lineHeight;496 cursor = x;497 for (const word of ["TEXT", "IS", "PUSH"]) {498 ctx.font = "800 10px Recursive, monospace";499 const tileWidth = Math.max(16, ctx.measureText(word).width + 10);500 drawMiniTile(501 ctx,502 word,503 cursor,504 lineY,505 word === "PUSH" ? "PUSH" : word === "IS" ? "IS" : "noun",506 10,507 0.42,508 );509 cursor += tileWidth + tileGap;510 }511 return { bottom: lineY + 15 };512}513514export function drawHint(ctx, hint, x, y, maxWidth, maxBottom) {515 const words = String(hint).split(/\s+/);516 ctx.save();517 ctx.fillStyle = "#8E877C";518 ctx.font = "500 11px Recursive, monospace";519 ctx.textAlign = "left";520 ctx.textBaseline = "top";521 let line = "";522 let lineY = y;523 for (const word of words) {524 const next = line ? `${line} ${word}` : word;525 if (line && ctx.measureText(next).width > maxWidth) {526 ctx.fillText(line, x, lineY);527 line = word;528 lineY += 16;529 } else line = next;530 }531 if (line && lineY <= maxBottom) ctx.fillText(line, x, lineY);532 ctx.restore();533}