make the chart generic and add an accuracy chart with a toggle between the player charts
Some checks failed
Deploy / deploy (push) Failing after 2m19s

This commit is contained in:
Lee 2024-10-02 10:22:10 +01:00
parent ca323287ba
commit 31416e21a1
11 changed files with 438 additions and 316 deletions

@ -92,6 +92,9 @@ export async function getScoreSaberPlayerFromToken(token: ScoreSaberPlayerToken)
rank: token.rank,
countryRank: token.countryRank,
pp: token.pp,
accuracy: {
averageRankedAccuracy: token.scoreStats.averageRankedAccuracy,
},
};
}
statisticHistory = history;

@ -20,6 +20,30 @@ export function sortPlayerHistory(history: Map<string, PlayerHistory>) {
);
}
/**
* Gets a value from an {@link PlayerHistory}
* based on the field
*
* @param history the history to get the value from
* @param field the field to get
*/
export function getValueFromHistory(history: PlayerHistory, field: string): number | null {
const keys = field.split(".");
let value: any = history;
// Navigate through the keys safely
for (const key of keys) {
if (value && key in value) {
value = value[key];
} else {
return null; // Return null if the key doesn't exist
}
}
// Ensure we return a number or null
return typeof value === "number" ? value : null;
}
/**
* Sorts the player history based on date,
* so the most recent date is first
@ -105,10 +129,14 @@ export async function trackScoreSaberPlayer(dateToday: Date, foundPlayer: IPlaye
if (history == undefined) {
history = {}; // Initialize if history is not found
}
// Set the history data
history.pp = player.pp;
history.countryRank = player.countryRank;
history.rank = player.rank;
history.accuracy = {
averageRankedAccuracy: rawPlayer.scoreStats.averageRankedAccuracy,
};
foundPlayer.setStatisticHistory(dateToday, history);
foundPlayer.sortStatisticHistory();
foundPlayer.lastTracked = new Date();

@ -13,4 +13,14 @@ export interface PlayerHistory {
* The pp of the player.
*/
pp?: number;
/**
* The player's accuracy.
*/
accuracy?: {
/**
* The player's average ranked accuracy.
*/
averageRankedAccuracy?: number;
};
}

@ -1,20 +0,0 @@
export const CustomizedAxisTick = ({
x,
y,
payload,
rotateAngle = -45,
}: {
x?: number;
y?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any;
rotateAngle?: number;
}) => {
return (
<g transform={`translate(${x},${y})`}>
<text x={0} y={0} dy={16} textAnchor="end" fill="#666" transform={`rotate(${rotateAngle})`}>
{payload.value}
</text>
</g>
);
};

@ -0,0 +1,153 @@
"use client";
import { CategoryScale, Chart, Legend, LinearScale, LineElement, PointElement, Title, Tooltip } from "chart.js";
import { Line } from "react-chartjs-2";
import { useIsMobile } from "@/hooks/use-is-mobile";
Chart.register(LinearScale, CategoryScale, PointElement, LineElement, Title, Tooltip, Legend);
export type AxisPosition = "left" | "right";
export type Axis = {
id?: string;
position?: AxisPosition;
display?: boolean;
grid?: { color?: string; drawOnChartArea?: boolean };
title?: { display: boolean; text: string; color?: string };
ticks?: {
stepSize?: number;
};
reverse?: boolean;
};
export type Dataset = {
label: string;
data: (number | null)[];
borderColor: string;
fill: boolean;
lineTension: number;
spanGaps: boolean;
yAxisID: string;
};
export type DatasetConfig = {
title: string;
field: string;
color: string;
axisId: string;
axisConfig: {
reverse: boolean;
display: boolean;
hideOnMobile?: boolean;
displayName: string;
position: AxisPosition;
};
labelFormatter: (value: number) => string;
};
export type ChartProps = {
labels: string[];
datasetConfig: DatasetConfig[];
histories: Record<string, (number | null)[]>;
};
const generateAxis = (
id: string,
reverse: boolean,
display: boolean,
position: AxisPosition,
displayName: string
): Axis => ({
id,
position,
display,
grid: { drawOnChartArea: id === "y", color: id === "y" ? "#252525" : "" },
title: { display: true, text: displayName, color: "#ffffff" },
ticks: { stepSize: 10 },
reverse,
});
const generateDataset = (label: string, data: (number | null)[], borderColor: string, yAxisID: string): Dataset => ({
label,
data,
borderColor,
fill: false,
lineTension: 0.5,
spanGaps: false,
yAxisID,
});
export default function GenericChart({ labels, datasetConfig, histories }: ChartProps) {
const isMobile = useIsMobile();
const axes: Record<string, Axis> = {
x: {
grid: { color: "#252525" },
reverse: false,
},
};
const datasets: Dataset[] = datasetConfig
.map(config => {
const historyArray = histories[config.field];
if (historyArray && historyArray.some(value => value !== null)) {
axes[config.axisId] = generateAxis(
config.axisId,
config.axisConfig.reverse,
isMobile && config.axisConfig.hideOnMobile ? false : config.axisConfig.display,
config.axisConfig.position,
config.axisConfig.displayName
);
return generateDataset(config.title, historyArray, config.color, config.axisId);
}
return null;
})
.filter(Boolean) as Dataset[];
const options: any = {
maintainAspectRatio: false,
responsive: true,
interaction: { mode: "index", intersect: false },
scales: axes,
elements: { point: { radius: 0 } },
plugins: {
legend: { position: "top", labels: { color: "white" } },
tooltip: {
callbacks: {
label(context: any) {
const value = Number(context.parsed.y);
const config = datasetConfig.find(cfg => cfg.title === context.dataset.label);
return config?.labelFormatter(value) ?? "";
},
},
},
},
};
const data = { labels, datasets };
return (
<div className="block h-[320px] w-full relative">
<Line
className="max-w-[100%]"
options={options}
data={data}
plugins={[
{
id: "legend-padding",
beforeInit: (chart: any) => {
const originalFit = chart.legend.fit;
chart.legend.fit = function fit() {
originalFit.bind(chart.legend)();
this.height += 2;
};
},
},
]}
/>
</div>
);
}

@ -0,0 +1,75 @@
"use client";
import { getDaysAgo, parseDate } from "@/common/time-utils";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import React from "react";
import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart";
import { getValueFromHistory } from "@/common/player-utils";
type Props = {
/**
* The player the chart is for
*/
player: ScoreSaberPlayer;
/**
* The data to render.
*/
datasetConfig: DatasetConfig[];
};
export default function GenericPlayerChart({ player, datasetConfig }: Props) {
if (!player.statisticHistory || Object.keys(player.statisticHistory).length === 0) {
return (
<div className="flex justify-center">
<p>Unable to load player rank chart, missing data...</p>
</div>
);
}
const labels: string[] = [];
const histories: Record<string, (number | null)[]> = {};
// Initialize histories for each dataset
datasetConfig.forEach(config => {
histories[config.field] = [];
});
// Sort the statistic entries by date
const statisticEntries = Object.entries(player.statisticHistory).sort(
([a], [b]) => parseDate(a).getTime() - parseDate(b).getTime()
);
let previousDate: Date | null = null;
// Iterate through each statistic entry
for (const [dateString, history] of statisticEntries) {
const currentDate = parseDate(dateString);
// Fill in missing days with null values
if (previousDate) {
const diffDays = Math.floor((currentDate.getTime() - previousDate.getTime()) / (1000 * 60 * 60 * 24));
for (let i = 1; i < diffDays; i++) {
labels.push(`${getDaysAgo(new Date(currentDate.getTime() - i * 24 * 60 * 60 * 1000))} days ago`);
datasetConfig.forEach(config => {
histories[config.field].push(null);
});
}
}
// Add today's label
const daysAgo = getDaysAgo(currentDate);
labels.push(daysAgo === 0 ? "Today" : `${daysAgo} days ago`);
// Push the historical data to histories
datasetConfig.forEach(config => {
histories[config.field].push(getValueFromHistory(history, config.field) ?? null);
});
previousDate = currentDate; // Update the previousDate for the next iteration
}
// Render the GenericChart with collected data
return <GenericChart labels={labels} datasetConfig={datasetConfig} histories={histories} />;
}

@ -0,0 +1,32 @@
"use client";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import React from "react";
import { DatasetConfig } from "@/components/chart/generic-chart";
import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
type Props = {
player: ScoreSaberPlayer;
};
// Dataset configuration for the chart
const datasetConfig: DatasetConfig[] = [
{
title: "Average Ranked Accuracy",
field: "accuracy.averageRankedAccuracy",
color: "#606fff",
axisId: "y",
axisConfig: {
reverse: false,
display: true,
hideOnMobile: false,
displayName: "Average Ranked Accuracy",
position: "left",
},
labelFormatter: (value: number) => `Average Ranked Accuracy ${value.toFixed(2)}%`,
},
];
export default function PlayerAccuracyChart({ player }: Props) {
return <GenericPlayerChart player={player} datasetConfig={datasetConfig} />;
}

@ -0,0 +1,76 @@
"use client";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart";
import PlayerRankingChart from "@/components/player/chart/player-ranking-chart";
import { FC, useState } from "react";
import Tooltip from "@/components/tooltip";
type PlayerChartsProps = {
/**
* The player who the charts are for
*/
player: ScoreSaberPlayer;
};
type SelectedChart = {
/**
* The index of the selected chart.
*/
index: number;
/**
* The chart to render.
*/
chart: FC<PlayerChartsProps>;
/**
* The label of the selected chart.
*/
label: string;
};
export default function PlayerCharts({ player }: PlayerChartsProps) {
const charts: SelectedChart[] = [
{
index: 0,
chart: PlayerRankingChart,
label: "Ranking",
},
{
index: 1,
chart: PlayerAccuracyChart,
label: "Accuracy",
},
];
const [selectedChart, setSelectedChart] = useState<SelectedChart>(charts[0]);
return (
<>
{selectedChart.chart({ player })}
<div className="flex items-center justify-center gap-2">
{charts.map(chart => {
const isSelected = chart.index === selectedChart.index;
return (
<Tooltip
key={chart.index}
display={
<div className="flex justify-center items-center flex-col">
<p>{chart.label} Chart</p>
<p className="text-gray-600">{isSelected ? "Currently Selected" : "Click to view"}</p>
</div>
}
>
<button
onClick={() => setSelectedChart(chart)}
className={`border ${isSelected ? "bg-input brightness-75" : "border-input"} w-fit p-2 rounded-full`}
/>
</Tooltip>
);
})}
</div>
</>
);
}

@ -0,0 +1,59 @@
"use client";
import { formatNumberWithCommas } from "@/common/number-utils";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import React from "react";
import { DatasetConfig } from "@/components/chart/generic-chart";
import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
type Props = {
player: ScoreSaberPlayer;
};
// Dataset configuration for the chart
const datasetConfig: DatasetConfig[] = [
{
title: "Rank",
field: "rank",
color: "#3EC1D3",
axisId: "y",
axisConfig: {
reverse: true,
display: true,
displayName: "Global Rank",
position: "left",
},
labelFormatter: (value: number) => `Rank #${formatNumberWithCommas(value)}`,
},
{
title: "Country Rank",
field: "countryRank",
color: "#FFEA00",
axisId: "y1",
axisConfig: {
reverse: true,
display: false,
displayName: "Country Rank",
position: "left",
},
labelFormatter: (value: number) => `Country Rank #${formatNumberWithCommas(value)}`,
},
{
title: "PP",
field: "pp",
color: "#606fff",
axisId: "y2",
axisConfig: {
reverse: false,
display: true,
hideOnMobile: true,
displayName: "PP",
position: "right",
},
labelFormatter: (value: number) => `PP ${formatNumberWithCommas(value)}pp`,
},
];
export default function PlayerRankingChart({ player }: Props) {
return <GenericPlayerChart player={player} datasetConfig={datasetConfig} />;
}

@ -6,7 +6,6 @@ import { ScoreSort } from "@/common/model/score/score-sort";
import { useQuery } from "@tanstack/react-query";
import Mini from "../ranking/mini";
import PlayerHeader from "./player-header";
import PlayerRankChart from "./player-rank-chart";
import PlayerScores from "./player-scores";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import Card from "@/components/card";
@ -14,6 +13,7 @@ import PlayerBadges from "@/components/player/player-badges";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useIsVisible } from "@/hooks/use-is-visible";
import { useRef } from "react";
import PlayerCharts from "@/components/player/chart/player-charts";
type Props = {
initialPlayerData: ScoreSaberPlayer;
@ -52,7 +52,7 @@ export default function PlayerData({
{!player.inactive && (
<Card className="gap-1">
<PlayerBadges player={player} />
<PlayerRankChart player={player} />
<PlayerCharts player={player} />
</Card>
)}
<PlayerScores

@ -1,294 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { formatNumberWithCommas } from "@/common/number-utils";
import { CategoryScale, Chart, Legend, LinearScale, LineElement, PointElement, Title, Tooltip } from "chart.js";
import { Line } from "react-chartjs-2";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import { getDaysAgo, parseDate } from "@/common/time-utils";
import { useIsMobile } from "@/hooks/use-is-mobile";
Chart.register(LinearScale, CategoryScale, PointElement, LineElement, Title, Tooltip, Legend);
type AxisPosition = "left" | "right";
/**
* A ChartJS axis
*/
type Axis = {
id?: string;
position?: AxisPosition;
display?: boolean;
grid?: { color?: string; drawOnChartArea?: boolean };
title?: { display: boolean; text: string; color?: string };
ticks?: {
stepSize?: number;
};
reverse?: boolean;
};
/**
* A ChartJS dataset
*/
type Dataset = {
label: string;
data: (number | null)[]; // Allow null values for gaps
borderColor: string;
fill: boolean;
lineTension: number;
spanGaps: boolean;
yAxisID: string;
};
/**
* Generate an axis
*
* @param id the id of the axis
* @param reverse if the axis should be reversed
* @param display if the axis should be displayed
* @param position the position of the axis
* @param displayName the optional name to display for the axis
*/
const generateAxis = (
id: string,
reverse: boolean,
display: boolean,
position: AxisPosition,
displayName: string
): Axis => ({
id,
position,
display,
grid: {
drawOnChartArea: id === "y",
color: id === "y" ? "#252525" : "",
},
title: {
display: true,
text: displayName,
color: "#ffffff",
},
ticks: {
stepSize: 10,
},
reverse,
});
/**
* Generate a dataset
*
* @param label the label of the dataset
* @param data the data of the dataset
* @param borderColor the border color of the dataset
* @param yAxisID the ID of the y-axis
*/
const generateDataset = (label: string, data: (number | null)[], borderColor: string, yAxisID: string): Dataset => ({
label,
data,
borderColor,
fill: false,
lineTension: 0.5,
spanGaps: false, // Set to false, so we can allow gaps
yAxisID,
});
type DatasetConfig = {
title: string;
field: string;
color: string;
axisId: string;
axisConfig: {
reverse: boolean;
display: boolean;
hideOnMobile?: boolean;
displayName: string;
position: AxisPosition;
};
labelFormatter: (value: number) => string;
};
// Configuration array for datasets and axes with label formatters
const datasetConfig: DatasetConfig[] = [
{
title: "Rank",
field: "rank",
color: "#3EC1D3",
axisId: "y",
axisConfig: {
reverse: true,
display: true,
displayName: "Global Rank",
position: "left",
},
labelFormatter: (value: number) => `Rank #${formatNumberWithCommas(value)}`,
},
{
title: "Country Rank",
field: "countryRank",
color: "#FFEA00",
axisId: "y1",
axisConfig: {
reverse: true,
display: false,
displayName: "Country Rank",
position: "left",
},
labelFormatter: (value: number) => `Country Rank #${formatNumberWithCommas(value)}`,
},
{
title: "PP",
field: "pp",
color: "#606fff",
axisId: "y2",
axisConfig: {
reverse: false,
display: true,
hideOnMobile: true,
displayName: "PP",
position: "right",
},
labelFormatter: (value: number) => `PP ${formatNumberWithCommas(value)}pp`,
},
];
type Props = {
player: ScoreSaberPlayer;
};
export default function PlayerRankChart({ player }: Props) {
const isMobile = useIsMobile();
if (!player.statisticHistory || Object.keys(player.statisticHistory).length === 0) {
return (
<div className="flex justify-center">
<p>Unable to load player rank chart, missing data...</p>
</div>
);
}
const labels: string[] = [];
const histories: Record<string, (number | null)[]> = {
rank: [],
countryRank: [],
pp: [],
};
const statisticEntries = Object.entries(player.statisticHistory).sort(
([a], [b]) => parseDate(a).getTime() - parseDate(b).getTime()
);
let previousDate: Date | null = null;
// Create labels and history data
for (const [dateString, history] of statisticEntries) {
const currentDate = parseDate(dateString);
// Insert nulls for missing days
if (previousDate) {
const diffDays = Math.floor((currentDate.getTime() - previousDate.getTime()) / (1000 * 60 * 60 * 24));
for (let i = 1; i < diffDays; i++) {
labels.push(`${getDaysAgo(new Date(currentDate.getTime() - i * 24 * 60 * 60 * 1000))} days ago`);
datasetConfig.forEach(config => {
histories[config.field].push(null);
});
}
}
const daysAgo = getDaysAgo(currentDate);
labels.push(daysAgo === 0 ? "Today" : `${daysAgo} days ago`);
// stupid typescript crying wahh wahh wahh - https://youtu.be/hBEKgHDzm_s?si=ekOdMMdb-lFnA1Yz&t=11
datasetConfig.forEach(config => {
(histories as any)[config.field].push((history as any)[config.field] ?? null);
});
previousDate = currentDate;
}
// Dynamically create axes and datasets based on datasetConfig
const axes: Record<string, Axis> = {
x: {
grid: {
color: "#252525", // gray grid lines
},
reverse: false,
},
};
const datasets: Dataset[] = datasetConfig
.map(config => {
if (histories[config.field].some(value => value !== null)) {
axes[config.axisId] = generateAxis(
config.axisId,
config.axisConfig.reverse,
isMobile && config.axisConfig.hideOnMobile ? false : config.axisConfig.display,
config.axisConfig.position,
config.axisConfig.displayName
);
return generateDataset(config.title, histories[config.field], config.color, config.axisId);
}
return null;
})
.filter(Boolean) as Dataset[];
const options: any = {
maintainAspectRatio: false,
responsive: true,
interaction: {
mode: "index",
intersect: false,
},
scales: axes,
elements: {
point: {
radius: 0,
},
},
plugins: {
legend: {
position: "top" as const,
labels: {
color: "white",
},
},
tooltip: {
callbacks: {
label(context: any) {
const value = Number(context.parsed.y);
const config = datasetConfig.find(cfg => cfg.title === context.dataset.label);
return config?.labelFormatter(value) ?? "";
},
},
},
},
};
const data = {
labels,
datasets,
};
return (
<div className="block h-[320px] w-full relative">
<Line
className="max-w-[100%]"
options={options}
data={data}
plugins={[
{
id: "legend-padding",
beforeInit: (chart: any) => {
const originalFit = chart.legend.fit;
chart.legend.fit = function fit() {
originalFit.bind(chart.legend)();
this.height += 2;
};
},
},
]}
/>
</div>
);
}