@@ -26,6 +26,7 @@ interface SimNode extends d3.SimulationNodeDatum {
2626 mastery_score : number ;
2727 mastery_tier : string ;
2828 subject : string ;
29+ course_color ?: string | null ;
2930 is_subject_root ?: boolean ;
3031}
3132
@@ -38,6 +39,12 @@ const ROOT_RADIUS = 22;
3839const getSimRadius = ( d : SimNode ) =>
3940 d . is_subject_root ? ROOT_RADIUS : getNodeRadius ( d . mastery_score ) ;
4041
42+ /** Resolve the color override for a node: prefer the node's own course_color,
43+ * then fall back to the courseColorMap lookup by subject name. */
44+ function nodeColorOverride ( d : SimNode , colorMap : Record < string , string > ) : string | undefined {
45+ return d . course_color ?? colorMap [ d . subject ] ?? undefined ;
46+ }
47+
4148/** Opacity encodes mastery tier — full colour = mastered, ghost = unexplored. */
4249function masteryOpacity ( tier : string ) : number {
4350 switch ( tier ) {
@@ -192,7 +199,7 @@ function KnowledgeGraph({
192199 . attr ( 'font-size' , d => d . is_subject_root ? '13px' : '11px' )
193200 . attr ( 'font-weight' , d => d . is_subject_root ? '600' : '400' )
194201 . attr ( 'font-family' , "'DM Sans', Inter, system-ui, sans-serif" )
195- . attr ( 'fill' , d => d . is_subject_root ? getCourseColor ( d . subject , courseColorMapRef . current [ d . subject ] ) . text : '#374151' )
202+ . attr ( 'fill' , d => d . is_subject_root ? getCourseColor ( d . subject , nodeColorOverride ( d , courseColorMapRef . current ) ) . text : '#374151' )
196203 . attr ( 'pointer-events' , 'none' )
197204 . style ( 'user-select' , 'none' ) ;
198205
@@ -224,9 +231,9 @@ function KnowledgeGraph({
224231 const circles = nodeSel . append ( 'circle' )
225232 . attr ( 'class' , 'main-circle' )
226233 . attr ( 'r' , d => getSimRadius ( d ) )
227- . attr ( 'fill' , d => getCourseColor ( d . subject , courseColorMapRef . current [ d . subject ] ) . fill )
234+ . attr ( 'fill' , d => getCourseColor ( d . subject , nodeColorOverride ( d , courseColorMapRef . current ) ) . fill )
228235 . attr ( 'fill-opacity' , d => masteryOpacity ( d . mastery_tier ) )
229- . attr ( 'stroke' , d => getCourseColor ( d . subject , courseColorMapRef . current [ d . subject ] ) . fill )
236+ . attr ( 'stroke' , d => getCourseColor ( d . subject , nodeColorOverride ( d , courseColorMapRef . current ) ) . fill )
230237 . attr ( 'stroke-opacity' , d => d . is_subject_root ? 0.7 : 0.4 )
231238 . attr ( 'stroke-width' , d => d . is_subject_root ? 2.5 : 1.5 ) ;
232239
@@ -263,7 +270,7 @@ function KnowledgeGraph({
263270 const lastStudied = sourceNode . last_studied_at
264271 ? new Date ( sourceNode . last_studied_at ) . toLocaleDateString ( )
265272 : 'Never' ;
266- const cc = getCourseColor ( sourceNode . subject , courseColorMapRef . current [ sourceNode . subject ] ) ;
273+ const cc = getCourseColor ( sourceNode . subject , sourceNode . course_color ?? courseColorMapRef . current [ sourceNode . subject ] ) ;
267274 tooltip . innerHTML = `
268275 <div style="font-weight:600;color:#111827;margin-bottom:4px">${ sourceNode . concept_name } </div>
269276 <div style="display:flex;align-items:center;gap:5px;margin-bottom:4px">
@@ -370,17 +377,25 @@ function KnowledgeGraph({
370377 const svg = d3 . select ( svgRef . current ) ;
371378 svg . selectAll < SVGCircleElement , SimNode > ( '.main-circle' )
372379 . attr ( 'fill' , d => {
373- const subj = nodeMap . get ( d . id ) ?. subject ?? d . subject ;
374- return getCourseColor ( subj , courseColorMap [ subj ] ) . fill ;
380+ const n = nodeMap . get ( d . id ) ;
381+ const subj = n ?. subject ?? d . subject ;
382+ const override = n ?. course_color ?? d . course_color ?? courseColorMap [ subj ] ;
383+ return getCourseColor ( subj , override ) . fill ;
375384 } )
376385 . attr ( 'fill-opacity' , d => masteryOpacity ( nodeMap . get ( d . id ) ?. mastery_tier ?? d . mastery_tier ) )
377386 . attr ( 'stroke' , d => {
378- const subj = nodeMap . get ( d . id ) ?. subject ?? d . subject ;
379- return getCourseColor ( subj , courseColorMap [ subj ] ) . fill ;
387+ const n = nodeMap . get ( d . id ) ;
388+ const subj = n ?. subject ?? d . subject ;
389+ const override = n ?. course_color ?? d . course_color ?? courseColorMap [ subj ] ;
390+ return getCourseColor ( subj , override ) . fill ;
380391 } ) ;
381392 svg . selectAll < SVGTextElement , SimNode > ( 'text' )
382393 . filter ( d => ! ! d . is_subject_root )
383- . attr ( 'fill' , d => getCourseColor ( d . subject , courseColorMap [ d . subject ] ) . text ) ;
394+ . attr ( 'fill' , d => {
395+ const n = nodeMap . get ( d . id ) ;
396+ const override = n ?. course_color ?? d . course_color ?? courseColorMap [ d . subject ] ;
397+ return getCourseColor ( d . subject , override ) . text ;
398+ } ) ;
384399 } , [ nodes , courseColorMap ] ) ;
385400
386401 // Separate lightweight effect: only add/remove the highlight ring.
0 commit comments