D3.js - Adjust tooltip width based on text inside

Viewed 1519

Live code: https://blockbuilder.org/ashleighc207/ef14357436ffe981f1cd5a841b8a8558

I have a chart library full of various D3.js charts (line, bar, map, etc) that all use a single function to create their tooltips. The tooltip is created as an svg polygon (specifically to support IE which, yes, I have to). I'm currently passing in width and height parameters into the tooltip function that are hardcoded based on the graph.

This isn't super dynamic and gets to be an issue when you have longer words/larger numbers. I'd like to base the width and height based on the (assuming best method here) bounding box, or BBox, of the text element(s). I've come across a couple solutions online that suggest creating the tooltip, appending the text, and readjusting the width/height of the tooltip after.

I've found two issues with said approach - first, Safari messes up my other BBox calculations when there's empty text elements being appended before getting said BBox. Second - since I'm using a polygon it's not as a simple as re-setting the width and height. I'd have to re-draw the polygon path.

TLDR; I'm looking for the best approach to dynamically size the tooltip based on the text inside, even though the tooltip function is called with the width/height parameters prior to the text elements being generated.

Here's the tooltip code: (I know it's a bit messy, in the midst of refactoring and this plays a vital key)

   function createTooltip(element, elementName, width, height, calloutDirection, secondData) {

// check if tooltip exists, and if not, continue
    if (d3.select(element + " #" + elementName + "-tooltip-container").empty() !== false) {

// set variables for width and height difference - exists because tooltip is made to be 135 x 50 by default
        var widthDiff = 135 - width,
            heightDiff = 50 - height;

        // create and append tooltip
        let tooltip = d3.select(element)
            .append("g")
            .style("filter", "url(#drop-shadow)")
            .attr("class", "tooltip-container")
            .attr("id", function(d, i) {
                return elementName + "-tooltip-container"
            })
            .style("opacity", "0")

          // create and append drop shadow for tooltip
        var defs = tooltip.append("defs");

        var filter = defs.append("filter")
            .attr("id", "drop-shadow")
            .attr("height", "130%");

        var feOffset = filter.append("feOffset")
            .attr("in", "SourceAlpha")
            .attr("dy", 1)
            .attr("result", "offset");

        var feGaussianBlur = filter.append("feGaussianBlur")
            .attr("id", "blur-ie")
            .attr("stdDeviation", 2)
            .attr("result", "blur");

        var feFlood = filter.append("feFlood")
            .attr("result", "flood")
            .attr("flood-color", "#000000")
            .attr("flood-opacity", 0.35);

        var feComposite = filter.append("feComposite")
            .attr("result", "composite")
            .attr("operator", "in")
            .attr("in2", "blur")

        var feBlend = filter.append("feBlend")
            .attr("result", "blend")
            .attr("in", "SourceGraphic")

        var feMerge = filter.append("feMerge");

        feMerge.append("feMergeNode")
            .attr("in", "SourceAlpha");
        feMerge.append("feMergeNode")
            .attr("in", "SourceGraphic");
        feMerge.append("feMergeNode")
            .attr("in2", "blur");


        d3.select("#blur-ie").attr("stdDeviation", 2)

        var range = [0, width];
        var x = d3.scaleLinear()
            .range(range)
            .domain([0, width]);

        var y = d3.scaleLinear()
            .range(range)
            .domain([0, width]);

        var poly;

        calloutDirection === "top-right" ?
            (poly = [{ "x": 0, "y": height },
                { "x": (width - 50), "y": height },
                { "x": (width - 35), "y": (height + 15) },
                { "x": (width - 35), "y": (height + 15) },
                { "x": (width - 20), "y": height },
                { "x": width, "y": height },
                { "x": width, "y": 0 },
                { "x": 0, "y": 0 }
            ], y.range([width, 0])) :
            calloutDirection === "top-left" ?
            (poly = [{ "x": 0, "y": height },
                { "x": 20, "y": height },
                { "x": 35, "y": (height + 15) },
                { "x": 35, "y": (height + 15) },
                { "x": 50, "y": height },
                { "x": width, "y": height },
                { "x": width, "y": 0 },
                { "x": 0, "y": 0 }
            ], y.range([width, 0])) :
            calloutDirection === "bottom-left" ?
            (poly = [{ "x": 0, "y": height },
                { "x": 20, "y": height },
                { "x": 35, "y": (height + 15) },
                { "x": 35, "y": (height + 15) },
                { "x": 50, "y": height },
                { "x": width, "y": height },
                { "x": width, "y": 0 },
                { "x": 0, "y": 0 }
            ]) :
            calloutDirection === "bottom-right" ?
            (poly = [{ "x": 0, "y": height },
                { "x": (width - 50), "y": height },
                { "x": (width - 35), "y": (height + 15) },
                { "x": (width - 35), "y": (height + 15) },
                { "x": (width - 20), "y": height },
                { "x": width, "y": height },
                { "x": width, "y": 0 },
                { "x": 0, "y": 0 }
            ]) :
            null;



        tooltip.selectAll("polygon")
            .data([poly])
            .enter()
            .append("polygon")
            .attr("points", function(d, i) {
                return d.map(function(d) {
                        return [x(d.x), y(d.y)].join(",");
                    })
                    .join(" ");
            })
            .attr("stroke", "#ffffff")
            .attr("stroke-linejoin", "round")
            .attr("stroke-width", "2")
            .attr("class", "tooltip-content")
            .attr("id", function(d, i) {
                return elementName + "-tooltip-content"
            })




    }
}

And here's code for one of the charts:

function(element, data, categories)  {

    // set the linechart data and dimensions
    let width = t.width - margin.left - margin.right,
        height = t.height - margin.top - margin.bottom,
        lcData = [Object.values(data)[0], Object.values(data)[1], categories],
        max = (Math.ceil(Math.max(...lcData[0], ...lcData[1]) / 10) * 10),
        min = Math.min(...lcData[0], ...lcData[1]);  

    // create arrays for x and y of both lcDatas for mouseover
    var lcDataX = [],
        lcDataOneY = [],
        lcDataTwoY = [];

    // set the y-scale
    let yScale = d3.scaleLinear()
        .domain([0, max])
        .range([height - 70, 0])
        .nice();

    //set the x-scale
    let xScale = d3.scaleBand()
        .domain(lcData[2])
        .rangeRound([0, width - 30]);

    // create a line
    let line = d3.line()
        .x(function(d, i) {
            return xScale(lcData[2][i]) + xScale.bandwidth() / 2;
        })
        .y(function(d) {
            return yScale(d);
        })

    // create svg container
    let lineChart = d3.select(element)
        .append("svg")
        .attr("class", "linechart")
        .attr("viewBox", "0 0 440 285")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
        .style("display", "block")
        .append("g")
        .attr("class", "linechart-group")
        .attr("transform", "translate(53.5, 20)")


    // create and append the legend
    let legendGroup = lineChart.append("g")
        .attr("class", "lc-legend")
        .attr("transform", "translate(-26, 247)")

    // create and append the colored legend squares
    legendGroup.append("rect")
        .attr("class", "lc-data-one-legend")
        .attr("height", 18)
        .attr("width", 18)
        .attr("x", (width / 2))

    legendGroup.append("rect")
        .attr("class", "lc-data-two-legend")
        .attr("height", 18)
        .attr("width", 18)
        .attr("x", (width / 4))

    // create and append the legend labels
    legendGroup.append("text")
        .attr("class", "lc-data-one-legend-label")
        .attr("x", (width * .57))
        .attr("y", ((height * .1) * .5))
        .text(function(){
            return Object.keys(data)[1]
        })

    legendGroup.append("text")
        .attr("class", "lc-data-two-legend-label")
        .attr("x", (width * .32))
        .attr("y", ((height * .1) * .50))
        .text(function() {
            return Object.keys(data)[0]
        })

    // create the x gridlines
    let xGridlines = d3.axisLeft()
        .tickFormat("")
        .ticks(3)
        .tickSize(-(width - 58))
        .scale(yScale.nice(3));

    // create the y gridlines
    let yGridlines = d3.axisBottom()
        .tickFormat("")
        .ticks(12)
        .tickSize(-(height - 40))
        .scale(xScale);

    // create a group for the x- and y-Gridlines and remove the domain for it
    lineChart.append("g")
        .attr("class", "lc-x-gridlines")
        .attr("transform", "translate(13, 10)")
        .call(xGridlines)
        .select(".domain").remove();

    lineChart.append("g")
        .attr("class", "lc-y-gridlines")
        .attr("transform", "translate(0, 210)")
        .call(yGridlines)
        .select(".domain").remove();

    // create a group for the x-axis and append it
    lineChart.append("g")
        .attr("class", "lc-xaxis")
        .attr("transform", "translate(0, 205)")
        .call(d3.axisBottom(xScale)
                .tickSizeOuter(0)
        )
        .selectAll("text")
        .attr("x", -24)
        .attr("y", -4)
        .attr("dy", "10")
        .attr("transform", "rotate(-45)")
        .attr("id", function(d, i) {
            return "lc-xaxis-tick-" + i
        })
        .on("mouseover", function(d, i) {
            linechartmouseover(this, element, i);
        })
        .on("mouseout", function(d, i) {
            linechartMouseout(this, element, i);
        });

    // create a group for the y-axis and append it
    lineChart.append("g")
        .attr("class", "lc-yaxis")
        .attr("transform", "translate(0, 10)")
        .call(d3.axisLeft(yScale)
            .ticks(3)
            .tickFormat(function(d) {
                return (d >= 1000 ? thousandFormat(d) : d);
            })
        )

    // create a group for the line
    let lineGroup = lineChart.append('g')
        .attr("class", "line-group")
        .attr("transform", "translate(-1, 10)")

    // append the first line to the svg group
    lineGroup.append("path")
        .datum(lcData[0])
        .attr("d", line)
        .attr("class", "lc-data-one-line")

    // append the second line to the svg group
    lineGroup.append("path")
        .datum(lcData[1])
        .attr("d", line)
        .attr("class", "lc-data-two-line")

    // create a group for the points
    let points = lineChart.append('g')
        .attr("class", "points-group")
        .attr("transform", "translate(-1, 7)")

    // create and append both set of points
    for (let i = 0; i < lcData.length - 1; i++) {

        // assign value of parent i to n
        let n = i;

        // create points and append them for each dataset
        points.selectAll("dot")
            .data(lcData[i])
            .enter()
            .append("rect")
            .attr("height", 6)
            .attr("width", 6)
            .attr("x", function(d, i) {
                let x = xScale(lcData[2][i]) + xScale.bandwidth() / 2.5;
                lcDataX.push(x);
                return x;
            })
            .attr("y", function(d) {
                n == 0 ? (lcDataOneY.push(yScale(d))) : (lcDataTwoY.push(yScale(d)));
                return yScale(d)
            })
            .attr("class", function(d, i) {
                return (n == 0 ? "lc-data-one-points" : "lc-data-two-points");
            })
            .attr("id", function(d, i) {
                return (n == 0 ? ("lc-data-one-point-" + i) : ("lc-data-two-point-" + i));
            })
            .on("mouseover", function(d, i) {
                linechartmouseover(this, element, i);
            })
            .on("mouseout", function(d, i) {
                linechartMouseout(this, element, i);
            });
    }


    // add mouseover to gridlines
    d3.selectAll(element + " .lc-y-gridlines .tick line")
        .on("mouseover", function(d, i) {
            linechartmouseover(this, element, i);
        })
        .on("mouseout", function(d, i) {
            linechartMouseout(this, element, i);
        });

    function linechartmouseover(element, id, i) {

        // create Y coordinate array based on the points with the highest value
        var lcDataY = lcDataOneY.map(function(n, i) {
            return (n < lcDataTwoY[i] || lcDataTwoY[i] == undefined) ? n : lcDataTwoY[i];
        })

        var x,
            y = lcDataY[i] - 49,
            direction,
            elem = id;

        // set the x based on the dataset
        (i === 0) ?  x = lcDataX[0] - 2 : (i === 1) ? x = lcDataX[1] - 2 : x = lcDataX[i] - 2;

        // define the direction of the tooltip's point based on positioning of x and y on the chart
        (i < lcData[0].length / 2 && y > 0) ? direction = "bottom-left"
        : (i < lcData[0].length / 2 && y <= 0) ? direction = "top-left" 
        : (i >= lcData[0].length / 2 && y > 0) ? direction = "bottom-right" 
        : direction = "top-right";

        //remove previous tooltips
        d3.selectAll("#linechart-tooltip-container").remove()

        //create tooltip
        createTooltip(elem + " .linechart", "linechart", 138, 64, direction, true);

        // define tooltip variable and variables for x and y positioning of tooltip
        let tooltip = d3.select("#linechart-tooltip-container"),
            xCoord, 
            yCoord;

        // define the adjustments needed to the tooltip position based on tooltip point direction
        direction === "top-right" ?
            (xCoord = x - 46.5, yCoord = y + 26) :
            direction === "bottom-left" ?
            (xCoord = x + 22, yCoord = y - 7) :
            direction === "top-left" ?
            (xCoord = x + 21.5, yCoord = y + 26) :
            direction === "bottom-right" ?
            (xCoord = x - 46.5, yCoord = y - 6) :
            null;

        // show and translate tooltip
        tooltip.style("display", "inline")
        .attr("transform", "translate(" + xCoord + "," + yCoord + ")")
        .transition()
        .ease(d3.easeSin)
        .style("opacity", "1")
        .duration(300)

        // create and append labels for all datasets
        for(let j = 0; j < lcData.length -1; j++) {

            // append the label of the dataset to the tooltip
            tooltip.append("text")
                .attr("class", "tooltip-label")
                .attr("id", "tooltip-label" + j)
                .attr("x", 20)
                .attr("y", function(d) {
                    return (j === 1 ? ((direction == "top-right" || direction == "top-left") ? 118 : 44) :
                     ((direction == "top-right" || direction == "top-left") ? 102 : 28))
                })
                .text(function(){
                    return Object.keys(data)[j] + ":";
                })

            // get the bounding box of the label to dynamically position the value next to it
            let tlabel = d3.select("#tooltip-label" + j); 
            let tlabelBox = tlabel.node().getBBox();

            // append dataset value to the tooltip
            tooltip.append("text")
                .attr("class", "tooltip-value")
                .attr("x", function(){
                    return (tlabelBox.width + tlabelBox.x + 4)
                })
                .attr("y", function(d) {
                    return (j === 1 ? ((direction == "top-right" || direction == "top-left") ? 118 : 44) :
                     ((direction == "top-right" || direction == "top-left") ? 102 : 28))
                })
                .text(function() {
                    return (lcData[j][i] !== undefined) ? commaFormat(lcData[j][i]) : "N/A"
                })

            // select points specific to each dataset
            let point = (j == 0 ? (id + " #lc-data-one-point-" + i) : (id + " #lc-data-two-point-" + i));

            // highlight corresponding points
            d3.select(point)
                .attr("height", 8)
                .attr("width", 8)
                .attr("transform", function(d) {
                    return j == 0 ? "translate(-1, 0)" : "translate(-2, 0)"
                })
        }

        // increase font size of xaxis on mouseover
        d3.select(id + " #lc-xaxis-tick-" + i)
            .attr("font-size", 14)
            .attr("transform", "rotate(-45) translate(-4,0)")
    }

    function linechartMouseout(element, id, i) {

        for (let j = 0; j < lcData.length; j++) {

            // select points specific to each dataset
            let point = (j == 0 ? (id + " #lc-data-one-point-" + i) : (id + " #lc-data-two-point-" + i));

            // remove highlight from corresponding points
            d3.select(point)
                .attr("height", 6)
                .attr("width", 6)
                .attr("transform", "translate(0,0)")
        }

        // reduce font size of xaxis
        d3.select(id + " #lc-xaxis-tick-" + i)
            .attr("font-size", 12)
            .attr("transform", "rotate(-45) translate(0,0)")

        // hide tooltip on mouseout
        d3.selectAll("#linechart-tooltip-container")
            .transition()
            .ease(d3.easeSin)
            .style("opacity", "0")
            .duration(300)
    }
}

Actual results: I have to hard code a specific width that supports the chart.

Desired results: I can dynamically generate the tooltip width/height with 20px on either side of the text elements. In this case, there's two text elements but not every graph will have two.

0 Answers
Related