Skip to content

Commit c97fc84

Browse files
Add Solver Breadcrumb breadcrumbs with downloadable data + Cosmos demo (#6)
* Add Solver Breadcrumb breadcrumbs with downloadable data + Cosmos demo * styling * fix types * format check script * format check script * refactor * refactor * refactor
1 parent 17d5280 commit c97fc84

10 files changed

+558
-18
lines changed

lib/react/DownloadDropdown.tsx

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { useState, useRef, useEffect } from "react"
2+
import type { BaseSolver } from "../BaseSolver"
3+
4+
interface DownloadDropdownProps {
5+
solver: BaseSolver
6+
className?: string
7+
}
8+
9+
const deepRemoveUnderscoreProperties = (obj: any): any => {
10+
if (obj === null || typeof obj !== "object") {
11+
return obj
12+
}
13+
14+
if (Array.isArray(obj)) {
15+
return obj.map(deepRemoveUnderscoreProperties)
16+
}
17+
18+
const result: any = {}
19+
for (const [key, value] of Object.entries(obj)) {
20+
if (!key.startsWith("_")) {
21+
result[key] = deepRemoveUnderscoreProperties(value)
22+
}
23+
}
24+
return result
25+
}
26+
27+
export const DownloadDropdown = ({
28+
solver,
29+
className = "",
30+
}: DownloadDropdownProps) => {
31+
const [isOpen, setIsOpen] = useState(false)
32+
const dropdownRef = useRef<HTMLDivElement>(null)
33+
34+
useEffect(() => {
35+
const handleClickOutside = (event: MouseEvent) => {
36+
if (
37+
dropdownRef.current &&
38+
!dropdownRef.current.contains(event.target as Node)
39+
) {
40+
setIsOpen(false)
41+
}
42+
}
43+
44+
document.addEventListener("mousedown", handleClickOutside)
45+
return () => document.removeEventListener("mousedown", handleClickOutside)
46+
}, [])
47+
48+
const downloadJSON = () => {
49+
try {
50+
if (typeof solver.getConstructorParams !== "function") {
51+
alert(
52+
`getConstructorParams() is not implemented for ${solver.constructor.name}`,
53+
)
54+
return
55+
}
56+
57+
const params = deepRemoveUnderscoreProperties(
58+
solver.getConstructorParams(),
59+
)
60+
const blob = new Blob([JSON.stringify(params, null, 2)], {
61+
type: "application/json",
62+
})
63+
const url = URL.createObjectURL(blob)
64+
const a = document.createElement("a")
65+
a.href = url
66+
a.download = `${solver.constructor.name}_params.json`
67+
a.click()
68+
URL.revokeObjectURL(url)
69+
} catch (error) {
70+
alert(
71+
`Error downloading params for ${solver.constructor.name}: ${error instanceof Error ? error.message : String(error)}`,
72+
)
73+
}
74+
setIsOpen(false)
75+
}
76+
77+
const downloadPageTsx = () => {
78+
try {
79+
const params = deepRemoveUnderscoreProperties(
80+
solver.getConstructorParams(),
81+
)
82+
const solverName = solver.constructor.name
83+
const isSchematicTracePipelineSolver =
84+
solverName === "SchematicTracePipelineSolver"
85+
86+
let content: string
87+
88+
if (isSchematicTracePipelineSolver) {
89+
content = `import { PipelineDebugger } from "site/components/PipelineDebugger"
90+
import type { InputProblem } from "lib/types/InputProblem"
91+
92+
const inputProblem: InputProblem = ${JSON.stringify(params, null, 2)}
93+
94+
export default () => <PipelineDebugger inputProblem={inputProblem} />
95+
`
96+
} else {
97+
content = `import { useMemo } from "react"
98+
import { GenericSolverDebugger } from "../components/GenericSolverDebugger"
99+
import { ${solverName} } from "lib/solvers/${solverName}/${solverName}"
100+
101+
export const inputProblem = ${JSON.stringify(params, null, 2)}
102+
103+
export default () => {
104+
const solver = useMemo(() => {
105+
return new ${solverName}(inputProblem as any)
106+
}, [])
107+
return <GenericSolverDebugger solver={solver} />
108+
}
109+
`
110+
}
111+
112+
const blob = new Blob([content], { type: "text/plain" })
113+
const url = URL.createObjectURL(blob)
114+
const a = document.createElement("a")
115+
a.href = url
116+
a.download = `${solverName}.page.tsx`
117+
a.click()
118+
URL.revokeObjectURL(url)
119+
} catch (error) {
120+
alert(
121+
`Error generating page.tsx for ${solver.constructor.name}: ${error instanceof Error ? error.message : String(error)}`,
122+
)
123+
}
124+
setIsOpen(false)
125+
}
126+
127+
const downloadTestTs = () => {
128+
try {
129+
const params = deepRemoveUnderscoreProperties(
130+
solver.getConstructorParams(),
131+
)
132+
const solverName = solver.constructor.name
133+
134+
const content = `import { ${solverName} } from "lib/solvers/${solverName}/${solverName}"
135+
import { test, expect } from "bun:test"
136+
137+
test("${solverName} should solve problem correctly", () => {
138+
const input = ${JSON.stringify(params, null, 2)}
139+
140+
const solver = new ${solverName}(input as any)
141+
solver.solve()
142+
143+
expect(solver).toMatchSolverSnapshot(import.meta.path)
144+
145+
// Add more specific assertions based on expected output
146+
// expect(solver.netLabelPlacementSolver!.netLabelPlacements).toMatchInlineSnapshot()
147+
})
148+
`
149+
150+
const blob = new Blob([content], { type: "text/plain" })
151+
const url = URL.createObjectURL(blob)
152+
const a = document.createElement("a")
153+
a.href = url
154+
a.download = `${solverName}.test.ts`
155+
a.click()
156+
URL.revokeObjectURL(url)
157+
} catch (error) {
158+
alert(
159+
`Error generating test.ts for ${solver.constructor.name}: ${error instanceof Error ? error.message : String(error)}`,
160+
)
161+
}
162+
setIsOpen(false)
163+
}
164+
165+
return (
166+
<div className={`relative ${className}`} ref={dropdownRef}>
167+
<button
168+
className="px-2 py-1 rounded text-xs cursor-pointer"
169+
onClick={() => setIsOpen(!isOpen)}
170+
title={`Download options for ${solver.constructor.name}`}
171+
>
172+
{solver.constructor.name}
173+
</button>
174+
175+
{isOpen && (
176+
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10 min-w-[150px]">
177+
<button
178+
className="w-full text-left px-3 py-2 hover:bg-gray-100 text-xs"
179+
onClick={downloadJSON}
180+
>
181+
Download JSON
182+
</button>
183+
<button
184+
className="w-full text-left px-3 py-2 hover:bg-gray-100 text-xs"
185+
onClick={downloadPageTsx}
186+
>
187+
Download page.tsx
188+
</button>
189+
<button
190+
className="w-full text-left px-3 py-2 hover:bg-gray-100 text-xs"
191+
onClick={downloadTestTs}
192+
>
193+
Download test.ts
194+
</button>
195+
</div>
196+
)}
197+
</div>
198+
)
199+
}

lib/react/GenericSolverDebugger.tsx

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,157 @@ import React, { useEffect, useMemo, useReducer } from "react"
22
import type { BaseSolver } from "../BaseSolver"
33
import { InteractiveGraphics } from "graphics-debug/react"
44
import { GenericSolverToolbar } from "./GenericSolverToolbar"
5+
import type { GraphicsObject } from "graphics-debug"
6+
7+
class ErrorBoundary extends React.Component<
8+
{ fallback: React.ReactNode; children: React.ReactNode },
9+
{ hasError: boolean }
10+
> {
11+
constructor(props: { fallback: React.ReactNode; children: React.ReactNode }) {
12+
super(props)
13+
this.state = { hasError: false }
14+
}
15+
static getDerivedStateFromError() {
16+
return { hasError: true }
17+
}
18+
override componentDidCatch(error: any) {
19+
console.error("InteractiveGraphics render error:", error)
20+
}
21+
override render() {
22+
if (this.state.hasError) {
23+
return this.props.fallback
24+
}
25+
return this.props.children
26+
}
27+
}
28+
29+
function SimpleGraphicsSVG({ graphics }: { graphics: GraphicsObject }) {
30+
const points = graphics.points ?? []
31+
const lines = graphics.lines ?? []
32+
const rects = graphics.rects ?? []
33+
const circles = graphics.circles ?? []
34+
const texts = (graphics as any).texts ?? []
35+
36+
let minX = Number.POSITIVE_INFINITY
37+
let minY = Number.POSITIVE_INFINITY
38+
let maxX = Number.NEGATIVE_INFINITY
39+
let maxY = Number.NEGATIVE_INFINITY
40+
41+
const consider = (x?: number, y?: number) => {
42+
if (typeof x === "number") {
43+
if (x < minX) minX = x
44+
if (x > maxX) maxX = x
45+
}
46+
if (typeof y === "number") {
47+
if (y < minY) minY = y
48+
if (y > maxY) maxY = y
49+
}
50+
}
51+
52+
for (const p of points) consider((p as any).x, (p as any).y)
53+
for (const l of lines) {
54+
const pts = (l as any).points ?? []
55+
for (const p of pts) consider(p.x, p.y)
56+
}
57+
for (const r of rects) {
58+
const x = (r as any).x ?? 0
59+
const y = (r as any).y ?? 0
60+
const w = (r as any).width ?? 0
61+
const h = (r as any).height ?? 0
62+
consider(x, y)
63+
consider(x + w, y + h)
64+
}
65+
for (const c of circles) {
66+
const x = (c as any).x ?? 0
67+
const y = (c as any).y ?? 0
68+
const rad = (c as any).radius ?? 1
69+
consider(x - rad, y - rad)
70+
consider(x + rad, y + rad)
71+
}
72+
for (const t of texts) consider((t as any).x, (t as any).y)
73+
74+
if (
75+
!isFinite(minX) ||
76+
!isFinite(minY) ||
77+
!isFinite(maxX) ||
78+
!isFinite(maxY)
79+
) {
80+
minX = -20
81+
minY = -20
82+
maxX = 20
83+
maxY = 20
84+
}
85+
86+
const pad = 10
87+
const vbX = minX - pad
88+
const vbY = minY - pad
89+
const vbW = Math.max(1, maxX - minX + 2 * pad)
90+
const vbH = Math.max(1, maxY - minY + 2 * pad)
91+
92+
return (
93+
<svg
94+
className="w-full h-[400px] bg-white"
95+
viewBox={`${vbX} ${vbY} ${vbW} ${vbH}`}
96+
role="img"
97+
aria-label="Graphics fallback"
98+
>
99+
{rects.map((r: any, i: number) => (
100+
<rect
101+
key={`rect-${i}`}
102+
x={r.x ?? 0}
103+
y={r.y ?? 0}
104+
width={r.width ?? 0}
105+
height={r.height ?? 0}
106+
fill="none"
107+
stroke={r.strokeColor ?? "black"}
108+
strokeWidth={r.strokeWidth ?? 1}
109+
/>
110+
))}
111+
{lines.map((l: any, i: number) => (
112+
<polyline
113+
key={`line-${i}`}
114+
fill="none"
115+
stroke={l.strokeColor ?? "black"}
116+
strokeWidth={l.strokeWidth ?? 1}
117+
points={(l.points ?? [])
118+
.map((p: any) => `${p.x ?? 0},${p.y ?? 0}`)
119+
.join(" ")}
120+
/>
121+
))}
122+
{circles.map((c: any, i: number) => (
123+
<circle
124+
key={`circle-${i}`}
125+
cx={c.x ?? 0}
126+
cy={c.y ?? 0}
127+
r={c.radius ?? 1.5}
128+
fill={c.fillColor ?? "none"}
129+
stroke={c.strokeColor ?? "black"}
130+
strokeWidth={c.strokeWidth ?? 1}
131+
/>
132+
))}
133+
{points.map((p: any, i: number) => (
134+
<circle
135+
key={`point-${i}`}
136+
cx={p.x ?? 0}
137+
cy={p.y ?? 0}
138+
r={p.radius ?? 1.5}
139+
fill={p.color ?? "black"}
140+
/>
141+
))}
142+
{texts.map((t: any, i: number) => (
143+
<text
144+
key={`text-${i}`}
145+
x={t.x ?? 0}
146+
y={t.y ?? 0}
147+
fontSize={t.fontSize ?? 10}
148+
fill={t.color ?? "black"}
149+
>
150+
{t.text ?? ""}
151+
</text>
152+
))}
153+
</svg>
154+
)
155+
}
5156

6157
export interface GenericSolverDebuggerProps {
7158
solver: BaseSolver
@@ -63,7 +214,13 @@ export const GenericSolverDebugger = ({
63214
{graphicsAreEmpty ? (
64215
<div className="p-4 text-gray-500">No Graphics Yet</div>
65216
) : (
66-
<InteractiveGraphics graphics={visualization} />
217+
<ErrorBoundary
218+
fallback={
219+
<SimpleGraphicsSVG graphics={visualization as GraphicsObject} />
220+
}
221+
>
222+
<InteractiveGraphics graphics={visualization} />
223+
</ErrorBoundary>
67224
)}
68225
</div>
69226
)

lib/react/GenericSolverToolbar.tsx

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

56
export interface GenericSolverToolbarProps {
67
solver: BaseSolver
@@ -111,6 +112,9 @@ export const GenericSolverToolbar = ({
111112

112113
return (
113114
<div className="space-y-2 p-2 border-b">
115+
<div className="flex items-center">
116+
<SolverBreadcrumbInputDownloader solver={solver} />
117+
</div>
114118
<div className="flex gap-2 items-center flex-wrap">
115119
<button
116120
onClick={handleStep}

0 commit comments

Comments
 (0)