From 0eae9b2f53ddd2e8a88e3cbf39747aa25b223b8f Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sun, 9 Aug 2026 02:05:24 +0200 Subject: [PATCH] fix(goals): keep the projection chart's saved line inside the plot (#2971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The y-domain was built from the target, the projection, the required path and today's balance — everything except the series actually drawn across the plot. A goal whose linked account had ever held more than the target mapped that history above the top of the scale, and with no clip region the line painted straight out of the plot and over the legend until the SVG edge cut it off. Two changes. The domain now accounts for the saved history, with 5% headroom so the peak's stroke isn't shaved in half. It is not allowed to follow that history without limit, though: every value the chart reasons about sits at or below the goal's own scale, so headroom above it buys nothing but taller spikes. A goal funded from a current account sees the whole balance as saved, and a €700 target against a salary landing and clearing is an order of magnitude apart — scaled to that peak, the target and both projection lines collapse into the floor and the chart stops answering "am I on track". Past twice the goal's scale the history is clipped instead, which keeps the target line at or above half the plot height. The clip region is the structural half. Series paths and the hover dot are confined to the plot box, so anything outside the domain — a peak past the ceiling, or a linked current account that went overdrawn and dipped below zero — stops at the edge instead of painting over the legend above or the date axis below. Clipped peaks stay readable: the tooltip still reports their real amount. Behaviour by how far the history overshoots the goal's scale: 0.6x -> domain 1050, no clipping 1.4x -> domain 1470, no clipping (autoscaled) 2.0x -> domain 2100, no clipping (at the ceiling) 8.0x -> domain 2100, clipped inside the plot --- .../goal_projection_chart_controller.js | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) 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