Skip to content

Commit 3cd60b5

Browse files
committed
Make an "Overview" mode for looking at the overall model (with a more compact view + less info on each. node)
1 parent 7729ea7 commit 3cd60b5

7 files changed

Lines changed: 291 additions & 24 deletions

File tree

frontend/src/App.tsx

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type EdgeFilterKey = "dataFlow" | "mapped" | "callStack";
3737
type EdgeFilters = Record<EdgeFilterKey, boolean>;
3838
type FocusMode = "none" | "upstream" | "downstream" | "neighborhood";
3939
type SidePanel = "inspector" | "add_model" | "initializations" | "mapping_code" | null;
40+
type GraphViewMode = "overview" | "detail";
4041

4142
type PendingMappingConnection = {
4243
sourceNode: GraphNodeData;
@@ -118,6 +119,7 @@ const layoutLabels: Record<LayoutMode, string> = {
118119
compact: "Compact",
119120
scale_grouped: "Scale grouped",
120121
call_stack: "Call stack",
122+
overview: "Overview",
121123
};
122124

123125
const valueTypeChoices = ["float", "integer", "boolean", "symbol", "string", "nothing", "julia"];
@@ -146,10 +148,14 @@ export default function App() {
146148
const [showRequiredPanel, setShowRequiredPanel] = useState(false);
147149
const [showWarningsPanel, setShowWarningsPanel] = useState(false);
148150
const [showOpenPanel, setShowOpenPanel] = useState(false);
151+
const [showRelationshipsPanel, setShowRelationshipsPanel] = useState(false);
152+
const [showScalesPanel, setShowScalesPanel] = useState(false);
149153
const [showSearchResults, setShowSearchResults] = useState(false);
150154
const [searchQuery, setSearchQuery] = useState("");
151155
const [layoutMode, setLayoutMode] = useState<LayoutMode>("data_flow");
152156
const [focusMode, setFocusMode] = useState<FocusMode>("neighborhood");
157+
const [viewMode, setViewMode] = useState<GraphViewMode>(() => defaultGraphViewMode(loadInitialGraph()));
158+
const [viewModeTouched, setViewModeTouched] = useState(false);
153159
const [edgeFilters, setEdgeFilters] = useState<EdgeFilters>(defaultEdgeFilters);
154160
const [collapsedScales, setCollapsedScales] = useState<Set<string>>(() => new Set());
155161
const [pinnedFocus, setPinnedFocus] = useState<FocusState | null>(null);
@@ -305,6 +311,10 @@ export default function App() {
305311
return () => window.clearTimeout(timeout);
306312
}, [activePanel, highlightAddModelPanel, addModelFocusRequest]);
307313

314+
useEffect(() => {
315+
if (!viewModeTouched) setViewMode(defaultGraphViewMode(graph));
316+
}, [graph, viewModeTouched]);
317+
308318
useEffect(() => {
309319
const nextNodes = visibleNodeData.map((node) => ({
310320
id: node.id,
@@ -323,14 +333,15 @@ export default function App() {
323333
setActivePort,
324334
setCandidatePopover: toggleCandidatePopover,
325335
removeGraphModel,
336+
viewMode,
326337
}),
327338
}));
328339
const nextEdges = visibleEdgeData.map((edge) => flowEdge(edge, new Set<string>(), new Set<string>(), false, false));
329-
layoutGraph(nextNodes, nextEdges, layoutMode).then((layouted) => {
340+
layoutGraph(nextNodes, nextEdges, effectiveLayoutMode(viewMode, layoutMode)).then((layouted) => {
330341
setNodes(layouted);
331342
setEdges(nextEdges);
332343
});
333-
}, [activeCandidatePortId, candidatePortIds, graph.cycleNodes, layoutMode, removeGraphModel, requiredInputPortIds, setEdges, setNodes, toggleCandidatePopover, visibleEdgeData, visibleNodeData]);
344+
}, [activeCandidatePortId, candidatePortIds, graph.cycleNodes, layoutMode, removeGraphModel, requiredInputPortIds, setEdges, setNodes, toggleCandidatePopover, viewMode, visibleEdgeData, visibleNodeData]);
334345

335346
useEffect(() => {
336347
const focusEdges = focus.active ? focus.edges : new Set<string>();
@@ -349,10 +360,11 @@ export default function App() {
349360
setActivePort,
350361
setCandidatePopover: toggleCandidatePopover,
351362
removeGraphModel,
363+
viewMode,
352364
}),
353365
})));
354366
setEdges((current) => current.map((edge) => edge.data ? flowEdge(edge.data, hoverHighlight.edges, focusEdges, Boolean(activePort), focus.active) : edge));
355-
}, [activeCandidatePortId, activePort, candidatePortIds, focus, graph.cycleNodes, hoverHighlight.edges, hoverHighlight.ports, removeGraphModel, requiredInputPortIds, setEdges, setNodes, toggleCandidatePopover]);
367+
}, [activeCandidatePortId, activePort, candidatePortIds, focus, graph.cycleNodes, hoverHighlight.edges, hoverHighlight.ports, removeGraphModel, requiredInputPortIds, setEdges, setNodes, toggleCandidatePopover, viewMode]);
356368

357369
useEffect(() => {
358370
if (candidatePopover && !candidatePortIds.has(candidatePopover.portId)) setCandidatePopover(null);
@@ -377,8 +389,8 @@ export default function App() {
377389
}, [editorConnected, portById]);
378390

379391
const relayout = useCallback(() => {
380-
layoutGraph(nodes, edges, layoutMode).then(setNodes);
381-
}, [edges, layoutMode, nodes, setNodes]);
392+
layoutGraph(nodes, edges, effectiveLayoutMode(viewMode, layoutMode)).then(setNodes);
393+
}, [edges, layoutMode, nodes, setNodes, viewMode]);
382394

383395
const focusNode = useCallback((node: GraphNodeData, port?: GraphPort | null) => {
384396
setPinnedFocus(null);
@@ -474,7 +486,7 @@ export default function App() {
474486
}, [flowInstance, focusEdge, focusNode, graph.edges, nodeById, nodes, portById]);
475487

476488
return (
477-
<main className={`app-shell ${candidatePopover ? "has-candidate-popover" : ""}`}>
489+
<main className={`app-shell ${viewMode === "overview" ? "overview-mode" : "detail-mode"} ${candidatePopover ? "has-candidate-popover" : ""}`}>
478490
<section className="graph-panel">
479491
<div className="topbar graph-workbench">
480492
<button
@@ -552,10 +564,22 @@ export default function App() {
552564
</div>
553565

554566
<div className="toolbar-group">
567+
<button
568+
className={`metric-button view-mode-button ${viewMode === "overview" ? "active overview-cta" : ""}`}
569+
onClick={() => {
570+
setViewModeTouched(true);
571+
setViewMode((mode) => mode === "overview" ? "detail" : "overview");
572+
}}
573+
title={viewMode === "overview" ? "Show full model inputs and outputs" : "Show compact cards for large graphs"}
574+
>
575+
{viewMode === "overview" ? "Overview Mode - Show Detailed View" : "Show Overview"}
576+
</button>
555577
<label className="select-control" title="Choose how the graph should be arranged">
556578
<Network size={14} />
557579
<select value={layoutMode} onChange={(event) => setLayoutMode(event.target.value as LayoutMode)}>
558-
{(Object.keys(layoutLabels) as LayoutMode[]).map((mode) => <option key={mode} value={mode}>{layoutLabels[mode]}</option>)}
580+
{(Object.keys(layoutLabels) as LayoutMode[])
581+
.filter((mode) => mode !== "overview")
582+
.map((mode) => <option key={mode} value={mode}>{layoutLabels[mode]}</option>)}
559583
</select>
560584
</label>
561585
<label className="select-control" title="Dim graph context around the current selection">
@@ -569,11 +593,32 @@ export default function App() {
569593
</button>
570594
</div>
571595

596+
<div className="toolbar-group graph-filter-buttons">
597+
<button
598+
className={`metric-button ${showRelationshipsPanel ? "active" : ""}`}
599+
onClick={() => setShowRelationshipsPanel((open) => !open)}
600+
title="Show relationship filters"
601+
>
602+
<Filter size={14} /> Relationships
603+
</button>
604+
<button
605+
className={`metric-button ${showScalesPanel ? "active" : ""}`}
606+
onClick={() => setShowScalesPanel((open) => !open)}
607+
title="Show scale visibility controls"
608+
>
609+
<Network size={14} /> Scales {collapsedScales.size > 0 ? `${graph.scales.length - collapsedScales.size}/${graph.scales.length}` : graph.scales.length}
610+
</button>
611+
</div>
612+
572613
<div className="toolbar-group panel-switch">
573614
<button className={`metric-button ${activePanel === "inspector" ? "active" : ""}`} onClick={() => togglePanel("inspector")}>Inspector</button>
574-
<button className={`metric-button ${activePanel === "add_model" ? "active" : ""}`} onClick={openAddModelPanel}>Add model</button>
575-
<button className={`metric-button ${activePanel === "initializations" ? "active" : ""}`} onClick={() => togglePanel("initializations")}>Initializations</button>
576-
<button className={`metric-button ${activePanel === "mapping_code" ? "active" : ""}`} onClick={() => togglePanel("mapping_code")}>Mapping code</button>
615+
{editorSocket && (
616+
<>
617+
<button className={`metric-button ${activePanel === "add_model" ? "active" : ""}`} onClick={openAddModelPanel}>Add model</button>
618+
<button className={`metric-button ${activePanel === "initializations" ? "active" : ""}`} onClick={() => togglePanel("initializations")}>Initializations</button>
619+
<button className={`metric-button ${activePanel === "mapping_code" ? "active" : ""}`} onClick={() => togglePanel("mapping_code")}>Mapping code</button>
620+
</>
621+
)}
577622
</div>
578623

579624
{editorSocket && (
@@ -591,8 +636,8 @@ export default function App() {
591636
</div>
592637
)}
593638

594-
<RelationshipLegend filters={edgeFilters} onToggle={toggleEdgeFilter} />
595-
<ScaleControls scales={graph.scales} collapsedScales={collapsedScales} onToggle={toggleScale} onExpandAll={expandAllScales} />
639+
{showRelationshipsPanel && <RelationshipLegend filters={edgeFilters} onToggle={toggleEdgeFilter} />}
640+
{showScalesPanel && <ScaleControls scales={graph.scales} collapsedScales={collapsedScales} onToggle={toggleScale} onExpandAll={expandAllScales} />}
596641

597642
{showRequiredPanel && (
598643
<FloatingPanel className="required-panel" title="Required Initializations" subtitle={`${requiredInputs.length} inputs`} onClose={() => setShowRequiredPanel(false)}>
@@ -631,6 +676,8 @@ export default function App() {
631676
setShowSearchResults(false);
632677
setCandidatePopover(null);
633678
setShowOpenPanel(false);
679+
setShowRelationshipsPanel(false);
680+
setShowScalesPanel(false);
634681
}}
635682
onEdgeClick={(_, edge) => {
636683
if (edge.data) {
@@ -647,7 +694,7 @@ export default function App() {
647694
setSelected(node.data);
648695
}}
649696
fitView
650-
fitViewOptions={{ padding: 0.08, minZoom: 0.03, maxZoom: 1 }}
697+
fitViewOptions={{ padding: viewMode === "overview" ? 0.14 : 0.08, minZoom: 0.03, maxZoom: viewMode === "overview" ? 1.25 : 1 }}
651698
minZoom={0.03}
652699
maxZoom={2}
653700
>
@@ -1938,6 +1985,14 @@ function loadInitialGraph() {
19381985
return fromWindow ?? sampleGraph;
19391986
}
19401987

1988+
function defaultGraphViewMode(graph: DependencyGraphView): GraphViewMode {
1989+
return graph.nodes.length > 45 || graph.edges.length > 110 ? "overview" : "detail";
1990+
}
1991+
1992+
function effectiveLayoutMode(viewMode: GraphViewMode, layoutMode: LayoutMode): LayoutMode {
1993+
return viewMode === "overview" ? "overview" : layoutMode === "overview" ? "data_flow" : layoutMode;
1994+
}
1995+
19411996
function loadEditorConfig() {
19421997
const embedded = document.getElementById("pse-editor-config");
19431998
if (!embedded?.textContent) return null;
@@ -1959,10 +2014,12 @@ function runtimeNodeData(
19592014
setActivePort: (port: GraphPort | null) => void;
19602015
setCandidatePopover: (port: GraphPort, anchor: { x: number; y: number }) => void;
19612016
removeGraphModel: (node: GraphNodeData) => void;
2017+
viewMode: GraphViewMode;
19622018
},
19632019
): RuntimeGraphNodeData {
19642020
return {
19652021
...node,
2022+
viewMode: options.viewMode,
19662023
cyclic: options.cycleNodeIds.has(node.id),
19672024
activePortId: options.activePort?.id ?? null,
19682025
highlightedPortIds: [...options.highlightedPortIds],

frontend/src/DependencyEdge.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,21 @@ export function DependencyEdge({
5252
{showChip && (
5353
<EdgeLabelRenderer>
5454
<EdgeTerminal
55-
className={`edge-terminal source ${data.kind} ${data.scaleRelation} ${highlighted ? "highlighted" : ""} ${dimmed ? "dimmed" : ""}`}
55+
className={`edge-terminal source ${data.kind} ${data.scaleRelation} ${highlighted ? "highlighted" : ""} ${data.focused ? "focused" : ""} ${dimmed ? "dimmed" : ""}`}
5656
x={sourceX}
5757
y={sourceY}
5858
side={sourcePosition}
5959
color={terminalColor(data, highlighted)}
6060
/>
6161
<EdgeTerminal
62-
className={`edge-terminal target ${data.kind} ${data.scaleRelation} ${highlighted ? "highlighted" : ""} ${dimmed ? "dimmed" : ""}`}
62+
className={`edge-terminal target ${data.kind} ${data.scaleRelation} ${highlighted ? "highlighted" : ""} ${data.focused ? "focused" : ""} ${dimmed ? "dimmed" : ""}`}
6363
x={targetX}
6464
y={targetY}
6565
side={targetPosition}
6666
color={terminalColor(data, highlighted)}
6767
/>
6868
<div
69-
className={`edge-chip ${data.kind} ${data.scaleRelation} ${highlighted ? "highlighted" : ""} ${dimmed ? "dimmed" : ""}`}
69+
className={`edge-chip ${data.kind} ${data.scaleRelation} ${highlighted ? "highlighted" : ""} ${data.focused ? "focused" : ""} ${dimmed ? "dimmed" : ""}`}
7070
style={{
7171
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY - 14}px)`,
7272
}}

frontend/src/ModelNode.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ export function ModelNode({ data, selected }: NodeProps<ModelFlowNode>) {
99
const cyclic = Boolean(data.cyclic);
1010
const dimmed = Boolean(data.dimmed);
1111
const focused = Boolean(data.focused);
12+
const overview = data.viewMode === "overview";
1213
return (
1314
<section
14-
className={`model-node ${data.role} ${cyclic ? "cyclic" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${dimmed ? "dimmed" : ""}`}
15+
className={`model-node ${data.role} ${overview ? "overview-node" : ""} ${cyclic ? "cyclic" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${dimmed ? "dimmed" : ""}`}
1516
data-scale={data.scale}
1617
style={{ width: nodeWidth(data) }}
1718
>
1819
<Handle className="call-handle call-target" id={`${data.id}:call-target`} type="target" position={Position.Left} />
1920
<Handle className="call-handle call-source" id={`${data.id}:call-source`} type="source" position={Position.Right} />
21+
{overview && <OverviewPortHandles inputs={data.inputs} outputs={data.outputs} />}
2022
{selected && data.onRemoveModel && (
2123
<button
2224
className="model-remove-button nodrag nopan"
@@ -38,6 +40,14 @@ export function ModelNode({ data, selected }: NodeProps<ModelFlowNode>) {
3840
</div>
3941
{data.role === "hard_dependency" ? <GitBranch size={18} /> : <Layers3 size={18} />}
4042
</header>
43+
{overview ? (
44+
<div className="overview-node-summary">
45+
<span>{data.scale}</span>
46+
<span>{data.inputs.length} in</span>
47+
<span>{data.outputs.length} out</span>
48+
</div>
49+
) : (
50+
<>
4151
<div className="node-meta">
4252
{data.role === "hard_dependency" && (
4353
<span className="meta-chip hard-chip" data-tooltip="Hard dependency: this model is called from its parent model run!, not independently scheduled." aria-label="Hard dependency called by parent model">
@@ -55,11 +65,43 @@ export function ModelNode({ data, selected }: NodeProps<ModelFlowNode>) {
5565
<PortColumn title="Inputs" ports={data.inputs} side="input" data={data} />
5666
<PortColumn title="Outputs" ports={data.outputs} side="output" data={data} />
5767
</div>
68+
</>
69+
)}
5870
{data.diagnostics.length > 0 && <div className="diagnostic">{data.diagnostics[0]}</div>}
5971
</section>
6072
);
6173
}
6274

75+
function OverviewPortHandles({ inputs, outputs }: { inputs: GraphPort[]; outputs: GraphPort[] }) {
76+
return (
77+
<div className="overview-port-handles" aria-hidden="true">
78+
{inputs.map((port, index) => (
79+
<Handle
80+
key={port.id}
81+
id={port.id}
82+
type="target"
83+
position={Position.Left}
84+
style={{ top: `${overviewHandlePosition(index, inputs.length)}%` }}
85+
/>
86+
))}
87+
{outputs.map((port, index) => (
88+
<Handle
89+
key={port.id}
90+
id={port.id}
91+
type="source"
92+
position={Position.Right}
93+
style={{ top: `${overviewHandlePosition(index, outputs.length)}%` }}
94+
/>
95+
))}
96+
</div>
97+
);
98+
}
99+
100+
function overviewHandlePosition(index: number, total: number) {
101+
if (total <= 1) return 50;
102+
return 24 + (index / (total - 1)) * 52;
103+
}
104+
63105
function PortColumn({ title, ports, side, data }: { title: string; ports: GraphPort[]; side: "input" | "output"; data: RuntimeGraphNodeData }) {
64106
const highlighted = new Set(data.highlightedPortIds ?? []);
65107
const focused = new Set(data.focusedPortIds ?? []);

frontend/src/layout.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { GraphEdgeData, GraphPort, RuntimeGraphNodeData } from "./types";
44
import { nodeWidth } from "./nodeSizing";
55

66
const elk = new ELK();
7-
export type LayoutMode = "data_flow" | "compact" | "scale_grouped" | "call_stack";
7+
export type LayoutMode = "data_flow" | "compact" | "scale_grouped" | "call_stack" | "overview";
88

99
export async function layoutGraph(nodes: Node<RuntimeGraphNodeData>[], edges: Edge<GraphEdgeData>[], mode: LayoutMode = "data_flow") {
1010
const layoutEdges = mode === "call_stack" ? edges.filter((edge) => isCallEdge(edge.data)) : edges;
@@ -34,7 +34,10 @@ export async function layoutGraph(nodes: Node<RuntimeGraphNodeData>[], edges: Ed
3434

3535
const result = await elk.layout(graph);
3636
const positions = new Map((result.children ?? []).map((child) => [child.id, { x: child.x ?? 0, y: child.y ?? 0 }]));
37-
const scaleOffsets = mode === "scale_grouped" ? scaleBandOffsets(nodes) : new Map<string, number>();
37+
const scaleOffsets =
38+
mode === "scale_grouped" ? scaleBandOffsets(nodes, 260) :
39+
mode === "overview" ? scaleBandOffsets(nodes, 130) :
40+
new Map<string, number>();
3841

3942
return nodes.map((node) => {
4043
const position = positions.get(node.id) ?? node.position;
@@ -61,6 +64,18 @@ function layoutOptions(mode: LayoutMode): Record<string, string> {
6164
};
6265
}
6366

67+
if (mode === "overview") {
68+
return {
69+
"elk.algorithm": "layered",
70+
"elk.direction": "RIGHT",
71+
"elk.spacing.nodeNode": "24",
72+
"elk.layered.spacing.nodeNodeBetweenLayers": "46",
73+
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
74+
"elk.layered.crossingMinimization.semiInteractive": "true",
75+
"elk.edgeRouting": "ORTHOGONAL",
76+
};
77+
}
78+
6479
if (mode === "call_stack") {
6580
return {
6681
"elk.algorithm": "layered",
@@ -83,16 +98,17 @@ function layoutOptions(mode: LayoutMode): Record<string, string> {
8398
};
8499
}
85100

86-
function scaleBandOffsets(nodes: Node<RuntimeGraphNodeData>[]) {
101+
function scaleBandOffsets(nodes: Node<RuntimeGraphNodeData>[], spacing: number) {
87102
const scales = [...new Set(nodes.map((node) => node.data.scale))].sort();
88-
return new Map(scales.map((scale, index) => [scale, index * 260]));
103+
return new Map(scales.map((scale, index) => [scale, index * spacing]));
89104
}
90105

91106
function isCallEdge(edge?: GraphEdgeData) {
92107
return edge?.kind === "hard_dependency" && !edge.sourcePort && !edge.targetPort;
93108
}
94109

95110
function nodeHeight(node: RuntimeGraphNodeData) {
111+
if (node.viewMode === "overview") return 108;
96112
return Math.max(160, 112 + Math.max(node.inputs.length, node.outputs.length) * 28);
97113
}
98114

frontend/src/nodeSizing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const PORT_HORIZONTAL_PADDING = 26;
88
const MONO_CHAR_WIDTH = 8.1;
99

1010
export function nodeWidth(node: RuntimeGraphNodeData) {
11+
if (node.viewMode === "overview") return 184;
1112
const longestInput = longestPortName(node.inputs);
1213
const longestOutput = longestPortName(node.outputs);
1314
const inputWidth = portColumnWidth(longestInput);

0 commit comments

Comments
 (0)