diff --git a/ui/src/components/ProcessDiagram/Minimap.tsx b/ui/src/components/ProcessDiagram/Minimap.tsx new file mode 100644 index 00000000..a67f9870 --- /dev/null +++ b/ui/src/components/ProcessDiagram/Minimap.tsx @@ -0,0 +1,166 @@ +import { useCallback, useRef } from 'react'; +import type { DiagramSection } from './types'; +import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; +import { colorForType } from './node-colors'; +import styles from './ProcessDiagram.module.css'; + +const MINIMAP_WIDTH = 180; +const MINIMAP_HEIGHT = 120; +const MINIMAP_PADDING = 4; + +interface MinimapProps { + sections: DiagramSection[]; + totalWidth: number; + totalHeight: number; + /** Current zoom/pan state */ + scale: number; + translateX: number; + translateY: number; + /** Container pixel dimensions */ + containerWidth: number; + containerHeight: number; + /** Called when user clicks/drags on minimap to pan */ + onPan: (translateX: number, translateY: number) => void; +} + +export function Minimap({ + sections, totalWidth, totalHeight, + scale, translateX, translateY, + containerWidth, containerHeight, + onPan, +}: MinimapProps) { + const svgRef = useRef(null); + const dragging = useRef(false); + + if (totalWidth <= 0 || totalHeight <= 0) return null; + + // Scale factor to fit diagram into minimap + const innerW = MINIMAP_WIDTH - MINIMAP_PADDING * 2; + const innerH = MINIMAP_HEIGHT - MINIMAP_PADDING * 2; + const miniScale = Math.min(innerW / totalWidth, innerH / totalHeight); + + // Viewport rectangle in minimap coordinates + // The visible area in diagram-space: from (-translateX/scale) to (-translateX/scale + containerWidth/scale) + const vpX = (-translateX / scale) * miniScale + MINIMAP_PADDING; + const vpY = (-translateY / scale) * miniScale + MINIMAP_PADDING; + const vpW = (containerWidth / scale) * miniScale; + const vpH = (containerHeight / scale) * miniScale; + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const mx = e.clientX - rect.left - MINIMAP_PADDING; + const my = e.clientY - rect.top - MINIMAP_PADDING; + // Convert minimap click to diagram coordinates, then to translate + const diagramX = mx / miniScale; + const diagramY = my / miniScale; + // Center the viewport on the clicked point + const newTx = -(diagramX * scale) + containerWidth / 2; + const newTy = -(diagramY * scale) + containerHeight / 2; + onPan(newTx, newTy); + }, + [miniScale, scale, containerWidth, containerHeight, onPan], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + dragging.current = true; + (e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId); + }, + [], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging.current) return; + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const mx = e.clientX - rect.left - MINIMAP_PADDING; + const my = e.clientY - rect.top - MINIMAP_PADDING; + const diagramX = mx / miniScale; + const diagramY = my / miniScale; + const newTx = -(diagramX * scale) + containerWidth / 2; + const newTy = -(diagramY * scale) + containerHeight / 2; + onPan(newTx, newTy); + }, + [miniScale, scale, containerWidth, containerHeight, onPan], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + dragging.current = false; + (e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId); + }, + [], + ); + + return ( +
+ + {/* Background */} + + + + {/* Render simplified nodes for each section */} + {sections.map((section, si) => ( + + {renderMinimapNodes(section.nodes)} + + ))} + + + {/* Viewport indicator */} + + +
+ ); +} + +function renderMinimapNodes(nodes: DiagramNodeType[]): React.ReactNode[] { + const elements: React.ReactNode[] = []; + for (const node of nodes) { + const x = node.x ?? 0; + const y = node.y ?? 0; + const w = node.width ?? 160; + const h = node.height ?? 40; + const color = colorForType(node.type); + + if (node.children && node.children.length > 0) { + // Compound: border only + elements.push( + , + ); + elements.push(...renderMinimapNodes(node.children)); + } else { + // Leaf: filled rectangle + elements.push( + , + ); + } + } + return elements; +} diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.module.css b/ui/src/components/ProcessDiagram/ProcessDiagram.module.css index a3a158af..c61cd5ad 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.module.css +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.module.css @@ -122,6 +122,17 @@ font-weight: 600; } +.minimap { + position: absolute; + bottom: 12px; + left: 12px; + border: 1px solid var(--border, #E4DFD8); + border-radius: var(--radius-sm, 5px); + box-shadow: var(--shadow-md, 0 2px 8px rgba(44, 37, 32, 0.08)); + overflow: hidden; + z-index: 5; +} + .nodeToolbar { position: absolute; display: flex; diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index 5dad44cc..7269c262 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -9,6 +9,7 @@ import { DiagramEdge } from './DiagramEdge'; import { CompoundNode } from './CompoundNode'; import { ErrorSection } from './ErrorSection'; import { ZoomControls } from './ZoomControls'; +import { Minimap } from './Minimap'; import { isCompoundType } from './node-colors'; import styles from './ProcessDiagram.module.css'; @@ -292,6 +293,18 @@ export function ProcessDiagram({ ); })()} + + { + setState(prev => ({ ...prev, translateX: tx, translateY: ty })); + }, []); + const resetView = useCallback(() => { setState({ scale: 1, translateX: FIT_PADDING, translateY: FIT_PADDING }); }, []); @@ -155,6 +159,7 @@ export function useZoomPan() { state, containerRef, transform, + panTo, resetView, onWheel, onPointerDown,