diff --git a/public/images/binary-heap.png b/public/images/binary-heap.png new file mode 100644 index 0000000..d7d90bb Binary files /dev/null and b/public/images/binary-heap.png differ diff --git a/src/app/binary-heap/menu.jsx b/src/app/binary-heap/menu.jsx new file mode 100644 index 0000000..fe3ee86 --- /dev/null +++ b/src/app/binary-heap/menu.jsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { CustomSlider } from '@/components/custom-slider'; +import { Button } from '@/components/ui/button'; +import { Plus, ArrowUpFromLine, Wand2, ArrowDownWideNarrow, RotateCcw } from 'lucide-react'; + +// Sidebar for the binary heap (min-heap): insert a value, extract the min, +// build a heap from a typed list, and run heapsort. + +export default function HeapMenu({ disabled, onInsert, onExtract, onBuild, onHeapsort, onClear, onSpeedChange }) { + const [value, setValue] = useState(''); + const [list, setList] = useState('5, 3, 8, 1, 9, 2, 7'); + + const num = () => Number(value); + const valid = value.trim() !== '' && Number.isFinite(num()); + const parsedList = () => list.split(/[\s,]+/).map(Number).filter((n) => Number.isFinite(n)); + + return ( +
+

Binary Heap (min)

+ +
+
+
+ Operation +
+
+
+ + setValue(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" + /> +
+
+ + +
+
+ +
+
+
+ Build +
+
+
+ + setList(e.target.value)} + disabled={disabled} + placeholder="e.g. 5, 3, 8, 1" + 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 +
+
+ +

Min-heap heapsort sorts in descending order.

+ + +
+
+ ); +} diff --git a/src/app/binary-heap/page.jsx b/src/app/binary-heap/page.jsx new file mode 100644 index 0000000..8ffd633 --- /dev/null +++ b/src/app/binary-heap/page.jsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from 'react'; +import Navbar from '@/components/navbar'; +import { fromValues, insertActions, extractActions, buildActions, heapsortActions } from '@/lib/algorithms/heap'; +import { binaryLayout } from '@/components/tree/layout'; +import { useTreeEditor } from '@/components/tree/use-tree-editor'; +import TreeCanvas from '@/components/tree/tree-canvas'; +import HeapMenu from './menu'; + +export default function BinaryHeap() { + const [initialTree] = useState(() => fromValues([50, 30, 70, 20, 40, 60, 80])); + const g = useTreeEditor({ initialTree }); + + return ( +
+ +
+ g.run(insertActions(g.getContext().tree, v))} + onExtract={() => g.run(extractActions(g.getContext().tree))} + onBuild={(values) => g.run(buildActions(values))} + onHeapsort={() => g.run(heapsortActions(g.getContext().tree))} + onClear={g.clear} + onSpeedChange={g.setSpeed} + /> +
+ {g.status && ( +
+ {g.status} +
+ )} + +
+
+
+ ); +} diff --git a/src/app/bst/page.jsx b/src/app/bst/page.jsx index 4ee4cfa..c15a6d8 100644 --- a/src/app/bst/page.jsx +++ b/src/app/bst/page.jsx @@ -2,26 +2,41 @@ import { useState } from 'react'; import Navbar from '@/components/navbar'; -import { fromValues, insertActions, deleteActions, searchActions } from '@/lib/algorithms/bst'; +import * as bst from '@/lib/algorithms/bst'; +import * as redBlack from '@/lib/algorithms/redBlack'; import { binaryLayout } from '@/components/tree/layout'; import { useTreeEditor } from '@/components/tree/use-tree-editor'; import TreeCanvas from '@/components/tree/tree-canvas'; import TreeMenu from '@/components/tree/tree-menu'; +const MODES = ['Binary Search Tree', 'Red-Black Tree']; +const SEED = [50, 30, 70, 20, 40, 60, 80]; +const seedFor = (mode) => (mode === 1 ? redBlack.fromValues(SEED) : bst.fromValues(SEED)); + export default function Bst() { - const [initialTree] = useState(() => fromValues([50, 30, 70, 20, 40, 60, 80])); + const [mode, setMode] = useState(0); + const [initialTree] = useState(() => seedFor(0)); const g = useTreeEditor({ initialTree }); + const algo = mode === 1 ? redBlack : bst; + + const onModeChange = (m) => { + if (g.isRunning) return; + setMode(m); + g.reset(seedFor(m)); + }; return (
g.run(insertActions(g.getContext().tree, v))} - onDelete={(v) => g.run(deleteActions(g.getContext().tree, v))} - onSearch={(v) => g.run(searchActions(g.getContext().tree, v))} + onInsert={(v) => g.run(algo.insertActions(g.getContext().tree, v))} + onDelete={(v) => g.run(algo.deleteActions(g.getContext().tree, v))} + onSearch={(v) => g.run(algo.searchActions(g.getContext().tree, v))} onClear={g.clear} onSpeedChange={g.setSpeed} /> diff --git a/src/app/components/algorithm-cards.jsx b/src/app/components/algorithm-cards.jsx index c28c7f7..633838d 100644 --- a/src/app/components/algorithm-cards.jsx +++ b/src/app/components/algorithm-cards.jsx @@ -37,9 +37,14 @@ const algorithms = [ image: '/AlgorithmVisualizer/images/network-flow.png?height=200&width=300' },{ id: 'bst', - title: 'Binary Search Tree', - description: "Insert, delete, and search on a BST with animated tree restructuring", + title: 'Binary Search Trees', + description: "Insert, delete, and search on a plain BST or a Red-Black tree, with animated rotations and recoloring", image: '/AlgorithmVisualizer/images/bst.png?height=200&width=300' + },{ + id: 'binary-heap', + title: 'Binary Heap', + description: "Min-heap: insert, extract-min, build-heap from a list, and heapsort with animated sift up and down", + image: '/AlgorithmVisualizer/images/binary-heap.png?height=200&width=300' }, { id: 'recursion-tree', diff --git a/src/app/recursion-tree/bst.js b/src/app/recursion-tree/bst.js deleted file mode 100644 index 1f08782..0000000 --- a/src/app/recursion-tree/bst.js +++ /dev/null @@ -1,25 +0,0 @@ -class Node{ - constructor(val) { - this.val = val; - this.left = undefined; - this.right = undefined; - } -} -export class BST{ - constructor() { - this.root = undefined; - } - insert(x){ - this.root = this.insertX(x,this.root); - } - insertX(x,node){ - if( node === undefined ){ - return new Node(x); - return; - } - if( node.value === x ) return node; - else if( node.value > x ) node.left = this.insertX(x,node.left); - else node.right = this.insertX(x,node.right); - } - -} \ No newline at end of file diff --git a/src/app/recursion-tree/page.jsx b/src/app/recursion-tree/page.jsx index 1f5ecd2..5083616 100644 --- a/src/app/recursion-tree/page.jsx +++ b/src/app/recursion-tree/page.jsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import Navbar from '@/components/navbar'; -import { getTree, recursionActions } from './fib'; +import { getTree, recursionActions } from '@/lib/algorithms/recursion'; import { buchheimLayout } from '@/components/tree/layout'; import { useTreeEditor } from '@/components/tree/use-tree-editor'; import TreeCanvas from '@/components/tree/tree-canvas'; diff --git a/src/components/tree/tree-canvas.jsx b/src/components/tree/tree-canvas.jsx index 08bfd61..a70156a 100644 --- a/src/components/tree/tree-canvas.jsx +++ b/src/components/tree/tree-canvas.jsx @@ -109,6 +109,7 @@ export default function TreeCanvas({ tree, layout, nodeState = {}, edgeState = { value={n.value} secondary={labels[n.id]} state={nodeState[n.id]} + color={n.color} /> ); })} diff --git a/src/components/tree/tree-menu.jsx b/src/components/tree/tree-menu.jsx index f5337cc..fbaf64e 100644 --- a/src/components/tree/tree-menu.jsx +++ b/src/components/tree/tree-menu.jsx @@ -1,12 +1,14 @@ import { useState } from 'react'; import { CustomSlider } from '@/components/custom-slider'; +import { CustomSelect } from '@/components/custom-select'; import { Button } from '@/components/ui/button'; import { Plus, Minus, Search, RotateCcw } from 'lucide-react'; -// Sidebar for tree visualizers: a value field + insert/delete/search actions, -// clear, and a speed slider. Callers pass the op handlers (they get the number). +// Sidebar for tree visualizers: an optional tree-type dropdown, a value field + +// insert/delete/search actions, clear, and a speed slider. Callers pass the op +// handlers (they get the number). -export default function TreeMenu({ title, disabled, onInsert, onDelete, onSearch, onClear, onSpeedChange }) { +export default function TreeMenu({ title, disabled, modes, onModeChange, onInsert, onDelete, onSearch, onClear, onSpeedChange }) { const [value, setValue] = useState(''); const num = () => Number(value); const valid = value.trim() !== '' && Number.isFinite(num()); @@ -15,6 +17,17 @@ export default function TreeMenu({ title, disabled, onInsert, onDelete, onSearch

{title}

+ {modes && ( +
+
+
+ Tree type +
+
+ +
+ )} +
diff --git a/src/components/tree/tree-node.jsx b/src/components/tree/tree-node.jsx index f6d771b..69e4966 100644 --- a/src/components/tree/tree-node.jsx +++ b/src/components/tree/tree-node.jsx @@ -11,11 +11,21 @@ const FILL = { remove: ['#f43f5e', '#be123c'], }; +// red/black node fills (used when a `color` is given, e.g. Red-Black trees) +const RB_FILL = { + red: ['#dc2626', '#b91c1c'], + black: ['#1f2937', '#0f172a'], +}; +// when a node has a fixed red/black fill, the operation highlight is drawn as +// an outer ring instead of recoloring the fill +const RING = { current: '#f59e0b', found: '#10b981', visited: '#94a3b8', remove: '#f43f5e' }; + const R = 16; -export default function TreeNode({ x, y, value, secondary, state }) { +export default function TreeNode({ x, y, value, secondary, state, color }) { if (state === 'hidden') return null; - const [bg, border] = FILL[state] || FILL.normal; + const [bg, border] = color ? RB_FILL[color] || RB_FILL.black : FILL[state] || FILL.normal; + const ring = color ? RING[state] : null; // operation highlight for colored nodes // shrink the primary label so longer values (e.g. recursion call labels // like "(4,3)") still fit inside the circle const len = String(value ?? '').length; @@ -24,7 +34,8 @@ export default function TreeNode({ x, y, value, secondary, state }) { return ( - + {ring && } + ({ tree: treeRef.current }); const clear = () => { if (isRunningRef.current) return; applyTree(null); clearMarks(); }; + const reset = (t) => { if (isRunningRef.current) return; applyTree(t); clearMarks(); }; const setSpeed = (s) => { speedRef.current = toDelay(s); }; - return { tree, nodeState, edgeState, labels, status, isRunning, run, getContext, clear, setSpeed }; + return { tree, nodeState, edgeState, labels, status, isRunning, run, getContext, clear, reset, setSpeed }; } function sleep(ms) { diff --git a/src/lib/algorithms/heap.js b/src/lib/algorithms/heap.js new file mode 100644 index 0000000..1fe9a1d --- /dev/null +++ b/src/lib/algorithms/heap.js @@ -0,0 +1,146 @@ +// Binary Heap (min-heap) — action-log planners for insert / extract-min / +// build-heap / heapsort, rendered on the reusable tree component. +// +// A heap is a complete binary tree stored as an array: node i's children are +// 2i+1 and 2i+2. We keep the heap as an array of { id, value } and rebuild a +// logical tree from it for each `setTree`. A sift swaps two array entries +// *including their ids*, so the two nodes' target positions swap and the canvas +// tween slides them past each other. + +let counter = 0; +const mkNode = (value) => ({ id: 'h' + ++counter, value }); + +// array of { id, value } -> logical tree { id, value, left, right } +export function buildTree(arr) { + const make = (i) => { + if (i >= arr.length) return null; + return { id: arr[i].id, value: arr[i].value, left: make(2 * i + 1), right: make(2 * i + 2) }; + }; + return make(0); +} + +// logical complete tree -> level-order array of { id, value } +export function toArray(tree) { + const out = []; + const q = tree ? [tree] : []; + while (q.length) { + const n = q.shift(); + out.push({ id: n.id, value: n.value }); + if (n.left) q.push(n.left); + if (n.right) q.push(n.right); + } + return out; +} + +export function fromValues(values) { + const a = values.map(mkNode); + heapify(a); + return buildTree(a); +} + +// in-place Floyd build-heap (no animation) — used for seeding +function heapify(a) { + for (let i = Math.floor(a.length / 2) - 1; i >= 0; i--) siftDown(a, i, a.length); +} + +function siftDown(a, i, size) { + for (;;) { + let small = i; + const l = 2 * i + 1; + const r = 2 * i + 2; + if (l < size && a[l].value < a[small].value) small = l; + if (r < size && a[r].value < a[small].value) small = r; + if (small === i) break; + [a[i], a[small]] = [a[small], a[i]]; + i = small; + } +} + +const snap = (actions, a) => actions.push({ type: 'setTree', tree: buildTree(a) }); +const mark = (actions, a, idxs, state) => { + for (const i of idxs) if (a[i]) actions.push({ type: 'markNode', id: a[i].id, state }); +}; + +export function insertActions(tree, value) { + const actions = []; + const a = toArray(tree); + a.push(mkNode(value)); + snap(actions, a); // appears at the end + let i = a.length - 1; + while (i > 0) { + const p = Math.floor((i - 1) / 2); + mark(actions, a, [i, p], 'current'); + if (a[i].value >= a[p].value) break; + [a[i], a[p]] = [a[p], a[i]]; // ids travel with values + snap(actions, a); + i = p; + } + actions.push({ type: 'status', text: `Inserted ${value}` }); + return actions; +} + +export function extractActions(tree) { + const actions = []; + const a = toArray(tree); + if (!a.length) { + actions.push({ type: 'status', text: 'Heap is empty' }); + actions.push({ type: 'clear' }); + return actions; + } + const min = a[0].value; + mark(actions, a, [0], 'current'); + const last = a.length - 1; + if (last > 0) { + [a[0], a[last]] = [a[last], a[0]]; + snap(actions, a); // min swapped to the end + } + a.pop(); // remove the min + snap(actions, a); + siftDownActions(actions, a, 0, a.length); + actions.push({ type: 'status', text: `Extracted min ${min}` }); + return actions; +} + +// animated sift-down: emit a snapshot per swap within a[0..size) +function siftDownActions(actions, a, i, size) { + for (;;) { + let small = i; + const l = 2 * i + 1; + const r = 2 * i + 2; + if (l < size && a[l].value < a[small].value) small = l; + if (r < size && a[r].value < a[small].value) small = r; + mark(actions, a, [i, l, r].filter((k) => k < size), 'current'); + if (small === i) break; + [a[i], a[small]] = [a[small], a[i]]; + snap(actions, a); // render the full array; `size` only bounds the sift + i = small; + } +} + +export function buildActions(values) { + const actions = []; + const a = values.map(mkNode); + snap(actions, a); // raw, unheapified + for (let i = Math.floor(a.length / 2) - 1; i >= 0; i--) { + siftDownActions(actions, a, i, a.length); + } + actions.push({ type: 'status', text: 'Built min-heap' }); + return actions; +} + +export function heapsortActions(tree) { + const actions = []; + const a = toArray(tree); + let size = a.length; + while (size > 1) { + const last = size - 1; + [a[0], a[last]] = [a[last], a[0]]; + snap(actions, a); // current min parked at the end of the active region + actions.push({ type: 'markNode', id: a[last].id, state: 'found' }); // sorted + size--; + siftDownActions(actions, a, 0, size); + } + if (a.length) actions.push({ type: 'markNode', id: a[0].id, state: 'found' }); + actions.push({ type: 'status', text: 'Heapsort complete (descending)' }); + return actions; +} diff --git a/src/app/recursion-tree/fib.jsx b/src/lib/algorithms/recursion.js similarity index 100% rename from src/app/recursion-tree/fib.jsx rename to src/lib/algorithms/recursion.js diff --git a/src/lib/algorithms/redBlack.js b/src/lib/algorithms/redBlack.js new file mode 100644 index 0000000..cccee93 --- /dev/null +++ b/src/lib/algorithms/redBlack.js @@ -0,0 +1,295 @@ +// Red-Black tree — action-log planners for insert / delete / search. +// +// Node (rendered): { id, value, left, right, color: 'red' | 'black' }. +// +// Immutable RB delete with stable ids is painful, so each planner builds a +// MUTABLE working copy (parent pointers + a nil sentinel, ids preserved), runs +// the standard CLRS algorithm on it, and emits an immutable snapshot() as a +// `setTree` action after every rotation / recolor. The tree component then +// animates each step (nodes slide on rotations; fills fade on recolor). + +import { searchActions as bstSearchActions } from './bst'; + +let counter = 0; + +// A fresh context (working tree + nil sentinel) per operation, so the shared +// nil's transient parent pointers never leak across calls. +function rbContext(immutableRoot) { + const nil = { id: null, value: null, color: 'black' }; + nil.left = nil.right = nil.parent = nil; + const T = { root: nil }; + + const build = (r) => { + if (!r) return nil; + const n = { id: r.id, value: r.value, color: r.color || 'black', left: nil, right: nil, parent: nil }; + n.left = build(r.left); + if (n.left !== nil) n.left.parent = n; + n.right = build(r.right); + if (n.right !== nil) n.right.parent = n; + return n; + }; + T.root = build(immutableRoot); + + const snap = (n) => (n === nil ? null : { id: n.id, value: n.value, color: n.color, left: snap(n.left), right: snap(n.right) }); + + const emit = (actions, activeId) => { + if (!actions) return; + actions.push({ type: 'setTree', tree: snap(T.root) }); + if (activeId) actions.push({ type: 'markNode', id: activeId, state: 'current' }); + }; + + const minimum = (n) => { + while (n.left !== nil) n = n.left; + return n; + }; + + const leftRotate = (x) => { + const y = x.right; + x.right = y.left; + if (y.left !== nil) y.left.parent = x; + y.parent = x.parent; + if (x.parent === nil) T.root = y; + else if (x === x.parent.left) x.parent.left = y; + else x.parent.right = y; + y.left = x; + x.parent = y; + }; + + const rightRotate = (x) => { + const y = x.left; + x.left = y.right; + if (y.right !== nil) y.right.parent = x; + y.parent = x.parent; + if (x.parent === nil) T.root = y; + else if (x === x.parent.right) x.parent.right = y; + else x.parent.left = y; + y.right = x; + x.parent = y; + }; + + const transplant = (u, v) => { + if (u.parent === nil) T.root = v; + else if (u === u.parent.left) u.parent.left = v; + else u.parent.right = v; + v.parent = u.parent; + }; + + const insertFixup = (z, actions) => { + while (z.parent.color === 'red') { + if (z.parent === z.parent.parent.left) { + const y = z.parent.parent.right; // uncle + if (y.color === 'red') { + z.parent.color = 'black'; + y.color = 'black'; + z.parent.parent.color = 'red'; + z = z.parent.parent; + emit(actions, z.id); + } else { + if (z === z.parent.right) { + z = z.parent; + leftRotate(z); + emit(actions, z.id); + } + z.parent.color = 'black'; + z.parent.parent.color = 'red'; + rightRotate(z.parent.parent); + emit(actions, z.parent.id); + } + } else { + const y = z.parent.parent.left; // uncle + if (y.color === 'red') { + z.parent.color = 'black'; + y.color = 'black'; + z.parent.parent.color = 'red'; + z = z.parent.parent; + emit(actions, z.id); + } else { + if (z === z.parent.left) { + z = z.parent; + rightRotate(z); + emit(actions, z.id); + } + z.parent.color = 'black'; + z.parent.parent.color = 'red'; + leftRotate(z.parent.parent); + emit(actions, z.parent.id); + } + } + } + if (T.root.color !== 'black') { + T.root.color = 'black'; + emit(actions, T.root.id); + } + }; + + const insert = (value, actions) => { + const z = { id: 'rb' + ++counter, value, color: 'red', left: nil, right: nil, parent: nil }; + let y = nil; + let x = T.root; + while (x !== nil) { + y = x; + x = value < x.value ? x.left : x.right; + } + z.parent = y; + if (y === nil) T.root = z; + else if (value < y.value) y.left = z; + else y.right = z; + emit(actions, z.id); // new red node slides in + insertFixup(z, actions); + }; + + const deleteFixup = (x, actions) => { + while (x !== T.root && x.color === 'black') { + if (x === x.parent.left) { + let w = x.parent.right; + if (w.color === 'red') { + w.color = 'black'; + x.parent.color = 'red'; + leftRotate(x.parent); + emit(actions, w.id); + w = x.parent.right; + } + if (w.left.color === 'black' && w.right.color === 'black') { + w.color = 'red'; + emit(actions, w.id); + x = x.parent; + } else { + if (w.right.color === 'black') { + w.left.color = 'black'; + w.color = 'red'; + rightRotate(w); + emit(actions, w.id); + w = x.parent.right; + } + w.color = x.parent.color; + x.parent.color = 'black'; + w.right.color = 'black'; + leftRotate(x.parent); + emit(actions, w.id); + x = T.root; + } + } else { + let w = x.parent.left; + if (w.color === 'red') { + w.color = 'black'; + x.parent.color = 'red'; + rightRotate(x.parent); + emit(actions, w.id); + w = x.parent.left; + } + if (w.right.color === 'black' && w.left.color === 'black') { + w.color = 'red'; + emit(actions, w.id); + x = x.parent; + } else { + if (w.left.color === 'black') { + w.right.color = 'black'; + w.color = 'red'; + leftRotate(w); + emit(actions, w.id); + w = x.parent.left; + } + w.color = x.parent.color; + x.parent.color = 'black'; + w.left.color = 'black'; + rightRotate(x.parent); + emit(actions, w.id); + x = T.root; + } + } + } + x.color = 'black'; + emit(actions, x === nil ? null : x.id); + }; + + const findNode = (value) => { + let cur = T.root; + while (cur !== nil && cur.value !== value) cur = value < cur.value ? cur.left : cur.right; + return cur === nil ? null : cur; + }; + + const remove = (value, actions) => { + let z = findNode(value); + if (!z) return false; + // two children: copy in-order successor's value into z (id preserved), + // then physically remove the successor (which has at most one child) + if (z.left !== nil && z.right !== nil) { + const s = minimum(z.right); + z.value = s.value; + emit(actions, z.id); // value copied up + z = s; + } + const child = z.left !== nil ? z.left : z.right; + const removedColor = z.color; + transplant(z, child); + emit(actions, child === nil ? null : child.id); + if (removedColor === 'black') deleteFixup(child, actions); + return true; + }; + + return { T, snap, insert, remove, findNode }; +} + +export function fromValues(values) { + let root = null; + for (const v of values) { + const ctx = rbContext(root); + if (!ctx.findNode(v)) ctx.insert(v, null); + root = ctx.snap(ctx.T.root); + } + return root; +} + +function contains(root, value) { + let cur = root; + while (cur) { + if (value === cur.value) return true; + cur = value < cur.value ? cur.left : cur.right; + } + return false; +} + +function pathTo(root, value) { + const path = []; + let cur = root; + while (cur) { + path.push(cur); + if (value === cur.value) break; + cur = value < cur.value ? cur.left : cur.right; + } + return path; +} + +export function insertActions(tree, value) { + const actions = []; + for (const n of pathTo(tree, value)) actions.push({ type: 'markNode', id: n.id, state: 'current' }); + if (contains(tree, value)) { + actions.push({ type: 'status', text: `${value} is already in the tree` }); + actions.push({ type: 'clear' }); + return actions; + } + const ctx = rbContext(tree); + ctx.insert(value, actions); + actions.push({ type: 'status', text: `Inserted ${value}` }); + return actions; +} + +export function deleteActions(tree, value) { + const actions = []; + const path = pathTo(tree, value); + for (const n of path) actions.push({ type: 'markNode', id: n.id, state: 'current' }); + const target = path[path.length - 1]; + if (!target || target.value !== value) { + actions.push({ type: 'status', text: `${value} not found` }); + actions.push({ type: 'clear' }); + return actions; + } + actions.push({ type: 'markNode', id: target.id, state: 'remove' }); + const ctx = rbContext(tree); + ctx.remove(value, actions); + actions.push({ type: 'status', text: `Deleted ${value}` }); + return actions; +} + +// search is the plain BST walk (RB nodes carry id/value/left/right) +export const searchActions = bstSearchActions;