Home > Back-end >  d3: how to focus separate tooltips for stacked area chart?
d3: how to focus separate tooltips for stacked area chart?

Time:12-12

I'm creating a stacked area chart using d3.js. Right now I'm not able to figure out on (mousemove) how to focus separate tooltip on the respective chart curve.

I'm trying to achieve this but for stacked area chart.

I have created a sandbox of my progress here: https://codesandbox.io/s/recursing-carlos-3t0lg

Relevant code:

    function mouseMove() {
      d3.event.preventDefault();
      const mouse = d3.mouse(d3.event.target);
      const [xCoord, yCoord] = mouse;
      const mouseDate = x.invert(xCoord);
      const mouseDateSnap = d3.timeDay.floor(mouseDate);
      const bisectDate = d3.bisector((d) => d.date).left;
      const xIndex = bisectDate(data, mouseDateSnap, 0);
      const mouseHours = data[xIndex].hours;
      let demandHours =
        data[xIndex].resourceType === "DMND" ? data[xIndex].hours : "";
      let supplyHours =
        data[xIndex].resourceType === "SPLY" ? data[xIndex].hours : "";

      if (x(mouseDateSnap) <= 0) return;

      svg
        .selectAll(".hoverLine")
        .attr("x1", x(mouseDateSnap))
        .attr("y1", margin.top)
        .attr("x2", x(mouseDateSnap))
        .attr("y2", height - margin.bottom)
        .attr("stroke", "#69b3a2")
        .attr("fill", "#cce5df");

      svg
        .select(".hoverPoint1")
        .attr("cx", x(mouseDateSnap))
        .attr("cy", y(supplyHours))
        .attr("r", "7")
        .attr("fill", "green");
      svg
        .select(".hoverPoint2")
        .attr("cx", x(mouseDateSnap))
        .attr("cy", y(demandHours))
        .attr("r", "7")
        .attr("fill", "yellow");

      const isLessThanHalf = xIndex > data.length / 2;
      const hoverTextX = isLessThanHalf ? "-0.75em" : "0.75em";
      const hoverTextAnchor = isLessThanHalf ? "end" : "start";

      svg
        .selectAll(".hoverText")
        .attr("x", x(mouseDateSnap))
        .attr("y", y(mouseHours))
        .attr("dx", hoverTextX)
        .attr("dy", "-1.25em")
        .style("text-anchor", hoverTextAnchor)
        .text(
          data[xIndex].resourceType === "DMND"
            ? demandHours   "sec"
            : supplyHours   "sec"
        );
    }

    svg.append("line").classed("hoverLine", true);
    svg.append("circle").classed("hoverPoint1", true);
    svg.append("circle").classed("hoverPoint2", true);
    svg.append("text").classed("hoverText", true);
    svg
      .append("rect")
      .attr("fill", "transparent")
      .attr("x", 0)
      .attr("y", 0)
      .attr("width", width)
      .attr("height", height);
    svg.on("mousemove", mouseMove);

In above code I'm creating 2 separate tooltips using selections hoverPoint1 (to show hours SPLY hours) and hoverPoint2 (to show DMND hours)

Expected:
on (mousemove), the green circle should move along the curve of blue plotted area AND at same time yellow circle should move along curve of gray plotted area. (please see this as example which shows single area tooltip https://observablehq.com/@elishaterada/simple-area-chart-with-tooltip)

thanks!

function stackedAreaPlot(data) {
  // set the dimensions and margins of the graph
  const margin = {
      top: 10,
      right: 30,
      bottom: 30,
      left: 60
    },
    width = 700 - margin.left - margin.right,
    height = 400 - margin.top - margin.bottom;

  // append the svg object to the body of the page
  const svg = d3
    .select("#stackedAreaPlot")
    .append("svg")
    .attr("width", width   margin.left   margin.right)
    .attr("height", height   margin.top   margin.bottom)
    .append("g")
    .attr("transform", `translate(${margin.left}, ${margin.top})`);

  // Add X axis --> it is a date format
  const x = d3
    .scaleTime()
    .domain(d3.extent(data, (d: any) => new Date(d.date)))
    .range([0, width]);
  svg
    .append("g")
    .attr("transform", `translate(0, ${height})`)
    .call(d3.axisBottom(x));

  // Add Y axis
  const y = d3.scaleLinear().domain([0, 2000]).range([height, 0]);
  svg.append("g").call(d3.axisLeft(y));

  let sumstat: any = d3
    .nest()
    .key((d: any) => {
      return d.date;
    })
    .entries(data);

  // stacked groups
  const myGroups = ["DMND", "SPLY"];
  const stackedData: any = d3
    .stack()
    // .keys(myGroup)
    .keys(myGroups)
    .value((data: any, key: any) => {
      return data &&
        data.values &&
        data.values.find((d) => d.resourceType === key) ?
         data.values.find((d) => d.resourceType === key).hours :
        null;
    })(sumstat);

  const color = d3
    .scaleOrdinal()
    .domain(myGroups)
    .range(["#a9b4bc", "#4b6f93"]);

  // Show the areas
  svg
    .selectAll("mylayers")
    .data(stackedData)
    .enter()
    .append("path")
    .style("fill", (d: any) => color(d.key))
    .attr(
      "d",
      d3
      .area()
      .x(function(d: any) {
        return x(new Date(d.data.key));
      })
      .y0(function(d: any) {
        return y( d[0]);
      })
      .y1(function(d: any) {
        return y( d[1]);
      })
    );

  // tooltip with hover point and line
  function mouseMove() {
    d3.event.preventDefault();
    const mouse = d3.mouse(d3.event.target);
    const [xCoord, yCoord] = mouse;
    const mouseDate = x.invert(xCoord);
    const mouseDateSnap = d3.timeDay.floor(mouseDate);
    const bisectDate = d3.bisector((d) => d.date).left;
    const xIndex = bisectDate(data, mouseDateSnap, 0);
    const mouseHours = data[xIndex].hours;
    let demandHours =
      data[xIndex].resourceType === "DMND" ? data[xIndex].hours : "";
    let supplyHours =
      data[xIndex].resourceType === "SPLY" ? data[xIndex].hours : "";

    if (x(mouseDateSnap) <= 0) return;

    svg
      .selectAll(".hoverLine")
      .attr("x1", x(mouseDateSnap))
      .attr("y1", margin.top)
      .attr("x2", x(mouseDateSnap))
      .attr("y2", height - margin.bottom)
      .attr("stroke", "#69b3a2")
      .attr("fill", "#cce5df");

    svg
      .select(".hoverPoint1")
      .attr("cx", x(mouseDateSnap))
      .attr("cy", y(supplyHours))
      .attr("r", "7")
      .attr("fill", "green");
    svg
      .select(".hoverPoint2")
      .attr("cx", x(mouseDateSnap))
      .attr("cy", y(demandHours))
      .attr("r", "7")
      .attr("fill", "yellow");

    const isLessThanHalf = xIndex > data.length / 2;
    const hoverTextX = isLessThanHalf ? "-0.75em" : "0.75em";
    const hoverTextAnchor = isLessThanHalf ? "end" : "start";

    svg
      .selectAll(".hoverText")
      .attr("x", x(mouseDateSnap))
      .attr("y", y(mouseHours))
      .attr("dx", hoverTextX)
      .attr("dy", "-1.25em")
      .style("text-anchor", hoverTextAnchor)
      .text(
        data[xIndex].resourceType === "DMND" ?
        demandHours   " hours" :
        supplyHours   " hours"
      );
  }

  svg.append("line").classed("hoverLine", true);
  svg.append("circle").classed("hoverPoint1", true);
  svg.append("circle").classed("hoverPoint2", true);
  svg.append("text").classed("hoverText", true);
  svg
    .append("rect")
    .attr("fill", "transparent")
    .attr("x", 0)
    .attr("y", 0)
    .attr("width", width)
    .attr("height", height);
  svg.on("mousemove", mouseMove);
}

var data = [{
    date: "2021-12-10T05:00:00.000Z",
    hours: 388.5421,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-11T05:00:00.000Z",
    hours: 357.17214,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-12T05:00:00.000Z",
    hours: 194.80227,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-12T05:00:00.000Z",
    hours: 24.5,
    resourceType: "DMND"
  },
  {
    date: "2021-12-13T05:00:00.000Z",
    hours: 466.68397,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-13T05:00:00.000Z",
    hours: 48,
    resourceType: "DMND"
  },
  {
    date: "2021-12-14T05:00:00.000Z",
    hours: 591.11745,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-14T05:00:00.000Z",
    hours: 62.75,
    resourceType: "DMND"
  },
  {
    date: "2021-12-15T05:00:00.000Z",
    hours: 631.64018,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-15T05:00:00.000Z",
    hours: 84.73,
    resourceType: "DMND"
  },
  {
    date: "2021-12-16T05:00:00.000Z",
    hours: 175.7,
    resourceType: "DMND"
  },
  {
    date: "2021-12-16T05:00:00.000Z",
    hours: 628.53835,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-17T05:00:00.000Z",
    hours: 240.24,
    resourceType: "DMND"
  },
  {
    date: "2021-12-17T05:00:00.000Z",
    hours: 673.66929,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-18T05:00:00.000Z",
    hours: 256.68,
    resourceType: "DMND"
  },
  {
    date: "2021-12-18T05:00:00.000Z",
    hours: 635.43202,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-19T05:00:00.000Z",
    hours: 634.73701,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-19T05:00:00.000Z",
    hours: 212.44,
    resourceType: "DMND"
  },
  {
    date: "2021-12-20T05:00:00.000Z",
    hours: 604.94103,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-20T05:00:00.000Z",
    hours: 209.76,
    resourceType: "DMND"
  },
  {
    date: "2021-12-21T05:00:00.000Z",
    hours: 618.83085,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-21T05:00:00.000Z",
    hours: 200.78,
    resourceType: "DMND"
  },
  {
    date: "2021-12-22T05:00:00.000Z",
    hours: 580.31758,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-22T05:00:00.000Z",
    hours: 204.78,
    resourceType: "DMND"
  },
  {
    date: "2021-12-23T05:00:00.000Z",
    hours: 231.15,
    resourceType: "DMND"
  },
  {
    date: "2021-12-23T05:00:00.000Z",
    hours: 679.8791100000001,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-24T05:00:00.000Z",
    hours: 654.02485,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-24T05:00:00.000Z",
    hours: 281.28,
    resourceType: "DMND"
  }
];

stackedAreaPlot(data);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id="stackedAreaPlot"></div>

CodePudding user response:

I think you shot yourself in the foot with a couple of design decisions. The first problem was that you were comparing mouseDateSnap (a Date) with data[0].date, which is still a string. You needed to make it a date.

Furthermore, by looking at the stacked data, you would ensure you always have all the data belonging to a point, for all traces. Then, you can easily draw the circles, but also draw tooltips for both traces, not just for one.

By using d3.select().enter(), you can make this work for any number of traces, and you remove the need of having the .hoverPoint1 and .hoverPoint2 logic.

Finally, if you use mouseDateSnap in combination with bisect, you will draw the points right at the axis ticks (nice), but the y value would be off. It would match the y value of the closest drawn point. So the circles would actually not look nice at all.

function stackedAreaPlot(data) {
  // set the dimensions and margins of the graph
  const margin = {
      top: 10,
      right: 30,
      bottom: 30,
      left: 60
    },
    width = 700 - margin.left - margin.right,
    height = 400 - margin.top - margin.bottom;

  // append the svg object to the body of the page
  const svg = d3
    .select("#stackedAreaPlot")
    .append("svg")
    .attr("width", width   margin.left   margin.right)
    .attr("height", height   margin.top   margin.bottom)
    .append("g")
    .attr("transform", `translate(${margin.left}, ${margin.top})`);

  // Add X axis --> it is a date format
  const x = d3
    .scaleTime()
    .domain(d3.extent(data, (d: any) => new Date(d.date)))
    .range([0, width]);
  svg
    .append("g")
    .attr("transform", `translate(0, ${height})`)
    .call(d3.axisBottom(x));

  // Add Y axis
  const y = d3.scaleLinear().domain([0, 2000]).range([height, 0]);
  svg.append("g").call(d3.axisLeft(y));

  let sumstat: any = d3
    .nest()
    .key((d: any) => {
      return d.date;
    })
    .entries(data);

  // stacked groups
  const myGroups = ["DMND", "SPLY"];
  const stackedData: any = d3
    .stack()
    // .keys(myGroup)
    .keys(myGroups)
    .value((data: any, key: any) => {
      return data &&
        data.values &&
        data.values.find((d) => d.resourceType === key) ?
         data.values.find((d) => d.resourceType === key).hours :
        null;
    })(sumstat);

  const color = d3
    .scaleOrdinal()
    .domain(myGroups)
    .range(["#a9b4bc", "#4b6f93"]);

  // Show the areas
  svg
    .selectAll("mylayers")
    .data(stackedData)
    .enter()
    .append("path")
    .style("fill", (d: any) => color(d.key))
    .attr(
      "d",
      d3
      .area()
      .x(function(d: any) {
        return x(new Date(d.data.key));
      })
      .y0(function(d: any) {
        return y( d[0]);
      })
      .y1(function(d: any) {
        return y( d[1]);
      })
    );

  // tooltip with hover point and line
  function mouseMove() {
    d3.event.preventDefault();
    const mouse = d3.mouse(d3.event.target);
    const [xCoord, yCoord] = mouse;
    const mouseDate = x.invert(xCoord);

    // Use `sumstat`, not `data`, to get the correct data object for all traces
    const bisectDate = d3.bisector(d => new Date(d.key)).left;
    const xIndex = bisectDate(sumstat, mouseDate);

    // We get the key directly from xVal
    const xVal = new Date(sumstat[xIndex].key);

    if (x(xVal) <= 0) return;

    svg
      .selectAll(".hoverLine")
      .attr("x1", x(xVal))
      .attr("y1", y.range()[0])
      .attr("x2", x(xVal))
      .attr("y2", y.range()[1])
      .attr("stroke", "#69b3a2")
      .attr("fill", "#cce5df");
    
    const isLessThanHalf = xIndex > sumstat.length / 2;
    const hoverTextX = isLessThanHalf ? "-0.75em" : "0.75em";
    const hoverTextAnchor = isLessThanHalf ? "end" : "start";

    // Create a mapping of type (DMND/SPLY) to single and stacked Y values
    const yVals = {
      DMND: { color: "green" },
      SPLY: { color: "yellow" }
    };
    sumstat[xIndex].values.forEach((el) => {
      // Get the single values from `sumstat`
      yVals[el.resourceType].hours = el.hours;
    });
    stackedData.forEach((group) => {
      // Get the cumulative values from `stackedData`
      yVals[group.key].cumulative = group[xIndex][1];
    });    

    let hoverPoints = svg
      .selectAll(".hoverPoint")
      .data(Object.values(yVals));
    
    const newHoverPoints = hoverPoints
      .enter()
      .append("g")
      .classed("hoverPoint", true);
    
    newHoverPoints
      .append("circle")
      .attr("r", 7)
      .attr("fill", d => d.color);
    
    newHoverPoints
      .append("text")
      .attr("dy", "-1.25em");
    
    newHoverPoints
      .merge(hoverPoints)
      .attr("transform", d => `translate(${x(xVal)}, ${y(d.cumulative)})`)
      .select("text")
      .attr("dx", hoverTextX)
      .style("text-anchor", hoverTextAnchor)
      .text(d => `${d.hours || 0}sec`);
  }

  svg.append("line").classed("hoverLine", true);
  svg
    .append("rect")
    .attr("fill", "transparent")
    .attr("x", 0)
    .attr("y", 0)
    .attr("width", width)
    .attr("height", height);
  svg.on("mousemove", mouseMove);
}

var data = [{
    date: "2021-12-10T05:00:00.000Z",
    hours: 388.5421,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-11T05:00:00.000Z",
    hours: 357.17214,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-12T05:00:00.000Z",
    hours: 194.80227,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-12T05:00:00.000Z",
    hours: 24.5,
    resourceType: "DMND"
  },
  {
    date: "2021-12-13T05:00:00.000Z",
    hours: 466.68397,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-13T05:00:00.000Z",
    hours: 48,
    resourceType: "DMND"
  },
  {
    date: "2021-12-14T05:00:00.000Z",
    hours: 591.11745,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-14T05:00:00.000Z",
    hours: 62.75,
    resourceType: "DMND"
  },
  {
    date: "2021-12-15T05:00:00.000Z",
    hours: 631.64018,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-15T05:00:00.000Z",
    hours: 84.73,
    resourceType: "DMND"
  },
  {
    date: "2021-12-16T05:00:00.000Z",
    hours: 175.7,
    resourceType: "DMND"
  },
  {
    date: "2021-12-16T05:00:00.000Z",
    hours: 628.53835,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-17T05:00:00.000Z",
    hours: 240.24,
    resourceType: "DMND"
  },
  {
    date: "2021-12-17T05:00:00.000Z",
    hours: 673.66929,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-18T05:00:00.000Z",
    hours: 256.68,
    resourceType: "DMND"
  },
  {
    date: "2021-12-18T05:00:00.000Z",
    hours: 635.43202,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-19T05:00:00.000Z",
    hours: 634.73701,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-19T05:00:00.000Z",
    hours: 212.44,
    resourceType: "DMND"
  },
  {
    date: "2021-12-20T05:00:00.000Z",
    hours: 604.94103,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-20T05:00:00.000Z",
    hours: 209.76,
    resourceType: "DMND"
  },
  {
    date: "2021-12-21T05:00:00.000Z",
    hours: 618.83085,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-21T05:00:00.000Z",
    hours: 200.78,
    resourceType: "DMND"
  },
  {
    date: "2021-12-22T05:00:00.000Z",
    hours: 580.31758,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-22T05:00:00.000Z",
    hours: 204.78,
    resourceType: "DMND"
  },
  {
    date: "2021-12-23T05:00:00.000Z",
    hours: 231.15,
    resourceType: "DMND"
  },
  {
    date: "2021-12-23T05:00:00.000Z",
    hours: 679.8791100000001,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-24T05:00:00.000Z",
    hours: 654.02485,
    resourceType: "SPLY"
  },
  {
    date: "2021-12-24T05:00:00.000Z",
    hours: 281.28,
    resourceType: "DMND"
  }
];

stackedAreaPlot(data);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.js"></script>
<div id="stackedAreaPlot"></div>

  • Related