diff --git a/frontend/src/features/canvas/Canvas.module.css b/frontend/src/features/canvas/Canvas.module.css index 811f745..50ff485 100644 --- a/frontend/src/features/canvas/Canvas.module.css +++ b/frontend/src/features/canvas/Canvas.module.css @@ -4,10 +4,11 @@ justify-content: center; align-items: flex-start; padding: 12px 0px; - height: 100vh; + height: 100%; background-color: var(--bg-tertiary); box-sizing: border-box; transition: background-color 200ms ease; + position: relative; } /* Main SVG canvas */ @@ -16,10 +17,7 @@ height: 100%; display: block; background: var(--canvas-bg); - border: 1px solid var(--border-primary); - border-radius: 10px; - box-shadow: var(--shadow-sm); - transition: background-color 200ms ease, border-color 200ms ease; + transition: background-color 200ms ease; } /* Override memory-viz box background fills for light mode */ diff --git a/frontend/src/features/canvas/Canvas.tsx b/frontend/src/features/canvas/Canvas.tsx index a3723c2..8e740f0 100644 --- a/frontend/src/features/canvas/Canvas.tsx +++ b/frontend/src/features/canvas/Canvas.tsx @@ -675,7 +675,7 @@ function Canvas({ className={styles.canvas} style={{ width: "100%", - height: canvasHeight ?? undefined, + minHeight: canvasHeight ?? undefined, display: "block", padding: 0, border: 0, diff --git a/frontend/src/features/canvas/components/LinkedListPreview.module.css b/frontend/src/features/canvas/components/LinkedListPreview.module.css new file mode 100644 index 0000000..98803f9 --- /dev/null +++ b/frontend/src/features/canvas/components/LinkedListPreview.module.css @@ -0,0 +1,83 @@ +.arrowFill { + fill: var(--text-primary); +} + +.diagramScroller { + overflow-x: auto; + overflow-y: hidden; +} + +.diagram { + display: block; + width: auto; + min-width: 100%; + height: auto; + max-width: none; +} + +.label { + fill: var(--text-primary); + font-size: 13px; + font-weight: 600; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.labelArrow { + stroke: var(--text-primary); + stroke-width: 1; +} + +.nextLabel { + fill: var(--text-primary); + font-size: 11px; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.nodeRect { + fill: var(--bg-primary); + stroke: var(--text-primary); + stroke-width: 1.5; +} + +.divider { + stroke: var(--text-primary); + stroke-width: 1.5; +} + +.dot { + fill: var(--text-primary); +} + +.nodeValue { + fill: var(--text-primary); + font-size: 15px; + font-weight: 500; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.linkLine { + stroke: var(--text-primary); + stroke-width: 1.5; +} + +.arrowHead { + fill: var(--text-primary); +} + +.cyclePath { + fill: none; + stroke: var(--accent-blue); + stroke-width: 2; + stroke-dasharray: 5 4; +} + +.footerText { + fill: var(--text-tertiary); + font-size: 12px; + font-style: italic; +} + +.list { + display: block; + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/features/canvas/components/LinkedListPreview.tsx b/frontend/src/features/canvas/components/LinkedListPreview.tsx new file mode 100644 index 0000000..595aa65 --- /dev/null +++ b/frontend/src/features/canvas/components/LinkedListPreview.tsx @@ -0,0 +1,183 @@ +import React, { useEffect, useId, useRef, useState } from "react"; +import styles from "./LinkedListPreview.module.css"; +import { LinkedListGraph } from "../utils/linkedListDetector"; +import { + buildLinkedListGraphLayout, + getRectPerimeterPoint, + createEdgePath, +} from "../utils/linkedListRenderer"; + +interface LinkedListPreviewProps { + graph: LinkedListGraph; +} + +export default function LinkedListPreview({ graph }: LinkedListPreviewProps) { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(600); + const rawId = useId(); + const markerId = `ll-arrow-${rawId.replace(/:/g, "")}`; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + const w = entry.contentRect.width; + if (w > 0) setContainerWidth(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + if (graph.nodes.length === 0) { + return null; + } + + const layout = buildLinkedListGraphLayout(graph, containerWidth); + + return ( +
+ + + + + + + + {layout.edges.map((edge) => { + const from = layout.byId[edge.fromId]; + const to = layout.byId[edge.toId]; + if (!from || !to) return null; + + const start = { x: from.dotX, y: from.dotY }; + const end = getRectPerimeterPoint( + { x: to.x, y: to.y, width: to.width, height: to.height }, + start + ); + const d = createEdgePath(start, end.point, end.side); + + return ( + + ); + })} + + {layout.nodes.map((node) => ( + + {node.labels.map((label, labelIndex) => { + const lx = node.x + node.width / 2; + const labelY = + node.y - 16 - (node.labels.length - 1 - labelIndex) * 14; + const isBottomLabel = labelIndex === node.labels.length - 1; + + return ( + + + {label} + + {isBottomLabel && ( + + )} + + ); + })} + + + + + {node.value} + + + {node.nextText === "•" ? ( + + ) : node.nextText === "None" ? ( + <> + + + None + + + ) : ( + + {node.nextText} + + )} + + ))} + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/features/canvas/components/StructurePanel.module.css b/frontend/src/features/canvas/components/StructurePanel.module.css new file mode 100644 index 0000000..6725ae0 --- /dev/null +++ b/frontend/src/features/canvas/components/StructurePanel.module.css @@ -0,0 +1,96 @@ +.dock { + flex: 0 0 auto; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 10px; + box-shadow: var(--shadow-sm); + overflow: hidden; + transition: background-color 200ms ease, border-color 200ms ease; +} + +.resizeHandle { + height: var(--resize-handle-width, 8px); + flex: 0 0 auto; + cursor: ns-resize; + user-select: none; + -webkit-user-select: none; + position: relative; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.15s ease; +} +.resizeHandle:hover { + background: var(--bg-hover); +} +.resizeHandle::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: var(--resize-handle-hover-width, 4px); + background: transparent; + border-radius: 2px; + transition: background 0.15s ease; +} +.resizeHandle:hover::before { + background: var(--accent-blue); +} +.resizeHandle:active { + background: var(--bg-tertiary); +} +.resizeHandle:active::before { + background: var(--accent-blue-hover); +} + +.header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + border-bottom: 1px solid var(--border-secondary); +} + +.label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tabs { display: flex; gap: 4px; } +.tab { + all: unset; + cursor: pointer; + font-size: 0.78rem; + padding: 3px 10px; + border-radius: 6px; + color: var(--text-tertiary); +} +.tabActive { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.collapseBtn { + all: unset; + cursor: pointer; + padding: 2px 8px; + color: var(--text-tertiary); + font-size: 0.9rem; +} +.collapseBtn:hover { color: var(--text-primary); } + +.body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px; +} \ No newline at end of file diff --git a/frontend/src/features/canvas/components/StructurePanel.tsx b/frontend/src/features/canvas/components/StructurePanel.tsx new file mode 100644 index 0000000..9b3f500 --- /dev/null +++ b/frontend/src/features/canvas/components/StructurePanel.tsx @@ -0,0 +1,98 @@ +import React, { useMemo, useRef } from "react"; +import { CanvasElement } from "../../shared/types"; +import { createElementsByIdMap } from "../utils/pythonTutorReferences"; +import { detectLinkedListGraph } from "../utils/linkedListDetector"; +import LinkedListPreview from "./LinkedListPreview"; +import styles from "./StructurePanel.module.css"; + +interface StructurePanelProps { + enabled: boolean; + elements: CanvasElement[]; + collapsed: boolean; + height: number; + onCollapsedChange: (collapsed: boolean) => void; + onHeightChange: (height: number) => void; +} + +export default function StructurePanel({ + enabled, + elements, + collapsed, + height, + onCollapsedChange, + onHeightChange, +}: StructurePanelProps) { + const dragRef = useRef<{ y: number; h: number } | null>(null); + + const elementsById = useMemo(() => createElementsByIdMap(elements), [elements]); + + const graph = useMemo(() => { + if (!enabled) { + return { nodes: [], edges: [] }; + } + + return detectLinkedListGraph(elements, elementsById); + }, [enabled, elements, elementsById]); + + if (!enabled || graph.nodes.length === 0) return null; + + const onHandleDown = (e: React.MouseEvent) => { + e.preventDefault(); + dragRef.current = { y: e.clientY, h: height }; + document.body.style.userSelect = "none"; + + const onMove = (ev: MouseEvent) => { + if (!dragRef.current) return; + + const delta = dragRef.current.y - ev.clientY; + const next = Math.max( + 110, + Math.min(window.innerHeight * 0.45, dragRef.current.h + delta) + ); + + onHeightChange(next); + }; + + const onUp = () => { + dragRef.current = null; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + return ( + <> + {!collapsed && ( +
+ )} + +
+
+ Linked list visualization + + +
+ + {!collapsed && ( +
+ +
+ )} +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/features/canvas/utils/linkedListDetector.ts b/frontend/src/features/canvas/utils/linkedListDetector.ts new file mode 100644 index 0000000..1d8c49a --- /dev/null +++ b/frontend/src/features/canvas/utils/linkedListDetector.ts @@ -0,0 +1,502 @@ +import { CanvasElement, ClassKind } from "../../shared/types"; +import { + createElementsByIdMap, + formatPrimitiveValue, + isPrimitiveElement, +} from "./pythonTutorReferences"; + +const PREFERRED_ROOT_LABELS = ["head", "first", "_first"]; +const VALUE_ATTRIBUTE_NAMES = ["item", "value", "data", "val", "_value"]; + +export type LinkedListNextKind = + | "node" + | "none" + | "missing" + | "invalid" + | "cycle"; + +export interface LinkedListNodeSnapshot { + nodeId: number; + className: string; + value: string; + labels: string[]; + nextTargetId: number | null; + nextKind: LinkedListNextKind; +} + +export interface LinkedListStructure { + key: string; + rootNodeId: number; + className: string; + nodes: LinkedListNodeSnapshot[]; + cycleTargetId: number | null; +} + +export interface LinkedListDetectionResult { + structures: LinkedListStructure[]; +} + +export interface LinkedListGraphNode { + nodeId: number; + className: string; + value: string; + labels: string[]; + nextKind: LinkedListNextKind; + nextTargetId: number | null; + group: number; +} + +export interface LinkedListGraphEdge { + fromId: number; + toId: number; +} + +export interface LinkedListGraph { + nodes: LinkedListGraphNode[]; + edges: LinkedListGraphEdge[]; +} + +interface CandidateNode { + nodeId: number; + className: string; + value: string; + labels: string[]; + nextTargetId: number | null; + nextKind: Exclude; +} + +function isClassElement( + element: CanvasElement | undefined +): element is CanvasElement & { kind: ClassKind } { + return element?.kind.name === "class"; +} + +function hasNextAttribute(element: CanvasElement | undefined): boolean { + return Boolean( + isClassElement(element) && + element.kind.classVariables.some((attribute) => attribute.name === "next") + ); +} + +function addLabel( + labelMap: Map>, + targetId: number | null, + label: string +): void { + if (targetId === null || !label.trim()) { + return; + } + + const existing = labelMap.get(targetId) ?? new Set(); + existing.add(label); + labelMap.set(targetId, existing); +} + +function buildNodeLabelMap(elements: CanvasElement[]): Map> { + const labelMap = new Map>(); + + elements.forEach((element) => { + if (element.kind.name === "function") { + element.kind.params.forEach((param) => { + addLabel(labelMap, param.targetId, param.name); + }); + return; + } + + if (element.kind.name === "class" && !hasNextAttribute(element)) { + element.kind.classVariables.forEach((attribute) => { + addLabel(labelMap, attribute.targetId, attribute.name); + }); + } + }); + + return labelMap; +} + +function formatReferenceValue( + targetId: number | null, + elementsById: Map +): string { + if (targetId === null) { + return ""; + } + + const target = elementsById.get(targetId); + if (!target) { + return "?"; + } + + if (isPrimitiveElement(target)) { + return formatPrimitiveValue(target.kind); + } + + if (typeof target.id === "number") { + return `id${target.id}`; + } + + return "?"; +} + +function resolveNodeValue( + element: CanvasElement & { kind: ClassKind }, + elementsById: Map +): string { + const attributes = element.kind.classVariables.filter( + (attribute) => attribute.name !== "next" + ); + + for (const attributeName of VALUE_ATTRIBUTE_NAMES) { + const match = attributes.find((attribute) => attribute.name === attributeName); + if (match) { + return formatReferenceValue(match.targetId, elementsById); + } + } + + for (const attribute of attributes) { + const value = formatReferenceValue(attribute.targetId, elementsById); + if (value) { + return value; + } + } + + return element.kind.className || "value"; +} + +function resolveNextPointer( + element: CanvasElement & { kind: ClassKind }, + elementsById: Map +): Pick | null { + const nextAttribute = element.kind.classVariables.find( + (attribute) => attribute.name === "next" + ); + + if (!nextAttribute) { + return null; + } + + if (nextAttribute.targetId === null) { + return { + nextTargetId: null, + nextKind: "missing", + }; + } + + const target = elementsById.get(nextAttribute.targetId); + if (!target) { + return { + nextTargetId: nextAttribute.targetId, + nextKind: "missing", + }; + } + + if (isPrimitiveElement(target) && target.kind.type === "NoneType") { + return { + nextTargetId: null, + nextKind: "none", + }; + } + + if ( + isClassElement(target) && + hasNextAttribute(target) && + typeof target.id === "number" + ) { + return { + nextTargetId: target.id, + nextKind: "node", + }; + } + + return { + nextTargetId: typeof target.id === "number" ? target.id : nextAttribute.targetId, + nextKind: "invalid", + }; +} + +function buildCandidateMap( + elements: CanvasElement[], + elementsById: Map, + labelMap: Map> +): Map { + const candidateMap = new Map(); + + elements.forEach((element) => { + if (!isClassElement(element) || typeof element.id !== "number") { + return; + } + + const nextPointer = resolveNextPointer(element, elementsById); + if (!nextPointer) { + return; + } + + if (nextPointer.nextKind === "invalid") { + return; + } + + candidateMap.set(element.id, { + nodeId: element.id, + className: element.kind.className || "Node", + value: resolveNodeValue(element, elementsById), + labels: Array.from(labelMap.get(element.id) ?? []), + nextTargetId: nextPointer.nextTargetId, + nextKind: nextPointer.nextKind, + }); + }); + + return candidateMap; +} + +function getRootPriority(labels: string[]): number { + const normalized = labels.map((label) => label.trim().toLowerCase()); + const priorityIndex = PREFERRED_ROOT_LABELS.findIndex((preferred) => + normalized.includes(preferred) + ); + + return priorityIndex === -1 ? Number.MAX_SAFE_INTEGER : priorityIndex; +} + +function compareStartNodes( + leftId: number, + rightId: number, + candidateMap: Map +): number { + const left = candidateMap.get(leftId); + const right = candidateMap.get(rightId); + + if (!left || !right) { + return leftId - rightId; + } + + const rootPriorityDifference = + getRootPriority(left.labels) - getRootPriority(right.labels); + if (rootPriorityDifference !== 0) { + return rootPriorityDifference; + } + + const labelCountDifference = right.labels.length - left.labels.length; + if (labelCountDifference !== 0) { + return labelCountDifference; + } + + return leftId - rightId; +} + +function traverseStructure( + startId: number, + candidateMap: Map +): LinkedListStructure | null { + const start = candidateMap.get(startId); + if (!start) { + return null; + } + + const visited = new Set(); + const nodes: LinkedListNodeSnapshot[] = []; + let currentId: number | null = startId; + let cycleTargetId: number | null = null; + + while (currentId !== null) { + const current = candidateMap.get(currentId); + if (!current) { + break; + } + + visited.add(currentId); + + let nextKind: LinkedListNextKind = current.nextKind; + const nextTargetId = current.nextTargetId; + + if ( + current.nextKind === "node" && + nextTargetId !== null && + visited.has(nextTargetId) + ) { + nextKind = "cycle"; + cycleTargetId = nextTargetId; + } + + nodes.push({ + nodeId: current.nodeId, + className: current.className, + value: current.value, + labels: current.labels, + nextTargetId, + nextKind, + }); + + if (nextKind !== "node" || nextTargetId === null) { + break; + } + + currentId = nextTargetId; + } + + if (nodes.length === 0) { + return null; + } + + return { + key: `${start.className}-${startId}`, + rootNodeId: startId, + className: start.className, + nodes, + cycleTargetId, + }; +} + +export function detectLinkedLists( + elements: CanvasElement[], + elementsById: Map = createElementsByIdMap(elements) +): LinkedListDetectionResult { + const labelMap = buildNodeLabelMap(elements); + const candidateMap = buildCandidateMap(elements, elementsById, labelMap); + + if (candidateMap.size === 0) { + return { structures: [] }; + } + + const incomingNextCounts = new Map(); + candidateMap.forEach((candidate) => { + incomingNextCounts.set(candidate.nodeId, 0); + }); + + candidateMap.forEach((candidate) => { + if (candidate.nextKind !== "node" || candidate.nextTargetId === null) { + return; + } + + if (candidateMap.has(candidate.nextTargetId)) { + incomingNextCounts.set( + candidate.nextTargetId, + (incomingNextCounts.get(candidate.nextTargetId) ?? 0) + 1 + ); + } + }); + + const orderedRoots = Array.from(candidateMap.keys()) + .filter((nodeId) => (incomingNextCounts.get(nodeId) ?? 0) === 0) + .sort((left, right) => compareStartNodes(left, right, candidateMap)); + + const coveredNodeIds = new Set(); + const structures: LinkedListStructure[] = []; + + for (const rootId of orderedRoots) { + if (coveredNodeIds.has(rootId)) { + continue; + } + + const structure = traverseStructure(rootId, candidateMap); + if (!structure) { + continue; + } + + structure.nodes.forEach((node) => coveredNodeIds.add(node.nodeId)); + structures.push(structure); + } + + const remainingStarts = Array.from(candidateMap.keys()) + .filter((nodeId) => !coveredNodeIds.has(nodeId)) + .sort((left, right) => compareStartNodes(left, right, candidateMap)); + + for (const startId of remainingStarts) { + const structure = traverseStructure(startId, candidateMap); + if (!structure) { + continue; + } + + structure.nodes.forEach((node) => coveredNodeIds.add(node.nodeId)); + structures.push(structure); + } + + return { structures }; +} + +export function detectLinkedListGraph( + elements: CanvasElement[], + elementsById: Map = createElementsByIdMap(elements) +): LinkedListGraph { + const labelMap = buildNodeLabelMap(elements); + const candidateMap = buildCandidateMap(elements, elementsById, labelMap); + + if (candidateMap.size === 0) { + return { nodes: [], edges: [] }; + } + + const incoming = new Map(); + + candidateMap.forEach((candidate) => { + incoming.set(candidate.nodeId, 0); + }); + + candidateMap.forEach((candidate) => { + if ( + candidate.nextKind === "node" && + candidate.nextTargetId !== null && + candidateMap.has(candidate.nextTargetId) + ) { + incoming.set( + candidate.nextTargetId, + (incoming.get(candidate.nextTargetId) ?? 0) + 1 + ); + } + }); + + const ordered: number[] = []; + const groupOf = new Map(); + const seen = new Set(); + let group = 0; + const walk = (startId: number) => { + let id: number | null = startId; + let advanced = false; + while (id !== null && candidateMap.has(id) && !seen.has(id)) { + seen.add(id); + ordered.push(id); + groupOf.set(id, group); + advanced = true; + const c: CandidateNode = candidateMap.get(id)!; + id = c.nextKind === "node" ? c.nextTargetId : null; + } + if (advanced) group += 1; + }; + Array.from(candidateMap.keys()) + .filter((id) => (incoming.get(id) ?? 0) === 0) + .sort((a, b) => compareStartNodes(a, b, candidateMap)) + .forEach(walk); + Array.from(candidateMap.keys()) + .sort((a, b) => compareStartNodes(a, b, candidateMap)) + .forEach(walk); + + const nodes: LinkedListGraphNode[] = ordered.map((id) => { + const candidate = candidateMap.get(id)!; + return { + nodeId: candidate.nodeId, + className: candidate.className, + value: candidate.value, + labels: candidate.labels, + nextKind: candidate.nextKind, + nextTargetId: candidate.nextTargetId, + group: groupOf.get(id) ?? 0, + }; + }); + + const edges: LinkedListGraphEdge[] = []; + + candidateMap.forEach((candidate) => { + if ( + candidate.nextKind === "node" && + candidate.nextTargetId !== null && + candidateMap.has(candidate.nextTargetId) + ) { + edges.push({ + fromId: candidate.nodeId, + toId: candidate.nextTargetId, + }); + } + }); + + return { + nodes, + edges, + }; +} \ No newline at end of file diff --git a/frontend/src/features/canvas/utils/linkedListRenderer.ts b/frontend/src/features/canvas/utils/linkedListRenderer.ts new file mode 100644 index 0000000..03b4b6a --- /dev/null +++ b/frontend/src/features/canvas/utils/linkedListRenderer.ts @@ -0,0 +1,239 @@ +import { + LinkedListNextKind, + LinkedListStructure, + LinkedListGraph, +} from "./linkedListDetector"; + +const CARD_PADDING_X = 12; +const CARD_PADDING_Y = 8; +const NODE_WIDTH = 110; +const NODE_HEIGHT = 45; +const NODE_GAP = 35; +const VALUE_RATIO = 0.6; +const LABEL_LINE_HEIGHT = 16; +const LABEL_TO_NODE_GAP = 14; +const FOOTER_HEIGHT = 24; +const ROW_GAP = 44; + +export interface LinkedListNodeLayout { + nodeId: number; + x: number; + y: number; + width: number; + height: number; + dividerX: number; + dotX: number; + dotY: number; + nextCellWidth: number; + value: string; + nextText: string; + labels: string[]; + nextKind: LinkedListNextKind; +} + +export interface LinkedListStructureLayout { + width: number; + height: number; + connectorY: number; + cyclePath: string | null; + footerText: string | null; + nodes: LinkedListNodeLayout[]; +} + +export interface LinkedListGraphLayout { + width: number; + height: number; + connectorY: number; + nodes: LinkedListNodeLayout[]; + byId: Record; + edges: { + fromId: number; + toId: number; + }[]; +} + +function getNextCellText(nextKind: LinkedListNextKind): string { + switch (nextKind) { + case "none": + return "None"; + case "missing": + return "?"; + case "invalid": + return "?"; + case "cycle": + return "•"; + case "node": + default: + return "•"; + } +} + +function createCyclePath( + nodes: LinkedListNodeLayout[], + targetNodeId: number +): string | null { + const source = nodes[nodes.length - 1]; + const target = nodes.find((node) => node.nodeId === targetNodeId); + + if (!source || !target) { + return null; + } + + const startX = source.x + source.width - 8; + const startY = source.y + source.height / 2; + const endX = target.x + target.width / 2; + const endY = target.y - 6; + const controlY = target.y - 40; + + return `M ${startX} ${startY} C ${startX + 30} ${controlY}, ${endX + 20} ${controlY}, ${endX} ${endY}`; +} + +function getFooterText(nextKind: LinkedListNextKind, hasCycle: boolean): string | null { + if (hasCycle) { + return "Cycle detected"; + } + + if (nextKind === "missing") { + return "Chain stopped at a missing reference"; + } + + if (nextKind === "invalid") { + return "Chain stopped at a non-node next reference"; + } + + return null; +} + +export function buildLinkedListGraphLayout( + graph: LinkedListGraph, + containerWidth: number +): LinkedListGraphLayout { + const maxLabelLines = Math.max( + 1, + ...graph.nodes.map((node) => Math.max(1, node.labels.length)) + ); + + const labelBand = maxLabelLines * LABEL_LINE_HEIGHT + LABEL_TO_NODE_GAP; + const cellW = NODE_WIDTH + NODE_GAP; + const rowH = labelBand + NODE_HEIGHT + ROW_GAP; + + const usable = Math.max(containerWidth - CARD_PADDING_X * 2, NODE_WIDTH); + const perRow = Math.max(1, Math.floor((usable + NODE_GAP) / cellW)); + + let col = 0; + let row = 0; + let prevGroup = graph.nodes.length > 0 ? graph.nodes[0].group : 0; + let maxCol = 0; + + const nodes: LinkedListNodeLayout[] = graph.nodes.map((node) => { + if (node.group !== prevGroup || col >= perRow) { + col = 0; + row += 1; + prevGroup = node.group; + } + + const x = CARD_PADDING_X + col * cellW; + const y = CARD_PADDING_Y + row * rowH + labelBand; + const dividerX = x + NODE_WIDTH * VALUE_RATIO; + + if (col + 1 > maxCol) { + maxCol = col + 1; + } + col += 1; + + return { + nodeId: node.nodeId, + x, + y, + width: NODE_WIDTH, + height: NODE_HEIGHT, + dividerX, + dotX: dividerX + (NODE_WIDTH * (1 - VALUE_RATIO)) / 2, + dotY: y + NODE_HEIGHT / 2, + nextCellWidth: NODE_WIDTH * (1 - VALUE_RATIO), + value: node.value, + nextText: getNextCellText(node.nextKind), + labels: node.labels, + nextKind: node.nextKind, + }; + }); + + const byId: Record = {}; + nodes.forEach((node) => { + byId[node.nodeId] = node; + }); + + const rowsUsed = graph.nodes.length > 0 ? row + 1 : 1; + const colsUsed = Math.max(1, maxCol); + + const width = Math.max( + containerWidth, + CARD_PADDING_X * 2 + colsUsed * cellW - NODE_GAP + ); + const height = CARD_PADDING_Y * 2 + rowsUsed * rowH; + + return { + width, + height, + connectorY: 0, + nodes, + byId, + edges: graph.edges, + }; +} + +type RectSide = "left" | "right" | "top" | "bottom"; + +export function getRectPerimeterPoint( + rect: { x: number; y: number; width: number; height: number }, + toward: { x: number; y: number } +): { point: { x: number; y: number }; side: RectSide } { + const cx = rect.x + rect.width / 2; + const cy = rect.y + rect.height / 2; + const dx = toward.x - cx; + const dy = toward.y - cy; + + if (dx === 0 && dy === 0) { + return { point: { x: cx, y: cy }, side: "top" }; + } + + const scaleX = dx === 0 ? Infinity : rect.width / 2 / Math.abs(dx); + const scaleY = dy === 0 ? Infinity : rect.height / 2 / Math.abs(dy); + const useHorizontalEdge = scaleY < scaleX; + const side: RectSide = useHorizontalEdge + ? dy < 0 ? "top" : "bottom" + : dx < 0 ? "left" : "right"; + const scale = useHorizontalEdge ? scaleY : scaleX; + + return { + point: { x: cx + dx * scale, y: cy + dy * scale }, + side, + }; +} + +export function createEdgePath( + start: { x: number; y: number }, + end: { x: number; y: number }, + entrySide: RectSide +): string { + const dx = end.x - start.x; + const dy = end.y - start.y; + const departVertically = Math.abs(dy) > Math.abs(dx); + const startOff = Math.max(20, (departVertically ? Math.abs(dy) : Math.abs(dx)) * 0.35); + const endOff = Math.max( + 20, + (entrySide === "top" || entrySide === "bottom" ? Math.abs(dy) : Math.abs(dx)) * 0.35 + ); + + const c1 = departVertically + ? { x: start.x, y: start.y + (dy >= 0 ? 1 : -1) * startOff } + : { x: start.x + (dx >= 0 ? 1 : -1) * startOff, y: start.y }; + + const c2 = + entrySide === "left" ? { x: end.x - endOff, y: end.y } + : entrySide === "right" ? { x: end.x + endOff, y: end.y } + : entrySide === "top" ? { x: end.x, y: end.y - endOff } + : { x: end.x, y: end.y + endOff }; + + return `M ${start.x} ${start.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${end.x} ${end.y}`; +} \ No newline at end of file diff --git a/frontend/src/features/canvasControls/CanvasControls.module.css b/frontend/src/features/canvasControls/CanvasControls.module.css index 723ece9..4808cb4 100644 --- a/frontend/src/features/canvasControls/CanvasControls.module.css +++ b/frontend/src/features/canvasControls/CanvasControls.module.css @@ -140,6 +140,17 @@ padding-top: 8px; } +.sectionHeading { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-secondary); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + .controlLabel { font-size: 0.9rem; font-weight: 500; diff --git a/frontend/src/features/canvasControls/CanvasControls.tsx b/frontend/src/features/canvasControls/CanvasControls.tsx index 00b3e5d..06dfb61 100644 --- a/frontend/src/features/canvasControls/CanvasControls.tsx +++ b/frontend/src/features/canvasControls/CanvasControls.tsx @@ -30,6 +30,8 @@ interface CanvasControlsProps { onPythonTutorStandalonePrimitivesChange?: (value: boolean) => void; fontScale?: number; onFontScaleChange?: (delta: number) => void; + showLinkedListView?: boolean; + onShowLinkedListViewChange?: (value: boolean) => void; } type ControlTab = "actions" | "view" | "settings"; @@ -77,6 +79,8 @@ export default function CanvasControls({ onPythonTutorStandalonePrimitivesChange, fontScale = 1, onFontScaleChange, + showLinkedListView = false, + onShowLinkedListViewChange, }: CanvasControlsProps) { const { theme, toggleTheme } = useTheme(); const isDarkMode = theme === 'dark'; @@ -312,6 +316,37 @@ export default function CanvasControls({
)} + {onShowLinkedListViewChange && ( + <> +
+ Structure Visualizations +
+ +
+ + +
+ + )} +
diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css b/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css index 15398ce..771d714 100644 --- a/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css +++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css @@ -87,6 +87,7 @@ .canvasColumn { flex: 1; min-width: 0; + min-height: 0; position: relative; display: flex; flex-direction: column; @@ -95,7 +96,14 @@ .canvasArea { flex: 1; position: relative; + min-height: 0; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 10px; + box-shadow: var(--shadow-sm); overflow: hidden; + box-sizing: border-box; + transition: background-color 200ms ease, border-color 200ms ease; } .infoPanel { diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx b/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx index ebf7505..986e753 100644 --- a/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx +++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx @@ -1,4 +1,5 @@ import Canvas from "../canvas/Canvas"; +import StructurePanel from "../canvas/components/StructurePanel"; import Palette from "../palette/Palette"; import ConfirmationModal from "./components/ConfirmationModal"; import InformationTabs from "../informationTabs/InformationTabs"; @@ -347,6 +348,9 @@ export default function MemoryModelEditor({ visualStyle: state.visualStyle, pythonTutorReferenceArrows: state.pythonTutorReferenceArrows, pythonTutorStandalonePrimitives: state.pythonTutorStandalonePrimitives, + showLinkedListView: state.showLinkedListView, + structurePanelCollapsed: state.structurePanelCollapsed, + structurePanelHeight: state.structurePanelHeight, questionView: state.questionView, isInfoPanelOpen: state.isInfoPanelOpen, canvasScale, @@ -549,6 +553,8 @@ export default function MemoryModelEditor({ onPythonTutorStandalonePrimitivesChange={ state.setPythonTutorStandalonePrimitives } + showLinkedListView={state.showLinkedListView} + onShowLinkedListViewChange={state.setShowLinkedListView} fontScale={fontScale} onFontScaleChange={adjustFontScale} /> @@ -597,6 +603,15 @@ export default function MemoryModelEditor({ /> + + {state.jsonOutput && (
{state.jsonOutput}
)} diff --git a/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts b/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts index 5117fc5..a30fec9 100644 --- a/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts +++ b/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts @@ -22,6 +22,9 @@ export function useUILocalStorage(state: UIState): void { state.visualStyle, state.pythonTutorReferenceArrows, state.pythonTutorStandalonePrimitives, + state.showLinkedListView, + state.structurePanelCollapsed, + state.structurePanelHeight, state.questionView, state.isInfoPanelOpen, state.canvasScale, diff --git a/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts b/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts index 0dc546c..dbbd3e7 100644 --- a/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts +++ b/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts @@ -57,6 +57,15 @@ export function useMemoryModelEditorState(sandbox: boolean) { useState(initialUIData.pythonTutorReferenceArrows ?? false); const [pythonTutorStandalonePrimitives, setPythonTutorStandalonePrimitives] = useState(initialUIData.pythonTutorStandalonePrimitives ?? false); + const [showLinkedListView, setShowLinkedListView] = useState( + initialUIData.showLinkedListView ?? false + ); + const [structurePanelCollapsed, setStructurePanelCollapsed] = useState( + initialUIData.structurePanelCollapsed ?? false + ); + const [structurePanelHeight, setStructurePanelHeight] = useState( + initialUIData.structurePanelHeight ?? 180 + ); const [questionView, setQuestionView] = useState( initialUIData.questionView ?? "root" ); @@ -114,6 +123,12 @@ export function useMemoryModelEditorState(sandbox: boolean) { setPythonTutorReferenceArrows, pythonTutorStandalonePrimitives, setPythonTutorStandalonePrimitives, + showLinkedListView, + setShowLinkedListView, + structurePanelCollapsed, + setStructurePanelCollapsed, + structurePanelHeight, + setStructurePanelHeight, questionView, setQuestionView, tabScrollPositions, diff --git a/frontend/src/features/memoryModelEditor/utils/localStorage.ts b/frontend/src/features/memoryModelEditor/utils/localStorage.ts index ea4247a..1e0a691 100644 --- a/frontend/src/features/memoryModelEditor/utils/localStorage.ts +++ b/frontend/src/features/memoryModelEditor/utils/localStorage.ts @@ -30,6 +30,9 @@ const DEFAULT_UI_STATE = { visualStyle: "memoryviz" as VisualStyle, pythonTutorReferenceArrows: false, pythonTutorStandalonePrimitives: false, + showLinkedListView: false, + structurePanelCollapsed: false, + structurePanelHeight: 200, }; export interface CanvasData { @@ -73,6 +76,9 @@ export interface UIState { visualStyle?: VisualStyle; pythonTutorReferenceArrows?: boolean; pythonTutorStandalonePrimitives?: boolean; + showLinkedListView?: boolean; + structurePanelCollapsed?: boolean; + structurePanelHeight?: number; questionView?: QuestionView; isInfoPanelOpen?: boolean; canvasScale?: number; @@ -138,6 +144,15 @@ export function loadInitialUIData(): UIState { const pythonTutorStandalonePrimitives = parsed?.pythonTutorStandalonePrimitives === true; + const showLinkedListView = parsed?.showLinkedListView === true; + const structurePanelCollapsed = parsed?.structurePanelCollapsed === true; + const structurePanelHeight = + typeof parsed?.structurePanelHeight === "number" && + parsed.structurePanelHeight >= 140 && + parsed.structurePanelHeight <= 800 + ? parsed.structurePanelHeight + : undefined; + const validViews = ["root", "loading", "test", "list", "question", "practice", "prep"]; const questionView = typeof parsed?.questionView === "string" && validViews.includes(parsed.questionView) && parsed.questionView !== "loading" @@ -170,6 +185,7 @@ export function loadInitialUIData(): UIState { visualStyle, pythonTutorReferenceArrows, pythonTutorStandalonePrimitives, + showLinkedListView, questionView, isInfoPanelOpen, canvasScale, diff --git a/frontend/src/features/palette/Palette.tsx b/frontend/src/features/palette/Palette.tsx index 16056e1..4ac7c05 100644 --- a/frontend/src/features/palette/Palette.tsx +++ b/frontend/src/features/palette/Palette.tsx @@ -78,6 +78,8 @@ interface PaletteProps { onPythonTutorReferenceArrowsChange?: (value: boolean) => void; pythonTutorStandalonePrimitives?: boolean; onPythonTutorStandalonePrimitivesChange?: (value: boolean) => void; + showLinkedListView?: boolean; + onShowLinkedListViewChange?: (value: boolean) => void; fontScale?: number; onFontScaleChange?: (delta: number) => void; } @@ -145,6 +147,8 @@ export default function Palette({ onPythonTutorReferenceArrowsChange, pythonTutorStandalonePrimitives = false, onPythonTutorStandalonePrimitivesChange, + showLinkedListView = false, + onShowLinkedListViewChange, fontScale, onFontScaleChange, }: PaletteProps) { @@ -279,6 +283,8 @@ export default function Palette({ onPythonTutorStandalonePrimitivesChange={ onPythonTutorStandalonePrimitivesChange } + showLinkedListView={showLinkedListView} + onShowLinkedListViewChange={onShowLinkedListViewChange} fontScale={fontScale} onFontScaleChange={onFontScaleChange} />