feat(result): rename raw to burst, add raw line to result graph, add ability to hide chart data (@miodec) (#6907)

This commit is contained in:
Jack 2025-09-02 11:06:15 +02:00 committed by GitHub
parent 7487e53c67
commit 8627235bef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 676 additions and 120 deletions

View file

@ -622,7 +622,7 @@ describe("result controller test", () => {
bailedOut: false,
blindMode: false,
charStats: [100, 2, 3, 5],
chartData: { wpm: [1, 2, 3], raw: [50, 55, 56], err: [0, 2, 0] },
chartData: { wpm: [1, 2, 3], burst: [50, 55, 56], err: [0, 2, 0] },
consistency: 23.5,
difficulty: "normal",
funbox: [],
@ -675,7 +675,7 @@ describe("result controller test", () => {
charStats: [100, 2, 3, 5],
chartData: {
err: [0, 2, 0],
raw: [50, 55, 56],
burst: [50, 55, 56],
wpm: [1, 2, 3],
},
consistency: 23.5,
@ -757,7 +757,7 @@ describe("result controller test", () => {
bailedOut: false,
blindMode: false,
charStats: [100, 2, 3, 5],
chartData: { wpm: [1, 2, 3], raw: [50, 55, 56], err: [0, 2, 0] },
chartData: { wpm: [1, 2, 3], burst: [50, 55, 56], err: [0, 2, 0] },
consistency: 23.5,
difficulty: "normal",
funbox: [],

View file

@ -123,19 +123,85 @@ describe("Result Utils", () => {
});
});
describe("legacy chartData conversion", () => {
it("should convert chartData with 'raw' property to 'burst'", () => {
const resultWithLegacyChartData: DBResult = {
chartData: {
wpm: [50, 55, 60],
raw: [52, 57, 62],
err: [1, 0, 2],
} as any,
} as any;
const result = replaceLegacyValues(resultWithLegacyChartData);
expect(result.chartData).toEqual({
wpm: [50, 55, 60],
burst: [52, 57, 62],
err: [1, 0, 2],
});
});
it("should not convert chartData when it's 'toolong'", () => {
const resultWithToolongChartData: DBResult = {
chartData: "toolong",
} as any;
const result = replaceLegacyValues(resultWithToolongChartData);
expect(result.chartData).toBe("toolong");
});
it("should not convert chartData when it doesn't have 'raw' property", () => {
const resultWithModernChartData: DBResult = {
chartData: {
wpm: [50, 55, 60],
burst: [52, 57, 62],
err: [1, 0, 2],
},
} as any;
const result = replaceLegacyValues(resultWithModernChartData);
expect(result.chartData).toEqual({
wpm: [50, 55, 60],
burst: [52, 57, 62],
err: [1, 0, 2],
});
});
it("should not convert chartData when it's undefined", () => {
const resultWithoutChartData: DBResult = {} as any;
const result = replaceLegacyValues(resultWithoutChartData);
expect(result.chartData).toBeUndefined();
});
});
it("should convert all legacy data at once", () => {
const resultWithBothLegacy: DBResult = {
const resultWithAllLegacy: DBResult = {
correctChars: 100,
incorrectChars: 8,
funbox: "memory#mirror" as any,
chartData: {
wpm: [50, 55, 60],
raw: [52, 57, 62],
err: [1, 0, 2],
} as any,
} as any;
const result = replaceLegacyValues(resultWithBothLegacy);
const result = replaceLegacyValues(resultWithAllLegacy);
expect(result.charStats).toEqual([100, 8, 0, 0]);
expect(result.correctChars).toBeUndefined();
expect(result.incorrectChars).toBeUndefined();
expect(result.funbox).toEqual(["memory", "mirror"]);
expect(result.chartData).toEqual({
wpm: [50, 55, 60],
burst: [52, 57, 62],
err: [1, 0, 2],
});
});
describe("no legacy values", () => {

View file

@ -137,7 +137,7 @@ function createResult(
keyConsistency: 33.18,
chartData: {
wpm: createArray(testDuration, () => random(80, 120)),
raw: createArray(testDuration, () => random(80, 120)),
burst: createArray(testDuration, () => random(80, 120)),
err: createArray(testDuration, () => (Math.random() < 0.1 ? 1 : 0)),
},
keySpacingStats: {

View file

@ -63,6 +63,7 @@ export async function getResult(uid: string, id: string): Promise<DBResult> {
_id: new ObjectId(id),
uid,
});
if (!result) throw new MonkeyError(404, "Result not found");
return replaceLegacyValues(result);
}

View file

@ -1,4 +1,9 @@
import { CompletedEvent, Result } from "@monkeytype/schemas/results";
import {
ChartData,
CompletedEvent,
OldChartData,
Result,
} from "@monkeytype/schemas/results";
import { Mode } from "@monkeytype/schemas/shared";
import { ObjectId } from "mongodb";
import { WithObjectId } from "./misc";
@ -8,6 +13,7 @@ export type DBResult = WithObjectId<Result<Mode>> & {
//legacy values
correctChars?: number;
incorrectChars?: number;
chartData: ChartData | OldChartData | "toolong";
};
export function buildDbResult(
@ -103,5 +109,18 @@ export function replaceLegacyValues(result: DBResult): DBResult {
}
}
if (
result.chartData !== undefined &&
result.chartData !== "toolong" &&
"raw" in result.chartData
) {
const temp = result.chartData;
result.chartData = {
wpm: temp.wpm,
burst: temp.raw,
err: temp.err,
};
}
return result;
}

View file

@ -331,6 +331,25 @@
</div>
</div>
<div class="chart">
<div class="chartLegend">
<button class="text" tabindex="-1" data-id="pbLine">
<i class="fas fa-crown"></i>
<div class="text">pb</div>
</button>
<button class="text" tabindex="-1" data-id="raw">
<div class="line dashed"></div>
<div class="text">raw</div>
</button>
<button class="text" tabindex="-1" data-id="burst">
<div class="line"></div>
<div class="text">burst</div>
</button>
<button class="text" tabindex="-1" data-id="errors">
<!-- <div class="line"></div> -->
<i class="fas fa-times"></i>
<div class="text">errors</div>
</button>
</div>
<!-- <div class="title">wpm over time</div> -->
<canvas id="wpmChart"></canvas>
</div>

View file

@ -66,6 +66,7 @@
<button class="showTestNotifications">show test notifications</button>
<button class="showRealWordsInput">show real words input</button>
<button class="xpBarTest">xp bar test</button>
<button class="toggleFakeChartData">toggle fake chart data</button>
</div>
</dialog>

View file

@ -779,6 +779,109 @@
.chart {
grid-area: chart;
width: 100%;
position: relative;
&:hover {
.chartLegend {
opacity: 1;
pointer-events: auto;
}
}
.chartLegend {
opacity: 0;
pointer-events: none;
position: absolute;
background: var(--bg-color);
border-radius: var(--roundness);
// top: -2.25em;
bottom: -0.75em;
padding: 0.25em;
right: 0;
font-size: 0.75em;
transition: opacity 0.125s;
cursor: pointer;
display: flex;
// button {
// border-radius: 0;
// background: var(--sub-alt-color);
// }
// button:first-child {
// border-top-left-radius: var(--roundness);
// border-bottom-left-radius: var(--roundness);
// }
// button:last-child {
// border-top-right-radius: var(--roundness);
// border-bottom-right-radius: var(--roundness);
// }
button {
padding: 0.5em 1em;
display: inline-grid;
grid-template-columns: auto 1fr;
align-items: center;
--color: var(--sub-color);
text-decoration: line-through;
.text {
pointer-events: none;
}
.line {
height: 0.25em;
width: 1.5em;
border-radius: calc(var(--roundness) / 2);
transition: background 0.125s;
background: var(--color);
pointer-events: none;
&.dashed {
background: linear-gradient(
90deg,
var(--color) 0%,
var(--color) 40%,
transparent 40%,
transparent 60%,
var(--color) 60%,
var(--color) 100%
);
}
}
.fas {
color: var(--color);
line-height: 0;
}
&.active {
text-decoration: none;
// .text {
// }
color: var(--sub-color);
&[data-id="raw"] {
--color: var(--main-color);
}
&[data-id="burst"] {
--color: var(--sub-color);
}
&[data-id="errors"] {
--color: var(--error-color);
}
}
&:hover {
color: var(--text-color);
background: var(--sub-alt-color);
}
&:active {
color: var(--sub-color);
}
}
}
canvas {
width: 100% !important;

View file

@ -3,7 +3,7 @@ import { get as getTypingSpeedUnit } from "../../utils/typing-speed-units";
import { Command, CommandsSubgroup } from "../types";
const subgroup: CommandsSubgroup = {
title: "Minimum burst...",
title: "Minimum word burst...",
configKey: "minBurst",
list: [
{
@ -48,7 +48,7 @@ const subgroup: CommandsSubgroup = {
const commands: Command[] = [
{
id: "changeMinBurst",
display: "Minimum burst...",
display: "Minimum word burst...",
alias: "minimum",
icon: "fa-bomb",
subgroup,

View file

@ -174,7 +174,7 @@ export const configMetadata: ConfigMetadataObject = {
},
burstHeatmap: {
icon: "fa-fire",
displayString: "burst heatmap",
displayString: "word burst heatmap",
changeRequiresRestart: false,
},
@ -246,12 +246,12 @@ export const configMetadata: ConfigMetadataObject = {
},
minBurst: {
icon: "fa-bomb",
displayString: "min burst",
displayString: "min word burst",
changeRequiresRestart: true,
},
minBurstCustomSpeed: {
icon: "fa-bomb",
displayString: "min burst custom speed",
displayString: "min word burst custom speed",
changeRequiresRestart: true,
},
britishEnglish: {
@ -477,7 +477,7 @@ export const configMetadata: ConfigMetadataObject = {
},
liveBurstStyle: {
icon: "fa-tachometer-alt",
displayString: "live burst style",
displayString: "live word burst style",
changeRequiresRestart: false,
},
timerColor: {

View file

@ -54,7 +54,7 @@ Chart.register(
(
Chart.defaults.animation as AnimationSpec<"line" | "bar" | "scatter">
).duration = 0;
Chart.defaults.elements.line.tension = 0.3;
Chart.defaults.elements.line.tension = 0.5;
Chart.defaults.elements.line.fill = "origin";
import "chartjs-adapter-date-fns";
@ -84,6 +84,7 @@ class ChartWithUpdateColors<
}
async updateColors(): Promise<void> {
//@ts-expect-error its too difficult to figure out these types, but this works
await updateColors(this);
}
@ -92,6 +93,11 @@ class ChartWithUpdateColors<
return this.data.datasets?.find((x) => x.yAxisID === id);
}
getScaleIds(): DatasetIds[] {
//@ts-expect-error its too difficult to figure out these types, but this works
return typedKeys(this.options?.scales ?? {}) as DatasetIds[];
}
getScale(
id: DatasetIds extends never ? never : "x" | DatasetIds
): DatasetIds extends never ? never : CartesianScaleOptions {
@ -106,7 +112,7 @@ export const result = new ChartWithUpdateColors<
"line" | "scatter",
number[],
string,
"wpm" | "raw" | "error"
"wpm" | "raw" | "error" | "burst"
>(document.querySelector("#wpmChart") as HTMLCanvasElement, {
type: "line",
data: {
@ -129,10 +135,11 @@ export const result = new ChartWithUpdateColors<
label: "raw",
data: [],
borderColor: "rgba(125, 125, 125, 1)",
borderWidth: 3,
borderWidth: 2,
yAxisID: "raw",
borderDash: [8, 8],
order: 3,
pointRadius: 1,
pointRadius: 0,
},
{
//@ts-expect-error the type is defined incorrectly, have to ingore the error
@ -157,6 +164,17 @@ export const result = new ChartWithUpdateColors<
return (value ?? 0) <= 0 ? 0 : 5;
},
},
{
//@ts-expect-error the type is defined incorrectly, have to ingore the error
clip: false,
label: "burst",
data: [],
borderColor: "rgba(125, 125, 125, 1)",
borderWidth: 3,
yAxisID: "burst",
order: 4,
pointRadius: 1,
},
],
},
options: {
@ -209,6 +227,23 @@ export const result = new ChartWithUpdateColors<
display: false,
},
},
burst: {
axis: "y",
display: false,
title: {
display: true,
text: "Burst Words per Minute",
},
beginAtZero: true,
min: 0,
ticks: {
autoSkip: true,
autoSkipPadding: 20,
},
grid: {
display: false,
},
},
error: {
axis: "y",
display: true,
@ -923,7 +958,7 @@ export const miniResult = new ChartWithUpdateColors<
"line" | "scatter",
number[],
string,
"wpm" | "raw" | "error"
"wpm" | "burst" | "error"
>(document.querySelector("#miniResultChartModal canvas") as HTMLCanvasElement, {
type: "line",
data: {
@ -933,19 +968,19 @@ export const miniResult = new ChartWithUpdateColors<
label: "wpm",
data: [],
borderColor: "rgba(125, 125, 125, 1)",
borderWidth: 2,
borderWidth: 3,
yAxisID: "wpm",
order: 2,
pointRadius: 2,
pointRadius: 1,
},
{
label: "raw",
label: "burst",
data: [],
borderColor: "rgba(125, 125, 125, 1)",
borderWidth: 2,
yAxisID: "raw",
borderWidth: 3,
yAxisID: "burst",
order: 3,
pointRadius: 2,
pointRadius: 1,
},
{
label: "errors",
@ -1003,12 +1038,12 @@ export const miniResult = new ChartWithUpdateColors<
display: true,
},
},
raw: {
burst: {
axis: "y",
display: false,
title: {
display: true,
text: "Raw Words per Minute",
text: "Burst Words per Minute",
},
beginAtZero: true,
min: 0,
@ -1171,6 +1206,70 @@ async function updateColors<
scale.title.color = subcolor;
}
if (chart.id === result.id) {
const c = chart as unknown as typeof result;
const wpm = c.getDataset("wpm");
wpm.backgroundColor = "transparent";
wpm.borderColor = maincolor;
wpm.pointBackgroundColor = maincolor;
wpm.pointBorderColor = maincolor;
const raw = c.getDataset("raw");
raw.backgroundColor = "transparent";
raw.borderColor = maincolor + "99";
raw.pointBackgroundColor = maincolor + "99";
raw.pointBorderColor = maincolor + "99";
const error = c.getDataset("error");
error.backgroundColor = errorcolor;
error.borderColor = errorcolor;
error.pointBackgroundColor = errorcolor;
error.pointBorderColor = errorcolor;
const burst = c.getDataset("burst");
burst.backgroundColor = blendTwoHexColors(
subaltcolor,
subaltcolor + "00",
0.5
);
burst.borderColor = subcolor;
burst.pointBackgroundColor = subcolor;
burst.pointBorderColor = subcolor;
chart.update("resize");
return;
}
if (chart.id === miniResult.id) {
const c = chart as unknown as typeof miniResult;
const wpm = c.getDataset("wpm");
wpm.backgroundColor = "transparent";
wpm.borderColor = maincolor;
wpm.pointBackgroundColor = maincolor;
wpm.pointBorderColor = maincolor;
const error = c.getDataset("error");
error.backgroundColor = errorcolor;
error.borderColor = errorcolor;
error.pointBackgroundColor = errorcolor;
error.pointBorderColor = errorcolor;
const burst = c.getDataset("burst");
burst.backgroundColor = blendTwoHexColors(
subaltcolor,
subaltcolor + "00",
0.75
);
burst.borderColor = subcolor;
burst.pointBackgroundColor = subcolor;
burst.pointBorderColor = subcolor;
chart.update("resize");
return;
}
//@ts-expect-error its too difficult to figure out these types, but this works
chart.data.datasets[0].borderColor = (ctx): string => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
@ -1326,10 +1425,10 @@ function setDefaultFontFamily(font: string): void {
export function updateAllChartColors(): void {
ThemeColors.update();
void result.updateColors();
void accountHistory.updateColors();
void accountHistogram.updateColors();
void globalSpeedHistogram.updateColors();
void result.updateColors();
void accountActivity.updateColors();
void miniResult.updateColors();
}

View file

@ -99,7 +99,7 @@ addToGlobal({
replay: Replay.getReplayExport,
enableTimerDebug: TestTimer.enableTimerDebug,
getTimerStats: TestTimer.getTimerStats,
toggleUnsmoothedRaw: Result.toggleUnsmoothedRaw,
toggleSmoothedBurst: Result.toggleSmoothedBurst,
egVideoListener: egVideoListener,
toggleDebugLogs: Logger.toggleDebugLogs,
toggleSentryDebug: Sentry.toggleDebug,

View file

@ -6,6 +6,7 @@ import { setMediaQueryDebugLevel } from "../ui";
import { signIn } from "../auth";
import * as Loader from "../elements/loader";
import { update } from "../elements/xp-bar";
import { toggleUserFakeChartData } from "../test/result";
let mediaQueryDebugLevel = 0;
@ -83,6 +84,11 @@ async function setup(modalEl: HTMLElement): Promise<void> {
}, 500);
void modal.hide();
});
modalEl
.querySelector(".toggleFakeChartData")
?.addEventListener("click", () => {
toggleUserFakeChartData();
});
}
const modal = new AnimatedModal({

View file

@ -2,7 +2,6 @@ import { ChartData } from "@monkeytype/schemas/results";
import AnimatedModal from "../utils/animated-modal";
import * as ChartController from "../controllers/chart-controller";
import Config from "../config";
import * as Arrays from "../utils/arrays";
function updateData(data: ChartData): void {
// let data = filteredResults[filteredId].chartData;
@ -11,35 +10,33 @@ function updateData(data: ChartData): void {
labels.push(i.toString());
}
//make sure data.wpm and data.err are the same length as data.raw using slice
data.wpm = data.wpm.slice(0, data.raw.length);
data.err = data.err.slice(0, data.raw.length);
labels = labels.slice(0, data.raw.length);
const smoothedRawData = Arrays.smooth(data.raw, 1);
//make sure data.wpm and data.err are the same length as data.burst using slice
data.wpm = data.wpm.slice(0, data.burst.length);
data.err = data.err.slice(0, data.burst.length);
labels = labels.slice(0, data.burst.length);
ChartController.miniResult.data.labels = labels;
ChartController.miniResult.getDataset("wpm").data = data.wpm;
ChartController.miniResult.getDataset("raw").data = smoothedRawData;
ChartController.miniResult.getDataset("burst").data = data.burst;
ChartController.miniResult.getDataset("error").data = data.err;
const maxChartVal = Math.max(
...[Math.max(...data.wpm), Math.max(...data.raw)]
...[Math.max(...data.wpm), Math.max(...data.burst)]
);
const minChartVal = Math.min(
...[Math.min(...data.wpm), Math.min(...data.raw)]
...[Math.min(...data.wpm), Math.min(...data.burst)]
);
ChartController.miniResult.getScale("wpm").max = Math.round(maxChartVal);
ChartController.miniResult.getScale("raw").max = Math.round(maxChartVal);
ChartController.miniResult.getScale("burst").max = Math.round(maxChartVal);
if (!Config.startGraphsAtZero) {
ChartController.miniResult.getScale("wpm").min = Math.round(minChartVal);
ChartController.miniResult.getScale("raw").min = Math.round(minChartVal);
ChartController.miniResult.getScale("burst").min = Math.round(minChartVal);
} else {
ChartController.miniResult.getScale("wpm").min = 0;
ChartController.miniResult.getScale("raw").min = 0;
ChartController.miniResult.getScale("burst").min = 0;
}
void ChartController.miniResult.updateColors();

View file

@ -42,25 +42,48 @@ import { getFunbox } from "@monkeytype/funbox";
import { SnapshotUserTag } from "../constants/default-snapshot";
import { Language } from "@monkeytype/schemas/languages";
import { canQuickRestart as canQuickRestartFn } from "../utils/quick-restart";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { z } from "zod";
let result: CompletedEvent;
let maxChartVal: number;
let useUnsmoothedRaw = false;
let useSmoothedBurst = true;
let useFakeChartData = false;
let quoteLang: Language | undefined;
let quoteId = "";
export function toggleUnsmoothedRaw(): void {
useUnsmoothedRaw = !useUnsmoothedRaw;
Notifications.add(useUnsmoothedRaw ? "on" : "off", 1);
export function toggleSmoothedBurst(): void {
useSmoothedBurst = !useSmoothedBurst;
Notifications.add(useSmoothedBurst ? "on" : "off", 1);
if (TestUI.resultVisible) {
void updateGraph().then(() => {
ChartController.result.update("resize");
});
}
}
export function toggleUserFakeChartData(): void {
useFakeChartData = !useFakeChartData;
Notifications.add(useFakeChartData ? "on" : "off", 1);
if (TestUI.resultVisible) {
void updateGraph().then(() => {
ChartController.result.update("resize");
});
}
}
let resultAnnotation: AnnotationOptions<"line">[] = [];
async function updateGraph(): Promise<void> {
if (result.chartData === "toolong") return;
const typingSpeedUnit = getTypingSpeedUnit(Config.typingSpeedUnit);
const labels = [];
ChartController.result.getScale("wpm").title.text =
typingSpeedUnit.fullUnitString;
let labels = [];
for (let i = 1; i <= TestInput.wpmHistory.length; i++) {
if (TestStats.lastSecondNotRound && i === TestInput.wpmHistory.length) {
@ -70,22 +93,29 @@ async function updateGraph(): Promise<void> {
}
}
ChartController.result.getScale("wpm").title.text =
typingSpeedUnit.fullUnitString;
const chartData1 = [
...TestInput.wpmHistory.map((a) =>
...result.chartData["wpm"].map((a) =>
Numbers.roundTo2(typingSpeedUnit.fromWpm(a))
),
];
if (result.chartData === "toolong") return;
const chartData2 = [
...result.chartData.raw.map((a) =>
...TestInput.rawHistory.map((a) =>
Numbers.roundTo2(typingSpeedUnit.fromWpm(a))
),
];
const valueWindow = Math.max(...result.chartData["burst"]) * 0.25;
let smoothedBurst = Arrays.smoothWithValueWindow(
result.chartData["burst"],
1,
useSmoothedBurst ? valueWindow : 0
);
const chartData3 = [
...smoothedBurst.map((a) => Numbers.roundTo2(typingSpeedUnit.fromWpm(a))),
];
if (
Config.mode !== "time" &&
TestStats.lastSecondNotRound &&
@ -96,36 +126,31 @@ async function updateGraph(): Promise<void> {
chartData2.pop();
}
let smoothedRawData = chartData2;
if (!useUnsmoothedRaw) {
smoothedRawData = Arrays.smooth(smoothedRawData, 1);
smoothedRawData = smoothedRawData.map((a) => Math.round(a));
}
ChartController.result.data.labels = labels;
ChartController.result.getDataset("wpm").data = chartData1;
ChartController.result.getDataset("wpm").label = Config.typingSpeedUnit;
ChartController.result.getDataset("raw").data = smoothedRawData;
maxChartVal = Math.max(
...[Math.max(...smoothedRawData), Math.max(...chartData1)]
...[
Math.max(...chartData1),
Math.max(...chartData2),
Math.max(...chartData3),
]
);
let minChartVal = 0;
if (!Config.startGraphsAtZero) {
const minChartVal = Math.min(
...[Math.min(...smoothedRawData), Math.min(...chartData1)]
minChartVal = Math.min(
...[
Math.min(...chartData1),
Math.min(...chartData2),
Math.min(...chartData3),
]
);
ChartController.result.getScale("wpm").min = minChartVal;
ChartController.result.getScale("raw").min = minChartVal;
} else {
ChartController.result.getScale("wpm").min = 0;
ChartController.result.getScale("raw").min = 0;
// Round down to nearest multiple of 10
minChartVal = Math.floor(minChartVal / 10) * 10;
}
ChartController.result.getDataset("error").data = result.chartData.err;
const subcolor = await ThemeColors.get("sub");
const fc = await ThemeColors.get("sub");
if (Config.funbox.length > 0) {
let content = "";
for (const fb of getActiveFunboxes()) {
@ -154,7 +179,7 @@ async function updateGraph(): Promise<void> {
weight: Chart.defaults.font.weight as string,
lineHeight: Chart.defaults.font.lineHeight as number,
},
color: fc,
color: subcolor,
padding: 3,
borderRadius: 3,
position: "start",
@ -164,11 +189,113 @@ async function updateGraph(): Promise<void> {
});
}
ChartController.result.data.labels = labels;
ChartController.result.getDataset("wpm").data = chartData1;
ChartController.result.getDataset("wpm").label = Config.typingSpeedUnit;
ChartController.result.getScale("wpm").min = minChartVal;
ChartController.result.getScale("wpm").max = maxChartVal;
ChartController.result.getDataset("raw").data = chartData2;
ChartController.result.getScale("raw").min = minChartVal;
ChartController.result.getScale("raw").max = maxChartVal;
ChartController.result.getDataset("burst").data = chartData3;
ChartController.result.getScale("burst").min = minChartVal;
ChartController.result.getScale("burst").max = maxChartVal;
ChartController.result.getDataset("error").data = result.chartData.err;
ChartController.result.getScale("error").max = Math.max(
...result.chartData.err
);
if (useFakeChartData) {
applyFakeChartData();
}
}
function applyFakeChartData(): void {
const fakeChartData = {
wpm: [
108, 120, 116, 114, 113, 120, 118, 121, 119, 120, 116, 118, 113, 110, 108,
110, 107, 107, 108, 109, 110, 112, 114, 112, 111, 109, 110, 108, 108, 109,
],
raw: [
108, 120, 116, 114, 113, 120, 123, 127, 131, 131, 131, 132, 130, 133, 134,
134, 131, 129, 129, 128, 129, 130, 131, 129, 129, 127, 127, 128, 127, 127,
],
burst: [
108, 132, 108, 108, 108, 156, 144, 156, 156, 132, 132, 144, 108, 168, 156,
132, 96, 108, 120, 120, 144, 156, 144, 84, 132, 84, 132, 156, 108, 120,
],
err: [
0, 0, 0, 0, 0, 0, 3, 1, 3, 0, 5, 0, 3, 5, 4, 0, 2, 0, 0, 0, 0, 0, 0, 1, 2,
1, 0, 4, 0, 0,
],
};
const labels = fakeChartData.wpm.map((_, i) => (i + 1).toString());
const typingSpeedUnit = getTypingSpeedUnit(Config.typingSpeedUnit);
const chartData1 = [
...fakeChartData["wpm"].map((a) =>
Numbers.roundTo2(typingSpeedUnit.fromWpm(a))
),
];
const chartData2 = [
...fakeChartData["raw"].map((a) =>
Numbers.roundTo2(typingSpeedUnit.fromWpm(a))
),
];
const chartData3 = [
...fakeChartData["burst"].map((a) =>
Numbers.roundTo2(typingSpeedUnit.fromWpm(a))
),
];
maxChartVal = Math.max(
...[
Math.max(...chartData1),
Math.max(...chartData2),
Math.max(...chartData3),
]
);
let minChartVal = 0;
if (!Config.startGraphsAtZero) {
minChartVal = Math.min(
...[
Math.min(...chartData1),
Math.min(...chartData2),
Math.min(...chartData3),
]
);
// Round down to nearest multiple of 10
minChartVal = Math.floor(minChartVal / 10) * 10;
}
ChartController.result.data.labels = labels;
ChartController.result.getDataset("wpm").data = chartData1;
ChartController.result.getDataset("wpm").label = Config.typingSpeedUnit;
ChartController.result.getScale("wpm").min = minChartVal;
ChartController.result.getScale("wpm").max = maxChartVal;
ChartController.result.getDataset("raw").data = chartData2;
ChartController.result.getScale("raw").min = minChartVal;
ChartController.result.getScale("raw").max = maxChartVal;
ChartController.result.getDataset("burst").data = chartData3;
ChartController.result.getScale("burst").min = minChartVal;
ChartController.result.getScale("burst").max = maxChartVal;
ChartController.result.getDataset("error").data = fakeChartData.err;
ChartController.result.getScale("error").max = Math.max(...fakeChartData.err);
}
export async function updateGraphPBLine(): Promise<void> {
@ -195,9 +322,9 @@ export async function updateGraphPBLine(): Promise<void> {
id: "lpb",
scaleID: "wpm",
value: chartlpb,
borderColor: themecolors.sub,
borderColor: themecolors.sub + "55",
borderWidth: 1,
borderDash: [2, 2],
// borderDash: [4, 16],
label: {
backgroundColor: themecolors.sub,
font: {
@ -211,7 +338,7 @@ export async function updateGraphPBLine(): Promise<void> {
padding: 3,
borderRadius: 3,
position: "center",
content: `PB: ${chartlpb}`,
content: ` PB: ${chartlpb} `,
display: true,
},
});
@ -632,9 +759,9 @@ async function updateTags(dontSave: boolean): Promise<void> {
id: "tpb",
scaleID: "wpm",
value: typingSpeedUnit.fromWpm(tpb),
borderColor: themecolors.sub,
borderColor: themecolors.sub + "55",
borderWidth: 1,
borderDash: [2, 2],
// borderDash: [4, 16],
label: {
backgroundColor: themecolors.sub,
font: {
@ -883,6 +1010,7 @@ export async function update(
await updateCrown(dontSave);
await updateGraph();
await updateGraphPBLine();
updateResultChartDataVisibility();
await updateTags(dontSave);
updateOther(difficultyFailed, failReason, afkDetected, isRepeated, tooShort);
@ -982,6 +1110,54 @@ export async function update(
);
}
const resultChartDataVisibility = new LocalStorageWithSchema({
key: "resultChartDataVisibility",
schema: z
.object({
raw: z.boolean(),
burst: z.boolean(),
errors: z.boolean(),
pbLine: z.boolean(),
})
.strict(),
fallback: {
raw: true,
burst: true,
errors: true,
pbLine: true,
},
});
function updateResultChartDataVisibility(update = false): void {
const vis = resultChartDataVisibility.get();
ChartController.result.getDataset("raw").hidden = !vis.raw;
ChartController.result.getDataset("burst").hidden = !vis.burst;
ChartController.result.getDataset("error").hidden = !vis.errors;
for (const annotation of resultAnnotation) {
if (annotation.id !== "lpb") continue;
annotation.display = vis.pbLine;
}
if (update) ChartController.result.update();
const buttons = $(".pageTest #result .chart .chartLegend button");
for (const button of buttons) {
const id = $(button).data("id") as string;
if (id !== "raw" && id !== "burst" && id !== "errors" && id !== "pbLine") {
return;
}
$(button).toggleClass("active", vis[id]);
if (id === "pbLine") {
$(button).toggleClass("hidden", !isAuthenticated());
}
}
}
export function updateTagsAfterEdit(
tagIds: string[],
tagPbIds: string[]
@ -1036,6 +1212,21 @@ export function updateTagsAfterEdit(
}
}
$(".pageTest #result .chart .chartLegend button").on("click", (event) => {
const $target = $(event.target);
const id = $target.data("id") as string;
if (id !== "raw" && id !== "burst" && id !== "errors" && id !== "pbLine") {
return;
}
const vis = resultChartDataVisibility.get();
vis[id] = !vis[id];
resultChartDataVisibility.set(vis);
updateResultChartDataVisibility(true);
});
$(".pageTest #favoriteQuoteButton").on("click", async () => {
if (quoteLang === undefined || quoteId === "") {
Notifications.add("Could not get quote stats!", -1);

View file

@ -722,7 +722,8 @@ export async function retrySavingResult(): Promise<void> {
}
function buildCompletedEvent(
difficultyFailed: boolean
stats: TestStats.Stats,
rawPerSecond: number[]
): Omit<CompletedEvent, "hash" | "uid"> {
//build completed event object
let stfk = Numbers.roundTo2(
@ -738,44 +739,8 @@ function buildCompletedEvent(
if (lkte < 0 || Config.mode === "zen") {
lkte = 0;
}
// stats
const stats = TestStats.calculateStats();
if (stats.time % 1 !== 0 && Config.mode !== "time") {
TestStats.setLastSecondNotRound();
}
PaceCaret.setLastTestWpm(stats.wpm); //todo why is this in here?
// if the last second was not rounded, add another data point to the history
if (TestStats.lastSecondNotRound && !difficultyFailed) {
const wpmAndRaw = TestStats.calculateWpmAndRaw();
TestInput.pushToWpmHistory(wpmAndRaw.wpm);
TestInput.pushToRawHistory(wpmAndRaw.raw);
TestInput.pushKeypressesToHistory();
TestInput.pushErrorToHistory();
TestInput.pushAfkToHistory();
}
//consistency
const rawPerSecond = TestInput.keypressCountHistory.map((count) =>
Math.round((count / 5) * 60)
);
//adjust last second if last second is not round
// if (TestStats.lastSecondNotRound && stats.time % 1 >= 0.1) {
if (
Config.mode !== "time" &&
TestStats.lastSecondNotRound &&
stats.time % 1 >= 0.5
) {
const timescale = 1 / (stats.time % 1);
//multiply last element of rawBefore by scale, and round it
rawPerSecond[rawPerSecond.length - 1] = Math.round(
(rawPerSecond[rawPerSecond.length - 1] as number) * timescale
);
}
const stddev = Numbers.stdDev(rawPerSecond);
const avg = Numbers.mean(rawPerSecond);
let consistency = Numbers.roundTo2(Numbers.kogasa(stddev / avg));
@ -805,7 +770,7 @@ function buildCompletedEvent(
const chartData = {
wpm: TestInput.wpmHistory,
raw: rawPerSecond,
burst: rawPerSecond,
err: chartErr,
};
@ -948,7 +913,44 @@ export async function finish(difficultyFailed = false): Promise<void> {
TestStats.removeAfkData();
}
const ce = buildCompletedEvent(difficultyFailed);
// stats
const stats = TestStats.calculateStats();
if (stats.time % 1 !== 0 && Config.mode !== "time") {
TestStats.setLastSecondNotRound();
}
PaceCaret.setLastTestWpm(stats.wpm);
// if the last second was not rounded, add another data point to the history
if (TestStats.lastSecondNotRound && !difficultyFailed) {
const wpmAndRaw = TestStats.calculateWpmAndRaw();
TestInput.pushToWpmHistory(wpmAndRaw.wpm);
TestInput.pushToRawHistory(wpmAndRaw.raw);
TestInput.pushKeypressesToHistory();
TestInput.pushErrorToHistory();
TestInput.pushAfkToHistory();
}
const rawPerSecond = TestInput.keypressCountHistory.map((count) =>
Math.round((count / 5) * 60)
);
//adjust last second if last second is not round
// if (TestStats.lastSecondNotRound && stats.time % 1 >= 0.1) {
if (
Config.mode !== "time" &&
TestStats.lastSecondNotRound &&
stats.time % 1 >= 0.5
) {
const timescale = 1 / (stats.time % 1);
//multiply last element of rawBefore by scale, and round it
rawPerSecond[rawPerSecond.length - 1] = Math.round(
(rawPerSecond[rawPerSecond.length - 1] as number) * timescale
);
}
const ce = buildCompletedEvent(stats, rawPerSecond);
console.debug("Completed event object", ce);

View file

@ -18,7 +18,7 @@ type CharCount = {
correctSpaces: number;
};
type Stats = {
export type Stats = {
wpm: number;
wpmRaw: number;
acc: number;

View file

@ -33,6 +33,51 @@ export function smooth(
return result;
}
/**
* Applies a conditional smoothing algorithm to an array of numbers.
* Values are only smoothed if they fall within a specified value window relative to the current value.
* This preserves large changes in the data while smoothing smaller variations.
* @param arr The input array of numbers.
* @param windowSize The size of the window used for smoothing.
* @param valueWindowSize The maximum difference allowed for values to be included in smoothing.
* @param getter An optional function to extract values from the array elements. Defaults to the identity function.
* @returns An array of smoothed values, where each value is the average of itself and nearby values within both the position and value windows.
*/
export function smoothWithValueWindow(
arr: number[],
windowSize: number,
valueWindowSize: number,
getter = (value: number): number => value
): number[] {
const get = getter;
const result = [];
for (let i = 0; i < arr.length; i += 1) {
const currentValue = get(arr[i] as number);
const leftOffset = i - windowSize;
const from = leftOffset >= 0 ? leftOffset : 0;
const to = i + windowSize + 1;
let count = 0;
let sum = 0;
for (let j = from; j < to && j < arr.length; j += 1) {
const neighborValue = get(arr[j] as number);
// Only include values that are within the value window
if (Math.abs(neighborValue - currentValue) <= valueWindowSize) {
sum += neighborValue;
count += 1;
}
}
// If no values were within the window, use the original value
result[i] = count > 0 ? sum / count : currentValue;
}
return result;
}
/**
* Shuffle an array of elements using the FisherYates algorithm.
* This function mutates the input array.

View file

@ -17,9 +17,16 @@ export const IncompleteTestSchema = z.object({
});
export type IncompleteTest = z.infer<typeof IncompleteTestSchema>;
export const OldChartDataSchema = z.object({
wpm: z.array(z.number().nonnegative()).max(122),
raw: z.array(z.number().int().nonnegative()).max(122),
err: z.array(z.number().nonnegative()).max(122),
});
export type OldChartData = z.infer<typeof OldChartDataSchema>;
export const ChartDataSchema = z.object({
wpm: z.array(z.number().nonnegative()).max(122),
raw: z.array(z.number().nonnegative()).max(122),
burst: z.array(z.number().int().nonnegative()).max(122),
err: z.array(z.number().nonnegative()).max(122),
});
export type ChartData = z.infer<typeof ChartDataSchema>;