@@ -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' ;
2223import { 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
2526import type { FlowCardData } from './pipeline-flow-canvas-nodes' ;
2627import { flowEdgeTypes , flowNodeTypes , sectionAccent } from './pipeline-flow-canvas-nodes' ;
@@ -29,40 +30,162 @@ import { computeFlowLayout, type FlowOrientation, parsePipelineFlowTree } from '
2930import type { EditTarget } from '../utils/yaml' ;
3031
3132const 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.
3335const PAN_PADDING = 240 ;
3436const MIN_ZOOM = 0.5 ;
3537const 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.
3873const 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.
4382function 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