Public
Edited
Jan 14
1 star
Insert cell
Insert cell
Insert cell
Plot.plot({
title: "Penguins",
subtitle: "Body mass vs. flipper length",
marks: [
Plot.dot(penguins, {
x: "body_mass_g",
y: "flipper_length_mm",
stroke: "species"
}),
swoopyAnnotation(annotations, {
x: "x",
y: "y",
text: "text"
})
]
})
Insert cell
annotations = [
{
text: "Here\ncome\nthe chonk\nstepper",
x: 5000,
y: 200,
path: "M619.5,90.29998779296875 C618.5,153.29998779296875 478.5,140.29998779296875 480.5,205.29998779296875",
relativeOffset: {
x: -410.5,
y: -190.9000244140625
},
currentTextPos: {
x: 70,
y: 14.39996337890625
}
}
]
Insert cell
function swoopyAnnotation(data, options = {}) {
const annotations = data.map((d) => ({ ...d }));
const mark = Plot.text(data, options);
let chart;

const _render = mark.render;
mark.render = function (index, { x, y }) {
const g = _render.apply(this, arguments);
const svg = d3.select(g);

annotations.forEach((d, i) => {
const group = svg.append("g");

const endPoint = d.path
? pathToBezierPoints(d.path)[3]
: [x(d.x) + 50, y(d.y) - 50];

// Main path with arrow at start
const path = group
.append("path")
.attr(
"d",
d.path ||
`M${x(d.x)},${y(d.y)} C${x(d.x) + 30},${y(d.y)} ${x(d.x) + 40},${
y(d.y) - 40
} ${endPoint[0]},${endPoint[1]}`
)
.attr("fill", "none")
.attr("stroke", "#333")
.attr("stroke-width", 1.5)
.attr("class", "annotation-path")
.attr("marker-start", "url(#arrow)");

// Add arrow marker
if (!svg.select("defs").node()) {
const defs = svg.append("defs");
defs
.append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 0)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M10,-5L0,0L10,5L8,0")
.attr("fill", "#333");
}

const points = pathToBezierPoints(path.attr("d"));

const defaultOffset = { x: 10, y: 0 };
d.relativeOffset = d.relativeOffset || defaultOffset;
d.currentTextPos = d.currentTextPos || {
x: points[3][0] + d.relativeOffset.x,
y: points[3][1] + d.relativeOffset.y
};

// Select parent text element and all its tspans
const textParent = svg.select(`text:nth-child(${i + 1})`);
// Apply position and drag behavior to parent
textParent
.attr("x", d.currentTextPos.x)
.attr("y", d.currentTextPos.y)
.attr("cursor", "move")
.call(
d3
.drag()
.subject(function () {
return {
x: +d3.select(this).attr("x"),
y: +d3.select(this).attr("y")
};
})
.on("drag", function (event) {
const pathEnd = pathToBezierPoints(path.attr("d"))[3];
const newX = event.x - pathEnd[0];
const newY = event.y - pathEnd[1];

d.relativeOffset = { x: newX, y: newY };
d.currentTextPos = { x: event.x, y: event.y };

// Move parent text element
d3.select(this).attr("x", event.x).attr("y", event.y);

// Move all tspans, updating both x and y
let currentY = event.y;
d3.select(this)
.selectAll("tspan")
.each(function (_, i) {
const tspan = d3.select(this);
tspan.attr("x", event.x).attr("y", currentY);

// For first line, use the parent text y position
if (i > 0) {
currentY +=
parseFloat(getComputedStyle(this).fontSize) * 1.2;
}
});

signal();
})
);

// Also position any tspans relative to parent
let currentY = d.currentTextPos.y;
textParent.selectAll("tspan").each(function (_, i) {
const tspan = d3.select(this);
tspan.attr("x", d.currentTextPos.x);
tspan.attr("y", currentY);

if (i > 0) {
currentY += parseFloat(getComputedStyle(this).fontSize) * 1.2;
}
});

points.forEach((p, j) => {
group
.append("circle")
.attr("cx", p[0])
.attr("cy", p[1])
.attr("r", 4)
.attr("fill", "white")
.attr("stroke", "#333")
.attr("stroke-width", 1.5)
.attr("cursor", "move")
.attr("class", "control-point edit-only")
.call(
d3.drag().on("drag", function (event) {
const newX = event.x;
const newY = event.y;
points[j] = [newX, newY];
path.attr("d", bezierPointsToPath(points));
d3.select(this).attr("cx", newX).attr("cy", newY);

// Update text position when any point moves
if (j === 3) {
const updatedX = newX + d.relativeOffset.x;
const updatedY = newY + d.relativeOffset.y;

// Update parent text element
textParent.attr("x", updatedX).attr("y", updatedY);

// Update all tspans with proper y positioning
let currentY = updatedY;
textParent.selectAll("tspan").each(function (_, i) {
const tspan = d3.select(this);
tspan.attr("x", updatedX);
tspan.attr("y", currentY);

if (i > 0) {
currentY +=
parseFloat(getComputedStyle(this).fontSize) * 1.2;
}
});

// Store the current position
d.currentTextPos = { x: updatedX, y: updatedY };
}

updateControlLines(group, points);
d.path = path.attr("d");
signal();
})
);
});

updateControlLines(group, points);
});

const buttonContainer = document.createElement("div");
buttonContainer.style.position = "absolute";
buttonContainer.style.top = "10px";
buttonContainer.style.right = "10px";

const copyBtn = document.createElement("button");
copyBtn.textContent = "Copy Annotations";
copyBtn.addEventListener("click", () => {
const config = annotations.map((d) => ({
text: d.text,
x: d.x,
y: d.y,
path: d.path,
relativeOffset: d.relativeOffset,
currentTextPos: d.currentTextPos
}));
navigator.clipboard.writeText(JSON.stringify(config, null, 2));
copyBtn.textContent = "Copied!";
setTimeout(() => (copyBtn.textContent = "Copy Annotations"), 2000);
});

buttonContainer.appendChild(copyBtn);

requestAnimationFrame(() => {
chart = g.ownerSVGElement;
if (chart?.parentElement?.nodeName === "FIGURE")
chart = chart.parentElement;
chart.parentElement.style.position = "relative";
chart.parentElement.appendChild(buttonContainer);
signal();
});

return g;
};

function signal() {
chart.value = annotations;
chart.dispatchEvent(new CustomEvent("input", { bubbles: true }));
}

function pathToBezierPoints(d) {
const commands = d.match(/[A-Z][^A-Z]*/g);
const points = [];

commands.forEach((cmd) => {
const type = cmd[0];
const coords = cmd
.slice(1)
.trim()
.split(/[,\s]+/)
.map(Number);

if (type === "M") {
points.push([coords[0], coords[1]]);
} else if (type === "C") {
points.push(
[coords[0], coords[1]],
[coords[2], coords[3]],
[coords[4], coords[5]]
);
}
});

return points;
}

function bezierPointsToPath(points) {
if (points.length < 4) return "";
return `M${points[0][0]},${points[0][1]} C${points[1][0]},${points[1][1]} ${points[2][0]},${points[2][1]} ${points[3][0]},${points[3][1]}`;
}

function updateControlLines(group, points) {
group.selectAll(".control-line").remove();

if (points.length >= 4) {
group
.append("line")
.attr("x1", points[0][0])
.attr("y1", points[0][1])
.attr("x2", points[1][0])
.attr("y2", points[1][1])
.attr("stroke", "#999")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "3,3")
.attr("class", "control-line edit-only");

group
.append("line")
.attr("x1", points[3][0])
.attr("y1", points[3][1])
.attr("x2", points[2][0])
.attr("y2", points[2][1])
.attr("stroke", "#999")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "3,3")
.attr("class", "control-line edit-only");
}
}

return mark;
}
Insert cell
html`<style>
.edit-only {
opacity: 0;
transition: opacity 0.2s;
}
.annotation-path {
pointer-events: none;
}
.annotation-path:hover + .edit-only,
.edit-only:hover {
opacity: 1;
}
g:hover .edit-only {
opacity: 1;
}
</style>`
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