feat: add minimap overview to process diagram
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 57s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

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:
hsiegeln
2026-03-27 17:16:05 +01:00
parent b1ff05439a
commit 30c8fe1091
4 changed files with 195 additions and 0 deletions

View 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;
}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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,