116 lines
4.2 KiB
TypeScript
116 lines
4.2 KiB
TypeScript
|
|
// 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 };
|
||
|
|
}
|