mirror of
https://github.com/we-promise/sure.git
synced 2026-06-06 03:09:02 +00:00
feat(charts): give sankey and goal tooltips their missing context rows
Chart tooltips answer three stacked questions: context (what am I looking at), value (how much), relation (vs what). Time-series already had all three; the other two were missing rows. - Sankey links showed a bare "$X (Y%)" with no indication of which flow was hovered. Links now lead with a swatch-dotted "Source → Target" context line; nodes get the same dot + name treatment, tying the card to the ribbon color. The context builder escapes node names centrally (they're user-named categories). - Goal projection adds a tertiary relation line — "52% of $20K target" — computed from the payload the chart already carries, for both the saved and projected segments. Hidden when the goal has no positive target. Template is i18n-wired like the existing tooltip strings (goals.show.projection.tooltip_target_relation). Verified with Playwright against the running app: all three surfaces pass computed-style and content assertions.
This commit is contained in:
@@ -23,6 +23,7 @@ export default class extends Controller {
|
||||
todayLabel: { type: String, default: "Today" },
|
||||
projectedTemplate: { type: String, default: "Projected: {amount}" },
|
||||
savedTemplate: { type: String, default: "Saved: {amount}" },
|
||||
targetRelationTemplate: { type: String, default: "{percent}% of {target} target" },
|
||||
};
|
||||
|
||||
connect() {
|
||||
@@ -452,7 +453,25 @@ export default class extends Controller {
|
||||
tooltipDate.className = CHART_TOOLTIP_CONTEXT_CLASSES;
|
||||
const tooltipValue = document.createElement("div");
|
||||
tooltipValue.className = CHART_TOOLTIP_VALUE_CLASSES;
|
||||
tooltip.replaceChildren(tooltipDate, tooltipValue);
|
||||
// Relation line: where this value sits against the goal target. Tertiary
|
||||
// so the hierarchy stays date < value > relation; hidden when the goal
|
||||
// has no positive target to compare against.
|
||||
const tooltipRelation = document.createElement("div");
|
||||
tooltipRelation.className = "text-xs text-subdued mt-0.5";
|
||||
tooltip.replaceChildren(tooltipDate, tooltipValue, tooltipRelation);
|
||||
|
||||
const setRelation = (amount) => {
|
||||
const target = Number(data.target_amount) || 0;
|
||||
if (target <= 0 || !data.target_amount_short_label) {
|
||||
tooltipRelation.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((amount / target) * 100);
|
||||
tooltipRelation.textContent = this.targetRelationTemplateValue
|
||||
.replace("{percent}", percent)
|
||||
.replace("{target}", data.target_amount_short_label);
|
||||
tooltipRelation.style.display = "";
|
||||
};
|
||||
|
||||
const overlay = svg
|
||||
.append("rect")
|
||||
@@ -505,6 +524,7 @@ export default class extends Controller {
|
||||
hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null);
|
||||
hoverSavedDot.style("display", "none");
|
||||
tooltipValue.textContent = this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency));
|
||||
setRelation(projValue);
|
||||
} else {
|
||||
// Saved segment: hoverDate is already snapped to nearest savedSeries
|
||||
// entry above, so reuse that entry directly instead of running
|
||||
@@ -513,6 +533,7 @@ export default class extends Controller {
|
||||
hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null);
|
||||
hoverProjDot.style("display", "none");
|
||||
tooltipValue.textContent = this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency));
|
||||
setRelation(savedPoint.value);
|
||||
}
|
||||
|
||||
tooltip.style.display = "block";
|
||||
|
||||
@@ -445,7 +445,17 @@ export default class extends Controller {
|
||||
linkPaths
|
||||
.on("mouseenter", (event, d) => {
|
||||
applyHover([d]);
|
||||
this.#showTooltip(event, d.value, d.percentage);
|
||||
// A link is a flow between two named nodes — without the names the
|
||||
// value floats context-free (the old tooltip showed only "$X (Y%)").
|
||||
this.#showTooltip(
|
||||
event,
|
||||
d.value,
|
||||
d.percentage,
|
||||
this.#tooltipContext(
|
||||
d.source.color,
|
||||
`${this.#esc(d.source.name)} → ${this.#esc(d.target.name)}`,
|
||||
),
|
||||
);
|
||||
})
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("mouseleave", () => {
|
||||
@@ -466,7 +476,12 @@ export default class extends Controller {
|
||||
(l) => l.source === d || l.target === d,
|
||||
);
|
||||
applyHover(connectedLinks);
|
||||
this.#showTooltip(event, d.value, d.percentage, d.name);
|
||||
this.#showTooltip(
|
||||
event,
|
||||
d.value,
|
||||
d.percentage,
|
||||
this.#tooltipContext(d.color, this.#esc(d.name)),
|
||||
);
|
||||
})
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("click", (event, d) => {
|
||||
@@ -490,7 +505,12 @@ export default class extends Controller {
|
||||
(l) => l.source === d || l.target === d,
|
||||
);
|
||||
applyHover(connectedLinks);
|
||||
this.#showTooltip(event, d.value, d.percentage, d.name);
|
||||
this.#showTooltip(
|
||||
event,
|
||||
d.value,
|
||||
d.percentage,
|
||||
this.#tooltipContext(d.color, this.#esc(d.name)),
|
||||
);
|
||||
})
|
||||
.on("mousemove", (event) => this.#updateTooltipPosition(event))
|
||||
.on("click", (event, d) => {
|
||||
@@ -517,20 +537,29 @@ export default class extends Controller {
|
||||
.style("pointer-events", "none");
|
||||
}
|
||||
|
||||
#showTooltip(event, value, percentage, title = null) {
|
||||
// Node names are user-named categories; escape anything interpolated into
|
||||
// .html() (the previous code injected them raw).
|
||||
#esc(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
}
|
||||
|
||||
// Context line shared by node and link tooltips: a swatch dot tying the
|
||||
// card to the hovered ribbon/node color, plus the (escaped) name(s).
|
||||
#tooltipContext(color, label) {
|
||||
const dot = `<span class="inline-block shrink-0 rounded-full" style="width: 8px; height: 8px; background: ${this.#esc(color || "var(--color-gray-400)")};"></span>`;
|
||||
return `<div class="flex items-center gap-1.5 text-xs text-secondary mb-1">${dot}<span class="min-w-0 truncate">${label}</span></div>`;
|
||||
}
|
||||
|
||||
#showTooltip(event, value, percentage, contextHtml = null) {
|
||||
if (!this.tooltip) this.#createTooltip();
|
||||
|
||||
// Node titles are user-named categories; escape them since this goes
|
||||
// through .html() (the previous interpolation injected them raw).
|
||||
const esc = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
const valueLine = `<span class="font-mono font-medium tabular-nums">${this.#formatCurrency(value)}</span> <span class="text-secondary">(${percentage || 0}%)</span>`;
|
||||
const content = title
|
||||
? `<div class="text-xs text-secondary mb-1">${esc(title)}</div><div>${valueLine}</div>`
|
||||
const content = contextHtml
|
||||
? `${contextHtml}<div>${valueLine}</div>`
|
||||
: valueLine;
|
||||
|
||||
const isInDialog = !!this.element.closest("dialog");
|
||||
|
||||
@@ -231,7 +231,8 @@
|
||||
data-goal-projection-chart-aria-description-value="<%= strip_tags(@goal.projection_summary) %>"
|
||||
data-goal-projection-chart-today-label-value="<%= t("goals.show.projection.today_marker") %>"
|
||||
data-goal-projection-chart-projected-template-value="<%= t("goals.show.projection.tooltip_projected", amount: "{amount}") %>"
|
||||
data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>"></div>
|
||||
data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>"
|
||||
data-goal-projection-chart-target-relation-template-value="<%= t("goals.show.projection.tooltip_target_relation", percent: "{percent}", target: "{target}") %>"></div>
|
||||
<% if @goal.target_date.nil? %>
|
||||
<div class="mt-3 flex items-center gap-2 text-xs text-secondary">
|
||||
<span class="text-subdued"><%= icon("calendar-plus", size: "sm") %></span>
|
||||
|
||||
@@ -152,6 +152,7 @@ en:
|
||||
today_marker: Today
|
||||
tooltip_projected: "Projected: %{amount}"
|
||||
tooltip_saved: "Saved: %{amount}"
|
||||
tooltip_target_relation: "%{percent}% of %{target} target"
|
||||
catch_up:
|
||||
title: "Save %{amount}/mo more to catch up"
|
||||
body: "Current pace %{avg}/mo · required %{required}/mo to hit your target."
|
||||
|
||||
Reference in New Issue
Block a user