From 343977d467b2c07ec65a68749de4494aa400e326 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 28 Feb 2026 14:32:12 -0500 Subject: [PATCH] Add diagram controls: direction toggle, zoom, fit-to-window, drag-to-pan - Toggle between left-to-right and top-to-bottom layout - Zoom in/out with stepped controls - Fit-to-window button (dashed square icon) resets zoom and pan - Click-and-drag to pan the diagram - Strip graph title from rendered SVG at render time Co-Authored-By: Claude Opus 4.6 --- apps/arc-web/app/routes/workflow-diagram.tsx | 164 +++++++++++++++++-- 1 file changed, 149 insertions(+), 15 deletions(-) diff --git a/apps/arc-web/app/routes/workflow-diagram.tsx b/apps/arc-web/app/routes/workflow-diagram.tsx index 0cefbaeee..df269e737 100644 --- a/apps/arc-web/app/routes/workflow-diagram.tsx +++ b/apps/arc-web/app/routes/workflow-diagram.tsx @@ -1,10 +1,14 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; -const fixBuildDot = `digraph fix_build { +type Direction = "LR" | "TB"; + +function buildDot(direction: Direction) { + return `digraph fix_build { graph [ label="Fix Build" ] - rankdir=LR + rankdir=${direction} bgcolor="transparent" pad=0.5 @@ -45,10 +49,33 @@ const fixBuildDot = `digraph fix_build { approve -> exit [label="Accept"] approve -> fix [label="Revise", style=dashed] }`; +} + +function stripGraphTitle(svg: SVGSVGElement) { + const title = svg.querySelector(".graph > title"); + if (!title) return; + let sibling = title.nextElementSibling; + while (sibling && sibling.tagName === "text") { + const next = sibling.nextElementSibling; + sibling.remove(); + sibling = next; + } + title.remove(); +} + +const ZOOM_STEPS = [25, 50, 75, 100, 150, 200]; +const DEFAULT_ZOOM_INDEX = 2; // 75% export default function WorkflowDiagram() { const containerRef = useRef(null); + const innerRef = useRef(null); + const svgRef = useRef(null); const [error, setError] = useState(null); + const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); + const [direction, setDirection] = useState("LR"); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); + const zoom = ZOOM_STEPS[zoomIndex]; useEffect(() => { let cancelled = false; @@ -59,22 +86,61 @@ export default function WorkflowDiagram() { if (cancelled) return; try { - const svg = viz.renderSVGElement(fixBuildDot); - svg.removeAttribute("width"); - svg.removeAttribute("height"); - svg.style.width = "100%"; - svg.style.height = "auto"; + const svg = viz.renderSVGElement(buildDot(direction)); + stripGraphTitle(svg); - if (containerRef.current) { - containerRef.current.replaceChildren(svg); + svgRef.current = svg; + if (innerRef.current) { + innerRef.current.replaceChildren(svg); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to render diagram"); } } + setPan({ x: 0, y: 0 }); render(); return () => { cancelled = true; }; + }, [direction]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + e.currentTarget.setPointerCapture(e.pointerId); + dragState.current = { startX: e.clientX, startY: e.clientY, startPanX: pan.x, startPanY: pan.y }; + }, [pan]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + const drag = dragState.current; + if (!drag) return; + setPan({ + x: drag.startPanX + e.clientX - drag.startX, + y: drag.startPanY + e.clientY - drag.startY, + }); + }, []); + + const onPointerUp = useCallback(() => { + dragState.current = null; + }, []); + + const fitToWindow = useCallback(() => { + const svg = svgRef.current; + const container = containerRef.current; + if (!svg || !container) return; + + const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width; + const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height; + const padPx = 48; // p-6 on each side + const containerW = container.clientWidth - padPx; + const containerH = container.clientHeight - padPx; + + const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100; + // Pick the largest zoom step that fits + let best = 0; + for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) { + if (ZOOM_STEPS[i] <= fitPct) { best = i; break; } + } + setZoomIndex(best); + setPan({ x: 0, y: 0 }); }, []); if (error) { @@ -82,11 +148,79 @@ export default function WorkflowDiagram() { } return ( -
-

Loading diagram...

+
+
+
+ + +
+ +
+ +
+ +
+ + +
+
+ +
+
+

Loading diagram...

+
+
); }