#TDX Ticket Flow

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 file
rawData = await d3.csv("data/ticket_data.csv")

// Define the columns you want to include in the Sankey diagram
initialColumns = ["Affiliation", "Method", "Ingestion", "MajorGroup", "FocusArea", "Unit"]

// Add priority and classification options
viewof 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 customization
viewof 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 diagram
function 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 selections
processedData = {
  // 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 diagram
function 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 changes
sankey = createSankey(processedData, {
  width: 1000,
  height: 700,
  title: "IT Ticket Flow Analysis"
})