Profit was a single always-teal line, so a month that lost money looked
like a month that made a little. It is now drawn per sign: one <Line> per
same-sign run, plus a gradient <Line> across each crossing. Recharts draws
a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate
series are the only way. Every variant is a key on the ONE shared dataset
rather than a sliced `data` prop, because Recharts builds a categorical axis
by concatenating every series' data and a short slice re-adds the month
labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content`
component to fold those per-run keys back into a single profit row.
Two honesty rules: profit stays null in a month with no income figure
(treating it as 0 drew a green dot for a month that was never billed), and
a month with no running-cost row gets a hollow dot, because its profit is
optimistic.
The toolbar's Series button toggles which lines a card draws, remembered in
localStorage. A hidden series takes its legend entry and its Y axis with it,
so there is never an axis for a line that is not drawn, and the last visible
series cannot be switched off.
Palette: Running moves off red to the project's existing brand purple,
because red is now reserved for a loss and a red Running line next to a red
loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit`
are separate tokens on purpose — loss used to read the Running token, so
recolouring Running silently dragged the red with it.
/charts alone now reads profit including running cost, so the card badge,
the profit line and the Profit bars view cannot disagree. Scoped to the
page: lib/profit.ts keeps its workbook default for every other surface.
411 lines
17 KiB
TypeScript
411 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell,
|
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
|
} from "recharts";
|
|
import { useEffect, useId, useState } from "react";
|
|
import { MONTH_NAMES } from "@/lib/data";
|
|
import { computeProfit, type ProfitOptions } from "@/lib/profit";
|
|
|
|
export type ChartView = "lines" | "stack" | "profit";
|
|
|
|
// Which lines the card draws. A hidden series disappears from the plot AND from
|
|
// its axis, so the reader never sees an axis for a line that is not there.
|
|
export type SeriesVisible = {
|
|
income?: boolean;
|
|
fixed?: boolean;
|
|
running?: boolean;
|
|
profit?: boolean;
|
|
};
|
|
|
|
type Props = {
|
|
income: (number | null)[];
|
|
fixed: (number | null)[];
|
|
running: (number | null)[];
|
|
view?: ChartView;
|
|
incomeColor?: string;
|
|
fixedColor?: string;
|
|
runningColor?: string;
|
|
profitColor?: string;
|
|
lossColor?: string;
|
|
// Which profit basis this chart shows. lib/profit.ts stays the single source of
|
|
// truth for the formula; this only decides which basis these cards read on.
|
|
includeRunningCost?: boolean;
|
|
showProfitLine?: boolean;
|
|
series?: SeriesVisible;
|
|
};
|
|
|
|
function readVar(name: string, fallback: string): string {
|
|
if (typeof window === "undefined") return fallback;
|
|
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
return v || fallback;
|
|
}
|
|
|
|
// Round 4 mobile rule: no chart text under 12px on a phone, so ticks step up to 12
|
|
// below sm and stay at 10 on desktop where there is room for them.
|
|
function useIsNarrow() {
|
|
const [narrow, setNarrow] = useState(false);
|
|
useEffect(() => {
|
|
const mq = window.matchMedia("(max-width: 639px)");
|
|
const sync = () => setNarrow(mq.matches);
|
|
sync();
|
|
mq.addEventListener("change", sync);
|
|
return () => mq.removeEventListener("change", sync);
|
|
}, []);
|
|
return narrow;
|
|
}
|
|
|
|
type Point = {
|
|
month: string;
|
|
profit: number | null;
|
|
// false = we have no running-cost row for this month. `rows.ts` leaves
|
|
// runningMonths null when there is no row, so this is exactly "no data".
|
|
runningKnown: boolean;
|
|
};
|
|
|
|
type Run = { start: number; end: number; up: boolean };
|
|
|
|
// The profit line is drawn as several series (one per same-sign run, one per
|
|
// crossing, plus a transparent one that carries the dots) so it can be coloured
|
|
// per segment. Those internal keys are an implementation detail and must never
|
|
// reach the user, and "profit" is a computed figure rather than a raw column, so
|
|
// its own Tooltip formatter cannot label it. A custom tooltip is the only place
|
|
// that knows both: it drops every profit-backed series and adds one clean row.
|
|
type TooltipItem = {
|
|
dataKey?: string | number;
|
|
name?: string;
|
|
value?: number | string | null;
|
|
color?: string;
|
|
stroke?: string;
|
|
payload?: { profit?: number | null; runningKnown?: boolean };
|
|
};
|
|
type TooltipProps = {
|
|
active?: boolean;
|
|
label?: string | number;
|
|
payload?: TooltipItem[];
|
|
showProfit: boolean;
|
|
fmt: (v: number) => string;
|
|
up: string;
|
|
down: string;
|
|
};
|
|
|
|
const INTERNAL_SERIES = /^(profitRun|profitCross)/;
|
|
|
|
function ChartTooltip({ active, label, payload, showProfit, fmt, up, down }: TooltipProps) {
|
|
if (!active || !payload?.length) return null;
|
|
|
|
const rows: { name: string; text: string; color: string }[] = [];
|
|
for (const p of payload) {
|
|
const key = String(p.dataKey ?? "");
|
|
if (INTERNAL_SERIES.test(key)) continue;
|
|
// The dot-carrying "profit" series is replaced by the single computed row below.
|
|
if (key === "profit") continue;
|
|
const name = p.name ?? key;
|
|
const value = p.value;
|
|
if (value == null) continue;
|
|
rows.push({ name, text: fmt(Number(value)), color: p.color || p.stroke || "#9ca3af" });
|
|
}
|
|
|
|
if (showProfit) {
|
|
const row = payload[0]?.payload;
|
|
if (row && row.profit != null) {
|
|
rows.push({
|
|
name: "Profit",
|
|
text: fmt(row.profit),
|
|
color: row.profit >= 0 ? up : down,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!rows.length) return null;
|
|
|
|
return (
|
|
// Reuse the recharts class names so globals.css keeps styling this tooltip.
|
|
<div className="recharts-default-tooltip">
|
|
<div className="recharts-tooltip-label">{label}</div>
|
|
{rows.map((r) => (
|
|
<div
|
|
key={r.name}
|
|
className="recharts-tooltip-item"
|
|
style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 2 }}
|
|
>
|
|
<span
|
|
style={{ width: 8, height: 8, borderRadius: 2, background: r.color, flexShrink: 0 }}
|
|
/>
|
|
<span style={{ opacity: 0.65 }}>{r.name}</span>
|
|
<span style={{ marginLeft: "auto", fontWeight: 600 }}>{r.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Split the series into maximal contiguous runs of the same sign. A null month ends
|
|
// a run, so the line never bridges a month we have no figure for.
|
|
function splitRuns(points: Point[]): Run[] {
|
|
const runs: Run[] = [];
|
|
let i = 0;
|
|
while (i < points.length) {
|
|
const first = points[i].profit;
|
|
if (first == null) { i++; continue; }
|
|
const up = first >= 0;
|
|
let j = i;
|
|
while (
|
|
j + 1 < points.length &&
|
|
points[j + 1].profit != null &&
|
|
(points[j + 1].profit as number) >= 0 === up
|
|
) j++;
|
|
runs.push({ start: i, end: j, up });
|
|
i = j + 1;
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
export function MachineLineChart({
|
|
income, fixed, running,
|
|
view = "lines",
|
|
incomeColor, fixedColor, runningColor, profitColor, lossColor,
|
|
includeRunningCost = false,
|
|
showProfitLine = true,
|
|
series,
|
|
}: Props) {
|
|
const on = {
|
|
income: series?.income ?? true,
|
|
fixed: series?.fixed ?? true,
|
|
running: series?.running ?? true,
|
|
profit: (series?.profit ?? true) && showProfitLine,
|
|
};
|
|
// The amount axis exists only if some non-profit line is actually drawn.
|
|
const showAmountAxis = on.income || on.fixed || on.running;
|
|
const INC = incomeColor ?? readVar("--color-chart-income", "#0090A6");
|
|
const FIX = fixedColor ?? readVar("--color-chart-fixed", "#BE851F");
|
|
const RUN = runningColor ?? readVar("--color-chart-running", "#8B5CF6");
|
|
const UP = profitColor ?? readVar("--color-chart-profit", "#7A9E06");
|
|
// Loss has its own token — it must not follow the Running line's colour.
|
|
const DOWN = lossColor ?? readVar("--color-chart-loss", "#C13B4D");
|
|
|
|
const narrow = useIsNarrow();
|
|
const tickSize = narrow ? 12 : 10;
|
|
// Unique per card: /charts renders one of these per machine, and a shared
|
|
// gradient id would make every card paint the first card's fade.
|
|
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
|
|
const lastActual = Math.max(
|
|
income.findLastIndex((v) => v != null),
|
|
fixed.findLastIndex((v) => v != null),
|
|
running.findLastIndex((v) => v != null),
|
|
);
|
|
const clipped = lastActual >= 0 ? lastActual + 1 : MONTH_NAMES.length;
|
|
const months = MONTH_NAMES.slice(0, clipped);
|
|
|
|
// Profit stays null in a month with no income figure. Treating it as 0 would
|
|
// draw a green "monthly profit" dot for a month that was never billed.
|
|
const profitOpts: ProfitOptions = { includeRunningCost };
|
|
const points: Point[] = months.map((m, i) => ({
|
|
month: m,
|
|
profit: income[i] == null
|
|
? null
|
|
: computeProfit(
|
|
{ income: income[i] as number, fixed: fixed[i] ?? 0, running: running[i] ?? 0 },
|
|
profitOpts,
|
|
),
|
|
runningKnown: running[i] != null,
|
|
}));
|
|
|
|
const data = months.map((m, i) => ({
|
|
month: m,
|
|
income: income[i] ?? null,
|
|
fixed: fixed[i] ?? null,
|
|
running: running[i] ?? null,
|
|
profit: points[i].profit,
|
|
// Carried onto the chart data because the profit dots read `payload` from here,
|
|
// not from the per-run `points` array.
|
|
runningKnown: points[i].runningKnown,
|
|
})) as (Record<string, unknown> & { month: string; runningKnown: boolean })[];
|
|
|
|
const runs = splitRuns(points);
|
|
|
|
// Recharts builds a categorical axis by concatenating the data of EVERY series,
|
|
// so a Line carrying its own `data` re-adds the month labels and the axis ends up
|
|
// reading "Jan … Aug, Jan … Aug" while the short series plots at the wrong months.
|
|
// Therefore every profit variant lives as its own key on the ONE shared dataset and
|
|
// no Line is given a `data` prop: profit is nulled outside its own run, and
|
|
// connectNulls={false} then draws exactly that run, in the right place.
|
|
const seriesDefs: { key: string; stroke: string }[] = [];
|
|
const assignRun = (key: string, keep: number[]) => {
|
|
const on = new Set(keep);
|
|
for (let i = 0; i < data.length; i++) data[i][key] = on.has(i) ? points[i].profit : null;
|
|
};
|
|
|
|
// One flat series per same-sign run, then one gradient series per genuine crossing.
|
|
// Recharts 2 draws a Line as a single <Curve>, so <Cell> cannot recolour a segment —
|
|
// separate series are the only way to get per-segment colour.
|
|
runs.forEach((r, idx) => {
|
|
if (r.end <= r.start) return;
|
|
const key = `profitRun${idx}`;
|
|
const keep: number[] = [];
|
|
for (let i = r.start; i <= r.end; i++) keep.push(i);
|
|
assignRun(key, keep);
|
|
seriesDefs.push({ key, stroke: r.up ? UP : DOWN });
|
|
});
|
|
|
|
const crossings = runs.flatMap((a, idx) => {
|
|
const b = runs[idx + 1];
|
|
// Not adjacent means a null month sits between them — do not bridge the gap.
|
|
if (!b || a.end + 1 !== b.start) return [];
|
|
const key = `profitCross${idx}`;
|
|
assignRun(key, [a.end, b.start]);
|
|
const def = { key, stroke: `url(#${uid}-g${idx})` };
|
|
seriesDefs.push(def);
|
|
return [{
|
|
key: `grad-${a.end}-${b.start}`,
|
|
id: `${uid}-g${idx}`,
|
|
from: a.up ? UP : DOWN,
|
|
to: b.up ? UP : DOWN,
|
|
}];
|
|
});
|
|
|
|
const fmt = (v: number) =>
|
|
new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 0, notation: "compact" }).format(v);
|
|
|
|
// Keep zero inside the profit axis so "above/below zero" is always readable,
|
|
// whichever way every month happens to fall.
|
|
const profitAxis = {
|
|
yAxisId: "profit",
|
|
orientation: "right" as const,
|
|
stroke: "#9ca3af",
|
|
tickLine: false,
|
|
axisLine: false,
|
|
tickFormatter: fmt,
|
|
// Ticks are dropped on a phone to protect the plot width; the scale is unchanged.
|
|
tick: narrow ? false : { fontSize: tickSize },
|
|
width: narrow ? 1 : 52,
|
|
// Function form so zero is always inside the range.
|
|
domain: ([min, max]: [number, number]): [number, number] => [Math.min(0, min), Math.max(0, max)],
|
|
};
|
|
|
|
if (view === "profit") {
|
|
return (
|
|
<div className="h-44 sm:h-52">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
|
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
|
|
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
<YAxis yAxisId="profit" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
|
|
<Tooltip formatter={(v: number) => fmt(v)} />
|
|
<ReferenceLine yAxisId="profit" y={0} stroke="#9ca3af" strokeDasharray="4 4" />
|
|
<Bar dataKey="profit" yAxisId="profit" radius={[4, 4, 0, 0]} fill={UP}>
|
|
{data.map((d, i) => (
|
|
<Cell key={i} fill={((d.profit as number | null) ?? 0) >= 0 ? UP : DOWN} />
|
|
))}
|
|
</Bar>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (view === "stack") {
|
|
return (
|
|
<div className="h-44 sm:h-52">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }} stackOffset="sign">
|
|
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
|
|
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
{showAmountAxis && <YAxis yAxisId="amount" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />}
|
|
<Tooltip formatter={(v: number) => fmt(v)} />
|
|
{on.fixed && <Bar dataKey="fixed" yAxisId="amount" stackId="a" fill={FIX} radius={[0, 0, 0, 0]} />}
|
|
{on.running && <Bar dataKey="running" yAxisId="amount" stackId="a" fill={RUN} radius={[4, 4, 0, 0]} barSize={20} />}
|
|
{on.income && <Line type="monotone" name="Income" dataKey="income" yAxisId="amount" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />}
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Round 4 Part A.5: default "Lines" view draws ACTUAL LINES (stroked, no fill).
|
|
// The chart's whole purpose is to match the workbook — the workbook draws lines.
|
|
return (
|
|
<div className="h-44 sm:h-52">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
|
{/* Defined before use so the fade ids exist when the Lines below mount. */}
|
|
<defs>
|
|
{crossings.map((c) => (
|
|
<linearGradient key={c.key} id={c.id} x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor={c.from} />
|
|
<stop offset="100%" stopColor={c.to} />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
|
|
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
{showAmountAxis && <YAxis yAxisId="amount" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />}
|
|
{on.profit && <YAxis {...profitAxis} />}
|
|
{on.profit && (
|
|
<ReferenceLine yAxisId="profit" y={0} stroke="#9ca3af" strokeDasharray="4 4" />
|
|
)}
|
|
<Tooltip
|
|
formatter={(v: number) => fmt(v)}
|
|
content={<ChartTooltip showProfit={on.profit} fmt={fmt} up={UP} down={DOWN} />}
|
|
/>
|
|
|
|
{on.income && <Line type="monotone" name="Income" dataKey="income" yAxisId="amount" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />}
|
|
{on.fixed && <Line type="monotone" name="Fixed" dataKey="fixed" yAxisId="amount" stroke={FIX} strokeWidth={2.5} dot={{ r: 3, fill: FIX }} connectNulls={false} />}
|
|
{on.running && <Line type="monotone" name="Running" dataKey="running" yAxisId="amount" stroke={RUN} strokeWidth={2.5} dot={{ r: 3, fill: RUN }} connectNulls={false} />}
|
|
|
|
{/* These must be DIRECT children of LineChart — Recharts parses only
|
|
recognised child types and silently drops anything else, so wrapping
|
|
them in a <g> makes the whole profit line disappear. Each reads one
|
|
masked key off the shared data and carries no `data` of its own. */}
|
|
{on.profit &&
|
|
seriesDefs.map((s) => (
|
|
<Line
|
|
key={s.key}
|
|
type="linear"
|
|
dataKey={s.key}
|
|
yAxisId="profit"
|
|
stroke={s.stroke}
|
|
strokeWidth={4}
|
|
dot={false}
|
|
activeDot={false}
|
|
isAnimationActive={false}
|
|
connectNulls={false}
|
|
/>
|
|
))}
|
|
{/* Dots ride their own transparent Line so each month can be coloured by its
|
|
own sign — filled = running cost known, hollow = no running-cost row for
|
|
that month, so that month's figure is optimistic. */}
|
|
{on.profit && (
|
|
<Line
|
|
type="linear"
|
|
dataKey="profit"
|
|
yAxisId="profit"
|
|
stroke="none"
|
|
strokeWidth={0}
|
|
connectNulls={false}
|
|
activeDot={false}
|
|
isAnimationActive={false}
|
|
dot={(props: { cx?: number; cy?: number; index?: number; payload?: unknown }) => {
|
|
const { cx, cy, index } = props;
|
|
const p = props.payload as
|
|
| { profit?: number | null; runningKnown?: boolean }
|
|
| undefined;
|
|
// LineDot's type forbids returning null, so an unplottable month
|
|
// gets an empty group instead of a circle at NaN.
|
|
if (p?.profit == null || cx == null || cy == null || Number.isNaN(cy)) {
|
|
return <g key={`d-${index}`} />;
|
|
}
|
|
const c = p.profit >= 0 ? UP : DOWN;
|
|
return p.runningKnown
|
|
? <circle key={`d-${index}`} cx={cx} cy={cy} r={3.5} fill={c} stroke="#fff" strokeWidth={1} />
|
|
: <circle key={`d-${index}`} cx={cx} cy={cy} r={3.5} fill="#fff" stroke={c} strokeWidth={2} />;
|
|
}}
|
|
/>
|
|
)}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|