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(
|
||||
val title: String,
|
||||
val data: List<Pair<Int, Double>>
|
||||
val data: List<Double>
|
||||
)
|
||||
|
||||
data class ReplayData(
|
||||
|
||||
@ -34,16 +34,8 @@ class ScoreService(
|
||||
fun getCharts(db: Record): List<ReplayDataChart> {
|
||||
if (!authService.isAdmin()) return emptyList()
|
||||
|
||||
return listOf(SCORES.SLIDEREND_RELEASE_TIMES to "slider end release times", SCORES.KEYPRESSES_TIMES to "keypress release times")
|
||||
.mapNotNull { (scoreType, title) -> db.get(scoreType)?.let { createChart(it.filterNotNull(), title) } }
|
||||
}
|
||||
|
||||
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)
|
||||
return listOf(SCORES.SLIDEREND_RELEASE_TIMES to "slider end release times", SCORES.KEYPRESSES_TIMES to "keypress times")
|
||||
.mapNotNull { (scoreType, title) -> db.get(scoreType)?.let { ReplayDataChart(title, it.filterNotNull()) } }
|
||||
}
|
||||
|
||||
fun getReplayData(replayId: Long): ReplayData? {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export interface ReplayDataChart {
|
||||
title: string;
|
||||
data: Array<{ first: number, second: number }>;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
export interface ReplayData {
|
||||
|
||||
@ -19,16 +19,3 @@
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="main term mb-2" *ngFor="let chart of this.replayData.charts">
|
||||
<h1>
|
||||
# {{ chart.title }}
|
||||
</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>
|
||||
<ng-container *ngFor="let chart of this.replayData.charts">
|
||||
<app-chart [title]="chart.title" [data]="chart.data"></app-chart>
|
||||
</ng-container>
|
||||
|
||||
<div class="main term" *ngIf="this.replayData.error_distribution && Object.keys(this.replayData.error_distribution).length > 0">
|
||||
<h1># hit distribution</h1>
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
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 {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";
|
||||
import {DistributionEntry, ReplayData, ReplayDataChart} from "../replays";
|
||||
import {DistributionEntry, ReplayData} from "../replays";
|
||||
import {calculateAccuracy} from "../format";
|
||||
import {Title} from "@angular/platform-browser";
|
||||
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
||||
import {ChartComponent} from "../../corelib/components/chart/chart.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-view-score',
|
||||
@ -22,7 +23,8 @@ import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.co
|
||||
NgForOf,
|
||||
NgOptimizedImage,
|
||||
RouterLink,
|
||||
OsuGradeComponent
|
||||
OsuGradeComponent,
|
||||
ChartComponent
|
||||
],
|
||||
templateUrl: './view-score.component.html',
|
||||
styleUrl: './view-score.component.css'
|
||||
@ -90,76 +92,6 @@ export class ViewScoreComponent implements OnInit {
|
||||
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 {
|
||||
this.isLoading = true;
|
||||
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