fix(goals/chart): drop title-tooltip, step projection cursor, dot follows projection line, collision-aware y-ticks, clean tooltip

Four chart fixes in one pass.

1) Browser was rendering the <title> child as a native hover tooltip
that fought with the custom crosshair tooltip. Drop <title>; use
aria-label on the <svg role="img"> instead — same SR accessible name,
no native tooltip side-effect.

2/3) The hover crosshair clamped at today: bisector ran the saved
series, which ends at today, so future hovers stuck the dot at the
last saved point. Now the controller forks:
  - Past hover: snap to nearest contribution via bisector.
  - Future hover: snap to whole-week intervals along the projection
    segment ([today, target_date]) and place the dot at the
    interpolated y on the dashed line. Movement steps cleanly week
    by week instead of pixel-by-pixel jitter.

4) Tooltip drops the redundant line:
  - Past: "<date> · Saved: $X" (no Projected — there isn't one).
  - Future: "<date> · Projected: $X" (no Saved — it's the future).

5) Y-axis tick label suppressed when its value falls within 5% of the
target line so "$2.5K" and "Target · $2,400" stop overlapping near
the right edge. Gridline stays; only the y-axis label drops.

Verified live via Playwright on House downpayment goal: <title>
absent, aria-label populated, past tooltip "Feb 10, 2026 · Saved:
$11,750", future tooltip "Nov 29, 2027 · Projected: $32,235",
neighbouring future x snaps to "Dec 13, 2027 · $32,704" (2-week jump
across the snapping boundary).
This commit is contained in:
Guillem Arias
2026-05-11 20:56:45 +02:00
parent 628e1f89bb
commit f6108e2a7b

View File

@@ -112,11 +112,13 @@ export default class extends Controller {
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("preserveAspectRatio", "none");
const titleId = `chart-title-${this._id()}`;
// Drop the <title> child — browsers render it as a native hover tooltip
// that fights with our own crosshair tooltip. aria-label gives the same
// SR accessible name without the tooltip side-effect.
const descId = `chart-desc-${this._id()}`;
svg.attr("role", "img").attr("aria-labelledby", titleId).attr("aria-describedby", descId);
svg.append("title").attr("id", titleId).text(this.ariaLabelValue || "Goal projection");
svg.attr("role", "img").attr("aria-label", this.ariaLabelValue || "Goal projection");
svg.append("desc").attr("id", descId).text(this.ariaDescriptionValue || "");
svg.attr("aria-describedby", descId);
const defs = svg.append("defs");
const gradient = defs
@@ -128,6 +130,9 @@ export default class extends Controller {
if (yAxisVisible) {
const yTicks = y.ticks(3);
// Suppress the tick label that visually collides with the target
// line label (within ~5% of the y range). Keep the gridline.
const labelCollisionThreshold = yMax * 0.05;
yTicks.forEach((tickValue) => {
svg
.append("line")
@@ -137,6 +142,8 @@ export default class extends Controller {
.attr("y2", y(tickValue))
.attr("stroke", borderSubdued)
.attr("stroke-width", 1);
const collidesWithTarget = targetAmount > 0 && Math.abs(tickValue - targetAmount) < labelCollisionThreshold;
if (collidesWithTarget) return;
svg
.append("text")
.attr("x", margin.left - 6)
@@ -337,39 +344,56 @@ export default class extends Controller {
const dateFmt = d3.timeFormat("%b %d, %Y");
const todayTs = today.getTime();
const targetTs = target ? target.getTime() : null;
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
const showAt = (xPos, yPos) => {
const xVal = x.invert(xPos);
if (!savedSeries.length) return;
const i = bisectDate(savedSeries, xVal);
const a = savedSeries[Math.max(0, i - 1)];
const b = savedSeries[Math.min(savedSeries.length - 1, i)];
const savedPoint = !a ? b : !b ? a : (xVal - a.date < b.date - xVal ? a : b);
const future = xVal.getTime() > todayTs && projectionSeries.length && targetTs;
crosshair.attr("x1", x(savedPoint.date)).attr("x2", x(savedPoint.date)).style("display", null);
hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null);
let projValue = null;
if (projectionSeries.length && targetTs && xVal.getTime() >= todayTs) {
const tFrac = (xVal.getTime() - todayTs) / (targetTs - todayTs);
if (tFrac >= 0 && tFrac <= 1) {
projValue = currentAmount + tFrac * (projectionEnd - currentAmount);
hoverProjDot.attr("cx", x(savedPoint.date)).attr("cy", y(projValue)).style("display", null);
} else {
hoverProjDot.style("display", "none");
}
// Date the crosshair + the active dot snaps to. Past = nearest saved
// contribution (sparse, monthly-ish). Future = weekly steps along the
// projection segment so the cursor doesn't jitter pixel-by-pixel.
let hoverDate;
if (future) {
const weeks = Math.round((xVal.getTime() - todayTs) / MS_PER_WEEK);
let snapped = todayTs + weeks * MS_PER_WEEK;
if (snapped > targetTs) snapped = targetTs;
if (snapped < todayTs) snapped = todayTs;
hoverDate = new Date(snapped);
} else {
hoverProjDot.style("display", "none");
const i = bisectDate(savedSeries, xVal);
const a = savedSeries[Math.max(0, i - 1)];
const b = savedSeries[Math.min(savedSeries.length - 1, i)];
hoverDate = !a ? b.date : !b ? a.date : (xVal - a.date < b.date - xVal ? a.date : b.date);
}
const lines = [
dateFmt(savedPoint.date),
`Saved: ${this._fmtMoney(savedPoint.value, data.currency)}`,
];
if (projValue !== null) {
const hoverX = x(hoverDate);
crosshair.attr("x1", hoverX).attr("x2", hoverX).style("display", null);
const lines = [dateFmt(hoverDate)];
if (future) {
// Projection segment: interpolate along the dashed line; saved dot
// stays hidden (no saved value in the future).
const tFrac = (hoverDate.getTime() - todayTs) / (targetTs - todayTs);
const projValue = currentAmount + tFrac * (projectionEnd - currentAmount);
hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null);
hoverSavedDot.style("display", "none");
lines.push(`Projected: ${this._fmtMoney(projValue, data.currency)}`);
} else {
// Saved segment: snap saved dot to the nearest contribution; no
// projection dot in the past.
const i = bisectDate(savedSeries, hoverDate);
const a = savedSeries[Math.max(0, i - 1)];
const b = savedSeries[Math.min(savedSeries.length - 1, i)];
const savedPoint = !a ? b : !b ? a : (hoverDate - a.date < b.date - hoverDate ? a : b);
hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null);
hoverProjDot.style("display", "none");
lines.push(`Saved: ${this._fmtMoney(savedPoint.value, data.currency)}`);
}
tooltip.textContent = lines.join("\n");
tooltip.style.whiteSpace = "pre";
tooltip.style.display = "block";