From 2a7874a0553dc825bd3d79f5a17388b0506af94d Mon Sep 17 00:00:00 2001 From: "nise.moe" Date: Sun, 18 Feb 2024 15:57:32 +0100 Subject: [PATCH] Calculate statistics client-side (frontend) and refactor of charts --- .../main/kotlin/com/nisemoe/nise/Models.kt | 4 +- .../com/nisemoe/nise/database/ScoreService.kt | 76 ++------- nise-frontend/src/app/replays.ts | 2 - .../app/view-score/view-score.component.css | 13 ++ .../app/view-score/view-score.component.html | 21 +-- .../app/view-score/view-score.component.ts | 149 +++++++++++------- nise-frontend/src/assets/style.css | 2 +- 7 files changed, 121 insertions(+), 146 deletions(-) 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 747f0c3..45eace8 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt @@ -71,9 +71,7 @@ data class ReplayPair( data class ReplayDataChart( val title: String, - val tableSamples: Int, - val table: List>, - 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 59375ab..15cb171 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 @@ -32,74 +32,18 @@ class ScoreService( } fun getCharts(db: Record): List { - if (!authService.isAdmin()) - return emptyList() + if (!authService.isAdmin()) return emptyList() - val charts = mutableListOf() + 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) } } + } - val sliderEndReleaseTimes = db.get(SCORES.SLIDEREND_RELEASE_TIMES) - if(sliderEndReleaseTimes != null) { - val sliderEndData = sliderEndReleaseTimes - .filterNotNull() - val sliderFrequencyData: List> = sliderEndData - .groupingBy { it } - .eachCount() - .map { (value, count) -> Pair(value, count / sliderEndData.size.toDouble() * 100) } - - val sliderEndTable = mutableListOf>() - - sliderEndTable.add(Triple( - "Median", - String.format("%.2f", db.get(SCORES.SLIDEREND_RELEASE_MEDIAN)), - String.format("%.2f", db.get(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED)) - )) - sliderEndTable.add(Triple( - "Std. deviation", - String.format("%.2f", db.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION)), - String.format("%.2f", db.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED)) - )) - - val sliderEndChart = ReplayDataChart( - title = "slider end release times", - tableSamples = 0, - table = sliderEndTable, - data = sliderFrequencyData - ) - charts.add(sliderEndChart) - } - - val keypressTimes = db.get(SCORES.KEYPRESSES_TIMES) - if(keypressTimes != null) { - val keypressData = keypressTimes - .filterNotNull() - val keypressFrequencyData: List> = keypressData - .groupingBy { it } - .eachCount() - .map { (value, count) -> Pair(value, count / keypressData.size.toDouble() * 100) } - - val keypressTable = mutableListOf>() - - keypressTable.add(Triple( - "Median", - String.format("%.2f", db.get(SCORES.KEYPRESSES_MEDIAN)), - String.format("%.2f", db.get(SCORES.KEYPRESSES_MEDIAN_ADJUSTED)) - )) - keypressTable.add(Triple( - "Std. deviation", - String.format("%.2f", db.get(SCORES.KEYPRESSES_STANDARD_DEVIATION)), - String.format("%.2f", db.get(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED)) - )) - - val keypressChart = ReplayDataChart( - title = "keypress release times", - tableSamples = 0, - table = keypressTable, - data = keypressFrequencyData - ) - charts.add(keypressChart) - } - - return charts + 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) } fun getReplayData(replayId: Long): ReplayData? { diff --git a/nise-frontend/src/app/replays.ts b/nise-frontend/src/app/replays.ts index e73898e..3ffa449 100644 --- a/nise-frontend/src/app/replays.ts +++ b/nise-frontend/src/app/replays.ts @@ -1,7 +1,5 @@ export interface ReplayDataChart { title: string; - tableSamples: number; - table: Array<{ first: string, second: string, third: string }>; data: Array<{ first: number, second: number }>; } 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 1905efc..62bfe51 100644 --- a/nise-frontend/src/app/view-score/view-score.component.css +++ b/nise-frontend/src/app/view-score/view-score.component.css @@ -19,3 +19,16 @@ 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 d2aa880..22dafa1 100644 --- a/nise-frontend/src/app/view-score/view-score.component.html +++ b/nise-frontend/src/app/view-score/view-score.component.html @@ -154,22 +154,11 @@

# {{ chart.title }}

- - - - - - - - - - - -
- value - - adjusted value (no outliers) -
{{ entry.first }}{{ entry.second }}{{ entry.third }}
+
    +
  • + {{ stat.name }}: {{ stat.value | number: '1.2-2' }} +
  • +
, any> { - const sortedData = chart.data.sort((a, b) => a.first - b.first); - - const minFirst = Math.floor(sortedData[0].first / 2) * 2; // Round down to nearest even number - const maxFirst = Math.ceil(sortedData[sortedData.length - 1].first / 2) * 2; // Round up to nearest even number - const groupRanges = Array.from({length: (maxFirst - minFirst) / 2 + 1}, (_, i) => minFirst + i * 2); - - const 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 }; - }); - - 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 }; - } - - buildCircleguardUrl(): string { if(!this.replayData) { return ""; @@ -118,6 +90,76 @@ 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( @@ -140,9 +182,10 @@ export class ViewScoreComponent implements OnInit { const sortedEntries = errorDistribution .sort((a, b) => parseInt(a[0]) - parseInt(b[0])); - this.barChartData.labels = this.generateLabelsFromEntries(sortedEntries); + const chartData = this.generateChartData(sortedEntries); + this.barChartData.labels = chartData.labels; for (let i = 0; i < 4; i++) { - this.barChartData.datasets[i].data = this.generateChartDataFromEntries(sortedEntries, i); + this.barChartData.datasets[i].data = chartData.datasets[i]; } } @@ -162,24 +205,10 @@ export class ViewScoreComponent implements OnInit { return [minKey, maxKey]; } - private generateLabelsFromEntries(entries: [string, DistributionEntry][]): string[] { - const range = this.getChartRange(entries); - - const labelEntries = []; - for (let key = range[0]; key <= range[1]; key += 2) { - const endKey = key + 2; - labelEntries.push(`${key}ms to ${endKey}ms`); - } - - if (range[1] % 2 !== range[0] % 2) { - labelEntries.push(`${range[1]}ms to ${range[1] + 1}ms`); - } - - return labelEntries; - } - - private generateChartDataFromEntries(entries: [string, DistributionEntry][], i: number): number[] { + private generateChartData(entries: [string, DistributionEntry][]): { labels: string[], datasets: number[][] } { const range = this.getChartRange(entries); + const labels: string[] = []; + const datasets: number[][] = Array(4).fill(0).map(() => []); const defaultPercentageValues: DistributionEntry = { percentageMiss: 0, @@ -190,10 +219,12 @@ export class ViewScoreComponent implements OnInit { const entriesMap = new Map(entries.map(([key, value]) => [parseInt(key), value])); - const chartData = []; for (let key = range[0]; key <= range[1]; key += 2) { + const endKey = key + 2 <= range[1] ? key + 2 : key + 1; + labels.push(`${key}ms to ${endKey}ms`); + const currentEntry = entriesMap.get(key) || { ...defaultPercentageValues }; - const nextEntry = entriesMap.get(key + 1) || { ...defaultPercentageValues }; + const nextEntry = key + 1 <= range[1] ? (entriesMap.get(key + 1) || { ...defaultPercentageValues }) : defaultPercentageValues; const sumEntry: DistributionEntry = { percentageMiss: currentEntry.percentageMiss + nextEntry.percentageMiss, @@ -202,21 +233,23 @@ export class ViewScoreComponent implements OnInit { percentage300: currentEntry.percentage300 + nextEntry.percentage300, }; - chartData.push(sumEntry); + datasets[0].push(sumEntry.percentageMiss); + datasets[1].push(sumEntry.percentage50); + datasets[2].push(sumEntry.percentage100); + datasets[3].push(sumEntry.percentage300); } - // Handle the case where the last key is not included because of an odd number of total keys + // Handling the case for an odd last key if needed if (range[1] % 2 !== range[0] % 2) { const lastEntry = entriesMap.get(range[1]) || { ...defaultPercentageValues }; - chartData.push(lastEntry); + 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); } - const propertyMap = ['percentageMiss', 'percentage50', 'percentage100', 'percentage300']; - if (i >= 0 && i < propertyMap.length) { - return chartData.map(entry => entry[propertyMap[i] as keyof DistributionEntry]); - } - - return []; + return { labels, datasets }; } protected readonly Object = Object; diff --git a/nise-frontend/src/assets/style.css b/nise-frontend/src/assets/style.css index cc1625e..206f1a6 100644 --- a/nise-frontend/src/assets/style.css +++ b/nise-frontend/src/assets/style.css @@ -295,7 +295,7 @@ fieldset > p:nth-of-type(2n){ } .chart { - filter: grayscale(20%) sepia(20%); + filter: grayscale(30%) sepia(20%); } .avatar {