feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+378
View File
@@ -0,0 +1,378 @@
import { useRef, type CSSProperties, type KeyboardEvent } from "react";
import {
aidMemoireCellDescription,
clearAidMemoireEntries,
compactAidMemoireColumns,
configureAidMemoire,
enterAidMemoireCell,
eraseAidMemoireCell,
labelAidMemoireCell,
MAX_AID_MEMOIRE_CELLS,
MAX_AID_MEMOIRE_COLUMNS,
resetAidMemoire,
setAidMemoireEnabled,
type AidMemoireState,
} from "../state/aidMemoire";
import {
maskValues,
symbolFor,
valueForKey,
type EntryMode,
} from "../state/session";
interface AidMemoireProps {
readonly size: number;
readonly state: AidMemoireState;
readonly selectedCell: number;
readonly active: boolean;
readonly readOnly?: boolean;
readonly mode: EntryMode;
readonly onStateChange: (state: AidMemoireState) => void;
readonly onSelect: (cell: number) => void;
readonly onMode: (mode: EntryMode) => void;
}
const modes: ReadonlyArray<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
{ mode: "corner", label: "Corner", key: "X" },
{ mode: "center", label: "Centre", key: "C" },
{ mode: "color", label: "Colour", key: "V" },
];
export function AidMemoire({
size,
state,
selectedCell,
active,
readOnly = false,
mode,
onStateChange,
onSelect,
onMode,
}: AidMemoireProps) {
const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);
const selected = Math.max(0, Math.min(state.cells.length - 1, selectedCell));
const selectAndFocus = (cell: number) => {
const next = Math.max(0, Math.min(state.cells.length - 1, cell));
onSelect(next);
cellRefs.current[next]?.focus();
};
const handleKeyDown = (
cell: number,
event: KeyboardEvent<HTMLButtonElement>,
) => {
const row = Math.floor(cell / state.columns);
const column = cell % state.columns;
const rowStart = row * state.columns;
const rowEnd = Math.min(
state.cells.length - 1,
rowStart + state.columns - 1,
);
let destination: number | undefined;
if (event.key === "ArrowLeft") destination = Math.max(rowStart, cell - 1);
if (event.key === "ArrowRight") destination = Math.min(rowEnd, cell + 1);
if (event.key === "ArrowUp") {
destination = cell - state.columns >= 0 ? cell - state.columns : cell;
}
if (event.key === "ArrowDown") {
destination =
cell + state.columns < state.cells.length ? cell + state.columns : cell;
}
if (event.key === "Home") {
destination = event.ctrlKey || event.metaKey ? 0 : rowStart;
}
if (event.key === "End") {
destination =
event.ctrlKey || event.metaKey ? state.cells.length - 1 : rowEnd;
}
if (event.key === "PageUp") destination = column;
if (event.key === "PageDown") {
const lastRowStart =
Math.floor((state.cells.length - 1) / state.columns) * state.columns;
destination = Math.min(state.cells.length - 1, lastRowStart + column);
}
if (destination !== undefined) {
event.preventDefault();
selectAndFocus(destination);
return;
}
if (readOnly) return;
if (
event.key === "Backspace" ||
event.key === "Delete" ||
event.key === "0"
) {
event.preventDefault();
onStateChange(eraseAidMemoireCell(state, cell, mode));
return;
}
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
const modeKey: Record<string, EntryMode> = {
z: "value",
x: "corner",
c: "center",
v: "color",
};
const nextMode = modeKey[event.key.toLowerCase()];
if (nextMode !== undefined) {
event.preventDefault();
onMode(nextMode);
return;
}
const value = valueForKey(event.key, mode === "color" ? 8 : size);
if (value !== null) {
event.preventDefault();
onStateChange(enterAidMemoireCell(state, cell, mode, value, size));
}
}
};
const configure = (cellCount: number, columns: number) => {
const next = configureAidMemoire(state, size, cellCount, columns);
onStateChange(next);
if (selected >= next.cells.length) onSelect(next.cells.length - 1);
};
return (
<section className="aid-memoire" aria-labelledby="aid-memoire-title">
<header className="section-heading aid-memoire__heading">
<div>
<p className="eyebrow">Optional scratch cells</p>
<h2 id="aid-memoire-title">Aid-mémoire</h2>
<p className="muted">
Track contextual sets, pseudo-digits or mappings. These cells do not
constrain the Sudoku.
</p>
</div>
<button
type="button"
className="text-button"
disabled={readOnly}
onClick={() => onStateChange(setAidMemoireEnabled(state, false))}
>
Hide
</button>
</header>
<div
className="aid-memoire__scroller"
aria-label="Scrollable aid-mémoire area"
>
<div
className="aid-memoire__grid"
role="grid"
aria-label={`${String(state.cells.length)} aid-mémoire scratch cells`}
aria-colcount={state.columns}
aria-rowcount={Math.ceil(state.cells.length / state.columns)}
aria-readonly={readOnly}
style={{ "--aid-columns": state.columns } as CSSProperties}
>
{Array.from(
{ length: Math.ceil(state.cells.length / state.columns) },
(_, rowIndex) => (
<div
key={rowIndex}
className="aid-memoire__row"
role="row"
aria-rowindex={rowIndex + 1}
>
{state.cells
.slice(
rowIndex * state.columns,
(rowIndex + 1) * state.columns,
)
.map((cell, columnIndex) => {
const index = rowIndex * state.columns + columnIndex;
const cornerMarks = maskValues(cell.cornerMarks, size);
const centerMarks = maskValues(cell.centerMarks, size);
return (
<button
key={index}
ref={(element) => {
cellRefs.current[index] = element;
}}
type="button"
role="gridcell"
className={`aid-memoire__cell${active && selected === index ? " is-selected" : ""}${cell.color ? ` has-color-${String(cell.color)}` : ""}`}
aria-label={aidMemoireCellDescription(
cell,
index,
size,
)}
aria-colindex={columnIndex + 1}
aria-selected={active && selected === index}
tabIndex={selected === index ? 0 : -1}
onClick={() => {
if (!readOnly) onSelect(index);
}}
onFocus={() => {
if (!readOnly) onSelect(index);
}}
onKeyDown={(event) => handleKeyDown(index, event)}
>
<span className="aid-memoire__label" aria-hidden="true">
{cell.label || `#${String(index + 1)}`}
</span>
{cell.value ? (
<span
className="aid-memoire__value"
aria-hidden="true"
>
{symbolFor(cell.value, size)}
</span>
) : (
<>
<span
className="aid-memoire__corner"
aria-hidden="true"
>
{cornerMarks
.map((mark) => symbolFor(mark, size))
.join(" ")}
</span>
<span
className="aid-memoire__centre"
aria-hidden="true"
>
{centerMarks
.map((mark) => symbolFor(mark, size))
.join("")}
</span>
</>
)}
</button>
);
})}
</div>
),
)}
</div>
</div>
<div
className="mode-switcher aid-memoire__modes"
role="group"
aria-label="Aid-mémoire entry mode"
>
{modes.map((item) => (
<button
key={item.mode}
type="button"
disabled={readOnly}
className={mode === item.mode ? "is-active" : ""}
aria-pressed={mode === item.mode}
title={`${item.label} mode (${item.key})`}
onClick={() => onMode(item.mode)}
>
{item.label}
</button>
))}
</div>
<div className="aid-memoire__selected">
<label>
Selected cell label
<input
value={state.cells[selected]?.label ?? ""}
maxLength={40}
disabled={readOnly}
onChange={(event) =>
onStateChange(
labelAidMemoireCell(state, selected, event.target.value),
)
}
/>
</label>
<p className="muted">
The regular keypad edits this cell in {mode} mode. Digits, Z/X/C/V,
Delete and arrow keys also work directly in the scratch grid.
</p>
</div>
<details className="aid-memoire__settings">
<summary>Configure scratch layout</summary>
<div className="aid-memoire__settings-body">
<div className="inline-fields">
<label>
Cells
<input
type="number"
min={1}
max={MAX_AID_MEMOIRE_CELLS}
value={state.cells.length}
disabled={readOnly}
onChange={(event) =>
configure(Number(event.target.value), state.columns)
}
/>
</label>
<label>
Columns
<input
type="number"
min={1}
max={Math.min(MAX_AID_MEMOIRE_COLUMNS, state.cells.length)}
value={state.columns}
disabled={readOnly}
onChange={(event) =>
configure(state.cells.length, Number(event.target.value))
}
/>
</label>
</div>
<div className="action-row">
<button
type="button"
disabled={readOnly}
onClick={() => configure(state.cells.length, state.cells.length)}
>
Single row
</button>
<button
type="button"
disabled={readOnly}
onClick={() =>
configure(
state.cells.length,
compactAidMemoireColumns(state.cells.length),
)
}
>
Compact grid
</button>
<button
type="button"
disabled={readOnly}
onClick={() => {
if (window.confirm("Clear every aid-mémoire entry?")) {
onStateChange(clearAidMemoireEntries(state));
}
}}
>
Clear entries
</button>
<button
type="button"
className="danger"
disabled={readOnly}
onClick={() => {
if (
window.confirm(
"Reset the aid-mémoire layout, labels and entries?",
)
) {
onStateChange(resetAidMemoire(state, size));
onSelect(0);
}
}}
>
Reset all
</button>
</div>
</div>
</details>
</section>
);
}