feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { compilePuzzle, type NormalizedPuzzle } from "../domain";
|
||||
import {
|
||||
candidateCellsForValues,
|
||||
candidateUnitIndicesForCells,
|
||||
cellLabel,
|
||||
deriveCandidateLinks,
|
||||
houseLabel,
|
||||
inspectCandidateCells,
|
||||
inspectCandidateHouses,
|
||||
type CandidateLink,
|
||||
type CandidateOverlay,
|
||||
} from "../helpers";
|
||||
import { symbolFor } from "../state/session";
|
||||
|
||||
const LINK_LIST_LIMIT = 160;
|
||||
const OVERLAY_LINK_LIMIT = 500;
|
||||
|
||||
interface CandidateLabProps {
|
||||
readonly puzzle: NormalizedPuzzle;
|
||||
readonly values: readonly number[];
|
||||
readonly candidateMasks: readonly number[];
|
||||
readonly selectedCells: readonly number[];
|
||||
readonly onOverlayChange?: (overlay: CandidateOverlay | undefined) => void;
|
||||
}
|
||||
|
||||
function nodeLabel(node: CandidateLink["a"], size: number): string {
|
||||
return `${cellLabel(node.cell, size)}(${symbolFor(node.value, size)})`;
|
||||
}
|
||||
|
||||
function linkLabel(link: CandidateLink, size: number): string {
|
||||
return `${nodeLabel(link.a, size)} ${link.kind === "strong" ? "⇔" : "—"} ${nodeLabel(link.b, size)}`;
|
||||
}
|
||||
|
||||
export function CandidateLab({
|
||||
puzzle,
|
||||
values,
|
||||
candidateMasks,
|
||||
selectedCells,
|
||||
onOverlayChange,
|
||||
}: CandidateLabProps) {
|
||||
const [activeValues, setActiveValues] = useState<readonly number[]>([]);
|
||||
const [scope, setScope] = useState("selection");
|
||||
const [showStrong, setShowStrong] = useState(true);
|
||||
const [showWeak, setShowWeak] = useState(false);
|
||||
const [includeCellLinks, setIncludeCellLinks] = useState(true);
|
||||
const [focusedLinkId, setFocusedLinkId] = useState<string>();
|
||||
const compiled = useMemo(() => compilePuzzle(puzzle), [puzzle]);
|
||||
const effectiveActiveValues = useMemo(
|
||||
() => activeValues.filter((value) => value <= puzzle.size),
|
||||
[activeValues, puzzle.size],
|
||||
);
|
||||
const relatedUnitIndices = useMemo(
|
||||
() => candidateUnitIndicesForCells(puzzle, selectedCells),
|
||||
[puzzle, selectedCells],
|
||||
);
|
||||
const selectedUnitIndex = scope.startsWith("unit:")
|
||||
? Number(scope.slice(5))
|
||||
: undefined;
|
||||
const effectiveScope =
|
||||
selectedUnitIndex !== undefined &&
|
||||
!relatedUnitIndices.includes(selectedUnitIndex)
|
||||
? "selection"
|
||||
: scope;
|
||||
const scopedUnitIndices = useMemo(() => {
|
||||
if (effectiveScope === "all")
|
||||
return compiled.units.map((_, index) => index);
|
||||
if (
|
||||
selectedUnitIndex !== undefined &&
|
||||
Number.isInteger(selectedUnitIndex) &&
|
||||
compiled.units[selectedUnitIndex] !== undefined
|
||||
) {
|
||||
return [selectedUnitIndex];
|
||||
}
|
||||
return relatedUnitIndices;
|
||||
}, [compiled.units, effectiveScope, relatedUnitIndices, selectedUnitIndex]);
|
||||
const scopedCells = useMemo(() => {
|
||||
if (effectiveScope === "selection") return [...new Set(selectedCells)];
|
||||
return [
|
||||
...new Set(
|
||||
scopedUnitIndices.flatMap(
|
||||
(unitIndex) => compiled.units[unitIndex]?.cells ?? [],
|
||||
),
|
||||
),
|
||||
];
|
||||
}, [compiled.units, effectiveScope, scopedUnitIndices, selectedCells]);
|
||||
const cellInspections = useMemo(
|
||||
() => inspectCandidateCells(puzzle, candidateMasks, selectedCells),
|
||||
[candidateMasks, puzzle, selectedCells],
|
||||
);
|
||||
const houseInspections = useMemo(
|
||||
() =>
|
||||
inspectCandidateHouses(puzzle, values, candidateMasks, scopedUnitIndices),
|
||||
[candidateMasks, puzzle, scopedUnitIndices, values],
|
||||
);
|
||||
const links = useMemo(
|
||||
() =>
|
||||
deriveCandidateLinks(puzzle, candidateMasks, {
|
||||
unitIndices: scopedUnitIndices,
|
||||
cellIndices: scopedCells,
|
||||
...(effectiveActiveValues.length === 0
|
||||
? {}
|
||||
: { values: effectiveActiveValues }),
|
||||
includeCellLinks,
|
||||
}).filter(({ kind }) => (kind === "strong" ? showStrong : showWeak)),
|
||||
[
|
||||
candidateMasks,
|
||||
effectiveActiveValues,
|
||||
includeCellLinks,
|
||||
puzzle,
|
||||
scopedCells,
|
||||
scopedUnitIndices,
|
||||
showStrong,
|
||||
showWeak,
|
||||
],
|
||||
);
|
||||
const focusedLink = links.find(({ id }) => id === focusedLinkId);
|
||||
const visibleLinks = useMemo(
|
||||
() =>
|
||||
focusedLink === undefined
|
||||
? links.slice(0, OVERLAY_LINK_LIMIT)
|
||||
: [focusedLink],
|
||||
[focusedLink, links],
|
||||
);
|
||||
const candidateCells = useMemo(() => {
|
||||
if (effectiveActiveValues.length === 0) return [];
|
||||
return candidateCellsForValues(candidateMasks, effectiveActiveValues);
|
||||
}, [candidateMasks, effectiveActiveValues]);
|
||||
const overlay = useMemo<CandidateOverlay>(
|
||||
() => ({
|
||||
activeValues: effectiveActiveValues,
|
||||
candidateCells,
|
||||
links: visibleLinks,
|
||||
}),
|
||||
[candidateCells, effectiveActiveValues, visibleLinks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onOverlayChange?.(overlay);
|
||||
}, [onOverlayChange, overlay]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
onOverlayChange?.(undefined);
|
||||
},
|
||||
[onOverlayChange],
|
||||
);
|
||||
|
||||
const toggleValue = (value: number) => {
|
||||
setFocusedLinkId(undefined);
|
||||
setActiveValues((current) => {
|
||||
const valid = current.filter((candidate) => candidate <= puzzle.size);
|
||||
return valid.includes(value)
|
||||
? valid.filter((candidate) => candidate !== value)
|
||||
: [...valid, value].sort((a, b) => a - b);
|
||||
});
|
||||
};
|
||||
const strongCount = links.filter(({ kind }) => kind === "strong").length;
|
||||
const weakCount = links.length - strongCount;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="candidate-lab stack"
|
||||
aria-labelledby="candidate-lab-title"
|
||||
>
|
||||
<div>
|
||||
<p className="eyebrow">Candidate graph</p>
|
||||
<h3 id="candidate-lab-title">Links and houses</h3>
|
||||
<p className="muted">
|
||||
Inspect legal candidates without changing any notes. A solid line is
|
||||
strong (at least one endpoint is true); a dashed line is weak (both
|
||||
endpoints cannot be true).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="candidate-filter">
|
||||
<legend>Digit filters</legend>
|
||||
<div
|
||||
className={`candidate-filter__digits${puzzle.size > 9 ? " candidate-filter__digits--wide" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={effectiveActiveValues.length === 0 ? "is-active" : ""}
|
||||
aria-pressed={effectiveActiveValues.length === 0}
|
||||
onClick={() => {
|
||||
setActiveValues([]);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{Array.from({ length: puzzle.size }, (_, index) => index + 1).map(
|
||||
(value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={
|
||||
effectiveActiveValues.includes(value) ? "is-active" : ""
|
||||
}
|
||||
aria-label={`Filter candidate ${symbolFor(value, puzzle.size)}`}
|
||||
aria-pressed={effectiveActiveValues.includes(value)}
|
||||
onClick={() => toggleValue(value)}
|
||||
>
|
||||
{symbolFor(value, puzzle.size)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="candidate-lab__controls">
|
||||
<label>
|
||||
Inspect
|
||||
<select
|
||||
aria-label="Candidate house scope"
|
||||
value={effectiveScope}
|
||||
onChange={(event) => {
|
||||
setScope(event.target.value);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
>
|
||||
<option value="selection">
|
||||
Selected cells and touching houses
|
||||
</option>
|
||||
{relatedUnitIndices.map((unitIndex) => (
|
||||
<option key={unitIndex} value={`unit:${String(unitIndex)}`}>
|
||||
{houseLabel(compiled.units[unitIndex]!)}
|
||||
</option>
|
||||
))}
|
||||
<option value="all">Whole grid</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showStrong}
|
||||
onChange={(event) => {
|
||||
setShowStrong(event.target.checked);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
/>
|
||||
Strong links
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showWeak}
|
||||
onChange={(event) => {
|
||||
setShowWeak(event.target.checked);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
/>
|
||||
Weak links
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCellLinks}
|
||||
onChange={(event) => {
|
||||
setIncludeCellLinks(event.target.checked);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
/>
|
||||
Links inside cells
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="metric-row" aria-live="polite">
|
||||
<span>
|
||||
Candidate cells <strong>{candidateCells.length}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Strong links <strong>{strongCount}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Weak links <strong>{weakCount}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="candidate-inspection"
|
||||
aria-labelledby="selected-candidates-title"
|
||||
>
|
||||
<h4 id="selected-candidates-title">Selected cells</h4>
|
||||
<div className="candidate-cell-list">
|
||||
{cellInspections.map((inspection) => (
|
||||
<article key={inspection.cell}>
|
||||
<strong>{cellLabel(inspection.cell, puzzle.size)}</strong>
|
||||
<span>
|
||||
{inspection.values.length === 0
|
||||
? values[inspection.cell]
|
||||
? `Filled ${symbolFor(values[inspection.cell]!, puzzle.size)}`
|
||||
: "No legal candidates"
|
||||
: inspection.values
|
||||
.map((value) => symbolFor(value, puzzle.size))
|
||||
.join(" ")}
|
||||
</span>
|
||||
<small>{inspection.houseLabels.join(" · ")}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="candidate-inspection"
|
||||
aria-labelledby="house-candidates-title"
|
||||
>
|
||||
<h4 id="house-candidates-title">House positions</h4>
|
||||
<div className="candidate-house-list">
|
||||
{houseInspections.map((inspection) => (
|
||||
<details
|
||||
key={inspection.unitIndex}
|
||||
open={houseInspections.length === 1}
|
||||
>
|
||||
<summary>
|
||||
<strong>{inspection.label}</strong>
|
||||
<span>{inspection.missingValues.length} missing</span>
|
||||
</summary>
|
||||
<dl>
|
||||
{inspection.positions
|
||||
.filter(
|
||||
({ value }) =>
|
||||
effectiveActiveValues.length === 0 ||
|
||||
effectiveActiveValues.includes(value),
|
||||
)
|
||||
.map((position) => (
|
||||
<div
|
||||
key={position.value}
|
||||
data-link-kind={position.linkKind}
|
||||
>
|
||||
<dt>{symbolFor(position.value, puzzle.size)}</dt>
|
||||
<dd>
|
||||
{position.cells.length === 0
|
||||
? "nowhere"
|
||||
: position.cells
|
||||
.map((cell) => cellLabel(cell, puzzle.size))
|
||||
.join(" · ")}
|
||||
{position.linkKind === "strong" ? " · strong" : ""}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="candidate-inspection"
|
||||
aria-labelledby="candidate-links-title"
|
||||
>
|
||||
<div className="candidate-section-heading">
|
||||
<h4 id="candidate-links-title">Visible links</h4>
|
||||
{focusedLink !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => setFocusedLinkId(undefined)}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{links.length === 0 ? (
|
||||
<p className="muted">
|
||||
No links match this scope and filter. Enable weak links or inspect
|
||||
another house.
|
||||
</p>
|
||||
) : (
|
||||
<div className="candidate-link-list">
|
||||
{links.slice(0, LINK_LIST_LIMIT).map((link) => (
|
||||
<button
|
||||
key={link.id}
|
||||
type="button"
|
||||
className={`candidate-link candidate-link--${link.kind}`}
|
||||
aria-pressed={focusedLink?.id === link.id}
|
||||
title={link.contexts.map(({ label }) => label).join(", ")}
|
||||
onClick={() =>
|
||||
setFocusedLinkId((current) =>
|
||||
current === link.id ? undefined : link.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>{linkLabel(link, puzzle.size)}</span>
|
||||
<small>
|
||||
{link.kind} ·{" "}
|
||||
{link.contexts.map(({ label }) => label).join(" / ")}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{links.length > LINK_LIST_LIMIT && (
|
||||
<p className="muted">
|
||||
Showing the first {LINK_LIST_LIMIT} of {links.length} links. Narrow
|
||||
the digit or house filter to inspect the rest.
|
||||
</p>
|
||||
)}
|
||||
{focusedLink === undefined && links.length > OVERLAY_LINK_LIMIT && (
|
||||
<p className="muted">
|
||||
The board overlay is capped at {OVERLAY_LINK_LIMIT} links for
|
||||
responsiveness. Focus a listed link or narrow the filters to see a
|
||||
precise relationship.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { cellsFormQuadruple } from "../domain/geometry";
|
||||
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
|
||||
import {
|
||||
removeKillerCagesAtCells,
|
||||
@@ -19,6 +20,10 @@ interface ConstraintEditorProps {
|
||||
function describeConstraint(constraint: VariantConstraint, size: number) {
|
||||
const cell = (index: number) =>
|
||||
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
|
||||
const marked = (description: string) =>
|
||||
"negated" in constraint && constraint.negated === true
|
||||
? `false · ${description}`
|
||||
: description;
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
return `${constraint.direction} diagonal`;
|
||||
@@ -29,22 +34,67 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
|
||||
case "non-consecutive":
|
||||
return "non-consecutive";
|
||||
case "killer-cage":
|
||||
return `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells`;
|
||||
return marked(
|
||||
`${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells${constraint.noRepeat === false ? " · repeats allowed" : ""}`,
|
||||
);
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return `${constraint.type} · ${String(constraint.cells.length)} cells`;
|
||||
return marked(
|
||||
`${constraint.type} · ${String(constraint.cells.length)} cells`,
|
||||
);
|
||||
case "arrow":
|
||||
return `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`;
|
||||
return marked(
|
||||
`arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`,
|
||||
);
|
||||
case "kropki":
|
||||
return `${constraint.kind} dot · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||||
return marked(
|
||||
`${constraint.kind} dot · ${cell(constraint.a)}–${cell(constraint.b)}`,
|
||||
);
|
||||
case "xv":
|
||||
return `sum ${String(constraint.total)} pair · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||||
return marked(
|
||||
`sum ${String(constraint.total)} pair · ${cell(constraint.a)}–${cell(constraint.b)}`,
|
||||
);
|
||||
case "inequality":
|
||||
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
|
||||
return marked(`${cell(constraint.lesser)} < ${cell(constraint.greater)}`);
|
||||
case "x-sum":
|
||||
return marked(
|
||||
`Σ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)}`,
|
||||
);
|
||||
case "skyscraper":
|
||||
return marked(
|
||||
`skyscraper ${String(constraint.count)} · ${constraint.side} ${String(constraint.index + 1)}`,
|
||||
);
|
||||
case "quadruple":
|
||||
return marked(
|
||||
`quadruple ${constraint.digits.join("")} · ${String(constraint.cells.length)} cells`,
|
||||
);
|
||||
case "maximum":
|
||||
return marked(`maximum · ${cell(constraint.cell)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function supportsPolarity(constraint: VariantConstraint): boolean {
|
||||
return !["diagonal", "anti-knight", "anti-king", "non-consecutive"].includes(
|
||||
constraint.type,
|
||||
);
|
||||
}
|
||||
|
||||
function parseClueDigits(text: string, size: number): number[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return [];
|
||||
const rawTokens = trimmed.split(/[\s,;]+/u).filter(Boolean);
|
||||
const tokens =
|
||||
size <= 9 && rawTokens.length === 1 && /^\d{2,4}$/u.test(rawTokens[0]!)
|
||||
? [...rawTokens[0]!]
|
||||
: rawTokens;
|
||||
return tokens.map((token) =>
|
||||
/^[A-G]$/iu.test(token)
|
||||
? token.toUpperCase().charCodeAt(0) - 55
|
||||
: Number(token),
|
||||
);
|
||||
}
|
||||
|
||||
export function ConstraintEditor({
|
||||
puzzle,
|
||||
selection,
|
||||
@@ -55,23 +105,55 @@ export function ConstraintEditor({
|
||||
busy,
|
||||
}: ConstraintEditorProps) {
|
||||
const [cageSum, setCageSum] = useState(10);
|
||||
const [cageAllowsRepeats, setCageAllowsRepeats] = useState(false);
|
||||
const [region, setRegion] = useState(1);
|
||||
const [newCluesAreFalse, setNewCluesAreFalse] = useState(false);
|
||||
const [quadrupleDigits, setQuadrupleDigits] = useState("1, 2, 3");
|
||||
const [outsideType, setOutsideType] = useState<"x-sum" | "skyscraper">(
|
||||
"x-sum",
|
||||
);
|
||||
const [outsideSide, setOutsideSide] = useState<
|
||||
"top" | "right" | "bottom" | "left"
|
||||
>("top");
|
||||
const [outsideLine, setOutsideLine] = useState(1);
|
||||
const [outsideValue, setOutsideValue] = useState(3);
|
||||
const constraints = puzzle.constraints ?? [];
|
||||
|
||||
const append = (constraint: VariantConstraint) =>
|
||||
onChange({ ...puzzle, constraints: [...constraints, constraint] });
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: [
|
||||
...constraints,
|
||||
supportsPolarity(constraint) && newCluesAreFalse
|
||||
? ({ ...constraint, negated: true } as VariantConstraint)
|
||||
: constraint,
|
||||
],
|
||||
});
|
||||
const need = (count: number) => selection.length === count;
|
||||
const atLeast = (count: number) => selection.length >= count;
|
||||
const cageCellCount = Math.max(1, selection.length);
|
||||
const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2;
|
||||
const maximumCageSum =
|
||||
(cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
|
||||
const minimumCageSum = cageAllowsRepeats
|
||||
? cageCellCount
|
||||
: (cageCellCount * (cageCellCount + 1)) / 2;
|
||||
const maximumCageSum = cageAllowsRepeats
|
||||
? cageCellCount * puzzle.size
|
||||
: (cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
|
||||
const validCage =
|
||||
selection.length >= 1 &&
|
||||
selection.length <= puzzle.size &&
|
||||
Number.isInteger(cageSum) &&
|
||||
cageSum >= minimumCageSum &&
|
||||
cageSum <= maximumCageSum;
|
||||
(newCluesAreFalse
|
||||
? cageSum >= 1 && cageSum <= puzzle.size ** 3
|
||||
: cageSum >= minimumCageSum && cageSum <= maximumCageSum);
|
||||
const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size);
|
||||
const validQuadruple =
|
||||
cellsFormQuadruple(puzzle.size, selection) &&
|
||||
parsedQuadrupleDigits.length >= 1 &&
|
||||
parsedQuadrupleDigits.length <= 4 &&
|
||||
parsedQuadrupleDigits.length <= selection.length &&
|
||||
parsedQuadrupleDigits.every(
|
||||
(digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size,
|
||||
);
|
||||
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
|
||||
|
||||
const toggleGlobal = (
|
||||
@@ -154,13 +236,21 @@ export function ConstraintEditor({
|
||||
Selection order defines lines and pair direction. Shift-click or drag
|
||||
to build a selection.
|
||||
</p>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newCluesAreFalse}
|
||||
onChange={(event) => setNewCluesAreFalse(event.target.checked)}
|
||||
/>
|
||||
Newly added clues must be false (Wrogn mode)
|
||||
</label>
|
||||
<div className="inline-fields">
|
||||
<label className="compact-field">
|
||||
Cage sum
|
||||
<input
|
||||
type="number"
|
||||
min={minimumCageSum}
|
||||
max={maximumCageSum}
|
||||
min={newCluesAreFalse ? 1 : minimumCageSum}
|
||||
max={newCluesAreFalse ? puzzle.size ** 3 : maximumCageSum}
|
||||
value={cageSum}
|
||||
onChange={(event) => setCageSum(Number(event.target.value))}
|
||||
/>
|
||||
@@ -175,6 +265,8 @@ export function ConstraintEditor({
|
||||
type: "killer-cage",
|
||||
cells: selection,
|
||||
sum: cageSum,
|
||||
...(cageAllowsRepeats ? { noRepeat: false } : {}),
|
||||
...(newCluesAreFalse ? { negated: true } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -195,6 +287,14 @@ export function ConstraintEditor({
|
||||
Remove selected cage
|
||||
</button>
|
||||
</div>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cageAllowsRepeats}
|
||||
onChange={(event) => setCageAllowsRepeats(event.target.checked)}
|
||||
/>
|
||||
Cage digits may repeat
|
||||
</label>
|
||||
<p className="muted">
|
||||
Adding replaces any cage touching the selection. Removing clears every
|
||||
cage touching a selected cell.
|
||||
@@ -316,7 +416,41 @@ export function ConstraintEditor({
|
||||
>
|
||||
1st > 2nd (inequality)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!need(1)}
|
||||
onClick={() => append({ type: "maximum", cell: selection[0]! })}
|
||||
>
|
||||
Maximum cell
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-fields">
|
||||
<label className="compact-field">
|
||||
Quadruple digits
|
||||
<input
|
||||
value={quadrupleDigits}
|
||||
placeholder="1, 3, 8"
|
||||
onChange={(event) => setQuadrupleDigits(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!validQuadruple}
|
||||
onClick={() =>
|
||||
append({
|
||||
type: "quadruple",
|
||||
cells: selection,
|
||||
digits: parsedQuadrupleDigits,
|
||||
})
|
||||
}
|
||||
>
|
||||
Add quadruple
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Select the four cells meeting at one grid intersection for a
|
||||
quadruple.
|
||||
</p>
|
||||
<div className="inline-fields">
|
||||
<label className="compact-field">
|
||||
Region
|
||||
@@ -342,6 +476,120 @@ export function ConstraintEditor({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-section">
|
||||
<p className="eyebrow">Outside clues</p>
|
||||
<h3>X-sums and skyscrapers</h3>
|
||||
<p className="muted">
|
||||
Choose the edge and row or column. Σ badges are X-sums; ▥ badges are
|
||||
visibility counts.
|
||||
</p>
|
||||
<div className="inline-fields outside-clue-fields">
|
||||
<label className="compact-field">
|
||||
Type
|
||||
<select
|
||||
value={outsideType}
|
||||
onChange={(event) =>
|
||||
setOutsideType(event.target.value as "x-sum" | "skyscraper")
|
||||
}
|
||||
>
|
||||
<option value="x-sum">X-sum</option>
|
||||
<option value="skyscraper">Skyscraper</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="compact-field">
|
||||
Side
|
||||
<select
|
||||
value={outsideSide}
|
||||
onChange={(event) =>
|
||||
setOutsideSide(
|
||||
event.target.value as "top" | "right" | "bottom" | "left",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="top">Top</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="bottom">Bottom</option>
|
||||
<option value="left">Left</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="compact-field">
|
||||
Row / column
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={puzzle.size}
|
||||
value={outsideLine}
|
||||
onChange={(event) => setOutsideLine(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact-field">
|
||||
Clue
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={
|
||||
newCluesAreFalse
|
||||
? puzzle.size ** 4
|
||||
: outsideType === "x-sum"
|
||||
? (puzzle.size * (puzzle.size + 1)) / 2
|
||||
: puzzle.size
|
||||
}
|
||||
value={outsideValue}
|
||||
onChange={(event) => setOutsideValue(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!Number.isInteger(outsideLine) ||
|
||||
outsideLine < 1 ||
|
||||
outsideLine > puzzle.size ||
|
||||
!Number.isInteger(outsideValue) ||
|
||||
outsideValue < 1 ||
|
||||
outsideValue >
|
||||
(newCluesAreFalse
|
||||
? puzzle.size ** 4
|
||||
: outsideType === "x-sum"
|
||||
? (puzzle.size * (puzzle.size + 1)) / 2
|
||||
: puzzle.size)
|
||||
}
|
||||
onClick={() => {
|
||||
const clue: VariantConstraint =
|
||||
outsideType === "x-sum"
|
||||
? {
|
||||
type: "x-sum",
|
||||
side: outsideSide,
|
||||
index: outsideLine - 1,
|
||||
sum: outsideValue,
|
||||
}
|
||||
: {
|
||||
type: "skyscraper",
|
||||
side: outsideSide,
|
||||
index: outsideLine - 1,
|
||||
count: outsideValue,
|
||||
};
|
||||
const withoutExisting = constraints.filter(
|
||||
(constraint) =>
|
||||
constraint.type !== outsideType ||
|
||||
constraint.side !== outsideSide ||
|
||||
constraint.index !== outsideLine - 1,
|
||||
);
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: [
|
||||
...withoutExisting,
|
||||
newCluesAreFalse
|
||||
? ({ ...clue, negated: true } as VariantConstraint)
|
||||
: clue,
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add / replace outside clue
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-section">
|
||||
<p className="eyebrow">Global rules</p>
|
||||
<div className="button-grid">
|
||||
@@ -398,27 +646,107 @@ export function ConstraintEditor({
|
||||
|
||||
{constraints.length > 0 && (
|
||||
<section className="panel-section">
|
||||
<p className="eyebrow">Constraints</p>
|
||||
<div className="section-heading">
|
||||
<p className="eyebrow">Constraints</p>
|
||||
<div className="constraint-batch-actions">
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: constraints.map((constraint) =>
|
||||
supportsPolarity(constraint)
|
||||
? ({
|
||||
...constraint,
|
||||
negated: true,
|
||||
} as VariantConstraint)
|
||||
: constraint,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Make all clues false
|
||||
</button>
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: constraints.map((constraint) => {
|
||||
if (!supportsPolarity(constraint)) return constraint;
|
||||
const cleaned = { ...constraint } as VariantConstraint & {
|
||||
negated?: boolean;
|
||||
};
|
||||
delete cleaned.negated;
|
||||
return cleaned;
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Make all clues true
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="constraint-list">
|
||||
{constraints.map((constraint, index) => (
|
||||
<li key={`${constraint.type}-${String(index)}`}>
|
||||
<span>{describeConstraint(constraint, puzzle.size)}</span>
|
||||
<button
|
||||
className="text-button danger"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: constraints.filter(
|
||||
(_, item) => item !== index,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{constraints.map((constraint, index) => {
|
||||
const description = describeConstraint(constraint, puzzle.size);
|
||||
const negated =
|
||||
"negated" in constraint && constraint.negated === true;
|
||||
return (
|
||||
<li key={`${constraint.type}-${String(index)}`}>
|
||||
<span>{description}</span>
|
||||
<span className="constraint-actions">
|
||||
{supportsPolarity(constraint) && (
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
aria-label={`${negated ? "Require true for" : "Require false for"} ${description}`}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: constraints.map((item, itemIndex) => {
|
||||
if (itemIndex !== index) return item;
|
||||
if ("negated" in item && item.negated === true) {
|
||||
const cleaned = {
|
||||
...item,
|
||||
} as VariantConstraint & {
|
||||
negated?: boolean;
|
||||
};
|
||||
delete cleaned.negated;
|
||||
return cleaned;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
negated: true,
|
||||
} as VariantConstraint;
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{negated ? "Require true" : "Require false"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="text-button danger"
|
||||
type="button"
|
||||
aria-label={`Remove ${description}`}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: constraints.filter(
|
||||
(_, item) => item !== index,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
activeHypothesis,
|
||||
gameplayMoment,
|
||||
MAIN_BRANCH_ID,
|
||||
type GameplayHistory,
|
||||
} from "../state/playHistory";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface GameplayHistoryDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly history: GameplayHistory;
|
||||
readonly replayMomentId?: string;
|
||||
readonly onClose: () => void;
|
||||
readonly onCreateSavepoint: (name: string) => void;
|
||||
readonly onRestoreSavepoint: (savepointId: string) => void;
|
||||
readonly onDeleteSavepoint: (savepointId: string) => void;
|
||||
readonly onStartHypothesis: (name: string, fromMomentId?: string) => void;
|
||||
readonly onFinishHypothesis: (decision: "keep" | "discard") => void;
|
||||
readonly onReplayMoment: (momentId: string) => void;
|
||||
readonly onReturnLive: () => void;
|
||||
}
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
const rest = seconds % 60;
|
||||
return [hours, minutes, rest]
|
||||
.map((part) => String(part).padStart(2, "0"))
|
||||
.join(":");
|
||||
}
|
||||
|
||||
export function GameplayHistoryDialog({
|
||||
open,
|
||||
history,
|
||||
replayMomentId,
|
||||
onClose,
|
||||
onCreateSavepoint,
|
||||
onRestoreSavepoint,
|
||||
onDeleteSavepoint,
|
||||
onStartHypothesis,
|
||||
onFinishHypothesis,
|
||||
onReplayMoment,
|
||||
onReturnLive,
|
||||
}: GameplayHistoryDialogProps) {
|
||||
const [hypothesisName, setHypothesisName] = useState("");
|
||||
const [savepointName, setSavepointName] = useState("");
|
||||
const hypothesis = activeHypothesis(history);
|
||||
const replayMoment = replayMomentId
|
||||
? gameplayMoment(history, replayMomentId)
|
||||
: undefined;
|
||||
const replayIndex = replayMoment
|
||||
? history.moments.findIndex((moment) => moment.id === replayMoment.id)
|
||||
: history.moments.length - 1;
|
||||
const branchNames = useMemo(
|
||||
() =>
|
||||
new Map([
|
||||
[MAIN_BRANCH_ID, "Main solve"],
|
||||
...history.branches.map((branch) => [branch.id, branch.name] as const),
|
||||
]),
|
||||
[history.branches],
|
||||
);
|
||||
|
||||
const submitHypothesis = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const name = hypothesisName.trim();
|
||||
if (!name) return;
|
||||
onStartHypothesis(name, replayMoment?.id);
|
||||
setHypothesisName("");
|
||||
};
|
||||
|
||||
const submitSavepoint = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const name = savepointName.trim();
|
||||
if (!name) return;
|
||||
onCreateSavepoint(name);
|
||||
setSavepointName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Branches, savepoints and replay"
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
<div className="history-dialog-grid">
|
||||
<div className="stack">
|
||||
<section className="panel-section history-section">
|
||||
<div>
|
||||
<p className="eyebrow">Hypotheses</p>
|
||||
<h3>Try a path without losing your solve</h3>
|
||||
</div>
|
||||
{hypothesis ? (
|
||||
<div className="hypothesis-active" role="status">
|
||||
<div>
|
||||
<strong>{hypothesis.name}</strong>
|
||||
<span>Every move is being kept on this branch.</span>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFinishHypothesis("keep")}
|
||||
>
|
||||
Keep changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => onFinishHypothesis("discard")}
|
||||
>
|
||||
Discard and return
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="history-name-form" onSubmit={submitHypothesis}>
|
||||
<label>
|
||||
Hypothesis name
|
||||
<input
|
||||
value={hypothesisName}
|
||||
maxLength={80}
|
||||
placeholder="e.g. Assume r4c7 is 8"
|
||||
onChange={(event) => setHypothesisName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={!hypothesisName.trim()}>
|
||||
{replayMoment ? "Branch from this step" : "Start hypothesis"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{history.branches.length > 0 && (
|
||||
<ul className="branch-list" aria-label="Hypothesis branches">
|
||||
{history.branches.map((branch) => (
|
||||
<li key={branch.id}>
|
||||
<span>{branch.name}</span>
|
||||
<span
|
||||
className={`branch-status branch-status--${branch.status}`}
|
||||
>
|
||||
{branch.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel-section history-section">
|
||||
<div>
|
||||
<p className="eyebrow">Named savepoints</p>
|
||||
<h3>Return to a deliberate checkpoint</h3>
|
||||
</div>
|
||||
<form className="history-name-form" onSubmit={submitSavepoint}>
|
||||
<label>
|
||||
Savepoint name
|
||||
<input
|
||||
value={savepointName}
|
||||
maxLength={80}
|
||||
disabled={replayMoment !== undefined}
|
||||
placeholder="e.g. Before the long chain"
|
||||
onChange={(event) => setSavepointName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!savepointName.trim() || replayMoment !== undefined}
|
||||
>
|
||||
Save current grid
|
||||
</button>
|
||||
</form>
|
||||
{history.savepoints.length === 0 ? (
|
||||
<p className="muted">No savepoints yet.</p>
|
||||
) : (
|
||||
<ul className="savepoint-list" aria-label="Named savepoints">
|
||||
{history.savepoints.map((savepoint) => (
|
||||
<li key={savepoint.id}>
|
||||
<div>
|
||||
<strong>{savepoint.name}</strong>
|
||||
<span>
|
||||
{formatElapsed(savepoint.state.elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={replayMoment !== undefined}
|
||||
onClick={() => onRestoreSavepoint(savepoint.id)}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
aria-label={`Delete savepoint ${savepoint.name}`}
|
||||
onClick={() => onDeleteSavepoint(savepoint.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="panel-section history-section history-replay">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Replay</p>
|
||||
<h3>Review every recorded state</h3>
|
||||
</div>
|
||||
<span className="status-pill">
|
||||
{String(Math.max(0, history.moments.length - 1))} changes
|
||||
</span>
|
||||
</div>
|
||||
{replayMoment ? (
|
||||
<p className="callout" role="status">
|
||||
Viewing “{replayMoment.label}”. The board is read-only until you
|
||||
return live or branch from this step.
|
||||
</p>
|
||||
) : (
|
||||
<p className="muted">
|
||||
Choose a step to inspect its complete grid, notes, colours and
|
||||
elapsed time.
|
||||
</p>
|
||||
)}
|
||||
<div className="action-row history-replay-controls">
|
||||
<button
|
||||
type="button"
|
||||
disabled={hypothesis !== undefined || replayIndex <= 0}
|
||||
onClick={() =>
|
||||
onReplayMoment(history.moments[replayIndex - 1]!.id)
|
||||
}
|
||||
>
|
||||
Previous step
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
replayMoment === undefined ||
|
||||
hypothesis !== undefined ||
|
||||
replayIndex >= history.moments.length - 1
|
||||
}
|
||||
onClick={() =>
|
||||
onReplayMoment(history.moments[replayIndex + 1]!.id)
|
||||
}
|
||||
>
|
||||
Next step
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={replayMoment === undefined}
|
||||
onClick={onReturnLive}
|
||||
>
|
||||
Return to live grid
|
||||
</button>
|
||||
</div>
|
||||
<ol className="history-list" aria-label="Solve history">
|
||||
{[...history.moments].reverse().map((moment) => {
|
||||
const isReplay = moment.id === replayMoment?.id;
|
||||
const isCurrent =
|
||||
replayMoment === undefined &&
|
||||
moment.id === history.currentMomentId;
|
||||
return (
|
||||
<li key={moment.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={isReplay || isCurrent ? "is-current" : ""}
|
||||
disabled={hypothesis !== undefined}
|
||||
aria-current={isReplay || isCurrent ? "step" : undefined}
|
||||
onClick={() =>
|
||||
isCurrent ? onReturnLive() : onReplayMoment(moment.id)
|
||||
}
|
||||
>
|
||||
<span className="history-step-number">
|
||||
{String(moment.sequence).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="history-step-copy">
|
||||
<strong>{moment.label}</strong>
|
||||
<span>
|
||||
{branchNames.get(moment.branchId) ?? "Hypothesis"} ·{" "}
|
||||
{formatElapsed(moment.state.elapsedSeconds)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
GENERATOR_VARIANTS,
|
||||
PRACTICE_TECHNIQUES,
|
||||
type DifficultyAssessment,
|
||||
type GeneratedVariantPuzzle,
|
||||
type GenerationDifficultyTarget,
|
||||
type GeneratorVariant,
|
||||
type GenerateVariantOptions,
|
||||
type PracticeTechnique,
|
||||
} from "../solver";
|
||||
|
||||
const difficultyTargets: readonly GenerationDifficultyTarget[] = [
|
||||
@@ -49,6 +51,10 @@ export function GeneratorWorkspace({
|
||||
const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
|
||||
const [constraintCount, setConstraintCount] = useState(8);
|
||||
const [seed, setSeed] = useState("");
|
||||
const [requiredTechnique, setRequiredTechnique] = useState<
|
||||
PracticeTechnique | ""
|
||||
>("");
|
||||
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
|
||||
const usesMarkingCount = ![
|
||||
"classic",
|
||||
"diagonal",
|
||||
@@ -66,6 +72,9 @@ export function GeneratorWorkspace({
|
||||
targetDifficulty,
|
||||
symmetry,
|
||||
...(usesMarkingCount ? { constraintCount } : {}),
|
||||
...(requiredTechnique === ""
|
||||
? {}
|
||||
: { requiredTechnique, maxTechniqueAttempts }),
|
||||
seed: seed.trim() || `local-${Date.now().toString(36)}`,
|
||||
});
|
||||
};
|
||||
@@ -101,6 +110,11 @@ export function GeneratorWorkspace({
|
||||
) {
|
||||
setSize(nextDefinition.supportedSizes[0]);
|
||||
}
|
||||
if (next !== "killer") {
|
||||
setRequiredTechnique((current) =>
|
||||
current === "killer-cage" ? "" : current,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{GENERATOR_VARIANTS.map((item) => (
|
||||
@@ -175,6 +189,41 @@ export function GeneratorWorkspace({
|
||||
onChange={(event) => setSeed(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Practice technique
|
||||
<select
|
||||
value={requiredTechnique}
|
||||
onChange={(event) =>
|
||||
setRequiredTechnique(
|
||||
event.target.value as PracticeTechnique | "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">No required technique</option>
|
||||
{PRACTICE_TECHNIQUES.filter(
|
||||
(technique) =>
|
||||
technique !== "killer-cage" || variant === "killer",
|
||||
).map((technique) => (
|
||||
<option key={technique} value={technique}>
|
||||
{techniqueLabel(technique)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{requiredTechnique !== "" && (
|
||||
<label>
|
||||
Mining attempts
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
value={maxTechniqueAttempts}
|
||||
onChange={(event) =>
|
||||
setMaxTechniqueAttempts(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="generator-description">{definition.description}</p>
|
||||
<div className="action-row">
|
||||
@@ -187,7 +236,9 @@ export function GeneratorWorkspace({
|
||||
</div>
|
||||
<p className="muted">
|
||||
Difficulty is an estimate from reproducible solver evidence, not a
|
||||
universal promise. Uniqueness is never claimed after a safety limit.
|
||||
universal promise. Technique practice only returns a puzzle whose
|
||||
logical path contains the requested move. Uniqueness is never claimed
|
||||
after a safety limit.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -229,11 +280,24 @@ export function GeneratorWorkspace({
|
||||
Markings <strong>{generation.generatedConstraintCount}</strong>
|
||||
</span>
|
||||
)}
|
||||
{generation?.requestedTechnique && (
|
||||
<span>
|
||||
Practice{" "}
|
||||
<strong>
|
||||
{techniqueLabel(generation.requestedTechnique)} ×
|
||||
{generation.difficulty.techniqueCounts[
|
||||
generation.requestedTechnique
|
||||
] ?? 0}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{assessment.summary}</p>
|
||||
{generation && (
|
||||
<p className="muted">
|
||||
Seed: <code>{String(generation.seed)}</code>
|
||||
Seed: <code>{String(generation.seed)}</code> · Found in{" "}
|
||||
{generation.generationAttempts} attempt
|
||||
{generation.generationAttempts === 1 ? "" : "s"}.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -14,11 +14,12 @@ export function HelpDialog({
|
||||
<h3>Five complementary workspaces</h3>
|
||||
<p>
|
||||
<strong>Play</strong> keeps values, two kinds of notes, colours,
|
||||
history and elapsed time. <strong>Set</strong> edits clues and
|
||||
constraints. <strong>Generate</strong> constructs and rates bounded,
|
||||
seedable variants. <strong>Solve</strong> explains logical steps and
|
||||
can verify uniqueness. <strong>Helpers</strong> answers focused
|
||||
questions without changing the board.
|
||||
branches, replay and elapsed time. <strong>Set</strong> edits clues
|
||||
and constraints. <strong>Generate</strong> constructs and rates
|
||||
bounded, seedable variants. <strong>Solve</strong> explains logical
|
||||
steps and can verify uniqueness. <strong>Helpers</strong> answers
|
||||
focused sum, candidate and relation questions without changing the
|
||||
board.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
@@ -32,6 +33,18 @@ export function HelpDialog({
|
||||
<dt>Shift + arrows</dt>
|
||||
<dd>Extend the selection</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Home / End</dt>
|
||||
<dd>Move to the first or last cell in the row</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ctrl/⌘ + Home/End</dt>
|
||||
<dd>Move to the first or last cell in the grid</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Page Up / Down</dt>
|
||||
<dd>Move to the first or last row in the same column</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>1–9 / A–G</dt>
|
||||
<dd>Enter the selected symbol</dd>
|
||||
@@ -64,13 +77,62 @@ export function HelpDialog({
|
||||
explanation.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Branches, savepoints and replay</h3>
|
||||
<p>
|
||||
Open <strong>History & branches</strong> while playing to name a
|
||||
checkpoint, isolate a hypothesis, or inspect an earlier grid.
|
||||
Discarding a hypothesis restores its exact starting state but keeps
|
||||
the abandoned path available in replay. Replayed grids are read-only
|
||||
until you return live or deliberately branch from that step. This
|
||||
working history stays in the current browser session; save the
|
||||
puzzle to the Library for durable puzzle progress.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Sum and Candidate Labs</h3>
|
||||
<p>
|
||||
Sum Lab enumerates bounded combinations and, when selected board
|
||||
cells are enabled, candidate-compatible assignments per position.
|
||||
Required, excluded and manually eliminated combinations remain local
|
||||
helper state. Candidate Lab inspects selected cells and houses,
|
||||
filters one or more digits, and can overlay strong and weak links
|
||||
without changing handwritten notes.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Aid-mémoire scratch cells</h3>
|
||||
<p>
|
||||
Enable the optional aid-mémoire in Play for a non-constraining row
|
||||
or compact grid of scratch cells. Each cell has its own label and
|
||||
accepts values, corner marks, centre marks and colours through the
|
||||
regular keypad or keyboard. Arrow keys stay within the configured
|
||||
grid; Home/End move within a row, Ctrl/⌘ + Home/End reach the first
|
||||
or last cell, and Page Up/Down move to the first or last row. The
|
||||
layout and entries are preserved with local Library progress.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Variant and false clues</h3>
|
||||
<p>
|
||||
The setter supports cages, lines, pair clues, X-sums, skyscrapers,
|
||||
quadruples and maximum cells. Enable Wrogn mode to make new local
|
||||
clues false, or switch existing clues individually or as a batch.
|
||||
Red dashed artwork and a ≠ mark identify false clues; Σ and ▥
|
||||
identify X-sum and skyscraper readings. A false multi-cell clue
|
||||
often stays undecided until enough of its cells are known, so exact
|
||||
searches for dense liar puzzles can be substantially slower.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Generation and ratings</h3>
|
||||
<p>
|
||||
Generation runs in a worker with explicit time and search limits. A
|
||||
requested level guides clue removal; the reported rating is then
|
||||
calculated independently from logical techniques, clue load and
|
||||
bounded exact-search evidence. A limit never becomes a false
|
||||
bounded exact-search evidence. Practice mode deterministically mines
|
||||
several candidates and succeeds only when the analysed solve path
|
||||
contains the requested technique. A limit never becomes a false
|
||||
uniqueness claim.
|
||||
</p>
|
||||
</section>
|
||||
@@ -78,10 +140,21 @@ export function HelpDialog({
|
||||
<h3>Import and privacy</h3>
|
||||
<p>
|
||||
Files, text, solving and generation stay in this browser. Compact
|
||||
grids, project JSON, share fragments and supported f-puzzles data
|
||||
are decoded locally. SudokuPad short IDs need its server and are
|
||||
intentionally rejected. Review an export before sharing: titles,
|
||||
authors, rules, solutions and progress may be included.
|
||||
grids, project JSON, share fragments, supported f-puzzles,
|
||||
SudokuPad/CTC inline data and supported Penpa+ long links are
|
||||
decoded locally. Server short IDs are intentionally rejected. SVG,
|
||||
PNG and PDF rendering also stays in the browser. Review an export
|
||||
before sharing: titles, authors, rules, solutions, progress and
|
||||
aid-mémoire entries may be included.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Screen-reader detail</h3>
|
||||
<p>
|
||||
Each Sudoku cell reports its row, column, region, value or notes,
|
||||
colour, conflict state, candidate highlights and touching variant
|
||||
clues. The board and aid-mémoire use real row and gridcell roles;
|
||||
their keyboard instructions are attached to the grids.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { NormalizedPuzzle } from "../domain";
|
||||
import {
|
||||
analyzeKillerCage,
|
||||
analyzeSumLab,
|
||||
calculateResidual,
|
||||
relationPairs,
|
||||
type CandidateOverlay,
|
||||
type RelationSpec,
|
||||
} from "../helpers";
|
||||
import { maskValues, symbolFor } from "../state/session";
|
||||
import { CandidateLab } from "./CandidateLab";
|
||||
|
||||
function digits(text: string) {
|
||||
return [
|
||||
@@ -25,18 +29,24 @@ function numbers(text: string) {
|
||||
|
||||
export function HelpersWorkspace({
|
||||
size,
|
||||
puzzle,
|
||||
values,
|
||||
selectedCells,
|
||||
candidateMasks,
|
||||
onCandidateOverlayChange,
|
||||
}: {
|
||||
size: number;
|
||||
puzzle: NormalizedPuzzle;
|
||||
values: readonly number[];
|
||||
selectedCells: readonly number[];
|
||||
candidateMasks: readonly number[];
|
||||
onCandidateOverlayChange?: (overlay: CandidateOverlay | undefined) => void;
|
||||
}) {
|
||||
const [helper, setHelper] = useState<"killer" | "residual" | "relations">(
|
||||
"killer",
|
||||
);
|
||||
const [helper, setHelper] = useState<
|
||||
"candidates" | "killer" | "sum" | "residual" | "relations"
|
||||
>("killer");
|
||||
const [cellCount, setCellCount] = useState(2);
|
||||
const [sum, setSum] = useState(10);
|
||||
const [sum, setSum] = useState(Math.min(10, size + 1));
|
||||
const [allowed, setAllowed] = useState("");
|
||||
const [required, setRequired] = useState("");
|
||||
const [excluded, setExcluded] = useState("");
|
||||
@@ -47,6 +57,19 @@ export function HelpersWorkspace({
|
||||
const [relation, setRelation] = useState("white");
|
||||
const [firstCandidates, setFirstCandidates] = useState("");
|
||||
const [secondCandidates, setSecondCandidates] = useState("");
|
||||
const [sumLabCellCount, setSumLabCellCount] = useState(2);
|
||||
const [sumLabTarget, setSumLabTarget] = useState(Math.min(10, size + 1));
|
||||
const [sumLabMinimum, setSumLabMinimum] = useState(1);
|
||||
const [sumLabMaximum, setSumLabMaximum] = useState(size);
|
||||
const [sumLabRequired, setSumLabRequired] = useState("");
|
||||
const [sumLabExcluded, setSumLabExcluded] = useState("");
|
||||
const [sumLabRepeats, setSumLabRepeats] = useState(false);
|
||||
const [sumLabUseCandidates, setSumLabUseCandidates] = useState(false);
|
||||
const [sumLabShowEliminated, setSumLabShowEliminated] = useState(true);
|
||||
const [sumLabEliminations, setSumLabEliminations] = useState<{
|
||||
readonly scope: string;
|
||||
readonly keys: ReadonlySet<string>;
|
||||
}>({ scope: "", keys: new Set() });
|
||||
|
||||
const effectiveCount =
|
||||
useBoardCandidates && selectedCells.length > 0
|
||||
@@ -137,6 +160,84 @@ export function HelpersWorkspace({
|
||||
}
|
||||
}, [firstCandidates, relation, secondCandidates, size]);
|
||||
|
||||
const sumLabCandidateMasks = useMemo(
|
||||
() =>
|
||||
sumLabUseCandidates && selectedCells.length > 0
|
||||
? selectedCells.map((cell) => candidateMasks[cell] ?? 0)
|
||||
: undefined,
|
||||
[candidateMasks, selectedCells, sumLabUseCandidates],
|
||||
);
|
||||
const sumLabEffectiveCount =
|
||||
sumLabCandidateMasks === undefined
|
||||
? sumLabCellCount
|
||||
: sumLabCandidateMasks.length;
|
||||
const sumLabScope = JSON.stringify({
|
||||
count: sumLabEffectiveCount,
|
||||
target: sumLabTarget,
|
||||
minimum: sumLabMinimum,
|
||||
maximum: sumLabMaximum,
|
||||
required: sumLabRequired,
|
||||
excluded: sumLabExcluded,
|
||||
repeats: sumLabRepeats,
|
||||
candidates: sumLabCandidateMasks,
|
||||
});
|
||||
const sumLabEliminatedKeys = useMemo(
|
||||
() =>
|
||||
sumLabEliminations.scope === sumLabScope
|
||||
? sumLabEliminations.keys
|
||||
: new Set<string>(),
|
||||
[sumLabEliminations, sumLabScope],
|
||||
);
|
||||
const sumLab = useMemo(() => {
|
||||
try {
|
||||
return {
|
||||
result: analyzeSumLab({
|
||||
cellCount: sumLabEffectiveCount,
|
||||
target: sumLabTarget,
|
||||
minimumDigit: sumLabMinimum,
|
||||
maximumDigit: sumLabMaximum,
|
||||
allowRepeats: sumLabRepeats,
|
||||
...(sumLabRequired.trim()
|
||||
? { requiredDigits: digits(sumLabRequired) }
|
||||
: {}),
|
||||
...(sumLabExcluded.trim()
|
||||
? { excludedDigits: digits(sumLabExcluded) }
|
||||
: {}),
|
||||
...(sumLabCandidateMasks === undefined
|
||||
? {}
|
||||
: { candidateMasks: sumLabCandidateMasks }),
|
||||
eliminatedKeys: sumLabEliminatedKeys,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Invalid Sum Lab input.",
|
||||
};
|
||||
}
|
||||
}, [
|
||||
sumLabCandidateMasks,
|
||||
sumLabEffectiveCount,
|
||||
sumLabExcluded,
|
||||
sumLabMaximum,
|
||||
sumLabMinimum,
|
||||
sumLabEliminatedKeys,
|
||||
sumLabRepeats,
|
||||
sumLabRequired,
|
||||
sumLabTarget,
|
||||
]);
|
||||
|
||||
const toggleSumLabCombination = (key: string) => {
|
||||
setSumLabEliminations((current) => {
|
||||
const keys = new Set(
|
||||
current.scope === sumLabScope ? current.keys : undefined,
|
||||
);
|
||||
if (keys.has(key)) keys.delete(key);
|
||||
else keys.add(key);
|
||||
return { scope: sumLabScope, keys };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="helpers-workspace stack">
|
||||
<div>
|
||||
@@ -151,6 +252,8 @@ export function HelpersWorkspace({
|
||||
{(
|
||||
[
|
||||
["killer", "Killer combinations"],
|
||||
["sum", "Sum Lab"],
|
||||
["candidates", "Candidate links"],
|
||||
["residual", `${String((size * (size + 1)) / 2)}-rule residual`],
|
||||
["relations", "Pair relations"],
|
||||
] as const
|
||||
@@ -168,6 +271,238 @@ export function HelpersWorkspace({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{helper === "candidates" && (
|
||||
<div className="helper-card">
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={values}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={selectedCells}
|
||||
onOverlayChange={onCandidateOverlayChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{helper === "sum" && (
|
||||
<section className="helper-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h3>Generalized Sum Lab</h3>
|
||||
<p className="muted">
|
||||
Explore sums with custom ranges and repeats. Select board cells
|
||||
first to filter cell orderings through their live candidates.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
disabled={sumLabEliminatedKeys.size === 0}
|
||||
onClick={() =>
|
||||
setSumLabEliminations({
|
||||
scope: sumLabScope,
|
||||
keys: new Set(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Restore all
|
||||
</button>
|
||||
</div>
|
||||
<div className="helper-controls sum-lab-controls">
|
||||
<label>
|
||||
Cell count
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="25"
|
||||
value={sumLabEffectiveCount}
|
||||
disabled={sumLabCandidateMasks !== undefined}
|
||||
onChange={(event) =>
|
||||
setSumLabCellCount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Target sum
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={sumLabMaximum * sumLabEffectiveCount}
|
||||
value={sumLabTarget}
|
||||
onChange={(event) =>
|
||||
setSumLabTarget(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Minimum digit
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={size}
|
||||
value={sumLabMinimum}
|
||||
onChange={(event) =>
|
||||
setSumLabMinimum(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Maximum digit
|
||||
<input
|
||||
type="number"
|
||||
min={sumLabMinimum}
|
||||
max={size}
|
||||
value={sumLabMaximum}
|
||||
onChange={(event) =>
|
||||
setSumLabMaximum(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Must include
|
||||
<input
|
||||
value={sumLabRequired}
|
||||
placeholder="e.g. 1, 7"
|
||||
onChange={(event) => setSumLabRequired(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Exclude
|
||||
<input
|
||||
value={sumLabExcluded}
|
||||
placeholder="e.g. 5"
|
||||
onChange={(event) => setSumLabExcluded(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sumLabRepeats}
|
||||
onChange={(event) => setSumLabRepeats(event.target.checked)}
|
||||
/>
|
||||
Allow repeated digits
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sumLabUseCandidates}
|
||||
disabled={selectedCells.length === 0}
|
||||
onChange={(event) =>
|
||||
setSumLabUseCandidates(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Use {selectedCells.length || "selected"} board cell
|
||||
{selectedCells.length === 1 ? "" : "s"} and candidates
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sumLabShowEliminated}
|
||||
onChange={(event) =>
|
||||
setSumLabShowEliminated(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Show eliminated combinations
|
||||
</label>
|
||||
</div>
|
||||
{sumLab.error ? (
|
||||
<p className="error-callout" role="alert">
|
||||
{sumLab.error}
|
||||
</p>
|
||||
) : sumLab.result ? (
|
||||
<div className="helper-result">
|
||||
<div className="metric-row">
|
||||
<span>
|
||||
<strong>{sumLab.result.activeCombinations.length}</strong>{" "}
|
||||
active of {sumLab.result.combinations.length}
|
||||
</span>
|
||||
<span>
|
||||
Possible{" "}
|
||||
<strong>
|
||||
{sumLab.result.possibleDigits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" ") || "none"}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Necessary{" "}
|
||||
<strong>
|
||||
{sumLab.result.necessaryDigits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" ") || "none"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
{sumLabCandidateMasks !== undefined && (
|
||||
<ol className="cell-possibilities">
|
||||
{sumLab.result.possibleByCell.map((entry, index) => {
|
||||
const cell = selectedCells[index];
|
||||
const label =
|
||||
cell === undefined
|
||||
? `Cell ${String(index + 1)}`
|
||||
: `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||||
return (
|
||||
<li key={cell ?? index}>
|
||||
{label}:{" "}
|
||||
{entry
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" ") || "—"}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
<p className="muted sum-lab-guidance">
|
||||
Select a combination to eliminate it; select it again to restore
|
||||
it. Eliminated combinations are excluded from the summaries
|
||||
above.
|
||||
</p>
|
||||
<div
|
||||
className="combination-cloud sum-combination-cloud"
|
||||
aria-label="Sum combinations"
|
||||
>
|
||||
{sumLab.result.combinations
|
||||
.filter(
|
||||
(combination) =>
|
||||
sumLabShowEliminated || !combination.eliminated,
|
||||
)
|
||||
.slice(0, 500)
|
||||
.map((combination) => (
|
||||
<button
|
||||
key={combination.key}
|
||||
type="button"
|
||||
className={`sum-combination${combination.eliminated ? " is-eliminated" : ""}`}
|
||||
aria-pressed={combination.eliminated}
|
||||
aria-label={`${combination.digits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(
|
||||
" + ",
|
||||
)}; ${combination.eliminated ? "restore" : "eliminate"}`}
|
||||
onClick={() => toggleSumLabCombination(combination.key)}
|
||||
>
|
||||
{combination.digits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" + ")}
|
||||
</button>
|
||||
))}
|
||||
{sumLab.result.combinations.length === 0 && (
|
||||
<span className="muted">No compatible combinations.</span>
|
||||
)}
|
||||
</div>
|
||||
{(sumLab.result.combinations.length > 500 ||
|
||||
sumLab.result.truncated) && (
|
||||
<p className="callout">
|
||||
{sumLab.result.truncated
|
||||
? `Analysis reached its ${sumLab.result.truncationReason ?? "safety"} limit. `
|
||||
: ""}
|
||||
The visible list is bounded; narrow the range or add filters
|
||||
before making deductions from an incomplete result.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{helper === "killer" && (
|
||||
<section className="helper-card">
|
||||
<div className="helper-controls">
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { PuzzleDefinition } from "../domain/types";
|
||||
import { normalizePuzzle } from "../domain/validation";
|
||||
import {
|
||||
decodePuzzleHash,
|
||||
encodePuzzleHash,
|
||||
exportFpuzzlesJson,
|
||||
exportFpuzzlesUrl,
|
||||
fromDomainPuzzle,
|
||||
importFpuzzles,
|
||||
parseFpuzzles,
|
||||
parsePlainGrid,
|
||||
parseSudokuDocument,
|
||||
importPuzzle,
|
||||
renderPuzzlePdf,
|
||||
renderPuzzlePng,
|
||||
renderPuzzleSvg,
|
||||
serializePlainGrid,
|
||||
serializeSudokuDocument,
|
||||
toDomainPuzzle,
|
||||
@@ -17,31 +17,13 @@ import {
|
||||
} from "../formats";
|
||||
import type { PlaySession } from "../state/session";
|
||||
import { maskValues } from "../state/session";
|
||||
import type { PortableAidMemoire } from "../state/aidMemoire";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
function parseImport(input: string): SudokuDocument {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku="))
|
||||
return decodePuzzleHash(trimmed);
|
||||
if (
|
||||
/^(?:https?:\/\/)?(?:www\.)?(?:f-puzzles\.com|sudokupad\.app)\//iu.test(
|
||||
trimmed,
|
||||
)
|
||||
)
|
||||
return importFpuzzles(trimmed);
|
||||
if (trimmed.startsWith("fpuzzles")) return importFpuzzles(trimmed);
|
||||
if (trimmed.startsWith("{")) {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (typeof parsed === "object" && parsed !== null && "schema" in parsed)
|
||||
return parseSudokuDocument(trimmed);
|
||||
return parseFpuzzles(parsed);
|
||||
}
|
||||
return parsePlainGrid(trimmed);
|
||||
}
|
||||
|
||||
function withProgress(
|
||||
puzzle: PuzzleDefinition,
|
||||
session: PlaySession,
|
||||
aidMemoire?: PortableAidMemoire,
|
||||
): SudokuDocument {
|
||||
const base = fromDomainPuzzle(puzzle);
|
||||
return {
|
||||
@@ -55,22 +37,38 @@ function withProgress(
|
||||
),
|
||||
colors: [...session.colors],
|
||||
elapsedMs: session.elapsedSeconds * 1_000,
|
||||
...(aidMemoire === undefined ? {} : { aidMemoire }),
|
||||
};
|
||||
}
|
||||
|
||||
function download(name: string, contents: string, type: string) {
|
||||
const url = URL.createObjectURL(new Blob([contents], { type }));
|
||||
function downloadBlob(name: string, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
function download(name: string, contents: string, type: string) {
|
||||
downloadBlob(name, new Blob([contents], { type }));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function checkedPuzzle(document: SudokuDocument): PuzzleDefinition {
|
||||
return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition);
|
||||
}
|
||||
|
||||
interface ImportExportDialogProps {
|
||||
open: boolean;
|
||||
puzzle: PuzzleDefinition;
|
||||
session: PlaySession;
|
||||
aidMemoire?: PortableAidMemoire;
|
||||
onClose: () => void;
|
||||
onImport: (
|
||||
puzzle: PuzzleDefinition,
|
||||
@@ -82,6 +80,7 @@ interface ImportExportDialogProps {
|
||||
| "candidates"
|
||||
| "colors"
|
||||
| "elapsedMs"
|
||||
| "aidMemoire"
|
||||
>,
|
||||
) => void;
|
||||
}
|
||||
@@ -90,19 +89,21 @@ export function ImportExportDialog({
|
||||
open,
|
||||
puzzle,
|
||||
session,
|
||||
aidMemoire,
|
||||
onClose,
|
||||
onImport,
|
||||
}: ImportExportDialogProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [includeProgress, setIncludeProgress] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const documentValue = useMemo(
|
||||
() =>
|
||||
includeProgress
|
||||
? withProgress(puzzle, session)
|
||||
? withProgress(puzzle, session, aidMemoire)
|
||||
: fromDomainPuzzle(puzzle),
|
||||
[includeProgress, puzzle, session],
|
||||
[aidMemoire, includeProgress, puzzle, session],
|
||||
);
|
||||
|
||||
const copy = async (value: string, label: string) => {
|
||||
@@ -116,18 +117,119 @@ export function ImportExportDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const copyExport = async (render: () => string, label: string) => {
|
||||
let value: string;
|
||||
try {
|
||||
value = render();
|
||||
} catch (error) {
|
||||
setFeedback(errorMessage(error, `${label} could not be created.`));
|
||||
return;
|
||||
}
|
||||
await copy(value, label);
|
||||
};
|
||||
|
||||
const exportText = (
|
||||
name: string,
|
||||
type: string,
|
||||
label: string,
|
||||
render: () => string,
|
||||
) => {
|
||||
try {
|
||||
download(name, render(), type);
|
||||
setFeedback(`${label} downloaded locally.`);
|
||||
} catch (error) {
|
||||
setFeedback(errorMessage(error, `${label} export failed.`));
|
||||
}
|
||||
};
|
||||
|
||||
const inspectImport = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await importPuzzle(input);
|
||||
checkedPuzzle(result.document);
|
||||
const givenCount = result.document.givens.filter(
|
||||
(value) => value !== 0,
|
||||
).length;
|
||||
setFeedback(
|
||||
`${result.label}: ${String(result.document.size)}×${String(result.document.size)}, ${String(givenCount)} givens and ${String(result.document.constraints.length)} constraints. Compatible and ready to import.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setFeedback(errorMessage(error, "The puzzle could not be inspected."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await importPuzzle(input);
|
||||
const parsed = result.document;
|
||||
onImport(checkedPuzzle(parsed), {
|
||||
values: parsed.values,
|
||||
cornerMarks: parsed.cornerMarks,
|
||||
centerMarks: parsed.centerMarks,
|
||||
candidates: parsed.candidates,
|
||||
colors: parsed.colors,
|
||||
elapsedMs: parsed.elapsedMs,
|
||||
aidMemoire: parsed.aidMemoire,
|
||||
});
|
||||
setFeedback(`${result.label} imported locally.`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setFeedback(errorMessage(error, "The puzzle could not be imported."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportVisual = async (format: "svg" | "png" | "pdf") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = {
|
||||
includeProgress,
|
||||
includeNotes: includeProgress,
|
||||
};
|
||||
if (format === "svg") {
|
||||
download(
|
||||
"sudoku.svg",
|
||||
renderPuzzleSvg(documentValue, options),
|
||||
"image/svg+xml;charset=utf-8",
|
||||
);
|
||||
} else if (format === "png") {
|
||||
downloadBlob(
|
||||
"sudoku.png",
|
||||
await renderPuzzlePng(documentValue, options),
|
||||
);
|
||||
} else {
|
||||
downloadBlob(
|
||||
"sudoku.pdf",
|
||||
await renderPuzzlePdf(documentValue, options),
|
||||
);
|
||||
}
|
||||
setFeedback(`${format.toUpperCase()} rendered and downloaded locally.`);
|
||||
} catch (error) {
|
||||
setFeedback(
|
||||
errorMessage(error, `The ${format.toUpperCase()} export failed.`),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} title="Import and export" onClose={onClose} wide>
|
||||
<div className="import-export-grid">
|
||||
<div className="import-export-grid" aria-busy={busy}>
|
||||
<section className="stack">
|
||||
<div>
|
||||
<p className="eyebrow">Import</p>
|
||||
<h3>Paste puzzle data</h3>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Accepts a plain grid, Sudoku Tools JSON/share link, raw f-puzzles
|
||||
JSON, or a self-contained f-puzzles/SudokuPad link. Server-only
|
||||
short IDs are never fetched.
|
||||
Accepts a plain grid, Sudoku Tools JSON/share link, f-puzzles or
|
||||
SudokuPad/CTC data, and supported Penpa+ long links. Everything is
|
||||
decoded locally. Server-only short IDs are never fetched.
|
||||
Unsupported constructs stop the import instead of being discarded.
|
||||
</p>
|
||||
<textarea
|
||||
rows={12}
|
||||
@@ -135,7 +237,10 @@ export function ImportExportDialog({
|
||||
maxLength={1_048_576}
|
||||
spellCheck={false}
|
||||
placeholder="Paste 81 characters, JSON or a puzzle URL…"
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setInput(event.target.value);
|
||||
setFeedback("");
|
||||
}}
|
||||
/>
|
||||
<div className="action-row">
|
||||
<button type="button" onClick={() => fileRef.current?.click()}>
|
||||
@@ -145,7 +250,7 @@ export function ImportExportDialog({
|
||||
ref={fileRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="application/json,text/plain,.json,.txt,.sdk"
|
||||
accept="application/json,text/plain,.json,.txt,.sdk,.scl,.ctc,.penpa"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
@@ -169,28 +274,15 @@ export function ImportExportDialog({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!input.trim()}
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = parseImport(input);
|
||||
onImport(toDomainPuzzle(parsed) as PuzzleDefinition, {
|
||||
values: parsed.values,
|
||||
cornerMarks: parsed.cornerMarks,
|
||||
centerMarks: parsed.centerMarks,
|
||||
candidates: parsed.candidates,
|
||||
colors: parsed.colors,
|
||||
elapsedMs: parsed.elapsedMs,
|
||||
});
|
||||
setFeedback("Puzzle imported locally.");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setFeedback(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The puzzle could not be imported.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={busy || !input.trim()}
|
||||
onClick={() => void inspectImport()}
|
||||
>
|
||||
Check compatibility
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !input.trim()}
|
||||
onClick={() => void applyImport()}
|
||||
>
|
||||
Import locally
|
||||
</button>
|
||||
@@ -213,11 +305,13 @@ export function ImportExportDialog({
|
||||
<div className="export-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
download(
|
||||
exportText(
|
||||
"sudoku-tools-puzzle.json",
|
||||
serializeSudokuDocument(documentValue, true),
|
||||
"application/json",
|
||||
"Project JSON",
|
||||
() => serializeSudokuDocument(documentValue, true),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -225,14 +319,13 @@ export function ImportExportDialog({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
download(
|
||||
"sudoku.txt",
|
||||
exportText("sudoku.txt", "text/plain", "Grid text", () =>
|
||||
serializePlainGrid(
|
||||
documentValue,
|
||||
includeProgress ? "values" : "givens",
|
||||
),
|
||||
"text/plain",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -240,11 +333,13 @@ export function ImportExportDialog({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
download(
|
||||
exportText(
|
||||
"sudoku.fpuzzles.json",
|
||||
exportFpuzzlesJson(documentValue, true),
|
||||
"application/json",
|
||||
"f-puzzles JSON",
|
||||
() => exportFpuzzlesJson(documentValue, true),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -252,21 +347,50 @@ export function ImportExportDialog({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void copy(exportFpuzzlesUrl(documentValue), "f-puzzles URL")
|
||||
void copyExport(
|
||||
() => exportFpuzzlesUrl(documentValue),
|
||||
"f-puzzles URL",
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy f-puzzles URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const share = `${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`;
|
||||
void copy(share, "Local share URL");
|
||||
void copyExport(
|
||||
() =>
|
||||
`${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`,
|
||||
"Local share URL",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy local share URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void exportVisual("svg")}
|
||||
>
|
||||
Download SVG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void exportVisual("png")}
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void exportVisual("pdf")}
|
||||
>
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
<p className="callout">
|
||||
Share links are self-contained. They may expose title, setter,
|
||||
|
||||
@@ -141,13 +141,15 @@ export function SolveWorkspace({
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{exact.count === 0
|
||||
? "No completion satisfies every supported rule."
|
||||
: exact.count === 1 && !exact.truncated
|
||||
? "The current puzzle has exactly one solution within the configured search bounds."
|
||||
: exact.count >= 2
|
||||
? "At least two solutions exist; the puzzle is not unique."
|
||||
: "Search stopped at a safety limit, so uniqueness is not established."}
|
||||
{exact.count >= 2
|
||||
? "At least two solutions exist; the puzzle is not unique."
|
||||
: exact.truncated
|
||||
? exact.count === 0
|
||||
? "Search stopped at a safety limit before finding a solution, so solvability is not established."
|
||||
: "Search stopped at a safety limit, so uniqueness is not established."
|
||||
: exact.count === 0
|
||||
? "No completion satisfies every supported rule."
|
||||
: "The current puzzle has exactly one solution."}
|
||||
</p>
|
||||
{exact.solutions[0] && (
|
||||
<button
|
||||
|
||||
+461
-57
@@ -1,5 +1,11 @@
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
||||
import {
|
||||
useId,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import type { NormalizedPuzzle, VariantConstraint } from "../domain/types";
|
||||
import type { CandidateNode, CandidateOverlay } from "../helpers";
|
||||
import { maskValues, symbolFor } from "../state/session";
|
||||
|
||||
interface SudokuBoardProps {
|
||||
@@ -14,6 +20,7 @@ interface SudokuBoardProps {
|
||||
conflicts?: ReadonlySet<number>;
|
||||
activeCell: number;
|
||||
showCandidates?: boolean;
|
||||
candidateOverlay?: CandidateOverlay;
|
||||
onCellPointerDown: (
|
||||
cell: number,
|
||||
event: PointerEvent<HTMLButtonElement>,
|
||||
@@ -25,6 +32,67 @@ interface SudokuBoardProps {
|
||||
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
function candidatePoint(size: number, node: CandidateNode) {
|
||||
const columns = Math.ceil(Math.sqrt(size));
|
||||
const rows = Math.ceil(size / columns);
|
||||
const slot = node.value - 1;
|
||||
return {
|
||||
x: (node.cell % size) + ((slot % columns) + 0.5) / columns,
|
||||
y: Math.floor(node.cell / size) + (Math.floor(slot / columns) + 0.5) / rows,
|
||||
};
|
||||
}
|
||||
|
||||
function CandidateLinkLayer({
|
||||
size,
|
||||
overlay,
|
||||
}: {
|
||||
size: number;
|
||||
overlay: CandidateOverlay;
|
||||
}) {
|
||||
const nodes = new Map<string, CandidateNode>();
|
||||
for (const link of overlay.links) {
|
||||
nodes.set(`${String(link.a.cell)}:${String(link.a.value)}`, link.a);
|
||||
nodes.set(`${String(link.b.cell)}:${String(link.b.value)}`, link.b);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
className="candidate-link-layer"
|
||||
viewBox={`0 0 ${String(size)} ${String(size)}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{overlay.links.map((link) => {
|
||||
const a = candidatePoint(size, link.a);
|
||||
const b = candidatePoint(size, link.b);
|
||||
return (
|
||||
<line
|
||||
key={link.id}
|
||||
className={`candidate-overlay-link candidate-overlay-link--${link.kind}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{[...nodes.entries()].map(([key, node]) => {
|
||||
const position = candidatePoint(size, node);
|
||||
return (
|
||||
<g
|
||||
key={key}
|
||||
className="candidate-overlay-node"
|
||||
transform={`translate(${String(position.x)} ${String(position.y)})`}
|
||||
>
|
||||
<circle r="0.105" />
|
||||
<text x="0" y="0">
|
||||
{symbolFor(node.value, size)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function point(size: number, cell: number) {
|
||||
return {
|
||||
x: (cell % size) + 0.5,
|
||||
@@ -41,6 +109,23 @@ function polyline(size: number, cells: readonly number[]) {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function outsidePoint(
|
||||
size: number,
|
||||
side: "top" | "right" | "bottom" | "left",
|
||||
index: number,
|
||||
) {
|
||||
switch (side) {
|
||||
case "top":
|
||||
return { x: index + 0.5, y: -0.48 };
|
||||
case "right":
|
||||
return { x: size + 0.48, y: index + 0.5 };
|
||||
case "bottom":
|
||||
return { x: index + 0.5, y: size + 0.48 };
|
||||
case "left":
|
||||
return { x: -0.48, y: index + 0.5 };
|
||||
}
|
||||
}
|
||||
|
||||
function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
|
||||
const commands: string[] = [];
|
||||
for (const cell of cells) {
|
||||
@@ -62,12 +147,168 @@ function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
|
||||
return commands.join(" ");
|
||||
}
|
||||
|
||||
function describeConstraint(
|
||||
constraint: VariantConstraint,
|
||||
size: number,
|
||||
): string {
|
||||
const cell = (index: number) =>
|
||||
`row ${String(Math.floor(index / size) + 1)}, column ${String((index % size) + 1)}`;
|
||||
const cells = (indices: readonly number[]) => indices.map(cell).join("; ");
|
||||
const polarity =
|
||||
"negated" in constraint && constraint.negated === true
|
||||
? "False clue: "
|
||||
: "";
|
||||
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
return `${constraint.direction} diagonal.`;
|
||||
case "anti-knight":
|
||||
return "Anti-knight rule.";
|
||||
case "anti-king":
|
||||
return "Anti-king rule.";
|
||||
case "non-consecutive":
|
||||
return "Non-consecutive orthogonal neighbours rule.";
|
||||
case "killer-cage":
|
||||
return `${polarity}killer cage ${String(constraint.sum)} at ${cells(constraint.cells)}.`;
|
||||
case "thermo":
|
||||
return `${polarity}thermometer from bulb through ${cells(constraint.cells)}.`;
|
||||
case "arrow":
|
||||
return `${polarity}arrow with bulb at ${cells(constraint.bulb)} and line through ${cells(constraint.line)}.`;
|
||||
case "kropki":
|
||||
return `${polarity}${constraint.kind} Kropki dot between ${cell(constraint.a)} and ${cell(constraint.b)}.`;
|
||||
case "xv":
|
||||
return `${polarity}sum ${String(constraint.total)} pair between ${cell(constraint.a)} and ${cell(constraint.b)}.`;
|
||||
case "inequality":
|
||||
return `${polarity}${cell(constraint.lesser)} is less than ${cell(constraint.greater)}.`;
|
||||
case "renban":
|
||||
return `${polarity}renban line through ${cells(constraint.cells)}.`;
|
||||
case "palindrome":
|
||||
return `${polarity}palindrome line through ${cells(constraint.cells)}.`;
|
||||
case "x-sum":
|
||||
return `${polarity}X-sum ${String(constraint.sum)} from the ${constraint.side}, ${constraint.side === "left" || constraint.side === "right" ? "row" : "column"} ${String(constraint.index + 1)}.`;
|
||||
case "skyscraper":
|
||||
return `${polarity}skyscraper count ${String(constraint.count)} from the ${constraint.side}, ${constraint.side === "left" || constraint.side === "right" ? "row" : "column"} ${String(constraint.index + 1)}.`;
|
||||
case "quadruple":
|
||||
return `${polarity}quadruple digits ${constraint.digits.map((digit) => symbolFor(digit, size)).join(", ")} touching ${cells(constraint.cells)}.`;
|
||||
case "maximum":
|
||||
return `${polarity}maximum at ${cell(constraint.cell)}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function constraintTouchesCell(
|
||||
constraint: VariantConstraint,
|
||||
cell: number,
|
||||
size: number,
|
||||
): boolean {
|
||||
switch (constraint.type) {
|
||||
case "diagonal": {
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
return constraint.direction === "main"
|
||||
? row === column
|
||||
: row + column === size - 1;
|
||||
}
|
||||
case "killer-cage":
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
case "quadruple":
|
||||
return constraint.cells.includes(cell);
|
||||
case "arrow":
|
||||
return constraint.bulb.includes(cell) || constraint.line.includes(cell);
|
||||
case "kropki":
|
||||
case "xv":
|
||||
return constraint.a === cell || constraint.b === cell;
|
||||
case "inequality":
|
||||
return constraint.lesser === cell || constraint.greater === cell;
|
||||
case "maximum":
|
||||
return constraint.cell === cell;
|
||||
case "x-sum":
|
||||
case "skyscraper": {
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
return constraint.side === "left" || constraint.side === "right"
|
||||
? row === constraint.index
|
||||
: column === constraint.index;
|
||||
}
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cellAccessibleDescription({
|
||||
puzzle,
|
||||
cell,
|
||||
value,
|
||||
corner,
|
||||
center,
|
||||
centerIsAutomatic,
|
||||
color,
|
||||
conflict,
|
||||
candidateHighlights,
|
||||
}: {
|
||||
puzzle: NormalizedPuzzle;
|
||||
cell: number;
|
||||
value: number;
|
||||
corner: readonly number[];
|
||||
center: readonly number[];
|
||||
centerIsAutomatic: boolean;
|
||||
color: number;
|
||||
conflict: boolean;
|
||||
candidateHighlights: readonly number[];
|
||||
}): string {
|
||||
const parts = [`Region ${String((puzzle.regions[cell] ?? 0) + 1)}`];
|
||||
if (value !== 0) {
|
||||
parts.push(puzzle.givens[cell] ? "Given digit" : "Entered digit");
|
||||
} else {
|
||||
if (corner.length > 0) {
|
||||
parts.push(
|
||||
`corner notes ${corner.map((digit) => symbolFor(digit, puzzle.size)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (center.length > 0) {
|
||||
parts.push(
|
||||
`${centerIsAutomatic ? "automatic candidates" : "centre notes"} ${center
|
||||
.map((digit) => symbolFor(digit, puzzle.size))
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (color > 0) parts.push(`colour ${String(color)}`);
|
||||
if (conflict) parts.push("conflict");
|
||||
if (candidateHighlights.length > 0) {
|
||||
parts.push(
|
||||
`Candidate Lab highlights ${candidateHighlights
|
||||
.map((digit) => symbolFor(digit, puzzle.size))
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const localConstraints = puzzle.constraints.filter((constraint) =>
|
||||
constraintTouchesCell(constraint, cell, puzzle.size),
|
||||
);
|
||||
if (localConstraints.length > 0) {
|
||||
parts.push(
|
||||
`clues: ${localConstraints
|
||||
.map((constraint) => describeConstraint(constraint, puzzle.size))
|
||||
.join(" ")}`,
|
||||
);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
|
||||
const { size } = puzzle;
|
||||
const regionCells = Array.from({ length: size }, () => new Set<number>());
|
||||
puzzle.regions.forEach((region, cell) => regionCells[region]?.add(cell));
|
||||
|
||||
const renderConstraint = (constraint: VariantConstraint, index: number) => {
|
||||
const polarityClass =
|
||||
"negated" in constraint && constraint.negated === true
|
||||
? " is-negated"
|
||||
: "";
|
||||
if (constraint.type === "diagonal") {
|
||||
return (
|
||||
<line
|
||||
@@ -85,9 +326,10 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
|
||||
const first = Math.min(...constraint.cells);
|
||||
const position = point(size, first);
|
||||
return (
|
||||
<g key={index} className="constraint-cage">
|
||||
<g key={index} className={`constraint-cage${polarityClass}`}>
|
||||
<path d={boundaryPath(size, cells, 0.09)} />
|
||||
<text x={position.x - 0.34} y={position.y - 0.27}>
|
||||
{constraint.negated === true ? "≠" : ""}
|
||||
{constraint.sum}
|
||||
</text>
|
||||
</g>
|
||||
@@ -96,9 +338,14 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
|
||||
if (constraint.type === "thermo") {
|
||||
const bulb = point(size, constraint.cells[0]!);
|
||||
return (
|
||||
<g key={index} className="constraint-thermo">
|
||||
<g key={index} className={`constraint-thermo${polarityClass}`}>
|
||||
<polyline points={polyline(size, constraint.cells)} />
|
||||
<circle cx={bulb.x} cy={bulb.y} r="0.31" />
|
||||
{constraint.negated === true && (
|
||||
<text className="constraint-false-mark" x={bulb.x} y={bulb.y}>
|
||||
≠
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -107,16 +354,104 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
|
||||
const end = point(size, constraint.line.at(-1)!);
|
||||
const connectedLine = [constraint.bulb.at(-1)!, ...constraint.line];
|
||||
return (
|
||||
<g key={index} className="constraint-arrow">
|
||||
<g key={index} className={`constraint-arrow${polarityClass}`}>
|
||||
<circle cx={bulb.x} cy={bulb.y} r="0.32" />
|
||||
<polyline points={polyline(size, connectedLine)} />
|
||||
<circle cx={end.x} cy={end.y} r="0.08" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (constraint.type === "maximum") {
|
||||
const center = point(size, constraint.cell);
|
||||
return (
|
||||
<g key={index} className={`constraint-maximum${polarityClass}`}>
|
||||
<circle cx={center.x} cy={center.y} r="0.18" />
|
||||
<path
|
||||
d={[
|
||||
`M${String(center.x - 0.1)} ${String(center.y - 0.17)}L${String(center.x)} ${String(center.y - 0.3)}L${String(center.x + 0.1)} ${String(center.y - 0.17)}`,
|
||||
`M${String(center.x - 0.1)} ${String(center.y + 0.17)}L${String(center.x)} ${String(center.y + 0.3)}L${String(center.x + 0.1)} ${String(center.y + 0.17)}`,
|
||||
`M${String(center.x - 0.17)} ${String(center.y - 0.1)}L${String(center.x - 0.3)} ${String(center.y)}L${String(center.x - 0.17)} ${String(center.y + 0.1)}`,
|
||||
`M${String(center.x + 0.17)} ${String(center.y - 0.1)}L${String(center.x + 0.3)} ${String(center.y)}L${String(center.x + 0.17)} ${String(center.y + 0.1)}`,
|
||||
].join("")}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (constraint.type === "quadruple") {
|
||||
const positions = constraint.cells.map((cell) => point(size, cell));
|
||||
const center = {
|
||||
x:
|
||||
positions.reduce((sum, position) => sum + position.x, 0) /
|
||||
positions.length,
|
||||
y:
|
||||
positions.reduce((sum, position) => sum + position.y, 0) /
|
||||
positions.length,
|
||||
};
|
||||
return (
|
||||
<g key={index} className={`constraint-quadruple${polarityClass}`}>
|
||||
<circle cx={center.x} cy={center.y} r="0.29" />
|
||||
<text x={center.x} y={center.y}>
|
||||
{constraint.negated === true ? "≠" : ""}
|
||||
{constraint.digits.map((digit) => symbolFor(digit, size)).join("")}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (constraint.type === "x-sum" || constraint.type === "skyscraper") {
|
||||
const position = outsidePoint(size, constraint.side, constraint.index);
|
||||
const value =
|
||||
constraint.type === "x-sum" ? constraint.sum : constraint.count;
|
||||
const companionIndex = puzzle.constraints.findIndex(
|
||||
(candidate) =>
|
||||
candidate.type !== constraint.type &&
|
||||
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
|
||||
candidate.side === constraint.side &&
|
||||
candidate.index === constraint.index &&
|
||||
(candidate.type === "x-sum" ? candidate.sum : candidate.count) ===
|
||||
value &&
|
||||
("negated" in candidate && candidate.negated === true) ===
|
||||
(constraint.negated === true),
|
||||
);
|
||||
const combined = companionIndex >= 0;
|
||||
if (combined && companionIndex < index) return null;
|
||||
const label = `${constraint.negated === true ? "≠" : ""}${String(value)}`;
|
||||
return (
|
||||
<g
|
||||
key={index}
|
||||
className={`constraint-outside constraint-${constraint.type}${combined ? " is-combined" : ""}${polarityClass}`}
|
||||
transform={`translate(${String(position.x)} ${String(position.y)})`}
|
||||
>
|
||||
<rect
|
||||
x="-0.46"
|
||||
y={combined ? "-0.31" : "-0.23"}
|
||||
width="0.92"
|
||||
height={combined ? "0.62" : "0.46"}
|
||||
rx="0.12"
|
||||
/>
|
||||
{combined ? (
|
||||
<>
|
||||
<text className="outside-clue-kinds" x="0" y="-0.15">
|
||||
Σ · ▥
|
||||
</text>
|
||||
<text className="outside-clue-value" x="0" y="0.1">
|
||||
{label}
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<text x="0" y="0">
|
||||
{constraint.type === "x-sum" ? "Σ" : "▥"}
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (constraint.type === "renban" || constraint.type === "palindrome") {
|
||||
return (
|
||||
<g key={index} className={`constraint-${constraint.type}`}>
|
||||
<g
|
||||
key={index}
|
||||
className={`constraint-${constraint.type}${polarityClass}`}
|
||||
>
|
||||
<polyline points={polyline(size, constraint.cells)} />
|
||||
{constraint.type === "palindrome" &&
|
||||
constraint.cells.map((cell) => {
|
||||
@@ -145,7 +480,7 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
className={`constraint-kropki constraint-kropki--${constraint.kind}`}
|
||||
className={`constraint-kropki constraint-kropki--${constraint.kind}${polarityClass}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="0.115"
|
||||
@@ -155,10 +490,11 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
|
||||
return (
|
||||
<g
|
||||
key={index}
|
||||
className={`constraint-xv constraint-xv--${String(constraint.total)}`}
|
||||
className={`constraint-xv constraint-xv--${String(constraint.total)}${polarityClass}`}
|
||||
>
|
||||
<circle cx={x} cy={y} r="0.18" />
|
||||
<text x={x} y={y}>
|
||||
{constraint.negated === true ? "≠" : ""}
|
||||
{constraint.total}
|
||||
</text>
|
||||
</g>
|
||||
@@ -167,7 +503,7 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
|
||||
return (
|
||||
<g
|
||||
key={index}
|
||||
className="constraint-inequality"
|
||||
className={`constraint-inequality${polarityClass}`}
|
||||
transform={`translate(${String(x)} ${String(y)}) rotate(${String(rotation)})`}
|
||||
>
|
||||
<path d="M0.12 -0.17L-0.12 0L0.12 0.17" />
|
||||
@@ -211,72 +547,140 @@ export function SudokuBoard({
|
||||
conflicts = new Set<number>(),
|
||||
activeCell,
|
||||
showCandidates,
|
||||
candidateOverlay,
|
||||
onCellPointerDown,
|
||||
onCellPointerEnter,
|
||||
onKeyDown,
|
||||
}: SudokuBoardProps) {
|
||||
const { size } = puzzle;
|
||||
const constraintDescriptionId = useId();
|
||||
const navigationDescriptionId = useId();
|
||||
const hasOutsideClues = puzzle.constraints.some(
|
||||
({ type }) => type === "x-sum" || type === "skyscraper",
|
||||
);
|
||||
const constraintDescription = puzzle.constraints
|
||||
.map((constraint) => describeConstraint(constraint, size))
|
||||
.join(" ");
|
||||
return (
|
||||
<div
|
||||
className="sudoku-board-frame"
|
||||
className={`sudoku-board-frame${hasOutsideClues ? " has-outside-clues" : ""}`}
|
||||
style={{ "--sudoku-size": size } as CSSProperties}
|
||||
>
|
||||
<p className="sr-only" id={navigationDescriptionId}>
|
||||
Use the arrow keys to move without wrapping. Home and End move to the
|
||||
first and last cell in the row. Control or Command plus Home and End
|
||||
move to the first and last grid cell. Page Up and Page Down keep the
|
||||
column and move to the first and last row. Hold Shift to extend the
|
||||
selection.
|
||||
</p>
|
||||
{constraintDescription && (
|
||||
<p className="sr-only" id={constraintDescriptionId}>
|
||||
Variant constraints. {constraintDescription}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="sudoku-board"
|
||||
role="grid"
|
||||
aria-label={`${String(size)} by ${String(size)} Sudoku grid`}
|
||||
aria-rowcount={size}
|
||||
aria-colcount={size}
|
||||
aria-multiselectable="true"
|
||||
aria-describedby={`${navigationDescriptionId}${constraintDescription ? ` ${constraintDescriptionId}` : ""}`}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{values.map((value, cell) => {
|
||||
const isGiven = Boolean(puzzle.givens[cell]);
|
||||
const corner = maskValues(cornerMarks[cell] ?? 0, size);
|
||||
const center = maskValues(
|
||||
centerMarks[cell] || (showCandidates ? (candidates[cell] ?? 0) : 0),
|
||||
size,
|
||||
);
|
||||
const row = Math.floor(cell / size) + 1;
|
||||
const column = (cell % size) + 1;
|
||||
return (
|
||||
<button
|
||||
key={cell}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
aria-label={`Row ${String(row)}, column ${String(column)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
|
||||
aria-selected={selected.has(cell)}
|
||||
tabIndex={cell === activeCell ? 0 : -1}
|
||||
className={[
|
||||
"sudoku-cell",
|
||||
isGiven ? "is-given" : "",
|
||||
highlighted.has(cell) ? "is-digit-highlighted" : "",
|
||||
selected.has(cell) ? "is-selected" : "",
|
||||
conflicts.has(cell) ? "has-conflict" : "",
|
||||
colors[cell] ? `has-color-${String(colors[cell])}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onPointerDown={(event) => onCellPointerDown(cell, event)}
|
||||
onPointerEnter={(event) => onCellPointerEnter(cell, event)}
|
||||
data-cell={cell}
|
||||
>
|
||||
{value ? (
|
||||
<span className="cell-value">{symbolFor(value, size)}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="corner-marks" aria-hidden="true">
|
||||
{corner.map((mark) => (
|
||||
<span key={mark}>{symbolFor(mark, size)}</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="center-marks" aria-hidden="true">
|
||||
{center.map((mark) => symbolFor(mark, size)).join("")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: size }, (_, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="sudoku-row"
|
||||
role="row"
|
||||
aria-rowindex={rowIndex + 1}
|
||||
>
|
||||
{Array.from({ length: size }, (_, columnIndex) => {
|
||||
const cell = rowIndex * size + columnIndex;
|
||||
const value = values[cell] ?? 0;
|
||||
const isGiven = Boolean(puzzle.givens[cell]);
|
||||
const corner = maskValues(cornerMarks[cell] ?? 0, size);
|
||||
const userCenterMask = centerMarks[cell] ?? 0;
|
||||
const automaticMask = showCandidates
|
||||
? (candidates[cell] ?? 0)
|
||||
: 0;
|
||||
const centerIsAutomatic =
|
||||
userCenterMask === 0 && automaticMask !== 0;
|
||||
const center = maskValues(userCenterMask || automaticMask, size);
|
||||
const candidateHighlighted =
|
||||
candidateOverlay?.candidateCells.includes(cell) ?? false;
|
||||
const candidateMask = candidates[cell] ?? 0;
|
||||
const candidateHighlights = candidateHighlighted
|
||||
? (candidateOverlay?.activeValues.filter(
|
||||
(digit) =>
|
||||
candidateMask === 0 ||
|
||||
(candidateMask & (1 << (digit - 1))) !== 0,
|
||||
) ?? [])
|
||||
: [];
|
||||
const conflict = conflicts.has(cell);
|
||||
const accessibleDescription = cellAccessibleDescription({
|
||||
puzzle,
|
||||
cell,
|
||||
value,
|
||||
corner,
|
||||
center,
|
||||
centerIsAutomatic,
|
||||
color: colors[cell] ?? 0,
|
||||
conflict,
|
||||
candidateHighlights,
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={cell}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
aria-rowindex={rowIndex + 1}
|
||||
aria-colindex={columnIndex + 1}
|
||||
aria-label={`Row ${String(rowIndex + 1)}, column ${String(columnIndex + 1)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
|
||||
aria-description={accessibleDescription}
|
||||
aria-selected={selected.has(cell)}
|
||||
aria-readonly={isGiven}
|
||||
aria-invalid={conflict || undefined}
|
||||
tabIndex={cell === activeCell ? 0 : -1}
|
||||
className={[
|
||||
"sudoku-cell",
|
||||
isGiven ? "is-given" : "",
|
||||
highlighted.has(cell) ? "is-digit-highlighted" : "",
|
||||
candidateHighlighted ? "is-candidate-highlighted" : "",
|
||||
selected.has(cell) ? "is-selected" : "",
|
||||
conflict ? "has-conflict" : "",
|
||||
colors[cell] ? `has-color-${String(colors[cell])}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onPointerDown={(event) => onCellPointerDown(cell, event)}
|
||||
onPointerEnter={(event) => onCellPointerEnter(cell, event)}
|
||||
data-cell={cell}
|
||||
>
|
||||
{value ? (
|
||||
<span className="cell-value">{symbolFor(value, size)}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="corner-marks" aria-hidden="true">
|
||||
{corner.map((mark) => (
|
||||
<span key={mark}>{symbolFor(mark, size)}</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="center-marks" aria-hidden="true">
|
||||
{center.map((mark) => symbolFor(mark, size)).join("")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ConstraintLayer puzzle={puzzle} />
|
||||
{candidateOverlay !== undefined && candidateOverlay.links.length > 0 && (
|
||||
<CandidateLinkLayer size={size} overlay={candidateOverlay} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+557
-73
@@ -24,6 +24,7 @@ import {
|
||||
toDomainPuzzle,
|
||||
type SudokuDocument,
|
||||
} from "../formats";
|
||||
import type { CandidateOverlay } from "../helpers";
|
||||
import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
|
||||
import type {
|
||||
DifficultyAssessment,
|
||||
@@ -57,9 +58,37 @@ import {
|
||||
matchingDigitCells,
|
||||
toggledDigitHighlight,
|
||||
} from "../state/gameplayHelpers";
|
||||
import {
|
||||
aidMemoireFromPortable,
|
||||
aidMemoireToPortable,
|
||||
cloneAidMemoire,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
eraseAidMemoireCell,
|
||||
setAidMemoireEnabled,
|
||||
type AidMemoireState,
|
||||
} from "../state/aidMemoire";
|
||||
import { navigateGridCell } from "../state/gridNavigation";
|
||||
import {
|
||||
activeHypothesis,
|
||||
aidMemoireFromGameplayState,
|
||||
beginHypothesis,
|
||||
createGameplayHistory,
|
||||
createSavepoint,
|
||||
deleteSavepoint,
|
||||
describeGameplayChange,
|
||||
finishHypothesis,
|
||||
gameplayMoment,
|
||||
recordGameplayMoment,
|
||||
restoreSavepoint,
|
||||
sessionFromGameplayState,
|
||||
type GameplayHistory,
|
||||
} from "../state/playHistory";
|
||||
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
|
||||
import { ConstraintEditor } from "./ConstraintEditor";
|
||||
import { AidMemoire } from "./AidMemoire";
|
||||
import { DigitCompletionBar } from "./DigitCompletionBar";
|
||||
import { GameplayHistoryDialog } from "./GameplayHistoryDialog";
|
||||
import { GeneratorWorkspace } from "./GeneratorWorkspace";
|
||||
import { HelpersWorkspace } from "./HelpersWorkspace";
|
||||
import { ImportExportDialog } from "./ImportExportDialog";
|
||||
@@ -79,6 +108,7 @@ interface Feedback {
|
||||
interface HistoryEntry {
|
||||
readonly puzzle: PuzzleDefinition;
|
||||
readonly session: PlaySnapshot;
|
||||
readonly aidMemoire: AidMemoireState;
|
||||
}
|
||||
|
||||
const library = createProjectLibrary();
|
||||
@@ -143,6 +173,7 @@ function progressFromSession(
|
||||
puzzle: PuzzleDefinition,
|
||||
session: PlaySession,
|
||||
completed: boolean,
|
||||
aidMemoire: AidMemoireState,
|
||||
): SudokuProgress {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -156,6 +187,7 @@ function progressFromSession(
|
||||
colors: [...session.colors],
|
||||
elapsedMs: session.elapsedSeconds * 1_000,
|
||||
completed,
|
||||
aidMemoire: aidMemoireToPortable(aidMemoire, puzzle.size),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,18 +227,27 @@ function safeNormalize(puzzle: PuzzleDefinition): {
|
||||
function initialProject(): {
|
||||
readonly puzzle: PuzzleDefinition;
|
||||
readonly session: PlaySession;
|
||||
readonly aidMemoire: AidMemoireState;
|
||||
} {
|
||||
if (typeof location !== "undefined" && location.hash.includes("sudoku=")) {
|
||||
try {
|
||||
const document = decodePuzzleHash(location.href);
|
||||
const puzzle = normalizePuzzle(toDomainPuzzle(document));
|
||||
return { puzzle, session: sessionFromDocument(puzzle, document) };
|
||||
return {
|
||||
puzzle,
|
||||
session: sessionFromDocument(puzzle, document),
|
||||
aidMemoire: aidMemoireFromPortable(document.aidMemoire, puzzle.size),
|
||||
};
|
||||
} catch {
|
||||
// An invalid hash is non-fatal; the import dialog can report details.
|
||||
}
|
||||
}
|
||||
const puzzle = normalizePuzzle(CLASSIC_SAMPLE);
|
||||
return { puzzle, session: createSession(puzzle.givens) };
|
||||
return {
|
||||
puzzle,
|
||||
session: createSession(puzzle.givens),
|
||||
aidMemoire: createAidMemoire(puzzle.size),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -245,6 +286,8 @@ function errorMessage(error: unknown): string {
|
||||
|
||||
function constraintLabel(type: string): string {
|
||||
if (type === "xv") return "Sum pairs (5/10)";
|
||||
if (type === "x-sum") return "X-sums";
|
||||
if (type === "false-clues") return "False clues";
|
||||
return type
|
||||
.split("-")
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
@@ -264,8 +307,19 @@ export function Workbench() {
|
||||
const [showDigitCompletion, setShowDigitCompletion] = useState(true);
|
||||
const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
|
||||
const [highlightedDigit, setHighlightedDigit] = useState<number | null>(null);
|
||||
const [candidateOverlay, setCandidateOverlay] = useState<CandidateOverlay>();
|
||||
const [aidMemoire, setAidMemoire] = useState<AidMemoireState>(
|
||||
boot.aidMemoire,
|
||||
);
|
||||
const [aidMemoireCell, setAidMemoireCell] = useState(0);
|
||||
const [aidMemoireActive, setAidMemoireActive] = useState(false);
|
||||
const [past, setPast] = useState<HistoryEntry[]>([]);
|
||||
const [future, setFuture] = useState<HistoryEntry[]>([]);
|
||||
const [gameplayHistory, setGameplayHistory] = useState<GameplayHistory>(() =>
|
||||
createGameplayHistory(boot.session, boot.aidMemoire),
|
||||
);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [replayMomentId, setReplayMomentId] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState<Feedback>();
|
||||
const [logical, setLogical] = useState<LogicalSolveResult>();
|
||||
@@ -286,9 +340,30 @@ export function Workbench() {
|
||||
const workerRef = useRef<SolverWorkerClient | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const workbenchRef = useRef<HTMLElement>(null);
|
||||
const replayReturnSessionRef = useRef<PlaySession | null>(null);
|
||||
const replayReturnAidMemoireRef = useRef<AidMemoireState | null>(null);
|
||||
|
||||
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
|
||||
const replayMoment = useMemo(
|
||||
() =>
|
||||
replayMomentId === undefined
|
||||
? undefined
|
||||
: gameplayMoment(gameplayHistory, replayMomentId),
|
||||
[gameplayHistory, replayMomentId],
|
||||
);
|
||||
const currentHypothesis = useMemo(
|
||||
() => activeHypothesis(gameplayHistory),
|
||||
[gameplayHistory],
|
||||
);
|
||||
const selectedSet = useMemo(() => new Set(selection), [selection]);
|
||||
const boardSelectedSet = useMemo(
|
||||
() => (aidMemoireActive ? new Set<number>() : selectedSet),
|
||||
[aidMemoireActive, selectedSet],
|
||||
);
|
||||
const handleCandidateOverlayChange = useCallback(
|
||||
(overlay: CandidateOverlay | undefined) => setCandidateOverlay(overlay),
|
||||
[],
|
||||
);
|
||||
const completionData = useMemo(
|
||||
() => digitCompletions(puzzle.size, session.values),
|
||||
[puzzle.size, session.values],
|
||||
@@ -345,20 +420,37 @@ export function Workbench() {
|
||||
}, []);
|
||||
|
||||
const currentHistory = useCallback(
|
||||
(): HistoryEntry => ({ puzzle, session: snapshotSession(session) }),
|
||||
[puzzle, session],
|
||||
(): HistoryEntry => ({
|
||||
puzzle,
|
||||
session: snapshotSession(session),
|
||||
aidMemoire: cloneAidMemoire(aidMemoire),
|
||||
}),
|
||||
[aidMemoire, puzzle, session],
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(nextPuzzle: PuzzleDefinition, nextSession: PlaySession): void => {
|
||||
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
|
||||
setFuture([]);
|
||||
setGameplayHistory((current) =>
|
||||
workspace === "play" && nextPuzzle === puzzle
|
||||
? recordGameplayMoment(
|
||||
current,
|
||||
nextSession,
|
||||
describeGameplayChange(session, nextSession, puzzle.size),
|
||||
aidMemoire,
|
||||
)
|
||||
: createGameplayHistory(nextSession, aidMemoire),
|
||||
);
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setReplayMomentId(undefined);
|
||||
setPuzzle(nextPuzzle);
|
||||
setSession(nextSession);
|
||||
clearAnalysis();
|
||||
setFeedback(undefined);
|
||||
},
|
||||
[clearAnalysis, currentHistory],
|
||||
[aidMemoire, clearAnalysis, currentHistory, puzzle, session, workspace],
|
||||
);
|
||||
|
||||
const loadPuzzle = useCallback(
|
||||
@@ -372,12 +464,26 @@ export function Workbench() {
|
||||
| "candidates"
|
||||
| "colors"
|
||||
| "elapsedMs"
|
||||
| "aidMemoire"
|
||||
>,
|
||||
projectId?: string,
|
||||
): void => {
|
||||
const valid = normalizePuzzle(nextPuzzle);
|
||||
const nextSession = sessionFromDocument(valid, progress);
|
||||
const nextAidMemoire = aidMemoireFromPortable(
|
||||
progress?.aidMemoire,
|
||||
valid.size,
|
||||
);
|
||||
setPuzzle(valid);
|
||||
setSession(sessionFromDocument(valid, progress));
|
||||
setSession(nextSession);
|
||||
setAidMemoire(nextAidMemoire);
|
||||
setAidMemoireCell(0);
|
||||
setAidMemoireActive(false);
|
||||
setGameplayHistory(createGameplayHistory(nextSession, nextAidMemoire));
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setReplayMomentId(undefined);
|
||||
setHistoryOpen(false);
|
||||
setSelection([0]);
|
||||
setActiveCell(0);
|
||||
setHighlightedDigit(null);
|
||||
@@ -393,8 +499,21 @@ export function Workbench() {
|
||||
const restoreProject = useCallback(
|
||||
(record: SudokuProjectRecord): void => {
|
||||
const valid = normalizePuzzle(toDomainPuzzle(record.puzzle));
|
||||
const nextSession = sessionFromProgress(valid, record.progress);
|
||||
const nextAidMemoire = aidMemoireFromPortable(
|
||||
record.progress?.aidMemoire ?? record.puzzle.aidMemoire,
|
||||
valid.size,
|
||||
);
|
||||
setPuzzle(valid);
|
||||
setSession(sessionFromProgress(valid, record.progress));
|
||||
setSession(nextSession);
|
||||
setAidMemoire(nextAidMemoire);
|
||||
setAidMemoireCell(0);
|
||||
setAidMemoireActive(false);
|
||||
setGameplayHistory(createGameplayHistory(nextSession, nextAidMemoire));
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setReplayMomentId(undefined);
|
||||
setHistoryOpen(false);
|
||||
setSelection([0]);
|
||||
setActiveCell(0);
|
||||
setHighlightedDigit(null);
|
||||
@@ -451,24 +570,46 @@ export function Workbench() {
|
||||
const undo = useCallback(() => {
|
||||
const target = past.at(-1);
|
||||
if (target === undefined) return;
|
||||
const restored = restoreSnapshot(session, target.session);
|
||||
setFuture((entries) =>
|
||||
[currentHistory(), ...entries].slice(0, MAX_HISTORY),
|
||||
);
|
||||
setPast(past.slice(0, -1));
|
||||
setPuzzle(target.puzzle);
|
||||
setSession((current) => restoreSnapshot(current, target.session));
|
||||
setSession(restored);
|
||||
setAidMemoire(cloneAidMemoire(target.aidMemoire));
|
||||
setAidMemoireActive(false);
|
||||
setGameplayHistory((current) =>
|
||||
workspace === "play" && target.puzzle === puzzle
|
||||
? recordGameplayMoment(current, restored, "Undo", target.aidMemoire)
|
||||
: createGameplayHistory(restored, target.aidMemoire),
|
||||
);
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setReplayMomentId(undefined);
|
||||
clearAnalysis();
|
||||
}, [clearAnalysis, currentHistory, past]);
|
||||
}, [clearAnalysis, currentHistory, past, puzzle, session, workspace]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
const target = future[0];
|
||||
if (target === undefined) return;
|
||||
const restored = restoreSnapshot(session, target.session);
|
||||
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
|
||||
setFuture(future.slice(1));
|
||||
setPuzzle(target.puzzle);
|
||||
setSession((current) => restoreSnapshot(current, target.session));
|
||||
setSession(restored);
|
||||
setAidMemoire(cloneAidMemoire(target.aidMemoire));
|
||||
setAidMemoireActive(false);
|
||||
setGameplayHistory((current) =>
|
||||
workspace === "play" && target.puzzle === puzzle
|
||||
? recordGameplayMoment(current, restored, "Redo", target.aidMemoire)
|
||||
: createGameplayHistory(restored, target.aidMemoire),
|
||||
);
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setReplayMomentId(undefined);
|
||||
clearAnalysis();
|
||||
}, [clearAnalysis, currentHistory, future]);
|
||||
}, [clearAnalysis, currentHistory, future, puzzle, session, workspace]);
|
||||
|
||||
const changeSelection = useCallback(
|
||||
(cell: number, additive: boolean, toggle: boolean): void => {
|
||||
@@ -488,6 +629,7 @@ export function Workbench() {
|
||||
const handlePointerDown = useCallback(
|
||||
(cell: number, event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (session.paused) return;
|
||||
setAidMemoireActive(false);
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
const command = event.ctrlKey || event.metaKey;
|
||||
@@ -528,9 +670,42 @@ export function Workbench() {
|
||||
[changeSelection, session.paused],
|
||||
);
|
||||
|
||||
const updateAidMemoire = useCallback(
|
||||
(next: AidMemoireState, label = "Aid-mémoire updated") => {
|
||||
if (next === aidMemoire) return;
|
||||
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
|
||||
setFuture([]);
|
||||
setAidMemoire(next);
|
||||
setGameplayHistory((current) =>
|
||||
recordGameplayMoment(current, session, label, next),
|
||||
);
|
||||
if (!next.enabled) setAidMemoireActive(false);
|
||||
setFeedback(undefined);
|
||||
},
|
||||
[aidMemoire, currentHistory, session],
|
||||
);
|
||||
|
||||
const enterValue = useCallback(
|
||||
(value: number): void => {
|
||||
(value: number, target: "auto" | "board" = "auto"): void => {
|
||||
if (session.paused) return;
|
||||
if (
|
||||
target === "auto" &&
|
||||
workspace === "play" &&
|
||||
aidMemoire.enabled &&
|
||||
aidMemoireActive
|
||||
) {
|
||||
updateAidMemoire(
|
||||
enterAidMemoireCell(
|
||||
aidMemoire,
|
||||
aidMemoireCell,
|
||||
entryMode,
|
||||
value,
|
||||
puzzle.size,
|
||||
),
|
||||
`Updated aid-mémoire cell ${String(aidMemoireCell + 1)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (workspace === "set" && entryMode === "value") {
|
||||
const givens = [...puzzle.givens];
|
||||
const values = [...session.values];
|
||||
@@ -554,37 +729,68 @@ export function Workbench() {
|
||||
enterSelection(session, selectedSet, entryMode, value, puzzle.givens),
|
||||
);
|
||||
},
|
||||
[commit, entryMode, puzzle, selectedSet, session, workspace],
|
||||
[
|
||||
aidMemoire,
|
||||
aidMemoireActive,
|
||||
aidMemoireCell,
|
||||
commit,
|
||||
entryMode,
|
||||
puzzle,
|
||||
selectedSet,
|
||||
session,
|
||||
updateAidMemoire,
|
||||
workspace,
|
||||
],
|
||||
);
|
||||
|
||||
const erase = useCallback((): void => {
|
||||
if (session.paused) return;
|
||||
if (workspace === "set" && entryMode === "value") {
|
||||
const givens = [...puzzle.givens];
|
||||
const values = [...session.values];
|
||||
for (const cell of selectedSet) {
|
||||
givens[cell] = 0;
|
||||
values[cell] = 0;
|
||||
const erase = useCallback(
|
||||
(target: "auto" | "board" = "auto"): void => {
|
||||
if (session.paused) return;
|
||||
if (
|
||||
target === "auto" &&
|
||||
workspace === "play" &&
|
||||
aidMemoire.enabled &&
|
||||
aidMemoireActive
|
||||
) {
|
||||
updateAidMemoire(
|
||||
eraseAidMemoireCell(aidMemoire, aidMemoireCell, entryMode),
|
||||
`Erased aid-mémoire cell ${String(aidMemoireCell + 1)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
commit({ ...puzzle, givens }, { ...session, values });
|
||||
return;
|
||||
}
|
||||
commit(
|
||||
if (workspace === "set" && entryMode === "value") {
|
||||
const givens = [...puzzle.givens];
|
||||
const values = [...session.values];
|
||||
for (const cell of selectedSet) {
|
||||
givens[cell] = 0;
|
||||
values[cell] = 0;
|
||||
}
|
||||
commit({ ...puzzle, givens }, { ...session, values });
|
||||
return;
|
||||
}
|
||||
commit(
|
||||
puzzle,
|
||||
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
|
||||
);
|
||||
},
|
||||
[
|
||||
aidMemoire,
|
||||
aidMemoireActive,
|
||||
aidMemoireCell,
|
||||
commit,
|
||||
entryMode,
|
||||
puzzle,
|
||||
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
|
||||
);
|
||||
}, [commit, entryMode, puzzle, selectedSet, session, workspace]);
|
||||
selectedSet,
|
||||
session,
|
||||
updateAidMemoire,
|
||||
workspace,
|
||||
],
|
||||
);
|
||||
|
||||
const moveActive = useCallback(
|
||||
(deltaRow: number, deltaColumn: number, extend: boolean): void => {
|
||||
const row = Math.floor(activeCell / puzzle.size);
|
||||
const column = activeCell % puzzle.size;
|
||||
const nextRow = Math.max(0, Math.min(puzzle.size - 1, row + deltaRow));
|
||||
const nextColumn = Math.max(
|
||||
0,
|
||||
Math.min(puzzle.size - 1, column + deltaColumn),
|
||||
);
|
||||
const next = nextRow * puzzle.size + nextColumn;
|
||||
(key: string, extend: boolean, command: boolean): void => {
|
||||
const next = navigateGridCell(activeCell, puzzle.size, key, command);
|
||||
if (next === null) return;
|
||||
changeSelection(next, extend, false);
|
||||
requestAnimationFrame(() => {
|
||||
workbenchRef.current
|
||||
@@ -597,6 +803,7 @@ export function Workbench() {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
setAidMemoireActive(false);
|
||||
const command = event.ctrlKey || event.metaKey;
|
||||
if (command && event.key.toLowerCase() === "z") {
|
||||
event.preventDefault();
|
||||
@@ -620,16 +827,20 @@ export function Workbench() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const movement: Record<string, readonly [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
const delta = movement[event.key];
|
||||
if (delta !== undefined) {
|
||||
if (
|
||||
[
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
].includes(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
moveActive(delta[0], delta[1], event.shiftKey);
|
||||
moveActive(event.key, event.shiftKey, command);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -638,7 +849,7 @@ export function Workbench() {
|
||||
event.key === "0"
|
||||
) {
|
||||
event.preventDefault();
|
||||
erase();
|
||||
erase("board");
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
@@ -657,7 +868,7 @@ export function Workbench() {
|
||||
const value = valueForKey(event.key, puzzle.size);
|
||||
if (value !== null) {
|
||||
event.preventDefault();
|
||||
enterValue(value);
|
||||
enterValue(value, "board");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -789,16 +1000,18 @@ export function Workbench() {
|
||||
kind:
|
||||
result.count === 1 && !result.truncated
|
||||
? "success"
|
||||
: result.count === 0 || result.count >= 2
|
||||
: result.count >= 2 || (result.count === 0 && !result.truncated)
|
||||
? "error"
|
||||
: "info",
|
||||
message:
|
||||
result.count === 0
|
||||
? "The definition has no solution."
|
||||
: result.count >= 2
|
||||
? "The definition has multiple solutions."
|
||||
: result.truncated
|
||||
? "A solution was found, but uniqueness was not established before a safety limit."
|
||||
result.count >= 2
|
||||
? "The definition has multiple solutions."
|
||||
: result.truncated
|
||||
? result.count === 0
|
||||
? "Search reached a safety limit before finding a solution; solvability is still unknown."
|
||||
: "A solution was found, but uniqueness was not established before a safety limit."
|
||||
: result.count === 0
|
||||
? "The definition has no solution."
|
||||
: `The definition is valid and uniquely solvable (${result.nodes.toLocaleString()} search nodes).`,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -828,7 +1041,7 @@ export function Workbench() {
|
||||
setWorkspace("generate");
|
||||
setFeedback({
|
||||
kind: "success",
|
||||
message: `Generated a unique ${generated.variant} puzzle. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
|
||||
message: `Generated a unique ${generated.variant} puzzle${generated.requestedTechnique === undefined ? "" : ` featuring ${generated.requestedTechnique.replaceAll("-", " ")}`}. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||
@@ -899,7 +1112,7 @@ export function Workbench() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const document = fromDomainPuzzle(puzzle);
|
||||
const progress = progressFromSession(puzzle, session, solved);
|
||||
const progress = progressFromSession(puzzle, session, solved, aidMemoire);
|
||||
let record: SudokuProjectRecord;
|
||||
const existing = currentProjectId
|
||||
? await library.get(currentProjectId)
|
||||
@@ -928,7 +1141,7 @@ export function Workbench() {
|
||||
} finally {
|
||||
setLibraryBusy(false);
|
||||
}
|
||||
}, [currentProjectId, puzzle, refreshLibrary, session, solved]);
|
||||
}, [aidMemoire, currentProjectId, puzzle, refreshLibrary, session, solved]);
|
||||
|
||||
const openProject = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -1032,13 +1245,167 @@ export function Workbench() {
|
||||
if (cells.length === 0) return;
|
||||
setSelection([...cells]);
|
||||
setActiveCell(cells[0]!);
|
||||
setAidMemoireActive(false);
|
||||
}, []);
|
||||
|
||||
const activeConstraints = useMemo(
|
||||
() => [...new Set(normalized.puzzle.constraints.map((item) => item.type))],
|
||||
[normalized.puzzle.constraints],
|
||||
const createNamedSavepoint = useCallback(
|
||||
(name: string) => {
|
||||
try {
|
||||
setGameplayHistory(
|
||||
createSavepoint(gameplayHistory, session, name, aidMemoire),
|
||||
);
|
||||
setFeedback({
|
||||
kind: "success",
|
||||
message: `Saved “${name.trim()}” locally for this solve.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||
}
|
||||
},
|
||||
[aidMemoire, gameplayHistory, session],
|
||||
);
|
||||
|
||||
const restoreNamedSavepoint = useCallback(
|
||||
(savepointId: string) => {
|
||||
try {
|
||||
const transition = restoreSavepoint(gameplayHistory, savepointId);
|
||||
const restored = sessionFromGameplayState(transition.state);
|
||||
setPast((entries) =>
|
||||
[...entries, currentHistory()].slice(-MAX_HISTORY),
|
||||
);
|
||||
setFuture([]);
|
||||
setGameplayHistory(transition.history);
|
||||
setSession(restored);
|
||||
setAidMemoire(
|
||||
aidMemoireFromGameplayState(transition.state, puzzle.size),
|
||||
);
|
||||
setAidMemoireActive(false);
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setReplayMomentId(undefined);
|
||||
setHistoryOpen(false);
|
||||
clearAnalysis();
|
||||
setFeedback({ kind: "success", message: "Savepoint restored." });
|
||||
} catch (error) {
|
||||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||
}
|
||||
},
|
||||
[clearAnalysis, currentHistory, gameplayHistory, puzzle.size],
|
||||
);
|
||||
|
||||
const startHypothesis = useCallback(
|
||||
(name: string, fromMomentId?: string) => {
|
||||
try {
|
||||
const transition = beginHypothesis(
|
||||
gameplayHistory,
|
||||
session,
|
||||
name,
|
||||
fromMomentId,
|
||||
aidMemoire,
|
||||
);
|
||||
setGameplayHistory(transition.history);
|
||||
setSession(sessionFromGameplayState(transition.state));
|
||||
setAidMemoire(
|
||||
aidMemoireFromGameplayState(transition.state, puzzle.size),
|
||||
);
|
||||
setAidMemoireActive(false);
|
||||
setPast([]);
|
||||
setFuture([]);
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setReplayMomentId(undefined);
|
||||
setHistoryOpen(false);
|
||||
clearAnalysis();
|
||||
setFeedback({
|
||||
kind: "info",
|
||||
message: `Hypothesis “${name.trim()}” started. Its moves are isolated until you keep or discard them.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||
}
|
||||
},
|
||||
[aidMemoire, clearAnalysis, gameplayHistory, puzzle.size, session],
|
||||
);
|
||||
|
||||
const completeHypothesis = useCallback(
|
||||
(decision: "keep" | "discard") => {
|
||||
try {
|
||||
const transition = finishHypothesis(
|
||||
gameplayHistory,
|
||||
session,
|
||||
decision,
|
||||
aidMemoire,
|
||||
);
|
||||
setGameplayHistory(transition.history);
|
||||
setSession(sessionFromGameplayState(transition.state));
|
||||
setAidMemoire(
|
||||
aidMemoireFromGameplayState(transition.state, puzzle.size),
|
||||
);
|
||||
setAidMemoireActive(false);
|
||||
setPast([]);
|
||||
setFuture([]);
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setReplayMomentId(undefined);
|
||||
setHistoryOpen(false);
|
||||
clearAnalysis();
|
||||
setFeedback({
|
||||
kind: "success",
|
||||
message:
|
||||
decision === "keep"
|
||||
? "Hypothesis changes kept in the main solve."
|
||||
: "Hypothesis discarded; its branch remains available in replay.",
|
||||
});
|
||||
} catch (error) {
|
||||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||
}
|
||||
},
|
||||
[aidMemoire, clearAnalysis, gameplayHistory, puzzle.size, session],
|
||||
);
|
||||
|
||||
const replayHistoryMoment = useCallback(
|
||||
(momentId: string) => {
|
||||
const moment = gameplayMoment(gameplayHistory, momentId);
|
||||
if (moment === undefined) return;
|
||||
if (replayReturnSessionRef.current === null) {
|
||||
replayReturnSessionRef.current = session;
|
||||
replayReturnAidMemoireRef.current = aidMemoire;
|
||||
}
|
||||
setSession(sessionFromGameplayState(moment.state, true));
|
||||
setAidMemoire(aidMemoireFromGameplayState(moment.state, puzzle.size));
|
||||
setAidMemoireActive(false);
|
||||
setReplayMomentId(moment.id);
|
||||
setHistoryOpen(false);
|
||||
setHighlightedDigit(null);
|
||||
},
|
||||
[aidMemoire, gameplayHistory, puzzle.size, session],
|
||||
);
|
||||
|
||||
const returnToLiveGrid = useCallback(() => {
|
||||
const live = replayReturnSessionRef.current;
|
||||
const liveAidMemoire = replayReturnAidMemoireRef.current;
|
||||
if (live !== null) setSession(live);
|
||||
if (liveAidMemoire !== null) setAidMemoire(liveAidMemoire);
|
||||
replayReturnSessionRef.current = null;
|
||||
replayReturnAidMemoireRef.current = null;
|
||||
setAidMemoireActive(false);
|
||||
setReplayMomentId(undefined);
|
||||
}, []);
|
||||
|
||||
const activeConstraints = useMemo(() => {
|
||||
const types: string[] = [
|
||||
...new Set(normalized.puzzle.constraints.map((item) => item.type)),
|
||||
];
|
||||
if (
|
||||
normalized.puzzle.constraints.some(
|
||||
(item) => "negated" in item && item.negated === true,
|
||||
)
|
||||
) {
|
||||
types.push("false-clues");
|
||||
}
|
||||
return types;
|
||||
}, [normalized.puzzle.constraints]);
|
||||
|
||||
return (
|
||||
<main className="sudoku-workbench" ref={workbenchRef}>
|
||||
<header className="workbench-hero">
|
||||
@@ -1119,7 +1486,15 @@ export function Workbench() {
|
||||
type="button"
|
||||
className={workspace === id ? "is-active" : ""}
|
||||
aria-current={workspace === id ? "page" : undefined}
|
||||
disabled={currentHypothesis !== undefined && id !== "play"}
|
||||
title={
|
||||
currentHypothesis !== undefined && id !== "play"
|
||||
? "Keep or discard the active hypothesis first"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (id !== "play") returnToLiveGrid();
|
||||
if (id !== "play") setAidMemoireActive(false);
|
||||
setWorkspace(id);
|
||||
if (id === "set") setEntryMode("value");
|
||||
}}
|
||||
@@ -1169,20 +1544,46 @@ export function Workbench() {
|
||||
<section className="board-column" aria-label="Puzzle board">
|
||||
<div className="board-toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button type="button" disabled={!past.length} onClick={undo}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!past.length || replayMoment !== undefined}
|
||||
onClick={undo}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<button type="button" disabled={!future.length} onClick={redo}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!future.length || replayMoment !== undefined}
|
||||
onClick={redo}
|
||||
>
|
||||
Redo
|
||||
</button>
|
||||
{workspace === "play" && (
|
||||
<button type="button" onClick={() => setHistoryOpen(true)}>
|
||||
{currentHypothesis
|
||||
? `Hypothesis: ${currentHypothesis.name}`
|
||||
: "History & branches"}
|
||||
</button>
|
||||
)}
|
||||
{replayMoment && (
|
||||
<button type="button" onClick={returnToLiveGrid}>
|
||||
Return live
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span>
|
||||
{selection.length === 1
|
||||
? cellLabel(selection[0]!, puzzle.size)
|
||||
: `${String(selection.length)} cells selected`}
|
||||
{replayMoment
|
||||
? `Replay · ${replayMoment.label}`
|
||||
: aidMemoireActive && aidMemoire.enabled
|
||||
? `Aid-mémoire · ${aidMemoire.cells[aidMemoireCell]?.label || `cell ${String(aidMemoireCell + 1)}`}`
|
||||
: selection.length === 1
|
||||
? cellLabel(selection[0]!, puzzle.size)
|
||||
: `${String(selection.length)} cells selected`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`board-surface${session.paused ? " is-paused" : ""}`}>
|
||||
<div
|
||||
className={`board-surface${session.paused && replayMoment === undefined ? " is-paused" : ""}${replayMoment ? " is-replay" : ""}`}
|
||||
>
|
||||
<SudokuBoard
|
||||
puzzle={normalized.puzzle}
|
||||
values={session.values}
|
||||
@@ -1190,16 +1591,19 @@ export function Workbench() {
|
||||
centerMarks={session.centerMarks}
|
||||
colors={session.colors}
|
||||
candidates={candidateMasks}
|
||||
selected={selectedSet}
|
||||
selected={boardSelectedSet}
|
||||
highlighted={highlightedCells}
|
||||
conflicts={conflictCells}
|
||||
activeCell={activeCell}
|
||||
showCandidates={showCandidates}
|
||||
candidateOverlay={
|
||||
workspace === "helpers" ? candidateOverlay : undefined
|
||||
}
|
||||
onCellPointerDown={handlePointerDown}
|
||||
onCellPointerEnter={handlePointerEnter}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{session.paused && (
|
||||
{session.paused && replayMoment === undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="paused-cover"
|
||||
@@ -1225,6 +1629,25 @@ export function Workbench() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{workspace === "play" &&
|
||||
aidMemoire.enabled &&
|
||||
(!session.paused || replayMoment !== undefined) && (
|
||||
<AidMemoire
|
||||
size={puzzle.size}
|
||||
state={aidMemoire}
|
||||
selectedCell={aidMemoireCell}
|
||||
active={aidMemoireActive && replayMoment === undefined}
|
||||
readOnly={replayMoment !== undefined}
|
||||
mode={entryMode}
|
||||
onStateChange={updateAidMemoire}
|
||||
onSelect={(cell) => {
|
||||
setAidMemoireCell(cell);
|
||||
setAidMemoireActive(true);
|
||||
setHighlightedDigit(null);
|
||||
}}
|
||||
onMode={setEntryMode}
|
||||
/>
|
||||
)}
|
||||
<div className="board-meta">
|
||||
<section className="rule-card">
|
||||
<p className="eyebrow">Rules</p>
|
||||
@@ -1247,13 +1670,37 @@ export function Workbench() {
|
||||
</section>
|
||||
|
||||
<aside className="side-panel">
|
||||
{workspace === "play" && (
|
||||
{workspace === "play" && replayMoment && (
|
||||
<div className="play-panel stack">
|
||||
<div>
|
||||
<p className="eyebrow">Read-only replay</p>
|
||||
<h2>{replayMoment.label}</h2>
|
||||
<p className="muted">
|
||||
This is the complete grid at{" "}
|
||||
{formatTime(replayMoment.state.elapsedSeconds)}. Return to the
|
||||
live grid or start a hypothesis from here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button type="button" onClick={returnToLiveGrid}>
|
||||
Return to live grid
|
||||
</button>
|
||||
<button type="button" onClick={() => setHistoryOpen(true)}>
|
||||
Open history
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspace === "play" && replayMoment === undefined && (
|
||||
<div className="play-panel stack">
|
||||
<div>
|
||||
<p className="eyebrow">Play locally</p>
|
||||
<h2>Enter your solve</h2>
|
||||
<p className="muted">
|
||||
Select one or several cells, then use the keypad or keyboard.
|
||||
{aidMemoireActive && aidMemoire.enabled
|
||||
? "The keypad is editing the selected aid-mémoire cell. Select the Sudoku grid to return to normal entry."
|
||||
: "Select one or several cells, then use the keypad or keyboard."}
|
||||
</p>
|
||||
</div>
|
||||
<NumberPad
|
||||
@@ -1323,6 +1770,22 @@ export function Workbench() {
|
||||
/>
|
||||
Enable matching-digit highlighting (Ctrl/⌘-click or bar)
|
||||
</label>
|
||||
<label className="option-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aidMemoire.enabled}
|
||||
onChange={(event) => {
|
||||
const enabled = event.target.checked;
|
||||
updateAidMemoire(
|
||||
setAidMemoireEnabled(aidMemoire, enabled),
|
||||
enabled ? "Aid-mémoire shown" : "Aid-mémoire hidden",
|
||||
);
|
||||
setAidMemoireActive(enabled);
|
||||
if (enabled) setAidMemoireCell(0);
|
||||
}}
|
||||
/>
|
||||
Show aid-mémoire scratch cells
|
||||
</label>
|
||||
</section>
|
||||
<section className="panel-section">
|
||||
<button
|
||||
@@ -1334,8 +1797,9 @@ export function Workbench() {
|
||||
</button>
|
||||
</section>
|
||||
<p className="muted shortcut-note">
|
||||
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/⌘ Z
|
||||
undoes · Ctrl/⌘-click a placed digit highlights its matches.
|
||||
Z/X/C/V change mode · arrows move · Home/End move across a row ·
|
||||
Shift extends · Ctrl/⌘ Z undoes · Ctrl/⌘-click a placed digit
|
||||
highlights its matches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1401,9 +1865,13 @@ export function Workbench() {
|
||||
|
||||
{workspace === "helpers" && (
|
||||
<HelpersWorkspace
|
||||
key={puzzle.size}
|
||||
size={puzzle.size}
|
||||
puzzle={normalized.puzzle}
|
||||
values={session.values}
|
||||
selectedCells={selection}
|
||||
candidateMasks={candidateMasks}
|
||||
onCandidateOverlayChange={handleCandidateOverlayChange}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
@@ -1413,6 +1881,7 @@ export function Workbench() {
|
||||
open={importOpen}
|
||||
puzzle={puzzle}
|
||||
session={session}
|
||||
aidMemoire={aidMemoireToPortable(aidMemoire, puzzle.size)}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImport={(next, progress) => {
|
||||
try {
|
||||
@@ -1437,6 +1906,21 @@ export function Workbench() {
|
||||
onExport={() => void exportLibrary()}
|
||||
onImport={(file) => void importLibrary(file)}
|
||||
/>
|
||||
<GameplayHistoryDialog
|
||||
open={historyOpen}
|
||||
history={gameplayHistory}
|
||||
replayMomentId={replayMomentId}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onCreateSavepoint={createNamedSavepoint}
|
||||
onRestoreSavepoint={restoreNamedSavepoint}
|
||||
onDeleteSavepoint={(savepointId) =>
|
||||
setGameplayHistory((current) => deleteSavepoint(current, savepointId))
|
||||
}
|
||||
onStartHypothesis={startHypothesis}
|
||||
onFinishHypothesis={completeHypothesis}
|
||||
onReplayMoment={replayHistoryMoment}
|
||||
onReturnLive={returnToLiveGrid}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user