0

I am making an organization chart in d3.js (version 5). I want it to be as dynamic as possible, so when there are changes, I can easily update the input files and it will re-render. I have a simplified reprex below and in this CodePen in which I have hard-coded the input data.

Here is a quick explanation of what I'm trying to achieve:

  • const tree is an array of objects that represent the hierarchical portion of the visualization:

    • a ROOT,
    • 3 Managers, and
    • 6 projects
  • const staff is an array of objects representing staff.

  • searchObj and findIndicesOfMatches work in a .map() to replace the names of staff on a project with the object representing them (along with properties I will use as I continue to develop this org chart)
  • I determine the tree layout and render the tree. The parent nodes expand to cover their children, as desired.
  • The last step, which is where I'm stuck, is that I want to loop through the leaf nodes, append a g, and render additional rects based on the staff property. I'm currently trying to use .each() on the nodes, checking to ensure they are leaves (if(!d.children)), append a g, and build out the representation of staff on the project.

What I'm unsure of is how to change the bound data to the staff property. Nothing I've tried works so far. Currently, I'm getting a single rect in the g, even for projects with no staff.

What it looks like: What it looks like

What it should look like: What it should look like

Any ideas?

index.html:

<!DOCTYPE html>
<html>

<head>
  <script src="https://d3js.org/d3.v5.min.js"></script>
  <script src="./org-chart.js"></script>

  <style>
    body {
      font-family: 'Helvetica';
      color: #666;
      font: 12px sans-serif;
    }
  </style>
</head>

<body>
  <div id="viz"></div>
</body>

</html>

org-chart.js:

const tree = [
  {
    id: 'ROOT',
    parent: null
  },
  {
    id: 'Manager 1',
    parent: 'ROOT'
  },
  {
    id: 'Manager 2',
    parent: 'ROOT'
  },
  {
    id: 'Manager 3',
    parent: 'ROOT'
  },
  {
    id: 'Project 1',
    parent: 'Manager 1',
    staff: ['Staff 1']
  },
  {
    id: 'Project 2',
    parent: 'Manager 1',
    staff: ['Staff 1', 'Staff 2']
  },
  {
    id: 'Project 3',
    parent: 'Manager 2',
    staff: ['Staff 2', 'Staff 3', 'Staff 4']
  },
  {
    id: 'Project 4',
    parent: 'Manager 2',
    staff: ['Staff 2', 'Staff 3', 'Staff 5']
  },
  {
    id: 'Project 5',
    parent: 'Manager 2',
    staff: []
  },
  {
    id: 'Project 6',
    parent: 'Manager 3',
    staff: ['Staff 4', 'Staff 5']
  }
];

const staff = [
  { name: 'Staff 1', office: 'Office 1' },
  { name: 'Staff 2', office: 'Office 2' },
  { name: 'Staff 3', office: 'Office 3' },
  { name: 'Staff 4', office: 'Office 4' },
  { name: 'Staff 5', office: 'Office 5' }
];

function searchObj(obj, query) {
  for (var key in obj) {
    var value = obj[key];
    if (typeof value === 'object') {
      searchObj(value, query);
    }
    if (value === query) {
      return true;
    }
  }
  return false;
}

function findIndicesOfMatches(arrayOfObjects, query) {
  booleanArray = arrayOfObjects.map(el => {
    return searchObj(el, query);
  });
  const reducer = (accumulator, currentValue, index) => {
    return currentValue ? accumulator.concat(index) : accumulator;
  };
  return booleanArray.reduce(reducer, []);
}

// Join tree and staff data
const joinedData = tree.map(el => {
  if ('staff' in el) {
    newStaffArray = el.staff.map(s => {
      const staffIndex = findIndicesOfMatches(staff, s);
      return staff[staffIndex];
    });

    return { ...el, staff: newStaffArray };
  } else {
    return el;
  }
});

console.log('joinedData');
console.log(joinedData);

// Sizing variables
const margin = { top: 50, right: 50, bottom: 90, left: 90 },
  width = 1000,
  height = 200,
  node_height = 25,
  leaf_node_width = 100;

// Draw function
drawOrgChart = data => {
  const svg = d3
    .select('#viz')
    .append('svg')
    .attr('width', width + margin.left + margin.right)
    .attr('height', height + margin.bottom + margin.top);

  const stratify = d3
    .stratify()
    .parentId(d => d.parent)
    .id(d => d.id);

  const tree = d3
    .tree()
    .size([width, height])
    .separation(d => leaf_node_width * 0.5);

  const dataStratified = stratify(data);

  var nodes = d3.hierarchy(dataStratified);

  root_node = tree(nodes);

  const g = svg
    .append('g')
    .attr('transform', `translate(${margin.left}, ${margin.top})`);

  var nodes = g
    .selectAll('.node')
    .data(nodes.descendants(), d => d.id)
    .enter()
    .append('g')
    .attr('class', function(d) {
      return 'node' + (d.children ? ' node--internal' : ' node--leaf');
    })
    .attr('transform', function(d) {
      return 'translate(' + d.x + ',' + d.y + ')';
    });

  var rect_colors = ['grey', 'blue', 'green', 'maroon'];

  nodes
    .append('rect')
    .attr('height', node_height)
    .attr('width', d => {
      const extent = d3.extent(d.leaves().map(d => d.x));
      return extent[1] - extent[0] + leaf_node_width;
    })
    .attr('fill', '#ffffff')
    .attr('stroke', d => {
      return rect_colors[d.depth];
    })
    .attr('transform', d => {
      const first_leaf_x = d.leaves()[0].x;
      return `translate(${-(d.x - first_leaf_x + leaf_node_width / 2)},0)`;
    })
    .attr('rx', 5)
    .attr('ry', 5);

  nodes
    .append('text')
    .attr('dy', '.35em')
    .attr('x', d => 0)
    .attr('y', node_height / 2)
    .style('text-anchor', 'middle')
    .text(function(d) {
      return d.data.data.id;
    });

  // This is the bit I can't figure out:
  // I'd like to append additional elements to
  // the leaf nodes based on the 'staff' property
  console.log(nodes.data());
  nodes.each(function(d, j) {
    if (!d.children) {
      const staff = d.data.data.staff;
      console.log(staff);

      d3.select(this)
        .append('g')
        .selectAll('rect')
        .data([staff])
        .enter()
        .append('rect')
        .attr('x', 0)
        .attr('y', (p, i) => 30 * (i + 1))
        .attr('height', node_height)
        .attr('width', leaf_node_width)
        .attr('transform', `translate(-${leaf_node_width / 2},0)`)
        .attr('stroke', 'red')
        .attr('fill', '#efefef80');
    }
  });
};

document.addEventListener('DOMContentLoaded', function() {
  drawOrgChart(joinedData);
});
twgardner2
  • 630
  • 1
  • 8
  • 27

1 Answers1

1

You need to replace .data([staff]) by .data(staff). staff is already an array. If you use [staff] it binds to an array of one element which is itself an array. This is why you only see one leaf for staff.

d3.select(this)
  .append('g')
  .selectAll('rect')
  // use staff instead of [staff]
  .data(staff)
  ....

See this modified CodePen

There is still a problem with the size of the rectangles (the last one is out of the svg), but it should set you on the right path.

thibautg
  • 2,004
  • 1
  • 14
  • 17
  • Wow, so simple. I know I tried that at one point, but I must have had another error when I did. As a point of clarification, I only loosely understand why I have to access the staff array as d.data.data.staff. Do you have a good explanation? – twgardner2 Dec 20 '18 at 12:37
  • I’m not quite familiar with your code, but it seems that it’s how data is output from [d3-hierarchy](https://github.com/d3/d3-hierarchy). And for the `data` function on a [d3-selection](https://github.com/d3/d3-selection), it usually expects an array and iterates over it to append elements. So there was no need to wrap `staff` in another array. You can always `console.log(d)` to see how `d` is structured in `nodes.each(function(d, j) {…})`. `d` is the data bound to the d3 element. – thibautg Dec 21 '18 at 08:44