diff --git a/frontend/src/features/stats/ChartTooltip.tsx b/frontend/src/features/stats/ChartTooltip.tsx
index e952f2f..3694e0d 100644
--- a/frontend/src/features/stats/ChartTooltip.tsx
+++ b/frontend/src/features/stats/ChartTooltip.tsx
@@ -5,7 +5,8 @@ import { chartTooltipStyle } from "./chartTheme";
type TooltipRow = {
name: React.ReactNode;
value: React.ReactNode;
- color: string;
+ // The series colour; leave it out for rows like totals that have no mark in the chart
+ color?: string;
};
export const ChartTooltip = ({ title, rows }: { title?: React.ReactNode; rows: TooltipRow[] }) => (
@@ -17,7 +18,11 @@ export const ChartTooltip = ({ title, rows }: { title?: React.ReactNode; rows: T
)}
{rows.map((row, index) => (
- {" "}
+ {row.color && (
+ <>
+ {" "}
+ >
+ )}
{row.value} {row.name}
))}
diff --git a/frontend/src/features/stats/ProductStats.tsx b/frontend/src/features/stats/ProductStats.tsx
index e501950..d0c7802 100644
--- a/frontend/src/features/stats/ProductStats.tsx
+++ b/frontend/src/features/stats/ProductStats.tsx
@@ -4,7 +4,7 @@ import { Pie, PieChart, PieLabelRenderProps, Sector, Tooltip } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import styles from "./Stats.module.css";
import { StatsView } from "./StatsView";
-import { chartColor, chartStyle, chartTextColor } from "./chartTheme";
+import { chartColor, chartStyle, chartTextColor, labelColorOn } from "./chartTheme";
import { AllProductStatsDocument } from "../../types";
type UserCount = {
@@ -29,6 +29,7 @@ const renderLabel = ({
textAnchor,
name,
value,
+ index,
}: PieLabelRenderProps) => {
const radius = Number(innerRadius) + (Number(outerRadius) - Number(innerRadius)) / 2;
return (
@@ -42,7 +43,7 @@ const renderLabel = ({
y={Number(cy) + radius * Math.sin(-midAngle * RADIAN)}
textAnchor="middle"
dominantBaseline="central"
- fill="#17121a"
+ fill={labelColorOn(chartColor(index))}
>
{value}
diff --git a/frontend/src/features/stats/PurchasesOverTimeChart.tsx b/frontend/src/features/stats/PurchasesOverTimeChart.tsx
index 22fac8c..6aa4014 100644
--- a/frontend/src/features/stats/PurchasesOverTimeChart.tsx
+++ b/frontend/src/features/stats/PurchasesOverTimeChart.tsx
@@ -1,35 +1,71 @@
import { addMonths, format, parseISO, startOfMonth } from "date-fns";
-import { CartesianGrid, Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
+import { Area, AreaChart, CartesianGrid, Legend, Tooltip, XAxis, YAxis } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
-import { axisLabelStyle, axisProps, chartColor, chartStyle, chartTextColor, gridStroke } from "./chartTheme";
+import {
+ axisLabelStyle,
+ axisProps,
+ chartStyle,
+ chartSurfaceColor,
+ chartTextColor,
+ gridStroke,
+ chartColors,
+ otherColor,
+} from "./chartTheme";
type ProductOrderStat = {
productName: string;
data: { x: string; y: number }[];
};
-type Row = {
- date: number;
- // Keyed by product name; a product has no value before its first purchase
- totals: Record;
+// One band of the stack: a product, or all the remaining products together
+type Band = {
+ name: string;
+ color: string;
};
-// One row per day with each product's running total. Products without purchases that day are left
-// out of the row, and connectNulls draws their line straight to the next day they have one.
-const toRows = (stats: ProductOrderStat[]): Row[] => {
- const rows = new Map();
- for (const { productName, data } of stats) {
- let total = 0;
- for (const point of data) {
- total += point.y;
- const date = parseISO(point.x).getTime();
- const row = rows.get(date) ?? { date, totals: {} };
- row.totals[productName] = total;
- rows.set(date, row);
- }
+type Row = {
+ date: number;
+ // Units sold up to and including this day, one entry per band, bottom band first
+ values: number[];
+ total: number;
+};
+
+// The best sellers each get a band, largest at the bottom. The rest are added up in one band on top,
+// since the palette has no more colours that stay distinguishable next to each other.
+const toChart = (stats: ProductOrderStat[]): { bands: Band[]; rows: Row[] } => {
+ const products = stats
+ .map(({ productName, data }) => {
+ let total = 0;
+ const totalByDate = new Map();
+ for (const point of data) {
+ total += point.y;
+ totalByDate.set(parseISO(point.x).getTime(), total);
+ }
+ return { productName, totalByDate, total };
+ })
+ .sort((a, b) => b.total - a.total);
+
+ const shown = products.slice(0, chartColors.length);
+ const others = products.slice(shown.length);
+ const bands = shown.map(({ productName }, index) => ({ name: productName, color: chartColors[index] }));
+ if (others.length) {
+ bands.push({ name: `Andre (${others.length} ${others.length === 1 ? "vare" : "varer"})`, color: otherColor });
}
- return [...rows.values()].sort((a, b) => a.date - b.date);
+
+ // A product's running total carries over to the days it sold nothing
+ const dates = [...new Set(products.flatMap(({ totalByDate }) => [...totalByDate.keys()]))].sort((a, b) => a - b);
+ const current = products.map(() => 0);
+ const rows = dates.map((date) => {
+ products.forEach(({ totalByDate }, index) => {
+ current[index] = totalByDate.get(date) ?? current[index];
+ });
+ const values = current.slice(0, shown.length);
+ if (others.length) values.push(current.slice(shown.length).reduce((sum, value) => sum + value, 0));
+ return { date, values, total: values.reduce((sum, value) => sum + value, 0) };
+ });
+
+ return { bands, rows };
};
// A tick on the first of each month, so each month is labelled once. Spans too short to have
@@ -47,11 +83,14 @@ const monthTicks = (rows: Row[]): number[] | undefined => {
return ticks.length >= 2 ? ticks : undefined;
};
+const percent = (value: number, total: number) => `${total ? Math.round((value / total) * 100) : 0} %`;
+
+// Total units sold over time, stacked by product, so each band's thickness is that product's share
export const PurchasesOverTimeChart = ({ stats }: { stats: ProductOrderStat[] }) => {
- const rows = toRows(stats);
+ const { bands, rows } = toChart(stats);
const ticks = monthTicks(rows);
return (
-
+
- active && payload.length ? (
+ content={({ active, payload }) => {
+ const row: Row | undefined = payload[0]?.payload;
+ if (!active || !row) return null;
+ // Top band first, as in the chart
+ const bandRows = bands
+ .map((band, index) => ({
+ name: band.name,
+ value: `${row.values[index]} (${percent(row.values[index], row.total)})`,
+ color: band.color,
+ }))
+ .reverse();
+ return (
entry.value !== undefined)
- .map((entry) => ({ name: entry.name, value: entry.value, color: String(entry.color) }))}
+ title={format(row.date, "yyyy-MM-dd")}
+ rows={[{ name: "totalt", value: row.total }, ...bandRows]}
/>
- ) : null
- }
+ );
+ }}
/>
+
);
};
diff --git a/frontend/src/features/stats/chartTheme.ts b/frontend/src/features/stats/chartTheme.ts
index cf168d9..824c169 100644
--- a/frontend/src/features/stats/chartTheme.ts
+++ b/frontend/src/features/stats/chartTheme.ts
@@ -1,12 +1,41 @@
import type { CSSProperties } from "react";
export const chartTextColor = "#f7effa";
+const darkLabelColor = "#17121a";
+
+// sRGB relative luminance, as in WCAG's contrast ratio
+const luminance = (hex: string) => {
+ const [r, g, b] = [1, 3, 5].map((i) => {
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+
+// Dark or white, whichever reads better on text drawn over the given colour
+export const labelColorOn = (fill: string) => {
+ const background = luminance(fill);
+ const contrastWith = (text: string) => {
+ const [hi, lo] = [luminance(text), background].sort((a, b) => b - a);
+ return (hi + 0.05) / (lo + 0.05);
+ };
+ return contrastWith(darkLabelColor) >= contrastWith("#ffffff") ? darkLabelColor : "#ffffff";
+};
const axisColor = "#b9a3c5";
-// nivo's default categorical scheme, kept so the charts look the same after moving to Recharts
-export const chartColors = ["#e8c1a0", "#f47560", "#f1e15b", "#e8a838", "#61cdbb", "#97e3d5"];
+// Categorical colours for the dark background. The order matters: each colour stays easy to tell
+// apart from the next one, also with colour blindness, so neighbouring bars, slices and bands work.
+// Colours repeat past the eighth; where that would put two of the same side by side, fold the rest
+// into one otherColor group instead.
+export const chartColors = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"];
export const chartColor = (index: number) => chartColors[index % chartColors.length];
+// For a group of "the rest", e.g. "Andre"; distinct from the last colour it follows
+export const otherColor = "#a89cae";
+
+// The darkest background colour, used to separate touching bands
+export const chartSurfaceColor = "#0e0610";
+
// Recharts draws plain SVG; give it the app font and fill its container
export const chartStyle: CSSProperties = {
width: "100%",