File size: 14,938 Bytes
a522797
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
"""

Interactive Curriculum Visualizer - FIXED VERSION

Creates CurricularAnalytics-style network graphs

"""
import streamlit as st
import networkx as nx
import plotly.graph_objects as go
import pickle
import json
from typing import Dict, List, Tuple
import numpy as np

class CurriculumVisualizer:
    """

    Creates interactive curriculum dependency graphs

    Similar to CurricularAnalytics.org

    """
    
    def __init__(self, graph: nx.DiGraph):
        self.graph = graph
        self.courses = dict(graph.nodes(data=True))
        self.positions = None
        self.layers = None
        
    def calculate_metrics(self, course_id: str) -> Dict:
        """Calculate blocking factor, delay factor, centrality"""
        
        # Blocking Factor: courses this blocks
        blocking = len(list(nx.descendants(self.graph, course_id)))
        
        # Correctly calculate the delay factor by finding the longest path
        delay = 0
        sinks = [n for n, d in self.graph.out_degree() if d == 0]
        
        max_len = 0
        for sink in sinks:
            try:
                paths = list(nx.all_simple_paths(self.graph, source=course_id, target=sink))
                if paths:
                    current_max = max(len(p) for p in paths)
                    if current_max > max_len:
                        max_len = current_max
            except (nx.NetworkXNoPath, nx.NodeNotFound):
                continue
        delay = max_len
        
        # Centrality: betweenness centrality
        centrality_dict = nx.betweenness_centrality(self.graph)
        centrality = centrality_dict.get(course_id, 0) * 100
        
        # Complexity (from your analyzer)
        complexity = self.courses[course_id].get('complexity', 0)
        
        return {
            'blocking': blocking,
            'delay': delay,
            'centrality': round(centrality, 1),
            'complexity': complexity
        }
    
    def create_hierarchical_layout(self) -> Dict:
        """Create semester-based layout like CurricularAnalytics"""
        
        # Topological sort to get course ordering
        try:
            topo_order = list(nx.topological_sort(self.graph))
        except nx.NetworkXError:
            # Has cycles, use DFS order
            topo_order = list(nx.dfs_preorder_nodes(self.graph))
        
        # Calculate depth for each node (semester level)
        depths = {}
        for node in topo_order:
            predecessors = list(self.graph.predecessors(node))
            if not predecessors:
                depths[node] = 0
            else:
                depths[node] = max(depths.get(p, 0) for p in predecessors) + 1
        
        # Group by depth (semester)
        layers = {}
        for node, depth in depths.items():
            if depth not in layers:
                layers[depth] = []
            layers[depth].append(node)
        
        # Create positions
        positions = {}
        max_width = max(len(nodes) for nodes in layers.values()) if layers else 1
        
        for depth, nodes in layers.items():
            width = len(nodes)
            spacing = 2.0 / (width + 1) if width > 0 else 1
            
            for i, node in enumerate(nodes):
                x = (i + 1) * spacing - 1  # Center around 0
                y = -depth * 2  # Vertical spacing
                positions[node] = (x, y)
        
        self.positions = positions
        self.layers = layers
        return positions
    
    def create_interactive_plot(self, highlight_path: List[str] = None) -> go.Figure:
        """Create Plotly interactive network graph"""
        
        if not self.positions:
            self.create_hierarchical_layout()
        
        # Create edge traces
        edge_traces = []
        
        for edge in self.graph.edges():
            if edge[0] not in self.positions or edge[1] not in self.positions:
                continue
            x0, y0 = self.positions[edge[0]]
            x1, y1 = self.positions[edge[1]]
            
            # Check if edge is on critical path
            is_critical = False
            if highlight_path and edge[0] in highlight_path and edge[1] in highlight_path:
                try:
                    idx0 = highlight_path.index(edge[0])
                    idx1 = highlight_path.index(edge[1])
                    is_critical = idx1 == idx0 + 1
                except ValueError:
                    is_critical = False
            
            edge_trace = go.Scatter(
                x=[x0, x1, None],
                y=[y0, y1, None],
                mode='lines',
                line=dict(
                    width=3 if is_critical else 1,
                    color='red' if is_critical else '#888'
                ),
                hoverinfo='none',
                showlegend=False
            )
            edge_traces.append(edge_trace)
        
        # Create node trace
        node_x = []
        node_y = []
        node_text = []
        node_color = []
        node_size = []
        
        for node in self.graph.nodes():
            if node not in self.positions:
                continue
            x, y = self.positions[node]
            node_x.append(x)
            node_y.append(y)
            
            # Get course info
            course_data = self.courses.get(node, {})
            metrics = self.calculate_metrics(node)
            
            # Create hover text
            hover_text = f"""

            <b>{node}: {course_data.get('name', 'Unknown')}</b><br>

            Credits: {course_data.get('credits', 4)}<br>

            <br><b>Metrics:</b><br>

            Complexity: {metrics['complexity']}<br>

            Blocking Factor: {metrics['blocking']}<br>

            Delay Factor: {metrics['delay']}<br>

            Centrality: {metrics['centrality']}<br>

            Prerequisites: {', '.join(self.graph.predecessors(node)) or 'None'}

            """
            node_text.append(hover_text)
            
            # Color by complexity
            node_color.append(metrics['complexity'])
            
            # Size by blocking factor
            node_size.append(15 + metrics['blocking'] * 2)
        
        node_trace = go.Scatter(
            x=node_x,
            y=node_y,
            mode='markers+text',
            text=[node for node in self.graph.nodes() if node in self.positions],
            textposition="top center",
            textfont=dict(size=10),
            hovertext=node_text,
            hoverinfo='text',
            marker=dict(
                showscale=True,
                colorscale='Viridis',
                size=node_size,
                color=node_color,
                colorbar=dict(
                    thickness=15,
                    title=dict(text="Complexity", side="right"), 
                    xanchor="left"
                ),
                line=dict(width=2, color='white')
            )
        )
        
        # Create figure
        fig = go.Figure(data=edge_traces + [node_trace])
        
        # FIXED: Updated layout with proper title syntax (no more titlefont_size)
        fig.update_layout(
            title=dict(
                text="Interactive Curriculum Map", 
                font=dict(size=20)
            ),
            showlegend=False,
            hovermode='closest',
            margin=dict(b=0, l=0, r=0, t=40),
            xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
            yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
            plot_bgcolor='white',
            height=800
        )
        
        return fig
    
    def find_critical_path(self) -> List[str]:
        """Find the longest path (critical path) in curriculum"""
        
        if not nx.is_directed_acyclic_graph(self.graph):
            return []
        
        # Find all paths from sources to sinks
        sources = [n for n in self.graph.nodes() if self.graph.in_degree(n) == 0]
        sinks = [n for n in self.graph.nodes() if self.graph.out_degree(n) == 0]
        
        longest_path = []
        max_length = 0
        
        for source in sources:
            for sink in sinks:
                try:
                    paths = list(nx.all_simple_paths(self.graph, source, sink))
                    for path in paths:
                        if len(path) > max_length:
                            max_length = len(path)
                            longest_path = path
                except nx.NetworkXNoPath:
                    continue
        
        return longest_path
    
    def export_to_curricular_analytics_format(self, plan: Dict) -> Dict:
        """Export plan in CurricularAnalytics JSON format"""
        
        ca_format = {
            "curriculum": {
                "name": "Generated Curriculum",
                "courses": [],
                "dependencies": []
            },
            "metrics": {}
        }
        
        # Add courses
        for course_id in self.graph.nodes():
            course_data = self.courses.get(course_id, {})
            metrics = self.calculate_metrics(course_id)
            
            ca_format["curriculum"]["courses"].append({
                "id": course_id,
                "name": course_data.get('name', ''),
                "credits": course_data.get('credits', 4),
                "complexity": metrics['complexity'],
                "blocking_factor": metrics['blocking'],
                "delay_factor": metrics['delay'],
                "centrality": metrics['centrality']
            })
        
        # Add dependencies
        for edge in self.graph.edges():
            ca_format["curriculum"]["dependencies"].append({
                "source": edge[0],
                "target": edge[1],
                "type": "prerequisite"
            })
        
        return ca_format

def run_visualizer():
    """Streamlit app for visualization"""
    
    st.set_page_config(page_title="Curriculum Visualizer", layout="wide")
    st.title("🗺️ Interactive Curriculum Visualizer")
    
    # Sidebar
    with st.sidebar:
        st.header("Controls")
        
        # File upload
        uploaded_file = st.file_uploader("Upload curriculum graph", type=['pkl'])
        
        # Display options
        show_critical = st.checkbox("Highlight Critical Path", value=True)
        show_metrics = st.checkbox("Show Metrics Panel", value=True)
        
        # Filter options
        st.subheader("Filter Courses")
        min_complexity = st.slider("Min Complexity", 0, 200, 0)
        subjects = st.multiselect("Subjects", ["CS", "DS", "MATH", "IS", "CY"])
    
    # Main content
    if uploaded_file:
        # Load graph
        graph = pickle.load(uploaded_file)
        visualizer = CurriculumVisualizer(graph)
        
        # Apply filters
        if subjects:
            nodes_to_keep = [
                n for n in graph.nodes() 
                if graph.nodes[n].get('subject') in subjects
            ]
            filtered_graph = graph.subgraph(nodes_to_keep).copy()
            visualizer = CurriculumVisualizer(filtered_graph)
        
        # Create visualization
        col1, col2 = st.columns([3, 1] if show_metrics else [1])
        
        with col1:
            # Find critical path
            critical_path = []
            if show_critical:
                critical_path = visualizer.find_critical_path()
                if critical_path:
                    st.info(f"Critical Path: {' → '.join(critical_path[:5])}...")
            
            # Create and display plot
            fig = visualizer.create_interactive_plot(critical_path)
            st.plotly_chart(fig, use_container_width=True)
        
        if show_metrics:
            with col2:
                st.subheader("📊 Curriculum Metrics")
                
                # Overall metrics
                total_courses = visualizer.graph.number_of_nodes()
                total_prereqs = visualizer.graph.number_of_edges()
                
                st.metric("Total Courses", total_courses)
                st.metric("Total Prerequisites", total_prereqs)
                if total_courses > 0:
                    st.metric("Avg Prerequisites", f"{total_prereqs/total_courses:.1f}")
                
                st.divider()
                
                # Most complex courses
                st.subheader("Most Complex Courses")
                complexities = []
                for node in visualizer.graph.nodes():
                    metrics = visualizer.calculate_metrics(node)
                    complexities.append((node, metrics['complexity']))
                
                complexities.sort(key=lambda x: x[1], reverse=True)
                
                for course, complexity in complexities[:5]:
                    name = visualizer.courses.get(course, {}).get('name', course)
                    st.write(f"**{course}**: {name}")
                    st.progress(min(complexity/200, 1.0) if complexity else 0.0)
                
                # Export button
                st.divider()
                if st.button("Export to CA Format"):
                    ca_json = visualizer.export_to_curricular_analytics_format({})
                    st.download_button(
                        "Download JSON",
                        json.dumps(ca_json, indent=2),
                        "curriculum_analytics.json",
                        "application/json"
                    )
    else:
        # Demo/instruction
        st.info("Upload a curriculum graph file to visualize")
        
        with st.expander("About this Visualizer"):
            st.write("""

            This tool creates interactive curriculum dependency graphs similar to CurricularAnalytics.org.

            

            **Features:**

            - Hierarchical layout by semester level

            - Color coding by complexity

            - Node size by blocking factor

            - Critical path highlighting

            - Interactive hover details

            - Export to CurricularAnalytics format

            

            **Metrics Calculated:**

            - **Blocking Factor**: Number of courses this prerequisite blocks

            - **Delay Factor**: Length of longest path through this course

            - **Centrality**: Importance in the curriculum network

            - **Complexity**: Combined metric from all factors

            """)

if __name__ == "__main__":
    run_visualizer()