James Lim
James Lim

Reputation: 59

D3 - Single and Multi Line chart tooltips

I'm pretty new to D3 and just incorporated the below tooltips into my application. I have both a single line chart as well as a multi line chart.

Single line: https://bl.ocks.org/alandunning/cfb7dcd7951826b9eacd54f0647f48d3

Multi Line: Multiseries line chart with mouseover tooltip

As you can see, the functionality of the two tooltips are different. The Single Line tooltip jumps from each data point whereas the Multi Line continually follows the chart path. I want to change the Multi Line functionality to mimic how the Single Line tooltip works.

Any help would be greatly appreciated. Please let me know if I need to provide more information. Also note that the data I'm working with is an array of arrays

Below is my code:

Single Line Chart:

let g = svg.append('g');
    g.append("path")
    .datum(this.dataObj)
    .attr("class",`line-${this.yAxisData} line`)
    .attr('d', line)
    .attr("stroke",`${this.color(this.dataObj.label)}`)
    .attr("fill",'none')
    .attr("transform",       `translate(${this.margin.left},${this.margin.top})`);

var focus = g.append("g")
    .attr("class", "focus")
    .style("display", "none");

    focus.append("line")
    .datum(this.dataObj)
    .attr("class", "x-hover-line hover-line")
    .attr("transform",`translate(${this.margin.left},${this.margin.top})`)
    .attr("stroke",`${this.color(this.dataObj.label)}`)
    .attr("y1", 0)
    .attr("y2", height);

    focus.append("circle")
    .datum(this.dataObj)
    .attr("transform",`translate(${this.margin.left},${this.margin.top})`)
    .attr("stroke",`${this.color(this.dataObj.label)}`)
    .attr("r", 7.5);

    focus.append("text")
    .attr("class","linetip")
    .attr("x", 40)
    .attr("dy", "0.5em");

    svg.append("rect")
    .attr("transform", `translate(${this.margin.left},${this.margin.top})`)
    .attr("class", "overlay")
    .attr("width", width)
    .attr("height", height)
    .on("mouseover", function() { focus.style("display", null); })
    .on("mouseout", function() { focus.style("display", "none"); })
    .on("mousemove", this.mousemove);

mousemove() {
    var bisectDate = d3.bisector(function(d) { return d.date; }).left;
    let mouse = d3.mouse(d3.event.currentTarget);
    let svg = d3.select(this.container);
    var x0 = this.x.invert(mouse[0]);
    var i = bisectDate(this.dataObj, x0);
    var d0 = this.dataObj[i - 1];
    var d1 = this.dataObj[i];
    var d = x0 - d0.date > d1.date - x0 ? d1 : d0;
    var focus = svg.select(".focus");
    focus.attr("transform", "translate(" + this.x(d[this.xAxisData]) + "," + this.y(d[this.yAxisData]) + ")");
    focus.select("text").text(`[${d[this.yAxisData]}]`);
    focus.select(".x-hover-line").attr("y2", this.height - this.y(d[this.yAxisData]));
    focus.select(".y-hover-line").attr("x2", this.width + this.width);
}

Multi Line Chart:

    //append paths
    let g = svg.append('g');
    let chartLines = g.selectAll('.lines')
    .data(this.dataObj)
    .enter()
    .append('g')
    .attr('class', 'lines');

    chartLines.append('path')
    .attr('class','line')
    .attr('d', d => {
        return line(d);
    })
    .attr('stroke', (d) => color(d[0].label))
    .attr('fill','none')
    .attr("transform", `translate(${this.margin.left},0)`);

    var mouseG = svg.append("g")
    .attr("class", "mouse-over-effects")

    mouseG.append("path") // this is the black vertical line to follow mouse
    .attr("class", "mouse-line")
    .style("stroke", "black")
    .style("stroke-width", "2px")
    .style("stroke-dasharray", "3,3")
    .style("opacity", "0");

    var mousePerLine = mouseG.selectAll('.mouse-per-line')
    .data(this.dataObj)
    .enter()
    .append("g")
    .attr("class", "mouse-per-line");

    mousePerLine.append("circle")
    .datum(d=>{return d})
    .attr("r", 7)
    .attr("stroke", (d,i) => {
        console.log(d)
        return `${this.color(d[i].label)}`
    })
    .style("fill", "none")
    .style("opacity", "0");

    mousePerLine.append("text")
    .datum(d=>{return d})
    .attr("transform", "translate(10,3)");

    mouseG.append('svg:rect') // append a rect to catch mouse movements on canvas
    .attr("transform", `translate(${this.margin.left},0)`)
    .attr('width', width) // can't catch mouse events on a g element
    .attr('height', height)
    .attr('fill', 'none')
    .attr('pointer-events', 'all')
    .on('mouseout', () => { // on mouse out hide line, circles and text
        d3.select(".mouse-line")
        .style("opacity", "0");
        d3.selectAll(".mouse-per-line circle")
        .style("opacity", "0");
        d3.selectAll(".mouse-per-line text")
        .style("opacity", "0");
    })
    .on('mouseover', () => { // on mouse in show line, circles and text
        d3.select(".mouse-line")
        .style("opacity", "1");
        d3.selectAll(".mouse-per-line circle")
        .style("opacity", "1");
        d3.selectAll(".mouse-per-line text")
        .style("opacity", "1");
    })
    .on('mousemove', () => {
        let mouse = d3.mouse(d3.event.currentTarget);
        d3.select(".mouse-line")
        .attr("d", () => {
            var d = "M" + mouse[0] + "," + height;
            d += " " + mouse[0] + "," + 0;
            return d;
        });
        d3.selectAll(".mouse-per-line")
        .attr("transform", (d, i) => {
            var lines = document.getElementsByClassName('line')
            var xDate = this.x.invert(mouse[0])
            var bisect = d3.bisector(function(d) { return d.date; }).right;
            var idx = bisect(this.dataObj, xDate);
            var beginning = 0,
            end = lines[i].getTotalLength()
            var target = null;

            while (true){
                var target = Math.floor((beginning + end) / 2);
                var pos = lines[i].getPointAtLength(target);
                if ((target === end || target === beginning) && pos.x !== mouse[0]) {
                    break;
                }
                if (pos.x > mouse[0])      end = target;
                else if (pos.x < mouse[0]) beginning = target;
                else break; //position found
            }

            d3.select('text')
            .text(this.y.invert(pos.y));

            return "translate(" + mouse[0] + "," + pos.y +")";
        });
    });

Upvotes: 2

Views: 7725

Answers (1)

I took Mark's answer as a reference from the Multiseries line chart with mouseover tooltip you provided.

Basically, what you need to do is set the tooltips to show on each tick of the x-axis data, so instead of grabbing the position of the mouse with mouse[0] and moving the tooltips, you should move it to the position where the x-axis data is.

Here's the detail of the changes I made:

mouseG.append('svg:rect')
    .attr('width', width)
    .attr('height', height)
    .attr('fill', 'none')
    .attr('pointer-events', 'all')
    .on('mouseout', () => {
        d3.select(".mouse-line")
        .style("opacity", "0");
        d3.selectAll(".mouse-per-line circle")
        .style("opacity", "0");
        d3.selectAll(".mouse-per-line text")
        .style("opacity", "0");
    })
    .on('mouseover', () => {
        d3.select(".mouse-line")
        .style("opacity", "1");
        d3.selectAll(".mouse-per-line circle")
        .style("opacity", "1");
        d3.selectAll(".mouse-per-line text")
        .style("opacity", "1");
    })
    .on('mousemove', () => {
        let mouse = d3.mouse(d3.event.currentTarget);
        // MOVE THIS BEFORE THE RETURN
        // d3.select(".mouse-line")
        // .attr("d", () => {
        //     var d = "M" + mouse[0] + "," + height;
        //     d += " " + mouse[0] + "," + 0;
        //     return d;
        // });
        d3.selectAll(".mouse-per-line")
        .attr("transform", (d, i) => {
            var lines = document.getElementsByClassName('line')
            var xDate = this.x.invert(mouse[0])
            var bisect = d3.bisector(function(d) { return d.date; }).right;
            var idx = bisect(this.dataObj, xDate);

            // GET RID OF THIS
            // var beginning = 0,
            // end = lines[i].getTotalLength()
            // var target = null;

            // while (true){
            //     var target = Math.floor((beginning + end) / 2);
            //     var pos = lines[i].getPointAtLength(target);
            //     if ((target === end || target === beginning) && pos.x !== mouse[0]) {
            //         break;
            //     }
            //     if (pos.x > mouse[0])      end = target;
            //     else if (pos.x < mouse[0]) beginning = target;
            //     else break; //position found
            // }

            // REPLACE pos.y WITH y(d.values[idx].temperature)
            // AND mouse[0] WITH x(d.values[idx].date)
            d3.select('text')
            .text(this.y.invert(pos.y));

            return "translate(" + mouse[0] + "," + pos.y +")";
        });
    });

Below is the fully working code with the changes applied. For this snippet I used interpolate('linear') to show the values correctly; if you use interpolate('basis'), the tooltips and lines will not match correctly:

<!DOCTYPE html>
<html>

<head>
  <script data-require="[email protected]" data-semver="3.5.3" src="http://cdnjs.cloudflare.com/ajax/libs/d3/3.5.3/d3.js"></script>
  <style>
    body {
      font: 10px sans-serif;
    }
    
    .axis path,
    .axis line {
      fill: none;
      stroke: #000;
      shape-rendering: crispEdges;
    }
    
    .x.axis path {
      display: none;
    }
    
    .line {
      fill: none;
      stroke: steelblue;
      stroke-width: 1.5px;
    }
  </style>
</head>

<body>
  <script>
    var myData = "date	New York	San Francisco	Austin\n\
20111001	63.4	62.7	72.2\n\
20111002	58.0	59.9	67.7\n\
20111003	53.3	59.1	69.4\n\
20111004	55.7	58.8	68.0\n\
20111005	64.2	58.7	72.4\n\
20111006	58.8	57.0	77.0\n\
20111007	57.9	56.7	82.3\n\
20111008	61.8	56.8	78.9\n\
20111009	69.3	56.7	68.8\n\
20111010	71.2	60.1	68.7\n\
20111011	68.7	61.1	70.3\n\
20111012	61.8	61.5	75.3\n\
20111013	63.0	64.3	76.6\n\
20111014	66.9	67.1	66.6\n\
20111015	61.7	64.6	68.0\n\
20111016	61.8	61.6	70.6\n\
20111017	62.8	61.1	71.1\n\
20111018	60.8	59.2	70.0\n\
20111019	62.1	58.9	61.6\n\
20111020	65.1	57.2	57.4\n\
20111021	55.6	56.4	64.3\n\
20111022	54.4	60.7	72.4\n";

    var margin = {
      top: 20,
      right: 80,
      bottom: 30,
      left: 50
    },
      width = 400 - margin.left - margin.right,
      height = 250 - margin.top - margin.bottom;

    var parseDate = d3.time.format("%Y%m%d").parse;

    var x = d3.time.scale()
      .range([0, width]);

    var y = d3.scale.linear()
      .range([height, 0]);

    var color = d3.scale.category10();

    var xAxis = d3.svg.axis()
      .scale(x)
      .orient("bottom");

    var yAxis = d3.svg.axis()
      .scale(y)
      .orient("left");

    var line = d3.svg.line()
      .interpolate("linear")
      .x(function (d) {
        return x(d.date);
      })
      .y(function (d) {
        return y(d.temperature);
      });

    var svg = d3.select("body").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 + ")");

    var data = d3.tsv.parse(myData);

    color.domain(d3.keys(data[0]).filter(function (key) {
      return key !== "date";
    }));

    data.forEach(function (d) {
      d.date = parseDate(d.date);
    });

    var cities = color.domain().map(function (name) {
      return {
        name: name,
        values: data.map(function (d) {
          return {
            date: d.date,
            temperature: +d[name]
          };
        })
      };
    });

    x.domain(d3.extent(data, function (d) {
      return d.date;
    }));

    y.domain([
      d3.min(cities, function (c) {
        return d3.min(c.values, function (v) {
          return v.temperature;
        });
      }),
      d3.max(cities, function (c) {
        return d3.max(c.values, function (v) {
          return v.temperature;
        });
      })
    ]);

    var legend = svg.selectAll('g')
      .data(cities)
      .enter()
      .append('g')
      .attr('class', 'legend');

    legend.append('rect')
      .attr('x', width - 20)
      .attr('y', function (d, i) {
        return i * 20;
      })
      .attr('width', 10)
      .attr('height', 10)
      .style('fill', function (d) {
        return color(d.name);
      });

    legend.append('text')
      .attr('x', width - 8)
      .attr('y', function (d, i) {
        return (i * 20) + 9;
      })
      .text(function (d) {
        return d.name;
      });

    svg.append("g")
      .attr("class", "x axis")
      .attr("transform", "translate(0," + height + ")")
      .call(xAxis);

    svg.append("g")
      .attr("class", "y axis")
      .call(yAxis)
      .append("text")
      .attr("transform", "rotate(-90)")
      .attr("y", 6)
      .attr("dy", ".71em")
      .style("text-anchor", "end")
      .text("Temperature (ºF)");

    var city = svg.selectAll(".city")
      .data(cities)
      .enter().append("g")
      .attr("class", "city");

    city.append("path")
      .attr("class", "line")
      .attr("d", function (d) {
        return line(d.values);
      })
      .style("stroke", function (d) {
        return color(d.name);
      });

    city.append("text")
      .datum(function (d) {
        return {
          name: d.name,
          value: d.values[d.values.length - 1]
        };
      })
      .attr("transform", function (d) {
        return "translate(" + x(d.value.date) + "," + y(d.value.temperature) + ")";
      })
      .attr("x", 3)
      .attr("dy", ".35em")
      .text(function (d) {
        return d.name;
      });

    var mouseG = svg.append("g")
      .attr("class", "mouse-over-effects");

    mouseG.append("path") // this is the black vertical line to follow mouse
      .attr("class", "mouse-line")
      .style("stroke", "black")
      .style("stroke-width", "1px")
      .style("opacity", "0");

    var lines = document.getElementsByClassName('line');

    var mousePerLine = mouseG.selectAll('.mouse-per-line')
      .data(cities)
      .enter()
      .append("g")
      .attr("class", "mouse-per-line");

    mousePerLine.append("circle")
      .attr("r", 7)
      .style("stroke", function (d) {
        return color(d.name);
      })
      .style("fill", "none")
      .style("stroke-width", "1px")
      .style("opacity", "0");

    mousePerLine.append("text")
      .attr("transform", "translate(10,3)");

    mouseG.append('svg:rect') // append a rect to catch mouse movements on canvas
      .attr('width', width) // can't catch mouse events on a g element
      .attr('height', height)
      .attr('fill', 'none')
      .attr('pointer-events', 'all')
      .on('mouseout', function () { // on mouse out hide line, circles and text
        d3.select(".mouse-line")
          .style("opacity", "0");
        d3.selectAll(".mouse-per-line circle")
          .style("opacity", "0");
        d3.selectAll(".mouse-per-line text")
          .style("opacity", "0");
      })
      .on('mouseover', function () { // on mouse in show line, circles and text
        d3.select(".mouse-line")
          .style("opacity", "1");
        d3.selectAll(".mouse-per-line circle")
          .style("opacity", "1");
        d3.selectAll(".mouse-per-line text")
          .style("opacity", "1");
      })
      .on('mousemove', function () { // mouse moving over canvas
        var mouse = d3.mouse(this);

        d3.selectAll(".mouse-per-line")
          .attr("transform", function (d, i) {

            var xDate = x.invert(mouse[0]),
              bisect = d3.bisector(function (d) { return d.date; }).left;
            idx = bisect(d.values, xDate);

            d3.select(this).select('text')
              .text(y.invert(y(d.values[idx].temperature)).toFixed(2));

            d3.select(".mouse-line")
              .attr("d", function () {
                var data = "M" + x(d.values[idx].date) + "," + height;
                data += " " + x(d.values[idx].date) + "," + 0;
                return data;
              });
            return "translate(" + x(d.values[idx].date) + "," + y(d.values[idx].temperature) + ")";
          });
      });

  </script>
</body>

</html>

Upvotes: 5

Related Questions