mirror of
https://github.com/we-promise/sure.git
synced 2026-06-06 11:19:02 +00:00
fix(charts): align every chart tooltip on the borderless soft-shadow card
One visual contract for all three D3 tooltip surfaces, matching the design reference: p-4, rounded-2xl, shadow-xl, no edge ring in light mode. Dark mode keeps a 1px alpha-white ring since a shadow alone disappears against dark surfaces. - goal_projection_chart_controller drops its hand-copied class string (it still carried the old bordered recipe — the drift this util exists to prevent) and builds its two lines through the shared factory: secondary date line, tabular value line. - New content conventions exported alongside the container contract: context line = text-xs text-secondary, values = font-medium tabular-nums. Time-series and sankey adopt them. - Sankey node titles now escape before .html(); user-named categories were previously interpolated raw into the tooltip markup.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
import {
|
||||
createChartTooltip,
|
||||
CHART_TOOLTIP_CONTEXT_CLASSES,
|
||||
CHART_TOOLTIP_VALUE_CLASSES,
|
||||
} from "utils/chart_tooltip";
|
||||
|
||||
// Projection chart for a goal. Renders:
|
||||
// - Saved area + line from goal creation → today (solid)
|
||||
@@ -439,10 +444,15 @@ export default class extends Controller {
|
||||
// clobber a stylesheet `position: fixed/sticky/absolute` with our
|
||||
// own `relative`. Read the computed style instead.
|
||||
if (getComputedStyle(root).position === "static") root.style.position = "relative";
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.className = "bg-container text-primary text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none z-50 privacy-sensitive";
|
||||
tooltip.style.display = "none";
|
||||
root.appendChild(tooltip);
|
||||
// Shared visual contract (utils/chart_tooltip) — this used to be a
|
||||
// hand-copied class string that drifted from the other charts the moment
|
||||
// the contract changed.
|
||||
const tooltip = createChartTooltip(root);
|
||||
const tooltipDate = document.createElement("div");
|
||||
tooltipDate.className = CHART_TOOLTIP_CONTEXT_CLASSES;
|
||||
const tooltipValue = document.createElement("div");
|
||||
tooltipValue.className = CHART_TOOLTIP_VALUE_CLASSES;
|
||||
tooltip.replaceChildren(tooltipDate, tooltipValue);
|
||||
|
||||
const overlay = svg
|
||||
.append("rect")
|
||||
@@ -485,7 +495,7 @@ export default class extends Controller {
|
||||
const hoverX = x(hoverDate);
|
||||
crosshair.attr("x1", hoverX).attr("x2", hoverX).style("display", null);
|
||||
|
||||
const lines = [dateFmt(hoverDate)];
|
||||
tooltipDate.textContent = dateFmt(hoverDate);
|
||||
|
||||
if (future) {
|
||||
// Projection segment: interpolate along the dashed line; saved dot
|
||||
@@ -494,7 +504,7 @@ export default class extends Controller {
|
||||
const projValue = currentAmount + tFrac * (projectionEnd - currentAmount);
|
||||
hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null);
|
||||
hoverSavedDot.style("display", "none");
|
||||
lines.push(this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency)));
|
||||
tooltipValue.textContent = this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency));
|
||||
} else {
|
||||
// Saved segment: hoverDate is already snapped to nearest savedSeries
|
||||
// entry above, so reuse that entry directly instead of running
|
||||
@@ -502,11 +512,9 @@ export default class extends Controller {
|
||||
const savedPoint = savedSeries.find((p) => p.date.getTime() === hoverDate.getTime()) || savedSeries[savedSeries.length - 1];
|
||||
hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null);
|
||||
hoverProjDot.style("display", "none");
|
||||
lines.push(this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency)));
|
||||
tooltipValue.textContent = this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency));
|
||||
}
|
||||
|
||||
tooltip.textContent = lines.join("\n");
|
||||
tooltip.style.whiteSpace = "pre";
|
||||
tooltip.style.display = "block";
|
||||
const tipRect = tooltip.getBoundingClientRect();
|
||||
const left = Math.min(width - tipRect.width - 4, Math.max(4, xPos + 12));
|
||||
|
||||
@@ -520,9 +520,18 @@ export default class extends Controller {
|
||||
#showTooltip(event, value, percentage, title = 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-medium tabular-nums">${this.#formatCurrency(value)}</span> <span class="text-secondary">(${percentage || 0}%)</span>`;
|
||||
const content = title
|
||||
? `${title}<br/>${this.#formatCurrency(value)} (${percentage || 0}%)`
|
||||
: `${this.#formatCurrency(value)} (${percentage || 0}%)`;
|
||||
? `<div class="text-xs text-secondary mb-1">${esc(title)}</div><div>${valueLine}</div>`
|
||||
: valueLine;
|
||||
|
||||
const isInDialog = !!this.element.closest("dialog");
|
||||
const x = isInDialog ? event.clientX : event.pageX;
|
||||
|
||||
@@ -385,11 +385,11 @@ export default class extends Controller {
|
||||
|
||||
_tooltipTemplate(datum) {
|
||||
return `
|
||||
<div style="margin-bottom: 4px; color: var(--color-gray-500);">
|
||||
<div class="text-xs text-secondary mb-1">
|
||||
${datum.date_formatted}
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<div class="flex items-center gap-2 text-primary font-medium tabular-nums">
|
||||
<div class="flex items-center justify-center h-4 w-4">
|
||||
${this._getTrendIcon(datum)}
|
||||
</div>
|
||||
@@ -400,7 +400,7 @@ export default class extends Controller {
|
||||
datum.trend.value === 0
|
||||
? `<span class="w-20"></span>`
|
||||
: `
|
||||
<span style="color: ${datum.trend.color};">
|
||||
<span class="tabular-nums" style="color: ${datum.trend.color};">
|
||||
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})
|
||||
</span>
|
||||
`
|
||||
|
||||
@@ -11,8 +11,20 @@
|
||||
// Not to be confused with DS::Tooltip — that is the info-icon hint primitive
|
||||
// (bg-inverse, aria-describedby, anchored to a static trigger). This is a
|
||||
// data-card surface created and updated inside D3 handler code.
|
||||
// Visual target: the borderless soft-shadow card from the design reference —
|
||||
// generous padding, large radius, no edge ring in light mode (the shadow alone
|
||||
// defines the surface). Dark mode keeps a 1px alpha ring because a shadow is
|
||||
// nearly invisible against dark surfaces and the card would otherwise melt
|
||||
// into the chart background.
|
||||
export const CHART_TOOLTIP_CLASSES =
|
||||
"bg-container text-primary text-sm font-sans absolute p-3 rounded-xl shadow-lg shadow-border-xs pointer-events-none z-50 privacy-sensitive";
|
||||
"bg-container text-primary text-sm font-sans absolute p-4 rounded-2xl shadow-xl theme-dark:ring-1 theme-dark:ring-alpha-white-200 pointer-events-none z-50 privacy-sensitive";
|
||||
|
||||
// Content conventions (kept here so the controllers stay aligned):
|
||||
// - context line (date / node title): `text-xs text-secondary mb-1`
|
||||
// - value figures: `font-medium tabular-nums`, secondary parentheticals in
|
||||
// `text-secondary`
|
||||
export const CHART_TOOLTIP_CONTEXT_CLASSES = "text-xs text-secondary mb-1";
|
||||
export const CHART_TOOLTIP_VALUE_CLASSES = "font-medium tabular-nums";
|
||||
|
||||
// Convenience factory for the raw-DOM idiom (no d3.select). Creates a hidden
|
||||
// tooltip div carrying the shared contract and appends it to `parent`.
|
||||
|
||||
Reference in New Issue
Block a user