diff --git a/src/common/model/player/impl/scoresaber-player.ts b/src/common/model/player/impl/scoresaber-player.ts index 9122d33..d323a31 100644 --- a/src/common/model/player/impl/scoresaber-player.ts +++ b/src/common/model/player/impl/scoresaber-player.ts @@ -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; diff --git a/src/common/player-utils.ts b/src/common/player-utils.ts index 475c857..25e416f 100644 --- a/src/common/player-utils.ts +++ b/src/common/player-utils.ts @@ -20,6 +20,30 @@ export function sortPlayerHistory(history: Map) { ); } +/** + * 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(); diff --git a/src/common/player/player-history.ts b/src/common/player/player-history.ts index 1f7dd81..5be6d1a 100644 --- a/src/common/player/player-history.ts +++ b/src/common/player/player-history.ts @@ -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; + }; } diff --git a/src/components/chart/customized-axis-tick.tsx b/src/components/chart/customized-axis-tick.tsx deleted file mode 100644 index 5c27a17..0000000 --- a/src/components/chart/customized-axis-tick.tsx +++ /dev/null @@ -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 ( - - - {payload.value} - - - ); -}; diff --git a/src/components/chart/generic-chart.tsx b/src/components/chart/generic-chart.tsx new file mode 100644 index 0000000..25678dc --- /dev/null +++ b/src/components/chart/generic-chart.tsx @@ -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; +}; + +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 = { + 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 ( +
+ { + const originalFit = chart.legend.fit; + chart.legend.fit = function fit() { + originalFit.bind(chart.legend)(); + this.height += 2; + }; + }, + }, + ]} + /> +
+ ); +} diff --git a/src/components/player/chart/generic-player-chart.tsx b/src/components/player/chart/generic-player-chart.tsx new file mode 100644 index 0000000..ff1c585 --- /dev/null +++ b/src/components/player/chart/generic-player-chart.tsx @@ -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 ( +
+

Unable to load player rank chart, missing data...

+
+ ); + } + + const labels: string[] = []; + const histories: Record = {}; + + // 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 ; +} diff --git a/src/components/player/chart/player-accuracy-chart.tsx b/src/components/player/chart/player-accuracy-chart.tsx new file mode 100644 index 0000000..88c88a2 --- /dev/null +++ b/src/components/player/chart/player-accuracy-chart.tsx @@ -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 ; +} diff --git a/src/components/player/chart/player-charts.tsx b/src/components/player/chart/player-charts.tsx new file mode 100644 index 0000000..c5b6326 --- /dev/null +++ b/src/components/player/chart/player-charts.tsx @@ -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; + + /** + * 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(charts[0]); + + return ( + <> + {selectedChart.chart({ player })} + +
+ {charts.map(chart => { + const isSelected = chart.index === selectedChart.index; + + return ( + +

{chart.label} Chart

+

{isSelected ? "Currently Selected" : "Click to view"}

+
+ } + > +