function buildHuffmanTree(text) {
if (!text || text.length === 0) return HUFFMAN_SAMPLE;
const freq = {};
for (const c of text) freq[c] = (freq[c] || 0) + 1;
const total = text.length;
let nodes = Object.entries(freq).map(([char, count]) => ({
name: char === " " ? "␣" : char,
weight: count / total,
count,
isLeaf: true
}));
if (nodes.length === 1) {
return { name: "root", children: [{ ...nodes[0], code: "0" }] };
}
let id = 0;
let queue = nodes.map(n => ({ ...n, id: id++ }));
queue.sort((a, b) => a.weight - b.weight);
while (queue.length > 1) {
const left = queue.shift();
const right = queue.shift();
const parent = {
name: "internal",
weight: left.weight + right.weight,
id: id++,
isLeaf: false,
children: [left, right]
};
queue.push(parent);
queue.sort((a, b) => a.weight - b.weight);
}
function assignCodes(node, code = "") {
if (node.isLeaf) { node.code = code || "0"; return; }
if (node.children) {
assignCodes(node.children[0], code + "0");
assignCodes(node.children[1], code + "1");
}
}
const root = queue[0];
assignCodes(root);
return root;
}
const HUFFMAN_SAMPLE = {
name: "root",
children: [
{ name: "a", weight: 0.50, code: "0", isLeaf: true },
{
name: "internal",
children: [
{ name: "n", weight: 0.33, code: "10", isLeaf: true },
{ name: "b", weight: 0.17, code: "11", isLeaf: true }
]
}
]
};
function injectCodes(node, codeMap) {
if (!node) return node;
const isLeaf = !node.children || node.children.length === 0;
if (isLeaf && codeMap[node.name] !== undefined) {
node = { ...node, code: codeMap[node.name] };
}
if (node.children) {
node = { ...node, children: node.children.map(c => injectCodes(c, codeMap)) };
}
return node;
}
function HuffmanTree({ treeData, codeMap, swaps, containerWidth }) {
const svgRef = React.useRef(null);
const wrapRef = React.useRef(null);
const tooltipRef = React.useRef(null);
const [dims, setDims] = React.useState({ w: 560, h: 320 });
React.useEffect(() => {
if (!wrapRef.current) return;
const ro = new ResizeObserver(entries => {
const { width } = entries[0].contentRect;
if (width > 0) setDims(d => ({ ...d, w: width }));
});
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
React.useEffect(() => {
const raw = treeData || HUFFMAN_SAMPLE;
const data = codeMap ? injectCodes(raw, codeMap) : raw;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const margin = { top: 50, right: 20, bottom: 80, left: 20 };
const W = dims.w - margin.left - margin.right;
const root = d3.hierarchy(data);
const depth = root.height;
const H = Math.max(220, depth * 110) - margin.top - margin.bottom;
svg
.attr("width", dims.w)
.attr("height", H + margin.top + margin.bottom);
const zoom = d3.zoom().scaleExtent([0.3, 3]).on("zoom", (e) => {
g.attr("transform", e.transform);
});
svg.call(zoom);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
d3.tree().size([W, H])(root);
const swappedNums = new Set((swaps || []).flat());
const BLOCK_PALETTE = [
"#5ba4cf","#f4a261","#57cc99","#c77dff","#f9c74f",
"#e76f51","#43aa8b","#a8dadc","#e9c46a","#9b72cf"
];
const uniqueWeights = [...new Set(root.descendants().map(d => d.data.weight))].sort((a,b)=>a-b);
const weightColor = {};
uniqueWeights.forEach((w, i) => { weightColor[w] = BLOCK_PALETTE[i % BLOCK_PALETTE.length]; });
const isLeaf = d => !d.data.children || d.data.children.length === 0;
const isNYT = d => d.data.name === "NYT";
const nodeColor = d => {
if (isNYT(d)) return "#9aa0a6";
if (d.data.weight === undefined) return isLeaf(d) ? "rgba(0,200,160,1)" : "#1a3a5c";
return weightColor[d.data.weight] || "#9aa0a6";
};
g.selectAll(".link")
.data(root.links())
.enter().append("path")
.attr("fill", "none")
.attr("stroke", "#1a3a5c")
.attr("stroke-width", 1)
.attr("opacity", 0.35)
.attr("d", d3.linkVertical().x(d => d.x).y(d => d.y));
g.selectAll(".edge-label")
.data(root.links())
.enter().append("text")
.attr("x", d => (d.source.x + d.target.x) / 2 + (d.target.x < d.source.x ? -8 : 8))
.attr("y", d => (d.source.y + d.target.y) / 2)
.attr("text-anchor", "middle")
.attr("font-family", "JetBrains Mono, monospace")
.attr("font-size", 10)
.attr("font-weight", "600")
.attr("fill", "rgba(0,180,140,0.95)")
.text(d => {
const siblings = d.source.children || [];
return siblings.indexOf(d.target) === 0 ? "0" : "1";
});
const node = g.selectAll(".node")
.data(root.descendants())
.enter().append("g")
.attr("transform", d => `translate(${d.x},${d.y})`);
node.append("circle")
.attr("r", d => isLeaf(d) ? 7 : 5)
.attr("fill", d => nodeColor(d))
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.attr("opacity", d => isNYT(d) ? 0.6 : 1);
node.filter(d => swappedNums.has(d.data.number))
.append("circle")
.attr("r", d => isLeaf(d) ? 12 : 10)
.attr("fill", "none")
.attr("stroke", "#ff6b35")
.attr("stroke-width", 2)
.attr("opacity", 0.9)
.append("animate")
.attr("attributeName", "r")
.attr("values", d => isLeaf(d) ? "12;16;12" : "10;14;10")
.attr("dur", "1s")
.attr("repeatCount", "3");
node.filter(d => swappedNums.has(d.data.number))
.append("text")
.attr("y", -22)
.attr("text-anchor", "middle")
.attr("font-family", "JetBrains Mono, monospace")
.attr("font-size", 8)
.attr("font-weight", "700")
.attr("fill", "#ff6b35")
.text("↔ swap");
node.filter(d => d.data.number !== undefined)
.append("text")
.attr("y", -12)
.attr("text-anchor", "middle")
.attr("font-family", "JetBrains Mono, monospace")
.attr("font-size", 9)
.attr("fill", "#888")
.text(d => `#${d.data.number}`);
node.filter(d => isLeaf(d))
.append("text")
.attr("y", 20)
.attr("text-anchor", "middle")
.attr("font-family", "JetBrains Mono, monospace")
.attr("font-size", 11)
.attr("font-weight", "600")
.attr("fill", d => isNYT(d) ? "#9aa0a6" : "#14161a")
.text(d => isNYT(d) ? "NYT" : d.data.name);
node.filter(d => isLeaf(d))
.append("text")
.attr("y", 32)
.attr("text-anchor", "middle")
.attr("font-family", "JetBrains Mono, monospace")
.attr("font-size", 9)
.attr("fill", "#aaa")
.text(d => `w=${d.data.weight}`);
const tooltip = d3.select(tooltipRef.current);
node.on("mouseover", (event, d) => {
const lines = isLeaf(d)
? [`symbol: "${d.data.name}"`, `weight: ${d.data.weight}`, `node #: ${d.data.number}`, `code: ${d.data.code||"—"}`]
: [`${d.data.name}`, `weight: ${d.data.weight}`, `node #: ${d.data.number}`];
tooltip.style("opacity", 1).html(lines.join("
"));
}).on("mousemove", (event) => {
const rect = wrapRef.current.getBoundingClientRect();
tooltip.style("left", `${event.clientX - rect.left + 12}px`).style("top", `${event.clientY - rect.top - 10}px`);
}).on("mouseleave", () => tooltip.style("opacity", 0));
}, [treeData, dims]);
return (