MoneyChart
- Status
- Implemented
- Family
- 7 of 13
- Registry
- @lopifi/money-chart
npx shadcn@latest add @lopifi/theme @lopifi/money-chartThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
Line and area, with everything a generic chart library gets wrong about money:
- Money-formatted axis — abbreviated (
$1.2M) with the exact value in the tooltip. Abbreviation is licensed here; see numerics. - Zero baseline is emphasized — a 1px
--border-strongline, distinct from the--border-subtlegridlines. When data crosses zero, that line is the most important thing in the chart. - Negative regions are visually distinct — a fill below zero at
--negativewith a 45° hatch, so it survives greyscale. - Y-axis starts at zero for quantities. The only sanctioned exception is a rate chart (
CurrencyPairChart), and there the axis is explicitly labeled. - Projections are dashed. A forecast rendered identically to actuals is a lie. The boundary itself is drawn — a dashed vertical rule labelled
Forecast →— because a dash pattern read across six months is not a date. - Gridlines: horizontal only,
--border-subtle. Vertical gridlines on a time series are noise. - Area fill is the one sanctioned gradient — a single hue fading to transparent, because it keeps the line readable over gridlines. Legibility, not decoration.
- Multi-currency series is rejected at the type level. Convert to a stated presentation currency first, and label the axis with it.
The FP&A layer
A plan chart has to say four things a line chart cannot, and each of them is a way of being wrong that a prettier chart hides.
- The range, and what it is.
bandtakes a low and a high and a requiredlabel—P10–P90,±1σ,Downside to upside. A shaded cone with no stated interval is a precision claim nobody made, and a reader who cannot tell which interval they are looking at will assume whichever suits them. The band is bounded by a hairline as well as shaded, because a fill alone disappears on a projector, and the edges are the part people quote. It widens with distance or it is lying about what is knowable. - The alternatives, unranked.
scenariosdraws base, upside and downside from the same anchor, told apart by dash rhythm first and colour second — a base/upside/downside triple in red-amber-green is three lines a third of readers cannot separate. No best-case styling and no ordering by outcome: a scenario is a set of assumptions, and presenting one as the good one is how a forecast review becomes a negotiation about which line to show the board. - The plan.
planis a reference, not a series: dotted, in the neutral tertiary tone, taking no viz colour and absent from the legend's isolate control. A budget is the line the business agreed to, not another thing that happened, and giving it a series colour puts it in competition with the measure it exists to be read against. - The variance, in words that survive greyscale. Stated as F and U rather than as a colour or a bare sign. Favourable and unfavourable are the terms of art, they are unambiguous under polarity — a cost under plan is F and revenue under plan is U, and both are negative absolute variances — and no amount of red and green carries that.
plan.polarityis required for the same reason it is required everywhere else: a chart does not know its own polarity, and guessing one gets half of FP&A backwards.
The variance is always taken against the first series declared, and the readout disappears when that series is isolated away. A variance whose base silently changes as the reader filters is worse than no variance: the number moves and nothing on the chart says why.
The band, the scenarios and the plan are all inside the y-domain. A cone clipped by the axis is a chart that has quietly cropped the bad case out of the picture.
The table equivalent carries all of it — a Basis column marking each period actual or forecast, the band's range, each scenario, the plan and the variance. A table listing the base values alone is not the chart in another form; it is a different, more confident chart.
Motion: stroke-dashoffset draw, 400ms --ease-out, once on mount or scroll-into-view. Instant on data change.
Touch: 44px-tall scrub target across the plot, with a persistent readout in a fixed header — not a floating tooltip a finger covers.
Lifted verbatim from Dashboard & charts. The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/money-chart.tsx
- Lopifi dependencies · 11
- chart-primitiveschart-utilscnforecastformat-moneymoneymoney.typesperiod-changetooltipuse-in-viewuse-media-query
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/money-chart.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import * as React from "react";
import { useInViewOnce } from "@/hooks/ledger/use-in-view";
import { useReducedMotion } from "@/hooks/ledger/use-media-query";
import { axisLabel, domainFor, niceTicks, vizColorFor } from "@/lib/ledger/chart-utils";
import { varianceAgainstPlan } from "@/lib/ledger/forecast";
import { formatMoney } from "@/lib/ledger/format-money";
import type { Polarity } from "@/lib/ledger/money.types";
import { ChartLegend, ChartTooltip, DataTableToggle, HatchDefs } from "@/components/ledger/chart-primitives";
import { Money } from "@/components/ledger/money";
/**
* The uncertainty around a projection.
*
* `label` is not optional decoration. A shaded band with no stated interval is
* the chart asserting a precision it has not defined — P10–P90 and ±1σ are
* different claims, and a reader who cannot tell which one they are looking at
* will assume whichever suits them.
*/
export type ForecastBand = {
/** Minor units, aligned to `values`. Entries before the split are ignored. */
low: (number | null)[];
high: (number | null)[];
/** The interval this band is: "P10–P90", "±1σ", "Downside to upside". */
label: string;
};
/**
* A named alternative to the base projection, branching at the same split.
*
* Carried on the series rather than passed as more series because a scenario
* is not a second measure — it is the same measure under a different set of
* assumptions, and it shares the actuals to the left of the boundary.
*/
export type ForecastScenario = {
key: string;
label: string;
/** Only the projected tail is read; the actual portion is the series' own. */
values: (number | null)[];
};
export type MoneySeries = {
key: string;
label: string;
/** Minor units. Multi-currency series is rejected at the type level: one currency per chart. */
values: number[];
/** A forecast rendered identically to actuals is a lie. */
projectedFrom?: number;
/** Uncertainty around the projected tail. Drawn behind it, never over it. */
band?: ForecastBand;
/** Base / upside / downside, as separate dashed lines from the split. */
scenarios?: ForecastScenario[];
};
/**
* The plan the actuals are measured against.
*
* A reference, not a series: it is drawn in the neutral tertiary tone with a
* dotted stroke and takes no viz colour, because a budget is the line the
* business agreed to rather than another thing that happened. It never appears
* in the legend's isolate control for the same reason — you isolate a measure,
* you do not isolate the yardstick.
*/
export type ChartPlan = {
/** Minor units, aligned to the series' labels. `null` where no plan exists. */
values: (number | null)[];
/** "FY26 Plan v3", "Board case". Named, because an unnamed budget is a rumour. */
label: string;
/**
* Which way is good, for the variance readout only.
*
* Required, and deliberately so. A chart does not know its own polarity —
* rule 13 — and the difference between revenue under plan and cost under
* plan is the whole of FP&A. Passing `neutral` states that the variance has
* no favourable direction, which is a real answer; guessing one is not.
*/
polarity: Polarity;
};
export interface MoneyChartProps {
className?: string;
currency: string;
height?: number;
labels: string[];
/** The budget or plan overlay, and the basis for the variance readout. */
plan?: ChartPlan;
series: MoneySeries[];
title?: string;
variant?: "line" | "area";
}
const PAD = { bottom: 28, left: 64, right: 16, top: 16 };
/**
* The move from the previous point, as a percentage.
*
* Returned undefined at the first point and wherever the previous value is
* zero, rather than rendered as 0% or ∞: a change from nothing has no
* percentage, and inventing one is the same class of error as rendering a
* null balance as `$0.00`. Rule 10 — the five distinct renders — applies to a
* delta exactly as it applies to a figure.
*/
function changeFrom(values: number[], index: number): string | undefined {
const previous = values[index - 1];
const current = values[index];
if (previous === undefined || current === undefined || previous === 0) return undefined;
const pct = ((current - previous) / Math.abs(previous)) * 100;
if (!Number.isFinite(pct)) return undefined;
const arrow = pct > 0 ? "↑" : pct < 0 ? "↓" : "→";
/* U+2212, never a hyphen — rule 11. */
const sign = pct < 0 ? "\u2212" : "";
return `${arrow} ${sign}${Math.abs(pct).toFixed(1)}%`;
}
/**
* Dash patterns for scenario lines.
*
* Cycled by position rather than hashed, because scenarios are given in a
* meaningful order — base first — and a stable hash would hand the base case
* whichever pattern its name happened to land on. Three distinct rhythms, not
* three hues: the whole point of a pattern here is that it survives greyscale
* and the two most common forms of colour blindness, which a base/upside/
* downside triple in red-amber-green does not.
*/
const SCENARIO_DASH = ["5 4", "1 4", "9 3 2 3"];
/**
* Everything a generic chart library gets wrong about money:
* a money-formatted axis, an emphasized zero baseline, hatched negative
* regions, horizontal gridlines only, and a dashed projection.
*
* Draws once on mount. Instant on data change (Money Law 2).
*/
export function MoneyChart({
labels,
series,
currency,
variant = "area",
height = 260,
className,
title,
plan,
}: MoneyChartProps) {
const [isolated, setIsolated] = React.useState<string | null>(null);
const [hover, setHover] = React.useState<number | null>(null);
const reduced = useReducedMotion();
/* Draws when scrolled to, not when mounted — see `useInViewOnce`. Once. */
const [viewRef, inView] = useInViewOnce<SVGSVGElement>();
const uid = React.useId().replace(/:/g, "");
const width = 720;
const visible = isolated ? series.filter((s) => s.key === isolated) : series;
/* The band, the scenarios and the plan are all inside the domain, not just
the base values. A cone or a budget line clipped by the axis is a chart
that has quietly cropped the bad case out of the picture. */
const all = [
...visible.flatMap((s) => s.values),
...visible.flatMap((s) => [...(s.band?.low ?? []), ...(s.band?.high ?? [])]),
...visible.flatMap((s) => (s.scenarios ?? []).flatMap((sc) => sc.values)),
...(plan?.values ?? []),
].filter((v): v is number => v != null);
const { min, max } = domainFor(all.length ? all : [0]);
const ticks = niceTicks(min, max);
const domainMin = Math.min(min, ticks[0]);
const domainMax = Math.max(max, ticks[ticks.length - 1]);
const plotW = width - PAD.left - PAD.right;
const plotH = height - PAD.top - PAD.bottom;
const x = (i: number) => PAD.left + (labels.length === 1 ? plotW / 2 : (i / (labels.length - 1)) * plotW);
const y = (v: number) => PAD.top + plotH - ((v - domainMin) / (domainMax - domainMin || 1)) * plotH;
const zeroY = y(0);
const crossesZero = domainMin < 0 && domainMax > 0;
/* The forecast boundary, as a single index across the chart. Where two
series disagree about where actuals stop — which happens when one measure
closes before another — the earliest wins, because the boundary marks the
first period the chart is no longer reporting. */
const splits = series.map((s) => s.projectedFrom).filter((v): v is number => v !== undefined);
const anyProjected = splits.length > 0;
const boundary = anyProjected ? Math.min(...splits) : labels.length;
const projectedAt = (i: number) => anyProjected && i >= boundary;
/* A band and a scenario set belong to one measure. Two cones on one chart is
a picture nobody can read the overlap of, so the first series carrying
each wins and the rest are ignored rather than stacked. */
/* The measure a plan variance is taken against. First declared wins: the
chart is drawn in the order the caller gave, and the thing they put first
is the thing the chart is about. */
const primary = series[0];
const bandSeries = series.find((s) => s.band);
const scenarioSeries = series.find((s) => s.scenarios && s.scenarios.length > 0);
return (
<DataTableToggle
className={className}
table={
/* The table equivalent carries everything the picture does, including
the parts that are only encoded in the drawing: which periods are
projected, how wide the band was, and what the variance to plan
came to. A table that lists the base values alone is not the chart
in another form — it is a different, more confident chart. */
<table className="w-full text-body">
<caption className="sr-only">{title ?? "Chart data"}</caption>
<thead>
<tr className="border-b border-border-subtle">
<th className="py-2 text-left text-label text-text-secondary" scope="col">
Period
</th>
{anyProjected ? (
<th className="py-2 text-left text-label text-text-secondary" scope="col">
Basis
</th>
) : null}
{series.map((s) => (
<th className="py-2 text-right text-label text-text-secondary" key={s.key} scope="col">
{s.label}
</th>
))}
{bandSeries ? (
<th className="py-2 text-right text-label text-text-secondary" scope="col">
{bandSeries.band?.label}
</th>
) : null}
{scenarioSeries?.scenarios?.map((sc) => (
<th className="py-2 text-right text-label text-text-secondary" key={sc.key} scope="col">
{sc.label}
</th>
))}
{plan ? (
<>
<th className="py-2 text-right text-label text-text-secondary" scope="col">
{plan.label}
</th>
<th className="py-2 text-right text-label text-text-secondary" scope="col">
Variance
</th>
</>
) : null}
</tr>
</thead>
<tbody>
{labels.map((label, i) => {
const band = bandSeries?.band;
const low = band?.low[i];
const high = band?.high[i];
return (
<tr className="border-b border-border-subtle" key={label}>
<th className="py-2 text-left font-normal" scope="row">
{label}
</th>
{anyProjected ? (
<td className="py-2 text-left text-caption text-text-secondary">
{projectedAt(i) ? "Forecast" : "Actual"}
</td>
) : null}
{series.map((s) => (
<td className="py-2 text-right" key={s.key}>
<Money accounting value={{ amount: s.values[i], currency }} />
</td>
))}
{bandSeries ? (
<td className="numeric py-2 text-right text-text-secondary">
{low == null || high == null ? (
<span className="text-text-tertiary">—</span>
) : (
`${formatMoney({ amount: low, currency }, { accounting: true })} to ${formatMoney(
{ amount: high, currency },
{ accounting: true },
)}`
)}
</td>
) : null}
{scenarioSeries?.scenarios?.map((sc) => {
const v = sc.values[i];
return (
<td className="py-2 text-right" key={sc.key}>
{v == null ? (
<span className="text-text-tertiary">—</span>
) : (
<Money accounting value={{ amount: v, currency }} />
)}
</td>
);
})}
{plan ? (
<>
<td className="py-2 text-right">
{plan.values[i] == null ? (
<span className="text-text-tertiary">—</span>
) : (
<Money accounting value={{ amount: plan.values[i], currency }} />
)}
</td>
<td className="numeric py-2 text-right text-text-secondary">
{varianceAgainstPlan(primary?.values[i], plan.values[i], plan.polarity, currency) ?? (
<span className="text-text-tertiary">—</span>
)}
</td>
</>
) : null}
</tr>
);
})}
</tbody>
</table>
}
>
<div className="relative">
<svg
aria-label={title ?? "Money chart"}
className="w-full"
onPointerLeave={() => setHover(null)}
onPointerMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * width;
const i = Math.round(((px - PAD.left) / plotW) * (labels.length - 1));
setHover(Math.max(0, Math.min(labels.length - 1, i)));
}}
ref={viewRef}
role="img"
style={{ height }}
viewBox={`0 0 ${width} ${height}`}
>
<HatchDefs id={`hatch-${uid}`} />
{/* Horizontal gridlines only. Vertical lines on a time series are noise. */}
{ticks.map((t) => (
<g key={t}>
<line
stroke="var(--border-subtle)"
strokeWidth="1"
x1={PAD.left}
x2={width - PAD.right}
y1={y(t)}
y2={y(t)}
/>
<text
className="numeric"
fill="var(--text-tertiary)"
fontSize="12"
textAnchor="end"
x={PAD.left - 8}
y={y(t) + 4}
>
{axisLabel(t, currency)}
</text>
</g>
))}
{/* The zero baseline is the most important line when data crosses it. */}
{crossesZero ? (
<line
stroke="var(--border-strong)"
strokeWidth="1"
x1={PAD.left}
x2={width - PAD.right}
y1={zeroY}
y2={zeroY}
/>
) : null}
{/* The band goes down first, before every line, so nothing a reader
needs to read precisely is ever seen through a translucent fill.
It is bounded top and bottom by a hairline as well as being
shaded: a fill alone disappears at low contrast and on a
projector, and the edges of a forecast range are the part people
actually quote. */}
{visible.map((s) => {
const band = s.band;
if (!band || s.projectedFrom === undefined) return null;
const from = Math.max(0, s.projectedFrom - 1);
const pts: { i: number; low: number; high: number }[] = [];
for (let i = from; i < labels.length; i++) {
const low = band.low[i];
const high = band.high[i];
/* The anchor is pinned to the actual, zero-width, whether or not
the caller supplied a range there. A cone that begins at the
first projected period floats free of the line it belongs to
and reads as a separate object — and it puts a step of
uncertainty on the boundary, where there is none: the last
actual is known exactly. Uncertainty grows from a fact. */
if (i === from && (low == null || high == null)) {
const anchor = s.values[i];
if (anchor == null) continue;
pts.push({ high: anchor, i, low: anchor });
continue;
}
if (low == null || high == null) continue;
pts.push({ high, i, low });
}
if (pts.length < 2) return null;
const color = vizColorFor(s.key);
const top = pts.map((p, n) => `${n === 0 ? "M" : "L"}${x(p.i)},${y(p.high)}`).join(" ");
const bottom = [...pts]
.reverse()
.map((p) => `L${x(p.i)},${y(p.low)}`)
.join(" ");
return (
<g key={`band-${s.key}`}>
<path d={`${top} ${bottom} Z`} fill={color} opacity="0.1" />
<path
d={top}
fill="none"
opacity="0.5"
stroke={color}
strokeDasharray="2 3"
strokeWidth="1"
/>
<path
d={pts.map((p, n) => `${n === 0 ? "M" : "L"}${x(p.i)},${y(p.low)}`).join(" ")}
fill="none"
opacity="0.5"
stroke={color}
strokeDasharray="2 3"
strokeWidth="1"
/>
</g>
);
})}
{/* The boundary between what happened and what is believed. The most
important x-position on a forecast chart and, until now, the one
thing it did not draw — the dashes carried the message alone, and
a dash pattern read across a gap of six months is not a date. */}
{anyProjected && boundary > 0 && boundary < labels.length ? (
<g>
<line
stroke="var(--border-strong)"
strokeDasharray="3 3"
strokeWidth="1"
x1={x(boundary - 1)}
x2={x(boundary - 1)}
y1={PAD.top}
y2={PAD.top + plotH}
/>
<text fill="var(--text-tertiary)" fontSize="11" x={x(boundary - 1) + 6} y={PAD.top + 10}>
Forecast →
</text>
</g>
) : null}
{/* The plan, under the actuals rather than over them. Dotted and in
the neutral tone, taking no viz colour: a budget is not another
thing that happened, it is the line the business agreed to, and
giving it a series colour puts it in competition with the
measure it exists to be read against. */}
{plan ? (
<path
d={plan.values
.map((v, i) => (v == null ? null : `${x(i)},${y(v)}`))
.filter((d): d is string => d !== null)
.map((d, i) => `${i === 0 ? "M" : "L"}${d}`)
.join(" ")}
fill="none"
opacity="0.9"
stroke="var(--text-tertiary)"
strokeDasharray="2 3"
strokeWidth="1.5"
/>
) : null}
{visible.map((s, si) => {
const color = vizColorFor(s.key);
const split = s.projectedFrom ?? s.values.length;
const actual = s.values.slice(0, split);
const path = actual.map((v, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(v)}`).join(" ");
const projPath = s.values
.slice(Math.max(0, split - 1))
.map((v, i) => `${i === 0 ? "M" : "L"}${x(split - 1 + i)},${y(v)}`)
.join(" ");
const areaPath = `${path} L${x(actual.length - 1)},${zeroY} L${x(0)},${zeroY} Z`;
return (
<g key={s.key}>
{variant === "area" && si === 0 ? (
<path d={areaPath} fill={`url(#hatch-${uid}-area)`} />
) : null}
{/* Negative region, hatched, so it survives greyscale. */}
{crossesZero && variant === "area" && si === 0 ? (
<clipPath id={`below-${uid}`}>
<rect
height={Math.max(0, PAD.top + plotH - zeroY)}
width={plotW}
x={PAD.left}
y={zeroY}
/>
</clipPath>
) : null}
{crossesZero && variant === "area" && si === 0 ? (
<path
clipPath={`url(#below-${uid})`}
d={areaPath}
fill={`url(#hatch-${uid})`}
opacity="0.6"
/>
) : null}
<path
d={path}
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
style={
reduced
? undefined
: {
animation: inView
? `ledger-draw 400ms var(--ease-out) ${si * 40}ms forwards`
: undefined,
strokeDasharray: 2000,
strokeDashoffset: 2000,
}
}
/>
{s.projectedFrom !== undefined ? (
<path
d={projPath}
fill="none"
opacity="0.85"
stroke={color}
strokeDasharray={SCENARIO_DASH[0]}
strokeWidth="2"
/>
) : null}
{/* Named scenarios branch from the same anchor as the base
projection, so the fan reads as one decision point rather
than three unrelated lines. They are told apart by dash
rhythm first and colour second, and they are never ranked:
no upside styling, no ordering by outcome. */}
{(s.scenarios ?? []).map((sc, n) => {
const at = s.projectedFrom ?? s.values.length;
const d = sc.values
.map((v, i) => (i < at - 1 || v == null ? null : `${x(i)},${y(v)}`))
.filter((piece): piece is string => piece !== null)
.map((piece, i) => `${i === 0 ? "M" : "L"}${piece}`)
.join(" ");
if (!d) return null;
return (
<path
d={d}
fill="none"
key={sc.key}
opacity="0.9"
stroke={vizColorFor(sc.key)}
strokeDasharray={SCENARIO_DASH[(n + 1) % SCENARIO_DASH.length]}
strokeWidth="1.75"
/>
);
})}
{/* The dot travels to the next point rather than teleporting
to it. 100ms, which is the press band — fast enough that it
never lags the pointer, slow enough that the eye follows
the reading across the series instead of re-finding it.
A transform, so it stays on the compositor. */}
{hover !== null ? (
<circle
cx={0}
cy={0}
fill={color}
r="3.5"
style={{
transform: `translate(${x(hover)}px, ${y(s.values[hover])}px)`,
transition: reduced ? undefined : "transform var(--duration-1) var(--ease-out)",
}}
/>
) : null}
</g>
);
})}
{hover !== null ? (
<line
stroke="var(--border-strong)"
strokeWidth="1"
style={{
transform: `translateX(${x(hover)}px)`,
transition: reduced ? undefined : "transform var(--duration-1) var(--ease-out)",
}}
x1={0}
x2={0}
y1={PAD.top}
y2={PAD.top + plotH}
/>
) : null}
{labels.map((label, i) =>
i % Math.ceil(labels.length / 8) === 0 ? (
<text
className="numeric"
fill="var(--text-tertiary)"
fontSize="12"
key={label}
textAnchor="middle"
x={x(i)}
y={height - 8}
>
{label}
</text>
) : null,
)}
</svg>
{hover !== null ? (
<div className="mt-2">
{/* The change is the number the chart is drawing and never states.
A reader hovering a point wants to know what it is *and* what
it did, and reading a slope off a line is exactly the estimate
a financial interface should not ask anyone to make.
It is revealed, not counted. Money Law 1: a figure never ticks
through intermediate values, because forty of those values are
wrong and someone glancing mid-animation reads one of them. The
digits are correct in the first frame; what animates is the
readout arriving. Sign is carried by an arrow and by U+2212, so
it survives greyscale — rule 12. */}
<ChartTooltip
pinned
rows={[
...visible.map((s) => ({
change: changeFrom(s.values, hover),
color: vizColorFor(s.key),
label: projectedAt(hover) ? `${s.label} (forecast)` : s.label,
value: formatMoney({ amount: s.values[hover], currency }, { accounting: true }),
})),
/* The range, at the period being pointed at. A cone the reader
has to estimate off the y-axis is decoration; the numbers
are what gets taken into the meeting. */
...(bandSeries?.band &&
bandSeries.band.low[hover] != null &&
bandSeries.band.high[hover] != null
? [
{
label: bandSeries.band.label,
value: `${formatMoney(
{ amount: bandSeries.band.low[hover], currency },
{ accounting: true },
)} to ${formatMoney(
{ amount: bandSeries.band.high[hover], currency },
{ accounting: true },
)}`,
},
]
: []),
...(scenarioSeries?.scenarios ?? [])
.filter((sc) => sc.values[hover] != null && projectedAt(hover))
.map((sc) => ({
color: vizColorFor(sc.key),
label: sc.label,
value: formatMoney(
{ amount: sc.values[hover] as number, currency },
{ accounting: true },
),
})),
...(plan && plan.values[hover] != null
? [
{
label: plan.label,
value: formatMoney(
{ amount: plan.values[hover] as number, currency },
{ accounting: true },
),
},
/* Always against the primary series, and only while it
is on screen. A variance whose base silently changes
when the reader isolates a different line is worse
than no variance at all — the number moves and
nothing on the chart says why. */
...(visible.some((s) => s.key === primary?.key)
? [
{
label: `Variance · ${primary?.label}`,
value:
varianceAgainstPlan(
primary?.values[hover],
plan.values[hover],
plan.polarity,
currency,
) ?? "—",
},
]
: []),
]
: []),
]}
title={labels[hover]}
x={0}
y={0}
/>
</div>
) : null}
{series.length > 1 ? (
<ChartLegend
className="mt-3"
isolated={isolated}
items={series.map((s) => ({
color: vizColorFor(s.key),
key: s.key,
label: s.label,
}))}
onIsolate={setIsolated}
/>
) : null}
{/* Scenario lines are told apart by dash rhythm, so the key has to
draw the rhythm — a coloured square would name three lines the
reader still cannot tell apart in greyscale. Deliberately not part
of ChartLegend: that control isolates a *series*, and a scenario is
not one, so putting it there would offer a filter that empties the
chart. */}
{scenarioSeries?.scenarios?.length ? (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-2">
{[
{ dash: SCENARIO_DASH[0], key: scenarioSeries.key, label: `${scenarioSeries.label} (base)` },
...scenarioSeries.scenarios.map((sc, n) => ({
dash: SCENARIO_DASH[(n + 1) % SCENARIO_DASH.length],
key: sc.key,
label: sc.label,
})),
].map((item) => (
<li className="flex items-center gap-2" key={item.key}>
<svg aria-hidden="true" className="shrink-0" height="8" width="22">
<line
stroke={vizColorFor(item.key)}
strokeDasharray={item.dash}
strokeWidth="2"
x1="0"
x2="22"
y1="4"
y2="4"
/>
</svg>
<span className="text-caption text-text-secondary">{item.label}</span>
</li>
))}
</ul>
) : null}
{anyProjected || plan ? (
<p className="mt-2 text-caption text-text-tertiary">
{anyProjected ? (
<>
Dashed segments from <span className="numeric">{labels[boundary - 1] ?? labels[0]}</span> are
projected, not actual.
</>
) : null}
{bandSeries?.band ? <> Shaded range is {bandSeries.band.label}.</> : null}
{scenarioSeries?.scenarios?.length ? (
<> Scenarios are alternative assumptions, not outcomes ranked by preference.</>
) : null}
{plan ? (
<>
{" "}
Dotted line is <span className="font-medium">{plan.label}</span>; variance is actual less
plan, marked F where the difference is favourable and U where it is not.
</>
) : null}
</p>
) : null}
</div>
</DataTableToggle>
);
}
The accounting it encodes
Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.
Also in Dashboard & charts
- DashboardShellResponsive grid with optional rearrange.
- KpiRow2–4 KPI cards with aligned baselines.Spec
- MoneyBarChartGrouped/stacked, zero-baseline origin, hatched negatives.
- WaterfallChartThe P&L bridge. Essential and rare.
- CategoryBreakdownRanked bars, not a pie.
- CashCalendarDaily heatmap of cash movement.
- CandlestickChartFor FX/crypto pairs.
- ChartLegendInteractive, keyboard-navigable.Spec
- ChartTooltipTouch-persistent readout.Spec
- DataTableToggleEvery chart's accessible table equivalent.Spec
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Dashboard & charts — the family specThe four-layer specification this component was built against, before it was written.Doc
- Financial charts for React: waterfalls and sparklinesThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- ExceptionA break requiring investigationTerm
Elsewhere in the system
Where MoneyChart sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
- MoneyChart, runningThe charts showcase — live, with theme, density, greyscale and reduced-motion switches.Live
- NPV, IRR & XIRRIRR by bisection, so a sign-change-heavy series does not converge on a confident wrong answer.Tool
- Dashboard & chartsWe wrap a charting engine (Recharts, or visx where more control is needed). We do not rebuild one. What we add is everything a chart library doesn't know about money. Specs for 11 React components: DashboardShell, KpiRow, MoneyChart, MoneyBarChart and 7 more.Doc
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Dashboard & charts changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.