2024-02-14 16:43:11 +00:00
|
|
|
import {Component, OnInit, ViewChild} from '@angular/core';
|
2024-02-18 13:25:14 +00:00
|
|
|
import {ChartConfiguration, ChartData, DefaultDataPoint} from 'chart.js';
|
2024-02-14 16:43:11 +00:00
|
|
|
import {BaseChartDirective, NgChartsModule} from 'ng2-charts';
|
|
|
|
|
import {HttpClient} from "@angular/common/http";
|
|
|
|
|
import {environment} from "../../environments/environment";
|
|
|
|
|
import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common";
|
|
|
|
|
import {ActivatedRoute, RouterLink} from "@angular/router";
|
|
|
|
|
import {catchError, throwError} from "rxjs";
|
2024-02-18 13:25:14 +00:00
|
|
|
import {DistributionEntry, ReplayData, ReplayDataChart} from "../replays";
|
2024-02-14 16:43:11 +00:00
|
|
|
import {calculateAccuracy} from "../format";
|
|
|
|
|
import {Title} from "@angular/platform-browser";
|
|
|
|
|
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
|
|
|
|
|
|
|
|
|
@Component({
|
|
|
|
|
selector: 'app-view-score',
|
|
|
|
|
standalone: true,
|
|
|
|
|
imports: [
|
|
|
|
|
NgChartsModule,
|
|
|
|
|
JsonPipe,
|
|
|
|
|
NgIf,
|
|
|
|
|
DecimalPipe,
|
|
|
|
|
NgForOf,
|
|
|
|
|
NgOptimizedImage,
|
|
|
|
|
RouterLink,
|
|
|
|
|
OsuGradeComponent
|
|
|
|
|
],
|
|
|
|
|
templateUrl: './view-score.component.html',
|
|
|
|
|
styleUrl: './view-score.component.css'
|
|
|
|
|
})
|
|
|
|
|
export class ViewScoreComponent implements OnInit {
|
|
|
|
|
|
|
|
|
|
@ViewChild(BaseChartDirective)
|
|
|
|
|
public chart!: BaseChartDirective;
|
|
|
|
|
|
|
|
|
|
isLoading = false;
|
|
|
|
|
error: string | null = null;
|
|
|
|
|
replayData: ReplayData | null = null;
|
|
|
|
|
replayId: number | null = null;
|
|
|
|
|
|
|
|
|
|
public barChartLegend = true;
|
|
|
|
|
public barChartPlugins = [];
|
|
|
|
|
|
|
|
|
|
public barChartData: ChartConfiguration<'bar'>['data'] = {
|
|
|
|
|
labels: [],
|
|
|
|
|
datasets: [
|
|
|
|
|
{ data: [], label: 'Miss (%)', backgroundColor: 'rgba(255,0,0,0.66)', borderRadius: 5 },
|
|
|
|
|
{ data: [], label: '50 (%)', backgroundColor: 'rgba(187,129,33,0.66)', borderRadius: 5 },
|
|
|
|
|
{ data: [], label: '100 (%)', backgroundColor: 'rgba(219,255,0,0.8)', borderRadius: 5 },
|
|
|
|
|
{ data: [], label: '300 (%)', backgroundColor: 'rgba(0,255,41,0.66)', borderRadius: 5 }
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
public barChartOptions: ChartConfiguration<'bar'>['options'] = {
|
|
|
|
|
responsive: true,
|
|
|
|
|
scales: {
|
|
|
|
|
x: {
|
|
|
|
|
stacked: true,
|
|
|
|
|
},
|
|
|
|
|
y: {
|
|
|
|
|
stacked: true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
constructor(private http: HttpClient,
|
|
|
|
|
private activatedRoute: ActivatedRoute,
|
|
|
|
|
private title: Title
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
ngOnInit(): void {
|
|
|
|
|
this.activatedRoute.params.subscribe(params => {
|
|
|
|
|
this.replayId = params['replayId'];
|
|
|
|
|
if (this.replayId) {
|
|
|
|
|
this.loadScoreData();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
buildCircleguardUrl(): string {
|
|
|
|
|
if(!this.replayData) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let url = "circleguard://m=" + this.replayData.beatmap_id + "&u=" + this.replayData.user_id;
|
|
|
|
|
|
|
|
|
|
if (this.replayData.mods.length > 0) {
|
|
|
|
|
url += "&m1=" + this.replayData.mods.join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
calculateStatistics(data: Array<{ first: number, second: number }>): Array<{ name: string, value: number }> {
|
|
|
|
|
if (data.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
const sortedData = data.sort((a, b) => a.first - b.first);
|
|
|
|
|
|
|
|
|
|
let mean = data.reduce((acc, curr) => acc + curr.first, 0) / data.length;
|
|
|
|
|
|
|
|
|
|
let median = data.length % 2 === 0 ? (sortedData[data.length / 2 - 1].first + sortedData[data.length / 2].first) / 2 : sortedData[Math.floor(data.length / 2)].first;
|
|
|
|
|
|
|
|
|
|
let variance = data.reduce((acc, curr) => acc + Math.pow(curr.first - mean, 2), 0) / (data.length - 1);
|
|
|
|
|
|
|
|
|
|
let stdDev = Math.sqrt(variance);
|
|
|
|
|
|
|
|
|
|
let min = sortedData[0].first;
|
|
|
|
|
let max = sortedData[sortedData.length - 1].first;
|
|
|
|
|
|
|
|
|
|
const statistics = {
|
|
|
|
|
'mean': mean,
|
|
|
|
|
'median': median,
|
|
|
|
|
'variance': variance,
|
|
|
|
|
'std. dev': stdDev,
|
|
|
|
|
'min': min,
|
|
|
|
|
'max': max
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return Object.entries(statistics).map(([name, value]) => ({ name, value }));
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-18 13:25:14 +00:00
|
|
|
buildChartData(chart: ReplayDataChart): ChartData<"bar", DefaultDataPoint<"bar">, any> {
|
2024-02-18 14:57:32 +00:00
|
|
|
let sortedData = chart.data.sort((a, b) => a.first - b.first);
|
|
|
|
|
|
|
|
|
|
const mean = sortedData.reduce((acc, curr) => acc + curr.first, 0) / sortedData.length;
|
|
|
|
|
const stdDev = Math.sqrt(sortedData.reduce((acc, curr) => acc + Math.pow(curr.first - mean, 2), 0) / sortedData.length);
|
2024-02-18 13:25:14 +00:00
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
sortedData = sortedData.filter(item => {
|
|
|
|
|
const zScore = (item.first - mean) / stdDev;
|
|
|
|
|
return Math.abs(zScore) <= 4;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const minFirst = Math.floor(sortedData[0].first / 2) * 2;
|
|
|
|
|
const maxFirst = Math.ceil(sortedData[sortedData.length - 1].first / 2) * 2;
|
2024-02-18 13:25:14 +00:00
|
|
|
const groupRanges = Array.from({length: (maxFirst - minFirst) / 2 + 1}, (_, i) => minFirst + i * 2);
|
|
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
let groupedData = groupRanges.map(rangeStart => {
|
2024-02-18 13:25:14 +00:00
|
|
|
const rangeEnd = rangeStart + 2;
|
|
|
|
|
const entriesInGroup = sortedData.filter(e => e.first >= rangeStart && e.first < rangeEnd);
|
|
|
|
|
const sumSecond = entriesInGroup.reduce((acc, curr) => acc + curr.second, 0);
|
|
|
|
|
return { first: rangeStart, second: sumSecond };
|
|
|
|
|
});
|
|
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
for (let i = groupedData.length - 1; i >= 0; i--) {
|
|
|
|
|
if (groupedData[i].second === 0 && (i === groupedData.length - 1 || groupedData[i + 1].second === 0)) {
|
|
|
|
|
groupedData.pop();
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
2024-02-18 13:25:14 +00:00
|
|
|
}
|
2024-02-18 11:45:17 +00:00
|
|
|
}
|
|
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
const labels = groupedData.map(({ first }) => `${first}ms to ${first + 2}ms`);
|
2024-02-18 11:45:17 +00:00
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
const datasets = [{
|
|
|
|
|
data: groupedData.map(({ second }) => second),
|
|
|
|
|
label: chart.title + " (%)",
|
|
|
|
|
backgroundColor: 'rgba(0,255,41,0.66)',
|
|
|
|
|
borderRadius: 5
|
|
|
|
|
}];
|
2024-02-18 11:45:17 +00:00
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
return { labels, datasets };
|
2024-02-18 11:45:17 +00:00
|
|
|
}
|
|
|
|
|
|
2024-02-14 16:43:11 +00:00
|
|
|
private loadScoreData(): void {
|
|
|
|
|
this.isLoading = true;
|
|
|
|
|
this.http.get<ReplayData>(`${environment.apiUrl}/score/${this.replayId}`).pipe(
|
|
|
|
|
catchError(error => {
|
|
|
|
|
this.isLoading = false;
|
|
|
|
|
return throwError(() => new Error('An error occurred with the request: ' + error.message));
|
|
|
|
|
})
|
|
|
|
|
).subscribe({
|
|
|
|
|
next: (response) => {
|
|
|
|
|
this.isLoading = false;
|
|
|
|
|
this.replayData = response;
|
|
|
|
|
|
|
|
|
|
this.title.setTitle(
|
|
|
|
|
`${this.replayData.username} on ${this.replayData.beatmap_title} (${this.replayData.beatmap_version})`
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
let errorDistribution = Object.entries(this.replayData.error_distribution);
|
|
|
|
|
|
|
|
|
|
if (errorDistribution.length > 1) {
|
|
|
|
|
const sortedEntries = errorDistribution
|
|
|
|
|
.sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
|
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
const chartData = this.generateChartData(sortedEntries);
|
|
|
|
|
this.barChartData.labels = chartData.labels;
|
2024-02-14 16:43:11 +00:00
|
|
|
for (let i = 0; i < 4; i++) {
|
2024-02-18 14:57:32 +00:00
|
|
|
this.barChartData.datasets[i].data = chartData.datasets[i];
|
2024-02-14 16:43:11 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
},
|
|
|
|
|
error: (error) => {
|
|
|
|
|
this.error = error;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getChartRange(entries: [string, DistributionEntry][]): [number, number] {
|
|
|
|
|
const keys = entries.map(([key, _]) => parseInt(key));
|
2024-02-18 13:50:33 +00:00
|
|
|
|
2024-02-14 16:43:11 +00:00
|
|
|
const minKey = Math.min(...keys);
|
|
|
|
|
const maxKey = Math.max(...keys);
|
|
|
|
|
|
2024-02-18 13:50:33 +00:00
|
|
|
return [minKey, maxKey];
|
2024-02-14 16:43:11 +00:00
|
|
|
}
|
|
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
private generateChartData(entries: [string, DistributionEntry][]): { labels: string[], datasets: number[][] } {
|
2024-02-14 16:43:11 +00:00
|
|
|
const range = this.getChartRange(entries);
|
2024-02-18 14:57:32 +00:00
|
|
|
const labels: string[] = [];
|
|
|
|
|
const datasets: number[][] = Array(4).fill(0).map(() => []);
|
2024-02-14 16:43:11 +00:00
|
|
|
|
|
|
|
|
const defaultPercentageValues: DistributionEntry = {
|
|
|
|
|
percentageMiss: 0,
|
|
|
|
|
percentage50: 0,
|
|
|
|
|
percentage100: 0,
|
|
|
|
|
percentage300: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const entriesMap = new Map<number, DistributionEntry>(entries.map(([key, value]) => [parseInt(key), value]));
|
|
|
|
|
|
2024-02-18 13:50:33 +00:00
|
|
|
for (let key = range[0]; key <= range[1]; key += 2) {
|
2024-02-18 14:57:32 +00:00
|
|
|
const endKey = key + 2 <= range[1] ? key + 2 : key + 1;
|
|
|
|
|
labels.push(`${key}ms to ${endKey}ms`);
|
|
|
|
|
|
2024-02-18 13:50:33 +00:00
|
|
|
const currentEntry = entriesMap.get(key) || { ...defaultPercentageValues };
|
2024-02-18 14:57:32 +00:00
|
|
|
const nextEntry = key + 1 <= range[1] ? (entriesMap.get(key + 1) || { ...defaultPercentageValues }) : defaultPercentageValues;
|
2024-02-18 13:50:33 +00:00
|
|
|
|
|
|
|
|
const sumEntry: DistributionEntry = {
|
|
|
|
|
percentageMiss: currentEntry.percentageMiss + nextEntry.percentageMiss,
|
|
|
|
|
percentage50: currentEntry.percentage50 + nextEntry.percentage50,
|
|
|
|
|
percentage100: currentEntry.percentage100 + nextEntry.percentage100,
|
|
|
|
|
percentage300: currentEntry.percentage300 + nextEntry.percentage300,
|
|
|
|
|
};
|
|
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
datasets[0].push(sumEntry.percentageMiss);
|
|
|
|
|
datasets[1].push(sumEntry.percentage50);
|
|
|
|
|
datasets[2].push(sumEntry.percentage100);
|
|
|
|
|
datasets[3].push(sumEntry.percentage300);
|
2024-02-14 16:43:11 +00:00
|
|
|
}
|
|
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
// Handling the case for an odd last key if needed
|
2024-02-18 13:50:33 +00:00
|
|
|
if (range[1] % 2 !== range[0] % 2) {
|
|
|
|
|
const lastEntry = entriesMap.get(range[1]) || { ...defaultPercentageValues };
|
2024-02-18 14:57:32 +00:00
|
|
|
labels.push(`${range[1]}ms to ${range[1] + 1}ms`);
|
|
|
|
|
datasets[0].push(lastEntry.percentageMiss);
|
|
|
|
|
datasets[1].push(lastEntry.percentage50);
|
|
|
|
|
datasets[2].push(lastEntry.percentage100);
|
|
|
|
|
datasets[3].push(lastEntry.percentage300);
|
2024-02-18 13:50:33 +00:00
|
|
|
}
|
2024-02-14 16:43:11 +00:00
|
|
|
|
2024-02-18 14:57:32 +00:00
|
|
|
return { labels, datasets };
|
2024-02-14 16:43:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected readonly Object = Object;
|
|
|
|
|
protected readonly calculateAccuracy = calculateAccuracy;
|
2024-02-18 13:50:33 +00:00
|
|
|
|
2024-02-14 16:43:11 +00:00
|
|
|
}
|