Made the charts a reusable component, refactor, and added options like group, show percentage, etc
This commit is contained in:
parent
2a7874a055
commit
bd0fce90dd
@ -71,7 +71,7 @@ data class ReplayPair(
|
|||||||
|
|
||||||
data class ReplayDataChart(
|
data class ReplayDataChart(
|
||||||
val title: String,
|
val title: String,
|
||||||
val data: List<Pair<Int, Double>>
|
val data: List<Double>
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ReplayData(
|
data class ReplayData(
|
||||||
|
|||||||
@ -34,16 +34,8 @@ class ScoreService(
|
|||||||
fun getCharts(db: Record): List<ReplayDataChart> {
|
fun getCharts(db: Record): List<ReplayDataChart> {
|
||||||
if (!authService.isAdmin()) return emptyList()
|
if (!authService.isAdmin()) return emptyList()
|
||||||
|
|
||||||
return listOf(SCORES.SLIDEREND_RELEASE_TIMES to "slider end release times", SCORES.KEYPRESSES_TIMES to "keypress release times")
|
return listOf(SCORES.SLIDEREND_RELEASE_TIMES to "slider end release times", SCORES.KEYPRESSES_TIMES to "keypress times")
|
||||||
.mapNotNull { (scoreType, title) -> db.get(scoreType)?.let { createChart(it.filterNotNull(), title) } }
|
.mapNotNull { (scoreType, title) -> db.get(scoreType)?.let { ReplayDataChart(title, it.filterNotNull()) } }
|
||||||
}
|
|
||||||
|
|
||||||
private fun createChart(data: List<Double>, title: String): ReplayDataChart {
|
|
||||||
val frequencyData = data
|
|
||||||
.groupingBy { it }
|
|
||||||
.eachCount()
|
|
||||||
.map { (value, count) -> Pair(value.roundToInt(), count.toDouble() / data.size * 100) }
|
|
||||||
return ReplayDataChart(title, frequencyData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getReplayData(replayId: Long): ReplayData? {
|
fun getReplayData(replayId: Long): ReplayData? {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export interface ReplayDataChart {
|
export interface ReplayDataChart {
|
||||||
title: string;
|
title: string;
|
||||||
data: Array<{ first: number, second: number }>;
|
data: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReplayData {
|
export interface ReplayData {
|
||||||
|
|||||||
@ -19,16 +19,3 @@
|
|||||||
max-width: 20%;
|
max-width: 20%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-statistics {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
list-style-type: none;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-statistics li {
|
|
||||||
display: flex;
|
|
||||||
margin-right: 65px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -150,24 +150,9 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="main term mb-2" *ngFor="let chart of this.replayData.charts">
|
<ng-container *ngFor="let chart of this.replayData.charts">
|
||||||
<h1>
|
<app-chart [title]="chart.title" [data]="chart.data"></app-chart>
|
||||||
# {{ chart.title }}
|
</ng-container>
|
||||||
</h1>
|
|
||||||
<ul class="chart-statistics">
|
|
||||||
<li *ngFor="let stat of this.calculateStatistics(chart.data)">
|
|
||||||
{{ stat.name }}: {{ stat.value | number: '1.2-2' }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<canvas baseChart
|
|
||||||
[data]="this.buildChartData(chart)"
|
|
||||||
[options]="barChartOptions"
|
|
||||||
[plugins]="barChartPlugins"
|
|
||||||
[legend]="false"
|
|
||||||
[type]="'bar'"
|
|
||||||
class="chart">
|
|
||||||
</canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main term" *ngIf="this.replayData.error_distribution && Object.keys(this.replayData.error_distribution).length > 0">
|
<div class="main term" *ngIf="this.replayData.error_distribution && Object.keys(this.replayData.error_distribution).length > 0">
|
||||||
<h1># hit distribution</h1>
|
<h1># hit distribution</h1>
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import {Component, OnInit, ViewChild} from '@angular/core';
|
import {Component, OnInit, ViewChild} from '@angular/core';
|
||||||
import {ChartConfiguration, ChartData, DefaultDataPoint} from 'chart.js';
|
import {ChartConfiguration} from 'chart.js';
|
||||||
import {BaseChartDirective, NgChartsModule} from 'ng2-charts';
|
import {BaseChartDirective, NgChartsModule} from 'ng2-charts';
|
||||||
import {HttpClient} from "@angular/common/http";
|
import {HttpClient} from "@angular/common/http";
|
||||||
import {environment} from "../../environments/environment";
|
import {environment} from "../../environments/environment";
|
||||||
import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common";
|
import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common";
|
||||||
import {ActivatedRoute, RouterLink} from "@angular/router";
|
import {ActivatedRoute, RouterLink} from "@angular/router";
|
||||||
import {catchError, throwError} from "rxjs";
|
import {catchError, throwError} from "rxjs";
|
||||||
import {DistributionEntry, ReplayData, ReplayDataChart} from "../replays";
|
import {DistributionEntry, ReplayData} from "../replays";
|
||||||
import {calculateAccuracy} from "../format";
|
import {calculateAccuracy} from "../format";
|
||||||
import {Title} from "@angular/platform-browser";
|
import {Title} from "@angular/platform-browser";
|
||||||
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
||||||
|
import {ChartComponent} from "../../corelib/components/chart/chart.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-view-score',
|
selector: 'app-view-score',
|
||||||
@ -22,7 +23,8 @@ import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.co
|
|||||||
NgForOf,
|
NgForOf,
|
||||||
NgOptimizedImage,
|
NgOptimizedImage,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
OsuGradeComponent
|
OsuGradeComponent,
|
||||||
|
ChartComponent
|
||||||
],
|
],
|
||||||
templateUrl: './view-score.component.html',
|
templateUrl: './view-score.component.html',
|
||||||
styleUrl: './view-score.component.css'
|
styleUrl: './view-score.component.css'
|
||||||
@ -90,76 +92,6 @@ export class ViewScoreComponent implements OnInit {
|
|||||||
return url;
|
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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
buildChartData(chart: ReplayDataChart): ChartData<"bar", DefaultDataPoint<"bar">, any> {
|
|
||||||
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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
const groupRanges = Array.from({length: (maxFirst - minFirst) / 2 + 1}, (_, i) => minFirst + i * 2);
|
|
||||||
|
|
||||||
let groupedData = groupRanges.map(rangeStart => {
|
|
||||||
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 };
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = groupedData.map(({ first }) => `${first}ms to ${first + 2}ms`);
|
|
||||||
|
|
||||||
const datasets = [{
|
|
||||||
data: groupedData.map(({ second }) => second),
|
|
||||||
label: chart.title + " (%)",
|
|
||||||
backgroundColor: 'rgba(0,255,41,0.66)',
|
|
||||||
borderRadius: 5
|
|
||||||
}];
|
|
||||||
|
|
||||||
return { labels, datasets };
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadScoreData(): void {
|
private loadScoreData(): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.http.get<ReplayData>(`${environment.apiUrl}/score/${this.replayId}`).pipe(
|
this.http.get<ReplayData>(`${environment.apiUrl}/score/${this.replayId}`).pipe(
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
.chart-statistics {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
list-style-type: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-statistics li {
|
||||||
|
display: flex;
|
||||||
|
margin-right: 100px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<div class="main term mb-2">
|
||||||
|
<h1>
|
||||||
|
# {{ title }}
|
||||||
|
</h1>
|
||||||
|
<fieldset class="text-center">
|
||||||
|
<input type="checkbox" checked="checked" [(ngModel)]="this.removeOutliers"> remove outliers
|
||||||
|
<input type="checkbox" checked="checked" [(ngModel)]="this.groupData"> group data
|
||||||
|
<input type="checkbox" checked="checked" [(ngModel)]="this.showPercentages"> show percentages
|
||||||
|
</fieldset>
|
||||||
|
<ul class="chart-statistics">
|
||||||
|
<li *ngFor="let stat of this.calculateStatistics()">
|
||||||
|
{{ stat.name }}: <strong style="margin-left: 4px">{{ stat.value | number: '1.2-2' }}</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<canvas baseChart
|
||||||
|
[data]="this.buildChartData()"
|
||||||
|
[options]="barChartOptions"
|
||||||
|
[plugins]="[]"
|
||||||
|
[legend]="false"
|
||||||
|
[type]="'bar'"
|
||||||
|
class="chart">
|
||||||
|
</canvas>
|
||||||
|
</div>
|
||||||
128
nise-frontend/src/corelib/components/chart/chart.component.ts
Normal file
128
nise-frontend/src/corelib/components/chart/chart.component.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import {ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges} from '@angular/core';
|
||||||
|
import {DecimalPipe, NgForOf} from "@angular/common";
|
||||||
|
import {ChartConfiguration, ChartData, DefaultDataPoint} from "chart.js";
|
||||||
|
import {NgChartsModule} from "ng2-charts";
|
||||||
|
import {FormsModule} from "@angular/forms";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chart',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
DecimalPipe,
|
||||||
|
NgChartsModule,
|
||||||
|
NgForOf,
|
||||||
|
FormsModule
|
||||||
|
],
|
||||||
|
templateUrl: './chart.component.html',
|
||||||
|
styleUrl: './chart.component.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ChartComponent {
|
||||||
|
|
||||||
|
public barChartOptions: ChartConfiguration<'bar'>['options'] = {
|
||||||
|
responsive: true,
|
||||||
|
//@ts-ignore
|
||||||
|
animations: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@Input() title!: string;
|
||||||
|
@Input() data!: number[];
|
||||||
|
|
||||||
|
removeOutliers = true;
|
||||||
|
groupData = true;
|
||||||
|
showPercentages = true;
|
||||||
|
|
||||||
|
calculateStatistics(): Array<{ name: string, value: number }> {
|
||||||
|
if (this.data.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let chartData = this.data;
|
||||||
|
|
||||||
|
if(this.removeOutliers) {
|
||||||
|
chartData = this.removeOutliersZScore(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assuming data is already without outliers and sorted if necessary
|
||||||
|
let sortedData = chartData.slice().sort((a, b) => a - b);
|
||||||
|
|
||||||
|
let mean = sortedData.reduce((acc, curr) => acc + curr, 0) / sortedData.length;
|
||||||
|
let median = sortedData.length % 2 === 0 ? (sortedData[sortedData.length / 2 - 1] + sortedData[sortedData.length / 2]) / 2 : sortedData[Math.floor(sortedData.length / 2)];
|
||||||
|
let variance = sortedData.reduce((acc, curr) => acc + Math.pow(curr - mean, 2), 0) / (sortedData.length - 1);
|
||||||
|
let stdDev = Math.sqrt(variance);
|
||||||
|
let min = sortedData[0];
|
||||||
|
let max = sortedData[sortedData.length - 1];
|
||||||
|
|
||||||
|
const statistics = {
|
||||||
|
'mean': mean,
|
||||||
|
'median': median,
|
||||||
|
'std. dev': stdDev,
|
||||||
|
'min': min,
|
||||||
|
'max': max
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.entries(statistics).map(([name, value]) => ({ name, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
buildChartData(): ChartData<"bar", DefaultDataPoint<"bar">, any> {
|
||||||
|
let chartData = this.data;
|
||||||
|
|
||||||
|
if(this.removeOutliers) {
|
||||||
|
chartData = this.removeOutliersZScore(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedData = chartData.slice().sort((a, b) => a - b);
|
||||||
|
const minData = Math.floor(sortedData[0]);
|
||||||
|
const maxData = Math.ceil(sortedData[sortedData.length - 1]);
|
||||||
|
let dataPoints: { value: number; count: number }[] = [];
|
||||||
|
|
||||||
|
if (this.groupData) {
|
||||||
|
const rangeSize = 2;
|
||||||
|
const groupRanges = Array.from({ length: (maxData - minData) / rangeSize + 1 }, (_, i) => minData + i * rangeSize);
|
||||||
|
|
||||||
|
dataPoints = groupRanges.map(rangeStart => {
|
||||||
|
const rangeEnd = rangeStart + rangeSize;
|
||||||
|
const countInGroup = sortedData.filter(value => value >= rangeStart && value < rangeEnd).length;
|
||||||
|
return { value: rangeStart, count: countInGroup };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Not grouping data, fill with zeros where needed
|
||||||
|
for (let i = minData; i <= maxData; i++) {
|
||||||
|
const countInGroup = sortedData.filter(value => Math.round(value) === i).length;
|
||||||
|
dataPoints.push({ value: i, count: countInGroup });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = dataPoints.reduce((acc, curr) => acc + curr.count, 0);
|
||||||
|
|
||||||
|
const labels = this.groupData ? dataPoints.map(({ value }) => `${value}ms to ${value + 2}ms`) : dataPoints.map(({ value }) => `${value}ms`);
|
||||||
|
const datasets = [{
|
||||||
|
data: dataPoints.map(({ count }) => this.showPercentages ? (count / total * 100) : count),
|
||||||
|
label: this.showPercentages ? this.title + " (%)": this.title,
|
||||||
|
backgroundColor: 'rgba(0,255,41,0.66)',
|
||||||
|
borderRadius: 5
|
||||||
|
}];
|
||||||
|
|
||||||
|
return { labels, datasets };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private removeOutliersZScore(data: number[]): number[] {
|
||||||
|
const mean = data.reduce((acc, curr) => acc + curr, 0) / data.length;
|
||||||
|
const stdDev = Math.sqrt(data.reduce((acc, curr) => acc + Math.pow(curr - mean, 2), 0) / data.length);
|
||||||
|
|
||||||
|
return data.filter(value => {
|
||||||
|
const zScore = (value - mean) / stdDev;
|
||||||
|
return Math.abs(zScore) <= 4;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user