diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index 43e3c5481..ed4ee109c 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -15,6 +15,18 @@ import { // // Data shape passed via `data-goal-projection-chart-data-value` // matches Goal#projection_payload. + +// Ceiling on how far the y-axis may stretch past the goal's own scale to fit a +// taller savings history, as a multiple of that scale. +// +// Every value the chart reasons about — the target, the projection, the +// required path, today's balance — sits at or below that scale, so any headroom +// above it is spent purely on historical peaks. Keep it small: at 2 the target +// line never falls below half the plot height, while a history that overshoots +// the target up to twofold (having saved past it, the ordinary case) still +// shows in full. +const SAVED_HEADROOM_FACTOR = 2; + export default class extends Controller { static values = { data: Object, @@ -159,7 +171,26 @@ export default class extends Controller { ] : []; - const yMax = Math.max(targetAmount * 1.05, projectionEnd, requiredEnd, currentAmount, 1); + // The scale the goal's own content needs: target, projection, required path + // and where the balance stands today. + const goalBand = Math.max(targetAmount * 1.05, projectionEnd, requiredEnd, currentAmount, 1); + + // The saved history used to be left out of the domain entirely, so a + // balance that ever rose above the target was drawn above the plot — the + // line escaped the chart and painted over the legend before the SVG edge + // cut it off. Include it, with headroom so the peak's stroke isn't shaved + // in half at the top. + // + // But do not let it set the scale without limit. A goal funded from a + // current account sees the whole balance as "saved", so a 700 target + // against a salary that lands and clears can be an order of magnitude + // apart; scaled to that peak, the target line and both projection lines + // collapse into the floor and the chart stops answering "am I on track". + // Past SAVED_HEADROOM_FACTOR the history is clipped instead — deliberately, + // inside the plot (see the clip path below), rather than bleeding out of + // it. Clipped peaks are still readable on hover. + const savedMax = d3.max(savedSeries, (d) => d.value) || 0; + const yMax = Math.min(Math.max(goalBand, savedMax * 1.05), goalBand * SAVED_HEADROOM_FACTOR); const x = d3.scaleTime().domain([start, endDate]).range([margin.left, margin.left + innerWidth]); const y = d3.scaleLinear().domain([0, yMax]).range([margin.top + innerHeight, margin.top]); @@ -187,6 +218,24 @@ export default class extends Controller { gradient.append("stop").attr("offset", "0%").attr("stop-color", textPrimary).attr("stop-opacity", 0.22); gradient.append("stop").attr("offset", "100%").attr("stop-color", textPrimary).attr("stop-opacity", 0); + // Everything driven by the saved series is confined to the plot box. The + // domain above already covers the common case, but this is the structural + // guarantee: a value outside it — a balance past the headroom ceiling, or a + // linked current account that went overdrawn and dipped below zero — stops + // at the plot edge instead of painting over the legend above or the date + // axis below. Bounds are exact vertically; a couple of pixels of horizontal + // bleed keep the stroke's round cap from being shaved at the left edge. + const clipId = `plot-clip-${this._id()}`; + defs + .append("clipPath") + .attr("id", clipId) + .append("rect") + .attr("x", margin.left - 2) + .attr("y", margin.top) + .attr("width", innerWidth + 4) + .attr("height", innerHeight); + const plotClip = `url(#${clipId})`; + const COLLISION_PX = 18; const targetY = targetAmount > 0 ? y(targetAmount) : null; const yTicks = yAxisVisible ? y.ticks(3) : []; @@ -270,6 +319,7 @@ export default class extends Controller { .append("path") .datum(savedSeries) .attr("fill", `url(#saved-fill-${this._id()})`) + .attr("clip-path", plotClip) .attr("d", area); svg @@ -280,6 +330,7 @@ export default class extends Controller { .attr("stroke-width", 2) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") + .attr("clip-path", plotClip) .attr("d", line); if (requiredSeries.length) { @@ -421,6 +472,10 @@ export default class extends Controller { .attr("pointer-events", "none") .style("display", "none"); + // Clipped like the line it rides on: hovering a point above the headroom + // ceiling would otherwise park the dot outside the plot. The tooltip still + // reports that point's real amount, which is how a clipped peak stays + // readable. const hoverSavedDot = svg .append("circle") .attr("r", 4) @@ -428,6 +483,7 @@ export default class extends Controller { .attr("stroke", containerBg) .attr("stroke-width", 2) .attr("pointer-events", "none") + .attr("clip-path", plotClip) .style("display", "none"); const hoverProjDot = svg