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 = '
No tree data available
'; 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 = '
Error parsing tree data
'; 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('') .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('') .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('') .on("click", function() { d3.select("#tree-visualization svg") .transition() .duration(300) .call(zoom.transform, d3.zoomIdentity.translate(0, 0).scale(0.7)); }); });