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];
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)");
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;
}