Unlisted
Edited
Jun 13, 2023
5 forks
Importers
40 stars
Insert cell
Insert cell
Insert cell
Insert cell
Plot.plot({
marks: [
Plot.dot(penguins.data, {
x: "body_mass_g",
y: "culmen_length_mm",
fill: "species"
}),
Plot.tooltip(penguins.data, {
x: "body_mass_g",
y: "culmen_length_mm" /*,
annotate: 193 */
})
]
})
Insert cell
Insert cell
Plot.plot({
facet: {
data,
x: "sex"
},
marks: [
Plot.frame(),
Plot.dot(data, {x: "weight", y: "height", fill: "sport"}),
Plot.tooltip(data, {x: "weight", y: "height", content: d => `${d.name}\n${d.sport}${d.gold ? "\nGOLD":""}`, stroke: "white" })
]
})
Insert cell
data = athletes.data
Insert cell
Insert cell
Insert cell
viewof clicked = Plot.plot({
facet: {
data,
x: "sex"
},
marks: [
Plot.frame(),
Plot.dot(data, { x: "weight", y: "height", fill: "sport" }),
Plot.tooltip(data, {
x: "weight",
y: "height",
content: (d) => `${d.name}\n${d.sport}${d.gold ? "\nGOLD" : ""}`,
stroke: "#ccc",
direction: "up",
annotate: selected && data.indexOf(selected),
onclick: (event, i, g) => {
const owner = ownerFigure(g);
owner.value = data[i];
owner.dispatchEvent(new CustomEvent("input"));
}
})
]
})
Insert cell
clicked
Insert cell
Insert cell
viewof clicked2 = Plot.plot({
facet: {
data,
x: "sex"
},
height: 120,
marginTop: 50,
marginLeft: 50,
marginRight: 50,
marks: [
Plot.frame(),
Plot.tickX(data, { x: "weight", stroke: "sport" }),
Plot.tooltip(data, {
x: "weight",
y: null,
dy: -18,
content: (d) => `${d.name}\n${d.sport}${d.gold ? " - GOLD" : ""}`,
stroke: "none",
direction: "up"
})
]
})
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
tooltip = (Plot) => {
// the Tooltip class is heavily inspired by Plot.Dot
class Tooltip extends Plot.Mark {
constructor(
data,
{
x,
y,
z,
stroke = "black",
fill = "none",
r = 4,
content = (d) => d,
direction = "down",
tx,
ty,
dx = 0,
dy = 0,
onclick,
onmouseover,
annotate,
formatter = defaultFormatter,
...options
} = {}
) {
super(
data,
[
{ name: "x", value: x, scale: "x", optional: true },
{ name: "y", value: y, scale: "y", optional: true },
{ name: "z", value: z, optional: true },
{ name: "content", value: content }
],
options
);

this.r = r;
this.fill = fill;
this.stroke = stroke;
this.annotate = annotate;
this.direction = direction;
this.tx = tx;
this.ty = ty;
this.dx = dx;
this.dy = dy;
this.onclick = onclick;
this.onmouseover = onmouseover;
this.formatter = formatter;
}
render(
index,
scales,
{ x: X, y: Y, z: Z, content: T },
{ width, height, marginTop, marginRight, marginBottom, marginLeft }
) {
const {
r,
stroke,
fill,
annotate,
direction,
tx,
ty,
dx,
dy,
onclick,
onmouseover,
formatter
} = this;
const x = X
? (i) => X[i]
: constant((marginLeft + width - marginRight) / 2);
const y = Y
? (i) => Y[i]
: constant((marginTop + height - marginBottom) / 2);

const quadtree = d3
.quadtree()
.x(x)
.y(y)
.addAll(index.filter((i) => x(i) !== undefined && y(i) !== undefined));

const g = d3.create("svg:g");
const highlights = g.append("g");

let frozen = -1; // freeze the tooltip on click

const catcher = g
.append("rect")
.attr("height", height)
.attr("width", width)
.style("fill", "none")
.attr("pointer-events", "all")
.on("pointerenter", () => {})
.on("pointerout", (event) => frozen === -1 && hide())
.on("pointermove", move);

catcher.on("click", (event) => {
const i = find(event);
if (frozen > -1 && i > -1 && i !== frozen) {
show((frozen = i));
} else {
frozen = frozen === -1 ? i : -1;
}
if (typeof onclick === "function" && i >= 0)
onclick(event, i, g.node());
});

function find(event) {
const p = d3.pointers(event)[0],
i = quadtree.find(...p);
if (Math.hypot(p[0] - x(i), p[1] - y(i)) < 30) return i;
return -1;
}

function move(event) {
if (frozen > -1) return;
const i = find(event);
if (i > -1) {
show(i);
if (typeof onmouseover === "function") {
onmouseover(event, i, g.node());
}
} else hide();
}

let tooltip;
let xy;
hide();

setTimeout(() => {
// in case the user uses onclick / onmouseover for dataflow
const owner = ownerFigure(g.node());
owner.value = "";
owner.dispatchEvent(new CustomEvent("input"));
tooltip =
this.tooltip ||
(this.tooltip = d3
.select(owner.parentElement)
.insert("div", ":first-child")
.style("position", "relative")
.style("height", 0)
.style("pointer-events", "none")
.style("font", "10px sans-serif")
.style("z-index", 2));

xy = g
.select(function () {
return this.parentElement;
})
.attr("transform");
if (xy) xy = xy.replace(/(\d+)/g, "$1px"); // html wants px
}, 1);

return g.node();

function show(i) {
highlights
.selectAll("circle")
.data(index.filter((j) => i === j || (Z && Z[i] === Z[j])))
.join("circle")
.attr("r", r)
.style("fill", fill)
.style("stroke", stroke)
.attr("cx", x)
.attr("cy", y);

tooltip &&
tooltip.call(callout, {
formatter,
direction,
text: T[i],
x: tx === undefined ? x(i) : tx,
y: ty === undefined ? y(i) : ty,
transform: xy,
dx,
dy
});
}

function hide() {
tooltip && tooltip.call(callout);
highlights.html("");

if (annotate !== undefined && index.includes(annotate)) {
setTimeout(() => show(annotate), 200);
}
}
}
}

return function tooltip(data, options) {
return new Tooltip(data, options);
};

function constant(x) {
return () => x;
}
}
Insert cell
// positions the container, then calls the formatter
callout = (
container,
{ formatter, direction, text, x, y, transform, dx, dy } = {}
) => {
if (text) {
container
.style("display", null)
.style("left", `${x + dx}px`)
.style("top", `${y + dy}px`);
formatter(
container.html("").append("div").style("transform", transform).node(),
text,
{ direction }
);
} else {
container.style("display", "none");
}
}
Insert cell
// formats a text string into a svg fragment with an arrow
// see also https://observablehq.com/@d3/line-chart-with-tooltip
function arrowTextFormatter(div, value, { direction } = {}) {
const g = d3.select(div).append("svg").style("overflow", "visible");

const path = g.append("path").attr("fill", "white").attr("stroke", "black");

const text = g
.append("text")
.style("pointer-events", "all")
.call((text) =>
text
.selectAll("tspan")
.data((value + "").split(/\n/))
.join("tspan")
.attr("x", 0)
.attr("y", (d, i) => `${i * 1.1}em`)
.style("font-weight", (_, i) => (i ? null : "bold"))
.text((d) => d)
);

const { width, height } = text.node().getBBox();

const w = 8 * Math.ceil(width / 8);
const h = 8 * Math.ceil(height / 8);

if (direction == "down") {
text.attr("transform", `translate(${-w / 2}, ${30})`);
path
.attr("transform", `translate(0, 5)`)
.attr(
"d",
`M${-w / 2 - 10},5 H-5 l5,-5 l5,5 H${w / 2 + 10} v${h + 20} h-${
w + 20
} z`
);
} else if (direction == "up") {
text.attr("transform", `translate(${-w / 2}, ${-h - 10})`);
path
.attr("transform", `translate(0, ${-h - 35})`)
.attr(
"d",
`M${-w / 2 - 10},5 h${w + 20} v${h + 20} h-${
w / 2 + 5
} l-5,5 l-5,-5 h-${w / 2 + 5} z`
);
} else if (direction == "right") {
text.attr("transform", `translate(${24}, ${-h / 2 + 5})`);
path
.attr(
"d",
`M0,-10 v${h / 2} l-5,5 l5,5 v${h / 2} h${w + 10} v${-h - 10} z`
)
.attr("transform", `translate(${14}, ${-h / 2})`);
}
// todo: "left"
}
Insert cell
defaultFormatter = {
const objectF = objectFormatter();
return (div, value, { direction } = {}) => {
if (typeof value === "object") return objectF(div, value, { direction });
return arrowTextFormatter(div, value, { direction });
};
}
Insert cell
// formats the content into an HTML fragment
objectFormatter = ({ columns, bolded = [], width = 300 } = {}) =>
function (div, obj, { direction } = {}) {
// TODO: direction

const bold = (t) => `<b>${t}</b>`;

const rows = Array.from(columns || Object.keys(obj), (key) =>
bolded.includes(key)
? { key: bold(key), value: bold(obj[key]) }
: { key, value: obj[key] }
);

const table = d3
.select(div)
.append("div")
.style("border", "solid .5px #333")
.style("border-radius", "5px")
.style("background", "white")
.style("padding-top", "5px")
.style("padding-left", "5px")
.style("padding-right", "0px")
.style("max-width", `${width}px`)
.append(() =>
Table(rows, {
width: {
key: 80,
value: width - 80
},
format: {
key: (d) => html`${d}`,
value: (d) => html`${d}`
}
})
);

table.style("overflow", "hidden");
table.select("tbody").style("pointer-events", "all");
table.select("thead").remove();
table.select("form").style("margin", "0");
table.selectAll("tbody tr td:first-child").style("visibility", "hidden");
}
Insert cell
// retrieves the top (FIGURE or SVG) element so we can issue customEvents
function ownerFigure(g) {
const svg = g.ownerSVGElement;
return svg?.parentElement?.nodeName === "FIGURE" ? svg.parentElement : svg;
}
Insert cell
/*
usage:
import {addTooltip} from "@fil/experimental-plot-tooltip-01"
Plot = require("@observablehq/plot").then(addTooltip); // or like below:
*/
addTooltip = (Plot) => ((Plot.tooltip = tooltip(Plot)), Plot)
Insert cell
Plot = addTooltip(_Plot)
Insert cell
_Plot = require("@observablehq/plot@0.6.6") // note: 0.6.7 breaks the compatibility with facets
Insert cell
athletes = FileAttachment("athletes.json").json()
Insert cell
penguins = FileAttachment("penguins.json").json()
Insert cell
import {bls} from "@observablehq/plot-line"
Insert cell
import {Table, Radio} from "@observablehq/inputs"
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more