@@ -72,6 +72,12 @@ type RequiredInput = {
7272 reason : "previous_time_step" | "mapped_unresolved" | "user_initialization" ;
7373} ;
7474
75+ type CycleBreakOption = {
76+ edge : GraphEdgeData ;
77+ node : GraphNodeData ;
78+ port : GraphPort ;
79+ } ;
80+
7581type ValidationWarning = {
7682 id : string ;
7783 severity : "error" | "warning" | "info" ;
@@ -160,6 +166,7 @@ export default function App() {
160166 const [ collapsedScales , setCollapsedScales ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
161167 const [ pinnedFocus , setPinnedFocus ] = useState < FocusState | null > ( null ) ;
162168 const [ selectedEdge , setSelectedEdge ] = useState < GraphEdgeData | null > ( null ) ;
169+ const [ cycleBreakMode , setCycleBreakMode ] = useState ( false ) ;
163170 const [ candidatePopover , setCandidatePopover ] = useState < CandidatePopover | null > ( null ) ;
164171 const [ addModelSelection , setAddModelSelection ] = useState < AddModelSelection | null > ( null ) ;
165172 const [ addModelFocusRequest , setAddModelFocusRequest ] = useState ( 0 ) ;
@@ -191,6 +198,8 @@ export default function App() {
191198 visibleNodeIds . has ( edge . source ) &&
192199 visibleNodeIds . has ( edge . target )
193200 ) ) , [ edgeFilters , graph . edges , visibleNodeIds ] ) ;
201+ const cycleBreakOptions = useMemo ( ( ) => deriveCycleBreakOptions ( graph , nodeById , portById ) , [ graph , nodeById , portById ] ) ;
202+ const cycleBreakPortIds = useMemo ( ( ) => new Set ( cycleBreakOptions . map ( ( option ) => option . port . id ) ) , [ cycleBreakOptions ] ) ;
194203 const hoverHighlight = useMemo ( ( ) => deriveHighlight ( graph , activePort ) , [ activePort , graph ] ) ;
195204 const traversalFocus = useMemo (
196205 ( ) => deriveFocus ( graph , selected ?. id ?? null , activePort , focusMode ) ,
@@ -270,6 +279,23 @@ export default function App() {
270279 editorSocket . send ( JSON . stringify ( command ) ) ;
271280 } , [ editorSocket ] ) ;
272281
282+ const breakCycleAtPort = useCallback ( ( port : GraphPort ) => {
283+ const target = portById . get ( port . id ) ;
284+ if ( ! target || port . role !== "input" ) return ;
285+ sendEditorCommand ( {
286+ action : "edit" ,
287+ kind : "mark_previous_timestep" ,
288+ scale : target . node . scale ,
289+ process : target . node . process ,
290+ variable : port . name ,
291+ } ) ;
292+ setCycleBreakMode ( false ) ;
293+ setPinnedFocus ( null ) ;
294+ setSelectedEdge ( null ) ;
295+ setSelected ( target . node ) ;
296+ setActivePort ( port ) ;
297+ } , [ portById , sendEditorCommand ] ) ;
298+
273299 const removeGraphModel = useCallback ( ( node : GraphNodeData ) => {
274300 const target = removableMappingNode ( node , nodeById ) ;
275301 if ( ! target ) {
@@ -327,11 +353,14 @@ export default function App() {
327353 requiredInputPortIds,
328354 candidatePortIds,
329355 cycleNodeIds : new Set ( graph . cycleNodes ) ,
356+ cycleBreakPortIds,
357+ cycleBreakMode,
330358 focusedNodeIds : new Set < string > ( ) ,
331359 hasActiveFocus : false ,
332360 activeCandidatePortId,
333361 setActivePort,
334362 setCandidatePopover : toggleCandidatePopover ,
363+ breakCycleAtPort,
335364 removeGraphModel,
336365 viewMode,
337366 } ) ,
@@ -341,7 +370,7 @@ export default function App() {
341370 setNodes ( layouted ) ;
342371 setEdges ( nextEdges ) ;
343372 } ) ;
344- } , [ activeCandidatePortId , candidatePortIds , graph . cycleNodes , layoutMode , removeGraphModel , requiredInputPortIds , setEdges , setNodes , toggleCandidatePopover , viewMode , visibleEdgeData , visibleNodeData ] ) ;
373+ } , [ activeCandidatePortId , breakCycleAtPort , candidatePortIds , cycleBreakMode , cycleBreakPortIds , graph . cycleNodes , layoutMode , removeGraphModel , requiredInputPortIds , setEdges , setNodes , toggleCandidatePopover , viewMode , visibleEdgeData , visibleNodeData ] ) ;
345374
346375 useEffect ( ( ) => {
347376 const focusEdges = focus . active ? focus . edges : new Set < string > ( ) ;
@@ -354,17 +383,20 @@ export default function App() {
354383 requiredInputPortIds,
355384 candidatePortIds,
356385 cycleNodeIds : new Set ( graph . cycleNodes ) ,
386+ cycleBreakPortIds,
387+ cycleBreakMode,
357388 focusedNodeIds : focus . nodes ,
358389 hasActiveFocus : focus . active ,
359390 activeCandidatePortId,
360391 setActivePort,
361392 setCandidatePopover : toggleCandidatePopover ,
393+ breakCycleAtPort,
362394 removeGraphModel,
363395 viewMode,
364396 } ) ,
365397 } ) ) ) ;
366398 setEdges ( ( current ) => current . map ( ( edge ) => edge . data ? flowEdge ( edge . data , hoverHighlight . edges , focusEdges , Boolean ( activePort ) , focus . active ) : edge ) ) ;
367- } , [ activeCandidatePortId , activePort , candidatePortIds , focus , graph . cycleNodes , hoverHighlight . edges , hoverHighlight . ports , removeGraphModel , requiredInputPortIds , setEdges , setNodes , toggleCandidatePopover , viewMode ] ) ;
399+ } , [ activeCandidatePortId , activePort , breakCycleAtPort , candidatePortIds , cycleBreakMode , cycleBreakPortIds , focus , graph . cycleNodes , hoverHighlight . edges , hoverHighlight . ports , removeGraphModel , requiredInputPortIds , setEdges , setNodes , toggleCandidatePopover , viewMode ] ) ;
368400
369401 useEffect ( ( ) => {
370402 if ( candidatePopover && ! candidatePortIds . has ( candidatePopover . portId ) ) setCandidatePopover ( null ) ;
@@ -415,6 +447,42 @@ export default function App() {
415447 if ( node ) focusNode ( node , port ?? null ) ;
416448 } , [ focusNode , nodeById , portById ] ) ;
417449
450+ const chooseCycleBreakPoint = useCallback ( ( ) => {
451+ setCycleBreakMode ( true ) ;
452+ setViewModeTouched ( true ) ;
453+ setViewMode ( "detail" ) ;
454+ setActivePanel ( "inspector" ) ;
455+ setSelected ( null ) ;
456+ setSelectedEdge ( null ) ;
457+ setCandidatePopover ( null ) ;
458+ setActivePort ( null ) ;
459+
460+ const nextFocus = emptyFocusState ( ) ;
461+ nextFocus . active = true ;
462+ for ( const option of cycleBreakOptions ) {
463+ nextFocus . edges . add ( option . edge . id ) ;
464+ nextFocus . nodes . add ( option . edge . source ) ;
465+ nextFocus . nodes . add ( option . edge . target ) ;
466+ if ( option . edge . sourcePort ) nextFocus . ports . add ( option . edge . sourcePort ) ;
467+ nextFocus . ports . add ( option . port . id ) ;
468+ }
469+ setPinnedFocus ( nextFocus ) ;
470+
471+ if ( flowInstance && cycleBreakOptions . length > 0 ) {
472+ const nodeIds = [ ...new Set ( cycleBreakOptions . flatMap ( ( option ) => [ option . edge . source , option . edge . target ] ) ) ] ;
473+ flowInstance . fitView ( {
474+ nodes : nodeIds . map ( ( id ) => ( { id } ) ) ,
475+ padding : 0.36 ,
476+ duration : 520 ,
477+ maxZoom : 1.05 ,
478+ } ) ;
479+ }
480+ } , [ cycleBreakOptions , flowInstance ] ) ;
481+
482+ useEffect ( ( ) => {
483+ if ( ! graph . cyclic ) setCycleBreakMode ( false ) ;
484+ } , [ graph . cyclic ] ) ;
485+
418486 const toggleEdgeFilter = useCallback ( ( key : EdgeFilterKey ) => {
419487 setEdgeFilters ( ( current ) => ( { ...current , [ key ] : ! current [ key ] } ) ) ;
420488 } , [ ] ) ;
@@ -486,7 +554,7 @@ export default function App() {
486554 } , [ flowInstance , focusEdge , focusNode , graph . edges , nodeById , nodes , portById ] ) ;
487555
488556 return (
489- < main className = { `app-shell ${ viewMode === "overview" ? "overview-mode" : "detail-mode" } ${ candidatePopover ? "has-candidate-popover" : "" } ` } >
557+ < main className = { `app-shell ${ viewMode === "overview" ? "overview-mode" : "detail-mode" } ${ candidatePopover ? "has-candidate-popover" : "" } ${ cycleBreakMode ? "cycle-break-mode" : "" } ` } >
490558 < section className = "graph-panel" >
491559 < div className = "topbar graph-workbench" >
492560 < button
@@ -636,6 +704,15 @@ export default function App() {
636704 </ div >
637705 ) }
638706
707+ { graph . cyclic && (
708+ < CycleBreakPrompt
709+ active = { cycleBreakMode }
710+ optionCount = { cycleBreakOptions . length }
711+ editorConnected = { editorConnected }
712+ onChoose = { chooseCycleBreakPoint }
713+ />
714+ ) }
715+
639716 { showRelationshipsPanel && < RelationshipLegend filters = { edgeFilters } onToggle = { toggleEdgeFilter } /> }
640717 { showScalesPanel && < ScaleControls scales = { graph . scales } collapsedScales = { collapsedScales } onToggle = { toggleScale } onExpandAll = { expandAllScales } /> }
641718
@@ -1105,6 +1182,33 @@ function OpenMappingPanel({
11051182 ) ;
11061183}
11071184
1185+ function CycleBreakPrompt ( {
1186+ active,
1187+ optionCount,
1188+ editorConnected,
1189+ onChoose,
1190+ } : {
1191+ active : boolean ;
1192+ optionCount : number ;
1193+ editorConnected : boolean ;
1194+ onChoose : ( ) => void ;
1195+ } ) {
1196+ return (
1197+ < div className = { `cycle-break-prompt ${ active ? "active" : "" } ` } role = "status" aria-live = "polite" >
1198+ < div >
1199+ < strong > Cycle detected</ strong >
1200+ < span >
1201+ Choose which variable to decouple. The selected input will be wrapped in < code > PreviousTimeStep</ code > , so that model uses the value from the previous timestep and is disconnected from this current-step variable within a run.
1202+ </ span >
1203+ </ div >
1204+ < button className = "metric-button danger cycle-break-cta" disabled = { ! editorConnected || optionCount === 0 } onClick = { onChoose } >
1205+ < ScissorsLineDashed size = { 14 } />
1206+ { active ? "Choose a highlighted input" : "Choose break point in graph" }
1207+ </ button >
1208+ </ div >
1209+ ) ;
1210+ }
1211+
11081212function InspectorDetails ( {
11091213 selected,
11101214 selectedEdge,
@@ -1252,7 +1356,7 @@ function EdgeDetails({
12521356 variable : targetPort . name ,
12531357 } ) }
12541358 >
1255- < ScissorsLineDashed size = { 14 } /> Break cycle here
1359+ < ScissorsLineDashed size = { 14 } /> Use previous timestep for { targetPort . name }
12561360 </ button >
12571361 ) }
12581362 { edge . diagnostics . length > 0 ? edge . diagnostics . map ( ( item ) => (
@@ -2034,11 +2138,14 @@ function runtimeNodeData(
20342138 requiredInputPortIds : Set < string > ;
20352139 candidatePortIds : Set < string > ;
20362140 cycleNodeIds : Set < string > ;
2141+ cycleBreakPortIds : Set < string > ;
2142+ cycleBreakMode : boolean ;
20372143 focusedNodeIds : Set < string > ;
20382144 hasActiveFocus : boolean ;
20392145 activeCandidatePortId : string | null ;
20402146 setActivePort : ( port : GraphPort | null ) => void ;
20412147 setCandidatePopover : ( port : GraphPort , anchor : { x : number ; y : number } ) => void ;
2148+ breakCycleAtPort : ( port : GraphPort ) => void ;
20422149 removeGraphModel : ( node : GraphNodeData ) => void ;
20432150 viewMode : GraphViewMode ;
20442151 } ,
@@ -2052,13 +2159,16 @@ function runtimeNodeData(
20522159 focusedPortIds : [ ...options . focusedPortIds ] ,
20532160 requiredInputPortIds : [ ...options . requiredInputPortIds ] ,
20542161 candidatePortIds : [ ...options . candidatePortIds ] ,
2162+ cycleBreakPortIds : [ ...options . cycleBreakPortIds ] ,
2163+ cycleBreakActive : options . cycleBreakMode ,
20552164 focused : options . focusedNodeIds . has ( node . id ) ,
20562165 dimmed : options . hasActiveFocus && ! options . focusedNodeIds . has ( node . id ) ,
20572166 onPortEnter : options . setActivePort ,
20582167 onPortLeave : ( port ) => {
20592168 if ( options . activeCandidatePortId !== port . id ) options . setActivePort ( null ) ;
20602169 } ,
20612170 onCandidateClick : options . setCandidatePopover ,
2171+ onCycleBreakClick : options . breakCycleAtPort ,
20622172 onRemoveModel : options . removeGraphModel ,
20632173 } ;
20642174}
@@ -2094,6 +2204,25 @@ function deriveRequiredInputPorts(graph: DependencyGraphView) {
20942204 return required ;
20952205}
20962206
2207+ function deriveCycleBreakOptions (
2208+ graph : DependencyGraphView ,
2209+ nodeById : Map < string , GraphNodeData > ,
2210+ portById : Map < string , { node : GraphNodeData ; port : GraphPort } > ,
2211+ ) : CycleBreakOption [ ] {
2212+ const options : CycleBreakOption [ ] = [ ] ;
2213+ const seenPorts = new Set < string > ( ) ;
2214+ for ( const edge of graph . edges ) {
2215+ if ( ! isCycleEdge ( edge ) || ! edge . targetPort ) continue ;
2216+ const node = nodeById . get ( edge . target ) ;
2217+ const portInfo = portById . get ( edge . targetPort ) ;
2218+ if ( ! node || ! portInfo || portInfo . port . role !== "input" ) continue ;
2219+ if ( seenPorts . has ( portInfo . port . id ) ) continue ;
2220+ seenPorts . add ( portInfo . port . id ) ;
2221+ options . push ( { edge, node, port : portInfo . port } ) ;
2222+ }
2223+ return options ;
2224+ }
2225+
20972226function deriveCandidatePortIds (
20982227 graph : DependencyGraphView ,
20992228 models : ModelDescriptor [ ] ,
0 commit comments