@@ -53,11 +53,7 @@ interface AttackPathGraphProps {
5353 selectedNodeId ?: string | null ;
5454 isFilteredView ?: boolean ;
5555 initialNodeId ?: string ;
56- // Tier 1 expansion state — controlled by the parent (lives in the graph
57- // store) so it survives filtered-view enter/exit. If omitted, no resource
58- // expansion is tracked and findings stay hidden in the full view.
5956 expandedResources ?: Set < string > ;
60- onResourceToggle ?: ( resourceId : string ) => void ;
6157 onNodeClick ?: ( node : GraphNode ) => void ;
6258 onInitialFilter ?: ( filteredData : AttackPathGraphData ) => void ;
6359 ref ?: Ref < GraphHandle > ;
@@ -144,32 +140,81 @@ const GraphDefs = () => (
144140
145141type GraphCanvasProps = Omit < AttackPathGraphProps , "className" > ;
146142
143+ // Mask covers the area NOT currently in view; the cut-out rectangle is the
144+ // viewport. To make the viewport rectangle stand out we darken the mask and
145+ // give its border high contrast against the minimap background.
147146const MINIMAP_COLORS = {
148147 light : {
149148 bg : "#f8fafc" ,
150- mask : "rgba(241, 245, 249 , 0.6 )" ,
151- maskStroke : "#cbd5e1 " ,
149+ mask : "rgba(15, 23, 42 , 0.45 )" ,
150+ maskStroke : "#0f172a " ,
152151 } ,
153152 dark : {
154153 bg : "#0f172a" ,
155- mask : "rgba(15, 23, 42 , 0.6 )" ,
156- maskStroke : "#475569 " ,
154+ mask : "rgba(0, 0, 0 , 0.7 )" ,
155+ maskStroke : "#cbd5e1 " ,
157156 } ,
158157} as const ;
159158
159+ const MINIMAP_VIEWPORT_STROKE_WIDTH = 3 ;
160+
161+ // Animated re-fit shared by every auto-fit trigger. We deliberately do not
162+ // cap maxZoom — for small subgraphs (e.g. a finding's filtered view with 3
163+ // nodes) the user expects the result to fill the canvas; an artificial cap
164+ // looks like a layout error.
165+ //
166+ // Padding is asymmetric: the minimap sits in the bottom-right corner of the
167+ // canvas (default 200×150 panel + offset), so a fit with uniform padding
168+ // can drop nodes underneath it. Generous bottom/right padding keeps the
169+ // fitted graph clear of the minimap.
170+ const AUTO_FIT_OPTIONS = {
171+ padding : { top : "32px" , left : "32px" , right : "240px" , bottom : "180px" } ,
172+ duration : 300 ,
173+ } as const ;
174+
175+ const MEASURED_FIT_MAX_ATTEMPTS = 30 ;
176+
177+ const scheduleMeasuredFit = (
178+ isMeasured : ( ) => boolean ,
179+ onMeasured : ( ) => void ,
180+ ) => {
181+ let frame = 0 ;
182+ let attempts = 0 ;
183+
184+ const tryFit = ( ) => {
185+ if ( isMeasured ( ) || attempts >= MEASURED_FIT_MAX_ATTEMPTS ) {
186+ onMeasured ( ) ;
187+ return ;
188+ }
189+
190+ attempts += 1 ;
191+ frame = requestAnimationFrame ( tryFit ) ;
192+ } ;
193+
194+ frame = requestAnimationFrame ( tryFit ) ;
195+
196+ return ( ) => cancelAnimationFrame ( frame ) ;
197+ } ;
198+
160199const GraphCanvas = ( {
161200 data,
162201 selectedNodeId,
163202 isFilteredView,
164203 initialNodeId,
165204 expandedResources,
166- onResourceToggle,
167205 onNodeClick,
168206 onInitialFilter,
169207 ref,
170208} : GraphCanvasProps ) => {
171- const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
172- useReactFlow ( ) ;
209+ const {
210+ zoomIn,
211+ zoomOut,
212+ fitView,
213+ getZoom,
214+ getNodes,
215+ getNodesBounds,
216+ getViewport,
217+ } = useReactFlow ( ) ;
173218 const { resolvedTheme } = useTheme ( ) ;
174219 const containerRef = useRef < HTMLDivElement > ( null ) ;
175220 const hasInitialized = useRef ( false ) ;
@@ -212,6 +257,112 @@ const GraphCanvas = ({
212257 }
213258 } , [ ] ) ; // eslint-disable-line react-hooks/exhaustive-deps -- one-time init
214259
260+ // --- Auto-fit triggers ---
261+ //
262+ // Filter toggles (finding click → filtered view, "Back to Full View" →
263+ // full graph) swap the data prop, which leaves the previous viewport
264+ // pointing at coordinates that no longer contain the new layout. Re-fit
265+ // once React Flow has applied the new layout (next animation frame).
266+ //
267+ // Resource expansion fits ONLY when a newly revealed finding sits
268+ // entirely outside the current viewport (i.e. its full bounding box
269+ // is past one of the viewport edges). This intentionally lets
270+ // partially-clipped edge nodes through without re-fitting — hidden
271+ // findings contribute zero size to the initial bbox, so a finding's
272+ // far edge often peeks past the padded viewport on the first reveal,
273+ // and a "partially outside" check would re-fit on every expand. The
274+ // user's "no cabe visualmente" complaint is about findings that
275+ // appear completely off-screen after the user has panned away, which
276+ // the strict check captures.
277+ const filteredFitInitRef = useRef ( false ) ;
278+ const previousFilteredRef = useRef ( isFilteredView ) ;
279+ const previousExpandedRef = useRef < ReadonlySet < string > > ( expanded ) ;
280+ // Captured every render so the expand-fit effect can resolve a resource
281+ // ID to its connected finding IDs without recomputing the lookup.
282+ // The map itself is built later in this render — see assignment below
283+ // the layout-derivation block.
284+ const resourceToFindingsRef = useRef < Map < string , Set < string > > > ( new Map ( ) ) ;
285+
286+ useEffect ( ( ) => {
287+ if ( ! filteredFitInitRef . current ) {
288+ filteredFitInitRef . current = true ;
289+ previousFilteredRef . current = isFilteredView ;
290+ return ;
291+ }
292+ if ( previousFilteredRef . current === isFilteredView ) return ;
293+ previousFilteredRef . current = isFilteredView ;
294+ // React Flow measures node sizes asynchronously via ResizeObserver after
295+ // the data swap. A single rAF runs while measured.width is still 0, so
296+ // fitView computes a degenerate bbox and the viewport keeps the user's
297+ // previous zoom — most visibly when leaving a filtered view in which the
298+ // user had zoomed in. Poll until visible nodes are measured (or give up
299+ // after ~500ms so we never block on a stuck observer).
300+ return scheduleMeasuredFit (
301+ ( ) => {
302+ const visibleNodes = getNodes ( ) . filter ( ( n ) => ! n . hidden ) ;
303+ return (
304+ visibleNodes . length > 0 &&
305+ visibleNodes . every ( ( n ) => ( n . measured ?. width ?? 0 ) > 0 )
306+ ) ;
307+ } ,
308+ ( ) => fitView ( AUTO_FIT_OPTIONS ) ,
309+ ) ;
310+ } , [ isFilteredView , fitView , getNodes ] ) ;
311+
312+ useEffect ( ( ) => {
313+ const previous = previousExpandedRef . current ;
314+ previousExpandedRef . current = expanded ;
315+ if ( previous === expanded ) return ;
316+ const newResourceIds = Array . from ( expanded ) . filter (
317+ ( id ) => ! previous . has ( id ) ,
318+ ) ;
319+ // Only fit on growth — collapsing intentionally leaves the user's
320+ // current framing alone.
321+ if ( newResourceIds . length === 0 ) return ;
322+
323+ const newFindingIds = new Set < string > ( ) ;
324+ for ( const resourceId of newResourceIds ) {
325+ const findings = resourceToFindingsRef . current . get ( resourceId ) ;
326+ if ( ! findings ) continue ;
327+ findings . forEach ( ( id ) => newFindingIds . add ( id ) ) ;
328+ }
329+ if ( newFindingIds . size === 0 ) return ;
330+
331+ // Findings transition from hidden to visible on expand, and React Flow
332+ // measures them asynchronously. Poll before checking whether their full
333+ // bounding boxes sit entirely past a viewport edge; collapsing and
334+ // partially clipped findings preserve the user's current frame.
335+ return scheduleMeasuredFit (
336+ ( ) => {
337+ const targets = getNodes ( ) . filter ( ( n ) => newFindingIds . has ( n . id ) ) ;
338+ return (
339+ targets . length === newFindingIds . size &&
340+ targets . every ( ( n ) => ( n . measured ?. width ?? 0 ) > 0 )
341+ ) ;
342+ } ,
343+ ( ) => {
344+ const targets = getNodes ( ) . filter ( ( n ) => newFindingIds . has ( n . id ) ) ;
345+ const containerEl = containerRef . current ;
346+ if ( ! containerEl ) return ;
347+ const { width, height } = containerEl . getBoundingClientRect ( ) ;
348+ if ( width === 0 || height === 0 ) return ;
349+ const { x, y, zoom } = getViewport ( ) ;
350+ const minX = - x / zoom ;
351+ const minY = - y / zoom ;
352+ const maxX = minX + width / zoom ;
353+ const maxY = minY + height / zoom ;
354+ const anyOutside = targets . some ( ( node ) => {
355+ const nx = node . position . x ;
356+ const ny = node . position . y ;
357+ const nw = node . measured ?. width ?? 0 ;
358+ const nh = node . measured ?. height ?? 0 ;
359+ return nx + nw < minX || nx > maxX || ny + nh < minY || ny > maxY ;
360+ } ) ;
361+ if ( anyOutside ) fitView ( AUTO_FIT_OPTIONS ) ;
362+ } ,
363+ ) ;
364+ } , [ expanded , fitView , getNodes , getViewport ] ) ;
365+
215366 const nodes = effectiveData . nodes ?? [ ] ;
216367 const edges = effectiveData . edges ?? [ ] ;
217368
@@ -254,6 +405,9 @@ const GraphCanvas = ({
254405 }
255406 } ) ;
256407
408+ // Refresh the expand-fit effect's lookup with the latest mapping.
409+ resourceToFindingsRef . current = resourceToFindings ;
410+
257411 // Tier 1: compute which finding nodes are hidden (not expanded)
258412 const hiddenFindingIds = new Set < string > ( ) ;
259413 if ( ! isFilteredView ) {
@@ -321,13 +475,6 @@ const GraphCanvas = ({
321475 const handleNodeClick = ( _event : MouseEvent , node : Node ) => {
322476 const graphNode = ( node . data as { graphNode : GraphNode } ) . graphNode ;
323477
324- // Tier 1: clicking resource in full view toggles connected findings
325- if ( ! isFilteredView && ! isFindingNode ( graphNode . labels ) ) {
326- if ( resourcesWithFindings . has ( node . id ) ) {
327- onResourceToggle ?.( node . id ) ;
328- }
329- }
330-
331478 // Always fire parent callback (handles selection + Tier 2 filtered view)
332479 onNodeClick ?.( graphNode ) ;
333480 } ;
@@ -351,11 +498,15 @@ const GraphCanvas = ({
351498 onNodeMouseEnter = { handleNodeMouseEnter }
352499 onNodeMouseLeave = { handleNodeMouseLeave }
353500 fitView
354- fitViewOptions = { { padding : 0.2 } }
355- zoomOnScroll = { false }
501+ fitViewOptions = { { padding : 0.2 , includeHiddenNodes : true } }
502+ // Supported React Flow behavior: wheel over the graph zooms the
503+ // viewport. The surrounding UX avoids using node details as inline
504+ // content, so this no longer fights a below-graph details section.
505+ zoomOnScroll = { true }
356506 zoomOnPinch = { true }
357507 zoomOnDoubleClick = { false }
358508 panOnScroll = { false }
509+ preventScrolling = { true }
359510 minZoom = { 0.1 }
360511 maxZoom = { 10 }
361512 proOptions = { { hideAttribution : true } }
@@ -368,6 +519,7 @@ const GraphCanvas = ({
368519 bgColor = { minimapColors . bg }
369520 maskColor = { minimapColors . mask }
370521 maskStrokeColor = { minimapColors . maskStroke }
522+ maskStrokeWidth = { MINIMAP_VIEWPORT_STROKE_WIDTH }
371523 nodeColor = { ( node ) => {
372524 const graphNode = ( node . data as { graphNode ?: GraphNode } )
373525 . graphNode ;
@@ -394,7 +546,6 @@ export const AttackPathGraph = ({
394546 isFilteredView,
395547 initialNodeId,
396548 expandedResources,
397- onResourceToggle,
398549 onNodeClick,
399550 onInitialFilter,
400551 ref,
@@ -419,7 +570,6 @@ export const AttackPathGraph = ({
419570 isFilteredView = { isFilteredView }
420571 initialNodeId = { initialNodeId }
421572 expandedResources = { expandedResources }
422- onResourceToggle = { onResourceToggle }
423573 onNodeClick = { onNodeClick }
424574 onInitialFilter = { onInitialFilter }
425575 />
0 commit comments