diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt index 45eace8..0380aa0 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt @@ -71,7 +71,7 @@ data class ReplayPair( data class ReplayDataChart( val title: String, - val data: List> + val data: List ) data class ReplayData( diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt index 15cb171..05b184c 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt @@ -34,16 +34,8 @@ class ScoreService( fun getCharts(db: Record): List { 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, 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? { diff --git a/nise-frontend/src/app/replays.ts b/nise-frontend/src/app/replays.ts index 3ffa449..07c31c8 100644 --- a/nise-frontend/src/app/replays.ts +++ b/nise-frontend/src/app/replays.ts @@ -1,6 +1,6 @@ export interface ReplayDataChart { title: string; - data: Array<{ first: number, second: number }>; + data: number[]; } export interface ReplayData { diff --git a/nise-frontend/src/app/view-score/view-score.component.css b/nise-frontend/src/app/view-score/view-score.component.css index 62bfe51..1905efc 100644 --- a/nise-frontend/src/app/view-score/view-score.component.css +++ b/nise-frontend/src/app/view-score/view-score.component.css @@ -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; -} diff --git a/nise-frontend/src/app/view-score/view-score.component.html b/nise-frontend/src/app/view-score/view-score.component.html index 22dafa1..d5f9671 100644 --- a/nise-frontend/src/app/view-score/view-score.component.html +++ b/nise-frontend/src/app/view-score/view-score.component.html @@ -150,24 +150,9 @@ -
-

- # {{ chart.title }} -

-
    -
  • - {{ stat.name }}: {{ stat.value | number: '1.2-2' }} -
  • -
- - -
+ + +

# hit distribution

diff --git a/nise-frontend/src/app/view-score/view-score.component.ts b/nise-frontend/src/app/view-score/view-score.component.ts index 7ad12c7..769aafb 100644 --- a/nise-frontend/src/app/view-score/view-score.component.ts +++ b/nise-frontend/src/app/view-score/view-score.component.ts @@ -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(`${environment.apiUrl}/score/${this.replayId}`).pipe( diff --git a/nise-frontend/src/corelib/components/chart/chart.component.css b/nise-frontend/src/corelib/components/chart/chart.component.css new file mode 100644 index 0000000..d193001 --- /dev/null +++ b/nise-frontend/src/corelib/components/chart/chart.component.css @@ -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; +} diff --git a/nise-frontend/src/corelib/components/chart/chart.component.html b/nise-frontend/src/corelib/components/chart/chart.component.html new file mode 100644 index 0000000..4f997a9 --- /dev/null +++ b/nise-frontend/src/corelib/components/chart/chart.component.html @@ -0,0 +1,23 @@ +
+

+ # {{ title }} +

+
+ remove outliers + group data + show percentages +
+
    +
  • + {{ stat.name }}: {{ stat.value | number: '1.2-2' }} +
  • +
+ + +
diff --git a/nise-frontend/src/corelib/components/chart/chart.component.ts b/nise-frontend/src/corelib/components/chart/chart.component.ts new file mode 100644 index 0000000..28b1d1a --- /dev/null +++ b/nise-frontend/src/corelib/components/chart/chart.component.ts @@ -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; + }); + } + +}