Skip to content

Commit 24b16b5

Browse files
committed
Add view filtering with scales
1 parent fee44cc commit 24b16b5

2 files changed

Lines changed: 207 additions & 6 deletions

File tree

frontend/src/App.tsx

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ export default function App() {
112112
const [layoutMode, setLayoutMode] = useState<LayoutMode>("data_flow");
113113
const [focusMode, setFocusMode] = useState<FocusMode>("neighborhood");
114114
const [edgeFilters, setEdgeFilters] = useState<EdgeFilters>(defaultEdgeFilters);
115+
const [collapsedScales, setCollapsedScales] = useState<Set<string>>(() => new Set());
115116
const [pinnedFocus, setPinnedFocus] = useState<FocusState | null>(null);
117+
const [selectedEdge, setSelectedEdge] = useState<GraphEdgeData | null>(null);
116118
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<Node<RuntimeGraphNodeData>, Edge<GraphEdgeData>> | null>(null);
117119
const [nodes, setNodes, onNodesChange] = useNodesState<Node<RuntimeGraphNodeData>>([]);
118120
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge<GraphEdgeData>>([]);
@@ -126,7 +128,13 @@ export default function App() {
126128
const warningItems = useMemo(() => deriveValidationWarnings(graph, requiredInputPortIds, incomingByPort), [graph, incomingByPort, requiredInputPortIds]);
127129
const actionableWarningItems = useMemo(() => warningItems.filter((item) => item.severity !== "info"), [warningItems]);
128130
const searchResults = useMemo(() => deriveSearchResults(graph, searchQuery), [graph, searchQuery]);
129-
const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => edgeMatchesFilters(edge, edgeFilters)), [edgeFilters, graph.edges]);
131+
const visibleNodeData = useMemo(() => graph.nodes.filter((node) => !collapsedScales.has(node.scale)), [collapsedScales, graph.nodes]);
132+
const visibleNodeIds = useMemo(() => new Set(visibleNodeData.map((node) => node.id)), [visibleNodeData]);
133+
const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => (
134+
edgeMatchesFilters(edge, edgeFilters) &&
135+
visibleNodeIds.has(edge.source) &&
136+
visibleNodeIds.has(edge.target)
137+
)), [edgeFilters, graph.edges, visibleNodeIds]);
130138
const hoverHighlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]);
131139
const traversalFocus = useMemo(
132140
() => deriveFocus(graph, selected?.id ?? null, activePort, focusMode),
@@ -135,7 +143,7 @@ export default function App() {
135143
const focus = useMemo(() => pinnedFocus?.active ? pinnedFocus : traversalFocus, [pinnedFocus, traversalFocus]);
136144

137145
useEffect(() => {
138-
const nextNodes = graph.nodes.map((node) => ({
146+
const nextNodes = visibleNodeData.map((node) => ({
139147
id: node.id,
140148
type: "model",
141149
position: { x: 0, y: 0 },
@@ -155,7 +163,7 @@ export default function App() {
155163
setNodes(layouted);
156164
setEdges(nextEdges);
157165
});
158-
}, [graph, layoutMode, requiredInputPortIds, setEdges, setNodes, visibleEdgeData]);
166+
}, [graph.cycleNodes, layoutMode, requiredInputPortIds, setEdges, setNodes, visibleEdgeData, visibleNodeData]);
159167

160168
useEffect(() => {
161169
const focusEdges = focus.active ? focus.edges : new Set<string>();
@@ -192,8 +200,15 @@ export default function App() {
192200

193201
const focusNode = useCallback((node: GraphNodeData, port?: GraphPort | null) => {
194202
setPinnedFocus(null);
203+
setSelectedEdge(null);
195204
setSelected(node);
196205
setActivePort(port ?? null);
206+
setCollapsedScales((current) => {
207+
if (!current.has(node.scale)) return current;
208+
const next = new Set(current);
209+
next.delete(node.scale);
210+
return next;
211+
});
197212
const renderedNode = nodes.find((item) => item.id === node.id);
198213
if (renderedNode && flowInstance) {
199214
flowInstance.setCenter(renderedNode.position.x + 156, renderedNode.position.y + 90, { zoom: 0.85, duration: 520 });
@@ -210,6 +225,21 @@ export default function App() {
210225
setEdgeFilters((current) => ({ ...current, [key]: !current[key] }));
211226
}, []);
212227

228+
const toggleScale = useCallback((scale: string) => {
229+
setSelected(null);
230+
setSelectedEdge(null);
231+
setActivePort(null);
232+
setPinnedFocus(null);
233+
setCollapsedScales((current) => {
234+
const next = new Set(current);
235+
if (next.has(scale)) next.delete(scale);
236+
else next.add(scale);
237+
return next;
238+
});
239+
}, []);
240+
241+
const expandAllScales = useCallback(() => setCollapsedScales(new Set()), []);
242+
213243
const focusWarning = useCallback((warning: ValidationWarning) => {
214244
if (warning.portIds?.length) {
215245
const nextFocus = emptyFocusState();
@@ -224,6 +254,7 @@ export default function App() {
224254
const first = portById.get(warning.portIds[0]);
225255
if (first) {
226256
setSelected(null);
257+
setSelectedEdge(null);
227258
setActivePort(null);
228259
if (flowInstance && warning.nodeIds && warning.nodeIds.length > 1) {
229260
flowInstance.fitView({
@@ -243,6 +274,7 @@ export default function App() {
243274
}
244275

245276
setPinnedFocus(null);
277+
setSelectedEdge(null);
246278
if (warning.edgeId) {
247279
const edge = graph.edges.find((item) => item.id === warning.edgeId);
248280
if (edge) focusEdge(edge);
@@ -305,7 +337,7 @@ export default function App() {
305337
</div>
306338

307339
<div className="metrics">
308-
<span>{graph.nodes.length} models</span>
340+
<span>{visibleNodeData.length}/{graph.nodes.length} models</span>
309341
<span>{visibleEdgeData.length}/{graph.edges.length} links</span>
310342
{requiredInputs.length > 0 && (
311343
<button
@@ -348,6 +380,7 @@ export default function App() {
348380
</div>
349381

350382
<RelationshipLegend filters={edgeFilters} onToggle={toggleEdgeFilter} />
383+
<ScaleControls scales={graph.scales} collapsedScales={collapsedScales} onToggle={toggleScale} onExpandAll={expandAllScales} />
351384

352385
{showRequiredPanel && (
353386
<FloatingPanel className="required-panel" title="Required Initializations" subtitle={`${requiredInputs.length} inputs`} onClose={() => setShowRequiredPanel(false)}>
@@ -371,7 +404,16 @@ export default function App() {
371404
onConnect={onConnect}
372405
onInit={setFlowInstance}
373406
onPaneClick={() => setShowSearchResults(false)}
407+
onEdgeClick={(_, edge) => {
408+
if (edge.data) {
409+
setSelectedEdge(edge.data);
410+
setSelected(null);
411+
setActivePort(null);
412+
setPinnedFocus(null);
413+
}
414+
}}
374415
onNodeClick={(_, node) => {
416+
setSelectedEdge(null);
375417
setSelected(node.data);
376418
}}
377419
fitView
@@ -392,6 +434,7 @@ export default function App() {
392434
</header>
393435
<InspectorDetails
394436
selected={selected}
437+
selectedEdge={selectedEdge}
395438
activePort={activePort}
396439
requiredInputPortIds={requiredInputPortIds}
397440
incomingEdges={activePort ? incomingByPort.get(activePort.id) ?? [] : []}
@@ -421,6 +464,36 @@ function RelationshipLegend({ filters, onToggle }: { filters: EdgeFilters; onTog
421464
);
422465
}
423466

467+
function ScaleControls({
468+
scales,
469+
collapsedScales,
470+
onToggle,
471+
onExpandAll,
472+
}: {
473+
scales: string[];
474+
collapsedScales: Set<string>;
475+
onToggle: (scale: string) => void;
476+
onExpandAll: () => void;
477+
}) {
478+
return (
479+
<div className="scale-controls">
480+
<div className="legend-title"><Network size={13} /> Scales</div>
481+
<div className="scale-list">
482+
{scales.map((scale) => {
483+
const collapsed = collapsedScales.has(scale);
484+
return (
485+
<button key={scale} className={collapsed ? "collapsed" : "active"} onClick={() => onToggle(scale)}>
486+
<span>{scale}</span>
487+
<small>{collapsed ? "collapsed" : "visible"}</small>
488+
</button>
489+
);
490+
})}
491+
</div>
492+
{collapsedScales.size > 0 && <button className="scale-reset" onClick={onExpandAll}>Show all scales</button>}
493+
</div>
494+
);
495+
}
496+
424497
function FloatingPanel({ className, title, subtitle, onClose, children }: { className: string; title: string; subtitle: string; onClose: () => void; children: ReactNode }) {
425498
return (
426499
<div className={`floating-panel ${className}`}>
@@ -494,6 +567,7 @@ function WarningList({
494567

495568
function InspectorDetails({
496569
selected,
570+
selectedEdge,
497571
activePort,
498572
requiredInputPortIds,
499573
incomingEdges,
@@ -503,6 +577,7 @@ function InspectorDetails({
503577
onFocusEdge,
504578
}: {
505579
selected: GraphNodeData | null;
580+
selectedEdge: GraphEdgeData | null;
506581
activePort: GraphPort | null;
507582
requiredInputPortIds: Set<string>;
508583
incomingEdges: GraphEdgeData[];
@@ -513,6 +588,9 @@ function InspectorDetails({
513588
}) {
514589
return (
515590
<>
591+
{selectedEdge && (
592+
<EdgeDetails edge={selectedEdge} nodeById={nodeById} portById={portById} />
593+
)}
516594
{selected ? (
517595
<div className="details">
518596
<Row label="Process" value={selected.process} />
@@ -528,9 +606,9 @@ function InspectorDetails({
528606
<div className="edit-suggestion" key={port.id}><ScissorsLineDashed size={14} /> {port.name} uses previous timestep</div>
529607
))}
530608
</div>
531-
) : (
609+
) : !selectedEdge ? (
532610
<div className="empty-state">Select a model node.</div>
533-
)}
611+
) : null}
534612

535613
<h3>Variable Provenance</h3>
536614
{activePort ? (
@@ -554,6 +632,38 @@ function InspectorDetails({
554632
);
555633
}
556634

635+
function EdgeDetails({
636+
edge,
637+
nodeById,
638+
portById,
639+
}: {
640+
edge: GraphEdgeData;
641+
nodeById: Map<string, GraphNodeData>;
642+
portById: Map<string, { node: GraphNodeData; port: GraphPort }>;
643+
}) {
644+
const source = nodeById.get(edge.source);
645+
const target = nodeById.get(edge.target);
646+
const sourcePort = edge.sourcePort ? portById.get(edge.sourcePort)?.port : null;
647+
const targetPort = edge.targetPort ? portById.get(edge.targetPort)?.port : null;
648+
return (
649+
<div className="edge-detail-card">
650+
<div className="variable-card-title">
651+
<span>{edgeKindLabel(edge)}</span>
652+
<small>{edge.scaleRelation}</small>
653+
</div>
654+
<Row label="Source" value={source ? `${source.scale}.${source.process}` : edge.source} />
655+
<Row label="Source var" value={sourcePort?.name ?? edge.sourceVariable ?? "model call"} />
656+
<Row label="Target" value={target ? `${target.scale}.${target.process}` : edge.target} />
657+
<Row label="Target var" value={targetPort?.name ?? edge.targetVariable ?? "model call"} />
658+
<Row label="Kind" value={edge.kind} />
659+
<Row label="Label" value={edge.label || "none"} />
660+
{edge.diagnostics.length > 0 ? edge.diagnostics.map((item) => (
661+
<div className="diagnostic" key={item}>{item}</div>
662+
)) : <div className="empty-state compact">No edge diagnostics.</div>}
663+
</div>
664+
);
665+
}
666+
557667
function EdgeList({
558668
title,
559669
edges,

frontend/src/styles.css

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,75 @@ h1 {
401401
border-style: dashed;
402402
}
403403

404+
.scale-controls {
405+
position: absolute;
406+
z-index: 35;
407+
top: 314px;
408+
left: 18px;
409+
display: grid;
410+
gap: 8px;
411+
width: 190px;
412+
padding: 10px;
413+
border: 1px solid var(--line);
414+
border-radius: 13px;
415+
background: rgba(255, 250, 242, 0.92);
416+
box-shadow: 0 18px 45px var(--shadow);
417+
backdrop-filter: blur(10px);
418+
}
419+
420+
.scale-list {
421+
display: grid;
422+
gap: 6px;
423+
}
424+
425+
.scale-list button,
426+
.scale-reset {
427+
display: grid;
428+
gap: 2px;
429+
width: 100%;
430+
padding: 7px 8px;
431+
border: 1px solid transparent;
432+
border-radius: 9px;
433+
background: transparent;
434+
color: var(--muted);
435+
font: inherit;
436+
text-align: left;
437+
cursor: pointer;
438+
}
439+
440+
.scale-list button.active {
441+
color: var(--ink);
442+
border-color: rgba(31, 122, 83, 0.22);
443+
background: var(--accent-soft);
444+
}
445+
446+
.scale-list button.collapsed {
447+
border-color: rgba(183, 166, 150, 0.34);
448+
border-style: dashed;
449+
background: rgba(183, 166, 150, 0.08);
450+
}
451+
452+
.scale-list span {
453+
overflow-wrap: anywhere;
454+
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
455+
font-size: 12px;
456+
font-weight: 700;
457+
}
458+
459+
.scale-list small {
460+
color: var(--muted);
461+
font-size: 10px;
462+
text-transform: uppercase;
463+
letter-spacing: 0.06em;
464+
}
465+
466+
.scale-reset {
467+
color: var(--accent);
468+
border-color: rgba(31, 122, 83, 0.2);
469+
background: rgba(31, 122, 83, 0.08);
470+
text-align: center;
471+
}
472+
404473
.floating-panel {
405474
position: absolute;
406475
z-index: 40;
@@ -886,6 +955,23 @@ h1 {
886955
padding: 10px;
887956
}
888957

958+
.edge-detail-card {
959+
display: grid;
960+
gap: 4px;
961+
margin-bottom: 14px;
962+
padding: 10px;
963+
border: 1px solid rgba(31, 122, 83, 0.24);
964+
border-radius: 12px;
965+
background:
966+
linear-gradient(135deg, rgba(31, 122, 83, 0.08), transparent 58%),
967+
rgba(255, 250, 242, 0.82);
968+
}
969+
970+
.edge-detail-card .diagnostic,
971+
.edge-detail-card .empty-state {
972+
margin-top: 6px;
973+
}
974+
889975
.variable-card .row:first-of-type {
890976
margin-top: 8px;
891977
}
@@ -1117,7 +1203,12 @@ h1 {
11171203
}
11181204

11191205
.relationship-legend,
1206+
.scale-controls,
11201207
.floating-panel {
11211208
top: 150px;
11221209
}
1210+
1211+
.scale-controls {
1212+
left: 220px;
1213+
}
11231214
}

0 commit comments

Comments
 (0)