diff --git a/backend/main.py b/backend/main.py index 5d267d0..f827449 100644 --- a/backend/main.py +++ b/backend/main.py @@ -261,30 +261,43 @@ def get_program(request: Request, program_id: int): """, (program_id,)) edges = cur.fetchall() - # for concentrations, merge in the base degree requirements so the full - # program path is visible alongside the concentration-specific courses + # for concentrations and streams, merge the full base degree so the + # complete program path is visible alongside the specific courses base_reqs = [] base_edges = [] - if degree.lower().startswith("concentration in"): - cur.execute(""" - SELECT program_id, degree FROM programs - WHERE dept_id = %s - AND program_id != %s - AND degree ILIKE ANY(ARRAY['%%B.A. Honours%%', '%%B.Sc. Honours%%', '%%B.Eng. Honours%%', - '%%Bachelor%%Honours%%']) - ORDER BY program_id - LIMIT 1 - """, (dept_id, program_id)) + deg_lower = degree.lower() + is_concentration = deg_lower.startswith("concentration in") + is_stream = deg_lower.startswith("stream in") + if is_concentration or is_stream: + if is_stream: + # streams belong to the International Business Honours degree + base_query = """ + SELECT program_id, degree FROM programs + WHERE dept_id = %s + AND program_id != %s + AND degree ILIKE '%%International Business%%Honours%%' + ORDER BY program_id + LIMIT 1 + """ + else: + # concentrations belong to the first honours degree in the department + base_query = """ + SELECT program_id, degree FROM programs + WHERE dept_id = %s + AND program_id != %s + AND degree ILIKE ANY(ARRAY['%%B.A. Honours%%', '%%B.Sc. Honours%%', '%%B.Eng. Honours%%', + '%%Bachelor%%Honours%%']) + ORDER BY program_id + LIMIT 1 + """ + cur.execute(base_query, (dept_id, program_id)) base_row = cur.fetchone() if base_row: base_prog_id = base_row[0] - # only pull year 1 and 2 courses from the base program — year 3+ are - # largely electives that the concentration already covers cur.execute(""" SELECT type, courses, credits, description, layout_col, layout_row FROM program_requirements WHERE program_id = %s - AND layout_col < 2 ORDER BY req_id """, (base_prog_id,)) base_reqs = cur.fetchall() diff --git a/frontend/app/map/components/CompareModal.jsx b/frontend/app/map/components/CompareModal.jsx index 41098e5..7c5a8e2 100644 --- a/frontend/app/map/components/CompareModal.jsx +++ b/frontend/app/map/components/CompareModal.jsx @@ -1,37 +1,64 @@ -/* Hallmark · component: CompareModal · genre: modern-minimal · theme: custom (Carleton) - * states: closed · selecting · loading · ready (3-col diff) - * pre-emit critique: P5 H5 E5 S5 R5 V5 - */ - 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { API } from '../utils/constants' -const getCourses = (map) => new Set( - (map?.requirements || []) - .filter(r => r.courses?.length > 0) - .flatMap(r => r.courses) +// ── helpers ──────────────────────────────────────────────────────────────────── + +const getCodes = (map) => new Set( + (map?.requirements || []).filter(r => r.courses?.length).flatMap(r => r.courses) ) +// build code → course name from the requirement descriptions (single-course reqs only) +const getNameMap = (map) => { + const m = {} + for (const req of (map?.requirements || [])) { + if (req.courses?.length === 1 && req.description) + m[req.courses[0]] = req.description + } + return m +} + +const totalCredits = (map) => + (map?.requirements || []).reduce((s, r) => s + (r.credits ?? 0), 0) + +const pct = (a, b) => { + const union = new Set([...a, ...b]).size + if (!union) return 0 + return Math.round((new Set([...a].filter(c => b.has(c))).size / union) * 100) +} + +// ── CompareModal ─────────────────────────────────────────────────────────────── + export const CompareModal = ({ open, onClose, departments }) => { - const [deptId, setDeptId] = useState('') - const [progIdA, setProgIdA] = useState('') - const [progIdB, setProgIdB] = useState('') - const [programs, setPrograms] = useState([]) - const [mapA, setMapA] = useState(null) - const [mapB, setMapB] = useState(null) - const [loading, setLoading] = useState(false) + const [deptA, setDeptA] = useState('') + const [deptB, setDeptB] = useState('') + const [progsA, setProgsA] = useState([]) + const [progsB, setProgsB] = useState([]) + const [progIdA, setProgIdA] = useState('') + const [progIdB, setProgIdB] = useState('') + const [mapA, setMapA] = useState(null) + const [mapB, setMapB] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!open) return + const h = e => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', h) + return () => window.removeEventListener('keydown', h) + }, [open, onClose]) + + useEffect(() => { + if (!deptA) { setProgsA([]); setProgIdA(''); setMapA(null); return } + setProgIdA(''); setMapA(null) + fetch(`${API}/programs?dept=${deptA}`).then(r => r.json()).then(setProgsA) + }, [deptA]) useEffect(() => { - if (!deptId) { setPrograms([]); setProgIdA(''); setProgIdB(''); return } - setPrograms([]) - setProgIdA('') - setProgIdB('') - setMapA(null) - setMapB(null) - fetch(`${API}/programs?dept=${deptId}`).then(r => r.json()).then(setPrograms) - }, [deptId]) + if (!deptB) { setProgsB([]); setProgIdB(''); setMapB(null); return } + setProgIdB(''); setMapB(null) + fetch(`${API}/programs?dept=${deptB}`).then(r => r.json()).then(setProgsB) + }, [deptB]) useEffect(() => { if (!progIdA || !progIdB) { setMapA(null); setMapB(null); return } @@ -44,21 +71,31 @@ export const CompareModal = ({ open, onClose, departments }) => { .catch(() => setLoading(false)) }, [progIdA, progIdB]) - useEffect(() => { - if (!open) return - const h = e => { if (e.key === 'Escape') onClose() } - window.addEventListener('keydown', h) - return () => window.removeEventListener('keydown', h) - }, [open, onClose]) + const swap = useCallback(() => { + setDeptA(deptB); setDeptB(deptA) + setProgIdA(progIdB); setProgIdB(progIdA) + setProgsA(progsB); setProgsB(progsA) + setMapA(mapB); setMapB(mapA) + }, [deptA, deptB, progIdA, progIdB, progsA, progsB, mapA, mapB]) if (!open) return null - const codesA = getCourses(mapA) - const codesB = getCourses(mapB) - const onlyA = mapA ? [...codesA].filter(c => !codesB.has(c)) : [] - const inBoth = mapA ? [...codesA].filter(c => codesB.has(c)) : [] - const onlyB = mapB ? [...codesB].filter(c => !codesA.has(c)) : [] + const codesA = getCodes(mapA) + const codesB = getCodes(mapB) + const namesA = getNameMap(mapA) + const namesB = getNameMap(mapB) + const nameMap = { ...namesB, ...namesA } + + const onlyA = mapA ? [...codesA].filter(c => !codesB.has(c)) : [] + const inBoth = mapA ? [...codesA].filter(c => codesB.has(c)) : [] + const onlyB = mapB ? [...codesB].filter(c => !codesA.has(c)) : [] const hasDiff = mapA && mapB && !loading + const overlap = hasDiff ? pct(codesA, codesB) : null + + const credA = hasDiff ? totalCredits(mapA).toFixed(1) : null + const credB = hasDiff ? totalCredits(mapB).toFixed(1) : null + + const bothSelected = progIdA && progIdB return ( <> @@ -70,7 +107,6 @@ export const CompareModal = ({ open, onClose, departments }) => { background: 'rgba(0,0,0,0.32)', backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)', - animation: 'backdrop-in 180ms var(--ease-out) both', }} /> @@ -88,8 +124,8 @@ export const CompareModal = ({ open, onClose, departments }) => { aria-label="Compare programs" style={{ pointerEvents: 'auto', - width: 'min(680px, 100%)', - maxHeight: 'min(640px, calc(100vh - 32px))', + width: 'min(800px, 100%)', + maxHeight: 'min(700px, calc(100vh - 32px))', background: 'var(--color-paper)', borderRadius: 10, border: '1px solid var(--color-rule)', @@ -98,113 +134,139 @@ export const CompareModal = ({ open, onClose, departments }) => { flexDirection: 'column', overflow: 'hidden', fontFamily: 'var(--font-body)', - animation: 'picker-in 200ms var(--ease-out) both', }} >
- {/* ── Header ──────────────────────────────────────────────────────── */} + {/* ── Header ── */}- Course data reflects the 2025–2026 Undergraduate Calendar. Always verify with the official Carleton University calendar before enrolling. + Course data reflects the 2026–2027 Undergraduate Calendar. Always verify with the official Carleton University calendar before enrolling.
Not affiliated with or endorsed by Carleton University. diff --git a/frontend/app/map/page.js b/frontend/app/map/page.js index ed00cc4..443f3e0 100644 --- a/frontend/app/map/page.js +++ b/frontend/app/map/page.js @@ -37,6 +37,20 @@ const shortenProgram = (degree = '') => { return c.length > 30 ? c.slice(0, 28) + '…' : c } + // Standalone "Concentration in X (N credits)" — show just the topic + const concM = degree.match(/^Concentration\s+in\s+(.+?)(?:\s*\(|$)/i) + if (concM) { + const c = concM[1].trim() + return c.length > 30 ? c.slice(0, 28) + '…' : c + } + + // Standalone "Stream in X (N credits)" — show just the topic + const streamM = degree.match(/^Stream\s+in\s+(.+?)(?:\s*\(|$)/i) + if (streamM) { + const c = streamM[1].trim() + return c.length > 30 ? c.slice(0, 28) + '…' : c + } + const specifics = [ [/artificial intelligence|machine learning/i, 'AI & Machine Learning'], [/cybersecurity|cyber security/i, 'Cybersecurity'], @@ -51,8 +65,8 @@ const shortenProgram = (degree = '') => { ] for (const [re, label] of specifics) if (re.test(degree)) return label - // Cognitive Science concentrations — extract the concentration name - const cogM = degree.match(/concentration\s+in\s+(.+?)(?:\s{2,}|$)/i) + // Any remaining "Concentration in X" embedded in a longer degree name + const cogM = degree.match(/concentration\s+in\s+(.+?)(?:\s*\(|\s{2,}|$)/i) if (cogM) { const c = cogM[1].trim() return c.length > 30 ? c.slice(0, 28) + '…' : c @@ -85,6 +99,10 @@ export default function MapPage() { const [nodes, setNodes] = useState([]) const [edges, setEdges] = useState([]) const [selectedNode, setSelectedNode] = useState(null) + const pillScrollRef = useRef(null) + const scrollPills = (dir) => { + if (pillScrollRef.current) pillScrollRef.current.scrollBy({ left: dir * 200, behavior: 'smooth' }) + } const [showPicker, setShowPicker] = useState(false) const [showNotes, setShowNotes] = useState(false) const [showCompare, setShowCompare] = useState(false) @@ -92,9 +110,31 @@ export default function MapPage() { const [highlightedId, setHighlightedId] = useState(null) const [chainIds, setChainIds] = useState(null) + const [isMobile, setIsMobile] = useState(false) const rfRef = useRef(null) const initialProgram = useRef(null) + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 640) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + + // Escape clears the selected node and chain highlight + useEffect(() => { + const handler = (e) => { + if (e.key !== 'Escape') return + if (selectedNode || chainIds) { + e.stopPropagation() + setSelectedNode(null) + setChainIds(null) + } + } + window.addEventListener('keydown', handler, true) + return () => window.removeEventListener('keydown', handler, true) + }, [selectedNode, chainIds]) + // load departments on mount useEffect(() => { fetch(`${API}/departments`).then(r => r.json()).then(setDepartments) @@ -234,7 +274,21 @@ export default function MapPage() { background: 'var(--color-paper)', }}> - + {/* nav bar */}