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 --- web/index.js | 299 +++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 258 insertions(+), 41 deletions(-) (limited to 'web/index.js') diff --git a/web/index.js b/web/index.js index 5757d2d..1f36a84 100644 --- a/web/index.js +++ b/web/index.js @@ -76,6 +76,10 @@ function applyStyles(cy, opts) { { 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 } }, ]); } @@ -93,6 +97,13 @@ async function loadDataset() { } 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(); }); @@ -102,7 +113,9 @@ async function loadDataset() { 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 }; + 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) { @@ -160,30 +173,86 @@ function filterSubgraph(dataset, query, depth, includeCoreq) { 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 = Number(depthInput.value || 0); - const includeCoreq = toggleCoreq.checked; - const subset = filterSubgraph(data, query, depth, includeCoreq); + const depth = 0; // permanently no depth filtering + const includeCoreq = true; // permanently include corequisites + const subset = data; // 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 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 = layoutSelect.value; + const layoutMode = 'preset'; // permanently preset if (layoutMode === 'preset') { // Apply preset positions nodes.forEach(n => { @@ -191,45 +260,193 @@ async function main() { 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) }); + // 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; } - 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}`); + + 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 ? `