Skip to content

Commit b545981

Browse files
committed
Add a way to interactively open a cycle from the graph editor
1 parent 82c88e8 commit b545981

4 files changed

Lines changed: 302 additions & 6 deletions

File tree

frontend/src/App.tsx

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
7581
type 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+
11081212
function 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+
20972226
function deriveCandidatePortIds(
20982227
graph: DependencyGraphView,
20992228
models: ModelDescriptor[],

frontend/src/ModelNode.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
2-
import { Clock3, GitBranch, Layers3, Link2, PhoneCall, Plus, Trash2 } from "lucide-react";
2+
import { Clock3, GitBranch, Layers3, Link2, PhoneCall, Plus, ScissorsLineDashed, Trash2 } from "lucide-react";
33
import type { GraphPort, RuntimeGraphNodeData } from "./types";
44
import { nodeWidth } from "./nodeSizing";
55

@@ -107,12 +107,13 @@ function PortColumn({ title, ports, side, data }: { title: string; ports: GraphP
107107
const focused = new Set(data.focusedPortIds ?? []);
108108
const requiredInputs = new Set(data.requiredInputPortIds ?? []);
109109
const candidatePorts = new Set(data.candidatePortIds ?? []);
110+
const cycleBreakPorts = new Set(data.cycleBreakPortIds ?? []);
110111
return (
111112
<div className={`port-column ${side}`}>
112113
<div className="port-title">{title}</div>
113114
{ports.map((port) => (
114115
<div
115-
className={`port ${port.mappingMode ? "mapped" : ""} ${requiredInputs.has(port.id) ? "required-input" : ""} ${port.previousTimeStep ? "previous" : ""} ${focused.has(port.id) ? "focused" : ""} ${highlighted.has(port.id) ? "highlighted" : ""} ${data.activePortId === port.id ? "active" : ""}`}
116+
className={`port ${port.mappingMode ? "mapped" : ""} ${requiredInputs.has(port.id) ? "required-input" : ""} ${cycleBreakPorts.has(port.id) ? "cycle-break-target" : ""} ${port.previousTimeStep ? "previous" : ""} ${focused.has(port.id) ? "focused" : ""} ${highlighted.has(port.id) ? "highlighted" : ""} ${data.activePortId === port.id ? "active" : ""}`}
116117
key={port.id}
117118
data-default={`${requiredInputs.has(port.id) ? "Required initialization" : portValueLabel(port)}: ${port.default}`}
118119
aria-label={`${port.name}, ${side}, ${requiredInputs.has(port.id) ? "required initialization" : portValueLabel(port).toLowerCase()} ${port.default}`}
@@ -146,6 +147,30 @@ function PortColumn({ title, ports, side, data }: { title: string; ports: GraphP
146147
<Plus size={10} />
147148
</button>
148149
)}
150+
{side === "input" && data.cycleBreakActive && cycleBreakPorts.has(port.id) && (
151+
<button
152+
className="port-cycle-break-button nodrag nopan"
153+
type="button"
154+
title="Use this input from the previous timestep to break the cycle"
155+
aria-label={`Break cycle at ${port.name}`}
156+
onPointerDown={(event) => {
157+
event.preventDefault();
158+
event.stopPropagation();
159+
}}
160+
onMouseDown={(event) => {
161+
event.preventDefault();
162+
event.stopPropagation();
163+
}}
164+
onClick={(event) => {
165+
event.preventDefault();
166+
event.stopPropagation();
167+
data.onPortEnter?.(port);
168+
data.onCycleBreakClick?.(port);
169+
}}
170+
>
171+
<ScissorsLineDashed size={11} />
172+
</button>
173+
)}
149174
{port.mappingMode && <Link2 size={12} />}
150175
{side === "output" && <Handle id={port.id} type="source" position={Position.Right} />}
151176
</div>

0 commit comments

Comments
 (0)