From 5423e0d7db6ca52d954c4529c0c2fb743319d693 Mon Sep 17 00:00:00 2001 From: blackhao <13851610112@163.com> Date: Fri, 22 Aug 2025 05:04:21 -0500 Subject: Prepare GitHub Pages: docs folder and data assets --- docs/index.js | 466 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 docs/index.js (limited to 'docs/index.js') diff --git a/docs/index.js b/docs/index.js new file mode 100644 index 0000000..ad1ac64 --- /dev/null +++ b/docs/index.js @@ -0,0 +1,466 @@ +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' } }, + // Hover styles + { selector: '.hover-edge', style: { 'line-color': '#000000', 'target-arrow-color': '#000000', 'z-index': 999 } }, + { selector: '.hover-node', style: { 'border-color': '#000000', 'border-width': 1, 'z-index': 999 } }, + { selector: '.hover-adjacent', style: { 'border-color': '#000000', 'border-width': 1, 'z-index': 998 } }, + ]); +} + +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(); }); + } + // Load parsed courses for node details + let courses = []; + try { + courses = await fetch('data/courses_parsed.json').then(r => { if (!r.ok) throw new Error('courses_parsed.json'); return r.json(); }); + } catch (e) { + console.warn('courses_parsed.json not found; node details limited'); + } + 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'); + const courseById = new Map(); + for (const c of courses) if (c && c.index) courseById.set(c.index, c); + return { graph, positions, courseById }; +} + +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)); +} + +let focusDetailsRef = null; // assigned inside render() +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.`; + + // Deterministic subject -> color mapping + function subjectOf(id) { + return (id || '').split(' ')[0] || ''; + } + const fixedPalette = { + 'CS': '#2563eb', + 'MATH': '#059669', + 'STAT': '#ef4444', + 'ECE': '#10b981', + 'PHYS': '#f59e0b', + 'CHEM': '#a855f7', + 'BIOE': '#14b8a6', + 'IS': '#0ea5e9', + 'ACCY': '#ec4899', + 'FIN': '#84cc16', + 'BADM': '#fb923c', + 'ME': '#8b5cf6', + 'AE': '#06b6d4', + 'CSE': '#22c55e', + 'LING': '#f97316', + 'PSYC': '#eab308' + }; + function hashColorFor(subject) { + // DJB2 string hash -> HSL + let h = 5381; + for (let i = 0; i < subject.length; i++) h = ((h << 5) + h) + subject.charCodeAt(i); + const hue = ((h >>> 0) % 360); + return `hsl(${hue}, 70%, 45%)`; + } + function colorForSubject(subject) { + if (fixedPalette[subject]) return fixedPalette[subject]; + return hashColorFor(subject); + } + + let cyRef = null; + const suggestionsEl = document.getElementById('searchSuggestions'); + + // Hide depth/coreq controls permanently if present + const di = document.getElementById('depthInput'); + if (di && di.closest) { const lab = di.closest('label'); if (lab) lab.style.display = 'none'; } + const tc = document.getElementById('toggleCoreq'); + if (tc) { tc.checked = true; if (tc.closest) { const lab = tc.closest('label'); if (lab) lab.style.display = 'none'; } } + + // Hide layout/spread controls and pin defaults + const layoutSelectEl = document.getElementById('layoutSelect'); + if (layoutSelectEl) { layoutSelectEl.value = 'preset'; const lab = layoutSelectEl.closest('label'); if (lab) lab.style.display = 'none'; } + const spreadInputEl = document.getElementById('spreadInput'); + if (spreadInputEl) { spreadInputEl.value = '1.5'; const lab = spreadInputEl.closest('label'); if (lab) lab.style.display = 'none'; } + + function render() { + const query = searchInput.value.trim().toUpperCase(); + const depth = 0; // permanently no depth filtering + const includeCoreq = true; // permanently include corequisites + const subset = data; + // Build from precomputed assets and subset selection + const nodes = dataset.graph.nodes.map(n => { + const subj = subjectOf(n.id); + const color = colorForSubject(subj); + return { data: { id: n.id, label: n.id, color } }; + }); + const edges = dataset.graph.edges.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, + }); + cyRef = cy; + // Disable node dragging by users + if (cy.autoungrabify) cy.autoungrabify(true); + cy.nodes().ungrabify(); + applyStyles(cy, { hideLabels: toggleLabels.checked }); + const layoutMode = 'preset'; // permanently preset + 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 { + // Fallback (should not hit since we pin preset) + cy.layout({ name: 'dagre', fit: true, animate: false }).run(); + } + statusEl.textContent = `Showing ${nodes.length} nodes, ${edges.length} edges`; + // Hover interactions + const tooltip = null; // remove floating tooltip; use sidebar only + const sidebar = document.getElementById('sidebar'); + const sidebarBody = document.getElementById('sidebarBody'); + const btnCloseSidebar = document.getElementById('btnCloseSidebar'); + const btnUnlock = document.getElementById('btnUnlock'); + const sidebarHandle = document.getElementById('sidebarHandle'); + let sidebarLocked = false; + + function clearHover() { + cy.elements('.hover-edge').removeClass('hover-edge'); + cy.elements('.hover-node').removeClass('hover-node'); + cy.elements('.hover-adjacent').removeClass('hover-adjacent'); + // no tooltip + } + + function astToText(node, parent) { + if (!node || typeof node !== 'object') return ''; + if (node.op === 'EMPTY') return ''; + if (node.op === 'COURSE') return node.course ? `${node.course}` : ''; + const parts = (node.items || []).map(n => astToText(n, node.op)).filter(Boolean); + if (!parts.length) return ''; + const sep = node.op === 'AND' ? ' and ' : ' or '; + let s = parts.join(sep); + if (parent && parent !== node.op && parts.length > 1) s = `(${s})`; + return s; + } + + function renderCourseDetails(courseId, lock) { + const course = dataset.courseById ? dataset.courseById.get(courseId) : null; + const name = (course && course.name) || courseId; + const desc = (course && course.description) || ''; + let hard = '', coreq = ''; + if (course && course.prerequisites) { + hard = astToText(course.prerequisites.hard, ''); + coreq = astToText(course.prerequisites.coreq_ok, ''); + } + if (!hard) hard = 'None'; + const header = `${courseId} — ${name}`; + const coreqBlock = coreq ? `