Public Access
feat(stats): add chart form selection and pie, donut, line charts
Tag rows with 0 games are no longer clickable.
This commit is contained in:
1 parent
8b94a25844
commit
e054e13a59
21 files changed
+815
-56
No files matched your search
@@ -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}),
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user