Made the charts a reusable component, refactor, and added options like group, show percentage, etc

This commit is contained in:
nise.moe 2024-02-18 17:02:32 +01:00
parent 2a7874a055
commit bd0fce90dd
9 changed files with 176 additions and 116 deletions

View File

@ -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(

View File

@ -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? {

View File

@ -1,6 +1,6 @@
export interface ReplayDataChart {
title: string;
data: Array<{ first: number, second: number }>;
data: number[];
}
export interface ReplayData {

View File

@ -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;
}

View File

@ -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>

View File

@ -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(

View File

@ -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;
}

View File

@ -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>

View 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;
});
}
}