Charts: a profit line that changes colour, and a Series toggle
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.
This commit is contained in:
parent
876619c3ac
commit
681225e51d
5 changed files with 504 additions and 45 deletions
|
|
@ -14,7 +14,10 @@ export default async function ChartsPage() {
|
|||
getRunningCosts(),
|
||||
]);
|
||||
|
||||
const rows = buildIncomeRows(assets, incomeActual, running);
|
||||
// /charts shows profit including running cost (ChartsGrid passes the same flag to
|
||||
// MachineLineChart), so the card badge and the profit line cannot disagree.
|
||||
// Scoped to this page — lib/profit.ts keeps its workbook default for every other surface.
|
||||
const rows = buildIncomeRows(assets, incomeActual, running, { includeRunningCost: true });
|
||||
|
||||
const items = rows.map((r) => {
|
||||
// r.months / r.fixedMonths / r.runningMonths already distinguish null (forecast) from 0 (Part A.2).
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@
|
|||
Brand palette (#EDAE49/#00798C/#D1495B) does NOT change; these are read only by chart marks. */
|
||||
--color-chart-income: #0090A6;
|
||||
--color-chart-fixed: #BE851F;
|
||||
--color-chart-running: #C13B4D;
|
||||
--color-chart-running: #8B5CF6;
|
||||
/* A month that did not make profit. Its own token so moving the Running line
|
||||
cannot drag the profit-loss red along with it. */
|
||||
--color-chart-loss: #C13B4D;
|
||||
/* Profit-positive. The client's chosen bright green, 2026-10-02 — deliberately
|
||||
outside the Coolors brand set so the profit line reads as its own series and
|
||||
not as another teal. */
|
||||
--color-chart-profit: #7A9E06;
|
||||
|
||||
--color-brand-blue: #30638E;
|
||||
--color-brand-orange: #EDAE49;
|
||||
|
|
@ -77,7 +84,9 @@
|
|||
/* Part A.4: dark-mode chart hues */
|
||||
--color-chart-income: #00A6BE;
|
||||
--color-chart-fixed: #BE851F;
|
||||
--color-chart-running: #D94F60;
|
||||
--color-chart-running: #8B5CF6;
|
||||
--color-chart-loss: #D94F60;
|
||||
--color-chart-profit: #7A9E06;
|
||||
}
|
||||
|
||||
html, body {
|
||||
|
|
@ -92,6 +101,11 @@ html, body {
|
|||
body, .dark body { transition: background-color 0.2s ease, color 0.2s ease; }
|
||||
|
||||
.recharts-cartesian-axis-tick text { fill: var(--color-text-soft); font-size: 11px; }
|
||||
/* Round 4: no text under 12px on mobile. This CSS rule beats Recharts' tick
|
||||
fontSize prop, so the bump has to happen here or nowhere. */
|
||||
@media (max-width: 639px) {
|
||||
.recharts-cartesian-axis-tick text { font-size: 12px; }
|
||||
}
|
||||
.recharts-cartesian-grid line { stroke: var(--color-border-soft); }
|
||||
.recharts-default-tooltip {
|
||||
background: rgba(255,255,255,0.98) !important;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale } from "lucide-react";
|
||||
import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale, SlidersHorizontal } from "lucide-react";
|
||||
import { ZAR2 } from "@/lib/utils";
|
||||
import { MachineLineChart, type ChartView } from "@/components/MachineLineChart";
|
||||
import { computeProfit } from "@/lib/profit";
|
||||
import { MachineLineChart, type ChartView, type SeriesVisible } from "@/components/MachineLineChart";
|
||||
|
||||
// /charts reads profit INCLUDING running cost, so the line, the card badge and the
|
||||
// Profit view all sit on one basis. lib/profit.ts keeps its workbook default.
|
||||
const CHART_PROFIT_INCLUDE_RUNNING = true;
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
|
|
@ -16,6 +19,17 @@ type Item = {
|
|||
};
|
||||
|
||||
const VIEW_KEY = "mp:chartsView";
|
||||
const SERIES_KEY = "mp:chartsSeries";
|
||||
|
||||
// Which lines a card draws. The Profit toggle also controls the loss colour,
|
||||
// because red is a sign of the profit line, not a series of its own.
|
||||
const ALL_SERIES: SeriesVisible = { income: true, fixed: true, running: true, profit: true };
|
||||
const SERIES_LIST: { key: keyof SeriesVisible; label: string; color: string }[] = [
|
||||
{ key: "income", label: "Income", color: "var(--color-chart-income)" },
|
||||
{ key: "fixed", label: "Fixed", color: "var(--color-chart-fixed)" },
|
||||
{ key: "running", label: "Running", color: "var(--color-chart-running)" },
|
||||
{ key: "profit", label: "Profit", color: "var(--color-chart-profit)" },
|
||||
];
|
||||
|
||||
export function ChartsGrid({ items }: { items: Item[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
@ -34,6 +48,51 @@ export function ChartsGrid({ items }: { items: Item[] }) {
|
|||
try { localStorage.setItem(VIEW_KEY, v); } catch {}
|
||||
};
|
||||
|
||||
const [series, setSeries] = useState<SeriesVisible>(ALL_SERIES);
|
||||
const [seriesOpen, setSeriesOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SERIES_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<SeriesVisible>;
|
||||
if (parsed && typeof parsed === "object") setSeries({ ...ALL_SERIES, ...parsed });
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const toggleSeries = (k: keyof SeriesVisible) => {
|
||||
setSeries((prev) => {
|
||||
const next = { ...prev, [k]: !prev[k] };
|
||||
// An empty chart reads as broken, so the last visible line stays on.
|
||||
if (Object.values(next).every((v) => !v)) return prev;
|
||||
try { localStorage.setItem(SERIES_KEY, JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const resetSeries = () => {
|
||||
setSeries(ALL_SERIES);
|
||||
try { localStorage.setItem(SERIES_KEY, JSON.stringify(ALL_SERIES)); } catch {}
|
||||
};
|
||||
|
||||
// Close the popover on Escape or an outside click.
|
||||
useEffect(() => {
|
||||
if (!seriesOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSeriesOpen(false); };
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement)?.closest?.("[data-series-popover]")) setSeriesOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [seriesOpen]);
|
||||
|
||||
const seriesOn = SERIES_LIST.filter((s) => series[s.key]).length;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = items;
|
||||
if (query.trim()) {
|
||||
|
|
@ -52,14 +111,30 @@ export function ChartsGrid({ items }: { items: Item[] }) {
|
|||
return { profitable, total: items.length, totalIncome };
|
||||
}, [items]);
|
||||
|
||||
// Legend swatches per view (Part A.5)
|
||||
const legend = view === "profit"
|
||||
? [{ color: "var(--color-chart-running)", label: "Profit / Loss" }]
|
||||
: [
|
||||
{ color: "var(--color-chart-income)", label: "Income" },
|
||||
{ color: "var(--color-chart-fixed)", label: "Fixed" },
|
||||
{ color: "var(--color-chart-running)", label: "Running" },
|
||||
];
|
||||
// Legend swatches per view (Part A.5). Profit/Loss only appear where they are
|
||||
// actually drawn — the Lines view is the only one carrying the profit line, so
|
||||
// the Cost-stack view must not advertise it. A hidden series leaves the legend
|
||||
// too, so the key always matches the card.
|
||||
const legend: { color: string; label: string }[] = (
|
||||
view === "profit"
|
||||
? [
|
||||
{ color: "var(--color-chart-profit)", label: "Profit" },
|
||||
{ color: "var(--color-chart-loss)", label: "Loss" },
|
||||
]
|
||||
: view === "lines"
|
||||
? [
|
||||
series.income ? { color: "var(--color-chart-income)", label: "Income" } : null,
|
||||
series.fixed ? { color: "var(--color-chart-fixed)", label: "Fixed" } : null,
|
||||
series.running ? { color: "var(--color-chart-running)", label: "Running" } : null,
|
||||
series.profit ? { color: "var(--color-chart-profit)", label: "Profit" } : null,
|
||||
series.profit ? { color: "var(--color-chart-loss)", label: "Loss" } : null,
|
||||
]
|
||||
: [
|
||||
series.income ? { color: "var(--color-chart-income)", label: "Income" } : null,
|
||||
series.fixed ? { color: "var(--color-chart-fixed)", label: "Fixed" } : null,
|
||||
series.running ? { color: "var(--color-chart-running)", label: "Running" } : null,
|
||||
]
|
||||
).filter((x): x is { color: string; label: string } => x !== null);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -85,11 +160,17 @@ export function ChartsGrid({ items }: { items: Item[] }) {
|
|||
<span className="text-gray-500 dark:text-gray-400">{l.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{view === "lines" && series.profit && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-white border-2 border-[#7A9E06]" />
|
||||
<span className="text-gray-500 dark:text-gray-400">hollow = no running-cost data</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar — wraps to multiple rows on mobile. Round 4 Part B.1/B.3/B.5. */}
|
||||
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-4 shadow-sm mb-5 flex flex-wrap items-center gap-3">
|
||||
<div className="relative bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-4 shadow-sm mb-5 flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[160px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
|
|
@ -108,7 +189,7 @@ export function ChartsGrid({ items }: { items: Item[] }) {
|
|||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id as typeof filter)}
|
||||
className={`min-h-[44px] px-3 text-xs font-medium rounded-lg transition ${
|
||||
className={`min-h-[44px] min-w-[44px] px-3 text-xs font-medium rounded-lg transition ${
|
||||
filter === f.id
|
||||
? "bg-[#30638E] text-white"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
|
||||
|
|
@ -139,6 +220,66 @@ export function ChartsGrid({ items }: { items: Item[] }) {
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Series filter — a small block of checkboxes so a card can be read with
|
||||
the profit line off, or with only income and profit on, etc. */}
|
||||
<div className="relative" data-series-popover>
|
||||
<button
|
||||
onClick={() => setSeriesOpen((o) => !o)}
|
||||
aria-expanded={seriesOpen}
|
||||
aria-haspopup="true"
|
||||
className={`min-h-[44px] min-w-[44px] px-3 text-xs font-medium rounded-lg transition flex items-center gap-1.5 ${
|
||||
seriesOn === SERIES_LIST.length
|
||||
? "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
|
||||
: "bg-[#30638E]/10 text-[#30638E] dark:text-[#7FB3D5]"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||
<span>Series</span>
|
||||
<span className="tabular-nums">
|
||||
{seriesOn}/{SERIES_LIST.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{seriesOpen && (
|
||||
<div className="absolute left-0 sm:left-auto sm:right-0 mt-2 z-30 w-60 max-w-[calc(100vw-2rem)] rounded-2xl border border-gray-100 dark:border-border bg-white dark:bg-surface shadow-lg p-2">
|
||||
<div className="px-2 pt-1 pb-2 text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft">
|
||||
Show on chart
|
||||
</div>
|
||||
{SERIES_LIST.map((s) => {
|
||||
// Only the LAST remaining series is locked — the guard exists to stop
|
||||
// an empty chart, not to trap the reader on one series. Disabling every
|
||||
// box here meant the only way to add a line back was "Show all".
|
||||
const locked = seriesOn === 1 && series[s.key];
|
||||
return (
|
||||
<label
|
||||
key={s.key}
|
||||
className={`flex items-center gap-3 min-h-[44px] px-2 rounded-lg text-sm ${
|
||||
locked ? "opacity-50" : "cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={series[s.key]}
|
||||
disabled={locked}
|
||||
onChange={() => toggleSeries(s.key)}
|
||||
className="w-4 h-4 accent-[#30638E] cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ background: s.color }} />
|
||||
<span className="text-gray-700 dark:text-gray-200">{s.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={resetSeries}
|
||||
disabled={seriesOn === SERIES_LIST.length}
|
||||
className="w-full min-h-[44px] mt-1 rounded-lg text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
|
|
@ -152,15 +293,17 @@ export function ChartsGrid({ items }: { items: Item[] }) {
|
|||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filtered.map((item) => {
|
||||
const positive = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 }) >= 0 || item.totalIncome > 0;
|
||||
// Profit uses computeProfit() — Part A.6
|
||||
const profit = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 });
|
||||
// Was computeProfit({ income: totalIncome, fixed: 0, running: 0 }) — which is
|
||||
// just totalIncome, so every card showed a green "profit" equal to its income.
|
||||
// totalProfit is the real YTD figure from buildIncomeRows, on the same basis
|
||||
// the profit line uses below.
|
||||
const profit = item.totalProfit;
|
||||
const positiveProfit = profit >= 0;
|
||||
return (
|
||||
<div key={item.id} className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm hover:shadow-md transition">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C] font-semibold">
|
||||
<span className="font-mono text-xs px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C] font-semibold">
|
||||
{item.code}
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white mt-1.5">{item.name}</h3>
|
||||
|
|
@ -181,6 +324,8 @@ export function ChartsGrid({ items }: { items: Item[] }) {
|
|||
fixed={item.bucket.fixed}
|
||||
running={item.bucket.running}
|
||||
view={view}
|
||||
includeRunningCost={CHART_PROFIT_INCLUDE_RUNNING}
|
||||
series={series}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe
|
|||
const clipped = lastActualIdx >= 0 ? data.slice(0, lastActualIdx + 1) : data;
|
||||
const INC = readVar("--color-chart-income", "#0090A6");
|
||||
const FIX = readVar("--color-chart-fixed", "#BE851F");
|
||||
const RUN = readVar("--color-chart-running", "#C13B4D");
|
||||
const RUN = readVar("--color-chart-running", "#8B5CF6");
|
||||
return (
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
|
|
|||
|
|
@ -2,13 +2,23 @@
|
|||
|
||||
import {
|
||||
ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
||||
} from "recharts";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { MONTH_NAMES } from "@/lib/data";
|
||||
import { computeProfit } from "@/lib/profit";
|
||||
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)[];
|
||||
|
|
@ -18,6 +28,12 @@ type Props = {
|
|||
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 {
|
||||
|
|
@ -26,15 +42,154 @@ function readVar(name: string, fallback: string): string {
|
|||
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,
|
||||
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", "#C13B4D");
|
||||
const PROF = profitColor ?? readVar("--color-chart-running", "#D1495B");
|
||||
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),
|
||||
|
|
@ -43,29 +198,106 @@ export function MachineLineChart({
|
|||
);
|
||||
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: computeProfit({ income: income[i] ?? 0, fixed: fixed[i] ?? 0, running: running[i] ?? 0 }),
|
||||
}));
|
||||
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: 10 }} tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
|
||||
<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)} />
|
||||
<Bar dataKey="profit" radius={[4, 4, 0, 0]} fill={INC}>
|
||||
<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 >= 0 ? INC : RUN} />
|
||||
<Cell key={i} fill={((d.profit as number | null) ?? 0) >= 0 ? UP : DOWN} />
|
||||
))}
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
|
|
@ -80,12 +312,12 @@ export function MachineLineChart({
|
|||
<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: 10 }} tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
|
||||
<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)} />
|
||||
<Bar dataKey="fixed" stackId="a" fill={FIX} radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="running" stackId="a" fill={RUN} radius={[4, 4, 0, 0]} barSize={20} />
|
||||
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
|
||||
{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>
|
||||
|
|
@ -98,17 +330,82 @@ export function MachineLineChart({
|
|||
<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: 10 }} tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
|
||||
<Tooltip formatter={(v: number) => fmt(v)} />
|
||||
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
|
||||
<Line type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} dot={{ r: 3, fill: FIX }} connectNulls={false} />
|
||||
<Line type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} dot={{ r: 3, fill: RUN }} connectNulls={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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue