Calculate statistics client-side (frontend) and refactor of charts

This commit is contained in:
nise.moe 2024-02-18 15:57:32 +01:00
parent 4f49a375d0
commit 2a7874a055
7 changed files with 121 additions and 146 deletions

View File

@ -71,9 +71,7 @@ data class ReplayPair(
data class ReplayDataChart( data class ReplayDataChart(
val title: String, val title: String,
val tableSamples: Int, val data: List<Pair<Int, Double>>
val table: List<Triple<String, String, String>>,
val data: List<Pair<Double, Double>>
) )
data class ReplayData( data class ReplayData(

View File

@ -32,74 +32,18 @@ class ScoreService(
} }
fun getCharts(db: Record): List<ReplayDataChart> { fun getCharts(db: Record): List<ReplayDataChart> {
if (!authService.isAdmin()) if (!authService.isAdmin()) return emptyList()
return emptyList()
val charts = mutableListOf<ReplayDataChart>() 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) private fun createChart(data: List<Double>, title: String): ReplayDataChart {
if(sliderEndReleaseTimes != null) { val frequencyData = data
val sliderEndData = sliderEndReleaseTimes .groupingBy { it }
.filterNotNull() .eachCount()
val sliderFrequencyData: List<Pair<Double, Double>> = sliderEndData .map { (value, count) -> Pair(value.roundToInt(), count.toDouble() / data.size * 100) }
.groupingBy { it } return ReplayDataChart(title, frequencyData)
.eachCount()
.map { (value, count) -> Pair(value, count / sliderEndData.size.toDouble() * 100) }
val sliderEndTable = mutableListOf<Triple<String, String, String>>()
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<Pair<Double, Double>> = keypressData
.groupingBy { it }
.eachCount()
.map { (value, count) -> Pair(value, count / keypressData.size.toDouble() * 100) }
val keypressTable = mutableListOf<Triple<String, String, String>>()
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
} }
fun getReplayData(replayId: Long): ReplayData? { fun getReplayData(replayId: Long): ReplayData? {

View File

@ -1,7 +1,5 @@
export interface ReplayDataChart { export interface ReplayDataChart {
title: string; title: string;
tableSamples: number;
table: Array<{ first: string, second: string, third: string }>;
data: Array<{ first: number, second: number }>; data: Array<{ first: number, second: number }>;
} }

View File

@ -19,3 +19,16 @@
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;
}

View File

@ -154,22 +154,11 @@
<h1> <h1>
# {{ chart.title }} # {{ chart.title }}
</h1> </h1>
<table class="mb-4"> <ul class="chart-statistics">
<thead> <li *ngFor="let stat of this.calculateStatistics(chart.data)">
<th></th> {{ stat.name }}: {{ stat.value | number: '1.2-2' }}
<th> </li>
value </ul>
</th>
<th>
adjusted value (no outliers)
</th>
</thead>
<tr *ngFor="let entry of chart.table">
<td>{{ entry.first }}</td>
<td class="text-center">{{ entry.second }}</td>
<td class="text-center">{{ entry.third }}</td>
</tr>
</table>
<canvas baseChart <canvas baseChart
[data]="this.buildChartData(chart)" [data]="this.buildChartData(chart)"
[options]="barChartOptions" [options]="barChartOptions"

View File

@ -76,34 +76,6 @@ export class ViewScoreComponent implements OnInit {
}); });
} }
buildChartData(chart: ReplayDataChart): ChartData<"bar", DefaultDataPoint<"bar">, 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 { buildCircleguardUrl(): string {
if(!this.replayData) { if(!this.replayData) {
return ""; return "";
@ -118,6 +90,76 @@ 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(
@ -140,9 +182,10 @@ export class ViewScoreComponent implements OnInit {
const sortedEntries = errorDistribution const sortedEntries = errorDistribution
.sort((a, b) => parseInt(a[0]) - parseInt(b[0])); .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++) { 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]; return [minKey, maxKey];
} }
private generateLabelsFromEntries(entries: [string, DistributionEntry][]): string[] { private generateChartData(entries: [string, DistributionEntry][]): { labels: string[], datasets: number[][] } {
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[] {
const range = this.getChartRange(entries); const range = this.getChartRange(entries);
const labels: string[] = [];
const datasets: number[][] = Array(4).fill(0).map(() => []);
const defaultPercentageValues: DistributionEntry = { const defaultPercentageValues: DistributionEntry = {
percentageMiss: 0, percentageMiss: 0,
@ -190,10 +219,12 @@ export class ViewScoreComponent implements OnInit {
const entriesMap = new Map<number, DistributionEntry>(entries.map(([key, value]) => [parseInt(key), value])); const entriesMap = new Map<number, DistributionEntry>(entries.map(([key, value]) => [parseInt(key), value]));
const chartData = [];
for (let key = range[0]; key <= range[1]; key += 2) { 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 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 = { const sumEntry: DistributionEntry = {
percentageMiss: currentEntry.percentageMiss + nextEntry.percentageMiss, percentageMiss: currentEntry.percentageMiss + nextEntry.percentageMiss,
@ -202,21 +233,23 @@ export class ViewScoreComponent implements OnInit {
percentage300: currentEntry.percentage300 + nextEntry.percentage300, 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) { if (range[1] % 2 !== range[0] % 2) {
const lastEntry = entriesMap.get(range[1]) || { ...defaultPercentageValues }; 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']; return { labels, datasets };
if (i >= 0 && i < propertyMap.length) {
return chartData.map(entry => entry[propertyMap[i] as keyof DistributionEntry]);
}
return [];
} }
protected readonly Object = Object; protected readonly Object = Object;

View File

@ -295,7 +295,7 @@ fieldset > p:nth-of-type(2n){
} }
.chart { .chart {
filter: grayscale(20%) sepia(20%); filter: grayscale(30%) sepia(20%);
} }
.avatar { .avatar {