feat(stats): add chart form selection and pie, donut, line charts
Build, Test & Push Frontend / quality-check (push) Successful in 30s
Build, Test & Push Frontend / docker (push) Successful in 22s

Tag rows with 0 games are no longer clickable.
This commit is contained in:
lobo committed 2026-10-04 10:43:02 +02:00
1 parent 8b94a25844
commit e054e13a59
21 files changed
+815 -56

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import {apiFetch} from './api.js';
import {dayNumber} from './stats.js';
// The chart forms of each stat group, the first is the default. Auth checks the same list.
export const STAT_CHARTS = {
PLATFORMS: ['BARS', 'PIE', 'DONUT', 'LIST'],
TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'],
FINISHED: ['BARS', 'LINE', 'LIST'],
TREND: ['LINE', 'BARS'],
};
/**
* The chart form to draw: the stored one when the group offers it, else the group's default.
*
* @param charts group -> form, as the user settings hold it; may be missing
*/
export const chartOf = (charts, group) => {
const forms = STAT_CHARTS[group];
const stored = charts?.[group];
return forms.includes(stored) ? stored : forms[0];
};
/**
* The slices of a pie: the items with a value, biggest first. More than `keep + 1` items: the `keep` biggest and
* one "other" slice with the rest.
*
* @returns `[{name, value, item, other}]`; the other slice has no name and no item
*/
export const topSlices = (items, valueOf, keep = 5) => {
const slices = items
.map((item) => ({name: item.name, value: valueOf(item), item, other: false}))
.filter((slice) => slice.value > 0)
.sort((a, b) => b.value - a.value);
if (slices.length <= keep + 1) return slices;
const rest = slices.slice(keep).reduce((sum, slice) => sum + slice.value, 0);
return [...slices.slice(0, keep), {name: null, value: rest, item: null, other: true}];
};
const round = (n) => Math.round(n * 1000) / 1000;
/**
* SVG path data of the slices of a pie (`inner` 0) or a donut, in a square of `2 * radius`, the first slice at the
* top, clockwise. A slice that is the whole circle is drawn as two half arcs.
*/
export const slicePaths = (values, radius, inner = 0) => {
const total = values.reduce((sum, value) => sum + value, 0);
const point = (angle, r) => `${round(radius + r * Math.sin(angle))},${round(radius - r * Math.cos(angle))}`;
let start = 0;
return values.map((value) => {
if (value <= 0) return '';
const from = start;
start += value;
if (value === total) {
const ring = (r, sweep) => `M${radius},${radius - r} A${r},${r} 0 1 ${sweep} ${radius},${radius + r} A${r},${r} 0 1 ${sweep} ${radius},${radius - r} Z`;
return inner > 0 ? `${ring(radius, 1)} ${ring(inner, 0)}` : ring(radius, 1);
}
const a0 = (from / total) * 2 * Math.PI;
const a1 = (start / total) * 2 * Math.PI;
const large = a1 - a0 > Math.PI ? 1 : 0;
const outer = `A${radius},${radius} 0 ${large} 1 ${point(a1, radius)}`;
return inner > 0
? `M${point(a0, radius)} ${outer} L${point(a1, inner)} A${inner},${inner} 0 ${large} 0 ${point(a0, inner)} Z`
: `M${radius},${radius} L${point(a0, radius)} ${outer} Z`;
});
};
/**
* The years from the first to the last one, a year without games has 0.
*
* @param perYear `[{year, games}]`
*/
export const yearSeries = (perYear) => {
if (perYear.length === 0) return [];
const years = perYear.map((y) => y.year);
const first = Math.min(...years);
const games = new Map(perYear.map((y) => [y.year, y.games]));
return Array.from({length: Math.max(...years) - first + 1},
(_, i) => ({year: first + i, games: games.get(first + i) ?? 0}));
};
/**
* Points of a line chart, spread over the width; the y axis starts at 0, so a small change looks small.
*/
export const linePoints = (values, width, height) => {
const max = Math.max(1, ...values);
return values.map((value, i) => ({
x: (i / (values.length - 1)) * width,
y: height - (value / max) * height,
}));
};
/**
* Bars for the pile per day, like trendPoints: x follows the date, so days without a snapshot keep their width.
*/
export const trendBars = (days, width, height) => {
if (days.length === 0) return [];
const max = Math.max(1, ...days.map((d) => d.backlogGames));
const first = dayNumber(days[0].day);
const slot = width / (dayNumber(days[days.length - 1].day) - first + 1);
return days.map((d) => {
const h = (d.backlogGames / max) * height;
return {x: (dayNumber(d.day) - first) * slot, y: height - h, w: slot * 0.8, h};
});
};
/**
* @param chart null puts the group back to its default form
*/
export const saveStatChart = (group, chart) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/stat-chart`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({group, chart}),
});
+94
View File
@@ -0,0 +1,94 @@
import {describe, expect, it} from 'vitest';
import {chartOf, linePoints, slicePaths, topSlices, trendBars, yearSeries} from './statCharts.js';
describe('chartOf', () => {
it('gives the stored form of the group', () => {
expect(chartOf({PLATFORMS: 'PIE'}, 'PLATFORMS')).toBe('PIE');
});
it('falls back to the first form', () => {
expect(chartOf({}, 'PLATFORMS')).toBe('BARS');
expect(chartOf({}, 'TREND')).toBe('LINE');
expect(chartOf({TASTE: 'LINE'}, 'TASTE')).toBe('BARS');
expect(chartOf({PLATFORMS: 'RADAR'}, 'PLATFORMS')).toBe('BARS');
});
it('accepts a missing or odd settings value', () => {
for (const charts of [null, undefined, 'PIE', 5]) expect(chartOf(charts, 'FINISHED')).toBe('BARS');
});
});
describe('topSlices', () => {
const item = (name, games) => ({name, games});
const games = (i) => i.games;
it('keeps all items up to one more than keep, biggest first', () => {
const slices = topSlices([1, 2, 3, 4, 5, 6].map((n) => item(`P${n}`, n)), games);
expect(slices.map((s) => s.name)).toEqual(['P6', 'P5', 'P4', 'P3', 'P2', 'P1']);
expect(slices.some((s) => s.other)).toBe(false);
});
it('sums the rest into an other slice', () => {
const slices = topSlices([1, 2, 3, 4, 5, 6, 7, 8].map((n) => item(`P${n}`, n)), games);
expect(slices).toHaveLength(6);
expect(slices.slice(0, 5).map((s) => s.name)).toEqual(['P8', 'P7', 'P6', 'P5', 'P4']);
expect(slices[5]).toMatchObject({name: null, other: true, value: 3 + 2 + 1, item: null});
});
it('drops items without a value', () => {
expect(topSlices([item('A', 2), item('B', 0)], games).map((s) => s.name)).toEqual(['A']);
});
});
describe('slicePaths', () => {
it('draws one value as a full circle, not an empty arc', () => {
const [d] = slicePaths([4], 50, 0);
expect(d).toBe('M50,0 A50,50 0 1 1 50,100 A50,50 0 1 1 50,0 Z');
});
it('draws one value of a donut as a ring with a hole', () => {
const [d] = slicePaths([4], 50, 30);
expect(d.match(/M/g)).toHaveLength(2);
});
it('splits a circle in the order of the values, the first from the top', () => {
const [a, b] = slicePaths([1, 1], 50, 0);
expect(a).toBe('M50,50 L50,0 A50,50 0 0 1 50,100 Z');
expect(b).toBe('M50,50 L50,100 A50,50 0 0 1 50,0 Z');
});
it('gives a donut slice an inner arc', () => {
const [a] = slicePaths([1, 3], 50, 30);
expect(a).toContain('A30,30 0 0 0');
});
});
describe('yearSeries', () => {
it('fills the years between the first and the last with 0', () => {
expect(yearSeries([{year: 2024, games: 1}, {year: 2022, games: 3}]))
.toEqual([{year: 2022, games: 3}, {year: 2023, games: 0}, {year: 2024, games: 1}]);
expect(yearSeries([])).toEqual([]);
});
});
describe('linePoints', () => {
it('spreads the values over the width, the biggest at the top', () => {
expect(linePoints([2, 4, 0], 100, 40)).toEqual([{x: 0, y: 20}, {x: 50, y: 0}, {x: 100, y: 40}]);
});
});
describe('trendBars', () => {
const day = (date, backlogGames) => ({day: date, backlogGames});
it('puts each bar where its date is, a day without a snapshot keeps its width', () => {
const bars = trendBars([day('2026-09-01', 10), day('2026-09-02', 5), day('2026-09-05', 0)], 100, 40);
expect(bars.map((b) => b.x)).toEqual([0, 20, 80]);
expect(bars.map((b) => b.h)).toEqual([40, 20, 0]);
expect(bars[1].y).toBe(20);
});
it('draws a single day', () => {
expect(trendBars([day('2026-09-01', 3)], 100, 40)).toHaveLength(1);
expect(trendBars([], 100, 40)).toEqual([]);
});
});
+1 -1
View File
@@ -19,7 +19,7 @@ export const formatPercent = (share, locale) =>
share.toLocaleString(locale, {style: 'percent', maximumFractionDigits: 0});
const DAY_MS = 24 * 60 * 60 * 1000;
const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
export const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
/**
* Points of an SVG polyline for the pile per day, oldest left. x follows the date, so days without a snapshot