{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "dashboard-charts",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Line/area with a money axis and negative-aware baseline.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useInViewOnce } from \"@/hooks/ledger/use-in-view\";\nimport { useReducedMotion } from \"@/hooks/ledger/use-media-query\";\nimport { axisLabel, domainFor, niceTicks, vizColorFor } from \"@/lib/ledger/chart-utils\";\nimport { varianceAgainstPlan } from \"@/lib/ledger/forecast\";\nimport { formatMoney } from \"@/lib/ledger/format-money\";\nimport type { Polarity } from \"@/lib/ledger/money.types\";\nimport { ChartLegend, ChartTooltip, DataTableToggle, HatchDefs } from \"@/components/ledger/chart-primitives\";\nimport { Money } from \"@/components/ledger/money\";\n\n/**\n * The uncertainty around a projection.\n *\n * `label` is not optional decoration. A shaded band with no stated interval is\n * the chart asserting a precision it has not defined — P10–P90 and ±1σ are\n * different claims, and a reader who cannot tell which one they are looking at\n * will assume whichever suits them.\n */\nexport type ForecastBand = {\n  /** Minor units, aligned to `values`. Entries before the split are ignored. */\n  low: (number | null)[];\n  high: (number | null)[];\n  /** The interval this band is: \"P10–P90\", \"±1σ\", \"Downside to upside\". */\n  label: string;\n};\n\n/**\n * A named alternative to the base projection, branching at the same split.\n *\n * Carried on the series rather than passed as more series because a scenario\n * is not a second measure — it is the same measure under a different set of\n * assumptions, and it shares the actuals to the left of the boundary.\n */\nexport type ForecastScenario = {\n  key: string;\n  label: string;\n  /** Only the projected tail is read; the actual portion is the series' own. */\n  values: (number | null)[];\n};\n\nexport type MoneySeries = {\n  key: string;\n  label: string;\n  /** Minor units. Multi-currency series is rejected at the type level: one currency per chart. */\n  values: number[];\n  /** A forecast rendered identically to actuals is a lie. */\n  projectedFrom?: number;\n  /** Uncertainty around the projected tail. Drawn behind it, never over it. */\n  band?: ForecastBand;\n  /** Base / upside / downside, as separate dashed lines from the split. */\n  scenarios?: ForecastScenario[];\n};\n\n/**\n * The plan the actuals are measured against.\n *\n * A reference, not a series: it is drawn in the neutral tertiary tone with a\n * dotted stroke and takes no viz colour, because a budget is the line the\n * business agreed to rather than another thing that happened. It never appears\n * in the legend's isolate control for the same reason — you isolate a measure,\n * you do not isolate the yardstick.\n */\nexport type ChartPlan = {\n  /** Minor units, aligned to the series' labels. `null` where no plan exists. */\n  values: (number | null)[];\n  /** \"FY26 Plan v3\", \"Board case\". Named, because an unnamed budget is a rumour. */\n  label: string;\n  /**\n   * Which way is good, for the variance readout only.\n   *\n   * Required, and deliberately so. A chart does not know its own polarity —\n   * rule 13 — and the difference between revenue under plan and cost under\n   * plan is the whole of FP&A. Passing `neutral` states that the variance has\n   * no favourable direction, which is a real answer; guessing one is not.\n   */\n  polarity: Polarity;\n};\n\nexport interface MoneyChartProps {\n  className?: string;\n  currency: string;\n  height?: number;\n  labels: string[];\n  /** The budget or plan overlay, and the basis for the variance readout. */\n  plan?: ChartPlan;\n  series: MoneySeries[];\n  title?: string;\n  variant?: \"line\" | \"area\";\n}\n\nconst PAD = { bottom: 28, left: 64, right: 16, top: 16 };\n\n/**\n * The move from the previous point, as a percentage.\n *\n * Returned undefined at the first point and wherever the previous value is\n * zero, rather than rendered as 0% or ∞: a change from nothing has no\n * percentage, and inventing one is the same class of error as rendering a\n * null balance as `$0.00`. Rule 10 — the five distinct renders — applies to a\n * delta exactly as it applies to a figure.\n */\nfunction changeFrom(values: number[], index: number): string | undefined {\n  const previous = values[index - 1];\n  const current = values[index];\n  if (previous === undefined || current === undefined || previous === 0) return undefined;\n  const pct = ((current - previous) / Math.abs(previous)) * 100;\n  if (!Number.isFinite(pct)) return undefined;\n  const arrow = pct > 0 ? \"↑\" : pct < 0 ? \"↓\" : \"→\";\n  /* U+2212, never a hyphen — rule 11. */\n  const sign = pct < 0 ? \"\\u2212\" : \"\";\n  return `${arrow} ${sign}${Math.abs(pct).toFixed(1)}%`;\n}\n\n/**\n * Dash patterns for scenario lines.\n *\n * Cycled by position rather than hashed, because scenarios are given in a\n * meaningful order — base first — and a stable hash would hand the base case\n * whichever pattern its name happened to land on. Three distinct rhythms, not\n * three hues: the whole point of a pattern here is that it survives greyscale\n * and the two most common forms of colour blindness, which a base/upside/\n * downside triple in red-amber-green does not.\n */\nconst SCENARIO_DASH = [\"5 4\", \"1 4\", \"9 3 2 3\"];\n\n/**\n * Everything a generic chart library gets wrong about money:\n * a money-formatted axis, an emphasized zero baseline, hatched negative\n * regions, horizontal gridlines only, and a dashed projection.\n *\n * Draws once on mount. Instant on data change (Money Law 2).\n */\nexport function MoneyChart({\n  labels,\n  series,\n  currency,\n  variant = \"area\",\n  height = 260,\n  className,\n  title,\n  plan,\n}: MoneyChartProps) {\n  const [isolated, setIsolated] = React.useState<string | null>(null);\n  const [hover, setHover] = React.useState<number | null>(null);\n  const reduced = useReducedMotion();\n  /* Draws when scrolled to, not when mounted — see `useInViewOnce`. Once. */\n  const [viewRef, inView] = useInViewOnce<SVGSVGElement>();\n  const uid = React.useId().replace(/:/g, \"\");\n  const width = 720;\n\n  const visible = isolated ? series.filter((s) => s.key === isolated) : series;\n  /* The band, the scenarios and the plan are all inside the domain, not just\n     the base values. A cone or a budget line clipped by the axis is a chart\n     that has quietly cropped the bad case out of the picture. */\n  const all = [\n    ...visible.flatMap((s) => s.values),\n    ...visible.flatMap((s) => [...(s.band?.low ?? []), ...(s.band?.high ?? [])]),\n    ...visible.flatMap((s) => (s.scenarios ?? []).flatMap((sc) => sc.values)),\n    ...(plan?.values ?? []),\n  ].filter((v): v is number => v != null);\n  const { min, max } = domainFor(all.length ? all : [0]);\n  const ticks = niceTicks(min, max);\n  const domainMin = Math.min(min, ticks[0]);\n  const domainMax = Math.max(max, ticks[ticks.length - 1]);\n\n  const plotW = width - PAD.left - PAD.right;\n  const plotH = height - PAD.top - PAD.bottom;\n  const x = (i: number) => PAD.left + (labels.length === 1 ? plotW / 2 : (i / (labels.length - 1)) * plotW);\n  const y = (v: number) => PAD.top + plotH - ((v - domainMin) / (domainMax - domainMin || 1)) * plotH;\n  const zeroY = y(0);\n  const crossesZero = domainMin < 0 && domainMax > 0;\n\n  /* The forecast boundary, as a single index across the chart. Where two\n     series disagree about where actuals stop — which happens when one measure\n     closes before another — the earliest wins, because the boundary marks the\n     first period the chart is no longer reporting. */\n  const splits = series.map((s) => s.projectedFrom).filter((v): v is number => v !== undefined);\n  const anyProjected = splits.length > 0;\n  const boundary = anyProjected ? Math.min(...splits) : labels.length;\n  const projectedAt = (i: number) => anyProjected && i >= boundary;\n\n  /* A band and a scenario set belong to one measure. Two cones on one chart is\n     a picture nobody can read the overlap of, so the first series carrying\n     each wins and the rest are ignored rather than stacked. */\n  /* The measure a plan variance is taken against. First declared wins: the\n     chart is drawn in the order the caller gave, and the thing they put first\n     is the thing the chart is about. */\n  const primary = series[0];\n  const bandSeries = series.find((s) => s.band);\n  const scenarioSeries = series.find((s) => s.scenarios && s.scenarios.length > 0);\n\n  return (\n    <DataTableToggle\n      className={className}\n      table={\n        /* The table equivalent carries everything the picture does, including\n           the parts that are only encoded in the drawing: which periods are\n           projected, how wide the band was, and what the variance to plan\n           came to. A table that lists the base values alone is not the chart\n           in another form — it is a different, more confident chart. */\n        <table className=\"w-full text-body\">\n          <caption className=\"sr-only\">{title ?? \"Chart data\"}</caption>\n          <thead>\n            <tr className=\"border-b border-border-subtle\">\n              <th className=\"py-2 text-left text-label text-text-secondary\" scope=\"col\">\n                Period\n              </th>\n              {anyProjected ? (\n                <th className=\"py-2 text-left text-label text-text-secondary\" scope=\"col\">\n                  Basis\n                </th>\n              ) : null}\n              {series.map((s) => (\n                <th className=\"py-2 text-right text-label text-text-secondary\" key={s.key} scope=\"col\">\n                  {s.label}\n                </th>\n              ))}\n              {bandSeries ? (\n                <th className=\"py-2 text-right text-label text-text-secondary\" scope=\"col\">\n                  {bandSeries.band?.label}\n                </th>\n              ) : null}\n              {scenarioSeries?.scenarios?.map((sc) => (\n                <th className=\"py-2 text-right text-label text-text-secondary\" key={sc.key} scope=\"col\">\n                  {sc.label}\n                </th>\n              ))}\n              {plan ? (\n                <>\n                  <th className=\"py-2 text-right text-label text-text-secondary\" scope=\"col\">\n                    {plan.label}\n                  </th>\n                  <th className=\"py-2 text-right text-label text-text-secondary\" scope=\"col\">\n                    Variance\n                  </th>\n                </>\n              ) : null}\n            </tr>\n          </thead>\n          <tbody>\n            {labels.map((label, i) => {\n              const band = bandSeries?.band;\n              const low = band?.low[i];\n              const high = band?.high[i];\n              return (\n                <tr className=\"border-b border-border-subtle\" key={label}>\n                  <th className=\"py-2 text-left font-normal\" scope=\"row\">\n                    {label}\n                  </th>\n                  {anyProjected ? (\n                    <td className=\"py-2 text-left text-caption text-text-secondary\">\n                      {projectedAt(i) ? \"Forecast\" : \"Actual\"}\n                    </td>\n                  ) : null}\n                  {series.map((s) => (\n                    <td className=\"py-2 text-right\" key={s.key}>\n                      <Money accounting value={{ amount: s.values[i], currency }} />\n                    </td>\n                  ))}\n                  {bandSeries ? (\n                    <td className=\"numeric py-2 text-right text-text-secondary\">\n                      {low == null || high == null ? (\n                        <span className=\"text-text-tertiary\">—</span>\n                      ) : (\n                        `${formatMoney({ amount: low, currency }, { accounting: true })} to ${formatMoney(\n                          { amount: high, currency },\n                          { accounting: true },\n                        )}`\n                      )}\n                    </td>\n                  ) : null}\n                  {scenarioSeries?.scenarios?.map((sc) => {\n                    const v = sc.values[i];\n                    return (\n                      <td className=\"py-2 text-right\" key={sc.key}>\n                        {v == null ? (\n                          <span className=\"text-text-tertiary\">—</span>\n                        ) : (\n                          <Money accounting value={{ amount: v, currency }} />\n                        )}\n                      </td>\n                    );\n                  })}\n                  {plan ? (\n                    <>\n                      <td className=\"py-2 text-right\">\n                        {plan.values[i] == null ? (\n                          <span className=\"text-text-tertiary\">—</span>\n                        ) : (\n                          <Money accounting value={{ amount: plan.values[i], currency }} />\n                        )}\n                      </td>\n                      <td className=\"numeric py-2 text-right text-text-secondary\">\n                        {varianceAgainstPlan(primary?.values[i], plan.values[i], plan.polarity, currency) ?? (\n                          <span className=\"text-text-tertiary\">—</span>\n                        )}\n                      </td>\n                    </>\n                  ) : null}\n                </tr>\n              );\n            })}\n          </tbody>\n        </table>\n      }\n    >\n      <div className=\"relative\">\n        <svg\n          aria-label={title ?? \"Money chart\"}\n          className=\"w-full\"\n          onPointerLeave={() => setHover(null)}\n          onPointerMove={(e) => {\n            const rect = e.currentTarget.getBoundingClientRect();\n            const px = ((e.clientX - rect.left) / rect.width) * width;\n            const i = Math.round(((px - PAD.left) / plotW) * (labels.length - 1));\n            setHover(Math.max(0, Math.min(labels.length - 1, i)));\n          }}\n          ref={viewRef}\n          role=\"img\"\n          style={{ height }}\n          viewBox={`0 0 ${width} ${height}`}\n        >\n          <HatchDefs id={`hatch-${uid}`} />\n\n          {/* Horizontal gridlines only. Vertical lines on a time series are noise. */}\n          {ticks.map((t) => (\n            <g key={t}>\n              <line\n                stroke=\"var(--border-subtle)\"\n                strokeWidth=\"1\"\n                x1={PAD.left}\n                x2={width - PAD.right}\n                y1={y(t)}\n                y2={y(t)}\n              />\n              <text\n                className=\"numeric\"\n                fill=\"var(--text-tertiary)\"\n                fontSize=\"12\"\n                textAnchor=\"end\"\n                x={PAD.left - 8}\n                y={y(t) + 4}\n              >\n                {axisLabel(t, currency)}\n              </text>\n            </g>\n          ))}\n\n          {/* The zero baseline is the most important line when data crosses it. */}\n          {crossesZero ? (\n            <line\n              stroke=\"var(--border-strong)\"\n              strokeWidth=\"1\"\n              x1={PAD.left}\n              x2={width - PAD.right}\n              y1={zeroY}\n              y2={zeroY}\n            />\n          ) : null}\n\n          {/* The band goes down first, before every line, so nothing a reader\n              needs to read precisely is ever seen through a translucent fill.\n              It is bounded top and bottom by a hairline as well as being\n              shaded: a fill alone disappears at low contrast and on a\n              projector, and the edges of a forecast range are the part people\n              actually quote. */}\n          {visible.map((s) => {\n            const band = s.band;\n            if (!band || s.projectedFrom === undefined) return null;\n            const from = Math.max(0, s.projectedFrom - 1);\n            const pts: { i: number; low: number; high: number }[] = [];\n            for (let i = from; i < labels.length; i++) {\n              const low = band.low[i];\n              const high = band.high[i];\n              /* The anchor is pinned to the actual, zero-width, whether or not\n                 the caller supplied a range there. A cone that begins at the\n                 first projected period floats free of the line it belongs to\n                 and reads as a separate object — and it puts a step of\n                 uncertainty on the boundary, where there is none: the last\n                 actual is known exactly. Uncertainty grows from a fact. */\n              if (i === from && (low == null || high == null)) {\n                const anchor = s.values[i];\n                if (anchor == null) continue;\n                pts.push({ high: anchor, i, low: anchor });\n                continue;\n              }\n              if (low == null || high == null) continue;\n              pts.push({ high, i, low });\n            }\n            if (pts.length < 2) return null;\n            const color = vizColorFor(s.key);\n            const top = pts.map((p, n) => `${n === 0 ? \"M\" : \"L\"}${x(p.i)},${y(p.high)}`).join(\" \");\n            const bottom = [...pts]\n              .reverse()\n              .map((p) => `L${x(p.i)},${y(p.low)}`)\n              .join(\" \");\n            return (\n              <g key={`band-${s.key}`}>\n                <path d={`${top} ${bottom} Z`} fill={color} opacity=\"0.1\" />\n                <path\n                  d={top}\n                  fill=\"none\"\n                  opacity=\"0.5\"\n                  stroke={color}\n                  strokeDasharray=\"2 3\"\n                  strokeWidth=\"1\"\n                />\n                <path\n                  d={pts.map((p, n) => `${n === 0 ? \"M\" : \"L\"}${x(p.i)},${y(p.low)}`).join(\" \")}\n                  fill=\"none\"\n                  opacity=\"0.5\"\n                  stroke={color}\n                  strokeDasharray=\"2 3\"\n                  strokeWidth=\"1\"\n                />\n              </g>\n            );\n          })}\n\n          {/* The boundary between what happened and what is believed. The most\n              important x-position on a forecast chart and, until now, the one\n              thing it did not draw — the dashes carried the message alone, and\n              a dash pattern read across a gap of six months is not a date. */}\n          {anyProjected && boundary > 0 && boundary < labels.length ? (\n            <g>\n              <line\n                stroke=\"var(--border-strong)\"\n                strokeDasharray=\"3 3\"\n                strokeWidth=\"1\"\n                x1={x(boundary - 1)}\n                x2={x(boundary - 1)}\n                y1={PAD.top}\n                y2={PAD.top + plotH}\n              />\n              <text fill=\"var(--text-tertiary)\" fontSize=\"11\" x={x(boundary - 1) + 6} y={PAD.top + 10}>\n                Forecast →\n              </text>\n            </g>\n          ) : null}\n\n          {/* The plan, under the actuals rather than over them. Dotted and in\n              the neutral tone, taking no viz colour: a budget is not another\n              thing that happened, it is the line the business agreed to, and\n              giving it a series colour puts it in competition with the\n              measure it exists to be read against. */}\n          {plan ? (\n            <path\n              d={plan.values\n                .map((v, i) => (v == null ? null : `${x(i)},${y(v)}`))\n                .filter((d): d is string => d !== null)\n                .map((d, i) => `${i === 0 ? \"M\" : \"L\"}${d}`)\n                .join(\" \")}\n              fill=\"none\"\n              opacity=\"0.9\"\n              stroke=\"var(--text-tertiary)\"\n              strokeDasharray=\"2 3\"\n              strokeWidth=\"1.5\"\n            />\n          ) : null}\n\n          {visible.map((s, si) => {\n            const color = vizColorFor(s.key);\n            const split = s.projectedFrom ?? s.values.length;\n            const actual = s.values.slice(0, split);\n            const path = actual.map((v, i) => `${i === 0 ? \"M\" : \"L\"}${x(i)},${y(v)}`).join(\" \");\n            const projPath = s.values\n              .slice(Math.max(0, split - 1))\n              .map((v, i) => `${i === 0 ? \"M\" : \"L\"}${x(split - 1 + i)},${y(v)}`)\n              .join(\" \");\n            const areaPath = `${path} L${x(actual.length - 1)},${zeroY} L${x(0)},${zeroY} Z`;\n\n            return (\n              <g key={s.key}>\n                {variant === \"area\" && si === 0 ? (\n                  <path d={areaPath} fill={`url(#hatch-${uid}-area)`} />\n                ) : null}\n                {/* Negative region, hatched, so it survives greyscale. */}\n                {crossesZero && variant === \"area\" && si === 0 ? (\n                  <clipPath id={`below-${uid}`}>\n                    <rect\n                      height={Math.max(0, PAD.top + plotH - zeroY)}\n                      width={plotW}\n                      x={PAD.left}\n                      y={zeroY}\n                    />\n                  </clipPath>\n                ) : null}\n                {crossesZero && variant === \"area\" && si === 0 ? (\n                  <path\n                    clipPath={`url(#below-${uid})`}\n                    d={areaPath}\n                    fill={`url(#hatch-${uid})`}\n                    opacity=\"0.6\"\n                  />\n                ) : null}\n                <path\n                  d={path}\n                  fill=\"none\"\n                  stroke={color}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"2\"\n                  style={\n                    reduced\n                      ? undefined\n                      : {\n                          animation: inView\n                            ? `ledger-draw 400ms var(--ease-out) ${si * 40}ms forwards`\n                            : undefined,\n                          strokeDasharray: 2000,\n                          strokeDashoffset: 2000,\n                        }\n                  }\n                />\n                {s.projectedFrom !== undefined ? (\n                  <path\n                    d={projPath}\n                    fill=\"none\"\n                    opacity=\"0.85\"\n                    stroke={color}\n                    strokeDasharray={SCENARIO_DASH[0]}\n                    strokeWidth=\"2\"\n                  />\n                ) : null}\n                {/* Named scenarios branch from the same anchor as the base\n                    projection, so the fan reads as one decision point rather\n                    than three unrelated lines. They are told apart by dash\n                    rhythm first and colour second, and they are never ranked:\n                    no upside styling, no ordering by outcome. */}\n                {(s.scenarios ?? []).map((sc, n) => {\n                  const at = s.projectedFrom ?? s.values.length;\n                  const d = sc.values\n                    .map((v, i) => (i < at - 1 || v == null ? null : `${x(i)},${y(v)}`))\n                    .filter((piece): piece is string => piece !== null)\n                    .map((piece, i) => `${i === 0 ? \"M\" : \"L\"}${piece}`)\n                    .join(\" \");\n                  if (!d) return null;\n                  return (\n                    <path\n                      d={d}\n                      fill=\"none\"\n                      key={sc.key}\n                      opacity=\"0.9\"\n                      stroke={vizColorFor(sc.key)}\n                      strokeDasharray={SCENARIO_DASH[(n + 1) % SCENARIO_DASH.length]}\n                      strokeWidth=\"1.75\"\n                    />\n                  );\n                })}\n                {/* The dot travels to the next point rather than teleporting\n                    to it. 100ms, which is the press band — fast enough that it\n                    never lags the pointer, slow enough that the eye follows\n                    the reading across the series instead of re-finding it.\n                    A transform, so it stays on the compositor. */}\n                {hover !== null ? (\n                  <circle\n                    cx={0}\n                    cy={0}\n                    fill={color}\n                    r=\"3.5\"\n                    style={{\n                      transform: `translate(${x(hover)}px, ${y(s.values[hover])}px)`,\n                      transition: reduced ? undefined : \"transform var(--duration-1) var(--ease-out)\",\n                    }}\n                  />\n                ) : null}\n              </g>\n            );\n          })}\n\n          {hover !== null ? (\n            <line\n              stroke=\"var(--border-strong)\"\n              strokeWidth=\"1\"\n              style={{\n                transform: `translateX(${x(hover)}px)`,\n                transition: reduced ? undefined : \"transform var(--duration-1) var(--ease-out)\",\n              }}\n              x1={0}\n              x2={0}\n              y1={PAD.top}\n              y2={PAD.top + plotH}\n            />\n          ) : null}\n\n          {labels.map((label, i) =>\n            i % Math.ceil(labels.length / 8) === 0 ? (\n              <text\n                className=\"numeric\"\n                fill=\"var(--text-tertiary)\"\n                fontSize=\"12\"\n                key={label}\n                textAnchor=\"middle\"\n                x={x(i)}\n                y={height - 8}\n              >\n                {label}\n              </text>\n            ) : null,\n          )}\n        </svg>\n\n        {hover !== null ? (\n          <div className=\"mt-2\">\n            {/* The change is the number the chart is drawing and never states.\n                A reader hovering a point wants to know what it is *and* what\n                it did, and reading a slope off a line is exactly the estimate\n                a financial interface should not ask anyone to make.\n\n                It is revealed, not counted. Money Law 1: a figure never ticks\n                through intermediate values, because forty of those values are\n                wrong and someone glancing mid-animation reads one of them. The\n                digits are correct in the first frame; what animates is the\n                readout arriving. Sign is carried by an arrow and by U+2212, so\n                it survives greyscale — rule 12. */}\n            <ChartTooltip\n              pinned\n              rows={[\n                ...visible.map((s) => ({\n                  change: changeFrom(s.values, hover),\n                  color: vizColorFor(s.key),\n                  label: projectedAt(hover) ? `${s.label} (forecast)` : s.label,\n                  value: formatMoney({ amount: s.values[hover], currency }, { accounting: true }),\n                })),\n                /* The range, at the period being pointed at. A cone the reader\n                   has to estimate off the y-axis is decoration; the numbers\n                   are what gets taken into the meeting. */\n                ...(bandSeries?.band &&\n                bandSeries.band.low[hover] != null &&\n                bandSeries.band.high[hover] != null\n                  ? [\n                      {\n                        label: bandSeries.band.label,\n                        value: `${formatMoney(\n                          { amount: bandSeries.band.low[hover], currency },\n                          { accounting: true },\n                        )} to ${formatMoney(\n                          { amount: bandSeries.band.high[hover], currency },\n                          { accounting: true },\n                        )}`,\n                      },\n                    ]\n                  : []),\n                ...(scenarioSeries?.scenarios ?? [])\n                  .filter((sc) => sc.values[hover] != null && projectedAt(hover))\n                  .map((sc) => ({\n                    color: vizColorFor(sc.key),\n                    label: sc.label,\n                    value: formatMoney(\n                      { amount: sc.values[hover] as number, currency },\n                      { accounting: true },\n                    ),\n                  })),\n                ...(plan && plan.values[hover] != null\n                  ? [\n                      {\n                        label: plan.label,\n                        value: formatMoney(\n                          { amount: plan.values[hover] as number, currency },\n                          { accounting: true },\n                        ),\n                      },\n                      /* Always against the primary series, and only while it\n                         is on screen. A variance whose base silently changes\n                         when the reader isolates a different line is worse\n                         than no variance at all — the number moves and\n                         nothing on the chart says why. */\n                      ...(visible.some((s) => s.key === primary?.key)\n                        ? [\n                            {\n                              label: `Variance · ${primary?.label}`,\n                              value:\n                                varianceAgainstPlan(\n                                  primary?.values[hover],\n                                  plan.values[hover],\n                                  plan.polarity,\n                                  currency,\n                                ) ?? \"—\",\n                            },\n                          ]\n                        : []),\n                    ]\n                  : []),\n              ]}\n              title={labels[hover]}\n              x={0}\n              y={0}\n            />\n          </div>\n        ) : null}\n\n        {series.length > 1 ? (\n          <ChartLegend\n            className=\"mt-3\"\n            isolated={isolated}\n            items={series.map((s) => ({\n              color: vizColorFor(s.key),\n              key: s.key,\n              label: s.label,\n            }))}\n            onIsolate={setIsolated}\n          />\n        ) : null}\n\n        {/* Scenario lines are told apart by dash rhythm, so the key has to\n            draw the rhythm — a coloured square would name three lines the\n            reader still cannot tell apart in greyscale. Deliberately not part\n            of ChartLegend: that control isolates a *series*, and a scenario is\n            not one, so putting it there would offer a filter that empties the\n            chart. */}\n        {scenarioSeries?.scenarios?.length ? (\n          <ul className=\"mt-3 flex flex-wrap gap-x-4 gap-y-2\">\n            {[\n              { dash: SCENARIO_DASH[0], key: scenarioSeries.key, label: `${scenarioSeries.label} (base)` },\n              ...scenarioSeries.scenarios.map((sc, n) => ({\n                dash: SCENARIO_DASH[(n + 1) % SCENARIO_DASH.length],\n                key: sc.key,\n                label: sc.label,\n              })),\n            ].map((item) => (\n              <li className=\"flex items-center gap-2\" key={item.key}>\n                <svg aria-hidden=\"true\" className=\"shrink-0\" height=\"8\" width=\"22\">\n                  <line\n                    stroke={vizColorFor(item.key)}\n                    strokeDasharray={item.dash}\n                    strokeWidth=\"2\"\n                    x1=\"0\"\n                    x2=\"22\"\n                    y1=\"4\"\n                    y2=\"4\"\n                  />\n                </svg>\n                <span className=\"text-caption text-text-secondary\">{item.label}</span>\n              </li>\n            ))}\n          </ul>\n        ) : null}\n\n        {anyProjected || plan ? (\n          <p className=\"mt-2 text-caption text-text-tertiary\">\n            {anyProjected ? (\n              <>\n                Dashed segments from <span className=\"numeric\">{labels[boundary - 1] ?? labels[0]}</span> are\n                projected, not actual.\n              </>\n            ) : null}\n            {bandSeries?.band ? <> Shaded range is {bandSeries.band.label}.</> : null}\n            {scenarioSeries?.scenarios?.length ? (\n              <> Scenarios are alternative assumptions, not outcomes ranked by preference.</>\n            ) : null}\n            {plan ? (\n              <>\n                {\" \"}\n                Dotted line is <span className=\"font-medium\">{plan.label}</span>; variance is actual less\n                plan, marked F where the difference is favourable and U where it is not.\n              </>\n            ) : null}\n          </p>\n        ) : null}\n      </div>\n    </DataTableToggle>\n  );\n}\n",
      "path": "components/ledger/money-chart.tsx",
      "target": "components/ledger/money-chart.tsx",
      "type": "registry:component"
    }
  ],
  "name": "money-chart",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/chart-primitives",
    "@lopifi/chart-utils",
    "@lopifi/forecast",
    "@lopifi/format-money",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/use-in-view",
    "@lopifi/use-media-query"
  ],
  "title": "MoneyChart",
  "type": "registry:component"
}
