DecisionTree/static/js/tree-visualizer.js

468 lines
17 KiB
JavaScript

document.addEventListener('DOMContentLoaded', function() {
// Check if tree visualization container exists
const treeContainer = document.getElementById('tree-visualization');
if (!treeContainer) return;
// Get the tree data from the hidden input
let treeData;
try {
const treeDataInput = document.getElementById('tree-data');
if (!treeDataInput || !treeDataInput.value) {
console.error('Tree data not found');
treeContainer.innerHTML = '<div class="alert alert-warning">No tree data available</div>';
return;
}
treeData = JSON.parse(treeDataInput.value);
// Ensure is_terminal is properly set as a boolean
function ensureTerminalFlag(node) {
if (!node) return;
// Convert is_terminal to boolean if it's a string
if (typeof node.is_terminal === 'string') {
node.is_terminal = (node.is_terminal.toLowerCase() === 'true');
}
// If is_terminal is undefined, set it to false (not terminal by default)
if (node.is_terminal === undefined) {
node.is_terminal = false;
}
// Process children recursively
if (node.children) {
node.children.forEach(ensureTerminalFlag);
}
}
ensureTerminalFlag(treeData);
// Add this after ensureTerminalFlag is called
console.log("Tree data after processing:", JSON.stringify(treeData, null, 2));
} catch (e) {
console.error('Error parsing tree data:', e);
treeContainer.innerHTML = '<div class="alert alert-danger">Error parsing tree data</div>';
return;
}
// Set dimensions and margins
const margin = {top: 20, right: 120, bottom: 20, left: 120},
width = 1200 - margin.left - margin.right,
height = 700 - margin.top - margin.bottom;
// Clear any existing content
treeContainer.innerHTML = '';
// Append SVG
const svg = d3.select("#tree-visualization").append("svg")
.attr("width", "100%")
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// Preprocess the tree data to ensure consistent spacing
// Add dummy nodes for missing yes/no branches
function addDummyNodes(node) {
if (!node) return;
// Initialize children array if it doesn't exist
if (!node.children) {
node.children = [];
}
// Check if we have both yes and no children
let hasYes = false;
let hasNo = false;
for (const child of node.children) {
if (child.type === 'yes') hasYes = true;
if (child.type === 'no') hasNo = true;
// Recursively process this child
addDummyNodes(child);
}
// Only add dummy nodes if this is not a terminal node
// Use explicit check for is_terminal === false
if (node.is_terminal === false) {
// Add dummy nodes if missing
if (!hasYes) {
node.children.push({
name: "(No 'Yes' branch)",
id: `dummy-yes-${node.id}`,
type: 'dummy-yes',
children: [],
isDummy: true,
parentId: node.id
});
}
if (!hasNo) {
node.children.push({
name: "(No 'No' branch)",
id: `dummy-no-${node.id}`,
type: 'dummy-no',
children: [],
isDummy: true,
parentId: node.id
});
}
}
}
// Process the tree data
addDummyNodes(treeData);
// Create a tree layout with more horizontal spacing
const tree = d3.tree()
.size([height, width - 300])
.separation(function(a, b) {
// Increase separation between nodes
return (a.parent == b.parent ? 1.5 : 2);
});
// Create the root node
const root = d3.hierarchy(treeData);
// Now that root is defined, we can log terminal nodes
console.log("Terminal nodes:", root.descendants().filter(d => d.data.is_terminal).map(d => d.data.id));
// Add this after the root is created
console.log("All nodes:", root.descendants().map(d => ({
id: d.data.id,
name: d.data.name,
is_terminal: d.data.is_terminal,
type: d.data.type
})));
// Compute the tree layout
tree(root);
// Add dotted lines for potential connections to dummy nodes
svg.selectAll(".potential-link")
.data(root.links().filter(d => d.target.data.isDummy))
.enter().append("path")
.attr("class", "potential-link")
.attr("d", d3.linkHorizontal()
.x(d => d.y)
.y(d => d.x))
.style("fill", "none")
.style("stroke", function(d) {
if (d.target.data.type === 'dummy-yes') return "#1cc88a";
if (d.target.data.type === 'dummy-no') return "#e74a3b";
return "#ccc";
})
.style("stroke-width", "1.5px")
.style("stroke-dasharray", "5,5") // This creates the dotted line
.style("opacity", 0.6);
// Add links between nodes (but not for dummy nodes)
svg.selectAll(".link")
.data(root.links().filter(d => !d.target.data.isDummy))
.enter().append("path")
.attr("class", "link")
.attr("d", d3.linkHorizontal()
.x(d => d.y)
.y(d => d.x))
.style("fill", "none")
.style("stroke", function(d) {
if (d.target.data.type === 'yes') return "#1cc88a";
if (d.target.data.type === 'no') return "#e74a3b";
return "#ccc";
})
.style("stroke-width", "1.5px");
// Add placeholder nodes for missing branches
svg.selectAll(".placeholder-node")
.data(root.descendants().filter(d => d.data.isDummy))
.enter().append("g")
.attr("class", "placeholder-node")
.attr("transform", d => `translate(${d.y},${d.x})`)
.each(function(d) {
const g = d3.select(this);
// Add a placeholder circle
g.append("circle")
.attr("r", 6)
.style("fill", "white")
.style("stroke", function() {
if (d.data.type === 'dummy-yes') return "#1cc88a";
if (d.data.type === 'dummy-no') return "#e74a3b";
return "#ccc";
})
.style("stroke-width", "1.5px")
.style("stroke-dasharray", "2,2")
.style("cursor", "pointer")
.on("click", function() {
window.location.href = `/admin/node/${d.data.parentId}`;
});
// Add a label
g.append("text")
.attr("dy", "0.31em")
.attr("x", 15)
.style("text-anchor", "start")
.text(function() {
if (d.data.type === 'dummy-yes') return "Missing 'Yes' branch";
if (d.data.type === 'dummy-no') return "Missing 'No' branch";
return "Missing branch";
})
.style("font-size", "10px")
.style("font-style", "italic")
.style("fill", function() {
if (d.data.type === 'dummy-yes') return "#1cc88a";
if (d.data.type === 'dummy-no') return "#e74a3b";
return "#666";
})
.style("cursor", "pointer")
.on("click", function() {
window.location.href = `/admin/node/${d.data.parentId}`;
});
});
// Add nodes (but not dummy nodes)
const node = svg.selectAll(".node")
.data(root.descendants().filter(d => !d.data.isDummy))
.enter().append("g")
.attr("class", "node")
.attr("transform", d => `translate(${d.y},${d.x})`);
// Add background rectangles for text
node.append("rect")
.attr("y", -10)
.attr("x", d => d.children ? -150 : 10)
.attr("width", 140)
.attr("height", 20)
.attr("rx", 4)
.attr("ry", 4)
.style("fill", "white")
.style("fill-opacity", 0.8)
.style("stroke", function(d) {
if (d.data.current) return "#f6c23e";
if (d.data.type === 'root') return "#4e73df";
if (d.data.type === 'yes') return "#1cc88a";
if (d.data.type === 'no') return "#e74a3b";
return "#ccc";
})
.style("stroke-width", "1px");
// Add circles to nodes
node.append("circle")
.attr("r", 8)
.style("fill", function(d) {
if (d.data.current) return "#f6c23e"; // Current node
if (d.data.type === 'root') return "#4e73df"; // Root node
if (d.data.type === 'yes') return "#1cc88a"; // Yes node
if (d.data.type === 'no') return "#e74a3b"; // No node
return "#fff";
})
.style("stroke", "#666")
.style("stroke-width", "1.5px");
// Add labels to nodes with truncated text
node.append("text")
.attr("dy", "0.31em")
.attr("x", d => d.children ? -15 : 15)
.style("text-anchor", d => d.children ? "end" : "start")
.text(d => {
// Truncate text to prevent overlap
const maxLength = 20;
if (d.data.name.length > maxLength) {
return d.data.name.substring(0, maxLength) + "...";
}
return d.data.name;
})
.style("font-size", "12px")
.style("font-family", "Arial, sans-serif")
.append("title") // Add tooltip with full text
.text(d => d.data.name);
// Add type labels (Yes/No/Root)
node.append("text")
.attr("dy", "-1.2em")
.attr("x", 0)
.style("text-anchor", "middle")
.style("font-size", "10px")
.style("font-weight", "bold")
.style("fill", function(d) {
if (d.data.type === 'root') return "#4e73df";
if (d.data.type === 'yes') return "#1cc88a";
if (d.data.type === 'no') return "#e74a3b";
return "#666";
})
.text(function(d) {
if (d.data.type === 'root') return "ROOT";
if (d.data.type === 'yes') return "YES";
if (d.data.type === 'no') return "NO";
return "";
});
// Add terminal node indicators
node.filter(d => {
console.log(`Node ${d.data.id} is_terminal:`, d.data.is_terminal);
return d.data.is_terminal === true;
})
.append("text")
.attr("dy", "2.5em")
.attr("x", 0)
.style("text-anchor", "middle")
.style("font-size", "10px")
.style("font-weight", "bold")
.style("fill", "#6c757d")
.text("TERMINAL");
// Add "Add" buttons for non-terminal nodes only
svg.selectAll(".add-button")
.data(root.descendants().filter(d => d.data.is_terminal === false && !d.data.isDummy))
.enter().append("g")
.attr("class", "add-button")
.attr("transform", d => {
// Position at the node itself
return `translate(${d.y},${d.x})`;
})
.each(function(d) {
const g = d3.select(this);
const hasYes = d.children && d.children.some(child => child.data.type === 'yes' && !child.data.isDummy);
const hasNo = d.children && d.children.some(child => child.data.type === 'no' && !child.data.isDummy);
// Create a container for the buttons
const buttonContainer = g.append("g")
.attr("transform", "translate(0, 20)"); // Position below the node
let yOffset = 0;
if (!hasYes) {
// Add Yes button
buttonContainer.append("rect")
.attr("y", yOffset)
.attr("x", -60)
.attr("width", 120)
.attr("height", 20)
.attr("rx", 4)
.attr("ry", 4)
.style("fill", "#d4edda")
.style("stroke", "#1cc88a")
.style("stroke-width", "1px")
.style("cursor", "pointer")
.on("click", function() {
window.location.href = `/admin/node/${d.data.id}`;
});
buttonContainer.append("text")
.attr("dy", yOffset + 14)
.attr("x", 0)
.style("text-anchor", "middle")
.text("Add 'Yes' branch")
.style("font-size", "10px")
.style("fill", "#155724")
.style("cursor", "pointer")
.on("click", function() {
window.location.href = `/admin/node/${d.data.id}`;
});
yOffset += 25; // Increment for next button
}
if (!hasNo) {
// Add No button
buttonContainer.append("rect")
.attr("y", yOffset)
.attr("x", -60)
.attr("width", 120)
.attr("height", 20)
.attr("rx", 4)
.attr("ry", 4)
.style("fill", "#f8d7da")
.style("stroke", "#e74a3b")
.style("stroke-width", "1px")
.style("cursor", "pointer")
.on("click", function() {
window.location.href = `/admin/node/${d.data.id}`;
});
buttonContainer.append("text")
.attr("dy", yOffset + 14)
.attr("x", 0)
.style("text-anchor", "middle")
.text("Add 'No' branch")
.style("font-size", "10px")
.style("fill", "#721c24")
.style("cursor", "pointer")
.on("click", function() {
window.location.href = `/admin/node/${d.data.id}`;
});
}
});
// Add indicator for non-terminal leaf nodes
node.filter(d => {
console.log(`Node ${d.data.id} expandable check:`,
d.data.is_terminal === false,
(!d.children || d.children.every(child => child.data.isDummy)));
return d.data.is_terminal === false &&
(!d.children || d.children.every(child => child.data.isDummy));
})
.append("text")
.attr("dy", "2.5em")
.attr("x", 0)
.style("text-anchor", "middle")
.style("font-size", "10px")
.style("font-style", "italic")
.style("fill", "#6c757d")
.text("EXPANDABLE");
// Add zoom functionality
const zoom = d3.zoom()
.scaleExtent([0.3, 2])
.on("zoom", (event) => {
svg.attr("transform", event.transform);
});
d3.select("#tree-visualization svg")
.call(zoom)
.call(zoom.transform, d3.zoomIdentity.translate(0, 0).scale(0.7));
// Add click handlers to navigate to node edit page
node.style("cursor", "pointer")
.on("click", function(event, d) {
window.location.href = `/admin/node/${d.data.id}`;
});
// Add zoom controls
const zoomControls = d3.select("#tree-visualization")
.append("div")
.attr("class", "zoom-controls")
.style("position", "absolute")
.style("top", "10px")
.style("right", "10px");
zoomControls.append("button")
.attr("class", "btn btn-sm btn-outline-secondary me-1")
.html('<i class="fas fa-search-plus"></i>')
.on("click", function() {
d3.select("#tree-visualization svg")
.transition()
.duration(300)
.call(zoom.scaleBy, 1.2);
});
zoomControls.append("button")
.attr("class", "btn btn-sm btn-outline-secondary me-1")
.html('<i class="fas fa-search-minus"></i>')
.on("click", function() {
d3.select("#tree-visualization svg")
.transition()
.duration(300)
.call(zoom.scaleBy, 0.8);
});
zoomControls.append("button")
.attr("class", "btn btn-sm btn-outline-secondary")
.html('<i class="fas fa-home"></i>')
.on("click", function() {
d3.select("#tree-visualization svg")
.transition()
.duration(300)
.call(zoom.transform, d3.zoomIdentity.translate(0, 0).scale(0.7));
});
});