Chris Farmer
Chris Farmer

Reputation: 25426

How to improve my simple nested data binding in d3?

I have a pretty simple nested data structure that I want to render as a bar chart with d3. The "nesting" is that each array element itself contains an array:

interface MyData {
  name: string
  values: number[]
}

const allData: MyData[] = [
  { name: "one", values: [4, 6, 10] },
  { name: "two", values: [1, 3, 4, 7] },
  { name: "three", values: [2, 2.5, 4] },
  { name: "four", values: [5, 8, 9] },
  { name: "five", values: [3, 5, 5.5, 6, 9] }
]

I want to render this in svg with d3 such that it looks something like this, with discrete lines for each value in the values array and a container rectangle containing the range of values, and I want to be able to toggle the visibility of individual elements in my main data array:

enter image description here

I have cobbled together a working version, creating the container rects and the point lines in separate steps, and I feel like I'm missing how to render these in a simpler way (codesandbox here).

    // Range container rectangle
    svg
      .select("g.plot-area")
      .selectAll("rect.range")
      .data(allData, (d: any) => d.name)
      .join(
        (enter) =>
          enter
            .append("rect")
            .classed("range", true)
            .attr("all my attrs for the range rect", ...)
        (update) =>
          update
            .attr("attrs for updated range rect", ...),
        (exit) => exit.remove()
      );

    // Individual point lines
    svg
      .select("g.plot-area")
      .selectAll("g.points")
      .data(allData, (d: any) => d.name) // <-- this seems weird
      .join(
        (enter) => {
          const points = enter.append("g").classed("points", true);
          points
            .selectAll("rect.point")
            .data((d) => d.values)
            .join("rect")
            .classed("point", true)
            .attr("attrs for this point", ...)
          return points;
        },
        (update) => {
          update
            .selectAll("rect.point")
            .attr("attrs for this point", ...)
          return update;
        }
      );

In particular, for the individual points it seems dumb that I have to bind to the main data set again for no other reason than to subsequently bind to each array of points. I feel like this should be able to happen with fewer steps.

How can I most effectively use d3 to render both the container rects and the individual point rects?

Upvotes: 2

Views: 103

Answers (1)

Robin Mackenzie
Robin Mackenzie

Reputation: 19299

Since you are only drawing rects then simply un-nesting the data seems a viable approach. The only difference between your .range and .point rects is only in their opacity and width.

E.g.

const unnestedData = allData.reduce((a, c) => {
  a.push({
    name: c.name,
    color: c.color,
    type: "range",
    min: d3.min(c.values),
    max: d3.max(c.values)
  });
  c.values.map(n => {
    a.push({
      name: c.name,
      color: c.color,
      type: "line",
      min: n,
      max: n // redundant
    });
  });
  return a;
}, []);

Returns:

0: {name: "one", color: "red", type: "range", min: 4, max: 10}
1: {name: "one", color: "red", type: "line", min: 4, max: 4}
2: {name: "one", color: "red", type: "line", min: 6, max: 6}
3: {name: "one", color: "red", type: "line", min: 10, max: 10}
4: {name: "two", color: "green", type: "range", min: 1, max: 7}
5: {name: "two", color: "green", type: "line", min: 1, max: 1}
6: {name: "two", color: "green", type: "line", min: 3, max: 3}
7: {name: "two", color: "green", type: "line", min: 4, max: 4}
8: {name: "two", color: "green", type: "line", min: 7, max: 7}
... etc

Then you only need a single cycle and employ a couple of conditions for opacity and width attributes:

svg.selectAll(".rects")
  .data(unnestedData) // d => d
  .join(
    enter => 
      enter.append("rect")
        .attr("class", d => d.type)
        .attr("opacity", d => d.type === "range" ? 0.3 : 1)
        .attr("fill", d => d.color)
        .attr("x", d => xScale(d.min))
        .attr("y", d => yScale(d.name))
        .attr("width", d => d.type === "range" ? xScale(d.max) - xScale(d.min) : 2)
        .attr("height", yScale.bandwidth())
  )

Example:

// input data
const allData = [
  { name: "one", values: [4, 6, 10], color: "red" },
  { name: "two", values: [1, 3, 4, 7], color: "green" },
  { name: "three", values: [2, 2.5, 4], color: "cyan" },
  { name: "four", values: [5, 8, 9], color: "purple" },
  { name: "five", values: [3, 5, 5.5, 6, 9], color: "orange" }
];

// un-nest the data
const unnestedData = allData.reduce((a, c) => {
  a.push({
    name: c.name,
    color: c.color,
    type: "range",
    min: d3.min(c.values),
    max: d3.max(c.values)
  });
  c.values.map(n => {
    a.push({
      name: c.name,
      color: c.color,
      type: "line",
      min: n,
      max: n // redundant
    });
  });
  return a;
}, []);

// basic viz
const width = 500;
const height = 200;
const margin = {top: 5, right: 5, bottom: 20, left: 50}

const svg = d3.select("#graph")
  .append("svg")
  .attr("width", width)
  .attr("height", height);

const xScale = d3.scaleLinear()
  .domain([1, 10])
  .range([margin.left, width - margin.right]);

const xAxis = svg.append("g")
  .attr("transform", `translate(0, ${height - margin.bottom})`)
  .call(d3.axisBottom(xScale));

const yScale = d3.scaleBand()
  .padding(0.1)
  .domain(allData.map(k => k.name))
  .range([margin.top, height - margin.bottom]);

const yAxis = svg.append("g")
  .attr("transform", `translate(${margin.left}, 0)`)
  .call(d3.axisLeft(yScale));

const plotG = svg.append("g")
  .attr("class", "plot-area");

// render
svg.selectAll(".rects")
  .data(unnestedData, d => d) 
  .join(
    enter => 
      enter
        .append("rect")
        .attr("class", d => d.type)
        .attr("opacity", d => d.type === "range" ? 0.3 : 1)
        .attr("fill", d => d.color)
        .attr("x", d => xScale(d.min))
        .attr("y", d => yScale(d.name))
        .attr("width", d => d.type === "range" ? xScale(d.max) - xScale(d.min) : 2)
        .attr("height", yScale.bandwidth())
  )
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<div id="graph"></div>

Upvotes: 2

Related Questions