This interactive visualization allows us to explore the assignment patterns for about 80,000 tickets. I’ve made my best attempt to discern the structure of assignments within OIT, but this likely needs refinement. The source data in ticket_data.csv are scrubbed of all sensitive information.
TDX SANKEY DIAGRAM
Code
d3 =require("d3@7")d3Sankey =require("d3-sankey@0.12")// Load the CSV file directly// Replace "ticket_data.csv" with the path to your actual CSV filerawData =await d3.csv("data/ticket_data.csv")// Define the columns you want to include in the Sankey diagraminitialColumns = ["Affiliation","Method","Ingestion","MajorGroup","FocusArea","Unit"]// Add priority and classification optionsviewof includePriority = Inputs.checkbox( ["Include Priority"], {value: [],label:"Priority Filter"})viewof priorityFilter = Inputs.checkbox( ["Normal","Urgent","High","VIP","Critical","Low"], {value: ["Normal","Urgent","High","VIP","Critical","Low"],label:"Priority Values to Include"})viewof includeClassification = Inputs.checkbox( ["Include Classification"], {value: [],label:"Classification Filter"})viewof classificationFilter = Inputs.checkbox( ["Service Request","Incident"], {value: ["Service Request","Incident"],label:"Classification Values to Include"})// UI controls for customizationviewof minLinkValue = Inputs.range([1,100], {value:5,step:1,label:"Minimum Link Value"})viewof columns = Inputs.checkbox( initialColumns, {value: initialColumns,label:"Columns to Include"})viewof nodeAlign = Inputs.select(newMap([ ["Left","sankeyLeft"], ["Right","sankeyRight"], ["Center","sankeyCenter"], ["Justify","sankeyJustify"]]), {value:"sankeyLeft",label:"Node Alignment"})viewof linkColor = Inputs.select(newMap([ ["Static","#aaa"], ["Source-Target Gradient","source-target"], ["Source Color","source"], ["Target Color","target"]]), {value:"source-target",label:"Link Color"})viewof nodeSort = Inputs.select(newMap([ ["Default","none"], ["Ascending by Size","ascending"], ["Descending by Size","descending"], ["Alphabetical","alphabetical"], ["Reverse Alphabetical","reverse-alpha"]]), {value:"descending",label:"Node Sorting"})// Process the CSV data for the Sankey diagramfunctionprocessCsvForSankey(data, columns, minValue =5) {// Initialize arrays for nodes and linksconst initialNodes = [];const links = [];const nodeMap =newMap();// To track node indiceslet nodeIndex =0;// Create nodes for each unique value in each column columns.forEach((column, colIndex) => {// Get unique values in this column// For columns after the first one, don't include "NA" valueslet uniqueValues;if (colIndex ===0) {// For the first column, include all values including "NA" uniqueValues = [...newSet(data.map(d => d[column]))].filter(d => d); } else {// For subsequent columns, exclude "NA" values uniqueValues = [...newSet(data.map(d => d[column]))].filter(d => d && d !=="NA"); }// Create nodes for each unique value uniqueValues.forEach(value => {// Create a prefixed name to avoid duplicatesconst nodeName =`${column}: ${value}`;// Add to nodes array initialNodes.push({name: nodeName,group: column });// Map the name to its index nodeMap.set(nodeName, nodeIndex++); }); });// Create links between adjacent columnsfor (let i =0; i < columns.length-1; i++) {const sourceCol = columns[i];const targetCol = columns[i +1];// Count transitions between columnsconst transitions =newMap(); data.forEach(row => {const source = row[sourceCol];const target = row[targetCol];// Skip if either value is missingif (!source ||!target) return;// Skip if target is "NA" (for columns after the first)if (i >0&& target ==="NA") return;const key =`${sourceCol}: ${source}|${targetCol}: ${target}`; transitions.set(key, (transitions.get(key) ||0) +1); });// Create links for transitions that meet the minimum value transitions.forEach((value, key) => {if (value < minValue) return;// Skip minor linksconst [sourceNode, targetNode] = key.split('|');// Get the indicesconst sourceIndex = nodeMap.get(sourceNode);const targetIndex = nodeMap.get(targetNode);// Skip if either node doesn't exist in our node listif (sourceIndex ===undefined|| targetIndex ===undefined) return;// Add the link links.push({source: sourceIndex,target: targetIndex,value: value }); }); }// Filter nodes to keep only those that are connected// First, collect all node indices that appear in linksconst connectedIndices =newSet(); links.forEach(link => { connectedIndices.add(link.source); connectedIndices.add(link.target); });// Create a new array of only connected nodesconst connectedNodes = initialNodes.filter((node, index) => connectedIndices.has(index));// Create a mapping from old indices to new indicesconst indexMapping =newMap(); connectedNodes.forEach((node, newIndex) => {const oldIndex = initialNodes.indexOf(node); indexMapping.set(oldIndex, newIndex); });// Update link indices to point to the new node positionsconst updatedLinks = links.map(link => ({source: indexMapping.get(link.source),target: indexMapping.get(link.target),value: link.value }));return { nodes: connectedNodes,links: updatedLinks };}// Process the CSV data based on current selectionsprocessedData = {// Filter data based on selected priority and classification valueslet filteredData = rawData;if (includePriority.length>0) { filteredData = filteredData.filter(d => priorityFilter.includes(d.Priority)); }if (includeClassification.length>0) { filteredData = filteredData.filter(d => classificationFilter.includes(d.Classification)); }// Add Priority to beginning of columns if selectedlet selectedColumns = [...columns];if (includePriority.length>0&&!selectedColumns.includes("Priority")) { selectedColumns.unshift("Priority"); }// Add Classification to beginning of columns if selectedif (includeClassification.length>0&&!selectedColumns.includes("Classification")) { selectedColumns.unshift("Classification"); }returnprocessCsvForSankey(filteredData, selectedColumns, minLinkValue);}// Function to create the Sankey diagramfunctioncreateSankey(data, { width =975, height =600, marginTop =30, marginRight =30, marginBottom =30, marginLeft =30, nodeWidth =15, nodePadding =10, title ="IT Ticket Flow Analysis"} = {}) {// Create a new SVG elementconst svg = d3.create("svg").attr("width", width).attr("height", height).attr("viewBox", [0,0, width, height]).attr("style","max-width: 100%; height: auto; font: 10px sans-serif;");// Add a title svg.append("text").attr("x", width /2).attr("y", marginTop /2).attr("text-anchor","middle").attr("font-size","16px").attr("font-weight","bold").text(title);// Create defs for gradientsconst defs = svg.append("defs");// Create a custom sort function based on the selected optionlet sortNodes =null;if (nodeSort ==="ascending") { sortNodes = (a, b) => a.value- b.value; } elseif (nodeSort ==="descending") { sortNodes = (a, b) => b.value- a.value; } elseif (nodeSort ==="alphabetical") { sortNodes = (a, b) => {const nameA = a.name.split(": ")[1] || a.name;const nameB = b.name.split(": ")[1] || b.name;return nameA.localeCompare(nameB); }; } elseif (nodeSort ==="reverse-alpha") { sortNodes = (a, b) => {const nameA = a.name.split(": ")[1] || a.name;const nameB = b.name.split(": ")[1] || b.name;return nameB.localeCompare(nameA); }; }// Create the Sankey generator with the selected alignment and sortingconst sankey = d3Sankey.sankey().nodeId(d => d.index).nodeWidth(nodeWidth).nodePadding(nodePadding).nodeAlign(d3Sankey[nodeAlign]).nodeSort(sortNodes) // Apply the sorting function if one is defined.extent([ [marginLeft, marginTop], [width - marginRight, height - marginBottom] ]);// Copy the data to avoid modifying the inputconst sankeyData =JSON.parse(JSON.stringify(data));// Format the processed dataconst {nodes, links} =sankey(sankeyData);// Assign colors to nodesconst colorScale = d3.scaleOrdinal().domain(nodes.map(d => d.group||"default")).range(d3.schemeCategory10); nodes.forEach((node, i) => { node.color= node.group?colorScale(node.group) :colorScale(i %10); });// Create gradient definitions for each link links.forEach((link, i) => {// Create unique IDs for gradients and paths link.gradient= { id:`link-gradient-${i}` }; link.path= { id:`link-path-${i}` };// Add gradient definitionconst gradient = defs.append("linearGradient").attr("id", link.gradient.id).attr("gradientUnits","userSpaceOnUse").attr("x1", link.source.x1).attr("y1", (link.source.y0+ link.source.y1) /2).attr("x2", link.target.x0).attr("y2", (link.target.y0+ link.target.y1) /2); gradient.append("stop").attr("offset","0%").attr("stop-color", link.source.color); gradient.append("stop").attr("offset","100%").attr("stop-color", link.target.color); });// Draw the gray background links svg.append("g").selectAll("path").data(links).join("path").attr("class","link-bg").attr("d", d3Sankey.sankeyLinkHorizontal()).attr("stroke","#ddd").attr("stroke-width", d =>Math.max(1, d.width)).attr("stroke-opacity",0.3).attr("fill","none");// Draw the gradient links (initially invisible)const gradientLinks = svg.append("g").selectAll("path").data(links).join("path").attr("id", d => d.path.id).attr("class","gradient-link").attr("d", d3Sankey.sankeyLinkHorizontal()).attr("stroke", d => {if (linkColor ==="source-target") {return`url(#${d.gradient.id})`; } elseif (linkColor ==="source") {return d.source.color; } elseif (linkColor ==="target") {return d.target.color; } else {return linkColor;// Static color } }).attr("stroke-width", d =>Math.max(1, d.width)).attr("stroke-opacity",0).attr("fill","none");// Set up dash arrays for animation gradientLinks.each(function() {const path = d3.select(this);const length = path.node().getTotalLength(); path.attr("stroke-dasharray",`${length}${length}`).attr("stroke-dashoffset", length); });// Add tooltips to links gradientLinks.append("title").text(d => {const sourceParts = d.source.name.split(": ");const targetParts = d.target.name.split(": ");const sourceName = sourceParts.length>1? sourceParts[1] : d.source.name;const targetName = targetParts.length>1? targetParts[1] : d.target.name;return`${sourceName} → ${targetName}\n${d3.format(",.0f")(d.value)} tickets`; });// Draw the nodesconst node = svg.append("g").selectAll("rect").data(nodes).join("rect").attr("class","node").attr("x", d => d.x0).attr("y", d => d.y0).attr("height", d => d.y1- d.y0).attr("width", d => d.x1- d.x0).attr("fill", d => d.color).attr("opacity",0.8);// Add tooltips to nodes node.append("title").text(d => {const parts = d.name.split(": ");const displayName = parts.length>1? parts[1] : d.name;return`${displayName}\n${d3.format(",.0f")(d.value)} tickets`; });// Add node labels svg.append("g").selectAll("text").data(nodes).join("text").attr("x", d => d.x0< width /2? d.x1+6: d.x0-6).attr("y", d => (d.y0+ d.y1) /2).attr("dy","0.35em").attr("text-anchor", d => d.x0< width /2?"start":"end").text(d => {// Remove the prefix for displayconst parts = d.name.split(": ");return parts.length>1? parts[1] : d.name; });// Animation and highlighting functionsfunctiontraceAncestry(node, visited =newSet()) {// Add this node to visited visited.add(node);// Find all links where this node is the target (incoming links)const incomingLinks = links.filter(link => link.target=== node);// For each incoming link, trace back to its source incomingLinks.forEach(link => {if (!visited.has(link.source)) {traceAncestry(link.source, visited); } });return visited; }functiontraceDescendants(node, visited =newSet()) {// Add this node to visited visited.add(node);// Find all links where this node is the source (outgoing links)const outgoingLinks = links.filter(link => link.source=== node);// For each outgoing link, trace forward to its target outgoingLinks.forEach(link => {if (!visited.has(link.target)) {traceDescendants(link.target, visited); } });return visited; }functionhighlightGenealogyLinks(node) {// Get all ancestors and descendants of this nodeconst ancestors =traceAncestry(node);const descendants =traceDescendants(node);const genealogy =newSet([...ancestors,...descendants]);// Highlight nodes in the genealogy node.forEach(n => { d3.select(n).transition().duration(200).attr("opacity", genealogy.has(n.__data__) ?0.9:0.3); });// Highlight links connected to the genealogy gradientLinks.attr("stroke-opacity", link => (genealogy.has(link.source) && genealogy.has(link.target)) ?0.7:0.1 ).attr("stroke-dashoffset",0); }functionresetHighlighting() {// Reset nodes node.transition().duration(200).attr("opacity",0.8);// Reset links gradientLinks.attr("stroke-opacity",0); }functionanimateLinks(node) {// Find all links where this node is the sourceconst sourceLinks = links.filter(link => link.source=== node);// Animate these links gradientLinks.filter(link => sourceLinks.includes(link)).attr("stroke-opacity",0.6).transition().duration(300).ease(d3.easeLinear).attr("stroke-dashoffset",0).on("end",function(event, link) {// Continue animation to target nodesanimateLinks(link.target); }); }functionresetLinks() {// Reset all gradient links gradientLinks.interrupt(); gradientLinks.attr("stroke-opacity",0).each(function() {const path = d3.select(this);const length = path.node().getTotalLength(); path.attr("stroke-dasharray",`${length}${length}`).attr("stroke-dashoffset", length); }); }// Add hover and click events to nodes node.attr("cursor","pointer").on("mouseover", (event, d) => {resetLinks();animateLinks(d); }).on("mouseout", resetLinks).on("click", (event, d) => {event.stopPropagation();// Prevent triggering svg click// Get all nodes in the genealogy (ancestors and descendants)const ancestors =traceAncestry(d);const descendants =traceDescendants(d);const genealogy =newSet([...ancestors,...descendants]);// Dim nodes not in the genealogy node.transition().duration(200).attr("opacity", n => genealogy.has(n) ?0.9:0.3);// Highlight links between nodes in the genealogy gradientLinks.transition().duration(200).attr("stroke-opacity", link => (genealogy.has(link.source) && genealogy.has(link.target)) ?0.7:0.1 ).attr("stroke-dashoffset",0); });// Add click event to the SVG background to reset the view svg.on("click", () => {// Reset nodes node.transition().duration(200).attr("opacity",0.8);// Reset linksresetLinks(); });return svg.node();}// Create the Sankey diagram with our processed data// This will now reactively update when processedData, nodeAlign, linkColor, or nodeSort changessankey =createSankey(processedData, {width:1000,height:700,title:"IT Ticket Flow Analysis"})
Source Code
---title: "TDXSankey"format: htmlcode-fold: truecode-tools: trueresources: - ticket_data.csv---#TDX Ticket FlowThis interactive visualization allows us to explore the assignment patterns for about 80,000 tickets. I've made my best attempt to discern the structure of assignments within OIT, but this likely needs refinement. The source data in `ticket_data.csv` are scrubbed of all sensitive information.## TDX SANKEY DIAGRAM```{ojs}// Load the D3 and Sankey librariesd3 = require("d3@7")d3Sankey = require("d3-sankey@0.12")// Load the CSV file directly// Replace "ticket_data.csv" with the path to your actual CSV filerawData = await d3.csv("data/ticket_data.csv")// Define the columns you want to include in the Sankey diagraminitialColumns = ["Affiliation", "Method", "Ingestion", "MajorGroup", "FocusArea", "Unit"]// Add priority and classification optionsviewof includePriority = Inputs.checkbox( ["Include Priority"], {value: [], label: "Priority Filter"})viewof priorityFilter = Inputs.checkbox( ["Normal", "Urgent", "High", "VIP", "Critical", "Low"], {value: ["Normal", "Urgent", "High", "VIP", "Critical", "Low"], label: "Priority Values to Include"})viewof includeClassification = Inputs.checkbox( ["Include Classification"], {value: [], label: "Classification Filter"})viewof classificationFilter = Inputs.checkbox( ["Service Request", "Incident"], {value: ["Service Request", "Incident"], label: "Classification Values to Include"})// UI controls for customizationviewof minLinkValue = Inputs.range([1, 100], { value: 5, step: 1, label: "Minimum Link Value"})viewof columns = Inputs.checkbox( initialColumns, {value: initialColumns, label: "Columns to Include"})viewof nodeAlign = Inputs.select(new Map([ ["Left", "sankeyLeft"], ["Right", "sankeyRight"], ["Center", "sankeyCenter"], ["Justify", "sankeyJustify"]]), { value: "sankeyLeft", label: "Node Alignment"})viewof linkColor = Inputs.select(new Map([ ["Static", "#aaa"], ["Source-Target Gradient", "source-target"], ["Source Color", "source"], ["Target Color", "target"]]), { value: "source-target", label: "Link Color"})viewof nodeSort = Inputs.select(new Map([ ["Default", "none"], ["Ascending by Size", "ascending"], ["Descending by Size", "descending"], ["Alphabetical", "alphabetical"], ["Reverse Alphabetical", "reverse-alpha"]]), { value: "descending", label: "Node Sorting"})// Process the CSV data for the Sankey diagramfunction processCsvForSankey(data, columns, minValue = 5) { // Initialize arrays for nodes and links const initialNodes = []; const links = []; const nodeMap = new Map(); // To track node indices let nodeIndex = 0; // Create nodes for each unique value in each column columns.forEach((column, colIndex) => { // Get unique values in this column // For columns after the first one, don't include "NA" values let uniqueValues; if (colIndex === 0) { // For the first column, include all values including "NA" uniqueValues = [...new Set(data.map(d => d[column]))].filter(d => d); } else { // For subsequent columns, exclude "NA" values uniqueValues = [...new Set(data.map(d => d[column]))].filter(d => d && d !== "NA"); } // Create nodes for each unique value uniqueValues.forEach(value => { // Create a prefixed name to avoid duplicates const nodeName = `${column}: ${value}`; // Add to nodes array initialNodes.push({ name: nodeName, group: column }); // Map the name to its index nodeMap.set(nodeName, nodeIndex++); }); }); // Create links between adjacent columns for (let i = 0; i < columns.length - 1; i++) { const sourceCol = columns[i]; const targetCol = columns[i + 1]; // Count transitions between columns const transitions = new Map(); data.forEach(row => { const source = row[sourceCol]; const target = row[targetCol]; // Skip if either value is missing if (!source || !target) return; // Skip if target is "NA" (for columns after the first) if (i > 0 && target === "NA") return; const key = `${sourceCol}: ${source}|${targetCol}: ${target}`; transitions.set(key, (transitions.get(key) || 0) + 1); }); // Create links for transitions that meet the minimum value transitions.forEach((value, key) => { if (value < minValue) return; // Skip minor links const [sourceNode, targetNode] = key.split('|'); // Get the indices const sourceIndex = nodeMap.get(sourceNode); const targetIndex = nodeMap.get(targetNode); // Skip if either node doesn't exist in our node list if (sourceIndex === undefined || targetIndex === undefined) return; // Add the link links.push({ source: sourceIndex, target: targetIndex, value: value }); }); } // Filter nodes to keep only those that are connected // First, collect all node indices that appear in links const connectedIndices = new Set(); links.forEach(link => { connectedIndices.add(link.source); connectedIndices.add(link.target); }); // Create a new array of only connected nodes const connectedNodes = initialNodes.filter((node, index) => connectedIndices.has(index)); // Create a mapping from old indices to new indices const indexMapping = new Map(); connectedNodes.forEach((node, newIndex) => { const oldIndex = initialNodes.indexOf(node); indexMapping.set(oldIndex, newIndex); }); // Update link indices to point to the new node positions const updatedLinks = links.map(link => ({ source: indexMapping.get(link.source), target: indexMapping.get(link.target), value: link.value })); return { nodes: connectedNodes, links: updatedLinks };}// Process the CSV data based on current selectionsprocessedData = { // Filter data based on selected priority and classification values let filteredData = rawData; if (includePriority.length > 0) { filteredData = filteredData.filter(d => priorityFilter.includes(d.Priority)); } if (includeClassification.length > 0) { filteredData = filteredData.filter(d => classificationFilter.includes(d.Classification)); } // Add Priority to beginning of columns if selected let selectedColumns = [...columns]; if (includePriority.length > 0 && !selectedColumns.includes("Priority")) { selectedColumns.unshift("Priority"); } // Add Classification to beginning of columns if selected if (includeClassification.length > 0 && !selectedColumns.includes("Classification")) { selectedColumns.unshift("Classification"); } return processCsvForSankey(filteredData, selectedColumns, minLinkValue);}// Function to create the Sankey diagramfunction createSankey(data, { width = 975, height = 600, marginTop = 30, marginRight = 30, marginBottom = 30, marginLeft = 30, nodeWidth = 15, nodePadding = 10, title = "IT Ticket Flow Analysis"} = {}) { // Create a new SVG element const svg = d3.create("svg") .attr("width", width) .attr("height", height) .attr("viewBox", [0, 0, width, height]) .attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;"); // Add a title svg.append("text") .attr("x", width / 2) .attr("y", marginTop / 2) .attr("text-anchor", "middle") .attr("font-size", "16px") .attr("font-weight", "bold") .text(title); // Create defs for gradients const defs = svg.append("defs"); // Create a custom sort function based on the selected option let sortNodes = null; if (nodeSort === "ascending") { sortNodes = (a, b) => a.value - b.value; } else if (nodeSort === "descending") { sortNodes = (a, b) => b.value - a.value; } else if (nodeSort === "alphabetical") { sortNodes = (a, b) => { const nameA = a.name.split(": ")[1] || a.name; const nameB = b.name.split(": ")[1] || b.name; return nameA.localeCompare(nameB); }; } else if (nodeSort === "reverse-alpha") { sortNodes = (a, b) => { const nameA = a.name.split(": ")[1] || a.name; const nameB = b.name.split(": ")[1] || b.name; return nameB.localeCompare(nameA); }; } // Create the Sankey generator with the selected alignment and sorting const sankey = d3Sankey.sankey() .nodeId(d => d.index) .nodeWidth(nodeWidth) .nodePadding(nodePadding) .nodeAlign(d3Sankey[nodeAlign]) .nodeSort(sortNodes) // Apply the sorting function if one is defined .extent([ [marginLeft, marginTop], [width - marginRight, height - marginBottom] ]); // Copy the data to avoid modifying the input const sankeyData = JSON.parse(JSON.stringify(data)); // Format the processed data const {nodes, links} = sankey(sankeyData); // Assign colors to nodes const colorScale = d3.scaleOrdinal() .domain(nodes.map(d => d.group || "default")) .range(d3.schemeCategory10); nodes.forEach((node, i) => { node.color = node.group ? colorScale(node.group) : colorScale(i % 10); }); // Create gradient definitions for each link links.forEach((link, i) => { // Create unique IDs for gradients and paths link.gradient = { id: `link-gradient-${i}` }; link.path = { id: `link-path-${i}` }; // Add gradient definition const gradient = defs.append("linearGradient") .attr("id", link.gradient.id) .attr("gradientUnits", "userSpaceOnUse") .attr("x1", link.source.x1) .attr("y1", (link.source.y0 + link.source.y1) / 2) .attr("x2", link.target.x0) .attr("y2", (link.target.y0 + link.target.y1) / 2); gradient.append("stop") .attr("offset", "0%") .attr("stop-color", link.source.color); gradient.append("stop") .attr("offset", "100%") .attr("stop-color", link.target.color); }); // Draw the gray background links svg.append("g") .selectAll("path") .data(links) .join("path") .attr("class", "link-bg") .attr("d", d3Sankey.sankeyLinkHorizontal()) .attr("stroke", "#ddd") .attr("stroke-width", d => Math.max(1, d.width)) .attr("stroke-opacity", 0.3) .attr("fill", "none"); // Draw the gradient links (initially invisible) const gradientLinks = svg.append("g") .selectAll("path") .data(links) .join("path") .attr("id", d => d.path.id) .attr("class", "gradient-link") .attr("d", d3Sankey.sankeyLinkHorizontal()) .attr("stroke", d => { if (linkColor === "source-target") { return `url(#${d.gradient.id})`; } else if (linkColor === "source") { return d.source.color; } else if (linkColor === "target") { return d.target.color; } else { return linkColor; // Static color } }) .attr("stroke-width", d => Math.max(1, d.width)) .attr("stroke-opacity", 0) .attr("fill", "none"); // Set up dash arrays for animation gradientLinks.each(function() { const path = d3.select(this); const length = path.node().getTotalLength(); path.attr("stroke-dasharray", `${length} ${length}`) .attr("stroke-dashoffset", length); }); // Add tooltips to links gradientLinks.append("title") .text(d => { const sourceParts = d.source.name.split(": "); const targetParts = d.target.name.split(": "); const sourceName = sourceParts.length > 1 ? sourceParts[1] : d.source.name; const targetName = targetParts.length > 1 ? targetParts[1] : d.target.name; return `${sourceName} → ${targetName}\n${d3.format(",.0f")(d.value)} tickets`; }); // Draw the nodes const node = svg.append("g") .selectAll("rect") .data(nodes) .join("rect") .attr("class", "node") .attr("x", d => d.x0) .attr("y", d => d.y0) .attr("height", d => d.y1 - d.y0) .attr("width", d => d.x1 - d.x0) .attr("fill", d => d.color) .attr("opacity", 0.8); // Add tooltips to nodes node.append("title") .text(d => { const parts = d.name.split(": "); const displayName = parts.length > 1 ? parts[1] : d.name; return `${displayName}\n${d3.format(",.0f")(d.value)} tickets`; }); // Add node labels svg.append("g") .selectAll("text") .data(nodes) .join("text") .attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6) .attr("y", d => (d.y0 + d.y1) / 2) .attr("dy", "0.35em") .attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end") .text(d => { // Remove the prefix for display const parts = d.name.split(": "); return parts.length > 1 ? parts[1] : d.name; }); // Animation and highlighting functions function traceAncestry(node, visited = new Set()) { // Add this node to visited visited.add(node); // Find all links where this node is the target (incoming links) const incomingLinks = links.filter(link => link.target === node); // For each incoming link, trace back to its source incomingLinks.forEach(link => { if (!visited.has(link.source)) { traceAncestry(link.source, visited); } }); return visited; } function traceDescendants(node, visited = new Set()) { // Add this node to visited visited.add(node); // Find all links where this node is the source (outgoing links) const outgoingLinks = links.filter(link => link.source === node); // For each outgoing link, trace forward to its target outgoingLinks.forEach(link => { if (!visited.has(link.target)) { traceDescendants(link.target, visited); } }); return visited; } function highlightGenealogyLinks(node) { // Get all ancestors and descendants of this node const ancestors = traceAncestry(node); const descendants = traceDescendants(node); const genealogy = new Set([...ancestors, ...descendants]); // Highlight nodes in the genealogy node.forEach(n => { d3.select(n) .transition() .duration(200) .attr("opacity", genealogy.has(n.__data__) ? 0.9 : 0.3); }); // Highlight links connected to the genealogy gradientLinks .attr("stroke-opacity", link => (genealogy.has(link.source) && genealogy.has(link.target)) ? 0.7 : 0.1 ) .attr("stroke-dashoffset", 0); } function resetHighlighting() { // Reset nodes node.transition().duration(200).attr("opacity", 0.8); // Reset links gradientLinks.attr("stroke-opacity", 0); } function animateLinks(node) { // Find all links where this node is the source const sourceLinks = links.filter(link => link.source === node); // Animate these links gradientLinks .filter(link => sourceLinks.includes(link)) .attr("stroke-opacity", 0.6) .transition() .duration(300) .ease(d3.easeLinear) .attr("stroke-dashoffset", 0) .on("end", function(event, link) { // Continue animation to target nodes animateLinks(link.target); }); } function resetLinks() { // Reset all gradient links gradientLinks.interrupt(); gradientLinks .attr("stroke-opacity", 0) .each(function() { const path = d3.select(this); const length = path.node().getTotalLength(); path.attr("stroke-dasharray", `${length} ${length}`) .attr("stroke-dashoffset", length); }); } // Add hover and click events to nodes node .attr("cursor", "pointer") .on("mouseover", (event, d) => { resetLinks(); animateLinks(d); }) .on("mouseout", resetLinks) .on("click", (event, d) => { event.stopPropagation(); // Prevent triggering svg click // Get all nodes in the genealogy (ancestors and descendants) const ancestors = traceAncestry(d); const descendants = traceDescendants(d); const genealogy = new Set([...ancestors, ...descendants]); // Dim nodes not in the genealogy node.transition().duration(200) .attr("opacity", n => genealogy.has(n) ? 0.9 : 0.3); // Highlight links between nodes in the genealogy gradientLinks .transition().duration(200) .attr("stroke-opacity", link => (genealogy.has(link.source) && genealogy.has(link.target)) ? 0.7 : 0.1 ) .attr("stroke-dashoffset", 0); }); // Add click event to the SVG background to reset the view svg.on("click", () => { // Reset nodes node.transition().duration(200).attr("opacity", 0.8); // Reset links resetLinks(); }); return svg.node();}// Create the Sankey diagram with our processed data// This will now reactively update when processedData, nodeAlign, linkColor, or nodeSort changessankey = createSankey(processedData, { width: 1000, height: 700, title: "IT Ticket Flow Analysis"})```