Skip to content

Commit a52f5cf

Browse files
committed
minimap improvement
1 parent 15c543b commit a52f5cf

1 file changed

Lines changed: 155 additions & 44 deletions

File tree

frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.tsx

Lines changed: 155 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import {
1414
BackgroundVariant,
1515
Controls,
1616
type Edge,
17-
MiniMap,
1817
type Node,
1918
ReactFlow,
2019
ReactFlowProvider,
20+
useReactFlow,
21+
useStore,
2122
} from '@xyflow/react';
2223
import { useDebouncedValue } from 'hooks/use-debounced-value';
23-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
24+
import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2425

2526
import type { FlowCardData } from './pipeline-flow-canvas-nodes';
2627
import { flowEdgeTypes, flowNodeTypes, sectionAccent } from './pipeline-flow-canvas-nodes';
@@ -29,40 +30,162 @@ import { computeFlowLayout, type FlowOrientation, parsePipelineFlowTree } from '
2930
import type { EditTarget } from '../utils/yaml';
3031

3132
const PARSE_DEBOUNCE_MS = 300;
32-
// How far past the diagram bounds the canvas may be panned, and the zoom range.
33+
// How far past the diagram the canvas may be panned, so a node near the edge can be
34+
// brought to the middle to inspect it comfortably.
3335
const PAN_PADDING = 240;
3436
const MIN_ZOOM = 0.5;
3537
const MAX_ZOOM = 1.25;
38+
39+
// Bounding box of the diagram's top-level nodes (children sit inside their parents, so
40+
// the top-level boxes cover everything; resource cards can sit at negative x, so we
41+
// measure rather than assume the content starts at the origin).
42+
function contentBounds(nodes: Node[]): { minX: number; minY: number; maxX: number; maxY: number } {
43+
let minX = 0;
44+
let minY = 0;
45+
let maxX = 0;
46+
let maxY = 0;
47+
let seen = false;
48+
for (const node of nodes) {
49+
if (node.parentId) {
50+
continue;
51+
}
52+
const w = (node.initialWidth ?? node.width ?? 0) as number;
53+
const h = (node.initialHeight ?? node.height ?? 0) as number;
54+
const right = node.position.x + w;
55+
const bottom = node.position.y + h;
56+
if (seen) {
57+
minX = Math.min(minX, node.position.x);
58+
minY = Math.min(minY, node.position.y);
59+
maxX = Math.max(maxX, right);
60+
maxY = Math.max(maxY, bottom);
61+
} else {
62+
minX = node.position.x;
63+
minY = node.position.y;
64+
maxX = right;
65+
maxY = bottom;
66+
seen = true;
67+
}
68+
}
69+
return { minX, minY, maxX, maxY };
70+
}
3671
// The minimap only earns its space once a pipeline is large enough to need
3772
// navigating; trivial graphs fit on screen and the minimap is just clutter.
3873
const MINIMAP_MIN_NODES = 8;
74+
const MINIMAP_WIDTH = 132;
75+
const MINIMAP_HEIGHT = 84;
76+
const MINIMAP_PAD = 6;
77+
// The frame's 1px border (border-box) shrinks the svg's drawing area on each side.
78+
const MINIMAP_BORDER = 1;
3979

40-
// Tint each minimap blip with its node's role accent so the overview stays legible
41-
// at thumbnail size; structural marks (section labels) drop out so only the actual
42-
// components show — a simple, uncluttered overview.
80+
// Tint each minimap blip with its node's role accent; structural marks (section
81+
// labels) drop out so only the actual components show — a simple, uncluttered overview.
4382
function miniMapNodeColor(node: Node): string {
4483
return sectionAccent((node.data as FlowCardData | undefined)?.section) ?? 'transparent';
4584
}
4685

47-
// A simple rounded blip per node. We render the fill via inline `style` (not the SVG
48-
// `fill` attribute, which doesn't evaluate the `var(--color-…)` accent) and floor the
49-
// size so content-sized cards — whose width/height React Flow hasn't measured yet —
50-
// still show as a visible dot. Sectionless marks (color 'transparent') drop out.
51-
type MiniMapBlipProps = { x: number; y: number; width: number; height: number; color?: string; borderRadius: number };
52-
function MiniMapBlip({ x, y, width, height, color, borderRadius }: MiniMapBlipProps) {
53-
if (!color || color === 'transparent') {
54-
return null;
55-
}
86+
/**
87+
* A compact overview minimap. Unlike React Flow's built-in `MiniMap` (whose bounds
88+
* grow to include the live viewport, so they bloat when you pan into the surrounding
89+
* padding), this draws the **content** at a fixed scale and shows the viewport as a
90+
* rectangle clipped to the frame. So the canvas can be panned generously past the
91+
* nodes while the minimap stays tight to the diagram. Click/drag re-centres the view.
92+
*/
93+
function PipelineMiniMap({ nodes }: { nodes: Node[] }) {
94+
const transform = useStore((s) => s.transform);
95+
const paneWidth = useStore((s) => s.width);
96+
const paneHeight = useStore((s) => s.height);
97+
const { setCenter } = useReactFlow();
98+
const draggingRef = useRef(false);
99+
100+
// The svg fills the frame's content box — i.e. the frame minus its 1px border on
101+
// each side — so nothing the svg draws is clipped by the border.
102+
const mapW = MINIMAP_WIDTH - 2 * MINIMAP_BORDER;
103+
const mapH = MINIMAP_HEIGHT - 2 * MINIMAP_BORDER;
104+
105+
const bounds = useMemo(() => contentBounds(nodes), [nodes]);
106+
const contentW = Math.max(bounds.maxX - bounds.minX, 1);
107+
const contentH = Math.max(bounds.maxY - bounds.minY, 1);
108+
const innerW = mapW - 2 * MINIMAP_PAD;
109+
const innerH = mapH - 2 * MINIMAP_PAD;
110+
const scale = Math.min(innerW / contentW, innerH / contentH);
111+
// Centre the content within the frame.
112+
const offsetX = MINIMAP_PAD + (innerW - contentW * scale) / 2 - bounds.minX * scale;
113+
const offsetY = MINIMAP_PAD + (innerH - contentH * scale) / 2 - bounds.minY * scale;
114+
115+
const [tx, ty, zoom] = transform;
116+
// The viewport in minimap coordinates, clamped to the drawing area (with a 1px inset
117+
// for the stroke) so its border is always fully visible even when the view extends
118+
// past the content into the surrounding pan padding.
119+
const inset = 1;
120+
const left = Math.max((-tx / zoom) * scale + offsetX, inset);
121+
const top = Math.max((-ty / zoom) * scale + offsetY, inset);
122+
const right = Math.min((-tx / zoom + paneWidth / zoom) * scale + offsetX, mapW - inset);
123+
const bottom = Math.min((-ty / zoom + paneHeight / zoom) * scale + offsetY, mapH - inset);
124+
const view = { x: left, y: top, w: Math.max(right - left, 0), h: Math.max(bottom - top, 0) };
125+
126+
const panToEvent = (e: ReactPointerEvent<SVGSVGElement>) => {
127+
const rect = e.currentTarget.getBoundingClientRect();
128+
const fx = (e.clientX - rect.left - offsetX) / scale;
129+
const fy = (e.clientY - rect.top - offsetY) / scale;
130+
setCenter(fx, fy, { zoom, duration: 0 });
131+
};
132+
56133
return (
57-
<rect
58-
height={Math.max(height, 18)}
59-
rx={borderRadius}
60-
ry={borderRadius}
61-
style={{ fill: color }}
62-
width={Math.max(width, 18)}
63-
x={x}
64-
y={y}
65-
/>
134+
<div
135+
className="nodrag nopan absolute z-10 overflow-hidden rounded-md border border-border bg-card shadow-sm"
136+
style={{ width: MINIMAP_WIDTH, height: MINIMAP_HEIGHT, right: 52, bottom: 12 }}
137+
>
138+
<svg
139+
className="block cursor-pointer"
140+
height={mapH}
141+
onPointerDown={(e) => {
142+
draggingRef.current = true;
143+
e.currentTarget.setPointerCapture(e.pointerId);
144+
panToEvent(e);
145+
}}
146+
onPointerMove={(e) => {
147+
if (draggingRef.current) {
148+
panToEvent(e);
149+
}
150+
}}
151+
onPointerUp={(e) => {
152+
draggingRef.current = false;
153+
e.currentTarget.releasePointerCapture(e.pointerId);
154+
}}
155+
role="presentation"
156+
width={mapW}
157+
>
158+
{nodes.map((node) => {
159+
const color = miniMapNodeColor(node);
160+
if (node.parentId || color === 'transparent') {
161+
return null;
162+
}
163+
const w = ((node.initialWidth ?? node.width ?? 0) as number) * scale;
164+
const h = ((node.initialHeight ?? node.height ?? 0) as number) * scale;
165+
return (
166+
<rect
167+
height={Math.max(h, 2)}
168+
key={node.id}
169+
rx={1.5}
170+
style={{ fill: color, opacity: 0.85 }}
171+
width={Math.max(w, 2)}
172+
x={node.position.x * scale + offsetX}
173+
y={node.position.y * scale + offsetY}
174+
/>
175+
);
176+
})}
177+
<rect
178+
fill="color-mix(in srgb, var(--color-primary) 12%, transparent)"
179+
height={view.h}
180+
rx={2}
181+
stroke="var(--color-primary)"
182+
strokeWidth={1.5}
183+
width={view.w}
184+
x={view.x}
185+
y={view.y}
186+
/>
187+
</svg>
188+
</div>
66189
);
67190
}
68191

@@ -327,12 +450,14 @@ export function PipelineFlowCanvas({
327450
};
328451
const injectedNodes = layout.rfNodes.map((node: Node) => injectNodeData(node, callbacks));
329452

330-
// The compact sidebar scrolls vertically and should hug the content (just a
331-
// little breathing room); the full canvas allows generous panning room.
332-
const pad = simple ? 16 : PAN_PADDING;
453+
// Allow panning a generous margin past the content (measured, since resources can
454+
// sit at negative x) so an edge node can be brought to the middle; the compact
455+
// sidebar hugs its content. The minimap stays tight to the content regardless.
456+
const margin = simple ? 16 : PAN_PADDING;
457+
const bounds = contentBounds(layout.rfNodes);
333458
const extent: [[number, number], [number, number]] = [
334-
[-pad, -pad],
335-
[layout.width + pad, layout.height + pad],
459+
[bounds.minX - margin, bounds.minY - margin],
460+
[bounds.maxX + margin, bounds.maxY + margin],
336461
];
337462

338463
// Which edge vocabularies appear — drives an adaptive legend (only shows the
@@ -481,21 +606,7 @@ export function PipelineFlowCanvas({
481606
showInteractive={false}
482607
/>
483608
)}
484-
{simple || rfNodes.length <= MINIMAP_MIN_NODES ? null : (
485-
<MiniMap
486-
bgColor="var(--color-card)"
487-
className="!m-0 overflow-hidden rounded-md border border-border shadow-sm"
488-
maskColor="color-mix(in srgb, var(--color-muted) 55%, transparent)"
489-
nodeBorderRadius={3}
490-
nodeColor={miniMapNodeColor}
491-
nodeComponent={MiniMapBlip}
492-
pannable
493-
// Sit just left of the bottom-right zoom controls, compact.
494-
position="bottom-right"
495-
style={{ width: 132, height: 84, right: 52, bottom: 12 }}
496-
zoomable
497-
/>
498-
)}
609+
{simple || rfNodes.length <= MINIMAP_MIN_NODES ? null : <PipelineMiniMap nodes={rfNodes} />}
499610
</ReactFlow>
500611
</ReactFlowProvider>
501612
{simple ? null : <FlowLegend flags={legend} />}

0 commit comments

Comments
 (0)