@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
22import mermaid from 'mermaid' ;
33import { api } from '../api' ;
44import { DependencyGraph } from '../types' ;
5+ import { generateCategoryColorsUnique , baseHueForName } from '../lib/utils' ;
56import { Card } from './ui/card' ;
67import { RefreshCw } from 'lucide-react' ;
78import svgPanZoom from 'svg-pan-zoom' ;
@@ -58,6 +59,8 @@ export default function MermaidGraphView() {
5859 const todoNodeIds : string [ ] = [ ] ;
5960 const oneoffNodeIds : string [ ] = [ ] ;
6061 const controlNodeIds : string [ ] = [ ] ;
62+ // Keep category labels by node id for grouping/coloring
63+ const categoryLabels : Record < string , string > = { } ;
6164
6265 // Define nodes based on node_type from API
6366 for ( const n of graph . nodes ) {
@@ -69,6 +72,7 @@ export default function MermaidGraphView() {
6972 // Rounded rect for categories
7073 lines . push ( `${ nid } ("${ label } ")` ) ;
7174 categoryNodeIds . push ( nid ) ;
75+ categoryLabels [ nid ] = n . title ;
7276 break ;
7377 case 'oneoff' :
7478 // Stadium shape for one-offs
@@ -88,10 +92,52 @@ export default function MermaidGraphView() {
8892 }
8993 }
9094
91- // Styles
92- if ( categoryNodeIds . length > 0 ) {
93- lines . push ( 'classDef category fill:#10b981,stroke:#0b3b2e,color:#0b3b2e' ) ;
94- lines . push ( `class ${ categoryNodeIds . join ( ',' ) } category;` ) ;
95+ // Styles: build per-category color classes using node_category_map, with fallback
96+ const byCategory : Record < string , { categories : string [ ] ; todos : string [ ] } > = { } ;
97+ const categoryNameByNode : Record < string , string > = { } ;
98+ if ( graph . node_category_map ) {
99+ for ( const [ idStr , name ] of Object . entries ( graph . node_category_map ) ) {
100+ const nid = nodeIdTodo ( Number ( idStr ) ) ;
101+ categoryNameByNode [ nid ] = name || 'Uncategorised' ;
102+ }
103+ }
104+ // Build a quick reverse edge map to find nearest category parents for todos
105+ const edgesByTo : Record < string , string [ ] > = { } ;
106+ for ( const e of graph . edges ) {
107+ const fromId = nodeIdTodo ( e . from_node_id ) ;
108+ const toId = nodeIdTodo ( e . to_node_id ) ;
109+ ( edgesByTo [ toId ] ||= [ ] ) . push ( fromId ) ;
110+ }
111+ // Group categories by their own titles
112+ for ( const nid of categoryNodeIds ) {
113+ const name = categoryLabels [ nid ] || 'Uncategorised' ;
114+ ( byCategory [ name ] ||= { categories : [ ] , todos : [ ] } ) . categories . push ( nid ) ;
115+ }
116+ // Group todos by provided map or nearest category parent
117+ for ( const nid of todoNodeIds ) {
118+ let name = categoryNameByNode [ nid ] ;
119+ if ( ! name ) {
120+ const parents = edgesByTo [ nid ] || [ ] ;
121+ const catParent = parents . find ( p => categoryLabels [ p ] ) ;
122+ if ( catParent ) name = categoryLabels [ catParent ] ;
123+ }
124+ if ( ! name ) name = 'Uncategorised' ;
125+ ( byCategory [ name ] ||= { categories : [ ] , todos : [ ] } ) . todos . push ( nid ) ;
126+ }
127+ // Define classDefs and assign classes
128+ const usedHues : number [ ] = [ ] ;
129+ // Sort names by their base hue to keep palette stable across renders
130+ const entries = Object . entries ( byCategory ) . sort ( ( a , b ) => baseHueForName ( a [ 0 ] ) - baseHueForName ( b [ 0 ] ) ) ;
131+ for ( const [ name , group ] of entries ) {
132+ const { hue, ...colors } = generateCategoryColorsUnique ( name , usedHues , 26 ) ;
133+ usedHues . push ( hue ) ;
134+ const safe = name . replace ( / [ ^ a - z A - Z 0 - 9 _ ] / g, '_' ) ;
135+ const catClass = `cat_${ safe } ` ;
136+ const todoClass = `todo_${ safe } ` ;
137+ lines . push ( `classDef ${ catClass } fill:${ colors . category . fill } ,stroke:${ colors . category . stroke } ,color:${ colors . category . text } ` ) ;
138+ lines . push ( `classDef ${ todoClass } fill:${ colors . todo . fill } ,stroke:${ colors . todo . stroke } ,color:${ colors . todo . text } ` ) ;
139+ if ( group . categories . length > 0 ) lines . push ( `class ${ group . categories . join ( ',' ) } ${ catClass } ;` ) ;
140+ if ( group . todos . length > 0 ) lines . push ( `class ${ group . todos . join ( ',' ) } ${ todoClass } ;` ) ;
95141 }
96142 if ( oneoffNodeIds . length > 0 ) {
97143 lines . push ( 'classDef oneoff fill:#f59e0b,stroke:#7c3d00,color:#111' ) ;
0 commit comments