Calculate statistics client-side (frontend) and refactor of charts
This commit is contained in:
parent
4f49a375d0
commit
2a7874a055
@ -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(
|
||||||
|
|||||||
@ -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)
|
|
||||||
if(sliderEndReleaseTimes != null) {
|
|
||||||
val sliderEndData = sliderEndReleaseTimes
|
|
||||||
.filterNotNull()
|
|
||||||
val sliderFrequencyData: List<Pair<Double, Double>> = sliderEndData
|
|
||||||
.groupingBy { it }
|
|
||||||
.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)
|
private fun createChart(data: List<Double>, title: String): ReplayDataChart {
|
||||||
if(keypressTimes != null) {
|
val frequencyData = data
|
||||||
val keypressData = keypressTimes
|
|
||||||
.filterNotNull()
|
|
||||||
val keypressFrequencyData: List<Pair<Double, Double>> = keypressData
|
|
||||||
.groupingBy { it }
|
.groupingBy { it }
|
||||||
.eachCount()
|
.eachCount()
|
||||||
.map { (value, count) -> Pair(value, count / keypressData.size.toDouble() * 100) }
|
.map { (value, count) -> Pair(value.roundToInt(), count.toDouble() / data.size * 100) }
|
||||||
|
return ReplayDataChart(title, frequencyData)
|
||||||
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? {
|
||||||
|
|||||||
@ -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 }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -295,7 +295,7 @@ fieldset > p:nth-of-type(2n){
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
filter: grayscale(20%) sepia(20%);
|
filter: grayscale(30%) sepia(20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user