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
Some checks failed
Deploy / deploy (push) Failing after 2m19s
This commit is contained in:
parent
ca323287ba
commit
31416e21a1
@ -92,6 +92,9 @@ export async function getScoreSaberPlayerFromToken(token: ScoreSaberPlayerToken)
|
|||||||
rank: token.rank,
|
rank: token.rank,
|
||||||
countryRank: token.countryRank,
|
countryRank: token.countryRank,
|
||||||
pp: token.pp,
|
pp: token.pp,
|
||||||
|
accuracy: {
|
||||||
|
averageRankedAccuracy: token.scoreStats.averageRankedAccuracy,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
statisticHistory = history;
|
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,
|
* Sorts the player history based on date,
|
||||||
* so the most recent date is first
|
* so the most recent date is first
|
||||||
@ -105,10 +129,14 @@ export async function trackScoreSaberPlayer(dateToday: Date, foundPlayer: IPlaye
|
|||||||
if (history == undefined) {
|
if (history == undefined) {
|
||||||
history = {}; // Initialize if history is not found
|
history = {}; // Initialize if history is not found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the history data
|
// Set the history data
|
||||||
history.pp = player.pp;
|
history.pp = player.pp;
|
||||||
history.countryRank = player.countryRank;
|
history.countryRank = player.countryRank;
|
||||||
history.rank = player.rank;
|
history.rank = player.rank;
|
||||||
|
history.accuracy = {
|
||||||
|
averageRankedAccuracy: rawPlayer.scoreStats.averageRankedAccuracy,
|
||||||
|
};
|
||||||
foundPlayer.setStatisticHistory(dateToday, history);
|
foundPlayer.setStatisticHistory(dateToday, history);
|
||||||
foundPlayer.sortStatisticHistory();
|
foundPlayer.sortStatisticHistory();
|
||||||
foundPlayer.lastTracked = new Date();
|
foundPlayer.lastTracked = new Date();
|
||||||
|
@ -13,4 +13,14 @@ export interface PlayerHistory {
|
|||||||
* The pp of the player.
|
* The pp of the player.
|
||||||
*/
|
*/
|
||||||
pp?: number;
|
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>
|
|
||||||
);
|
|
||||||
};
|
|
153
src/components/chart/generic-chart.tsx
Normal file
153
src/components/chart/generic-chart.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
75
src/components/player/chart/generic-player-chart.tsx
Normal file
75
src/components/player/chart/generic-player-chart.tsx
Normal file
@ -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} />;
|
||||||
|
}
|
32
src/components/player/chart/player-accuracy-chart.tsx
Normal file
32
src/components/player/chart/player-accuracy-chart.tsx
Normal file
@ -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} />;
|
||||||
|
}
|
76
src/components/player/chart/player-charts.tsx
Normal file
76
src/components/player/chart/player-charts.tsx
Normal file
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
59
src/components/player/chart/player-ranking-chart.tsx
Normal file
59
src/components/player/chart/player-ranking-chart.tsx
Normal file
@ -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 { useQuery } from "@tanstack/react-query";
|
||||||
import Mini from "../ranking/mini";
|
import Mini from "../ranking/mini";
|
||||||
import PlayerHeader from "./player-header";
|
import PlayerHeader from "./player-header";
|
||||||
import PlayerRankChart from "./player-rank-chart";
|
|
||||||
import PlayerScores from "./player-scores";
|
import PlayerScores from "./player-scores";
|
||||||
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||||
import Card from "@/components/card";
|
import Card from "@/components/card";
|
||||||
@ -14,6 +13,7 @@ import PlayerBadges from "@/components/player/player-badges";
|
|||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
import { useIsVisible } from "@/hooks/use-is-visible";
|
import { useIsVisible } from "@/hooks/use-is-visible";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
import PlayerCharts from "@/components/player/chart/player-charts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialPlayerData: ScoreSaberPlayer;
|
initialPlayerData: ScoreSaberPlayer;
|
||||||
@ -52,7 +52,7 @@ export default function PlayerData({
|
|||||||
{!player.inactive && (
|
{!player.inactive && (
|
||||||
<Card className="gap-1">
|
<Card className="gap-1">
|
||||||
<PlayerBadges player={player} />
|
<PlayerBadges player={player} />
|
||||||
<PlayerRankChart player={player} />
|
<PlayerCharts player={player} />
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<PlayerScores
|
<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>
|
|
||||||
);
|
|
||||||
}
|
|
Reference in New Issue
Block a user