feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user