From a7e4bdf4db26db497272376f9c21ed873725a59f Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 02:01:37 +0530 Subject: [PATCH] update webapp --- webapp/static/app.js | 503 ++++++++++++++++++++++++++++++++++-- webapp/static/styles.css | 187 +++++++++++++- webapp/templates/index.html | 38 ++- webapp/tests.py | 18 +- 4 files changed, 708 insertions(+), 38 deletions(-) diff --git a/webapp/static/app.js b/webapp/static/app.js index 74c3ef6..cd4f38a 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -1,6 +1,7 @@ const storageKey = "dsai-gate-progress-v1"; const notesKey = "dsai-gate-notes-v1"; const themeKey = "dsai-gate-theme"; +const customMapNodesKey = "dsai-gate-custom-map-nodes-v1"; const subjects = JSON.parse(document.querySelector("#syllabus-data").textContent); const slides = subjects.flatMap((subject) => @@ -32,7 +33,20 @@ let currentSlide = 0; let currentPresentationView = "focus"; let progress = loadStored(storageKey); let notes = loadStored(notesKey); +let customMapNodes = loadStored(customMapNodesKey); let noteSaveTimer; +let selectedCustomNodeId = null; +if (!Array.isArray(customMapNodes)) customMapNodes = []; + +const mapColors = [ + { branch: "#9f6674", soft: "#f4e4e8", text: "#633642", foreground: "#ffffff" }, + { branch: "#79a7a3", soft: "#e3f0ee", text: "#355f5c", foreground: "#173f3c" }, + { branch: "#c29a62", soft: "#f5ead8", text: "#715126", foreground: "#4d3414" }, + { branch: "#82a986", soft: "#e6f0e5", text: "#416546", foreground: "#214727" }, + { branch: "#b7836e", soft: "#f3e4de", text: "#704636", foreground: "#4f2c20" }, + { branch: "#ae7898", soft: "#f2e3ec", text: "#6c3f59", foreground: "#ffffff" }, + { branch: "#8586ae", soft: "#e9e8f2", text: "#4d4e76", foreground: "#ffffff" }, +]; function loadStored(key) { try { @@ -160,6 +174,8 @@ function escapeMermaid(value) { } function resourceMapDefinition(slide, topicMap) { + const subjectIndex = subjects.findIndex((subject) => subject.id === slide.subject.id); + const color = mapColors[subjectIndex] || mapColors[0]; const lines = [ "flowchart LR", `topic["${escapeMermaid(slide.topic)}"]`, @@ -175,10 +191,10 @@ function resourceMapDefinition(slide, topicMap) { lines.push(`class resource${index} ${resource.relevance === "topic" ? "matched" : "broader"}`); }); lines.push( - "classDef current fill:#312e81,stroke:#a78bfa,color:#ffffff,stroke-width:3px", - "classDef concept fill:#172033,stroke:#58c4dd,color:#ffffff,stroke-width:2px", - "classDef matched fill:#172033,stroke:#22c55e,color:#ffffff,stroke-width:2px", - "classDef broader fill:#172033,stroke:#64748b,color:#cbd5e1,stroke-width:1px", + `classDef current fill:${color.branch},stroke:${color.text},color:${color.foreground},stroke-width:3px`, + `classDef concept fill:${color.soft},stroke:${color.branch},color:${color.text},stroke-width:2px`, + "classDef matched fill:#dcfce7,stroke:#22c55e,color:#166534,stroke-width:2px", + "classDef broader fill:#f1f5f9,stroke:#64748b,color:#334155,stroke-width:1px", "class topic current", ); if (topicMap.related.length) { @@ -187,27 +203,422 @@ function resourceMapDefinition(slide, topicMap) { return lines.join("\n"); } -function overallMapDefinition() { - const lines = [ - "mindmap", - ' root(("GATE DA"))', - ]; +const svgNamespace = "http://www.w3.org/2000/svg"; + +function svgElement(name, attributes = {}) { + const element = document.createElementNS(svgNamespace, name); + Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value)); + return element; +} + +function radialPoint(center, radius, angle) { + return { + x: center.x + Math.cos(angle) * radius, + y: center.y + Math.sin(angle) * radius, + }; +} + +function radialNode({ id, x, y, width, label, fill, stroke, color, className, activate }) { + const words = label.split(/\s+/); + const lines = []; + let line = ""; + words.forEach((word) => { + if (`${line} ${word}`.trim().length > 22 && line) { + lines.push(line); + line = word; + } else { + line = `${line} ${word}`.trim(); + } + }); + if (line) lines.push(line); + + const height = Math.max(44, lines.length * 15 + 20); + const group = svgElement("g", { + class: `radial-node${activate ? " map-link" : ""} ${className}`, + transform: `translate(${x - width / 2} ${y - height / 2})`, + "data-node-id": id, + "data-x": x, + "data-y": y, + "data-width": width, + "data-height": height, + }); + if (activate) { + group.setAttribute("role", "link"); + group.setAttribute("tabindex", "0"); + group.setAttribute("aria-label", label); + } + group.append(svgElement("rect", { + width, + height, + rx: className === "root-node" ? height / 2 : 10, + fill, + stroke, + })); + const text = svgElement("text", { + x: width / 2, + y: height / 2 - ((lines.length - 1) * 7), + fill: color, + "text-anchor": "middle", + }); + lines.forEach((textLine, index) => { + const span = svgElement("tspan", { + x: width / 2, + dy: index === 0 ? "0" : "15", + }); + span.textContent = textLine; + text.append(span); + }); + group.append(text); + if (activate) { + group.addEventListener("click", activate); + group.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(); + } + }); + } + return group; +} + +function enableMapInteractions(map, svg, viewport) { + const state = { scale: 1, x: 0, y: 0 }; + let interaction = null; + let suppressClick = false; + + const applyViewport = () => { + viewport.setAttribute("transform", `translate(${state.x} ${state.y}) scale(${state.scale})`); + map.style.setProperty("--map-zoom", `${Math.round(state.scale * 100)}%`); + }; + const svgPoint = (event) => { + const point = svg.createSVGPoint(); + point.x = event.clientX; + point.y = event.clientY; + return point.matrixTransform(svg.getScreenCTM().inverse()); + }; + const graphPoint = (event) => { + const point = svgPoint(event); + return { + x: (point.x - state.x) / state.scale, + y: (point.y - state.y) / state.scale, + }; + }; + const updateNode = (node, x, y) => { + const width = Number(node.dataset.width); + const height = Number(node.dataset.height); + node.dataset.x = x; + node.dataset.y = y; + node.setAttribute("transform", `translate(${x - width / 2} ${y - height / 2})`); + viewport.querySelectorAll(`[data-from="${node.dataset.nodeId}"]`).forEach((edge) => { + edge.setAttribute("x1", x); + edge.setAttribute("y1", y); + }); + viewport.querySelectorAll(`[data-to="${node.dataset.nodeId}"]`).forEach((edge) => { + edge.setAttribute("x2", x); + edge.setAttribute("y2", y); + }); + if (node.dataset.custom === "true") { + const savedNode = customMapNodes.find((item) => item.id === node.dataset.nodeId); + if (savedNode) { + savedNode.x = x; + savedNode.y = y; + document.querySelector("#map-save-status").textContent = "Move pending..."; + } + } + }; + const zoomAt = (factor, anchor = { x: 950, y: 725 }) => { + const nextScale = Math.min(3, Math.max(0.4, state.scale * factor)); + const graphX = (anchor.x - state.x) / state.scale; + const graphY = (anchor.y - state.y) / state.scale; + state.x = anchor.x - graphX * nextScale; + state.y = anchor.y - graphY * nextScale; + state.scale = nextScale; + applyViewport(); + }; + const reset = () => { + state.scale = 1; + state.x = 0; + state.y = 0; + applyViewport(); + }; + const selectCustomNode = (node) => { + viewport.querySelectorAll(".custom-node.selected").forEach((item) => item.classList.remove("selected")); + selectedCustomNodeId = node?.dataset.custom === "true" ? node.dataset.nodeId : null; + if (selectedCustomNodeId) { + node.classList.add("selected"); + document.querySelector("#map-save-status").textContent = "Selected node. Press Delete to remove it."; + } + }; + + svg.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + const node = event.target.closest(".radial-node"); + selectCustomNode(node); + const point = node ? graphPoint(event) : svgPoint(event); + interaction = { + type: node ? "node" : "pan", + node, + start: point, + originX: node ? Number(node.dataset.x) : state.x, + originY: node ? Number(node.dataset.y) : state.y, + moved: false, + }; + svg.setPointerCapture(event.pointerId); + svg.classList.add("interacting"); + node?.classList.add("dragging"); + }); + svg.addEventListener("pointermove", (event) => { + if (!interaction) return; + const point = interaction.type === "node" ? graphPoint(event) : svgPoint(event); + const dx = point.x - interaction.start.x; + const dy = point.y - interaction.start.y; + interaction.moved ||= Math.hypot(dx, dy) > 3; + if (interaction.type === "node") { + updateNode(interaction.node, interaction.originX + dx, interaction.originY + dy); + } else { + state.x = interaction.originX + dx; + state.y = interaction.originY + dy; + applyViewport(); + } + }); + const endInteraction = (event) => { + if (!interaction) return; + suppressClick = interaction.moved; + if (interaction.moved && interaction.node?.dataset.custom === "true") { + saveStored(customMapNodesKey, customMapNodes); + document.querySelector("#map-save-status").textContent = "Position saved"; + } + interaction.node?.classList.remove("dragging"); + interaction = null; + svg.classList.remove("interacting"); + if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId); + }; + svg.addEventListener("pointerup", endInteraction); + svg.addEventListener("pointercancel", endInteraction); + svg.addEventListener("click", (event) => { + if (!suppressClick) return; + event.preventDefault(); + event.stopImmediatePropagation(); + suppressClick = false; + }, true); + svg.addEventListener("wheel", (event) => { + event.preventDefault(); + zoomAt(event.deltaY < 0 ? 1.12 : 1 / 1.12, svgPoint(event)); + }, { passive: false }); + + document.querySelector("#map-zoom-in").onclick = () => zoomAt(1.2); + document.querySelector("#map-zoom-out").onclick = () => zoomAt(1 / 1.2); + document.querySelector("#map-reset").onclick = reset; + reset(); +} + +function renderRadialOverallMap(map) { + const width = 1900; + const height = 1450; + const center = { x: width / 2, y: height / 2 }; + const subjectRadius = 300; + const topicRadii = [520, 680]; + const sectorWidth = (Math.PI * 2) / subjects.length; + const svg = svgElement("svg", { + id: "overall-memory-map-svg", + class: "radial-memory-map", + viewBox: `0 0 ${width} ${height}`, + role: "graphics-document", + "aria-label": "GATE DA syllabus radial memory map", + }); + const viewport = svgElement("g", { class: "radial-viewport" }); + const edges = svgElement("g", { class: "radial-edges" }); + const nodes = svgElement("g", { class: "radial-nodes" }); + const rootId = "root"; + const positions = new Map([[rootId, center]]); + const nodeColors = new Map([[rootId, mapColors[0]]]); + subjects.forEach((subject, subjectIndex) => { - lines.push(` subject${subjectIndex}["${escapeMermaid(subject.title)}"]`); + const color = mapColors[subjectIndex] || mapColors[0]; + const subjectAngle = -Math.PI / 2 + subjectIndex * sectorWidth; + const subjectPoint = radialPoint(center, subjectRadius, subjectAngle); + positions.set(subject.id, subjectPoint); + nodeColors.set(subject.id, color); + edges.append(svgElement("line", { + x1: center.x, + y1: center.y, + x2: subjectPoint.x, + y2: subjectPoint.y, + stroke: color.branch, + class: "root-edge", + "data-from": rootId, + "data-to": subject.id, + })); + + const firstRingCount = Math.ceil(subject.topics.length / 2); subject.topics.forEach((topic, topicIndex) => { - lines.push(` topic${subjectIndex}_${topicIndex}["${escapeMermaid(topic)}"]`); + const ringIndex = topicIndex < firstRingCount ? 0 : 1; + const ringTopics = ringIndex === 0 + ? subject.topics.slice(0, firstRingCount) + : subject.topics.slice(firstRingCount); + const position = ringIndex === 0 ? topicIndex : topicIndex - firstRingCount; + const spread = sectorWidth * 0.72; + const topicAngle = subjectAngle + ( + ringTopics.length === 1 ? 0 : -spread / 2 + (spread * position) / (ringTopics.length - 1) + ); + const topicPoint = radialPoint(center, topicRadii[ringIndex], topicAngle); + positions.set(`${subject.id}:${topicIndex}`, topicPoint); + nodeColors.set(`${subject.id}:${topicIndex}`, color); + edges.append(svgElement("line", { + x1: subjectPoint.x, + y1: subjectPoint.y, + x2: topicPoint.x, + y2: topicPoint.y, + stroke: color.branch, + class: "topic-edge", + "data-from": subject.id, + "data-to": `${subject.id}:${topicIndex}`, + })); + const slideIndex = slides.findIndex( + (slide) => slide.subject.id === subject.id && slide.topic === topic, + ); + nodes.append(radialNode({ + id: `${subject.id}:${topicIndex}`, + ...topicPoint, + width: 170, + label: topic, + fill: color.soft, + stroke: color.branch, + color: color.text, + className: "topic-node", + activate: () => { + setPresentationView("focus", false); + currentSlide = slideIndex; + renderSlide(); + }, + })); }); + + nodes.append(radialNode({ + id: subject.id, + ...subjectPoint, + width: 190, + label: subject.title, + fill: color.branch, + stroke: color.text, + color: color.foreground, + className: "subject-node", + activate: () => window.open(subject.guide, "_blank", "noopener,noreferrer"), + })); }); - return lines.join("\n"); + + nodes.append(radialNode({ + id: rootId, + ...center, + width: 120, + label: "GATE DA", + fill: "#312e81", + stroke: "#a78bfa", + color: "#ffffff", + className: "root-node", + })); + customMapNodes.forEach((customNode, index) => { + const parentPoint = positions.get(customNode.parentId) || center; + const color = nodeColors.get(customNode.parentId) || mapColors[index % mapColors.length]; + const angle = index * 2.399963229728653; + const x = Number.isFinite(customNode.x) ? customNode.x : parentPoint.x + Math.cos(angle) * 190; + const y = Number.isFinite(customNode.y) ? customNode.y : parentPoint.y + Math.sin(angle) * 190; + customNode.x = x; + customNode.y = y; + positions.set(customNode.id, { x, y }); + nodeColors.set(customNode.id, color); + edges.append(svgElement("line", { + x1: parentPoint.x, + y1: parentPoint.y, + x2: x, + y2: y, + stroke: color.branch, + class: "custom-edge topic-edge", + "data-from": customNode.parentId, + "data-to": customNode.id, + })); + const node = radialNode({ + id: customNode.id, + x, + y, + width: 170, + label: customNode.title, + fill: "#fff7d6", + stroke: color.branch, + color: "#57450c", + className: "custom-node", + activate: customNode.url + ? () => window.open(customNode.url, "_blank", "noopener,noreferrer") + : undefined, + }); + node.dataset.custom = "true"; + if (customNode.id === selectedCustomNodeId) node.classList.add("selected"); + nodes.append(node); + }); + saveStored(customMapNodesKey, customMapNodes); + viewport.append(edges, nodes); + svg.append(viewport); + map.replaceChildren(svg); + enableMapInteractions(map, svg, viewport); + map.dataset.rendered = "true"; +} + +function populateMapParentOptions() { + const select = document.querySelector("#map-node-parent"); + const options = [{ id: "root", label: "GATE DA" }]; + subjects.forEach((subject) => { + options.push({ id: subject.id, label: subject.title }); + subject.topics.forEach((topic, index) => { + options.push({ id: `${subject.id}:${index}`, label: `${subject.short} · ${topic}` }); + }); + }); + customMapNodes.forEach((node) => { + options.push({ id: node.id, label: `Custom · ${node.title}` }); + }); + select.replaceChildren(...options.map((option) => { + const element = document.createElement("option"); + element.value = option.id; + element.textContent = option.label; + return element; + })); +} + +function deleteSelectedCustomNode() { + if (!selectedCustomNodeId) return; + const deleting = new Set([selectedCustomNodeId]); + let foundDescendant = true; + while (foundDescendant) { + foundDescendant = false; + customMapNodes.forEach((node) => { + if (deleting.has(node.parentId) && !deleting.has(node.id)) { + deleting.add(node.id); + foundDescendant = true; + } + }); + } + customMapNodes = customMapNodes.filter((node) => !deleting.has(node.id)); + selectedCustomNodeId = null; + saveStored(customMapNodesKey, customMapNodes); + populateMapParentOptions(); + document.querySelector("#map-save-status").textContent = `${deleting.size} node${deleting.size === 1 ? "" : "s"} deleted`; + rerenderOverallMap(); +} + +function rerenderOverallMap() { + const map = document.querySelector("#overall-map"); + map.dataset.rendered = "false"; + renderOverallMap(); } function renderOverallMapFallback(map) { const grid = document.createElement("div"); grid.className = "overall-map-fallback"; - subjects.forEach((subject) => { + subjects.forEach((subject, subjectIndex) => { const section = document.createElement("section"); const title = document.createElement("h3"); const topics = document.createElement("ul"); + section.style.setProperty("--map-color", (mapColors[subjectIndex] || mapColors[0]).branch); + section.style.setProperty("--map-soft-color", (mapColors[subjectIndex] || mapColors[0]).soft); title.textContent = subject.title; subject.topics.forEach((topic) => { const item = document.createElement("li"); @@ -223,15 +634,8 @@ function renderOverallMapFallback(map) { async function renderOverallMap() { const map = document.querySelector("#overall-map"); if (map.dataset.rendered === "true") return; - if (!window.mermaid) { - renderOverallMapFallback(map); - return; - } try { - const { svg, bindFunctions } = await window.mermaid.render("overall-memory-map-svg", overallMapDefinition()); - map.innerHTML = svg; - bindFunctions?.(map); - map.dataset.rendered = "true"; + renderRadialOverallMap(map); } catch { renderOverallMapFallback(map); } @@ -275,6 +679,12 @@ function conceptNode(topic, current) { function renderResourceMapFallback(map, slide, topicMap) { const concepts = document.createElement("div"); const resources = document.createElement("div"); + const subjectIndex = subjects.findIndex((subject) => subject.id === slide.subject.id); + const color = mapColors[subjectIndex] || mapColors[0]; + map.style.setProperty("--map-color", color.branch); + map.style.setProperty("--map-soft-color", color.soft); + map.style.setProperty("--map-text-color", color.text); + map.style.setProperty("--map-foreground-color", color.foreground); concepts.className = "concept-branch"; resources.className = "resource-branch"; concepts.append(conceptNode(slide.topic, true), ...topicMap.related.map((topic) => conceptNode(topic, false))); @@ -392,11 +802,14 @@ document.querySelectorAll("[data-present-start]").forEach((button) => { searchInput.addEventListener("input", applyFilters); document.addEventListener("keydown", (event) => { - if (!presentation.hidden && document.activeElement !== notesInput) { + const editing = event.target instanceof Element + && event.target.matches("input, textarea, select, [contenteditable='true']"); + if (!presentation.hidden && !editing) { if (event.key === "ArrowLeft") moveSlide(-1); if (event.key === "ArrowRight") moveSlide(1); - if (event.key.toLowerCase() === "n") toggleNotes(); - if (event.key.toLowerCase() === "f") toggleFullscreen(); + if (event.shiftKey && event.key.toLowerCase() === "n") toggleNotes(); + if (event.shiftKey && event.key.toLowerCase() === "f") toggleFullscreen(); + if (event.key === "Delete" && currentPresentationView === "overall") deleteSelectedCustomNode(); if (event.key === "Escape") closePresentation(); return; } @@ -416,6 +829,41 @@ document.querySelector("#menu-toggle").addEventListener("click", () => sidebar.c document.querySelector("#menu-close").addEventListener("click", () => sidebar.classList.remove("open")); document.querySelector("#fullscreen-toggle").addEventListener("click", toggleFullscreen); document.querySelector("#presentation-fullscreen").addEventListener("click", toggleFullscreen); +document.querySelector("#open-memory-map").addEventListener("click", () => { + presentation.hidden = false; + document.body.classList.add("presentation-open"); + setPresentationView("overall"); +}); +document.querySelector("#map-add-node").addEventListener("click", () => { + const form = document.querySelector("#map-node-form"); + form.hidden = !form.hidden; + if (!form.hidden) { + populateMapParentOptions(); + if (selectedCustomNodeId) document.querySelector("#map-node-parent").value = selectedCustomNodeId; + document.querySelector("#map-node-title").focus(); + } +}); +document.querySelector("#map-node-cancel").addEventListener("click", () => { + document.querySelector("#map-node-form").hidden = true; +}); +document.querySelector("#map-node-form").addEventListener("submit", (event) => { + event.preventDefault(); + const title = document.querySelector("#map-node-title").value.trim(); + const url = document.querySelector("#map-node-url").value.trim(); + if (!title) return; + customMapNodes.push({ + id: `custom:${Date.now()}`, + parentId: document.querySelector("#map-node-parent").value, + title, + url, + }); + saveStored(customMapNodesKey, customMapNodes); + populateMapParentOptions(); + event.currentTarget.reset(); + event.currentTarget.hidden = true; + document.querySelector("#map-save-status").textContent = "New node saved"; + rerenderOverallMap(); +}); document.querySelector("#present-all").addEventListener("click", () => openPresentation(0)); document.querySelector("#presentation-close").addEventListener("click", closePresentation); focusTab.addEventListener("click", () => { @@ -442,7 +890,11 @@ const preferredTheme = localStorage.getItem(themeKey) || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); document.body.classList.toggle("dark", preferredTheme === "dark"); -window.mermaid?.initialize({ startOnLoad: false, securityLevel: "loose", theme: preferredTheme === "dark" ? "dark" : "neutral" }); +window.mermaid?.initialize({ + startOnLoad: false, + securityLevel: "loose", + theme: preferredTheme === "dark" ? "dark" : "neutral", +}); themeToggle.addEventListener("click", () => { document.body.classList.toggle("dark"); @@ -463,6 +915,7 @@ subjectCards.forEach((card) => observer.observe(card)); syncInputs(); updateProgress(); applyFilters(); +populateMapParentOptions(); const presentMatch = location.hash.match(/^#present=(\d+)$/); if (location.hash === "#map") { diff --git a/webapp/static/styles.css b/webapp/static/styles.css index adfff51..d7a9eed 100644 --- a/webapp/static/styles.css +++ b/webapp/static/styles.css @@ -986,6 +986,77 @@ footer a { font-weight: 750; } +.map-tools { + display: flex; + gap: 6px; +} + +.map-tools .icon-button { + width: 34px; + min-height: 34px; + font-size: 13px; +} + +.map-tools .button { + min-height: 34px; + padding: 0 11px; + font-size: 9px; +} + +#map-reset { + width: auto; + padding: 0 11px; + font-size: 9px; +} + +.map-node-form { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(220px, 1.3fr) auto auto; + align-items: end; + gap: 10px; + margin-bottom: 14px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface-soft); +} + +.map-node-form[hidden] { + display: none; +} + +.map-node-form label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 9px; + font-weight: 800; +} + +.map-node-form input, +.map-node-form select { + min-width: 0; + min-height: 38px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 10px; + outline: none; + background: var(--surface); + color: var(--text); + font: inherit; +} + +.map-node-form input:focus, +.map-node-form select:focus { + border-color: var(--accent); +} + +.map-save-status { + grid-column: 1 / -1; + color: var(--muted); + font-size: 9px; +} + .map-legend span { display: flex; align-items: center; @@ -1003,17 +1074,18 @@ footer a { } .subject-key { - background: #06b6d4; + background: linear-gradient(135deg, #8b5cf6 0 25%, #06b6d4 25% 50%, #f59e0b 50% 75%, #22c55e 75%); } .topic-key { - background: #64748b; + border: 1px solid #8b5cf6; + background: #ede9fe; } .overall-map { min-height: 600px; padding: 20px; - overflow: auto; + overflow: hidden; border: 1px solid var(--line); border-radius: 20px; background: var(--surface-soft); @@ -1024,6 +1096,92 @@ footer a { height: auto; } +.radial-memory-map { + width: 100%; + min-width: 1500px; + height: auto; + cursor: grab; + font-family: inherit; + touch-action: none; + user-select: none; +} + +.radial-memory-map.interacting { + cursor: grabbing; +} + +.radial-edges line { + stroke-linecap: round; +} + +.radial-edges .root-edge { + stroke-width: 12px; + opacity: 0.76; +} + +.radial-edges .topic-edge { + stroke-width: 4px; + opacity: 0.58; +} + +.radial-node rect { + stroke-width: 2px; + transition: filter 160ms ease, stroke-width 160ms ease; +} + +.radial-node { + cursor: grab; +} + +.radial-node.dragging { + cursor: grabbing; +} + +.radial-node.dragging rect { + filter: brightness(0.95) saturate(1.12); + stroke-width: 4px; +} + +.radial-node.custom-node rect { + stroke-dasharray: 6 3; +} + +.radial-node.custom-node.selected rect { + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--accent) 60%, transparent)); + stroke: var(--accent); + stroke-dasharray: none; + stroke-width: 5px; +} + +.radial-node text { + font-size: 12px; + font-weight: 700; + pointer-events: none; +} + +.radial-node.subject-node text, +.radial-node.root-node text { + font-weight: 850; +} + +.overall-map .map-link { + cursor: pointer; + outline: none; +} + +.overall-map .map-link .node-bkg, +.overall-map .map-link rect { + transition: filter 160ms ease, stroke-width 160ms ease; +} + +.overall-map .map-link:hover .node-bkg, +.overall-map .map-link:focus-visible .node-bkg, +.overall-map .map-link:hover rect, +.overall-map .map-link:focus-visible rect { + filter: brightness(0.96) saturate(1.08); + stroke-width: 3px !important; +} + .overall-map-fallback { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); @@ -1032,12 +1190,14 @@ footer a { .overall-map-fallback section { padding: 18px; - border: 1px solid var(--line); + border: 1px solid var(--map-color); border-radius: 16px; + background: var(--map-soft-color); } .overall-map-fallback h3 { margin: 0 0 12px; + color: var(--map-color); } .overall-map-fallback ul { @@ -1187,11 +1347,18 @@ footer a { } .concept-node.current { - border-color: var(--accent); - background: var(--accent-soft); + border-color: var(--map-text-color, var(--accent)); + background: var(--map-color, var(--accent-soft)); + color: var(--map-foreground-color, var(--text)); font-size: 11px; } +.concept-node:not(.current) { + border-color: var(--map-color, var(--line)); + background: var(--map-soft-color, var(--surface-soft)); + color: var(--map-text-color, var(--text)); +} + .concept-node:disabled { cursor: default; } @@ -1377,9 +1544,17 @@ body.presentation-open { .subject-grid { grid-template-columns: 1fr; } + + .map-node-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 780px) { + .map-node-form { + grid-template-columns: 1fr; + } + .button-label { display: none; } diff --git a/webapp/templates/index.html b/webapp/templates/index.html index 9214b84..825537e 100644 --- a/webapp/templates/index.html +++ b/webapp/templates/index.html @@ -38,6 +38,9 @@ Fullscreen + @@ -252,15 +255,38 @@

Topic

Complete syllabus

Overall memory map

-

Seven subjects and all {{ total_topics }} concepts in one connected view.

+

Seven subjects and all {{ total_topics }} concepts in one connected view. Select any node to open it.

GATE DA - Subject - Topic + Subject branch + Related topics +
+
+ + + +
-
+ +
@@ -282,8 +308,8 @@

Topic notes

diff --git a/webapp/tests.py b/webapp/tests.py index 1dd62f6..3e1d3ad 100644 --- a/webapp/tests.py +++ b/webapp/tests.py @@ -20,6 +20,10 @@ def test_homepage(self): self.assertIn(b'id="resource-map"', response.data) self.assertIn(b'id="overall-map"', response.data) self.assertIn(b'id="overall-map-tab"', response.data) + self.assertIn(b'id="open-memory-map"', response.data) + self.assertIn(b'id="map-zoom-in"', response.data) + self.assertIn(b'id="map-add-node"', response.data) + self.assertIn(b"Shift + N notes", response.data) self.assertIn(b"mermaid@11.4.1", response.data) self.assertIn(PAGES_URL.encode(), response.data) @@ -52,7 +56,19 @@ def test_static_export(self): (output / "static" / "app.js").read_bytes(), ) self.assertIn( - b"overallMapDefinition", + b"renderRadialOverallMap", + (output / "static" / "app.js").read_bytes(), + ) + self.assertIn( + b"enableMapInteractions", + (output / "static" / "app.js").read_bytes(), + ) + self.assertIn( + b"customMapNodesKey", + (output / "static" / "app.js").read_bytes(), + ) + self.assertIn( + b"deleteSelectedCustomNode", (output / "static" / "app.js").read_bytes(), )