feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class AppErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error(
|
||||
"Sudoku Tools encountered an unrecoverable interface error",
|
||||
error,
|
||||
info,
|
||||
);
|
||||
}
|
||||
render(): ReactNode {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<main className="fatal-error" role="alert">
|
||||
<h1>Sudoku Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => globalThis.location.reload()}
|
||||
>
|
||||
Reload application
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useState } from "react";
|
||||
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
|
||||
|
||||
interface ConstraintEditorProps {
|
||||
puzzle: PuzzleDefinition;
|
||||
selection: readonly number[];
|
||||
onChange: (puzzle: PuzzleDefinition) => void;
|
||||
onNewGrid: (size: number) => void;
|
||||
onCheck: () => void;
|
||||
onGenerate: () => void;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function describeConstraint(constraint: VariantConstraint, size: number) {
|
||||
const cell = (index: number) =>
|
||||
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
return `${constraint.direction} diagonal`;
|
||||
case "anti-knight":
|
||||
return "anti-knight";
|
||||
case "anti-king":
|
||||
return "anti-king";
|
||||
case "non-consecutive":
|
||||
return "non-consecutive";
|
||||
case "killer-cage":
|
||||
return `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells`;
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return `${constraint.type} · ${String(constraint.cells.length)} cells`;
|
||||
case "arrow":
|
||||
return `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`;
|
||||
case "kropki":
|
||||
return `${constraint.kind} dot · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||||
case "xv":
|
||||
return `${constraint.total === 5 ? "V" : "X"} · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||||
case "inequality":
|
||||
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function ConstraintEditor({
|
||||
puzzle,
|
||||
selection,
|
||||
onChange,
|
||||
onNewGrid,
|
||||
onCheck,
|
||||
onGenerate,
|
||||
busy,
|
||||
}: ConstraintEditorProps) {
|
||||
const [cageSum, setCageSum] = useState(10);
|
||||
const [region, setRegion] = useState(1);
|
||||
const constraints = puzzle.constraints ?? [];
|
||||
|
||||
const append = (constraint: VariantConstraint) =>
|
||||
onChange({ ...puzzle, constraints: [...constraints, constraint] });
|
||||
const need = (count: number) => selection.length === count;
|
||||
const atLeast = (count: number) => selection.length >= count;
|
||||
|
||||
const toggleGlobal = (
|
||||
type: "anti-knight" | "anti-king" | "non-consecutive",
|
||||
) => {
|
||||
const exists = constraints.some((constraint) => constraint.type === type);
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: exists
|
||||
? constraints.filter((constraint) => constraint.type !== type)
|
||||
: [...constraints, { type }],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="setter-panel stack">
|
||||
<section className="panel-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Puzzle definition</p>
|
||||
<h2>Set a puzzle</h2>
|
||||
</div>
|
||||
<label className="compact-field">
|
||||
Grid
|
||||
<select
|
||||
value={puzzle.size}
|
||||
onChange={(event) => onNewGrid(Number(event.target.value))}
|
||||
>
|
||||
<option value="4">4 × 4</option>
|
||||
<option value="6">6 × 6</option>
|
||||
<option value="9">9 × 9</option>
|
||||
<option value="12">12 × 12</option>
|
||||
<option value="16">16 × 16</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
<label>
|
||||
Title
|
||||
<input
|
||||
value={puzzle.title ?? ""}
|
||||
maxLength={256}
|
||||
onChange={(event) =>
|
||||
onChange({ ...puzzle, title: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Setter
|
||||
<input
|
||||
value={puzzle.author ?? ""}
|
||||
maxLength={256}
|
||||
onChange={(event) =>
|
||||
onChange({ ...puzzle, author: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Rules
|
||||
<textarea
|
||||
rows={3}
|
||||
maxLength={16_384}
|
||||
value={puzzle.rules ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange({ ...puzzle, rules: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="panel-section">
|
||||
<p className="eyebrow">Selected cells</p>
|
||||
<h3>
|
||||
{selection.length
|
||||
? `${String(selection.length)} selected`
|
||||
: "Select cells on the grid"}
|
||||
</h3>
|
||||
<p className="muted">
|
||||
Selection order defines lines and pair direction. Shift-click or drag
|
||||
to build a selection.
|
||||
</p>
|
||||
<div className="inline-fields">
|
||||
<label className="compact-field">
|
||||
Cage sum
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={puzzle.size * puzzle.size}
|
||||
value={cageSum}
|
||||
onChange={(event) => setCageSum(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!atLeast(1) || !Number.isInteger(cageSum)}
|
||||
onClick={() =>
|
||||
append({ type: "killer-cage", cells: selection, sum: cageSum })
|
||||
}
|
||||
>
|
||||
Add cage
|
||||
</button>
|
||||
</div>
|
||||
<div className="button-grid">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!atLeast(2)}
|
||||
onClick={() => append({ type: "thermo", cells: selection })}
|
||||
>
|
||||
Thermo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!atLeast(2)}
|
||||
onClick={() => append({ type: "renban", cells: selection })}
|
||||
>
|
||||
Renban
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!atLeast(2)}
|
||||
onClick={() => append({ type: "palindrome", cells: selection })}
|
||||
>
|
||||
Palindrome
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!atLeast(2)}
|
||||
onClick={() =>
|
||||
append({
|
||||
type: "arrow",
|
||||
bulb: [selection[0]!],
|
||||
line: selection.slice(1),
|
||||
})
|
||||
}
|
||||
>
|
||||
Arrow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!need(2)}
|
||||
onClick={() =>
|
||||
append({
|
||||
type: "kropki",
|
||||
a: selection[0]!,
|
||||
b: selection[1]!,
|
||||
kind: "white",
|
||||
})
|
||||
}
|
||||
>
|
||||
White dot
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!need(2)}
|
||||
onClick={() =>
|
||||
append({
|
||||
type: "kropki",
|
||||
a: selection[0]!,
|
||||
b: selection[1]!,
|
||||
kind: "black",
|
||||
})
|
||||
}
|
||||
>
|
||||
Black dot
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!need(2)}
|
||||
onClick={() =>
|
||||
append({
|
||||
type: "xv",
|
||||
a: selection[0]!,
|
||||
b: selection[1]!,
|
||||
total: 5,
|
||||
})
|
||||
}
|
||||
>
|
||||
V pair
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!need(2)}
|
||||
onClick={() =>
|
||||
append({
|
||||
type: "xv",
|
||||
a: selection[0]!,
|
||||
b: selection[1]!,
|
||||
total: 10,
|
||||
})
|
||||
}
|
||||
>
|
||||
X pair
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!need(2)}
|
||||
onClick={() =>
|
||||
append({
|
||||
type: "inequality",
|
||||
lesser: selection[0]!,
|
||||
greater: selection[1]!,
|
||||
})
|
||||
}
|
||||
>
|
||||
First < second
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-fields">
|
||||
<label className="compact-field">
|
||||
Region
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={puzzle.size}
|
||||
value={region}
|
||||
onChange={(event) => setRegion(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!atLeast(1) || region < 1 || region > puzzle.size}
|
||||
onClick={() => {
|
||||
const regions = [...(puzzle.regions ?? [])];
|
||||
for (const cell of selection) regions[cell] = region - 1;
|
||||
onChange({ ...puzzle, regions });
|
||||
}}
|
||||
>
|
||||
Paint region
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-section">
|
||||
<p className="eyebrow">Global rules</p>
|
||||
<div className="button-grid">
|
||||
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
|
||||
(type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={
|
||||
constraints.some((constraint) => constraint.type === type)
|
||||
? "is-active"
|
||||
: ""
|
||||
}
|
||||
aria-pressed={constraints.some(
|
||||
(constraint) => constraint.type === type,
|
||||
)}
|
||||
onClick={() => toggleGlobal(type)}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{(["main", "anti"] as const).map((direction) => {
|
||||
const active = constraints.some(
|
||||
(constraint) =>
|
||||
constraint.type === "diagonal" &&
|
||||
constraint.direction === direction,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={direction}
|
||||
type="button"
|
||||
className={active ? "is-active" : ""}
|
||||
aria-pressed={active}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: active
|
||||
? constraints.filter(
|
||||
(constraint) =>
|
||||
constraint.type !== "diagonal" ||
|
||||
constraint.direction !== direction,
|
||||
)
|
||||
: [...constraints, { type: "diagonal", direction }],
|
||||
})
|
||||
}
|
||||
>
|
||||
{direction} diagonal
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{constraints.length > 0 && (
|
||||
<section className="panel-section">
|
||||
<p className="eyebrow">Constraints</p>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="panel-section action-row">
|
||||
<button type="button" onClick={onCheck} disabled={busy}>
|
||||
Check definition & uniqueness
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerate}
|
||||
disabled={busy || puzzle.size > 9}
|
||||
>
|
||||
Generate classic
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} title="Sudoku Tools help" onClose={onClose} wide>
|
||||
<div className="help-grid">
|
||||
<section>
|
||||
<h3>Four 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>Solve</strong> explains logical steps and can
|
||||
verify uniqueness. <strong>Helpers</strong> answers focused
|
||||
questions without changing the board.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Keyboard</h3>
|
||||
<dl className="shortcut-list">
|
||||
<div>
|
||||
<dt>Arrow keys</dt>
|
||||
<dd>Move the active cell</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Shift + arrows</dt>
|
||||
<dd>Extend the selection</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>1–9 / A–G</dt>
|
||||
<dd>Enter the selected symbol</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Z / X / C / V</dt>
|
||||
<dd>Value, corner, centre or colour mode</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Backspace</dt>
|
||||
<dd>Erase in the current mode</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ctrl/⌘ + Z/Y</dt>
|
||||
<dd>Undo or redo</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Hints and solutions</h3>
|
||||
<p>
|
||||
Candidate legality is computed independently from handwritten notes.
|
||||
Logical deductions report their premises, affected houses,
|
||||
placements and eliminations. Exact search is separately labelled; it
|
||||
proves feasibility or uniqueness but is not presented as a human
|
||||
explanation.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<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.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
analyzeKillerCage,
|
||||
calculateResidual,
|
||||
relationPairs,
|
||||
type RelationSpec,
|
||||
} from "../helpers";
|
||||
import { maskValues, symbolFor } from "../state/session";
|
||||
|
||||
function digits(text: string) {
|
||||
return [
|
||||
...new Set(
|
||||
(text.match(/\d+|[A-P]/giu) ?? []).map((token) =>
|
||||
/^\d+$/u.test(token)
|
||||
? Number(token)
|
||||
: token.toUpperCase().charCodeAt(0) - 55,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function numbers(text: string) {
|
||||
return (text.match(/-?\d+(?:\.\d+)?/gu) ?? []).map(Number);
|
||||
}
|
||||
|
||||
export function HelpersWorkspace({
|
||||
size,
|
||||
selectedCells,
|
||||
candidateMasks,
|
||||
}: {
|
||||
size: number;
|
||||
selectedCells: readonly number[];
|
||||
candidateMasks: readonly number[];
|
||||
}) {
|
||||
const [helper, setHelper] = useState<"killer" | "residual" | "relations">(
|
||||
"killer",
|
||||
);
|
||||
const [cellCount, setCellCount] = useState(2);
|
||||
const [sum, setSum] = useState(10);
|
||||
const [allowed, setAllowed] = useState("");
|
||||
const [required, setRequired] = useState("");
|
||||
const [excluded, setExcluded] = useState("");
|
||||
const [allowRepeats, setAllowRepeats] = useState(false);
|
||||
const [useBoardCandidates, setUseBoardCandidates] = useState(false);
|
||||
const [knownParts, setKnownParts] = useState("15, 12");
|
||||
const [unknownCount, setUnknownCount] = useState(2);
|
||||
const [relation, setRelation] = useState("white");
|
||||
const [firstCandidates, setFirstCandidates] = useState("");
|
||||
const [secondCandidates, setSecondCandidates] = useState("");
|
||||
|
||||
const effectiveCount =
|
||||
useBoardCandidates && selectedCells.length > 0
|
||||
? selectedCells.length
|
||||
: cellCount;
|
||||
const killer = useMemo(() => {
|
||||
try {
|
||||
const candidates =
|
||||
useBoardCandidates && selectedCells.length > 0
|
||||
? selectedCells.map((cell) =>
|
||||
maskValues(candidateMasks[cell] ?? 0, size),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
result: analyzeKillerCage({
|
||||
size,
|
||||
cellCount: effectiveCount,
|
||||
sum,
|
||||
allowRepeats,
|
||||
...(allowed.trim() ? { allowedDigits: digits(allowed) } : {}),
|
||||
...(required.trim() ? { requiredDigits: digits(required) } : {}),
|
||||
...(excluded.trim() ? { excludedDigits: digits(excluded) } : {}),
|
||||
...(candidates === undefined ? {} : { candidates }),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Invalid helper input.",
|
||||
};
|
||||
}
|
||||
}, [
|
||||
allowRepeats,
|
||||
allowed,
|
||||
candidateMasks,
|
||||
effectiveCount,
|
||||
excluded,
|
||||
required,
|
||||
selectedCells,
|
||||
size,
|
||||
sum,
|
||||
useBoardCandidates,
|
||||
]);
|
||||
|
||||
const residual = useMemo(() => {
|
||||
try {
|
||||
return {
|
||||
result: calculateResidual({
|
||||
size,
|
||||
knownSums: numbers(knownParts),
|
||||
unknownCount,
|
||||
allowRepeats,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Invalid residual input.",
|
||||
};
|
||||
}
|
||||
}, [allowRepeats, knownParts, size, unknownCount]);
|
||||
|
||||
const pairs = useMemo(() => {
|
||||
try {
|
||||
const pairSpec: RelationSpec =
|
||||
relation === "white"
|
||||
? { type: "kropki", kind: "white" }
|
||||
: relation === "black"
|
||||
? { type: "kropki", kind: "black" }
|
||||
: relation === "v"
|
||||
? { type: "xv", total: 5 }
|
||||
: relation === "x"
|
||||
? { type: "xv", total: 10 }
|
||||
: relation === "less"
|
||||
? { type: "inequality", relation: "<" }
|
||||
: { type: "inequality", relation: ">" };
|
||||
return {
|
||||
result: relationPairs(
|
||||
pairSpec,
|
||||
size,
|
||||
firstCandidates.trim() ? digits(firstCandidates) : undefined,
|
||||
secondCandidates.trim() ? digits(secondCandidates) : undefined,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Invalid pair input.",
|
||||
};
|
||||
}
|
||||
}, [firstCandidates, relation, secondCandidates, size]);
|
||||
|
||||
return (
|
||||
<div className="helpers-workspace stack">
|
||||
<div>
|
||||
<p className="eyebrow">Focused calculators</p>
|
||||
<h2>Sudoku helpers</h2>
|
||||
<p className="muted">
|
||||
Answer a local question without changing the puzzle. Values are
|
||||
bounded to the current {size}×{size} symbol set.
|
||||
</p>
|
||||
</div>
|
||||
<div className="subtabs" role="tablist" aria-label="Helper">
|
||||
{(
|
||||
[
|
||||
["killer", "Killer combinations"],
|
||||
["residual", `${String((size * (size + 1)) / 2)}-rule residual`],
|
||||
["relations", "Pair relations"],
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={helper === id}
|
||||
className={helper === id ? "is-active" : ""}
|
||||
onClick={() => setHelper(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{helper === "killer" && (
|
||||
<section className="helper-card">
|
||||
<div className="helper-controls">
|
||||
<label>
|
||||
Cells
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={size}
|
||||
value={cellCount}
|
||||
disabled={useBoardCandidates && selectedCells.length > 0}
|
||||
onChange={(event) => setCellCount(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Sum
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={size * size}
|
||||
value={sum}
|
||||
onChange={(event) => setSum(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Allowed digits
|
||||
<input
|
||||
value={allowed}
|
||||
placeholder="all"
|
||||
onChange={(event) => setAllowed(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Must include
|
||||
<input
|
||||
value={required}
|
||||
placeholder="e.g. 1, 7"
|
||||
onChange={(event) => setRequired(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Exclude
|
||||
<input
|
||||
value={excluded}
|
||||
placeholder="e.g. 5"
|
||||
onChange={(event) => setExcluded(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowRepeats}
|
||||
onChange={(event) => setAllowRepeats(event.target.checked)}
|
||||
/>
|
||||
Allow repeated digits
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useBoardCandidates}
|
||||
disabled={selectedCells.length === 0}
|
||||
onChange={(event) =>
|
||||
setUseBoardCandidates(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Use {selectedCells.length || "selected"} board cell
|
||||
{selectedCells.length === 1 ? "" : "s"} and candidates
|
||||
</label>
|
||||
</div>
|
||||
{killer.error ? (
|
||||
<p className="error-callout" role="alert">
|
||||
{killer.error}
|
||||
</p>
|
||||
) : killer.result ? (
|
||||
<div className="helper-result">
|
||||
<div className="metric-row">
|
||||
<span>
|
||||
<strong>{killer.result.combinations.length}</strong>{" "}
|
||||
combinations
|
||||
</span>
|
||||
<span>
|
||||
Possible{" "}
|
||||
<strong>
|
||||
{killer.result.possibleDigits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" ") || "none"}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Necessary{" "}
|
||||
<strong>
|
||||
{killer.result.necessaryDigits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" ") || "none"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
{killer.result.possibleByCell.some(
|
||||
(entry) => entry.length > 0,
|
||||
) && (
|
||||
<ol className="cell-possibilities">
|
||||
{killer.result.possibleByCell.map((entry, index) => (
|
||||
<li key={index}>
|
||||
Cell {index + 1}:{" "}
|
||||
{entry.map((value) => symbolFor(value, size)).join(" ") ||
|
||||
"—"}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<div className="combination-cloud" aria-label="Combinations">
|
||||
{killer.result.combinations.slice(0, 300).map((combination) => (
|
||||
<code key={combination.join("-")}>
|
||||
{combination
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join("")}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
{(killer.result.combinations.length > 300 ||
|
||||
killer.result.truncated) && (
|
||||
<p className="muted">
|
||||
The visible list is bounded; refine the filters to inspect
|
||||
fewer results.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{helper === "residual" && (
|
||||
<section className="helper-card">
|
||||
<div className="helper-controls">
|
||||
<label>
|
||||
Accounted values or cage sums
|
||||
<input
|
||||
value={knownParts}
|
||||
onChange={(event) => setKnownParts(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Unaccounted cells
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={size}
|
||||
value={unknownCount}
|
||||
onChange={(event) =>
|
||||
setUnknownCount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{residual.error ? (
|
||||
<p className="error-callout" role="alert">
|
||||
{residual.error}
|
||||
</p>
|
||||
) : residual.result ? (
|
||||
<div className="helper-result">
|
||||
<p className="residual-equation">
|
||||
{residual.result.total} − {residual.result.accounted} ={" "}
|
||||
<strong>{residual.result.residual}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{residual.result.analysis?.combinations.length ?? 0} possible
|
||||
digit combinations for {unknownCount} residual cell
|
||||
{unknownCount === 1 ? "" : "s"}.
|
||||
</p>
|
||||
<div className="combination-cloud">
|
||||
{residual.result.analysis?.combinations
|
||||
.slice(0, 300)
|
||||
.map((combination) => (
|
||||
<code key={combination.join("-")}>
|
||||
{combination
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join("")}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{helper === "relations" && (
|
||||
<section className="helper-card">
|
||||
<div className="helper-controls">
|
||||
<label>
|
||||
Relation
|
||||
<select
|
||||
value={relation}
|
||||
onChange={(event) => setRelation(event.target.value)}
|
||||
>
|
||||
<option value="white">White Kropki · difference 1</option>
|
||||
<option value="black">Black Kropki · ratio 1:2</option>
|
||||
<option value="v">V · sum 5</option>
|
||||
<option value="x">X · sum 10</option>
|
||||
<option value="less">First < second</option>
|
||||
<option value="greater">First > second</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
First-cell candidates
|
||||
<input
|
||||
value={firstCandidates}
|
||||
placeholder="all"
|
||||
onChange={(event) => setFirstCandidates(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Second-cell candidates
|
||||
<input
|
||||
value={secondCandidates}
|
||||
placeholder="all"
|
||||
onChange={(event) => setSecondCandidates(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{pairs.error ? (
|
||||
<p className="error-callout" role="alert">
|
||||
{pairs.error}
|
||||
</p>
|
||||
) : (
|
||||
<div className="pair-table">
|
||||
{pairs.result?.map(([first, second]) => (
|
||||
<code key={`${String(first)}-${String(second)}`}>
|
||||
{symbolFor(first, size)} → {symbolFor(second, size)}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { PuzzleDefinition } from "../domain/types";
|
||||
import {
|
||||
decodePuzzleHash,
|
||||
encodePuzzleHash,
|
||||
exportFpuzzlesJson,
|
||||
exportFpuzzlesUrl,
|
||||
fromDomainPuzzle,
|
||||
importFpuzzles,
|
||||
parseFpuzzles,
|
||||
parsePlainGrid,
|
||||
parseSudokuDocument,
|
||||
serializePlainGrid,
|
||||
serializeSudokuDocument,
|
||||
toDomainPuzzle,
|
||||
type SudokuDocument,
|
||||
} from "../formats";
|
||||
import type { PlaySession } from "../state/session";
|
||||
import { maskValues } from "../state/session";
|
||||
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,
|
||||
): SudokuDocument {
|
||||
const base = fromDomainPuzzle(puzzle);
|
||||
return {
|
||||
...base,
|
||||
values: [...session.values],
|
||||
cornerMarks: session.cornerMarks.map((mask) =>
|
||||
maskValues(mask, puzzle.size),
|
||||
),
|
||||
centerMarks: session.centerMarks.map((mask) =>
|
||||
maskValues(mask, puzzle.size),
|
||||
),
|
||||
colors: [...session.colors],
|
||||
elapsedMs: session.elapsedSeconds * 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
function download(name: string, contents: string, type: string) {
|
||||
const url = URL.createObjectURL(new Blob([contents], { type }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
interface ImportExportDialogProps {
|
||||
open: boolean;
|
||||
puzzle: PuzzleDefinition;
|
||||
session: PlaySession;
|
||||
onClose: () => void;
|
||||
onImport: (
|
||||
puzzle: PuzzleDefinition,
|
||||
progress?: Pick<
|
||||
SudokuDocument,
|
||||
| "values"
|
||||
| "cornerMarks"
|
||||
| "centerMarks"
|
||||
| "candidates"
|
||||
| "colors"
|
||||
| "elapsedMs"
|
||||
>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function ImportExportDialog({
|
||||
open,
|
||||
puzzle,
|
||||
session,
|
||||
onClose,
|
||||
onImport,
|
||||
}: ImportExportDialogProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [includeProgress, setIncludeProgress] = useState(true);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const documentValue = useMemo(
|
||||
() =>
|
||||
includeProgress
|
||||
? withProgress(puzzle, session)
|
||||
: fromDomainPuzzle(puzzle),
|
||||
[includeProgress, puzzle, session],
|
||||
);
|
||||
|
||||
const copy = async (value: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setFeedback(`${label} copied.`);
|
||||
} catch {
|
||||
setFeedback(
|
||||
"Clipboard access was denied; use the download option instead.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} title="Import and export" onClose={onClose} wide>
|
||||
<div className="import-export-grid">
|
||||
<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.
|
||||
</p>
|
||||
<textarea
|
||||
rows={12}
|
||||
value={input}
|
||||
maxLength={1_048_576}
|
||||
spellCheck={false}
|
||||
placeholder="Paste 81 characters, JSON or a puzzle URL…"
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
/>
|
||||
<div className="action-row">
|
||||
<button type="button" onClick={() => fileRef.current?.click()}>
|
||||
Choose puzzle file
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="application/json,text/plain,.json,.txt,.sdk"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file === undefined) return;
|
||||
if (file.size > 1_048_576) {
|
||||
setFeedback("Puzzle files are limited to 1 MiB.");
|
||||
return;
|
||||
}
|
||||
void file
|
||||
.text()
|
||||
.then((contents) => {
|
||||
setInput(contents);
|
||||
setFeedback(
|
||||
`${file.name} loaded locally; review and import it.`,
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setFeedback("The puzzle file could not be read."),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<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.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Import locally
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="stack">
|
||||
<div>
|
||||
<p className="eyebrow">Export</p>
|
||||
<h3>Choose a portable representation</h3>
|
||||
</div>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeProgress}
|
||||
onChange={(event) => setIncludeProgress(event.target.checked)}
|
||||
/>
|
||||
Include values, notes, colours and elapsed time
|
||||
</label>
|
||||
<div className="export-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
"sudoku-tools-puzzle.json",
|
||||
serializeSudokuDocument(documentValue, true),
|
||||
"application/json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download project JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
"sudoku.txt",
|
||||
serializePlainGrid(
|
||||
documentValue,
|
||||
includeProgress ? "values" : "givens",
|
||||
),
|
||||
"text/plain",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download grid text
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
"sudoku.fpuzzles.json",
|
||||
exportFpuzzlesJson(documentValue, true),
|
||||
"application/json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download f-puzzles JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void copy(exportFpuzzlesUrl(documentValue), "f-puzzles URL")
|
||||
}
|
||||
>
|
||||
Copy f-puzzles URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const share = `${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`;
|
||||
void copy(share, "Local share URL");
|
||||
}}
|
||||
>
|
||||
Copy local share URL
|
||||
</button>
|
||||
</div>
|
||||
<p className="callout">
|
||||
Share links are self-contained. They may expose title, setter,
|
||||
rules, solution progress and notes to anyone receiving the URL.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{feedback && (
|
||||
<p className="status-line" role="status">
|
||||
{feedback}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useRef } from "react";
|
||||
import type { SudokuProjectSummary } from "../storage";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
function date(value: number) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function LibraryDialog({
|
||||
open,
|
||||
summaries,
|
||||
mode,
|
||||
busy,
|
||||
feedback,
|
||||
onClose,
|
||||
onSave,
|
||||
onOpen,
|
||||
onDelete,
|
||||
onClear,
|
||||
onExport,
|
||||
onImport,
|
||||
}: {
|
||||
open: boolean;
|
||||
summaries: readonly SudokuProjectSummary[];
|
||||
mode: "indexeddb" | "memory";
|
||||
busy: boolean;
|
||||
feedback?: string;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClear: () => void;
|
||||
onExport: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
|
||||
<div className="library-toolbar">
|
||||
<div>
|
||||
<p>
|
||||
{mode === "indexeddb"
|
||||
? "Saved in this browser profile."
|
||||
: "IndexedDB is unavailable; saves last only for this open tab."}
|
||||
</p>
|
||||
<p className="muted">
|
||||
Export important puzzles before clearing browser data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button type="button" disabled={busy} onClick={onSave}>
|
||||
Save current
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || summaries.length === 0}
|
||||
onClick={onExport}
|
||||
>
|
||||
Export library
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
Import library
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onImport(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{feedback && (
|
||||
<p className="status-line" role="status">
|
||||
{feedback}
|
||||
</p>
|
||||
)}
|
||||
{summaries.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No saved puzzles</h3>
|
||||
<p>Save the current puzzle to build a local library.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="library-list">
|
||||
{summaries.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className="library-open"
|
||||
type="button"
|
||||
onClick={() => onOpen(item.id)}
|
||||
>
|
||||
<strong>{item.title || "Untitled puzzle"}</strong>
|
||||
<span>
|
||||
{item.size}×{item.size} · {date(item.updatedAt)}
|
||||
{item.completed ? " · complete" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="text-button danger"
|
||||
type="button"
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{summaries.length > 0 && (
|
||||
<div className="danger-zone">
|
||||
<button type="button" className="danger" onClick={onClear}>
|
||||
Clear local library
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({ open, title, children, onClose, wide }: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={`modal${wide ? " modal--wide" : ""}`}
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === dialogRef.current) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__surface">
|
||||
<header className="modal__header">
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<button className="icon-button" type="button" onClick={onClose}>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</header>
|
||||
<div className="modal__body">{children}</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { EntryMode } from "../state/session";
|
||||
import { symbolFor } from "../state/session";
|
||||
|
||||
const modes: Array<{ 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 NumberPad({
|
||||
size,
|
||||
mode,
|
||||
onMode,
|
||||
onValue,
|
||||
onErase,
|
||||
}: {
|
||||
size: number;
|
||||
mode: EntryMode;
|
||||
onMode: (mode: EntryMode) => void;
|
||||
onValue: (value: number) => void;
|
||||
onErase: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="number-pad">
|
||||
<div className="mode-switcher" role="group" aria-label="Entry mode">
|
||||
{modes.map((item) => (
|
||||
<button
|
||||
key={item.mode}
|
||||
type="button"
|
||||
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={`digit-pad${size > 9 ? " digit-pad--wide" : ""}`}
|
||||
role="group"
|
||||
aria-label={mode === "color" ? "Colours" : "Digits"}
|
||||
>
|
||||
{Array.from({ length: mode === "color" ? 8 : size }, (_, index) => {
|
||||
const value = index + 1;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={
|
||||
mode === "color" ? `color-choice color-${String(value)}` : ""
|
||||
}
|
||||
onClick={() => onValue(value)}
|
||||
>
|
||||
{mode === "color" ? (
|
||||
<>
|
||||
<span aria-hidden="true" />{" "}
|
||||
<span className="sr-only">Colour {value}</span>
|
||||
</>
|
||||
) : (
|
||||
symbolFor(value, size)
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button type="button" className="erase-key" onClick={onErase}>
|
||||
Erase
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
ExactSolveResult,
|
||||
LogicalSolveResult,
|
||||
LogicalStep,
|
||||
} from "../solver";
|
||||
import { symbolFor } from "../state/session";
|
||||
|
||||
function cellName(cell: number, size: number) {
|
||||
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||||
}
|
||||
|
||||
function techniqueName(value: string) {
|
||||
return value
|
||||
.split("-")
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
step,
|
||||
index,
|
||||
size,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
step: LogicalStep;
|
||||
index: number;
|
||||
size: number;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`solve-step${active ? " is-active" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="step-number">{index + 1}</span>
|
||||
<span>
|
||||
<strong>{techniqueName(step.technique)}</strong>
|
||||
<small>{step.explanation}</small>
|
||||
{(step.placements.length > 0 || step.eliminations.length > 0) && (
|
||||
<span className="step-effects">
|
||||
{step.placements
|
||||
.map(
|
||||
(placement) =>
|
||||
`${cellName(placement.cell, size)}=${symbolFor(placement.value, size)}`,
|
||||
)
|
||||
.join(", ")}
|
||||
{step.placements.length > 0 && step.eliminations.length > 0
|
||||
? " · "
|
||||
: ""}
|
||||
{step.eliminations
|
||||
.map(
|
||||
(elimination) =>
|
||||
`${cellName(elimination.cell, size)} −${elimination.values.map((value) => symbolFor(value, size)).join("")}`,
|
||||
)
|
||||
.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SolveWorkspace({
|
||||
size,
|
||||
busy,
|
||||
logical,
|
||||
exact,
|
||||
error,
|
||||
onLogical,
|
||||
onExact,
|
||||
onApplyValues,
|
||||
onFocusCells,
|
||||
}: {
|
||||
size: number;
|
||||
busy: boolean;
|
||||
logical?: LogicalSolveResult;
|
||||
exact?: ExactSolveResult;
|
||||
error?: string;
|
||||
onLogical: () => void;
|
||||
onExact: () => void;
|
||||
onApplyValues: (values: readonly number[]) => void;
|
||||
onFocusCells: (cells: readonly number[]) => void;
|
||||
}) {
|
||||
const [stepSelection, setStepSelection] = useState<{
|
||||
logical?: LogicalSolveResult;
|
||||
index: number;
|
||||
}>({ index: 0 });
|
||||
const activeStep =
|
||||
stepSelection.logical === logical ? stepSelection.index : 0;
|
||||
useEffect(() => {
|
||||
const step = logical?.steps[activeStep];
|
||||
onFocusCells(step?.focusCells ?? []);
|
||||
}, [activeStep, logical, onFocusCells]);
|
||||
|
||||
return (
|
||||
<div className="solve-workspace stack">
|
||||
<div>
|
||||
<p className="eyebrow">Explainable analysis</p>
|
||||
<h2>Solve and verify</h2>
|
||||
<p className="muted">
|
||||
Human logic and exact search are deliberately separate. A uniqueness
|
||||
proof is not described as a human deduction.
|
||||
</p>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button type="button" disabled={busy} onClick={onLogical}>
|
||||
Build logical solve path
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={onExact}>
|
||||
Count solutions (up to 2)
|
||||
</button>
|
||||
</div>
|
||||
{busy && (
|
||||
<p className="status-line" role="status">
|
||||
Analysing in a local worker…
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="error-callout" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{exact && (
|
||||
<section className="analysis-summary">
|
||||
<p className="eyebrow">Exact search</p>
|
||||
<div className="metric-row">
|
||||
<span>
|
||||
<strong>{exact.count}</strong> solution
|
||||
{exact.count === 1 ? "" : "s"} found
|
||||
</span>
|
||||
<span>
|
||||
<strong>{exact.nodes.toLocaleString()}</strong> nodes
|
||||
</span>
|
||||
<span>
|
||||
<strong>{exact.elapsedMs.toLocaleString()}</strong> ms
|
||||
</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."}
|
||||
</p>
|
||||
{exact.solutions[0] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApplyValues(exact.solutions[0]!)}
|
||||
>
|
||||
Show first exact solution on board
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{logical && (
|
||||
<section className="logical-results">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Logical path</p>
|
||||
<h3>
|
||||
{logical.steps.length} explained step
|
||||
{logical.steps.length === 1 ? "" : "s"}
|
||||
</h3>
|
||||
</div>
|
||||
<span className={`status-pill status-${logical.status}`}>
|
||||
{logical.status}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{logical.status === "solved"
|
||||
? "The configured human techniques complete this puzzle."
|
||||
: logical.status === "stuck"
|
||||
? "No supported next deduction was found. Exact search may still solve it."
|
||||
: logical.status === "invalid"
|
||||
? "A contradiction was reached."
|
||||
: "The logical-step safety limit was reached."}
|
||||
</p>
|
||||
{logical.status === "solved" && (
|
||||
<button type="button" onClick={() => onApplyValues(logical.values)}>
|
||||
Show logical result on board
|
||||
</button>
|
||||
)}
|
||||
<div className="solve-steps">
|
||||
{logical.steps.map((step, index) => (
|
||||
<StepCard
|
||||
key={`${step.technique}-${String(index)}`}
|
||||
step={step}
|
||||
index={index}
|
||||
size={size}
|
||||
active={activeStep === index}
|
||||
onSelect={() => setStepSelection({ logical, index })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
||||
import type { NormalizedPuzzle, VariantConstraint } from "../domain/types";
|
||||
import { maskValues, symbolFor } from "../state/session";
|
||||
|
||||
interface SudokuBoardProps {
|
||||
puzzle: NormalizedPuzzle;
|
||||
values: readonly number[];
|
||||
cornerMarks?: readonly number[];
|
||||
centerMarks?: readonly number[];
|
||||
colors?: readonly number[];
|
||||
candidates?: readonly number[];
|
||||
selected: ReadonlySet<number>;
|
||||
conflicts?: ReadonlySet<number>;
|
||||
activeCell: number;
|
||||
showCandidates?: boolean;
|
||||
onCellPointerDown: (
|
||||
cell: number,
|
||||
event: PointerEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
onCellPointerEnter: (
|
||||
cell: number,
|
||||
event: PointerEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
function point(size: number, cell: number) {
|
||||
return {
|
||||
x: (cell % size) + 0.5,
|
||||
y: Math.floor(cell / size) + 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function polyline(size: number, cells: readonly number[]) {
|
||||
return cells
|
||||
.map((cell) => {
|
||||
const { x, y } = point(size, cell);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
|
||||
const commands: string[] = [];
|
||||
for (const cell of cells) {
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
const x0 = column + inset;
|
||||
const x1 = column + 1 - inset;
|
||||
const y0 = row + inset;
|
||||
const y1 = row + 1 - inset;
|
||||
if (!cells.has(cell - size) || row === 0)
|
||||
commands.push(`M${x0} ${y0}H${x1}`);
|
||||
if (!cells.has(cell + size) || row === size - 1)
|
||||
commands.push(`M${x0} ${y1}H${x1}`);
|
||||
if (!cells.has(cell - 1) || column === 0)
|
||||
commands.push(`M${x0} ${y0}V${y1}`);
|
||||
if (!cells.has(cell + 1) || column === size - 1)
|
||||
commands.push(`M${x1} ${y0}V${y1}`);
|
||||
}
|
||||
return commands.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) => {
|
||||
if (constraint.type === "diagonal") {
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
className="constraint-diagonal"
|
||||
x1={constraint.direction === "main" ? 0.12 : size - 0.12}
|
||||
y1={0.12}
|
||||
x2={constraint.direction === "main" ? size - 0.12 : 0.12}
|
||||
y2={size - 0.12}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (constraint.type === "killer-cage") {
|
||||
const cells = new Set(constraint.cells);
|
||||
const first = Math.min(...constraint.cells);
|
||||
const position = point(size, first);
|
||||
return (
|
||||
<g key={index} className="constraint-cage">
|
||||
<path d={boundaryPath(size, cells, 0.09)} />
|
||||
<text x={position.x - 0.34} y={position.y - 0.27}>
|
||||
{constraint.sum}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (constraint.type === "thermo") {
|
||||
const bulb = point(size, constraint.cells[0]!);
|
||||
return (
|
||||
<g key={index} className="constraint-thermo">
|
||||
<polyline points={polyline(size, constraint.cells)} />
|
||||
<circle cx={bulb.x} cy={bulb.y} r="0.31" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (constraint.type === "arrow") {
|
||||
const bulb = point(size, constraint.bulb[0]!);
|
||||
const end = point(size, constraint.line.at(-1)!);
|
||||
const connectedLine = [constraint.bulb.at(-1)!, ...constraint.line];
|
||||
return (
|
||||
<g key={index} className="constraint-arrow">
|
||||
<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 === "renban" || constraint.type === "palindrome") {
|
||||
return (
|
||||
<g key={index} className={`constraint-${constraint.type}`}>
|
||||
<polyline points={polyline(size, constraint.cells)} />
|
||||
{constraint.type === "palindrome" &&
|
||||
constraint.cells.map((cell) => {
|
||||
const p = point(size, cell);
|
||||
return <circle key={cell} cx={p.x} cy={p.y} r="0.14" />;
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (
|
||||
constraint.type === "kropki" ||
|
||||
constraint.type === "xv" ||
|
||||
constraint.type === "inequality"
|
||||
) {
|
||||
const a = point(
|
||||
size,
|
||||
constraint.type === "inequality" ? constraint.lesser : constraint.a,
|
||||
);
|
||||
const b = point(
|
||||
size,
|
||||
constraint.type === "inequality" ? constraint.greater : constraint.b,
|
||||
);
|
||||
const x = (a.x + b.x) / 2;
|
||||
const y = (a.y + b.y) / 2;
|
||||
if (constraint.type === "kropki")
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
className={`constraint-kropki constraint-kropki--${constraint.kind}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="0.115"
|
||||
/>
|
||||
);
|
||||
const rotation = Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
|
||||
return (
|
||||
<text
|
||||
key={index}
|
||||
className={`constraint-label constraint-label--${constraint.type}`}
|
||||
x={x}
|
||||
y={y}
|
||||
transform={
|
||||
constraint.type === "inequality"
|
||||
? `rotate(${String(rotation)} ${String(x)} ${String(y)})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{constraint.type === "xv"
|
||||
? constraint.total === 5
|
||||
? "V"
|
||||
: "X"
|
||||
: "<"}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="constraint-layer"
|
||||
viewBox={`0 0 ${String(size)} ${String(size)}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="region-boundaries">
|
||||
{regionCells.map((cells, index) => (
|
||||
<path key={index} d={boundaryPath(size, cells, 0)} />
|
||||
))}
|
||||
</g>
|
||||
{puzzle.constraints.map(renderConstraint)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SudokuBoard({
|
||||
puzzle,
|
||||
values,
|
||||
cornerMarks = [],
|
||||
centerMarks = [],
|
||||
colors = [],
|
||||
candidates = [],
|
||||
selected,
|
||||
conflicts = new Set<number>(),
|
||||
activeCell,
|
||||
showCandidates,
|
||||
onCellPointerDown,
|
||||
onCellPointerEnter,
|
||||
onKeyDown,
|
||||
}: SudokuBoardProps) {
|
||||
const { size } = puzzle;
|
||||
return (
|
||||
<div
|
||||
className="sudoku-board-frame"
|
||||
style={{ "--sudoku-size": size } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
className="sudoku-board"
|
||||
role="grid"
|
||||
aria-label={`${String(size)} by ${String(size)} Sudoku grid`}
|
||||
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" : "",
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ConstraintLayer puzzle={puzzle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user