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.