Skip to content

Commit f1d2adf

Browse files
committed
Add solver toolbar context menu
1 parent 0139a5a commit f1d2adf

File tree

3 files changed

+230
-19
lines changed

3 files changed

+230
-19
lines changed

lib/react/GenericSolverDebugger.tsx

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import React, { useEffect, useMemo, useReducer, useState } from "react"
22
import type { BaseSolver } from "../BaseSolver"
33
import type { BasePipelineSolver } from "../BasePipelineSolver"
4-
import { InteractiveGraphics } from "graphics-debug/react"
4+
import {
5+
InteractiveGraphics,
6+
InteractiveGraphicsCanvas,
7+
} from "graphics-debug/react"
58
import { GenericSolverToolbar } from "./GenericSolverToolbar"
69
import { PipelineStagesTable } from "./PipelineStagesTable"
710
import { SimpleGraphicsSVG } from "./SimpleGraphicsSVG"
@@ -45,6 +48,9 @@ export const GenericSolverDebugger = ({
4548
onSolverCompleted,
4649
}: GenericSolverDebuggerProps) => {
4750
const [renderCount, incRenderCount] = useReducer((x) => x + 1, 0)
51+
const [currentAnimationSpeed, setCurrentAnimationSpeed] =
52+
useState(animationSpeed)
53+
const [renderer, setRenderer] = useState<"vector" | "canvas">("vector")
4854
const [solver] = useState<BaseSolver>(() => {
4955
if (createSolver) {
5056
return createSolver()
@@ -90,6 +96,10 @@ export const GenericSolverDebugger = ({
9096

9197
const isPipelineSolver = (solver as any).pipelineDef !== undefined
9298

99+
useEffect(() => {
100+
setCurrentAnimationSpeed(animationSpeed)
101+
}, [animationSpeed])
102+
93103
const handleStepUntilPhase = (phaseName: string) => {
94104
const pipelineSolver = solver as BasePipelineSolver<any>
95105
if (!solver.solved && !solver.failed) {
@@ -113,9 +123,31 @@ export const GenericSolverDebugger = ({
113123
<GenericSolverToolbar
114124
solver={solver}
115125
triggerRender={incRenderCount}
116-
animationSpeed={animationSpeed}
126+
animationSpeed={currentAnimationSpeed}
117127
onSolverStarted={onSolverStarted}
118128
onSolverCompleted={onSolverCompleted}
129+
renderer={renderer}
130+
onRendererChange={setRenderer}
131+
onAnimationSpeedChange={setCurrentAnimationSpeed}
132+
onDownloadVisualization={() => {
133+
try {
134+
const visualizationSnapshot = solver.visualize()
135+
const blob = new Blob(
136+
[JSON.stringify(visualizationSnapshot, null, 2)],
137+
{ type: "application/json" },
138+
)
139+
const url = URL.createObjectURL(blob)
140+
const a = document.createElement("a")
141+
a.href = url
142+
a.download = `${solver.constructor.name}_visualization.json`
143+
a.click()
144+
URL.revokeObjectURL(url)
145+
} catch (error) {
146+
alert(
147+
`Error downloading visualization: ${error instanceof Error ? error.message : String(error)}`,
148+
)
149+
}
150+
}}
119151
/>
120152
{graphicsAreEmpty ? (
121153
<div className="p-4 text-gray-500">No Graphics Yet</div>
@@ -125,7 +157,11 @@ export const GenericSolverDebugger = ({
125157
<SimpleGraphicsSVG graphics={visualization as GraphicsObject} />
126158
}
127159
>
128-
<InteractiveGraphics graphics={visualization} />
160+
{renderer === "canvas" ? (
161+
<InteractiveGraphicsCanvas graphics={visualization} />
162+
) : (
163+
<InteractiveGraphics graphics={visualization} />
164+
)}
129165
</ErrorBoundary>
130166
)}
131167
{isPipelineSolver && (

lib/react/GenericSolverToolbar.tsx

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import React, { useReducer, useRef, useEffect } from "react"
22
import type { BaseSolver } from "../BaseSolver"
33
import { SolverBreadcrumbInputDownloader } from "./SolverBreadcrumbInputDownloader"
4+
import { SolverContextMenu } from "./SolverContextMenu"
45

56
export interface GenericSolverToolbarProps {
67
solver: BaseSolver
78
triggerRender: () => void
89
animationSpeed?: number
910
onSolverStarted?: (solver: BaseSolver) => void
1011
onSolverCompleted?: (solver: BaseSolver) => void
12+
renderer?: "vector" | "canvas"
13+
onRendererChange?: (renderer: "vector" | "canvas") => void
14+
onAnimationSpeedChange?: (speed: number) => void
15+
onDownloadVisualization?: () => void
1116
}
1217

1318
export const GenericSolverToolbar = ({
@@ -16,6 +21,10 @@ export const GenericSolverToolbar = ({
1621
animationSpeed = 25,
1722
onSolverStarted,
1823
onSolverCompleted,
24+
renderer = "vector",
25+
onRendererChange = () => {},
26+
onAnimationSpeedChange = () => {},
27+
onDownloadVisualization = () => {},
1928
}: GenericSolverToolbarProps) => {
2029
const [isAnimating, setIsAnimating] = useReducer((x) => !x, false)
2130
const animationRef = useRef<NodeJS.Timeout | undefined>(undefined)
@@ -42,6 +51,25 @@ export const GenericSolverToolbar = ({
4251
}
4352
}
4453

54+
const startAnimation = () => {
55+
animationRef.current = setInterval(() => {
56+
if (solver.solved || solver.failed) {
57+
if (animationRef.current) {
58+
clearInterval(animationRef.current)
59+
animationRef.current = undefined
60+
}
61+
setIsAnimating()
62+
triggerRender()
63+
if (onSolverCompleted && solver.solved) {
64+
onSolverCompleted(solver)
65+
}
66+
return
67+
}
68+
solver.step()
69+
triggerRender()
70+
}, animationSpeed)
71+
}
72+
4573
const handleAnimate = () => {
4674
if (isAnimating) {
4775
if (animationRef.current) {
@@ -51,22 +79,7 @@ export const GenericSolverToolbar = ({
5179
setIsAnimating()
5280
} else {
5381
setIsAnimating()
54-
animationRef.current = setInterval(() => {
55-
if (solver.solved || solver.failed) {
56-
if (animationRef.current) {
57-
clearInterval(animationRef.current)
58-
animationRef.current = undefined
59-
}
60-
setIsAnimating()
61-
triggerRender()
62-
if (onSolverCompleted && solver.solved) {
63-
onSolverCompleted(solver)
64-
}
65-
return
66-
}
67-
solver.step()
68-
triggerRender()
69-
}, animationSpeed)
82+
startAnimation()
7083
}
7184
}
7285

@@ -135,9 +148,30 @@ export const GenericSolverToolbar = ({
135148
}
136149
}, [solver.solved, solver.failed, isAnimating])
137150

151+
useEffect(() => {
152+
if (!isAnimating) return
153+
if (animationRef.current) {
154+
clearInterval(animationRef.current)
155+
}
156+
startAnimation()
157+
return () => {
158+
if (animationRef.current) {
159+
clearInterval(animationRef.current)
160+
animationRef.current = undefined
161+
}
162+
}
163+
}, [animationSpeed, isAnimating])
164+
138165
return (
139166
<div className="space-y-2 p-2 border-b">
140167
<div className="flex items-center">
168+
<SolverContextMenu
169+
renderer={renderer}
170+
onRendererChange={onRendererChange}
171+
animationSpeed={animationSpeed}
172+
onAnimationSpeedChange={onAnimationSpeedChange}
173+
onDownloadVisualization={onDownloadVisualization}
174+
/>
141175
<SolverBreadcrumbInputDownloader solver={solver} />
142176
</div>
143177
<div className="flex gap-2 items-center flex-wrap">

lib/react/SolverContextMenu.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { useEffect, useRef, useState } from "react"
2+
3+
type RendererOption = "vector" | "canvas"
4+
5+
const animationOptions = [
6+
{ label: "1s / step", value: 1000 },
7+
{ label: "500ms / step", value: 500 },
8+
{ label: "250ms / step", value: 250 },
9+
{ label: "100ms / step", value: 100 },
10+
{ label: "50ms / step", value: 50 },
11+
{ label: "25ms / step", value: 25 },
12+
{ label: "10ms / step", value: 10 },
13+
]
14+
15+
export const SolverContextMenu = ({
16+
renderer,
17+
onRendererChange,
18+
animationSpeed,
19+
onAnimationSpeedChange,
20+
onDownloadVisualization,
21+
}: {
22+
renderer: RendererOption
23+
onRendererChange: (renderer: RendererOption) => void
24+
animationSpeed: number
25+
onAnimationSpeedChange: (speed: number) => void
26+
onDownloadVisualization: () => void
27+
}) => {
28+
const [openMenu, setOpenMenu] = useState<
29+
"renderer" | "debug" | "animation" | null
30+
>(null)
31+
const menuRef = useRef<HTMLDivElement>(null)
32+
33+
useEffect(() => {
34+
const handleClickOutside = (event: MouseEvent) => {
35+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
36+
setOpenMenu(null)
37+
}
38+
}
39+
40+
document.addEventListener("mousedown", handleClickOutside)
41+
return () => document.removeEventListener("mousedown", handleClickOutside)
42+
}, [])
43+
44+
const renderMenuItem = (
45+
label: string,
46+
isSelected: boolean,
47+
onClick: () => void,
48+
) => (
49+
<button
50+
type="button"
51+
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-slate-800"
52+
onClick={onClick}
53+
>
54+
<span>{label}</span>
55+
{isSelected && <span className="text-sm"></span>}
56+
</button>
57+
)
58+
59+
return (
60+
<div
61+
className="flex items-center gap-4 text-sm font-semibold"
62+
ref={menuRef}
63+
>
64+
<div className="relative">
65+
<button
66+
type="button"
67+
className="px-2 py-1 text-gray-700 hover:text-gray-900"
68+
onClick={() =>
69+
setOpenMenu((menu) => (menu === "renderer" ? null : "renderer"))
70+
}
71+
>
72+
Renderer
73+
</button>
74+
{openMenu === "renderer" && (
75+
<div className="absolute left-0 top-full z-20 mt-2 w-48 rounded-xl bg-slate-900 text-white shadow-lg ring-1 ring-black/20">
76+
{renderMenuItem("Canvas", renderer === "canvas", () => {
77+
onRendererChange("canvas")
78+
setOpenMenu(null)
79+
})}
80+
{renderMenuItem("Vector", renderer === "vector", () => {
81+
onRendererChange("vector")
82+
setOpenMenu(null)
83+
})}
84+
</div>
85+
)}
86+
</div>
87+
88+
<div className="relative">
89+
<button
90+
type="button"
91+
className="px-2 py-1 text-gray-700 hover:text-gray-900"
92+
onClick={() =>
93+
setOpenMenu((menu) => (menu === "debug" ? null : "debug"))
94+
}
95+
>
96+
Debug
97+
</button>
98+
{openMenu === "debug" && (
99+
<div className="absolute left-0 top-full z-20 mt-2 w-64 rounded-xl bg-slate-900 text-white shadow-lg ring-1 ring-black/20">
100+
<button
101+
type="button"
102+
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-slate-800"
103+
onClick={() => {
104+
onDownloadVisualization()
105+
setOpenMenu(null)
106+
}}
107+
>
108+
Download Visualization
109+
</button>
110+
</div>
111+
)}
112+
</div>
113+
114+
<div className="relative">
115+
<button
116+
type="button"
117+
className="px-2 py-1 text-gray-700 hover:text-gray-900"
118+
onClick={() =>
119+
setOpenMenu((menu) => (menu === "animation" ? null : "animation"))
120+
}
121+
>
122+
Animation
123+
</button>
124+
{openMenu === "animation" && (
125+
<div className="absolute left-0 top-full z-20 mt-2 w-56 rounded-xl bg-slate-900 text-white shadow-lg ring-1 ring-black/20">
126+
{animationOptions.map((option) =>
127+
renderMenuItem(
128+
option.label,
129+
animationSpeed === option.value,
130+
() => {
131+
onAnimationSpeedChange(option.value)
132+
setOpenMenu(null)
133+
},
134+
),
135+
)}
136+
</div>
137+
)}
138+
</div>
139+
</div>
140+
)
141+
}

0 commit comments

Comments
 (0)