468 lines
17 KiB
JavaScript
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));
|
|
});
|
|
});
|