mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user