Files
sudoku-tools/src/components/ConstraintEditor.tsx
T

437 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
import {
removeKillerCagesAtCells,
replaceOverlappingKillerCages,
selectionTouchesKillerCage,
} from "./constraintEditing";
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 `sum ${String(constraint.total)} pair · ${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 cageCellCount = Math.max(1, selection.length);
const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2;
const maximumCageSum =
(cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
const validCage =
selection.length >= 1 &&
selection.length <= puzzle.size &&
Number.isInteger(cageSum) &&
cageSum >= minimumCageSum &&
cageSum <= maximumCageSum;
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
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={minimumCageSum}
max={maximumCageSum}
value={cageSum}
onChange={(event) => setCageSum(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={!validCage}
onClick={() =>
onChange({
...puzzle,
constraints: replaceOverlappingKillerCages(constraints, {
type: "killer-cage",
cells: selection,
sum: cageSum,
}),
})
}
>
Add / replace cage
</button>
<button
type="button"
className="danger"
disabled={!selectedCageExists}
onClick={() =>
onChange({
...puzzle,
constraints: removeKillerCagesAtCells(constraints, selection),
})
}
>
Remove selected cage
</button>
</div>
<p className="muted">
Adding replaces any cage touching the selection. Removing clears every
cage touching a selected cell.
</p>
<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,
})
}
>
Sum 5 (XV)
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "xv",
a: selection[0]!,
b: selection[1]!,
total: 10,
})
}
>
Sum 10 (XV)
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "inequality",
lesser: selection[0]!,
greater: selection[1]!,
})
}
>
1st &lt; 2nd (inequality)
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "inequality",
lesser: selection[1]!,
greater: selection[0]!,
})
}
>
1st &gt; 2nd (inequality)
</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 &amp; uniqueness
</button>
<button type="button" onClick={onGenerate} disabled={busy}>
Open generator
</button>
</section>
</div>
);
}