javascriptd3.jsvisualizationforce-layout

Display D3 Network Graph Horizontally without folding


I'm creating a Network graph which connects nodes with paths.

My requirements are simple - The Network graph should be either vertical or horizontal without it folding.

So far, I created a graph that displays the chart in horizontal format.

However, the graph only displays in single line (without folding) if there are very limited set of nodes( i tried multiple trial-error of forceManyBody().strength() and forceLink(links).distance() to somehow get it working)

enter image description here

But for larger no. of nodes, the graph folds itself like this --

enter image description here

Some variation of d3.forceManyBody().strength(-600) gives me a single row but with reverse order of links , like this--

enter image description here

Here, 5050 circle should be first cirle but its coming at the end.

So, my questions are --

  1. How to find forceManyBody().strength() and forceLink(links).distance() correctly based on nodes so I have a single row
  2. Why does the first circle come at last ?

I do not mind if I have to scroll to view all nodes ( may be d3.zoom can help ?)

Looking for pointers. Please find code and data below :

const width = 1413;
const height = 480;

// data

const nodes = [{
    "_time": 1666891307118,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "QUEUE_PROCESSOR",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "5050",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891307241,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "PROPAGATION_DISPATCHER",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "1110",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891307580,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "PROPAGATION_PROCESSOR",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "1150",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891307937,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "QUEUE_PROCESSOR",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "5000",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891308121,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "QUEUE_PROCESSOR",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "5010",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891308278,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "CXML_OUT_DISPATCHER",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "1250",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891308605,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "PROPAGATION_PROCESSOR",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "1145",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891309471,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "CXML_OUT_DISPATCHER",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "1300",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891309485,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "CXML_OUT_DISPATCHER",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "1450",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666891313018,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "QUEUE_PROCESSOR",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "5050",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  },
  {
    "_time": 1666902123954,
    "CUSTOMER_NAME": " Customer_1",
    "CUSTOMER": "CID_123",
    "SOURCE": "EXTERNAL_GATEWAY",
    "SUPPLIER_ANID": "SUPP_ID",
    "TRACKING_STATUS": "FAILED",
    "CHECKPOINT": "1440",
    "DOCUMENT_NUMBER": "DOC_NO_123",
    "PAYLOAD_ID": "PID_123"
  }
];

const links = [{
    "source": 0,
    "target": 1,
    "time": 123
  },
  {
    "source": 1,
    "target": 2,
    "time": 339
  },
  {
    "source": 2,
    "target": 3,
    "time": 357
  },
  {
    "source": 3,
    "target": 4,
    "time": 184
  },
  {
    "source": 4,
    "target": 5,
    "time": 157
  },
  {
    "source": 5,
    "target": 6,
    "time": 327
  },
  {
    "source": 6,
    "target": 7,
    "time": 866
  },
  {
    "source": 7,
    "target": 8,
    "time": 14
  },
  {
    "source": 8,
    "target": 9,
    "time": 3533
  },
  {
    "source": 9,
    "target": 10,
    "time": 10810936
  }
];
const circleRadius = 25;
const linkColor = '#999'; //#FFFF00
const dangerColor = '#FF5286';
const dangerTimeInSec = 2;
const WAITING_FOR_CONFIRMATION_COLOR = '#F8D06B';
const IN_PROCESS_COLOR = '#6E9FFF';
const COMPLETED_COLOR = '#6CCF8E';
const ERROR_COLOR = '#FF5286';

function getStatusColor(data) {
  if (data.TRACKING_STATUS === 'WAITING_FOR_CONFIRMATION') {
    return WAITING_FOR_CONFIRMATION_COLOR;
  }
  if (data.TRACKING_STATUS === 'IN_PROCESS') {
    return IN_PROCESS_COLOR;
  }
  if (data.TRACKING_STATUS === 'COMPLETED') {
    return COMPLETED_COLOR;
  }

  if (data.TRACKING_STATUS === 'FAILED') {
    return ERROR_COLOR;
  }
  return 'gray';
}

function getTimeTextColor(data) {
  if (data.time > (dangerTimeInSec * 1000)) {
    return dangerColor;
  }
  return linkColor
}

function getTimeBetweenNodes(data) {
  const timeInSecs = data.time / 1000;
  return `${timeInSecs}s`
}

function createChart() {

  const svgId = "svgId";
  const node = document.getElementById(svgId);
  // svg.append('g';)
  while (node && node.firstChild) {
    node && node.firstChild.remove();
  }

  const svg = d3.select(`#${CSS.escape(svgId)}`);
  // const centerX = width /2;
  const centerY = height / 2;
  const simulation = d3.forceSimulation(nodes)
    .force("charge", d3.forceManyBody().strength(-600))
    .force(
      "collision",
      d3
      .forceCollide()
      .radius(function(d) {
        return d.radius * 2;
      })
    )
    .force("link", d3.forceLink(links).distance(50))
    .force("y", d3.forceY(0).strength(0.55))
    .force("center", d3.forceCenter(width / 2, centerY))
    .stop();

  for (let i = 0; i < 300; ++i) {
    simulation.tick();
  }



  const arrowId = `arrow-${svgId}`;
  svg.append("svg:defs").append("svg:marker")
    .attr("id", arrowId)
    .attr("viewBox", "0 -5 10 10")
    .attr('refX', 0)
    .attr("markerWidth", 5)
    .attr("markerHeight", 5)
    .attr("orient", "auto")
    .append("svg:path")
    .style("stroke", linkColor)
    .attr("fill", linkColor)
    .attr("d", "M0,-5L10,0L0,5");

  const lines = svg.selectAll("line")
    .data(links)
    .enter().append("path")
    .attr("class", "link")
    .style("stroke", linkColor)
    .attr('marker-end', (d) => `url(#${arrowId})`)
    .style("stroke-width", 1);



  const circles = svg.selectAll('circle')
    .data(nodes)
    .enter()
    .append('circle')
    .attr('fill', 'none')
    .attr('stroke', (d) => {
      return getStatusColor(d)
    })
    .style("pointer-events", "visible")
    .attr('stroke-width', 2)
    .attr('r', circleRadius)
  // .call(drag)
  // .call(zoom)
  //   .on('click', handleClick);

  // svg.call(zoom);



  const texts = svg.selectAll('text')
    .data(nodes)
    .enter()
    .append('text')
    .attr('text-anchor', 'middle')
    .attr('text-baseline', 'middle')
    .attr('font-size', '.8rem')
    .attr('fill', '#FFF')
    .style('pointer-events', 'none')
    .text((node) => `${node.CHECKPOINT}`);

  const timeTexts = svg
    .selectAll("timeText")
    .data(links)
    .enter()
    .append("text")
    .attr("text-anchor", "middle")
    .attr("text-baseline", "middle")
    .attr("font-size", ".8rem")
    .style("pointer-events", "none")
    .attr('fill', (d) => getTimeTextColor(d))
    .style('pointer-events', 'none')
    .text((node) => getTimeBetweenNodes(node));

  const sourceTexts = svg.selectAll('sourceTexts')
    .data(nodes)
    .enter()
    .append('foreignObject')
    .attr("width", 80)
    .attr("height", 80);

  sourceTexts.append("xhtml:div")
    .append('p')
    .attr('class', 'source-text')
    .html((d) => {
      return d.SOURCE.split("_").join(" ")
    });

  circles.attr('cx', (d) => d.x)
    .attr('cy', (d) => d.y);

  texts.attr('x', (d) => d.x)
    .attr('y', (d) => d.y + (circleRadius / 8));

  sourceTexts.attr('x', (d) => {
      return d.x - (circleRadius * 1.5);
    })
    .attr('y', (d) => d.y + (circleRadius));

  timeTexts.attr("x", (d) => {
    return d.source.x + (d.target.x - d.source.x) / 2;
  }).attr("y", (d) => {
    return d.source.y + (d.target.y - d.source.y) / 2 - 10;
  });

  lines
    .attr("d", (d) => "M" + (d.source.x + circleRadius) + "," + (d.source.y) + ", " + (d.target.x - (circleRadius + 10)) + "," + (d.target.y))

}

setTimeout(() => {
  createChart()
}, 1000);
<script src="https://d3js.org/d3.v7.min.js"></script>
<svg id="svgId" width="1413px" height="100vh"></svg>


Solution

  • As the comments mentioned. Your use case is very simple and can be recreated with the use of scales and shapes. I used a linear scale and used the indexes of the nodes as the domain for the scale, as the time intervals between the nodes differ in multiple orders of magnitude.

    Each node is contained within a group to simplify relative positioning of the circle, texts and line.

    As the number of edges has length of nodes - 1, I used the each function to iterate separately over the node groups and attach an edge only, if the current index is not the last one.

    // data
    
    const nodes = [{
        "_time": 1666891307118,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "QUEUE_PROCESSOR",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "5050",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891307241,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "PROPAGATION_DISPATCHER",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "1110",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891307580,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "PROPAGATION_PROCESSOR",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "1150",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891307937,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "QUEUE_PROCESSOR",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "5000",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891308121,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "QUEUE_PROCESSOR",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "5010",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891308278,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "CXML_OUT_DISPATCHER",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "1250",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891308605,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "PROPAGATION_PROCESSOR",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "1145",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891309471,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "CXML_OUT_DISPATCHER",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "1300",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891309485,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "CXML_OUT_DISPATCHER",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "1450",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666891313018,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "QUEUE_PROCESSOR",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "5050",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      },
      {
        "_time": 1666902123954,
        "CUSTOMER_NAME": " Customer_1",
        "CUSTOMER": "CID_123",
        "SOURCE": "EXTERNAL_GATEWAY",
        "SUPPLIER_ANID": "SUPP_ID",
        "TRACKING_STATUS": "FAILED",
        "CHECKPOINT": "1440",
        "DOCUMENT_NUMBER": "DOC_NO_123",
        "PAYLOAD_ID": "PID_123"
      }
    ];
    
    const width = 1600;
    const height = 400;
    
    const margin = 100;
    
    const data = nodes.map((d) => {
    d.id = d._time - nodes[0]._time;
    return d;
    });
    
    console.log({data})
    
    const svg = d3.select('svg');
    
    
    
    
    const container = svg.append('g')
    .style('transform', `translate(${margin}px, ${height / 2}px)`);
    const innerWidth = width - (margin * 2);
    
    const scale = d3.scaleLinear()
    .range([0, innerWidth])
    .domain(d3.extent(data, (d, i) => i));
    
    const groups = container.selectAll('g')
    .data(data)
    .enter()
    .append('g')
    .style('transform', (d, i) => `translate(${scale(i)}px, 0`);
    
    
    groups.each(function(d, i) {
        const e = d3.select(this);
      if (i < data.length - 1) {
        e.append('line')
          .attr('x1', 0)
          .attr('y1', 0)
          .attr('x2', scale(1))
          .attr('y2', 0);
          
        e.append('text')
            .attr('x', scale(1) / 2)
          .attr('y', -20)
          .attr('text-anchor', 'middle')
          .text(data[i+1].id)
      }
    });
    
    groups.append('circle')
    .attr('r', 30);
    
    groups.append('text')
    .attr('x', 0)
    .attr('y', 5)
    .attr('text-anchor', 'middle')
    .text((d) => d.id);
    
    groups.append('text')
    .attr('x', 0)
    .attr('y', 50)
    .attr('text-anchor', 'middle')
    .attr('font-size', 10)
    .text((d) => d.SOURCE);
    circle {
      fill: white;
      stroke: red;
    }
    
    line {
      stroke:black;
    }
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <svg width=1600 height=480></svg>