@@ -485,7 +485,8 @@ export function normalizeThicknessesRatios(
485485
486486
487487/**
488- * Normalizes edge thicknesses based on edge counts.
488+ * Normalizes edge thicknesses based on edge counts using improved scaling.
489+ * Uses square root scaling for better visual distribution when dealing with high values.
489490 * @param edgeCounts - The raw edge counts.
490491 * @param maxEdgeCount - The maximum edge count.
491492 * @param maxThickness - The maximum allowed thickness.
@@ -497,10 +498,21 @@ export function normalizeThicknesses(
497498 maxThickness : number
498499) : { [ key : string ] : number } {
499500 const normalized : { [ key : string ] : number } = { } ;
501+ const minThickness = 0.5 ; // Ensure edges are always visible
502+
503+ // Use square root scaling for better visual distribution with high values
504+ const maxSqrt = Math . sqrt ( maxEdgeCount ) ;
500505
501506 Object . keys ( edgeCounts ) . forEach ( ( edge ) => {
502507 const count = edgeCounts [ edge ] ;
503- normalized [ edge ] = ( count / maxEdgeCount ) * maxThickness ;
508+ // Apply square root scaling to compress the range
509+ const sqrtCount = Math . sqrt ( count ) ;
510+ let thickness = ( sqrtCount / maxSqrt ) * maxThickness ;
511+
512+ // Ensure minimum thickness for visibility
513+ thickness = Math . max ( thickness , minThickness ) ;
514+
515+ normalized [ edge ] = thickness ;
504516 } ) ;
505517
506518 return normalized ;
@@ -620,10 +632,12 @@ const createEdgeTooltip = (
620632) : string => {
621633 // Build basic statistics section with mode-appropriate labels
622634 const countLabel = uniqueStudentMode ? 'Unique Students on this path' : 'Total Visits on this path' ;
635+ const countValue = uniqueStudentMode ? edgeCount : visits ;
623636 let tooltip = `${ currentStep } to ${ nextStep } \n\n`
624637 + `Statistics:\n`
625638 + `- Total Students at ${ currentStep } : \n\t\t${ totalCount } \n`
626- + `- ${ countLabel } : \n\t\t${ edgeCount } \n`
639+ + `- ${ countLabel } : \n\t\t${ countValue } \n`
640+ + `- Unique Students on this path: \n\t\t${ edgeCount } \n`
627641 + `- Total Edge Visits: \n\t\t${ visits } \n` ;
628642
629643 // Add repeat visit analysis if data exists
@@ -719,11 +733,12 @@ const generateTopSequenceVisualization = (
719733 const firstAttempts = firstAttemptOutcomes [ edgeKey ] || { } ;
720734 const edgeCount = edgeCounts [ edgeKey ] || 0 ;
721735 const visits = totalVisits [ edgeKey ] || 0 ;
736+ const visitsForFiltering = uniqueStudentMode ? edgeCount : visits ;
722737 const totalCount = totalNodeEdges [ currentStep ] || 0 ;
723738 const edgeColor = calculateEdgeColors ( outcomes , errorMode ) ;
724739
725- // Only show edge if it meets minimum visit threshold
726- if ( visits >= minVisits ) {
740+ // Only show edge if it meets minimum visit threshold (using mode-appropriate count)
741+ if ( visitsForFiltering >= minVisits ) {
727742 const tooltip = createEdgeTooltip (
728743 currentStep , nextStep , edgeKey , edgeCount , totalCount , visits ,
729744 ratioEdges , outcomes , firstAttempts , repeatVisits , edgeColor , uniqueStudentMode
@@ -773,14 +788,34 @@ const generateFullGraphVisualization = (
773788 let dotContent = '' ;
774789 const totalSteps = selectedSequence . length ;
775790
776- // First, generate all nodes with coloring based on selected sequence
777- for ( let rank = 0 ; rank < totalSteps ; rank ++ ) {
778- const currentStep = selectedSequence [ rank ] ;
779- const color = calculateColor ( rank , totalSteps ) ;
780- const studentCount = totalNodeEdges [ currentStep ] || 0 ;
781- const nodeTooltip = createNodeTooltip ( rank , color , studentCount ) ;
791+ // First, collect all nodes that will appear in edges
792+ const allNodesInEdges = new Set < string > ( ) ;
793+ for ( const edgeKey of Object . keys ( normalizedThicknesses ) ) {
794+ const thickness = normalizedThicknesses [ edgeKey ] ;
795+ if ( thickness >= threshold ) {
796+ const edgeCount = edgeCounts [ edgeKey ] || 0 ;
797+ const visits = totalVisits [ edgeKey ] || 0 ;
798+ const visitsForFiltering = uniqueStudentMode ? edgeCount : visits ;
799+
800+ if ( visitsForFiltering >= minVisits ) {
801+ const [ currentStep , nextStep ] = edgeKey . split ( '->' ) ;
802+ if ( currentStep && nextStep ) {
803+ allNodesInEdges . add ( currentStep ) ;
804+ allNodesInEdges . add ( nextStep ) ;
805+ }
806+ }
807+ }
808+ }
782809
783- dotContent += ` "${ currentStep } " [rank=${ rank + 1 } , style=filled, fillcolor="${ color } ", tooltip="${ nodeTooltip } "];\n` ;
810+ // Generate all nodes that will appear, with coloring based on selected sequence
811+ for ( const nodeName of allNodesInEdges ) {
812+ const sequenceRank = selectedSequence . indexOf ( nodeName ) ;
813+ const color = sequenceRank >= 0 ? calculateColor ( sequenceRank , totalSteps ) : '#ffffff' ; // White for nodes not in sequence
814+ const rank = sequenceRank >= 0 ? sequenceRank + 1 : 0 ;
815+ const studentCount = totalNodeEdges [ nodeName ] || 0 ;
816+ const nodeTooltip = createNodeTooltip ( sequenceRank , color , studentCount ) ;
817+
818+ dotContent += ` "${ nodeName } " [rank=${ rank } , style=filled, fillcolor="${ color } ", tooltip="${ nodeTooltip } "];\n` ;
784819 }
785820
786821 // Then, generate all qualifying edges
@@ -794,11 +829,12 @@ const generateFullGraphVisualization = (
794829 const firstAttempts = firstAttemptOutcomes [ edgeKey ] || { } ;
795830 const edgeCount = edgeCounts [ edgeKey ] || 0 ;
796831 const visits = totalVisits [ edgeKey ] || 0 ;
832+ const visitsForFiltering = uniqueStudentMode ? edgeCount : visits ;
797833 const totalCount = totalNodeEdges [ currentStep ] || 0 ;
798834 const edgeColor = calculateEdgeColors ( outcomes , errorMode ) ;
799835
800- // Apply minimum visits filter
801- if ( visits >= minVisits ) {
836+ // Apply minimum visits filter (using mode-appropriate count)
837+ if ( visitsForFiltering >= minVisits ) {
802838 const tooltip = createEdgeTooltip (
803839 currentStep , nextStep , edgeKey , edgeCount , totalCount , visits ,
804840 ratioEdges , outcomes , firstAttempts , repeatVisits , edgeColor , uniqueStudentMode
@@ -867,17 +903,14 @@ export function generateDotString(
867903 const outcomesToUse = uniqueStudentMode ? firstAttemptOutcomes : edgeOutcomeCounts ;
868904
869905 console . log ( "generateDotString: Using unique student mode:" , uniqueStudentMode ) ;
870- console . log ( "generateDotString: Using visits data keys:" , Object . keys ( visitsToUse ) . length ) ;
871- console . log ( "generateDotString: Using outcomes data keys:" , Object . keys ( outcomesToUse ) . length ) ;
872- console . log ( "generateDotString: Sample visit counts:" , Object . entries ( visitsToUse ) . slice ( 0 , 3 ) ) ;
873- console . log ( "generateDotString: Sample normalized thicknesses:" , Object . entries ( normalizedThicknesses ) . slice ( 0 , 3 ) ) ;
874906 console . log ( "generateDotString: Min visits threshold:" , minVisits ) ;
907+ console . log ( "generateDotString: Thickness threshold:" , threshold ) ;
875908
876909 // Initialize DOT string with Graphviz header and configuration
877910 let dotString = 'digraph G {\ngraph [size="8,6!", dpi=150];\n' ;
878911
879912 // Use appropriate edge counts for tooltips and statistics based on mode
880- const edgeCountsToUse = uniqueStudentMode ? edgeCounts : visitsToUse ;
913+ const edgeCountsToUse = uniqueStudentMode ? edgeCounts : totalVisits ;
881914
882915 // Generate visualization content based on mode
883916 if ( justTopSequence ) {
@@ -1062,7 +1095,7 @@ function checkSequenceConnectivity(
10621095function checkNodePredecessors (
10631096 edges : Array < { edge : string , count : number } > ,
10641097 allNodes : Set < string > ,
1065- selectedSequence : string [ ]
1098+ _selectedSequence : string [ ]
10661099) : boolean {
10671100 // Build incoming edge map
10681101 const incomingEdges = new Map < string , Set < string > > ( ) ;
@@ -1072,31 +1105,51 @@ function checkNodePredecessors(
10721105 incomingEdges . set ( node , new Set ( ) ) ;
10731106 } ) ;
10741107
1075- // Track incoming edges for each node
1108+ // Track incoming edges for each node (excluding self-loops)
10761109 edges . forEach ( ( { edge } ) => {
10771110 const [ fromNode , toNode ] = edge . split ( '->' ) ;
1078- if ( fromNode && toNode ) {
1111+ if ( fromNode && toNode && fromNode !== toNode ) { // Skip self-loops
10791112 incomingEdges . get ( toNode ) ?. add ( fromNode ) ;
10801113 }
10811114 } ) ;
10821115
1083- // Get the first node in the selected sequence (this can be without predecessors)
1084- const firstSequenceNode = selectedSequence . length > 0 ? selectedSequence [ 0 ] : null ;
1116+ // Find all nodes that could be legitimate starting points
1117+ const nodesWithoutPredecessors = new Set < string > ( ) ;
1118+ const nodesWithPredecessors = new Set < string > ( ) ;
10851119
1086- // Check that all nodes (except potential first nodes) have at least one predecessor
10871120 for ( const node of allNodes ) {
10881121 const hasIncomingEdges = ( incomingEdges . get ( node ) ?. size ?? 0 ) > 0 ;
1089-
1090- // Allow nodes without predecessors only if they're the first in the selected sequence
1091- // or if they could be legitimate starting points
1092- if ( ! hasIncomingEdges && node !== firstSequenceNode ) {
1093- // Additional check: see if this node appears as first in any actual student path
1094- // For now, we'll be conservative and require all non-first-sequence nodes to have predecessors
1095- console . log ( `Node ${ node } has no predecessors and isn't the first in selected sequence` ) ;
1096- return false ;
1122+ if ( hasIncomingEdges ) {
1123+ nodesWithPredecessors . add ( node ) ;
1124+ } else {
1125+ nodesWithoutPredecessors . add ( node ) ;
10971126 }
10981127 }
10991128
1129+ // If we have some nodes with predecessors and some without, that's fine
1130+ // This allows for multiple entry points in the graph
1131+ // Only fail if ALL nodes lack predecessors (which would be unusual) or if we have
1132+ // too many isolated nodes (more than half the graph)
1133+ const totalNodes = allNodes . size ;
1134+ const isolatedNodes = nodesWithoutPredecessors . size ;
1135+
1136+ if ( totalNodes > 1 && isolatedNodes === totalNodes ) {
1137+ // All nodes are isolated - this suggests a problem with the threshold
1138+ console . log ( "All nodes have no predecessors - threshold may be too high" ) ;
1139+ return false ;
1140+ }
1141+
1142+ if ( totalNodes > 2 && isolatedNodes > totalNodes / 2 ) {
1143+ // More than half the nodes are isolated - likely too restrictive
1144+ console . log ( `Too many isolated nodes: ${ isolatedNodes } /${ totalNodes } ` ) ;
1145+ return false ;
1146+ }
1147+
1148+ // Log the nodes without predecessors for debugging
1149+ if ( nodesWithoutPredecessors . size > 0 ) {
1150+ console . log ( `Nodes without predecessors (acceptable starting points): ${ Array . from ( nodesWithoutPredecessors ) . join ( ', ' ) } ` ) ;
1151+ }
1152+
11001153 console . log ( "All nodes have valid predecessors or are legitimate starting points" ) ;
11011154 return true ;
11021155}
@@ -1121,9 +1174,10 @@ function checkGraphConnectivity(
11211174 } ) ;
11221175
11231176 // Add edges (bidirectional for connectivity check)
1177+ // Skip self-loops as they don't contribute to graph connectivity
11241178 edges . forEach ( ( { edge } ) => {
11251179 const [ fromNode , toNode ] = edge . split ( '->' ) ;
1126- if ( fromNode && toNode ) {
1180+ if ( fromNode && toNode && fromNode !== toNode ) { // Skip self-loops
11271181 adjacencyList . get ( fromNode ) ?. add ( toNode ) ;
11281182 adjacencyList . get ( toNode ) ?. add ( fromNode ) ; // Make it bidirectional for connectivity
11291183 }
0 commit comments