@@ -54,10 +54,14 @@ type RequiredInput = {
5454
5555type 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
404461function 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+
9391022function buildPortIndex ( graph : DependencyGraphView ) {
9401023 const index = new Map < string , { node : GraphNodeData ; port : GraphPort } > ( ) ;
9411024 for ( const node of graph . nodes ) {
0 commit comments