Skip to content

Commit e50b7ec

Browse files
committed
Highlight only producers when clicking on "multiple producers" warning
1 parent ff44aff commit e50b7ec

2 files changed

Lines changed: 153 additions & 45 deletions

File tree

frontend/src/App.tsx

Lines changed: 128 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,14 @@ type RequiredInput = {
5454

5555
type ValidationWarning = {
5656
id: string;
57+
severity: "error" | "warning" | "info";
58+
category: "init" | "mapping" | "ownership" | "hard_dependency" | "cross_scale";
5759
title: string;
5860
detail: string;
5961
nodeId?: string;
62+
nodeIds?: string[];
6063
portId?: string;
64+
portIds?: string[];
6165
edgeId?: string;
6266
};
6367

@@ -108,6 +112,7 @@ export default function App() {
108112
const [layoutMode, setLayoutMode] = useState<LayoutMode>("data_flow");
109113
const [focusMode, setFocusMode] = useState<FocusMode>("neighborhood");
110114
const [edgeFilters, setEdgeFilters] = useState<EdgeFilters>(defaultEdgeFilters);
115+
const [pinnedFocus, setPinnedFocus] = useState<FocusState | null>(null);
111116
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<Node<RuntimeGraphNodeData>, Edge<GraphEdgeData>> | null>(null);
112117
const [nodes, setNodes, onNodesChange] = useNodesState<Node<RuntimeGraphNodeData>>([]);
113118
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge<GraphEdgeData>>([]);
@@ -119,13 +124,15 @@ export default function App() {
119124
const requiredInputPortIds = useMemo(() => deriveRequiredInputPorts(graph), [graph]);
120125
const requiredInputs = useMemo(() => deriveRequiredInputs(graph, requiredInputPortIds, incomingByPort), [graph, incomingByPort, requiredInputPortIds]);
121126
const warningItems = useMemo(() => deriveValidationWarnings(graph, requiredInputPortIds, incomingByPort), [graph, incomingByPort, requiredInputPortIds]);
127+
const actionableWarningItems = useMemo(() => warningItems.filter((item) => item.severity !== "info"), [warningItems]);
122128
const searchResults = useMemo(() => deriveSearchResults(graph, searchQuery), [graph, searchQuery]);
123129
const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => edgeMatchesFilters(edge, edgeFilters)), [edgeFilters, graph.edges]);
124130
const hoverHighlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]);
125-
const focus = useMemo(
131+
const traversalFocus = useMemo(
126132
() => deriveFocus(graph, selected?.id ?? null, activePort, focusMode),
127133
[activePort, focusMode, graph, selected?.id],
128134
);
135+
const focus = useMemo(() => pinnedFocus?.active ? pinnedFocus : traversalFocus, [pinnedFocus, traversalFocus]);
129136

130137
useEffect(() => {
131138
const nextNodes = graph.nodes.map((node) => ({
@@ -184,6 +191,7 @@ export default function App() {
184191
}, [edges, layoutMode, nodes, setNodes]);
185192

186193
const focusNode = useCallback((node: GraphNodeData, port?: GraphPort | null) => {
194+
setPinnedFocus(null);
187195
setSelected(node);
188196
setActivePort(port ?? null);
189197
const renderedNode = nodes.find((item) => item.id === node.id);
@@ -202,6 +210,55 @@ export default function App() {
202210
setEdgeFilters((current) => ({ ...current, [key]: !current[key] }));
203211
}, []);
204212

213+
const focusWarning = useCallback((warning: ValidationWarning) => {
214+
if (warning.portIds?.length) {
215+
const nextFocus = emptyFocusState();
216+
nextFocus.active = true;
217+
for (const portId of warning.portIds) {
218+
const target = portById.get(portId);
219+
if (!target) continue;
220+
nextFocus.ports.add(portId);
221+
nextFocus.nodes.add(target.node.id);
222+
}
223+
setPinnedFocus(nextFocus);
224+
const first = portById.get(warning.portIds[0]);
225+
if (first) {
226+
setSelected(null);
227+
setActivePort(null);
228+
if (flowInstance && warning.nodeIds && warning.nodeIds.length > 1) {
229+
flowInstance.fitView({
230+
nodes: warning.nodeIds.map((id) => ({ id })),
231+
padding: 0.28,
232+
duration: 520,
233+
maxZoom: 0.95,
234+
});
235+
} else {
236+
const renderedNode = nodes.find((item) => item.id === first.node.id);
237+
if (renderedNode && flowInstance) {
238+
flowInstance.setCenter(renderedNode.position.x + 156, renderedNode.position.y + 90, { zoom: 0.9, duration: 520 });
239+
}
240+
}
241+
}
242+
return;
243+
}
244+
245+
setPinnedFocus(null);
246+
if (warning.edgeId) {
247+
const edge = graph.edges.find((item) => item.id === warning.edgeId);
248+
if (edge) focusEdge(edge);
249+
return;
250+
}
251+
if (warning.portId) {
252+
const target = portById.get(warning.portId);
253+
if (target) focusNode(target.node, target.port);
254+
return;
255+
}
256+
if (warning.nodeId) {
257+
const node = nodeById.get(warning.nodeId);
258+
if (node) focusNode(node);
259+
}
260+
}, [flowInstance, focusEdge, focusNode, graph.edges, nodeById, nodes, portById]);
261+
205262
return (
206263
<main className="app-shell">
207264
<section className="graph-panel">
@@ -259,13 +316,13 @@ export default function App() {
259316
<CircleAlert size={14} /> {requiredInputs.length} init
260317
</button>
261318
)}
262-
{warningItems.length > 0 && (
319+
{actionableWarningItems.length > 0 && (
263320
<button
264321
className={`metric-button caution ${showWarningsPanel ? "active" : ""}`}
265-
title={`${warningItems.length} graph warnings`}
322+
title={`${actionableWarningItems.length} actionable graph warnings`}
266323
onClick={() => setShowWarningsPanel((open) => !open)}
267324
>
268-
<AlertTriangle size={14} /> {warningItems.length} warn
325+
<AlertTriangle size={14} /> {actionableWarningItems.length} warn
269326
</button>
270327
)}
271328
{graph.cyclic && <span className="warn"><AlertTriangle size={14} /> cycle</span>}
@@ -299,8 +356,8 @@ export default function App() {
299356
)}
300357

301358
{showWarningsPanel && (
302-
<FloatingPanel className="warnings-panel" title="Validation Warnings" subtitle={`${warningItems.length} checks`} onClose={() => setShowWarningsPanel(false)}>
303-
<WarningList warnings={warningItems} nodeById={nodeById} portById={portById} edgeById={new Map(graph.edges.map((edge) => [edge.id, edge]))} onFocusNode={focusNode} onFocusEdge={focusEdge} />
359+
<FloatingPanel className="warnings-panel" title="Validation Warnings" subtitle={`${actionableWarningItems.length} warnings, ${warningItems.length - actionableWarningItems.length} info`} onClose={() => setShowWarningsPanel(false)}>
360+
<WarningList warnings={warningItems} onFocusWarning={focusWarning} />
304361
</FloatingPanel>
305362
)}
306363

@@ -403,47 +460,34 @@ function RequiredInputList({ groups, onSelect, compact = false }: { groups: Map<
403460

404461
function WarningList({
405462
warnings,
406-
nodeById,
407-
portById,
408-
edgeById,
409-
onFocusNode,
410-
onFocusEdge,
463+
onFocusWarning,
411464
}: {
412465
warnings: ValidationWarning[];
413-
nodeById: Map<string, GraphNodeData>;
414-
portById: Map<string, { node: GraphNodeData; port: GraphPort }>;
415-
edgeById: Map<string, GraphEdgeData>;
416-
onFocusNode: (node: GraphNodeData, port?: GraphPort | null) => void;
417-
onFocusEdge: (edge: GraphEdgeData) => void;
466+
onFocusWarning: (warning: ValidationWarning) => void;
418467
}) {
419468
if (warnings.length === 0) return <div className="empty-state">No validation warnings.</div>;
469+
const grouped = groupValidationWarnings(warnings);
420470
return (
421471
<div className="warning-list">
422-
{warnings.map((warning) => (
423-
<button
424-
key={warning.id}
425-
className="warning-item"
426-
onClick={() => {
427-
if (warning.edgeId) {
428-
const edge = edgeById.get(warning.edgeId);
429-
if (edge) onFocusEdge(edge);
430-
return;
431-
}
432-
if (warning.portId) {
433-
const target = portById.get(warning.portId);
434-
if (target) onFocusNode(target.node, target.port);
435-
return;
436-
}
437-
if (warning.nodeId) {
438-
const node = nodeById.get(warning.nodeId);
439-
if (node) onFocusNode(node);
440-
}
441-
}}
442-
>
443-
<strong>{warning.title}</strong>
444-
<span>{warning.detail}</span>
445-
</button>
446-
))}
472+
{(["error", "warning", "info"] as const).map((severity) => {
473+
const items = grouped.get(severity) ?? [];
474+
if (items.length === 0) return null;
475+
return (
476+
<section className="warning-group" key={severity}>
477+
<h4>{validationSeverityLabel(severity)} ({items.length})</h4>
478+
{items.map((warning) => (
479+
<button
480+
key={warning.id}
481+
className={`warning-item ${warning.severity} ${warning.category}`}
482+
onClick={() => onFocusWarning(warning)}
483+
>
484+
<strong>{warning.title}</strong>
485+
<span>{warning.detail}</span>
486+
</button>
487+
))}
488+
</section>
489+
);
490+
})}
447491
</div>
448492
);
449493
}
@@ -884,6 +928,8 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI
884928
if (requiredInputPortIds.has(port.id) && incoming.length > 0) {
885929
warnings.push({
886930
id: `required-with-edge:${port.id}`,
931+
severity: "error",
932+
category: "init",
887933
title: "Input marked init but connected",
888934
detail: `${node.scale}.${node.process}.${port.name} has incoming data-flow edges and should not be required.`,
889935
nodeId: node.id,
@@ -893,6 +939,8 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI
893939
if (port.mappingMode && requiredInputPortIds.has(port.id) && !port.previousTimeStep) {
894940
warnings.push({
895941
id: `unresolved-mapping:${port.id}`,
942+
severity: "warning",
943+
category: "mapping",
896944
title: "Mapped input has no producer",
897945
detail: `${node.scale}.${node.process}.${port.name} declares mapping metadata but no source output was found.`,
898946
nodeId: node.id,
@@ -905,29 +953,38 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI
905953
for (const [key, group] of outputs) {
906954
if (group.length <= 1) continue;
907955
const [scale, variable] = key.split(":");
956+
const producerLabels = group.map(({ node }) => `${node.scale}.${node.process}`).join(", ");
908957
warnings.push({
909958
id: `multiple-producers:${key}`,
959+
severity: "warning",
960+
category: "ownership",
910961
title: "Multiple producers",
911-
detail: `${scale}.${variable} is output by ${group.length} models; check whether ownership is intentional.`,
962+
detail: `${scale}.${variable} is output by ${group.length} models at the same scale: ${producerLabels}.`,
912963
nodeId: group[0].node.id,
964+
nodeIds: group.map(({ node }) => node.id),
913965
portId: group[0].port.id,
966+
portIds: group.map(({ port }) => port.id),
914967
});
915968
}
916969

917970
for (const edge of graph.edges) {
918971
if (edge.diagnostics.some((item) => item.includes("Forwarded to a hard dependency"))) {
919972
warnings.push({
920973
id: `hard-forward:${edge.id}`,
974+
severity: "info",
975+
category: "hard_dependency",
921976
title: "Hard input forwarding",
922-
detail: `${edge.targetVariable ?? "input"} is satisfied through the owning model status before a hard dependency call.`,
977+
detail: `${edge.targetVariable ?? "input"} is satisfied through the owning model status before a hard dependency call. This is expected for declared hard dependencies.`,
923978
edgeId: edge.id,
924979
});
925980
}
926981
if (edge.scaleRelation === "multiscale" && edge.kind !== "mapped_variable" && !isCallEdge(edge)) {
927982
warnings.push({
928983
id: `implicit-cross-scale:${edge.id}`,
929-
title: "Implicit cross-scale edge",
930-
detail: `${edge.sourceVariable ?? "source"} -> ${edge.targetVariable ?? "target"} crosses scales without a mapped-variable edge kind.`,
984+
severity: "info",
985+
category: "cross_scale",
986+
title: "Inferred cross-scale edge",
987+
detail: `${edge.sourceVariable ?? "source"} -> ${edge.targetVariable ?? "target"} crosses scales through graph inference rather than a direct mapped-variable edge.`,
931988
edgeId: edge.id,
932989
});
933990
}
@@ -936,6 +993,32 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI
936993
return warnings;
937994
}
938995

996+
function groupValidationWarnings(warnings: ValidationWarning[]) {
997+
const grouped = new Map<ValidationWarning["severity"], ValidationWarning[]>();
998+
for (const warning of warnings) {
999+
const group = grouped.get(warning.severity) ?? [];
1000+
group.push(warning);
1001+
grouped.set(warning.severity, group);
1002+
}
1003+
return grouped;
1004+
}
1005+
1006+
function validationSeverityLabel(severity: ValidationWarning["severity"]) {
1007+
if (severity === "error") return "Likely bugs";
1008+
if (severity === "warning") return "Review";
1009+
return "Information";
1010+
}
1011+
1012+
function mergeFocusStates(primary: FocusState, secondary: FocusState | null): FocusState {
1013+
if (!secondary?.active) return primary;
1014+
return {
1015+
active: primary.active || secondary.active,
1016+
edges: new Set([...primary.edges, ...secondary.edges]),
1017+
nodes: new Set([...primary.nodes, ...secondary.nodes]),
1018+
ports: new Set([...primary.ports, ...secondary.ports]),
1019+
};
1020+
}
1021+
9391022
function buildPortIndex(graph: DependencyGraphView) {
9401023
const index = new Map<string, { node: GraphNodeData; port: GraphPort }>();
9411024
for (const node of graph.nodes) {

frontend/src/styles.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,19 @@ h1 {
10221022
gap: 8px;
10231023
}
10241024

1025+
.warning-group {
1026+
display: grid;
1027+
gap: 7px;
1028+
}
1029+
1030+
.warning-group h4 {
1031+
margin: 6px 0 0;
1032+
color: var(--muted);
1033+
font-size: 11px;
1034+
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
1035+
font-weight: 700;
1036+
}
1037+
10251038
.warning-item,
10261039
.provenance-edge {
10271040
display: grid;
@@ -1038,6 +1051,18 @@ h1 {
10381051
cursor: pointer;
10391052
}
10401053

1054+
.warning-item.error {
1055+
border-color: rgba(191, 106, 84, 0.34);
1056+
border-left-color: var(--clay);
1057+
background: rgba(191, 106, 84, 0.08);
1058+
}
1059+
1060+
.warning-item.info {
1061+
border-color: rgba(127, 143, 115, 0.26);
1062+
border-left-color: var(--sage);
1063+
background: rgba(127, 143, 115, 0.08);
1064+
}
1065+
10411066
.provenance-edge {
10421067
border-color: rgba(183, 166, 150, 0.42);
10431068
border-left-color: var(--line-strong);

0 commit comments

Comments
 (0)