diff --git a/src/app/components/ActionBar.tsx b/src/app/components/ActionBar.tsx new file mode 100644 index 0000000..01b3ee6 --- /dev/null +++ b/src/app/components/ActionBar.tsx @@ -0,0 +1,104 @@ +import type { MazeEvent, MazeSettings } from "@/lib/maze"; +import { MazeController } from "@/lib/MazeController"; +import { LucideSlidersHorizontal } from "lucide-react"; +import { type ReactElement, useCallback } from "react"; +import { + Button, + Dialog, + DialogTrigger, + Modal, + ModalOverlay, +} from "react-aria-components"; +import { useImmer } from "use-immer"; +import SettingsForm, { FormFields } from "./SettingsForm"; + +export interface ActionBarProps { + onEvent: (event: MazeEvent, settings: MazeSettings) => void; + solvable: boolean; +} + +export default function ActionBar({ + onEvent, + solvable, +}: ActionBarProps): ReactElement { + const [fields, setFields] = useImmer({ + dimensions: { + rows: "", + cols: "", + cellWallRatio: MazeController.DEFAULTS.dimensions.cellWallRatio, + }, + generationAlgorithm: MazeController.DEFAULTS.generationAlgorithm, + doAnimateGenerating: MazeController.DEFAULTS.doAnimateGenerating, + solveAlgorithm: MazeController.DEFAULTS.solveAlgorithm, + doAnimateSolving: MazeController.DEFAULTS.doAnimateSolving, + }); + + const handleEvent = useCallback( + (action: MazeEvent) => { + const settings: MazeSettings = { + dimensions: { + rows: fields.dimensions.rows + ? parseInt(fields.dimensions.rows) + : MazeController.DEFAULTS.dimensions.rows, + cols: fields.dimensions.cols + ? parseInt(fields.dimensions.cols) + : MazeController.DEFAULTS.dimensions.cols, + cellWallRatio: fields.dimensions.cellWallRatio, + }, + generationAlgorithm: fields.generationAlgorithm, + doAnimateGenerating: fields.doAnimateGenerating, + solveAlgorithm: fields.solveAlgorithm, + doAnimateSolving: fields.doAnimateSolving, + }; + console.log("Settings", settings); + onEvent(action, settings); + }, + [fields, onEvent], + ); + + return ( +
+ {/* Generate Button */} + + + {/* Solve Button */} + + + {/* More Settings Button */} + + + + {/* Settings Popover */} + + + + {({ close }) => ( + + )} + + + + +
+ ); +} diff --git a/src/app/components/GlassSelect.tsx b/src/app/components/GlassSelect.tsx deleted file mode 100644 index b45fbce..0000000 --- a/src/app/components/GlassSelect.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { ChevronDown, Check } from "lucide-react"; -import { ReactElement } from "react"; -import { - Select, - Label, - Button, - SelectValue, - Popover, - ListBox, - ListBoxItem, -} from "react-aria-components"; - -export interface GlassSelectProps { - name: string; - defaultSelectedKey: string; - label: string; - items: [string, string][]; -} - -export default function GlassSelect({ - name, - defaultSelectedKey, - label, - items, -}: GlassSelectProps): ReactElement { - return ( - - ); -} diff --git a/src/app/components/MySelect.tsx b/src/app/components/MySelect.tsx new file mode 100644 index 0000000..d8afbb2 --- /dev/null +++ b/src/app/components/MySelect.tsx @@ -0,0 +1,78 @@ +import { LucideCheck, LucideChevronDown } from "lucide-react"; +import { type ReactElement } from "react"; +import { + Button, + Label, + ListBox, + ListBoxItem, + Popover, + Select, + SelectValue, +} from "react-aria-components"; +import { twMerge } from "tailwind-merge"; + +export interface MySelectProps { + name: string; + defaultSelectedKey: string; + label: string; + ariaLabel?: string; + items: [string, string][]; + className?: string; +} + +export default function MySelect({ + name, + defaultSelectedKey, + label, + ariaLabel, + items, + className, +}: MySelectProps): ReactElement { + return ( + + ); +} diff --git a/src/app/components/OptionsForm.tsx b/src/app/components/OptionsForm.tsx deleted file mode 100644 index bf2e9cc..0000000 --- a/src/app/components/OptionsForm.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { type FormActionType } from "@/app/page"; -import type { - GenerationAlgorithm, - MazeSettings, - SolveAlgorithm, -} from "@/lib/maze"; -import { MazeController } from "@/lib/MazeController"; -import { getFloatFromForm, getIntFromForm } from "@/lib/utils"; -import { useCallback, useRef, type ReactElement } from "react"; -import { - Button, - FieldError, - Form, - Input, - Label, - Switch, - TextField, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import GlassSelect from "./GlassSelect"; - -const GENERATION_ALGORITHMS: Record = { - random: "Surprise Me!", - prims: "Prim’s Algorithm", - wilsons: "Wilson’s Algorithm", - backtracker: "Recursive Backtracker", -} as const; - -const SOLVE_ALGORITHMS: Record = { - random: "Surprise Me!", - bfs: "Flood Fill (BFS)", - tremaux: "Trémaux’s Algorithm (DFS)", -} as const; - -export interface OptionsFormProps { - onAction: (action: FormActionType, settings: MazeSettings) => void; - solvable: boolean; - className?: string; -} - -export default function OptionsForm({ - onAction, - solvable, - className, -}: OptionsFormProps): ReactElement { - const formRef = useRef(null); - - const handlePress = useCallback( - (action: FormActionType) => { - if (!formRef.current) return; - - const formData = new FormData(formRef.current); - const rows: number = - getIntFromForm(formData, "rows") ?? - MazeController.DEFAULTS.dimensions.rows; - const cols: number = - getIntFromForm(formData, "cols") ?? - MazeController.DEFAULTS.dimensions.cols; - const cellWallRatio: number = - getFloatFromForm(formData, "cellWallRatio") ?? - MazeController.DEFAULTS.dimensions.cellWallRatio; - const generationAlgorithm: GenerationAlgorithm = formData.get( - "generationAlgorithm", - ) as GenerationAlgorithm; - const doAnimateGenerating: boolean = - formData.get("doAnimateGeneration") === "on"; - const solveAlgorithm: SolveAlgorithm = formData.get( - "solveAlgorithm", - ) as SolveAlgorithm; - const doAnimateSolving: boolean = - formData.get("doAnimateSolving") === "on"; - - const settings: MazeSettings = { - dimensions: { - rows, - cols, - cellWallRatio, - }, - generationAlgorithm, - doAnimateGenerating, - solveAlgorithm, - doAnimateSolving, - }; - - if (action !== "clear" && !formRef.current.reportValidity()) return; - - onAction(action, settings); - }, - [onAction], - ); - - return ( -
{ - e.preventDefault(); - }} - className={twMerge("flex flex-col items-center gap-4", className)} - > - {/* Island glass tube with majority of settings */} -
- {/* Rows */} - - - {/* Input doesn't allow ::after, a wrapper is needed */} -
- - {/* Glass tube */} -
- {/* Error message */} - -
-
- - {/* Cols */} - - - {/* Input doesn't allow ::after, a wrapper is needed */} -
- - {/* Glass tube */} -
- {/* Error message */} - -
-
- - {/* Cell-Wall Ratio */} - - - {/* Input doesn't allow ::after, a wrapper is needed */} -
- - {/* Glass tube */} -
- {/* Error message */} - -
-
- - {/* Generation Algorithm Dropdown */} - - - {/* Animate Generating Switch */} - - - {/* Actual Switch */} - {/* Track */} -
- {/* Circle/Handle */} -
-
-
- - {/* Solve Algorithm Dropdown */} - - - {/* Animate Solving Switch */} - - - {/* Actual Switch */} - {/* Track */} -
- {/* Circle/Handle */} -
-
-
- - - - - - -
-
- ); -} diff --git a/src/app/components/SettingsForm.tsx b/src/app/components/SettingsForm.tsx new file mode 100644 index 0000000..82f3217 --- /dev/null +++ b/src/app/components/SettingsForm.tsx @@ -0,0 +1,231 @@ +import type { GenerationAlgorithm, SolveAlgorithm } from "@/lib/maze"; +import { MazeController } from "@/lib/MazeController"; +import { getFloatFromForm } from "@/lib/utils"; +import { type ReactElement, useCallback, useRef } from "react"; +import { + Button, + FieldError, + Form, + Input, + Label, + Slider, + SliderOutput, + SliderThumb, + SliderTrack, + Switch, + TextField, +} from "react-aria-components"; +import MySelect from "./MySelect"; + +const GENERATION_ALGORITHMS: Record = { + random: "Surprise Me!", + prims: "Prim’s Algorithm", + wilsons: "Wilson’s Algorithm", + backtracker: "Recursive Backtracker", +} as const; + +const SOLVE_ALGORITHMS: Record = { + random: "Surprise Me!", + bfs: "Flood Fill (BFS)", + tremaux: "Trémaux’s Algorithm (DFS)", +} as const; + +export interface FormFields { + dimensions: { + rows: string; + cols: string; + cellWallRatio: number; + }; + generationAlgorithm: GenerationAlgorithm; + doAnimateGenerating: boolean; + solveAlgorithm: SolveAlgorithm; + doAnimateSolving: boolean; +} + +export interface SettingsFormProps { + fields: FormFields; + setFields: (fields: FormFields) => void; + close: () => void; +} + +export default function SettingsForm({ + fields, + setFields, + close, +}: SettingsFormProps): ReactElement { + const formRef = useRef(null); + + const onExit = useCallback(() => { + if (!formRef.current?.reportValidity()) return; + + const formData = new FormData(formRef.current!); + const settings: FormFields = { + dimensions: { + rows: formData.get("rows")!.toString(), + cols: formData.get("cols")!.toString(), + cellWallRatio: getFloatFromForm(formData, "cellWallRatio")!, + }, + generationAlgorithm: formData.get( + "generationAlgorithm", + ) as GenerationAlgorithm, + doAnimateGenerating: formData.get("doAnimateGeneration") === "on", + solveAlgorithm: formData.get("solveAlgorithm") as SolveAlgorithm, + doAnimateSolving: formData.get("doAnimateSolve") === "on", + }; + setFields(settings); + close(); + }, [close, setFields]); + + return ( +
{ + e.preventDefault(); + }} + className="mx-auto flex w-full min-w-72 max-w-screen-sm flex-col gap-4 border-2 border-border bg-surface p-4" + > +

Maze Settings

+ + {/* Dimensions */} +
+

Dimensions

+ +
+
+ {/* Rows */} + + + + + + + {/* Cols */} + + + + + +
+ + + Range + Rows & columns range: + [{MazeController.DIMS_RANGE.minSize},  + {MazeController.DIMS_RANGE.maxSize}] + + + {/* Cell-Wall Ratio Slider */} + +
+ + +
+
+ + + +
+
+
+
+ + {/* Generation Algorithm */} +
+

Algorithms

+ +
+ {/* Generation Algorithm Dropdown */} + + + {/* Solve Algorithm Dropdown */} + +
+
+ + {/* Animation Toggles */} +
+

Animation

+ + + + {/* Track */} +
+ {/* Circle/Handle */} +
+
+
+ + + + {/* Track */} +
+ {/* Circle/Handle */} +
+
+
+
+ + {/* Save-and-Exit Button */} + +
+ ); +} diff --git a/src/app/components/ZoomBar.tsx b/src/app/components/ZoomBar.tsx new file mode 100644 index 0000000..2c69b39 --- /dev/null +++ b/src/app/components/ZoomBar.tsx @@ -0,0 +1,39 @@ +import { type ReactElement } from "react"; +import { Label, Radio, RadioGroup } from "react-aria-components"; + +export interface ZoomBarProps { + onChange: (value: number) => void; +} + +export default function ZoomBar({ onChange }: ZoomBarProps): ReactElement { + return ( + <> + {/* Zoom settings island */} + { + onChange(parseFloat(value)); + }} + className="flex items-center gap-4 px-4 py-2" + > + + {[ + ["0.25", "1/4"], + ["0.5", "1/2"], + ["1", "1"], + ].map(([value, text]) => { + return ( + + {text} + + ); + })} + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index ec769f3..c38b942 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,13 +3,27 @@ @tailwind utilities; @layer base { + :root { + --color-primary: 213 97% 87%; /* blue-200 */ + --color-primary-light: 214 95% 93%; /* blue-100 */ + --color-secondary: 141 79% 85%; /* green-200 */ + --color-disabled: 213 27% 84%; /* slate-300 */ + --color-error: 0 72% 51%; /* red-600 */ + --color-surface: 210 40% 98%; /* slate-50 */ + --color-surface-overlay: 210 40% 98% / 0.2; /* slate-50/20 */ + --color-text: 222 47% 11%; /* slate-900 */ + --color-text-placeholder: 215 16% 47%; /* slate-500 */ + --color-text-disabled: 215 25% 27%; /* slate-700 */ + --color-border: 215 16% 47%; /* slate-500 */ + } + input, button { @apply outline-0 focus-visible:outline-2; } input::placeholder { - @apply text-slate-500; + @apply text-text-placeholder; } button { @@ -17,92 +31,6 @@ } } -@layer components { - /* Standalone glass tubes */ - .glass-tube { - @apply shadow shadow-slate-400; - border: 1px solid theme("colors.slate.700 / 0.5"); - border-radius: 9999px; - background-image: linear-gradient( - to bottom, - theme("colors.slate.50 / 0.5"), - theme("colors.slate.900 / 0.2") 10%, - theme("colors.slate.900 / 0.1") 30%, - theme("colors.slate.50 / 0.05") 50%, - theme("colors.slate.50 / 0.05") 80%, - theme("colors.slate.50 / 0.4") - ); - pointer-events: none; - } - - /* Glass-tube container (overlayed on top using pseudo-elements) */ - .glass-tube-container { - position: relative; - border-radius: 9999px; - - &::after { - @apply shadow shadow-slate-400; - position: absolute; - inset: 0; - border: 1px solid hsl(215deg 25% 26.7% / 0.5); - border-radius: 9999px; - background-image: linear-gradient( - to bottom, - theme("colors.slate.50 / 0.5"), - theme("colors.slate.900 / 0.2") 10%, - theme("colors.slate.900 / 0.1") 30%, - theme("colors.slate.50 / 0.05") 50%, - theme("colors.slate.50 / 0.05") 80%, - theme("colors.slate.50 / 0.4") - ); - pointer-events: none; - content: ""; - } - } - - /* Make surface look like glass */ - .glass-surface { - @apply shadow shadow-slate-500 backdrop-blur-sm; - position: relative; - padding: 1px; - - /* Gradient border */ - &::before { - position: absolute; - mask: - linear-gradient(white 0 0) content-box, - linear-gradient(white 0 0); - mask-composite: exclude; - inset: 0; - border-radius: inherit; - background-image: linear-gradient( - to bottom right, - theme("colors.slate.900 / 0.4"), - theme("colors.slate.50 / 0.5") - ); - padding: 1px; - pointer-events: none; - content: ""; - } - - /* Gradient surface */ - &::after { - position: absolute; - inset: 1px; - border-radius: inherit; - background-image: linear-gradient( - to bottom right, - theme("colors.slate.50 / 0.2"), - theme("colors.slate.500 / 0.06") 30%, - theme("colors.slate.500 / 0.06") 70%, - theme("colors.slate.900 / 0.15") - ); - pointer-events: none; - content: ""; - } - } -} - @layer utilities { .remove-input-arrows { /* Chrome, Safari, Edge, Opera */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0f68eac..d472f12 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,11 @@ import type { Metadata } from "next"; -import { Lato } from "next/font/google"; +import { Roboto_Slab } from "next/font/google"; import { type ReactElement, type ReactNode } from "react"; import { twMerge } from "tailwind-merge"; import "./globals.css"; -const latoFont = Lato({ - weight: ["300", "400", "700"], +const robotoSlabFont = Roboto_Slab({ + weight: ["300", "400", "600"], subsets: ["latin"], }); @@ -23,8 +23,8 @@ export default function RootLayout({ {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 92ba765..0fde51b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,6 @@ "use client"; -import type { MazeSettings } from "@/lib/maze"; +import type { MazeEvent, MazeSettings } from "@/lib/maze"; import { MazeController } from "@/lib/MazeController"; -import { type RectSize } from "@/lib/twoDimens"; import { useCallback, useEffect, @@ -9,14 +8,11 @@ import { useState, type ReactElement, } from "react"; -import { Label, Radio, RadioGroup } from "react-aria-components"; import { match } from "ts-pattern"; -import OptionsForm from "./components/OptionsForm"; - -export type FormActionType = "generate" | "solve" | "clear"; +import ActionBar from "./components/ActionBar"; +import ZoomBar from "./components/ZoomBar"; export default function Home(): ReactElement { - const containerRef = useRef(null); const canvasRef = useRef(null); const [mazeController, setMazeController] = useState( null, @@ -24,25 +20,13 @@ export default function Home(): ReactElement { const [solvable, setSolvable] = useState(false); useEffect(() => { - if (canvasRef.current === null) return; - const ctx = canvasRef.current.getContext("2d"); + const ctx = canvasRef.current!.getContext("2d"); if (ctx === null) return; - setMazeController((prev) => { - if (prev !== null) return prev; - return new MazeController( - ctx, - (size: Readonly) => { - if (containerRef.current === null) return; - containerRef.current.style.width = `${size.width}px`; - containerRef.current.style.height = `${size.height}px`; - }, - setSolvable, - ); - }); + setMazeController(new MazeController(ctx, setSolvable)); }, []); const onAction = useCallback( - (action: FormActionType, settings: MazeSettings) => { + (action: MazeEvent, settings: MazeSettings) => { if (mazeController === null) return; match(action) .with("generate", () => { @@ -51,67 +35,32 @@ export default function Home(): ReactElement { .with("solve", () => { mazeController.solve(settings); }) - .with("clear", () => { - mazeController.clear(); - }) .exhaustive(); }, [mazeController], ); return ( -
-
-
- -
- - {/* Zoom settings island */} - +
+ + { if (mazeController === null) return; - mazeController.zoomTo(parseFloat(value)); + mazeController.zoomTo(value); }} - className="glass-tube-container flex items-center gap-2 px-4 py-2 text-sm" - > - - - 1/4 - - - 1/2 - - - 1 - - + />
-
- - Your browser doesn‘t support HTML Canvas. - -
+ Your browser doesn‘t support HTML Canvas. +
); diff --git a/src/lib/MazeController.ts b/src/lib/MazeController.ts index a44b196..284ab17 100644 --- a/src/lib/MazeController.ts +++ b/src/lib/MazeController.ts @@ -63,14 +63,9 @@ export class MazeController { */ constructor( ctx: CanvasRenderingContext2D, - setContainerSize: (size: Readonly) => void, private setSolvable: (solvable: boolean) => void, ) { - this.drawer = new MazeDrawer( - ctx, - MazeController.DEFAULTS.dimensions, - setContainerSize, - ); + this.drawer = new MazeDrawer(ctx, MazeController.DEFAULTS.dimensions); } public async generate(settings: MazeSettings): Promise { diff --git a/src/lib/MazeDrawer.ts b/src/lib/MazeDrawer.ts index 68b64df..835d097 100644 --- a/src/lib/MazeDrawer.ts +++ b/src/lib/MazeDrawer.ts @@ -8,11 +8,11 @@ import { clamp, easeOutQuad, type Direction } from "./utils"; /** Colors used by the renderer. */ const COLOR = { - empty: colors.blue[500], - partial: colors.blue[300], - solid: colors.blue[50], + empty: colors.blue[400], + partial: colors.green[200], + solid: colors.slate[50], path: colors.slate[700], - partialPath: "#33415566" /* colors.slate[700] / 40 */, + partialPath: colors.slate[700] + "66", start: colors.amber[500], end: colors.fuchsia[500], } as const; @@ -64,13 +64,10 @@ export default class MazeDrawer { * @param ctx - The canvas rendering context. The canvas should have a CSS * width as well as a width and height attribute (set to 0 initially). * @param initialGridSize - The inital dimensions of the maze. - * @param setContainerSize - Callback to set the container size. Has to set - * programmatically for CSS transitions to work. */ constructor( ctx: CanvasRenderingContext2D, initialGridSize: Readonly, - private setContainerSize: (size: RectSize) => void, ) { this.visibleCtx = ctx; const hiddenCanvas = document.createElement("canvas"); @@ -150,7 +147,6 @@ export default class MazeDrawer { this.gridSize = dims; this.cellWallRatio = dims.cellWallRatio; this.updateCanvasSize(); - this.setContainerSize({ width: this.width, height: this.height }); this.visibleCtx.canvas.style.width = `${this.width}px`; this.visibleCtx.canvas.style.height = `${this.height}px`; this.fillWithWall(); @@ -168,7 +164,6 @@ export default class MazeDrawer { this.isComplete = false; }; redraw(); - this.setContainerSize({ width: this.width, height: this.height }); this.visibleCtx.canvas.style.width = `${this.width}px`; this.visibleCtx.canvas.style.height = `${this.height}px`; redraw(); diff --git a/tailwind.config.ts b/tailwind.config.ts index 793726a..a3368fd 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -6,7 +6,29 @@ const config: Config = { "./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], - theme: {}, + theme: { + extend: { + colors: { + primary: { + light: "hsl(var(--color-primary-light) / )", + DEFAULT: "hsl(var(--color-primary) / )", + }, + secondary: "hsl(var(--color-secondary) / )", + disabled: "hsl(var(--color-disabled) / )", + error: "hsl(var(--color-error) / )", + surface: { + overlay: "hsl(var(--color-surface-overlay))", + DEFAULT: "hsl(var(--color-surface) / )", + }, + text: { + placeholder: "hsl(var(--color-text-placeholder) / )", + disabled: "hsl(var(--color-text-disabled) / )", + DEFAULT: "hsl(var(--color-text) / )", + }, + border: "hsl(var(--color-border) / )", + }, + }, + }, plugins: [require("tailwindcss-react-aria-components")], }; export default config;