diff --git a/public/images/linked-list.png b/public/images/linked-list.png new file mode 100644 index 0000000..05ef195 Binary files /dev/null and b/public/images/linked-list.png differ diff --git a/src/app/components/algorithm-cards.jsx b/src/app/components/algorithm-cards.jsx index 87647eb..dfd9534 100644 --- a/src/app/components/algorithm-cards.jsx +++ b/src/app/components/algorithm-cards.jsx @@ -63,6 +63,11 @@ const algorithms = [ title: 'Game of Life', description: "Visualize the Game of Life cellular automaton", image: '/AlgorithmVisualizer/images/game-of-life.png?height=200&width=300' + },{ + id: 'linked-list', + title: 'Linked List', + description: "Visualize insertion, deletion, search, and reversal on singly and doubly linked lists", + image: '/AlgorithmVisualizer/images/linked-list.png?height=200&width=300' }, // { // id: '15-puzzle', diff --git a/src/app/linked-list/arrow.jsx b/src/app/linked-list/arrow.jsx new file mode 100644 index 0000000..2ce5bdb --- /dev/null +++ b/src/app/linked-list/arrow.jsx @@ -0,0 +1,9 @@ +// Pointer arrow between two points. Uses the shared +// defined in the canvas . + +export default function Arrow({ x1, y1, x2, y2, color = '#64748b' }) { + return ( + + ); +} diff --git a/src/app/linked-list/canvas.jsx b/src/app/linked-list/canvas.jsx new file mode 100644 index 0000000..d2450c2 --- /dev/null +++ b/src/app/linked-list/canvas.jsx @@ -0,0 +1,107 @@ +import Node, { NODE_W, NODE_H } from './node'; +import Arrow from './arrow'; + +const VB_W = 240; +const VB_H = 90; +const PAD = 16; +const Y = 56; // resting row +const LIFT = 26; // how high a staged node floats above the row + +export default function Canvas({ nodes, nextOf, prevOf, listType, nodeState = {}, pointers = [], liftedId = null }) { + const count = nodes.length; + const spacing = count > 1 + ? Math.min(46, (VB_W - 2 * PAD - NODE_W) / (count - 1)) + : 0; + const totalW = (count - 1) * spacing + NODE_W; + const startX = Math.max(PAD, (VB_W - totalW) / 2); + + const posById = {}; + nodes.forEach((n, i) => { + posById[n.id] = { x: startX + i * spacing, y: n.id === liftedId ? Y - LIFT : Y, i }; + }); + + const doubly = listType === 1; + const tail = nodes[count - 1]; + + return ( + + + + + + + + + + + {/* next pointers */} + {nodes.map((n) => { + const target = nextOf[n.id]; + if (target == null) return null; + const src = posById[n.id]; + const dst = posById[target]; + if (!dst) return null; + const y1 = src.y + (doubly ? -3 : 0); + const y2 = dst.y + (doubly ? -3 : 0); + // forward link exits the next-cell to the target's left edge; + // a flipped (leftward) link during reverse points the other way. + if (dst.i > src.i) { + return ; + } + return ; + })} + + {/* prev pointers (doubly only) */} + {doubly && nodes.map((n) => { + const target = prevOf[n.id]; + if (target == null) return null; + const src = posById[n.id]; + const dst = posById[target]; + if (!dst) return null; + return ; + })} + + {/* null terminator after the tail */} + {tail && nextOf[tail.id] == null && ( + null + )} + + {/* null terminator before the head (doubly: head.prev = null) */} + {doubly && nodes[0] && prevOf[nodes[0].id] == null && ( + null + )} + + {/* nodes */} + {nodes.map((n) => ( + + ))} + + {/* head / tail labels (skip while a node is lifted to avoid clutter) */} + {nodes[0] && nodes[0].id !== liftedId && ( + head + )} + {tail && count > 1 && tail.id !== liftedId && ( + tail + )} + + {/* operation pointer captions (curr / prev / next) */} + {pointers.map((p, idx) => { + if (p.nodeId == null || !posById[p.nodeId]) return null; + const px = posById[p.nodeId].x + NODE_W / 2; + return ( + + ▲ {p.label} + + ); + })} + + ); +} diff --git a/src/app/linked-list/menu.jsx b/src/app/linked-list/menu.jsx new file mode 100644 index 0000000..e76e416 --- /dev/null +++ b/src/app/linked-list/menu.jsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { CustomSelect } from '@/components/custom-select'; +import { CustomSlider } from '@/components/custom-slider'; +import { CustomToggle } from '@/components/custom-toggle'; +import { Button } from '@/components/ui/button'; +import { Play, Shuffle, RotateCcw } from 'lucide-react'; + +const OPERATIONS = [ + 'Insert at head', + 'Insert at tail', + 'Insert at index', + 'Delete by value', + 'Delete at index', + 'Search', + 'Reverse', +]; + +const NEEDS_VALUE = new Set([0, 1, 2, 3, 5]); +const NEEDS_INDEX = new Set([2, 4]); + +export default function Menu({ + disabled, + onListTypeChange, + onOperationChange, + onValueChange, + onIndexChange, + onSpeedChange, + onVisualize, + onRandomize, + onReset, +}) { + const [operation, setOperation] = useState(0); + + const handleOperation = (op) => { + setOperation(op); + onOperationChange(op); + }; + + return ( +
+

Linked List

+ +
+
+
+ Config +
+
+ onListTypeChange(checked ? 1 : 0)} + disabled={disabled} + /> + + {NEEDS_VALUE.has(operation) && ( +
+ + onValueChange(e.target.value)} + disabled={disabled} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" + /> +
+ )} + {NEEDS_INDEX.has(operation) && ( +
+ + onIndexChange(e.target.value)} + disabled={disabled} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" + /> +
+ )} + +
+ +
+
+
+ Actions +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/linked-list/node.jsx b/src/app/linked-list/node.jsx new file mode 100644 index 0000000..0964096 --- /dev/null +++ b/src/app/linked-list/node.jsx @@ -0,0 +1,48 @@ +// Two-cell box node. Movement is a CSS transform transition (animates whenever +// x changes); the mount fade-in is a one-shot SMIL that +// fires only when a freshly-inserted node is added to the DOM. + +const COLORS = { + normal: { fill: '#0d9488', stroke: '#0f766e' }, + active: { fill: '#f59e0b', stroke: '#d97706' }, + found: { fill: '#16a34a', stroke: '#15803d' }, + remove: { fill: '#e11d48', stroke: '#be123c' }, + done: { fill: '#334155', stroke: '#475569' }, +}; + +const W = 24; +const H = 14; + +export default function Node({ x, y, value, listType, state = 'normal', isHead, isTail }) { + const c = COLORS[state] || COLORS.normal; + const doubly = listType === 1; + const dataMid = doubly ? 12 : 9; + + return ( + + + + + + {/* prev cell (doubly): slash when head (prev = null), else a pointer dot */} + {doubly && } + {doubly && (isHead + ? + : )} + + + + {value} + + {/* next cell: slash when tail (next = null), else a pointer dot */} + {isTail + ? + : } + + ); +} + +export const NODE_W = W; +export const NODE_H = H; diff --git a/src/app/linked-list/page.jsx b/src/app/linked-list/page.jsx new file mode 100644 index 0000000..cf892bb --- /dev/null +++ b/src/app/linked-list/page.jsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState, useRef } from 'react'; +import Navbar from '@/components/navbar'; +import { + buildList, + linkify, + randomValues, + reduceStructure, + insertActions, + deleteByValueActions, + deleteByIndexActions, + searchActions, + reverseActions, +} from '@/lib/algorithms/linkedList'; +import Canvas from './canvas'; +import Menu from './menu'; + +// Slider (10..100, higher = faster) -> per-step delay in ms. +const toDelay = (s) => 1100 - s * 10; + +export default function LinkedList() { + // Deterministic seed so server-rendered (static export) and client markup + // match; randomness is applied only via the Random button after mount. + const [initial] = useState(() => buildList([10, 24, 37, 42, 58])); + + const [nodes, setNodes] = useState(initial.nodes); + const [nextOf, setNextOf] = useState(initial.nextOf); + const [prevOf, setPrevOf] = useState(initial.prevOf); + const [listType, setListType] = useState(0); + const [nodeState, setNodeState] = useState({}); + const [pointers, setPointers] = useState([]); + const [liftedId, setLiftedId] = useState(null); + const [isRunning, setIsRunning] = useState(false); + + const nodesRef = useRef(initial.nodes); + const nextOfRef = useRef(initial.nextOf); + const prevOfRef = useRef(initial.prevOf); + const listTypeRef = useRef(0); + const isRunningRef = useRef(false); + const speedRef = useRef(toDelay(50)); + const operationRef = useRef(0); + const valueRef = useRef('42'); + const indexRef = useRef('1'); + + // --- state appliers (keep refs and React state in sync) --- + const applyStructure = ({ nodes: n, nextOf: nx, prevOf: pv }) => { + nodesRef.current = n; setNodes(n); + nextOfRef.current = nx; setNextOf(nx); + prevOfRef.current = pv; setPrevOf(pv); + }; + const relink = (arr) => applyStructure({ nodes: arr, ...linkify(arr) }); + + // Apply one action: visual actions touch marks/pointers; everything else is + // a structural change handled by the lib reducer. + const applyAction = (action) => { + switch (action.type) { + case 'mark': + setNodeState((s) => ({ ...s, [action.id]: action.state })); + break; + case 'pointers': + setPointers(action.items); + break; + case 'lift': + setLiftedId(action.id); + break; + case 'drop': + setLiftedId(null); + break; + case 'clear': + setNodeState({}); + setPointers([]); + setLiftedId(null); + break; + default: + if (action.type === 'stageNode') setLiftedId(action.id); + applyStructure(reduceStructure( + { nodes: nodesRef.current, nextOf: nextOfRef.current, prevOf: prevOfRef.current }, + action, + )); + } + }; + + const runActions = async (actions) => { + if (!actions.length || isRunningRef.current) return; + isRunningRef.current = true; + setIsRunning(true); + setNodeState({}); + setPointers([]); + setLiftedId(null); + for (const action of actions) { + applyAction(action); + await sleep(speedRef.current); + } + isRunningRef.current = false; + setIsRunning(false); + }; + + const handleVisualize = () => { + const op = operationRef.current; + const list = { + nodes: nodesRef.current, + nextOf: nextOfRef.current, + prevOf: prevOfRef.current, + listType: listTypeRef.current, + }; + const value = Number(valueRef.current); + let actions = []; + if (op === 0) actions = insertActions(list, 'head', value); + else if (op === 1) actions = insertActions(list, 'tail', value); + else if (op === 2) actions = insertActions(list, indexRef.current, value); + else if (op === 3) actions = deleteByValueActions(list, value); + else if (op === 4) actions = deleteByIndexActions(list, indexRef.current); + else if (op === 5) actions = searchActions(list, value); + else if (op === 6) actions = reverseActions(list); + runActions(actions); + }; + + const handleRandomize = () => { + if (isRunningRef.current) return; + setNodeState({}); + setPointers([]); + setLiftedId(null); + relink(buildList(randomValues(5)).nodes); + }; + + const handleReset = () => { + if (isRunningRef.current) return; + setNodeState({}); + setPointers([]); + setLiftedId(null); + relink(nodesRef.current.map((n) => ({ ...n }))); + }; + + return ( +
+ +
+ { listTypeRef.current = t; setListType(t); }} + onOperationChange={(op) => { operationRef.current = op; }} + onValueChange={(v) => { valueRef.current = v; }} + onIndexChange={(v) => { indexRef.current = v; }} + onSpeedChange={(s) => { speedRef.current = toDelay(s); }} + onVisualize={handleVisualize} + onRandomize={handleRandomize} + onReset={handleReset} + /> +
+
+ +
+
+
+
+ ); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/lib/algorithms/linkedList.js b/src/lib/algorithms/linkedList.js new file mode 100644 index 0000000..d02ea22 --- /dev/null +++ b/src/lib/algorithms/linkedList.js @@ -0,0 +1,225 @@ +// Linked List visualizer — data model, action planners, and a reducer. +// +// Data model: +// nodes : ordered array of { id, value } — physical left-to-right placement +// nextOf : map id -> id | null — next pointers +// prevOf : map id -> id | null — prev pointers (doubly mode) +// +// Operations don't mutate state directly. Each planner returns a flat list of +// ACTIONS; the visualizer iterates them, applying each (via reduceStructure for +// structural ones, or its own visual state for marks/pointers) with a delay +// between steps. +// +// Action shapes: +// { type: 'mark', id, state } -> set a node's highlight +// { type: 'pointers', items: [{label,nodeId}] } -> set floating pointer labels +// { type: 'clear' } -> clear all marks, pointers, lift +// { type: 'stageNode', id, value, index } -> insert node unlinked + lift it +// { type: 'lift', id } -> raise a node out of the row +// { type: 'drop' } -> lower the lifted node into the row +// { type: 'relink' } -> rebuild forward links from order +// { type: 'removeNode', id } -> remove node, relink +// { type: 'setNext', from, to } -> set a single next pointer +// { type: 'setPrev', from, to } -> set a single prev pointer +// { type: 'reorder', order: [id, ...] } -> reorder array, relink forward + +let idCounter = 0; + +export function makeNode(value) { + return { id: ++idCounter, value }; +} + +// Rebuild forward next/prev maps from the array order. +export function linkify(nodes) { + const nextOf = {}; + const prevOf = {}; + nodes.forEach((node, i) => { + nextOf[node.id] = i + 1 < nodes.length ? nodes[i + 1].id : null; + prevOf[node.id] = i - 1 >= 0 ? nodes[i - 1].id : null; + }); + return { nextOf, prevOf }; +} + +export function buildList(values) { + const nodes = values.map(makeNode); + return { nodes, ...linkify(nodes) }; +} + +export function randomValues(count = 5, min = 1, max = 99) { + return Array.from( + { length: count }, + () => Math.floor(Math.random() * (max - min + 1)) + min + ); +} + +// Apply a structural action, returning a fresh { nodes, nextOf, prevOf }. +export function reduceStructure(state, action) { + const { nodes, nextOf, prevOf } = state; + switch (action.type) { + case 'stageNode': { + // Insert the node into the array (reserving its slot) but leave it + // unlinked; existing links are untouched so the surrounding list + // stays connected (the old neighbours bridge over the staged slot). + const arr = [...nodes]; + arr.splice(action.index, 0, { id: action.id, value: action.value }); + return { + nodes: arr, + nextOf: { ...nextOf, [action.id]: null }, + prevOf: { ...prevOf, [action.id]: null }, + }; + } + case 'relink': + return { nodes, ...linkify(nodes) }; + case 'removeNode': { + const arr = nodes.filter((n) => n.id !== action.id); + return { nodes: arr, ...linkify(arr) }; + } + case 'reorder': { + const byId = Object.fromEntries(nodes.map((n) => [n.id, n])); + const arr = action.order.map((id) => byId[id]); + return { nodes: arr, ...linkify(arr) }; + } + case 'setNext': + return { nodes, nextOf: { ...nextOf, [action.from]: action.to }, prevOf }; + case 'setPrev': + return { nodes, nextOf, prevOf: { ...prevOf, [action.from]: action.to } }; + default: + return state; + } +} + +const clamp = (n, lo, hi) => Math.max(lo, Math.min(n, hi)); + +// --- planners: each returns a flat list of actions --- + +export function insertActions(list, position, value) { + const { nodes, listType } = list; + const doubly = listType === 1; + const idx = position === 'head' ? 0 + : position === 'tail' ? nodes.length + : clamp(Number(position) || 0, 0, nodes.length); + + const predId = idx - 1 >= 0 ? nodes[idx - 1].id : null; + const succId = idx < nodes.length ? nodes[idx].id : null; + + const actions = []; + // walk to the insertion point + for (let i = 0; i < idx; i++) { + actions.push({ type: 'mark', id: nodes[i].id, state: 'active' }); + actions.push({ type: 'mark', id: nodes[i].id, state: 'done' }); + } + + // 1) create the node, floating above its slot + const node = makeNode(Number.isFinite(value) ? value : 0); + actions.push({ type: 'stageNode', id: node.id, value: node.value, index: idx }); + actions.push({ type: 'mark', id: node.id, state: 'active' }); + + // 2) point the new node at its successor + actions.push({ type: 'setNext', from: node.id, to: succId }); + if (doubly && succId != null) actions.push({ type: 'setPrev', from: succId, to: node.id }); + + // 3) redirect the predecessor at the new node + if (predId != null) { + actions.push({ type: 'setNext', from: predId, to: node.id }); + if (doubly) actions.push({ type: 'setPrev', from: node.id, to: predId }); + } + + // 4) drop it into place and normalise links + actions.push({ type: 'drop' }); + actions.push({ type: 'relink' }); + actions.push({ type: 'mark', id: node.id, state: 'found' }); + actions.push({ type: 'clear' }); + return actions; +} + +// Steps to detach and remove nodes[idx]: flag it, lift it out of the row, +// bypass it with the predecessor's pointer, then drop it from the list. +function removeStepsAt(nodes, idx, doubly) { + const target = nodes[idx]; + const predId = idx - 1 >= 0 ? nodes[idx - 1].id : null; + const succId = idx + 1 < nodes.length ? nodes[idx + 1].id : null; + + const actions = [ + { type: 'mark', id: target.id, state: 'remove' }, + { type: 'lift', id: target.id }, + ]; + if (predId != null) { + actions.push({ type: 'setNext', from: predId, to: succId }); + if (doubly && succId != null) actions.push({ type: 'setPrev', from: succId, to: predId }); + } else if (doubly && succId != null) { + actions.push({ type: 'setPrev', from: succId, to: null }); + } + actions.push({ type: 'removeNode', id: target.id }); + return actions; +} + +export function deleteByValueActions(list, value) { + const { nodes, listType } = list; + const actions = []; + let idx = -1; + for (let i = 0; i < nodes.length; i++) { + actions.push({ type: 'mark', id: nodes[i].id, state: 'active' }); + if (nodes[i].value === value) { idx = i; break; } + actions.push({ type: 'mark', id: nodes[i].id, state: 'done' }); + } + if (idx >= 0) actions.push(...removeStepsAt(nodes, idx, listType === 1)); + actions.push({ type: 'clear' }); + return actions; +} + +export function deleteByIndexActions(list, index) { + const { nodes, listType } = list; + const idx = Number(index); + if (!(idx >= 0 && idx < nodes.length)) return [{ type: 'clear' }]; + + const actions = []; + for (let i = 0; i < idx; i++) { + actions.push({ type: 'mark', id: nodes[i].id, state: 'active' }); + actions.push({ type: 'mark', id: nodes[i].id, state: 'done' }); + } + actions.push(...removeStepsAt(nodes, idx, listType === 1)); + actions.push({ type: 'clear' }); + return actions; +} + +export function searchActions(list, value) { + const { nodes } = list; + const actions = []; + for (let i = 0; i < nodes.length; i++) { + actions.push({ type: 'mark', id: nodes[i].id, state: 'active' }); + if (nodes[i].value === value) { + actions.push({ type: 'mark', id: nodes[i].id, state: 'found' }); + actions.push({ type: 'clear' }); + return actions; + } + actions.push({ type: 'mark', id: nodes[i].id, state: 'done' }); + } + actions.push({ type: 'clear' }); + return actions; +} + +export function reverseActions(list) { + const { nodes, listType } = list; + if (nodes.length < 2) return []; + + const ids = nodes.map((n) => n.id); + const actions = []; + let prev = null; + for (let i = 0; i < ids.length; i++) { + const curr = ids[i]; + const next = i + 1 < ids.length ? ids[i + 1] : null; + const items = []; + if (prev != null) items.push({ label: 'prev', nodeId: prev }); + items.push({ label: 'curr', nodeId: curr }); + if (next != null) items.push({ label: 'next', nodeId: next }); + actions.push({ type: 'pointers', items }); + actions.push({ type: 'mark', id: curr, state: 'active' }); + actions.push({ type: 'setNext', from: curr, to: prev }); + if (listType === 1) actions.push({ type: 'setPrev', from: curr, to: next }); + actions.push({ type: 'mark', id: curr, state: 'done' }); + prev = curr; + } + actions.push({ type: 'reorder', order: [...ids].reverse() }); + actions.push({ type: 'clear' }); + return actions; +}