feat: add minimap overview to process diagram
Small overview panel in the bottom-left showing the full diagram layout with colored node rectangles and an amber viewport indicator. Click or drag on the minimap to pan the main diagram. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
166
ui/src/components/ProcessDiagram/Minimap.tsx
Normal file
166
ui/src/components/ProcessDiagram/Minimap.tsx
Normal file
@@ -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<SVGSVGElement>(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<SVGSVGElement>) => {
|
||||
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<SVGSVGElement>) => {
|
||||
dragging.current = true;
|
||||
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||
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<SVGSVGElement>) => {
|
||||
dragging.current = false;
|
||||
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.minimap}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={MINIMAP_WIDTH}
|
||||
height={MINIMAP_HEIGHT}
|
||||
onClick={handleClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
style={{ cursor: 'crosshair' }}
|
||||
>
|
||||
{/* Background */}
|
||||
<rect width={MINIMAP_WIDTH} height={MINIMAP_HEIGHT} fill="var(--bg-surface, #FFFFFF)" rx={3} />
|
||||
|
||||
<g transform={`translate(${MINIMAP_PADDING}, ${MINIMAP_PADDING}) scale(${miniScale})`}>
|
||||
{/* Render simplified nodes for each section */}
|
||||
{sections.map((section, si) => (
|
||||
<g key={si} transform={`translate(0, ${section.offsetY})`}>
|
||||
{renderMinimapNodes(section.nodes)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Viewport indicator */}
|
||||
<rect
|
||||
x={vpX}
|
||||
y={vpY}
|
||||
width={Math.max(vpW, 4)}
|
||||
height={Math.max(vpH, 4)}
|
||||
fill="var(--amber, #C6820E)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--amber, #C6820E)"
|
||||
strokeWidth={1.5}
|
||||
rx={1}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<rect key={node.id} x={x} y={y} width={w} height={h}
|
||||
fill="none" stroke={color} strokeWidth={2} rx={2} opacity={0.6} />,
|
||||
);
|
||||
elements.push(...renderMinimapNodes(node.children));
|
||||
} else {
|
||||
// Leaf: filled rectangle
|
||||
elements.push(
|
||||
<rect key={node.id} x={x} y={y} width={w} height={h}
|
||||
fill={color} rx={2} opacity={0.7} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
);
|
||||
})()}
|
||||
|
||||
<Minimap
|
||||
sections={sections}
|
||||
totalWidth={totalWidth}
|
||||
totalHeight={totalHeight}
|
||||
scale={zoom.state.scale}
|
||||
translateX={zoom.state.translateX}
|
||||
translateY={zoom.state.translateY}
|
||||
containerWidth={zoom.containerRef.current?.clientWidth ?? 0}
|
||||
containerHeight={zoom.containerRef.current?.clientHeight ?? 0}
|
||||
onPan={zoom.panTo}
|
||||
/>
|
||||
|
||||
<ZoomControls
|
||||
onZoomIn={zoom.zoomIn}
|
||||
onZoomOut={zoom.zoomOut}
|
||||
|
||||
@@ -111,6 +111,10 @@ export function useZoomPan() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const panTo = useCallback((tx: number, ty: number) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user