mega-plant/lib/chart-profit.ts

116 lines
4.2 KiB
TypeScript
Raw Normal View History

// Per-segment colouring for a profit line.
//
// Recharts draws a <Line> as a single <Curve> path, so <Cell> cannot recolour one
// segment of it — Cells only work on <Bar>. Getting a line that is green where the
// month made money and red where it did not therefore means one <Line> per same-sign
// run, plus one extra <Line> per genuine crossing to carry the gradient between them.
//
// This is the shared implementation. `/charts` (MachineLineChart) and the dashboard
// (IncomeAreaChart) both draw the same shape of series, and two private copies of this
// logic would drift the moment one of them was fixed.
//
// The colours come from the tokens already ruled for this purpose — see AGENTS.md:
// `--color-chart-profit` is a deliberate exception to the "never change the palette"
// rule, and `--color-chart-loss` has its own token precisely so a loss does not follow
// the Running line's colour when that hue is recoloured.
export type ProfitRun = {
/** Index of the first month in the run. */
start: number;
/** Index of the last month in the run, inclusive. */
end: number;
/** true = the month made money (or broke even). */
up: boolean;
};
// Split a 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 — treating a missing
// month as 0 would draw a confident line across a gap in the data.
export function splitProfitRuns(profit: readonly (number | null)[]): ProfitRun[] {
const runs: ProfitRun[] = [];
let i = 0;
while (i < profit.length) {
const first = profit[i];
if (first == null) {
i++;
continue;
}
const up = first >= 0;
let j = i;
while (
j + 1 < profit.length &&
profit[j + 1] != null &&
(profit[j + 1] as number) >= 0 === up
) {
j++;
}
runs.push({ start: i, end: j, up });
i = j + 1;
}
return runs;
}
export type ProfitSeriesDef = { key: string; stroke: string };
export type ProfitGradient = { id: string; from: string; to: string };
/**
* Write one key per profit variant onto the SHARED chart data, and describe the series
* and gradients to draw.
*
* Nothing is given a `data` prop of its own: Recharts builds a categorical axis by
* concatenating the data of every series, so a Line carrying a short `data` re-adds the
* month labels and the axis renders "Jan … Aug, Jan … Aug" while that series plots at
* the wrong months. Instead each variant is a key on the one dataset, nulled outside
* its own run, and `connectNulls={false}` then draws exactly that run in the right
* place.
*
* `mutate` is called for every variant with a `keep` index list and the key to write.
*/
export function buildProfitSeries({
profit,
upColor,
downColor,
idPrefix,
mutate,
}: {
profit: readonly (number | null)[];
upColor: string;
downColor: string;
/** Unique per chart instance, or two charts would share one gradient def. */
idPrefix: string;
mutate: (key: string, keep: number[]) => void;
}): { series: ProfitSeriesDef[]; gradients: ProfitGradient[] } {
const runs = splitProfitRuns(profit);
const series: ProfitSeriesDef[] = [];
const gradients: ProfitGradient[] = [];
const keepRange = (from: number, to: number) => {
const keep: number[] = [];
for (let i = from; i <= to; i++) keep.push(i);
return keep;
};
// A run of a single point has no segment to draw, so it is skipped. The point still
// shows as a dot on the axis below, which is where a lone month's profit belongs.
runs.forEach((r, idx) => {
if (r.end <= r.start) return;
const key = `profitRun${idx}`;
mutate(key, keepRange(r.start, r.end));
series.push({ key, stroke: r.up ? upColor : downColor });
});
// One gradient series per genuine crossing: two adjacent runs, same sign flip.
runs.forEach((a, idx) => {
const b = runs[idx + 1];
// Not adjacent means a null month sits between them — never bridge that gap.
if (!b || a.end + 1 !== b.start) return;
const key = `profitCross${idx}`;
const id = `${idPrefix}-g${idx}`;
mutate(key, [a.end, b.start]);
series.push({ key, stroke: `url(#${id})` });
gradients.push({ id, from: a.up ? upColor : downColor, to: b.up ? upColor : downColor });
});
return { series, gradients };
}