diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/index.html | 18 | ||||
| -rw-r--r-- | web/index.js | 299 | ||||
| -rw-r--r-- | web/styles.css | 24 |
3 files changed, 297 insertions, 44 deletions
diff --git a/web/index.html b/web/index.html index 11e2b87..448f762 100644 --- a/web/index.html +++ b/web/index.html @@ -14,7 +14,10 @@ <div class="controls"> <label> Course (e.g., CS 225 or CS): - <input id="searchInput" type="text" placeholder="CS 225" /> + <span class="search-box"> + <input id="searchInput" type="text" placeholder="CS 225" autocomplete="off" /> + <div id="searchSuggestions" class="suggestions hidden" role="listbox" aria-label="Suggestions"></div> + </span> </label> <label> Depth: @@ -57,6 +60,19 @@ <footer> <span id="status">Loading dataset...</span> </footer> + <aside id="sidebar" class="sidebar" aria-label="Edge details"> + <div class="sidebar-header"> + <span>Edge Details</span> + <button id="btnCloseSidebar" type="button">Close</button> + </div> + <div class="sidebar-content"> + <div id="sidebarBody">Hover an edge to see details here.</div> + <div style="margin-top:10px;"> + <button id="btnUnlock" type="button" class="hidden">Unlock</button> + </div> + </div> + </aside> + <button id="sidebarHandle" class="sidebar-handle" title="Toggle details">Details</button> </div> <script src="https://cdn.jsdelivr.net/npm/cytoscape@3.28.1/dist/cytoscape.min.js"></script> 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 ? `<span class="course-link" data-course="${node.course}">${node.course}</span>` : ''; + 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 = `<span class="course-link" data-course="${courseId}">${courseId}</span> — <span class="course-link" data-course="${courseId}">${name}</span>`; + const coreqBlock = coreq ? `<div style="margin-top:4px;"><span style=\"font-weight:600;\">Coreq-allowed:</span> ${coreq}</div>` : ''; + const html = `<div style="font-weight:600;margin-bottom:6px;">${header}</div>${desc ? `<div style=\"margin-bottom:8px;\">${desc}</div>` : ''}<div><span style=\"font-weight:600;\">Hard prerequisites:</span> ${hard}</div>${coreqBlock}`; + if (sidebar && sidebarBody) { + sidebar.classList.add('open'); + sidebarBody.innerHTML = html; + if (sidebarHandle) sidebarHandle.style.display = 'none'; + if (lock) { sidebarLocked = true; if (btnUnlock) btnUnlock.classList.remove('hidden'); } else { if (btnUnlock) btnUnlock.classList.add('hidden'); } + } + } + + // make available to search + focusDetailsRef = (id) => { renderCourseDetails(id, true); }; + + // Delegate clicks for course-link inside sidebar + if (sidebarBody) { + sidebarBody.addEventListener('click', (e) => { + const el = e.target.closest('.course-link'); + if (!el || !cyRef) return; + const cid = el.getAttribute('data-course'); + if (!cid) return; + const ele = cyRef.$id(cid); + if (ele.nonempty()) { + const prevZoom = cyRef.zoom(); + const MIN_FOCUS_ZOOM = 1.5; + const targetZoom = prevZoom < MIN_FOCUS_ZOOM ? MIN_FOCUS_ZOOM : prevZoom; + if (targetZoom !== prevZoom) cyRef.zoom(targetZoom); + cyRef.animate({ center: { eles: ele } }, { duration: 400 }); + cyRef.elements('.hover-node').removeClass('hover-node'); + ele.addClass('hover-node'); + renderCourseDetails(cid, true); + } + }); + } + + // Replace edge text content with clickable course links + cy.on('mouseover', 'edge', (evt) => { + clearHover(); + const e = evt.target; + e.addClass('hover-edge'); + e.source().addClass('hover-adjacent'); + e.target().addClass('hover-adjacent'); + if (!sidebarLocked && sidebar && sidebarBody) { + const s = e.source().data('id'); + const t = e.target().data('id'); + sidebar.classList.add('open'); + sidebarBody.innerHTML = `From <span class=\"course-link\" data-course=\"${s}\">${s}</span> to <span class=\"course-link\" data-course=\"${t}\">${t}</span>`; + if (btnUnlock) btnUnlock.classList.add('hidden'); + if (sidebarHandle) sidebarHandle.style.display = 'none'; + } + }); + cy.on('tap', 'edge', (evt) => { + const e = evt.target; + const s = e.source().data('id'); + const t = e.target().data('id'); + if (sidebar && sidebarBody) { + sidebar.classList.add('open'); + sidebarBody.innerHTML = `From <span class=\"course-link\" data-course=\"${s}\">${s}</span> to <span class=\"course-link\" data-course=\"${t}\">${t}</span>`; + sidebarLocked = true; + if (btnUnlock) btnUnlock.classList.remove('hidden'); + } + }); + + if (btnCloseSidebar) btnCloseSidebar.onclick = () => { sidebar.classList.remove('open'); sidebarLocked = false; if (btnUnlock) btnUnlock.classList.add('hidden'); if (sidebarHandle) sidebarHandle.style.display = 'inline-block'; }; + if (btnUnlock) btnUnlock.onclick = () => { sidebarLocked = false; btnUnlock.classList.add('hidden'); }; + cy.on('mouseout', 'edge', clearHover); + if (sidebarHandle) sidebarHandle.onclick = () => { sidebar.classList.toggle('open'); }; + cy.on('mouseout', 'edge', clearHover); + + cy.on('mouseover', 'node', (evt) => { + clearHover(); + evt.target.addClass('hover-node'); + if (!sidebarLocked) { + const id = evt.target.data('id'); + renderCourseDetails(id, false); + } + }); + cy.on('tap', 'node', (evt) => { renderCourseDetails(evt.target.data('id'), true); }); + + cy.on('tap', (evt) => { + if (evt.target === cy) clearHover(); + }); + } + + // Focus on a specific course; if zoom is too small, zoom in to a minimum before centering + function focusOnCourse() { + if (!cyRef) { render(); setTimeout(focusOnCourse, 50); return; } + const raw = searchInput.value.trim(); + if (!raw) return; + let id = raw.toUpperCase().replace(/\s+/g, ' '); + id = id.includes(' ') ? id : id.replace(/^([A-Z]{2,4})(\d)/, '$1 $2'); + const ele = cyRef.$id(id); + if (ele.nonempty()) { + const prevZoom = cyRef.zoom(); + const MIN_FOCUS_ZOOM = 1.5; // stronger minimum zoom when focusing + const targetZoom = prevZoom < MIN_FOCUS_ZOOM ? MIN_FOCUS_ZOOM : prevZoom; + if (targetZoom !== prevZoom) cyRef.zoom(targetZoom); + cyRef.animate({ center: { eles: ele } }, { duration: 400 }); + cyRef.elements('.hover-node').removeClass('hover-node'); + ele.addClass('hover-node'); + if (typeof focusDetailsRef === 'function') focusDetailsRef(id); + } + } + + btnApply.addEventListener('click', focusOnCourse); + // Press Enter in search input acts like Apply + if (searchInput) { + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); focusOnCourse(); } + }); + // suggestions: top 10 prefix matches on typing + searchInput.addEventListener('input', () => { + const q = searchInput.value.trim().toUpperCase(); + if (!q) { suggestionsEl.classList.add('hidden'); suggestionsEl.innerHTML=''; return; } + const list = []; + for (const n of dataset.graph.nodes) { + if (n.id.startsWith(q) || (n.label && n.label.toUpperCase().includes(q))) { + list.push({ id: n.id, label: n.label || n.id }); + if (list.length >= 10) break; + } + } + if (!list.length) { suggestionsEl.classList.add('hidden'); suggestionsEl.innerHTML=''; return; } + suggestionsEl.innerHTML = list.map(item => `<div class="suggestions-item" data-id="${item.id}"><strong>${item.id}</strong> — ${item.label}</div>`).join(''); + suggestionsEl.classList.remove('hidden'); + }); + suggestionsEl.addEventListener('click', (e) => { + const el = e.target.closest('.suggestions-item'); + if (!el) return; + const id = el.getAttribute('data-id'); + if (!id) return; + searchInput.value = id; + suggestionsEl.classList.add('hidden'); + focusOnCourse(); + }); + document.addEventListener('click', (e) => { + if (!suggestionsEl.contains(e.target) && e.target !== searchInput) { + suggestionsEl.classList.add('hidden'); + } }); } - btnApply.addEventListener('click', render); btnReset.addEventListener('click', () => { searchInput.value = ''; - depthInput.value = '2'; - toggleCoreq.checked = true; - layoutSelect.value = 'preset'; - spreadInput.value = '1.5'; + // layout preset/spread pinned; no reset needed toggleLabels.checked = true; render(); }); diff --git a/web/styles.css b/web/styles.css index 9744e6d..9816f72 100644 --- a/web/styles.css +++ b/web/styles.css @@ -4,8 +4,28 @@ header { padding: 10px 12px; border-bottom: 1px solid #ddd; display: flex; align header h1 { font-size: 18px; margin: 0 8px 0 0; } .controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } label { font-size: 14px; display: flex; align-items: center; gap: 6px; } -main { height: calc(100% - 100px); } +.search-box { position: relative; display: inline-block; } +.suggestions { position: absolute; top: 100%; left: 0; right: 0; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); max-height: 280px; overflow-y: auto; z-index: 1300; } +.suggestions.hidden { display: none; } +.suggestions-item { padding: 6px 8px; font-size: 13px; cursor: pointer; } +.suggestions-item:hover, .suggestions-item.active { background: #f1f5f9; } +#app { display: grid; grid-template-rows: auto 1fr 24px; } +main { height: auto; } #cy { height: 100%; } -footer { height: 36px; display: flex; align-items: center; padding: 0 12px; border-top: 1px solid #eee; font-size: 12px; color: #666; } +footer { height: 24px; line-height: 24px; display: flex; align-items: center; padding: 0 12px; border-top: 1px solid #eee; font-size: 12px; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* Right collapsible sidebar */ +.sidebar { position: fixed; top: 0; right: 0; height: 100%; width: 0; overflow: hidden; background: #ffffff; border-left: 1px solid #e5e7eb; box-shadow: -2px 0 8px rgba(0,0,0,0.05); transition: width 0.2s ease-in-out; z-index: 1100; } +.sidebar.open { width: 320px; } +.sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid #eee; font-weight: 600; } +.sidebar-content { padding: 10px 12px; font-size: 12px; line-height: 1.4; color: #111827; } +.sidebar button { font-size: 12px; padding: 4px 8px; background: #111827; color: #fff; border: none; border-radius: 4px; cursor: pointer; } +.sidebar button.hidden { display: none; } + +.sidebar-handle { position: fixed; right: 10px; top: 10px; z-index: 1200; background: #111827; color: #fff; border: none; border-radius: 4px; padding: 6px 8px; cursor: pointer; opacity: 0.9; } + +/* Link-like styling inside sidebar */ +.course-link { color: #2563eb; text-decoration: none; cursor: pointer; } +.course-link:hover { text-decoration: underline; } |
