// Built fresh from scratch whenever relType changes (cheap -- no
// animation to preserve across redraws, unlike the CLT/pizza figures
// elsewhere in this deck), so hover/tap handlers below are always
// closures over the *current* relType and line.
relSvg = {
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${relWidth} ${relHeight}`)
.attr("width", relWidth)
.attr("height", relHeight)
.attr("class", "rel-svg")
.style("touch-action", "none");
const xAxisY = relYScale(0);
const yAxisX = relXScale(0);
const gridG = svg.append("g");
for (const gx of relXTicks) {
gridG.append("line")
.attr("x1", relXScale(gx)).attr("x2", relXScale(gx))
.attr("y1", relMarginTop).attr("y2", relHeight - relMarginBottom)
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
for (const gy of relYTicks) {
gridG.append("line")
.attr("x1", relMarginLeft).attr("x2", relWidth - relMarginRight)
.attr("y1", relYScale(gy)).attr("y2", relYScale(gy))
.attr("stroke", "#e5e5e5").attr("stroke-width", 1);
}
svg.append("line")
.attr("x1", relXScale(relXDomain[0])).attr("x2", relXScale(relXDomain[1]))
.attr("y1", xAxisY).attr("y2", xAxisY)
.attr("stroke", "#333").attr("stroke-width", 2);
svg.append("line")
.attr("x1", yAxisX).attr("x2", yAxisX)
.attr("y1", relYScale(relYDomain[0])).attr("y2", relYScale(relYDomain[1]))
.attr("stroke", "#333").attr("stroke-width", 2);
const xTipX = relXScale(relXDomain[1]);
svg.append("path")
.attr("d", `M ${xTipX} ${xAxisY} L ${xTipX - 9} ${xAxisY - 5} L ${xTipX - 9} ${xAxisY + 5} Z`)
.attr("fill", "#333");
const yTipY = relYScale(relYDomain[1]);
svg.append("path")
.attr("d", `M ${yAxisX} ${yTipY} L ${yAxisX - 5} ${yTipY + 9} L ${yAxisX + 5} ${yTipY + 9} Z`)
.attr("fill", "#333");
svg.append("text")
.attr("x", xTipX).attr("y", xAxisY + 22)
.attr("text-anchor", "end").attr("font-size", 18).attr("font-weight", "bold")
.attr("fill", "#333").text("X");
svg.append("text")
.attr("x", yAxisX - 10).attr("y", yTipY + 4)
.attr("text-anchor", "end").attr("font-size", 18).attr("font-weight", "bold")
.attr("fill", "#333").text("Y");
// Numeric tick marks/labels along both axes. The x=0 label is
// skipped here since the y-axis tick loop already draws a single
// "0" right at the origin -- drawing it twice would just overlap.
const tickG = svg.append("g");
for (const gx of relXTicks) {
if (gx === 0) continue;
const px = relXScale(gx);
tickG.append("line")
.attr("x1", px).attr("x2", px)
.attr("y1", xAxisY).attr("y2", xAxisY + 5)
.attr("stroke", "#333").attr("stroke-width", 1);
tickG.append("text")
.attr("x", px).attr("y", xAxisY + 19)
.attr("text-anchor", "middle").attr("font-size", 13)
.attr("fill", "#555").text(svFmt(gx));
}
for (const gy of relYTicks) {
const py = relYScale(gy);
tickG.append("line")
.attr("x1", yAxisX - 5).attr("x2", yAxisX)
.attr("y1", py).attr("y2", py)
.attr("stroke", "#333").attr("stroke-width", 1);
// The origin's "0" sits diagonally down-left of the axis crossing
// instead of directly left, so it doesn't crowd either axis line.
if (gy === 0) {
tickG.append("text")
.attr("x", yAxisX - 10).attr("y", py + 16)
.attr("text-anchor", "end").attr("font-size", 13)
.attr("fill", "#555").text(svFmt(gy));
} else {
tickG.append("text")
.attr("x", yAxisX - 9).attr("y", py + 4)
.attr("text-anchor", "end").attr("font-size", 13)
.attr("fill", "#555").text(svFmt(gy));
}
}
if (relType) {
const x0 = relXDomain[0], x1 = relXDomain[1];
const y0 = relLineFn(relType, x0), y1 = relLineFn(relType, x1);
svg.append("line")
.attr("x1", relXScale(x0)).attr("y1", relYScale(y0))
.attr("x2", relXScale(x1)).attr("y2", relYScale(y1))
.attr("stroke", relColor(relType)).attr("stroke-width", 3);
const hoverG = svg.append("g").attr("display", "none");
// Dashed projection lines out to each axis, drawn first so the
// dot/glow/text sit visually on top of them.
const hoverGuideY = hoverG.append("line")
.attr("stroke", relColor(relType)).attr("stroke-opacity", 0.55)
.attr("stroke-width", 1).attr("stroke-dasharray", "4,3");
const hoverGuideX = hoverG.append("line")
.attr("stroke", relColor(relType)).attr("stroke-opacity", 0.55)
.attr("stroke-width", 1).attr("stroke-dasharray", "4,3");
hoverG.append("circle").attr("class", "rel-hover-glow").attr("r", 9)
.attr("fill", relColor(relType)).attr("fill-opacity", 0.25);
hoverG.append("circle").attr("class", "rel-hover-dot").attr("r", 4)
.attr("fill", relColor(relType));
const hoverText = hoverG.append("text")
.attr("text-anchor", "middle")
.attr("font-size", 14)
.attr("fill", relColor(relType));
function showHover(x, y) {
const px = relXScale(x), py = relYScale(y);
hoverG.attr("display", null);
// Out to the y-axis (horizontal) and down to the x-axis (vertical).
hoverGuideY.attr("x1", px).attr("y1", py).attr("x2", yAxisX).attr("y2", py);
hoverGuideX.attr("x1", px).attr("y1", py).attr("x2", px).attr("y2", xAxisY);
hoverG.select(".rel-hover-glow").attr("cx", px).attr("cy", py);
hoverG.select(".rel-hover-dot").attr("cx", px).attr("cy", py);
hoverText.attr("x", px).attr("y", py - 14).text(`X = ${svFmt(x)}, Y = ${svFmt(y)}`);
}
function hideHover() {
hoverG.attr("display", "none");
}
// Snap-candidates are the line's own points at each relGridStep
// x-step (the same spacing as the background grid). Pixel radius is
// derived from the actual on-screen spacing between them, kept
// just under half a step, so neighbouring points never fight over
// the same pointer position.
const pixelStep = Math.abs(relXScale(relGridStep) - relXScale(0));
const mouseRadius = pixelStep * 0.45;
const touchRadius = pixelStep * 0.48;
function nearestCandidate(mx, my) {
let best = null, bestDist = Infinity;
for (const gx of relXTicks) {
const gy = relLineFn(relType, gx);
const px = relXScale(gx), py = relYScale(gy);
const d = Math.hypot(px - mx, py - my);
if (d < bestDist) { bestDist = d; best = { x: gx, y: gy, dist: d }; }
}
return best;
}
const overlay = svg.append("rect")
.attr("x", relMarginLeft).attr("y", relMarginTop)
.attr("width", relWidth - relMarginLeft - relMarginRight)
.attr("height", relHeight - relMarginTop - relMarginBottom)
.attr("fill", "transparent")
.style("cursor", "pointer");
// Mouse/pen: continuous hover as the pointer moves.
overlay.on("pointermove", (event) => {
if (event.pointerType === "touch") return;
const [mx, my] = d3.pointer(event);
const cand = nearestCandidate(mx, my);
if (cand && cand.dist <= mouseRadius) showHover(cand.x, cand.y);
else hideHover();
});
overlay.on("pointerleave", () => hideHover());
// Touch (and mouse, harmlessly): tap to show, tap elsewhere to hide.
overlay.on("click", (event) => {
const [mx, my] = d3.pointer(event);
const cand = nearestCandidate(mx, my);
if (cand && cand.dist <= touchRadius) showHover(cand.x, cand.y);
else hideHover();
});
}
return svg.node();
}