add score acc chart
This commit is contained in:
parent
0731d20edc
commit
90c57ad086
@ -24,12 +24,12 @@ import { Config } from "@ssr/common/config";
|
||||
import { SSRCache } from "@ssr/common/cache";
|
||||
import { fetchWithCache } from "../common/cache.util";
|
||||
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
||||
import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/beatleader-score-token";
|
||||
import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/score/score";
|
||||
import {
|
||||
AdditionalScoreData,
|
||||
AdditionalScoreDataModel,
|
||||
} from "@ssr/common/model/additional-score-data/additional-score-data";
|
||||
import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/beatleader-score-improvement-token";
|
||||
import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement";
|
||||
|
||||
const playerScoresCache = new SSRCache({
|
||||
ttl: 1000 * 60, // 1 minute
|
||||
|
35
projects/common/src/service/impl/beatleader.ts
Normal file
35
projects/common/src/service/impl/beatleader.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import Service from "../service";
|
||||
import { BeatSaverMapToken } from "../../types/token/beatsaver/map";
|
||||
import { ScoreStatsToken } from "../../types/token/beatleader/score-stats/score-stats";
|
||||
|
||||
const LOOKUP_MAP_STATS_BY_SCORE_ID_ENDPOINT = `https://cdn.scorestats.beatleader.xyz/:scoreId.json`;
|
||||
|
||||
class BeatLeaderService extends Service {
|
||||
constructor() {
|
||||
super("BeatLeader");
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the score stats for a score
|
||||
*
|
||||
* @param scoreId the score id to get
|
||||
* @returns the score stats, or undefined if nothing was found
|
||||
*/
|
||||
async lookupScoreStats(scoreId: number): Promise<ScoreStatsToken | undefined> {
|
||||
const before = performance.now();
|
||||
this.log(`Looking score stats for "${scoreId}"...`);
|
||||
|
||||
const response = await this.fetch<ScoreStatsToken>(
|
||||
LOOKUP_MAP_STATS_BY_SCORE_ID_ENDPOINT.replace(":scoreId", scoreId)
|
||||
);
|
||||
// Score stats not found
|
||||
if (response == undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.log(`Found score stats for score "${scoreId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export const beatLeaderService = new BeatLeaderService();
|
@ -1,5 +1,5 @@
|
||||
import { BeatLeaderModifierToken } from "./beatleader-modifiers-token";
|
||||
import { BeatLeaderModifierRatingToken } from "./beatleader-modifier-rating-token";
|
||||
import { BeatLeaderModifierToken } from "./modifier/modifiers";
|
||||
import { BeatLeaderModifierRatingToken } from "./modifier/modifier-rating";
|
||||
|
||||
export type BeatLeaderDifficultyToken = {
|
||||
id: number;
|
@ -1,5 +1,5 @@
|
||||
import { BeatLeaderSongToken } from "./beatleader-song-token";
|
||||
import { BeatLeaderDifficultyToken } from "./beatleader-difficulty-token";
|
||||
import { BeatLeaderSongToken } from "./score/song";
|
||||
import { BeatLeaderDifficultyToken } from "./difficulty";
|
||||
|
||||
export type BeatLeaderLeaderboardToken = {
|
||||
id: string;
|
@ -0,0 +1,66 @@
|
||||
export type ScoreStatsAccuracyTrackerToken = {
|
||||
/**
|
||||
* The accuracy of the right hand.
|
||||
*/
|
||||
accRight: number;
|
||||
|
||||
/**
|
||||
* The accuracy of the left hand.
|
||||
*/
|
||||
accLeft: number;
|
||||
|
||||
/**
|
||||
* The left hand pre-swing.
|
||||
*/
|
||||
leftPreswing: number;
|
||||
|
||||
/**
|
||||
* The right hand pre-swing.
|
||||
*/
|
||||
rightPreswing: number;
|
||||
|
||||
/**
|
||||
* The average pre-swing.
|
||||
*/
|
||||
averagePreswing: number;
|
||||
|
||||
/**
|
||||
* The left hand post-swing.
|
||||
*/
|
||||
leftPostswing: number;
|
||||
|
||||
/**
|
||||
* The right hand post-swing.
|
||||
*/
|
||||
rightPostswing: number;
|
||||
|
||||
/**
|
||||
* The left hand time dependence.
|
||||
*/
|
||||
leftTimeDependence: number;
|
||||
|
||||
/**
|
||||
* The right hand time dependence.
|
||||
*/
|
||||
rightTimeDependence: number;
|
||||
|
||||
/**
|
||||
* The left hand average cut.
|
||||
*/
|
||||
leftAverageCut: number[];
|
||||
|
||||
/**
|
||||
* The right hand average cut.
|
||||
*/
|
||||
rightAverageCut: number[];
|
||||
|
||||
/**
|
||||
* The grid accuracy.
|
||||
*/
|
||||
gridAcc: number[];
|
||||
|
||||
/**
|
||||
* The full combo accuracy.
|
||||
*/
|
||||
fcAcc: number;
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
export type ScoreStatsHeadPositionToken = {
|
||||
/**
|
||||
* The X position of the head
|
||||
*/
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* The Y position of the head
|
||||
*/
|
||||
y: number;
|
||||
|
||||
/**
|
||||
* The Z position of the head
|
||||
*/
|
||||
z: number;
|
||||
};
|
@ -0,0 +1,51 @@
|
||||
export type ScoreStatsHitTrackerToken = {
|
||||
/**
|
||||
* The maximum combo achieved.
|
||||
*/
|
||||
maxCombo: number;
|
||||
|
||||
/**
|
||||
* The highest amount of 115 notes hit in a row.
|
||||
*/
|
||||
maxStreak: number;
|
||||
|
||||
/**
|
||||
* The left hand timing.
|
||||
*/
|
||||
leftTiming: number;
|
||||
|
||||
/**
|
||||
* The right hand timing.
|
||||
*/
|
||||
rightTiming: number;
|
||||
|
||||
/**
|
||||
* The left hand misses.
|
||||
*/
|
||||
leftMiss: number;
|
||||
|
||||
/**
|
||||
* The right hand misses.
|
||||
*/
|
||||
rightMiss: number;
|
||||
|
||||
/**
|
||||
* The left hand bad cuts.
|
||||
*/
|
||||
leftBadCuts: number;
|
||||
|
||||
/**
|
||||
* The right hand bad cuts.
|
||||
*/
|
||||
rightBadCuts: number;
|
||||
|
||||
/**
|
||||
* The left hand bombs.
|
||||
*/
|
||||
leftBombs: number;
|
||||
|
||||
/**
|
||||
* The right hand bombs.
|
||||
*/
|
||||
rightBombs: number;
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
export type ScoreStatsGraphTrackerToken = {
|
||||
/**
|
||||
* The accuracy graph data.
|
||||
*/
|
||||
graph: number[];
|
||||
};
|
@ -0,0 +1,26 @@
|
||||
import { ScoreStatsHitTrackerToken } from "./hit-tracker";
|
||||
import { ScoreStatsAccuracyTrackerToken } from "./accuracy-tracker";
|
||||
import { ScoreStatsWinTrackerToken } from "./win-tracker";
|
||||
import { ScoreStatsGraphTrackerToken } from "./score-graph-tracker";
|
||||
|
||||
export type ScoreStatsToken = {
|
||||
/**
|
||||
* The hit tracker stats.
|
||||
*/
|
||||
hitTracker: ScoreStatsHitTrackerToken;
|
||||
|
||||
/**
|
||||
* The accuracy tracker stats.
|
||||
*/
|
||||
accuracyTracker: ScoreStatsAccuracyTrackerToken;
|
||||
|
||||
/**
|
||||
* The win tracker stats.
|
||||
*/
|
||||
winTracker: ScoreStatsWinTrackerToken;
|
||||
|
||||
/**
|
||||
* The score graph tracker stats.
|
||||
*/
|
||||
scoreGraphTracker: ScoreStatsGraphTrackerToken;
|
||||
};
|
@ -0,0 +1,48 @@
|
||||
import { ScoreStatsHeadPositionToken } from "./head-position";
|
||||
|
||||
export type ScoreStatsWinTrackerToken = {
|
||||
/**
|
||||
* Whether the score was won. (not failed)
|
||||
*/
|
||||
won: boolean;
|
||||
|
||||
/**
|
||||
* The time the score ended.
|
||||
*/
|
||||
endTime: number;
|
||||
|
||||
/**
|
||||
* The total amount of pauses.
|
||||
*/
|
||||
nbOfPause: number;
|
||||
|
||||
/**
|
||||
* The total amount of pause time.
|
||||
*/
|
||||
totalPauseDuration: number;
|
||||
|
||||
/**
|
||||
* The jump distance the score was played on.
|
||||
*/
|
||||
jumpDistance: number;
|
||||
|
||||
/**
|
||||
* The average height of the player.
|
||||
*/
|
||||
averageHeight: number;
|
||||
|
||||
/**
|
||||
* The average head position of the player.
|
||||
*/
|
||||
averageHeadPosition: ScoreStatsHeadPositionToken;
|
||||
|
||||
/**
|
||||
* The total score.
|
||||
*/
|
||||
totalScore: number;
|
||||
|
||||
/**
|
||||
* The maximum score for this song.
|
||||
*/
|
||||
maxScore: number;
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import { BeatLeaderLeaderboardToken } from "./beatleader-leaderboard-token";
|
||||
import { BeatLeaderScoreImprovementToken } from "./beatleader-score-improvement-token";
|
||||
import { BeatLeaderScoreOffsetsToken } from "./beatleader-score-offsets-token";
|
||||
import { BeatLeaderPlayerToken } from "./beatleader-player-token";
|
||||
import { BeatLeaderLeaderboardToken } from "../leaderboard";
|
||||
import { BeatLeaderScoreImprovementToken } from "./score-improvement";
|
||||
import { BeatLeaderScoreOffsetsToken } from "./score-offsets";
|
||||
import { BeatLeaderPlayerToken } from "../player";
|
||||
|
||||
export type BeatLeaderScoreToken = {
|
||||
myScore: null; // ??
|
@ -1,5 +1,5 @@
|
||||
import { connectWebSocket, WebsocketCallbacks } from "./websocket";
|
||||
import { BeatLeaderScoreToken } from "../types/token/beatleader/beatleader-score-token";
|
||||
import { BeatLeaderScoreToken } from "../types/token/beatleader/score/score";
|
||||
|
||||
type BeatLeaderWebsocket = {
|
||||
/**
|
||||
|
@ -2,11 +2,11 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart";
|
||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||
import Card from "@/components/card";
|
||||
import { DualRangeSlider } from "@/components/ui/dual-range-slider";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||
|
||||
type Props = {
|
||||
/**
|
@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||
import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats";
|
||||
import { formatTime } from "@ssr/common/utils/time-utils";
|
||||
import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The score stats to use in the chart
|
||||
*/
|
||||
scoreStats: ScoreStatsToken;
|
||||
};
|
||||
|
||||
export default function PlayerScoreAccuracyChart({ scoreStats }: Props) {
|
||||
const graph = scoreStats.scoreGraphTracker.graph;
|
||||
|
||||
const histories: Record<string, (number | null)[]> = {};
|
||||
const labels: string[] = [];
|
||||
|
||||
for (let seconds = 0; seconds < graph.length; seconds++) {
|
||||
labels.push(formatTime(seconds));
|
||||
|
||||
const history = histories["accuracy"];
|
||||
if (!history) {
|
||||
histories["accuracy"] = [];
|
||||
}
|
||||
histories["accuracy"].push(graph[seconds] * 100);
|
||||
}
|
||||
|
||||
const datasetConfig: DatasetConfig[] = [
|
||||
{
|
||||
title: "Accuracy",
|
||||
field: "accuracy",
|
||||
color: "#3EC1D3",
|
||||
axisId: "y",
|
||||
axisConfig: {
|
||||
reverse: false,
|
||||
display: true,
|
||||
displayName: "Accuracy",
|
||||
position: "left",
|
||||
},
|
||||
labelFormatter: (value: number) => `${value.toFixed(2)}%`,
|
||||
},
|
||||
];
|
||||
|
||||
return <GenericChart labels={labels} datasetConfig={datasetConfig} histories={histories} />;
|
||||
}
|
@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
|
||||
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||
import LeaderboardPpChart from "@/components/leaderboard/leaderboard-pp-chart";
|
||||
import LeaderboardPpChart from "@/components/leaderboard/chart/leaderboard-pp-chart";
|
||||
import Card from "@/components/card";
|
||||
|
||||
type LeaderboardDataProps = {
|
||||
|
@ -62,8 +62,7 @@ export default function LeaderboardScores({
|
||||
selectedLeaderboardId + "",
|
||||
currentPage
|
||||
),
|
||||
staleTime: 30 * 1000,
|
||||
enabled: (shouldFetch && isLeaderboardPage) || !isLeaderboardPage,
|
||||
enabled: shouldFetch,
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -15,6 +15,12 @@ import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import Card from "@/components/card";
|
||||
import { MapStats } from "@/components/score/map-stats";
|
||||
import PlayerScoreAccuracyChart from "@/components/leaderboard/chart/player-score-accuracy-chart";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
||||
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||
import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats";
|
||||
import { beatLeaderService } from "@ssr/common/service/impl/beatleader";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
@ -40,10 +46,67 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
type LeaderboardDropdownData = {
|
||||
/**
|
||||
* The initial scores.
|
||||
*/
|
||||
scores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||
|
||||
/**
|
||||
* The score stats for this score,
|
||||
*/
|
||||
scoreStats?: ScoreStatsToken;
|
||||
};
|
||||
|
||||
export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) {
|
||||
const scoresPage = getPageFromRank(score.rank, 12);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const [baseScore, setBaseScore] = useState<number>(score.score);
|
||||
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [leaderboardDropdownData, setLeaderboardDropdownData] = useState<LeaderboardDropdownData | undefined>();
|
||||
|
||||
const { data, isError, isLoading } = useQuery<LeaderboardDropdownData>({
|
||||
queryKey: ["leaderboardDropdownData", leaderboard.id, score.id, isLeaderboardExpanded],
|
||||
queryFn: async () => {
|
||||
const scores = await fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>(
|
||||
"scoresaber",
|
||||
leaderboard.id + "",
|
||||
scoresPage
|
||||
);
|
||||
const scoreStats = score.additionalData
|
||||
? await beatLeaderService.lookupScoreStats(score.additionalData.scoreId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
scores: scores,
|
||||
scoreStats: scoreStats,
|
||||
};
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
enabled: loading,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setLeaderboardDropdownData({
|
||||
...data,
|
||||
scores: data.scores,
|
||||
scoreStats: data.scoreStats,
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleLeaderboardOpen = (isExpanded: boolean) => {
|
||||
if (!isExpanded) {
|
||||
setLeaderboardDropdownData(undefined);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setIsLeaderboardExpanded(isExpanded);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the base score
|
||||
@ -59,6 +122,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
||||
*/
|
||||
useEffect(() => {
|
||||
setIsLeaderboardExpanded(false);
|
||||
setLeaderboardDropdownData(undefined);
|
||||
}, [score]);
|
||||
|
||||
const accuracy = (baseScore / leaderboard.maxScore) * 100;
|
||||
@ -81,7 +145,9 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
||||
beatSaverMap={beatSaverMap}
|
||||
score={score}
|
||||
alwaysSingleLine={isMobile}
|
||||
setIsLeaderboardExpanded={setIsLeaderboardExpanded}
|
||||
setIsLeaderboardExpanded={(isExpanded: boolean) => {
|
||||
handleLeaderboardOpen(isExpanded);
|
||||
}}
|
||||
updateScore={score => {
|
||||
setBaseScore(score.score);
|
||||
}}
|
||||
@ -98,7 +164,7 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
||||
</div>
|
||||
|
||||
{/* Leaderboard */}
|
||||
{isLeaderboardExpanded && (
|
||||
{isLeaderboardExpanded && leaderboardDropdownData && !loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
@ -108,8 +174,15 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
||||
<Card className="flex gap-4 w-full relative border border-input">
|
||||
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
|
||||
|
||||
{leaderboardDropdownData.scoreStats && (
|
||||
<div className="flex gap-2">
|
||||
<PlayerScoreAccuracyChart scoreStats={leaderboardDropdownData.scoreStats} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LeaderboardScores
|
||||
initialPage={getPageFromRank(score.rank, 12)}
|
||||
initialPage={scoresPage}
|
||||
initialScores={leaderboardDropdownData.scores}
|
||||
leaderboard={leaderboard}
|
||||
disableUrlChanging
|
||||
/>
|
||||
|
Reference in New Issue
Block a user