summaryrefslogtreecommitdiff
path: root/web/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/index.js')
-rw-r--r--web/index.js249
1 files changed, 249 insertions, 0 deletions
diff --git a/web/index.js b/web/index.js
new file mode 100644
index 0000000..5757d2d
--- /dev/null
+++ b/web/index.js
@@ -0,0 +1,249 @@
+const statusEl = document.getElementById('status');
+const searchInput = document.getElementById('searchInput');
+const depthInput = document.getElementById('depthInput');
+const toggleCoreq = document.getElementById('toggleCoreq');
+const btnApply = document.getElementById('btnApply');
+const btnReset = document.getElementById('btnReset');
+const layoutSelect = document.getElementById('layoutSelect');
+const spreadInput = document.getElementById('spreadInput');
+const toggleLabels = document.getElementById('toggleLabels');
+// Fixed preset: always load positions_drl.json (or smacof/fa2 depending on precompute)
+
+// Keep node visual size and spacing logic in one place
+const NODE_SIZE = 6; // px — must match node width/height in applyStyles
+function getMinSpacingPx() {
+ return Math.max(1, Math.round(NODE_SIZE * 1.5));
+}
+
+/**
+ * Build nodes and edges from courses_parsed.json
+ * - Node id: index (e.g., "CS 225")
+ * - Edge: prereq -> course (type: hard or coreq)
+ */
+function buildGraphElements(dataset, includeCoreq) {
+ const elements = { nodes: new Map(), edges: [] };
+
+ function ensureNode(id, name) {
+ if (!elements.nodes.has(id)) {
+ elements.nodes.set(id, { data: { id, label: id, name: name || id } });
+ }
+ }
+
+ function addEdge(src, dst, kind) {
+ const id = `${src}->${dst}#${kind}`;
+ elements.edges.push({ data: { id, source: src, target: dst, kind } });
+ }
+
+ function collectCoursesFromAst(ast) {
+ const out = [];
+ function walk(node) {
+ if (!node || typeof node !== 'object') return;
+ const op = node.op;
+ if (op === 'COURSE' && node.course) {
+ out.push(node.course);
+ } else if (node.items && Array.isArray(node.items)) {
+ node.items.forEach(walk);
+ }
+ }
+ walk(ast);
+ return Array.from(new Set(out));
+ }
+
+ for (const c of dataset) {
+ const idx = c.index;
+ ensureNode(idx, c.name);
+ const pr = c.prerequisites || {};
+ const hard = pr.hard || { op: 'EMPTY' };
+ const coreq = pr.coreq_ok || { op: 'EMPTY' };
+ const hardCourses = collectCoursesFromAst(hard);
+ const coreqCourses = includeCoreq ? collectCoursesFromAst(coreq) : [];
+ for (const pre of hardCourses) {
+ ensureNode(pre, null);
+ addEdge(pre, idx, 'hard');
+ }
+ for (const pre of coreqCourses) {
+ ensureNode(pre, null);
+ addEdge(pre, idx, 'coreq');
+ }
+ }
+
+ return [Array.from(elements.nodes.values()), elements.edges];
+}
+
+function applyStyles(cy, opts) {
+ const hideLabels = opts?.hideLabels;
+ cy.style([
+ { selector: 'node', style: { 'label': hideLabels ? '' : 'data(label)', 'font-size': 6, 'width': NODE_SIZE, 'height': NODE_SIZE, 'background-color': 'data(color)', 'color': '#111827' } },
+ { selector: 'edge', style: { 'width': 0.24, 'line-color': '#94a3b8', 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', 'arrow-scale': 0.55, 'target-arrow-color': '#94a3b8' } },
+ { selector: 'edge[kind = "coreq"]', style: { 'line-style': 'dashed', 'line-color': '#22c55e', 'target-arrow-color': '#22c55e' } },
+ ]);
+}
+
+function runLayout(cy, opts) {
+ const spread = Number(opts?.spread || 1.5);
+ const layout = cy.layout({ name: 'dagre', rankDir: 'LR', nodeSep: 30 * spread, edgeSep: 12 * spread, rankSep: 80 * spread, fit: true, animate: false });
+ layout.run();
+}
+
+async function loadDataset() {
+ // Prefer reduced graph if present
+ let graph;
+ try {
+ graph = await fetch('../data/graph_reduced.json').then(r => { if (!r.ok) throw new Error('graph_reduced.json'); return r.json(); });
+ } catch (e) {
+ graph = await fetch('../data/graph.json').then(r => { if (!r.ok) throw new Error('graph.json'); return r.json(); });
+ }
+ async function loadPos(name) {
+ try {
+ return await fetch(`../data/${name}`).then(r => { if (!r.ok) throw new Error(name); return r.json(); });
+ } catch (e) { console.warn(`${name} not found`); return {}; }
+ }
+ // DEFAULT: DRL precomputed positions; fallback to SMACOF, then disk positions
+ let positions = await loadPos('positions_drl.json');
+ if (!Object.keys(positions).length) positions = await loadPos('positions_smacof.json');
+ if (!Object.keys(positions).length) positions = await loadPos('positions.json');
+ return { graph, positions };
+}
+
+function filterSubgraph(dataset, query, depth, includeCoreq) {
+ query = (query || '').trim();
+ if (!query) return dataset;
+ const isSubject = /^[A-Z]{2,4}$/.test(query);
+ const isCourse = /^[A-Z]{2,4}\s+\d/.test(query);
+ const wanted = new Set();
+
+ if (isSubject) {
+ for (const c of dataset) {
+ if (c.index.startsWith(query + ' ')) wanted.add(c.index);
+ }
+ } else if (isCourse) {
+ wanted.add(query.toUpperCase());
+ }
+
+ if (!wanted.size) return [];
+
+ // Expand predecessors by depth using edges from AST
+ const prereqMap = new Map(); // course -> set(prereq)
+ function collectCoursesFromAst(ast) {
+ const out = [];
+ function walk(node) {
+ if (!node || typeof node !== 'object') return;
+ const op = node.op;
+ if (op === 'COURSE' && node.course) {
+ out.push(node.course);
+ } else if (node.items && Array.isArray(node.items)) {
+ node.items.forEach(walk);
+ }
+ }
+ walk(ast);
+ return Array.from(new Set(out));
+ }
+ for (const c of dataset) {
+ const hard = c.prerequisites?.hard || { op: 'EMPTY' };
+ const coreq = c.prerequisites?.coreq_ok || { op: 'EMPTY' };
+ const pre = new Set(collectCoursesFromAst(hard));
+ if (includeCoreq) collectCoursesFromAst(coreq).forEach(x => pre.add(x));
+ prereqMap.set(c.index, pre);
+ }
+
+ let frontier = new Set(wanted);
+ const all = new Set(wanted);
+ for (let d = 0; d < depth; d++) {
+ const next = new Set();
+ for (const course of frontier) {
+ const pres = prereqMap.get(course) || new Set();
+ pres.forEach(p => { if (!all.has(p)) { all.add(p); next.add(p); } });
+ }
+ if (!next.size) break;
+ frontier = next;
+ }
+ return dataset.filter(c => all.has(c.index));
+}
+
+async function main() {
+ try {
+ const dataset = await loadDataset();
+ const data = dataset.graph.nodes.map(n => ({ index: n.id })) // minimal for filtering
+ statusEl.textContent = `Loaded ${dataset.graph.nodes.length} nodes.`;
+
+ function render() {
+ const query = searchInput.value.trim().toUpperCase();
+ const depth = Number(depthInput.value || 0);
+ const includeCoreq = toggleCoreq.checked;
+ const subset = filterSubgraph(data, query, depth, includeCoreq);
+ // Build from precomputed assets and subset selection
+ const allowed = new Set(subset.map(s => s.index));
+ const nodes = dataset.graph.nodes.filter(n => allowed.has(n.id)).map(n => ({ data: { id: n.id, label: n.id, color: n.color || '#4f46e5' } }));
+ const edges = dataset.graph.edges.filter(e => allowed.has(e.source) && allowed.has(e.target))
+ .map(e => ({ data: { id: `${e.source}->${e.target}#${e.kind}`, source: e.source, target: e.target, kind: e.kind } }));
+
+ const cy = cytoscape({
+ container: document.getElementById('cy'),
+ elements: [...nodes, ...edges],
+ wheelSensitivity: 0.2,
+ });
+ applyStyles(cy, { hideLabels: toggleLabels.checked });
+ const layoutMode = layoutSelect.value;
+ if (layoutMode === 'preset') {
+ // Apply preset positions
+ nodes.forEach(n => {
+ const p = dataset.positions[n.data.id];
+ if (p) cy.$id(n.data.id).position({ x: p.x, y: p.y });
+ });
+ cy.fit(undefined, 40);
+ } else if (layoutMode === 'cose') {
+ cy.layout({ name: 'cose', fit: true, animate: false, nodeOverlap: getMinSpacingPx(), nodeRepulsion: 800000 * Number(spreadInput.value || 1.5), idealEdgeLength: 40 * Number(spreadInput.value || 1.5) }).run();
+ } else if (layoutMode === 'fcose') {
+ cy.layout({ name: 'fcose', quality: 'default', fit: true, animate: false, nodeDimensionsIncludeLabels: true, packComponents: true, nodeSeparation: getMinSpacingPx(), idealEdgeLength: 28 * Number(spreadInput.value || 1.5), nodeRepulsion: 12000 * Number(spreadInput.value || 1.5) }).run();
+ } else if (layoutMode === 'cola') {
+ cy.layout({ name: 'cola', fit: true, animate: false, avoidOverlap: true, nodeSpacing: function() { return getMinSpacingPx(); }, edgeLength: 24 * Number(spreadInput.value || 1.5) }).run();
+ } else if (layoutMode === 'elk') {
+ cy.layout({ name: 'elk', fit: true, animate: false, elk: { 'elk.algorithm': 'layered', 'elk.layered.spacing.nodeNodeBetweenLayers': 50 * Number(spreadInput.value || 1.5), 'elk.spacing.nodeNode': 20 * Number(spreadInput.value || 1.5) } }).run();
+ } else if (layoutMode === 'fa2') {
+ // ForceAtlas2 with LinLog to emphasize communities; strong gravity to avoid border crowding
+ cy.layout({ name: 'forceAtlas2',
+ animated: false,
+ gravity: 1.0,
+ strongGravity: true,
+ linLogMode: true,
+ outboundAttractionDistribution: false,
+ barnesHutOptimize: true,
+ barnesHutTheta: 1.2,
+ scalingRatio: 2.0,
+ slowDown: 5,
+ edgeWeightInfluence: 1,
+ }).run();
+ } else {
+ runLayout(cy, { spread: Number(spreadInput.value || 1.5) });
+ }
+ statusEl.textContent = `Showing ${nodes.length} nodes, ${edges.length} edges` + (query ? ` (filter: ${query}, depth ${depth})` : '');
+ cy.on('tap', 'node', (evt) => {
+ const d = evt.target.data();
+ alert(`${d.label}`);
+ });
+ }
+
+ btnApply.addEventListener('click', render);
+ btnReset.addEventListener('click', () => {
+ searchInput.value = '';
+ depthInput.value = '2';
+ toggleCoreq.checked = true;
+ layoutSelect.value = 'preset';
+ spreadInput.value = '1.5';
+ toggleLabels.checked = true;
+ render();
+ });
+ layoutSelect.addEventListener('change', render);
+ spreadInput.addEventListener('change', render);
+ toggleLabels.addEventListener('change', render);
+
+ render();
+ } catch (e) {
+ statusEl.textContent = 'Failed to load dataset';
+ console.error(e);
+ }
+}
+
+main();
+
+