diff --git a/lib/react/GenericSolverDebugger.tsx b/lib/react/GenericSolverDebugger.tsx index 81bae19..6628e05 100644 --- a/lib/react/GenericSolverDebugger.tsx +++ b/lib/react/GenericSolverDebugger.tsx @@ -1,7 +1,10 @@ import React, { useEffect, useMemo, useReducer, useState } from "react" import type { BaseSolver } from "../BaseSolver" import type { BasePipelineSolver } from "../BasePipelineSolver" -import { InteractiveGraphics } from "graphics-debug/react" +import { + InteractiveGraphics, + InteractiveGraphicsCanvas, +} from "graphics-debug/react" import { GenericSolverToolbar } from "./GenericSolverToolbar" import { PipelineStagesTable } from "./PipelineStagesTable" import { SimpleGraphicsSVG } from "./SimpleGraphicsSVG" @@ -45,6 +48,9 @@ export const GenericSolverDebugger = ({ onSolverCompleted, }: GenericSolverDebuggerProps) => { const [renderCount, incRenderCount] = useReducer((x) => x + 1, 0) + const [currentAnimationSpeed, setCurrentAnimationSpeed] = + useState(animationSpeed) + const [renderer, setRenderer] = useState<"vector" | "canvas">("vector") const [solver] = useState(() => { if (createSolver) { return createSolver() @@ -90,6 +96,10 @@ export const GenericSolverDebugger = ({ const isPipelineSolver = (solver as any).pipelineDef !== undefined + useEffect(() => { + setCurrentAnimationSpeed(animationSpeed) + }, [animationSpeed]) + const handleStepUntilPhase = (phaseName: string) => { const pipelineSolver = solver as BasePipelineSolver if (!solver.solved && !solver.failed) { @@ -113,9 +123,31 @@ export const GenericSolverDebugger = ({ { + try { + const visualizationSnapshot = solver.visualize() + const blob = new Blob( + [JSON.stringify(visualizationSnapshot, null, 2)], + { type: "application/json" }, + ) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${solver.constructor.name}_visualization.json` + a.click() + URL.revokeObjectURL(url) + } catch (error) { + alert( + `Error downloading visualization: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }} /> {graphicsAreEmpty ? (
No Graphics Yet
@@ -125,7 +157,11 @@ export const GenericSolverDebugger = ({ } > - + {renderer === "canvas" ? ( + + ) : ( + + )} )} {isPipelineSolver && ( diff --git a/lib/react/GenericSolverToolbar.tsx b/lib/react/GenericSolverToolbar.tsx index 41477f9..a240133 100644 --- a/lib/react/GenericSolverToolbar.tsx +++ b/lib/react/GenericSolverToolbar.tsx @@ -1,6 +1,7 @@ import React, { useReducer, useRef, useEffect } from "react" import type { BaseSolver } from "../BaseSolver" import { SolverBreadcrumbInputDownloader } from "./SolverBreadcrumbInputDownloader" +import { SolverContextMenu } from "./SolverContextMenu" export interface GenericSolverToolbarProps { solver: BaseSolver @@ -8,6 +9,10 @@ export interface GenericSolverToolbarProps { animationSpeed?: number onSolverStarted?: (solver: BaseSolver) => void onSolverCompleted?: (solver: BaseSolver) => void + renderer?: "vector" | "canvas" + onRendererChange?: (renderer: "vector" | "canvas") => void + onAnimationSpeedChange?: (speed: number) => void + onDownloadVisualization?: () => void } export const GenericSolverToolbar = ({ @@ -16,6 +21,10 @@ export const GenericSolverToolbar = ({ animationSpeed = 25, onSolverStarted, onSolverCompleted, + renderer = "vector", + onRendererChange = () => {}, + onAnimationSpeedChange = () => {}, + onDownloadVisualization = () => {}, }: GenericSolverToolbarProps) => { const [isAnimating, setIsAnimating] = useReducer((x) => !x, false) const animationRef = useRef(undefined) @@ -42,6 +51,25 @@ export const GenericSolverToolbar = ({ } } + const startAnimation = () => { + animationRef.current = setInterval(() => { + if (solver.solved || solver.failed) { + if (animationRef.current) { + clearInterval(animationRef.current) + animationRef.current = undefined + } + setIsAnimating() + triggerRender() + if (onSolverCompleted && solver.solved) { + onSolverCompleted(solver) + } + return + } + solver.step() + triggerRender() + }, animationSpeed) + } + const handleAnimate = () => { if (isAnimating) { if (animationRef.current) { @@ -51,22 +79,7 @@ export const GenericSolverToolbar = ({ setIsAnimating() } else { setIsAnimating() - animationRef.current = setInterval(() => { - if (solver.solved || solver.failed) { - if (animationRef.current) { - clearInterval(animationRef.current) - animationRef.current = undefined - } - setIsAnimating() - triggerRender() - if (onSolverCompleted && solver.solved) { - onSolverCompleted(solver) - } - return - } - solver.step() - triggerRender() - }, animationSpeed) + startAnimation() } } @@ -135,9 +148,30 @@ export const GenericSolverToolbar = ({ } }, [solver.solved, solver.failed, isAnimating]) + useEffect(() => { + if (!isAnimating) return + if (animationRef.current) { + clearInterval(animationRef.current) + } + startAnimation() + return () => { + if (animationRef.current) { + clearInterval(animationRef.current) + animationRef.current = undefined + } + } + }, [animationSpeed, isAnimating]) + return (
+
diff --git a/lib/react/SolverContextMenu.tsx b/lib/react/SolverContextMenu.tsx new file mode 100644 index 0000000..527719f --- /dev/null +++ b/lib/react/SolverContextMenu.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from "react" + +type RendererOption = "vector" | "canvas" + +const animationOptions = [ + { label: "1s / step", value: 1000 }, + { label: "500ms / step", value: 500 }, + { label: "250ms / step", value: 250 }, + { label: "100ms / step", value: 100 }, + { label: "50ms / step", value: 50 }, + { label: "25ms / step", value: 25 }, + { label: "10ms / step", value: 10 }, +] + +export const SolverContextMenu = ({ + renderer, + onRendererChange, + animationSpeed, + onAnimationSpeedChange, + onDownloadVisualization, +}: { + renderer: RendererOption + onRendererChange: (renderer: RendererOption) => void + animationSpeed: number + onAnimationSpeedChange: (speed: number) => void + onDownloadVisualization: () => void +}) => { + const [openMenu, setOpenMenu] = useState< + "renderer" | "debug" | "animation" | null + >(null) + const menuRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setOpenMenu(null) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + const renderMenuItem = ( + label: string, + isSelected: boolean, + onClick: () => void, + ) => ( + + ) + + return ( +
+
+ + {openMenu === "renderer" && ( +
+ {renderMenuItem("Canvas", renderer === "canvas", () => { + onRendererChange("canvas") + setOpenMenu(null) + })} + {renderMenuItem("Vector", renderer === "vector", () => { + onRendererChange("vector") + setOpenMenu(null) + })} +
+ )} +
+ +
+ + {openMenu === "debug" && ( +
+ +
+ )} +
+ +
+ + {openMenu === "animation" && ( +
+ {animationOptions.map((option) => + renderMenuItem( + option.label, + animationSpeed === option.value, + () => { + onAnimationSpeedChange(option.value) + setOpenMenu(null) + }, + ), + )} +
+ )} +
+
+ ) +}