Skip to content

Commit 82c88e8

Browse files
committed
Draw cycles for mono scale
1 parent 3cd60b5 commit 82c88e8

6 files changed

Lines changed: 196 additions & 11 deletions

File tree

frontend/src/App.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,7 +1141,13 @@ function InspectorDetails({
11411141
return (
11421142
<>
11431143
{selectedEdge && (
1144-
<EdgeDetails edge={selectedEdge} nodeById={nodeById} portById={portById} />
1144+
<EdgeDetails
1145+
edge={selectedEdge}
1146+
nodeById={nodeById}
1147+
portById={portById}
1148+
onCommand={onCommand}
1149+
editorConnected={editorConnected}
1150+
/>
11451151
)}
11461152
{selected ? (
11471153
<div className="details">
@@ -1208,17 +1214,22 @@ function EdgeDetails({
12081214
edge,
12091215
nodeById,
12101216
portById,
1217+
onCommand,
1218+
editorConnected,
12111219
}: {
12121220
edge: GraphEdgeData;
12131221
nodeById: Map<string, GraphNodeData>;
12141222
portById: Map<string, { node: GraphNodeData; port: GraphPort }>;
1223+
onCommand: (command: Record<string, unknown>) => void;
1224+
editorConnected: boolean;
12151225
}) {
12161226
const source = nodeById.get(edge.source);
12171227
const target = nodeById.get(edge.target);
12181228
const sourcePort = edge.sourcePort ? portById.get(edge.sourcePort)?.port : null;
12191229
const targetPort = edge.targetPort ? portById.get(edge.targetPort)?.port : null;
1230+
const breakable = isCycleEdge(edge) && target && targetPort && targetPort.role === "input";
12201231
return (
1221-
<div className="edge-detail-card">
1232+
<div className={`edge-detail-card ${isCycleEdge(edge) ? "cycle-edge-card" : ""}`}>
12221233
<div className="variable-card-title">
12231234
<span>{edgeKindLabel(edge)}</span>
12241235
<small>{edge.scaleRelation}</small>
@@ -1229,6 +1240,21 @@ function EdgeDetails({
12291240
<Row label="Target var" value={targetPort?.name ?? edge.targetVariable ?? "model call"} />
12301241
<Row label="Kind" value={edge.kind} />
12311242
<Row label="Label" value={edge.label || "none"} />
1243+
{breakable && (
1244+
<button
1245+
className="metric-button danger cycle-break-button"
1246+
disabled={!editorConnected}
1247+
onClick={() => onCommand({
1248+
action: "edit",
1249+
kind: "mark_previous_timestep",
1250+
scale: target.scale,
1251+
process: target.process,
1252+
variable: targetPort.name,
1253+
})}
1254+
>
1255+
<ScissorsLineDashed size={14} /> Break cycle here
1256+
</button>
1257+
)}
12321258
{edge.diagnostics.length > 0 ? edge.diagnostics.map((item) => (
12331259
<div className="diagnostic" key={item}>{item}</div>
12341260
)) : <div className="empty-state compact">No edge diagnostics.</div>}
@@ -2198,9 +2224,9 @@ function flowEdge(
21982224
targetHandle: edge.targetPort ?? (callEdge ? `${edge.target}:call-target` : undefined),
21992225
markerEnd: callEdge ? undefined : edgeMarker(edgeColor(edge, highlighted || focused)),
22002226
type: "dependency",
2201-
animated: !callEdge && edge.scaleRelation === "multiscale",
2202-
className: `${edge.kind} ${callEdge ? "call_edge" : "variable_edge"} ${edge.scaleRelation} ${focused ? "focused" : ""} ${highlighted ? "highlighted" : dimmed ? "dimmed" : ""}`,
2203-
style: edgeStyle(edgeColor(edge, highlighted || focused), highlighted || focused),
2227+
animated: !callEdge && (edge.scaleRelation === "multiscale" || isCycleEdge(edge)),
2228+
className: `${edge.kind} ${callEdge ? "call_edge" : "variable_edge"} ${edge.scaleRelation} ${isCycleEdge(edge) ? "cycle_edge" : ""} ${focused ? "focused" : ""} ${highlighted ? "highlighted" : dimmed ? "dimmed" : ""}`,
2229+
style: edgeStyle(edgeColor(edge, highlighted || focused), highlighted || focused || isCycleEdge(edge), isCycleEdge(edge)),
22042230
selected: highlighted || focused,
22052231
zIndex: highlighted ? 120 : focused ? 90 : callEdge ? 3 : 5,
22062232
data: { ...edge, highlighted, focused, dimmed },
@@ -2209,6 +2235,7 @@ function flowEdge(
22092235

22102236
function edgeColor(edge: GraphEdgeData, highlighted: boolean) {
22112237
if (highlighted) return edgeColors.accent;
2238+
if (isCycleEdge(edge)) return "#d3422f";
22122239
if (edge.kind === "hard_dependency") return edgeColors.hard;
22132240
if (edge.kind === "mapped_variable" || edge.scaleRelation === "multiscale") return edgeColors.mapped;
22142241
return edgeColors.base;
@@ -2225,10 +2252,10 @@ function edgeMarker(color: string) {
22252252
};
22262253
}
22272254

2228-
function edgeStyle(color: string, highlighted: boolean) {
2255+
function edgeStyle(color: string, highlighted: boolean, cycle = false) {
22292256
return {
22302257
stroke: color,
2231-
strokeWidth: highlighted ? 3 : 2.2,
2258+
strokeWidth: cycle ? 4 : highlighted ? 3 : 2.2,
22322259
};
22332260
}
22342261

@@ -2513,11 +2540,13 @@ function groupEdgesByPort(edges: GraphEdgeData[], side: "sourcePort" | "targetPo
25132540

25142541
function edgeMatchesFilters(edge: GraphEdgeData, filters: EdgeFilters) {
25152542
if (isCallEdge(edge)) return filters.callStack;
2543+
if (isCycleEdge(edge)) return filters.dataFlow;
25162544
if (edge.kind === "mapped_variable" || edge.scaleRelation === "multiscale") return filters.mapped;
25172545
return filters.dataFlow;
25182546
}
25192547

25202548
function edgeKindLabel(edge: GraphEdgeData) {
2549+
if (isCycleEdge(edge)) return "cycle dependency";
25212550
if (isCallEdge(edge)) return "call stack";
25222551
if (edge.kind === "mapped_variable") return "mapped variable";
25232552
if (edge.diagnostics.some((item) => item.includes("Forwarded to a hard dependency"))) return "hard input forwarding";
@@ -2529,6 +2558,10 @@ function isCallEdge(edge: GraphEdgeData) {
25292558
return edge.kind === "hard_dependency" && !edge.sourcePort && !edge.targetPort;
25302559
}
25312560

2561+
function isCycleEdge(edge: GraphEdgeData) {
2562+
return edge.kind === "cycle_dependency" || edge.diagnostics.some((item) => item.includes("Cycle edge"));
2563+
}
2564+
25322565
function emptyFocusState(): FocusState {
25332566
return {
25342567
active: false,

frontend/src/DependencyEdge.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ function EdgeTerminal({ className, x, y, side, color }: { className: string; x:
104104

105105
function terminalColor(data: GraphEdgeData, highlighted: boolean) {
106106
if (highlighted) return "#1f7a53";
107+
if (data.kind === "cycle_dependency" || data.diagnostics.some((item) => item.includes("Cycle edge"))) return "#d3422f";
107108
if (data.kind === "hard_dependency") return "#bf6a54";
108109
if (data.kind === "mapped_variable" || data.scaleRelation === "multiscale") return "#1f7a53";
109110
return "#b7a696";

frontend/src/styles.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,12 @@ h1 {
10241024
stroke: var(--clay);
10251025
}
10261026

1027+
.react-flow__edge.cycle_dependency path,
1028+
.react-flow__edge.cycle_edge path {
1029+
stroke: #d3422f;
1030+
filter: drop-shadow(0 0 5px rgba(211, 66, 47, 0.26));
1031+
}
1032+
10271033
.react-flow__edge.call_edge path {
10281034
stroke-width: 1.7;
10291035
stroke-dasharray: 3 6;
@@ -1097,6 +1103,13 @@ h1 {
10971103
background: rgba(255, 246, 240, 0.96);
10981104
}
10991105

1106+
.edge-chip.cycle_dependency,
1107+
.edge-chip.cycle_edge {
1108+
color: #7a2018;
1109+
border-color: rgba(211, 66, 47, 0.44);
1110+
background: rgba(255, 238, 233, 0.98);
1111+
}
1112+
11001113
.edge-chip small {
11011114
color: var(--muted);
11021115
font-size: 9px;
@@ -1188,6 +1201,18 @@ h1 {
11881201
opacity: 0.12;
11891202
}
11901203

1204+
.cycle-edge-card {
1205+
border-color: rgba(211, 66, 47, 0.34);
1206+
background:
1207+
linear-gradient(135deg, rgba(211, 66, 47, 0.1), transparent 58%),
1208+
rgba(255, 250, 242, 0.88);
1209+
}
1210+
1211+
.cycle-break-button {
1212+
justify-content: center;
1213+
margin: 8px 0 4px;
1214+
}
1215+
11911216
.inspector {
11921217
border-left: 1px solid var(--line);
11931218
background: rgba(255, 250, 242, 0.82);

frontend/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export type GraphEdgeData = {
4848
targetPort: string | null;
4949
sourceVariable: string | null;
5050
targetVariable: string | null;
51-
kind: "soft_dependency" | "mapped_variable" | "hard_dependency";
51+
kind: "soft_dependency" | "mapped_variable" | "hard_dependency" | "cycle_dependency";
5252
scaleRelation: "same_scale" | "multiscale";
5353
label: string;
5454
diagnostics: string[];

src/visualization/dependency_graph_view.jl

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ function graph_view(graph::DependencyGraph, context=nothing; diagnostics::Vector
245245
cyclic, cycle_vec = is_graph_cyclic(graph; warn=false)
246246
cycle_nodes = cyclic ? [_model_node_id(last(pair), process(first(pair))) for pair in cycle_vec] : String[]
247247
cycle_edges = cyclic ? _cycle_edge_ids(edges, cycle_nodes) : String[]
248+
edges = _mark_cycle_edges(edges, cycle_edges)
248249
scales = sort!(unique([node.scale for node in nodes]); by=string)
249250
return _dependency_graph_view(nodes, edges, scales, cyclic, cycle_nodes, cycle_edges, diagnostics)
250251
end
@@ -313,6 +314,33 @@ function _cycle_edge_ids(edges, cycle_nodes::Vector{String})
313314
return unique(ids)
314315
end
315316

317+
function _mark_cycle_edges(edges, cycle_edge_ids::Vector{String})
318+
isempty(cycle_edge_ids) && return edges
319+
cycle_edge_id_set = Set(cycle_edge_ids)
320+
return [
321+
edge.id in cycle_edge_id_set ? _cycle_edge(edge) : edge
322+
for edge in edges
323+
]
324+
end
325+
326+
function _cycle_edge(edge::GraphEdge)
327+
diagnostics = copy(edge.diagnostics)
328+
push!(diagnostics, "Cycle edge: select this edge to break the cycle at the target input with `PreviousTimeStep`.")
329+
return GraphEdge(
330+
edge.id,
331+
edge.source,
332+
edge.target,
333+
edge.source_port,
334+
edge.target_port,
335+
edge.source_variable,
336+
edge.target_variable,
337+
:cycle_dependency,
338+
edge.scale_relation,
339+
edge.label,
340+
unique(diagnostics),
341+
)
342+
end
343+
316344
function _push_edge!(edges, edge_ids::Set{String}, edge)
317345
edge.id in edge_ids && return edges
318346
push!(edges, edge)
@@ -945,10 +973,105 @@ function _graph_view_from_mapping_only(mapping::ModelMapping, diagnostics)
945973
))
946974
end
947975
end
976+
977+
edges = GraphEdge[]
978+
edge_ids = Set{String}()
979+
_add_inferred_mapping_edges!(edges, edge_ids, nodes)
980+
_add_spec_mapped_input_edges!(edges, edge_ids, nodes, mapping)
981+
948982
scales = sort!(unique([node.scale for node in nodes]); by=string)
949-
cyclic = any(occursin.("Cyclic", diagnostics))
950-
cycle_nodes = cyclic ? [node.id for node in nodes] : String[]
951-
return _dependency_graph_view(nodes, GraphEdge[], scales, cyclic, cycle_nodes, String[], diagnostics)
983+
detected_cycle, detected_cycle_nodes, cycle_edges = _cycle_from_rendered_edges(nodes, edges)
984+
cyclic = detected_cycle || any(occursin.("Cyclic", diagnostics))
985+
cycle_nodes = detected_cycle ? detected_cycle_nodes : (cyclic ? [node.id for node in nodes] : String[])
986+
edges = _mark_cycle_edges(edges, cycle_edges)
987+
return _dependency_graph_view(nodes, edges, scales, cyclic, cycle_nodes, cycle_edges, diagnostics)
988+
end
989+
990+
function _add_inferred_mapping_edges!(edges, edge_ids::Set{String}, nodes)
991+
outputs = Dict{Tuple{Symbol,Symbol},Vector{Tuple{GraphNode,GraphPort}}}()
992+
for node in nodes
993+
for output in node.outputs
994+
push!(get!(outputs, (node.scale, output.name), Tuple{GraphNode,GraphPort}[]), (node, output))
995+
end
996+
end
997+
998+
for node in nodes
999+
for input in node.inputs
1000+
for (producer_node, output) in get(outputs, (node.scale, input.name), Tuple{GraphNode,GraphPort}[])
1001+
producer_node.id == node.id && continue
1002+
edge = GraphEdge(
1003+
"edge:inferred:$(producer_node.id):$(output.id):$(node.id):$(input.id)",
1004+
producer_node.id,
1005+
node.id,
1006+
output.id,
1007+
input.id,
1008+
output.name,
1009+
input.name,
1010+
:soft_dependency,
1011+
:same_scale,
1012+
string(input.name),
1013+
["Inferred for visualization after dependency compilation failed."],
1014+
)
1015+
_push_edge!(edges, edge_ids, edge)
1016+
end
1017+
end
1018+
end
1019+
1020+
return edges
1021+
end
1022+
1023+
function _cycle_from_rendered_edges(nodes, edges)
1024+
node_ids = Set(node.id for node in nodes)
1025+
outgoing = Dict{String,Vector{GraphEdge}}(id => GraphEdge[] for id in node_ids)
1026+
for edge in edges
1027+
haskey(outgoing, edge.source) || continue
1028+
edge.target in node_ids || continue
1029+
push!(outgoing[edge.source], edge)
1030+
end
1031+
1032+
visited = Set{String}()
1033+
stack = Set{String}()
1034+
parent_node = Dict{String,String}()
1035+
parent_edge = Dict{String,String}()
1036+
1037+
function visit(node_id::String)
1038+
push!(visited, node_id)
1039+
push!(stack, node_id)
1040+
1041+
for edge in get(outgoing, node_id, GraphEdge[])
1042+
target = edge.target
1043+
if !(target in visited)
1044+
parent_node[target] = node_id
1045+
parent_edge[target] = edge.id
1046+
found, cycle_nodes, cycle_edges = visit(target)
1047+
found && return true, cycle_nodes, cycle_edges
1048+
elseif target in stack
1049+
cycle_nodes = String[target]
1050+
cycle_edges = String[edge.id]
1051+
cursor = node_id
1052+
while cursor != target && haskey(parent_node, cursor)
1053+
push!(cycle_nodes, cursor)
1054+
push!(cycle_edges, parent_edge[cursor])
1055+
cursor = parent_node[cursor]
1056+
end
1057+
push!(cycle_nodes, target)
1058+
reverse!(cycle_nodes)
1059+
reverse!(cycle_edges)
1060+
return true, cycle_nodes, unique(cycle_edges)
1061+
end
1062+
end
1063+
1064+
delete!(stack, node_id)
1065+
return false, String[], String[]
1066+
end
1067+
1068+
for node in nodes
1069+
node.id in visited && continue
1070+
found, cycle_nodes, cycle_edges = visit(node.id)
1071+
found && return true, cycle_nodes, cycle_edges
1072+
end
1073+
1074+
return false, String[], String[]
9521075
end
9531076

9541077
function _graph_node(node::AbstractDependencyNode, id::String, context, node_ids)

test/test-dependency-graph-view.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,5 +274,8 @@ PlantSimEngine.outputs_(::GraphViewHardChildModel) = (child_output=-Inf,)
274274
cyclic_view = graph_view(cyclic_mapping)
275275
@test cyclic_view.cyclic
276276
@test !isempty(cyclic_view.cycle_nodes)
277+
@test !isempty(cyclic_view.edges)
278+
@test !isempty(cyclic_view.cycle_edges)
279+
@test any(edge -> edge.kind == :cycle_dependency, cyclic_view.edges)
277280
@test occursin("Cyclic dependency detected", join(cyclic_view.diagnostics, "\n"))
278281
end

0 commit comments

Comments
 (0)