From d63321552951f62439f81b4b074cc3b9eefd587b Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 21:47:29 +0200 Subject: [PATCH] fix(goals/chart): suppress projection-end label when it collides with target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: an on-track goal whose projected end is just above the target showed two right-anchored labels stacked on top of each other — "Target · $2,400" and the projection-end short value "$2.4K". The projection dot already conveys "you'll hit the target on time"; the extra label adds noise. Now: when willHit AND |projDotY - y(targetAmount)| < 18px, skip the projection-end label entirely. The colored dot at the target_date keeps the visual cue. Also refactor the y-axis label collision check from value-based (within 5% of yMax) to pixel-based (within 18px of target's y), matching the projection-end logic. When a y-tick is close to target, the Target label drops into the y-axis column at that row (short format) instead of right-edge full format. Either way, no two labels ever stack within 18 vertical px. Verified live: Wedding fund (on_track, projection ≈ target) → just "Target · $2,400" + y-ticks, no "$2.4K". House downpayment (behind) → "Target · $50,000" + "Short $12.3K" both retained (well separated). --- .../goal_projection_chart_controller.js | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index 742b7b9e7..bd370e54e 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -128,11 +128,13 @@ 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); + const COLLISION_PX = 18; + const targetY = targetAmount > 0 ? y(targetAmount) : null; + const yTicks = yAxisVisible ? y.ticks(3) : []; + const targetCollidesWithTick = + targetY !== null && yTicks.some((tv) => Math.abs(y(tv) - targetY) < COLLISION_PX); + 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") @@ -142,8 +144,9 @@ 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; + // Skip the y-axis label when its row is close to the target line. + // The target's own label will take over that y-slot below. + if (targetY !== null && Math.abs(y(tickValue) - targetY) < COLLISION_PX) return; svg .append("text") .attr("x", margin.left - 6) @@ -166,14 +169,29 @@ export default class extends Controller { .attr("stroke-width", 1) .attr("stroke-dasharray", "3 3"); - svg - .append("text") - .attr("x", margin.left + innerWidth - 4) - .attr("y", y(targetAmount) - 6) - .attr("text-anchor", "end") - .attr("font-size", 10) - .attr("fill", textPrimary) - .text(`Target · ${this._fmtMoney(targetAmount, data.currency)}`); + if (targetCollidesWithTick) { + // Merge target label into the y-axis column at the target's y-row. + // The collided y-axis tick was suppressed above so this label takes + // over that slot cleanly. + svg + .append("text") + .attr("x", margin.left - 6) + .attr("y", targetY + 3) + .attr("text-anchor", "end") + .attr("font-size", 10) + .attr("fill", textPrimary) + .text(`Target · ${this._fmtMoneyShort(targetAmount, data.currency)}`); + } else { + // Plenty of room — keep the right-side full-format label. + svg + .append("text") + .attr("x", margin.left + innerWidth - 4) + .attr("y", targetY - 6) + .attr("text-anchor", "end") + .attr("font-size", 10) + .attr("fill", textPrimary) + .text(`Target · ${this._fmtMoney(targetAmount, data.currency)}`); + } } const area = d3 @@ -227,7 +245,14 @@ export default class extends Controller { .attr("stroke", containerBg) .attr("stroke-width", 2); - if (innerWidth >= 320) { + // Suppress the projection-end label when it would visually collide + // with the target label above. In a barely-on-track case the dot + // already conveys "you'll hit the target" — duplicating "$2.4K" + // beside "Target · $2,400" adds noise. + const projDotY = y(projectionEnd); + const collidesWithTargetLabel = targetAmount > 0 && Math.abs(projDotY - y(targetAmount)) < 18; + + if (innerWidth >= 320 && !(willHit && collidesWithTargetLabel)) { const labelText = willHit ? this._fmtMoneyShort(projectionEnd, data.currency) : `Short ${this._fmtMoneyShort(targetAmount - projectionEnd, data.currency)}`;