function hover(
dots,
invalidation,
{
raise = true,
r = 5,
tipcontent = (d) =>
"<div>" +
Object.entries(d).map(([k, v]) => `<div>${k}: ${v}</div>`).join("\n") +
"</div>"
} = {}
) {
const hitRadius = 20;
const svg = d3.select(dots.nodes()[0].ownerSVGElement);
const data = dots.data();
const mouse = svg
.append("circle")
.attr("r", r)
.attr("fill", "none")
.style("pointer-events", "none");
const tipg = tippy(mouse.node(), {
theme: tippytheme,
allowHTML: true,
hideOnClick: false,
interactive: true,
appendTo: () => document.body
});
let positions, radiuses, quadtree, hovered;
invalidation && invalidation.then(tipg.destroy);
svg
.on("touchstart", function (event) {
event.preventDefault();
})
.on("pointerdown.action pointermove.action", (event) => {
if (!quadtree) {
const base = svg.node().getBoundingClientRect();
radiuses = dots.nodes().map((d) => +d3.select(d).attr("r"));
positions = Array.from(dots, (d, i) => {
const bbox = d.getBoundingClientRect();
return [bbox.x - base.x + radiuses[i], bbox.y - base.y + radiuses[i]];
});
quadtree = d3
.quadtree()
.x((i) => positions[i][0])
.y((i) => positions[i][1])
.addAll(d3.range(positions.length));
}
const m = d3.pointer(event, svg.node()),
i = quadtree.find(...m),
position = positions[i];
if (Math.hypot(position[0] - m[0], position[1] - m[1]) < hitRadius + 10) {
hovered = { position, datum: data[i] };
dots.classed("highlight", function (e) {
if (hovered.datum === e) {
mouse.attr("transform", `translate(${position})`);
d3.select(this)?.attr("r") &&
mouse.attr("r", +d3.select(this)?.attr("r") + 1);
return raise ? d3.select(this).raise() : d3.select(this);
}
});
} else {
dots.classed("highlight", false);
hovered = null;
}
})
.on("pointermove.tipmove pointerup.tipmove", (event) => {
if (!hovered) {
tipg.hide();
} else {
if (event.type === "pointerup") {
tipg.show();
tipg.setProps({
placement: hovered.position[1] < 150 ? "bottom" : "top"
});
tipg.setContent(tipcontent(hovered.datum));
}
}
})
.style("cursor", "pointer");
return {
reset: () => (quadtree = undefined)
};
}