diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js
index 5ce7f5c22..742b7b9e7 100644
--- a/app/javascript/controllers/goal_projection_chart_controller.js
+++ b/app/javascript/controllers/goal_projection_chart_controller.js
@@ -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
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";