Skip to content

MoneyChart

Line/area with a money axis and negative-aware baseline.
Implemented
Status
Implemented
Family
7 of 13
Registry
@lopifi/money-chart
npx shadcn@latest add @lopifi/theme @lopifi/money-chart

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

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-strong line, distinct from the --border-subtle gridlines. When data crosses zero, that line is the most important thing in the chart.
  • Negative regions are visually distinct — a fill below zero at --negative with 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. band takes a low and a high and a required label — 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. scenarios draws 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. plan is 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.polarity is 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.

components/ledger/money-chart.tsx
"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

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

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.

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.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.