Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions lib/react/GenericSolverDebugger.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<BaseSolver>(() => {
if (createSolver) {
return createSolver()
Expand Down Expand Up @@ -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<any>
if (!solver.solved && !solver.failed) {
Expand All @@ -113,9 +123,31 @@ export const GenericSolverDebugger = ({
<GenericSolverToolbar
solver={solver}
triggerRender={incRenderCount}
animationSpeed={animationSpeed}
animationSpeed={currentAnimationSpeed}
onSolverStarted={onSolverStarted}
onSolverCompleted={onSolverCompleted}
renderer={renderer}
onRendererChange={setRenderer}
onAnimationSpeedChange={setCurrentAnimationSpeed}
onDownloadVisualization={() => {
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 ? (
<div className="p-4 text-gray-500">No Graphics Yet</div>
Expand All @@ -125,7 +157,11 @@ export const GenericSolverDebugger = ({
<SimpleGraphicsSVG graphics={visualization as GraphicsObject} />
}
>
<InteractiveGraphics graphics={visualization} />
{renderer === "canvas" ? (
<InteractiveGraphicsCanvas graphics={visualization} />
) : (
<InteractiveGraphics graphics={visualization} />
)}
</ErrorBoundary>
)}
{isPipelineSolver && (
Expand Down
66 changes: 50 additions & 16 deletions lib/react/GenericSolverToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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
triggerRender: () => void
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 = ({
Expand All @@ -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<NodeJS.Timeout | undefined>(undefined)
Expand All @@ -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) {
Expand All @@ -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()
}
}

Expand Down Expand Up @@ -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 (
<div className="space-y-2 p-2 border-b">
<div className="flex items-center">
<SolverContextMenu
renderer={renderer}
onRendererChange={onRendererChange}
animationSpeed={animationSpeed}
onAnimationSpeedChange={onAnimationSpeedChange}
onDownloadVisualization={onDownloadVisualization}
/>
<SolverBreadcrumbInputDownloader solver={solver} />
</div>
<div className="flex gap-2 items-center flex-wrap">
Expand Down
141 changes: 141 additions & 0 deletions lib/react/SolverContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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,
) => (
<button
type="button"
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-slate-800"
onClick={onClick}
>
<span>{label}</span>
{isSelected && <span className="text-sm">✓</span>}
</button>
)

return (
<div
className="flex items-center gap-4 text-sm font-semibold"
ref={menuRef}
>
<div className="relative">
<button
type="button"
className="px-2 py-1 text-gray-700 hover:text-gray-900"
onClick={() =>
setOpenMenu((menu) => (menu === "renderer" ? null : "renderer"))
}
>
Renderer
</button>
{openMenu === "renderer" && (
<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">
{renderMenuItem("Canvas", renderer === "canvas", () => {
onRendererChange("canvas")
setOpenMenu(null)
})}
{renderMenuItem("Vector", renderer === "vector", () => {
onRendererChange("vector")
setOpenMenu(null)
})}
</div>
)}
</div>

<div className="relative">
<button
type="button"
className="px-2 py-1 text-gray-700 hover:text-gray-900"
onClick={() =>
setOpenMenu((menu) => (menu === "debug" ? null : "debug"))
}
>
Debug
</button>
{openMenu === "debug" && (
<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">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-slate-800"
onClick={() => {
onDownloadVisualization()
setOpenMenu(null)
}}
>
Download Visualization
</button>
</div>
)}
</div>

<div className="relative">
<button
type="button"
className="px-2 py-1 text-gray-700 hover:text-gray-900"
onClick={() =>
setOpenMenu((menu) => (menu === "animation" ? null : "animation"))
}
>
Animation
</button>
{openMenu === "animation" && (
<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">
{animationOptions.map((option) =>
renderMenuItem(
option.label,
animationSpeed === option.value,
() => {
onAnimationSpeedChange(option.value)
setOpenMenu(null)
},
),
)}
</div>
)}
</div>
</div>
)
}