Skip to content

Commit f200e02

Browse files
committed
feat: Add Linked List visualizer
New /linked-list route animating singly and doubly linked lists with insert (head/tail/index), delete (by value/index), search, and reverse. - Box-style two-cell nodes (data + pointer cell) rendered in SVG, with CSS-transition movement and a SMIL mount fade-in - Head/tail null terminators shown as slashes; doubly mode adds prev pointers and a left null terminator on the head - Page drives step-by-step animations imperatively with refs + sleep, mirroring the recursion-tree pattern - Added a Linked List card to the home page grid
1 parent 7270842 commit f200e02

8 files changed

Lines changed: 548 additions & 0 deletions

File tree

public/images/linked-list.png

216 KB
Loading

src/app/components/algorithm-cards.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ const algorithms = [
6363
title: 'Game of Life',
6464
description: "Visualize the Game of Life cellular automaton",
6565
image: '/AlgorithmVisualizer/images/game-of-life.png?height=200&width=300'
66+
},{
67+
id: 'linked-list',
68+
title: 'Linked List',
69+
description: "Visualize insertion, deletion, search, and reversal on singly and doubly linked lists",
70+
image: '/AlgorithmVisualizer/images/linked-list.png?height=200&width=300'
6671
},
6772
// {
6873
// id: '15-puzzle',

src/app/linked-list/arrow.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Pointer arrow between two points. Uses the shared <marker id="arrow">
2+
// defined in the canvas <defs>.
3+
4+
export default function Arrow({ x1, y1, x2, y2, color = '#64748b' }) {
5+
return (
6+
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke={color} strokeWidth="0.6"
7+
markerEnd="url(#arrow)" />
8+
);
9+
}

src/app/linked-list/canvas.jsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import Node, { NODE_W, NODE_H } from './node';
2+
import Arrow from './arrow';
3+
4+
const VB_W = 240;
5+
const VB_H = 90;
6+
const PAD = 16;
7+
const Y = 48;
8+
9+
export default function Canvas({ nodes, nextOf, prevOf, listType, nodeState = {}, pointers = [] }) {
10+
const count = nodes.length;
11+
const spacing = count > 1
12+
? Math.min(46, (VB_W - 2 * PAD - NODE_W) / (count - 1))
13+
: 0;
14+
const totalW = (count - 1) * spacing + NODE_W;
15+
const startX = Math.max(PAD, (VB_W - totalW) / 2);
16+
17+
const posById = {};
18+
nodes.forEach((n, i) => { posById[n.id] = { x: startX + i * spacing, i }; });
19+
20+
const doubly = listType === 1;
21+
const nextY = doubly ? Y - 3 : Y;
22+
const prevY = Y + 3;
23+
24+
const tail = nodes[count - 1];
25+
26+
return (
27+
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} xmlns="http://www.w3.org/2000/svg" className="w-full">
28+
<defs>
29+
<filter id="nodeShadow" x="-20%" y="-20%" width="140%" height="140%">
30+
<feDropShadow dx="0.3" dy="0.4" stdDeviation="0.5" floodOpacity="0.25" />
31+
</filter>
32+
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5"
33+
markerWidth="4" markerHeight="4" orient="auto-start-reverse">
34+
<path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b" />
35+
</marker>
36+
</defs>
37+
38+
{/* next pointers */}
39+
{nodes.map((n) => {
40+
const target = nextOf[n.id];
41+
if (target == null) return null;
42+
const src = posById[n.id];
43+
const dst = posById[target];
44+
if (!dst) return null;
45+
// forward (head->tail) points from the next-cell to the node's left;
46+
// a flipped (leftward) link during reverse points the other way.
47+
if (dst.i > src.i) {
48+
return <Arrow key={'nx' + n.id} x1={src.x + NODE_W} y1={nextY} x2={dst.x} y2={nextY} />;
49+
}
50+
return <Arrow key={'nx' + n.id} x1={src.x} y1={nextY} x2={dst.x + NODE_W} y2={nextY} />;
51+
})}
52+
53+
{/* prev pointers (doubly only) */}
54+
{doubly && nodes.map((n) => {
55+
const target = prevOf[n.id];
56+
if (target == null) return null;
57+
const src = posById[n.id];
58+
const dst = posById[target];
59+
if (!dst || Math.abs(dst.i - src.i) !== 1) return null;
60+
return <Arrow key={'pv' + n.id} x1={src.x} y1={prevY} x2={dst.x + NODE_W} y2={prevY} color="#94a3b8" />;
61+
})}
62+
63+
{/* null terminator after the tail */}
64+
{tail && nextOf[tail.id] == null && (
65+
<text x={posById[tail.id].x + NODE_W + 9} y={Y} textAnchor="middle" dominantBaseline="central"
66+
style={{ font: '4px sans-serif' }} fill="#94a3b8">null</text>
67+
)}
68+
69+
{/* null terminator before the head (doubly: head.prev = null) */}
70+
{doubly && nodes[0] && prevOf[nodes[0].id] == null && (
71+
<text x={posById[nodes[0].id].x - 9} y={Y} textAnchor="middle" dominantBaseline="central"
72+
style={{ font: '4px sans-serif' }} fill="#94a3b8">null</text>
73+
)}
74+
75+
{/* nodes */}
76+
{nodes.map((n) => (
77+
<Node key={n.id} x={posById[n.id].x} y={Y} value={n.value}
78+
listType={listType} state={nodeState[n.id]}
79+
isHead={prevOf[n.id] == null} isTail={nextOf[n.id] == null} />
80+
))}
81+
82+
{/* head / tail labels */}
83+
{nodes[0] && (
84+
<text x={posById[nodes[0].id].x + NODE_W / 2} y={Y - NODE_H / 2 - 4}
85+
textAnchor="middle" style={{ font: '3.5px sans-serif', fontWeight: 600 }} fill="#475569">head</text>
86+
)}
87+
{tail && count > 1 && (
88+
<text x={posById[tail.id].x + NODE_W / 2} y={Y - NODE_H / 2 - 4}
89+
textAnchor="middle" style={{ font: '3.5px sans-serif', fontWeight: 600 }} fill="#475569">tail</text>
90+
)}
91+
92+
{/* operation pointer captions (curr / prev / next / slow / fast) */}
93+
{pointers.map((p, idx) => {
94+
if (p.nodeId == null || !posById[p.nodeId]) return null;
95+
const px = posById[p.nodeId].x + NODE_W / 2;
96+
return (
97+
<text key={idx} x={px} y={Y + NODE_H / 2 + 7} textAnchor="middle"
98+
style={{ font: '4px sans-serif', fontWeight: 700 }} fill="#0f172a">
99+
{p.label}
100+
</text>
101+
);
102+
})}
103+
</svg>
104+
);
105+
}

src/app/linked-list/menu.jsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { useState } from 'react';
2+
import { CustomSelect } from '@/components/custom-select';
3+
import { CustomSlider } from '@/components/custom-slider';
4+
import { CustomToggle } from '@/components/custom-toggle';
5+
import { Button } from '@/components/ui/button';
6+
import { Play, Shuffle, RotateCcw } from 'lucide-react';
7+
8+
const OPERATIONS = [
9+
'Insert at head',
10+
'Insert at tail',
11+
'Insert at index',
12+
'Delete by value',
13+
'Delete at index',
14+
'Search',
15+
'Reverse',
16+
];
17+
18+
const NEEDS_VALUE = new Set([0, 1, 2, 3, 5]);
19+
const NEEDS_INDEX = new Set([2, 4]);
20+
21+
export default function Menu({
22+
disabled,
23+
onListTypeChange,
24+
onOperationChange,
25+
onValueChange,
26+
onIndexChange,
27+
onSpeedChange,
28+
onVisualize,
29+
onRandomize,
30+
onReset,
31+
}) {
32+
const [operation, setOperation] = useState(0);
33+
34+
const handleOperation = (op) => {
35+
setOperation(op);
36+
onOperationChange(op);
37+
};
38+
39+
return (
40+
<div className="w-64 bg-gray-100 p-4 space-y-6">
41+
<h2 className="text-lg font-semibold">Linked List</h2>
42+
43+
<div className="space-y-3">
44+
<div className="flex items-center gap-2">
45+
<div className="h-px flex-1 bg-gray-300" />
46+
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Config</span>
47+
<div className="h-px flex-1 bg-gray-300" />
48+
</div>
49+
<CustomToggle
50+
title="Doubly linked"
51+
onCheckedChange={(checked) => onListTypeChange(checked ? 1 : 0)}
52+
disabled={disabled}
53+
/>
54+
<CustomSelect
55+
title="Operation"
56+
options={OPERATIONS}
57+
onChange={handleOperation}
58+
disabled={disabled}
59+
/>
60+
{NEEDS_VALUE.has(operation) && (
61+
<div className="space-y-2">
62+
<label className="text-sm font-medium whitespace-nowrap">Value</label>
63+
<input
64+
type="number"
65+
defaultValue={42}
66+
onChange={(e) => onValueChange(e.target.value)}
67+
disabled={disabled}
68+
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"
69+
/>
70+
</div>
71+
)}
72+
{NEEDS_INDEX.has(operation) && (
73+
<div className="space-y-2">
74+
<label className="text-sm font-medium whitespace-nowrap">Index</label>
75+
<input
76+
type="number"
77+
defaultValue={1}
78+
min={0}
79+
onChange={(e) => onIndexChange(e.target.value)}
80+
disabled={disabled}
81+
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"
82+
/>
83+
</div>
84+
)}
85+
<CustomSlider
86+
title="Speed"
87+
defaultValue={50}
88+
min={10}
89+
max={100}
90+
step={1}
91+
onChange={onSpeedChange}
92+
/>
93+
</div>
94+
95+
<div className="space-y-3">
96+
<div className="flex items-center gap-2">
97+
<div className="h-px flex-1 bg-gray-300" />
98+
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</span>
99+
<div className="h-px flex-1 bg-gray-300" />
100+
</div>
101+
<Button className="w-full" onClick={onVisualize} disabled={disabled}>
102+
<Play /> Visualize
103+
</Button>
104+
<div className="flex gap-2">
105+
<Button className="flex-1" variant="outline" onClick={onRandomize} disabled={disabled}>
106+
<Shuffle /> Random
107+
</Button>
108+
<Button className="flex-1" variant="outline" onClick={onReset} disabled={disabled}>
109+
<RotateCcw /> Reset
110+
</Button>
111+
</div>
112+
</div>
113+
</div>
114+
);
115+
}

src/app/linked-list/node.jsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Two-cell box node. Movement is a CSS transform transition (animates whenever
2+
// x changes); the mount fade-in is a one-shot SMIL <animate begin="0s"> that
3+
// fires only when a freshly-inserted node is added to the DOM.
4+
5+
const COLORS = {
6+
normal: { fill: '#0d9488', stroke: '#0f766e' },
7+
active: { fill: '#f59e0b', stroke: '#d97706' },
8+
found: { fill: '#16a34a', stroke: '#15803d' },
9+
remove: { fill: '#e11d48', stroke: '#be123c' },
10+
done: { fill: '#334155', stroke: '#475569' },
11+
};
12+
13+
const W = 24;
14+
const H = 14;
15+
16+
export default function Node({ x, y, value, listType, state = 'normal', isHead, isTail }) {
17+
const c = COLORS[state] || COLORS.normal;
18+
const doubly = listType === 1;
19+
const dataMid = doubly ? 12 : 9;
20+
21+
return (
22+
<g style={{ transform: `translate(${x}px, ${y}px)`, transition: 'transform 0.4s ease' }}>
23+
<animate attributeName="opacity" from="0" to="1" dur="0.4s" begin="0s" />
24+
25+
<rect x="0" y={-H / 2} width={W} height={H} rx="1.5"
26+
fill={c.fill} stroke={c.stroke} strokeWidth="0.5" filter="url(#nodeShadow)" />
27+
28+
{/* prev cell (doubly): slash when head (prev = null), else a pointer dot */}
29+
{doubly && <line x1="6" y1={-H / 2} x2="6" y2={H / 2} stroke={c.stroke} strokeWidth="0.4" />}
30+
{doubly && (isHead
31+
? <line x1="1" y1={H / 2 - 1.5} x2="5" y2={-H / 2 + 1.5} stroke="#f8fafc" strokeWidth="0.5" />
32+
: <circle cx="3" cy="0" r="1.1" fill="#0f172a" />)}
33+
34+
<line x1="18" y1={-H / 2} x2="18" y2={H / 2} stroke={c.stroke} strokeWidth="0.4" />
35+
36+
<text x={dataMid} y="0" textAnchor="middle" dominantBaseline="central"
37+
style={{ font: '5px sans-serif', fontWeight: 600 }} fill="#f8fafc">{value}</text>
38+
39+
{/* next cell: slash when tail (next = null), else a pointer dot */}
40+
{isTail
41+
? <line x1="19" y1={H / 2 - 1.5} x2="23" y2={-H / 2 + 1.5} stroke="#f8fafc" strokeWidth="0.5" />
42+
: <circle cx="21" cy="0" r="1.1" fill="#0f172a" />}
43+
</g>
44+
);
45+
}
46+
47+
export const NODE_W = W;
48+
export const NODE_H = H;

0 commit comments

Comments
 (0)