diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index 87062e173..758fc6c9c 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -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: [], diff --git a/backend/__tests__/utils/result.spec.ts b/backend/__tests__/utils/result.spec.ts index 18ef3c746..0328512e4 100644 --- a/backend/__tests__/utils/result.spec.ts +++ b/backend/__tests__/utils/result.spec.ts @@ -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", () => { diff --git a/backend/src/api/controllers/dev.ts b/backend/src/api/controllers/dev.ts index 8df8fd9f9..543089ef9 100644 --- a/backend/src/api/controllers/dev.ts +++ b/backend/src/api/controllers/dev.ts @@ -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: { diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts index 066e8772e..4c6aa6b07 100644 --- a/backend/src/dal/result.ts +++ b/backend/src/dal/result.ts @@ -63,6 +63,7 @@ export async function getResult(uid: string, id: string): Promise { _id: new ObjectId(id), uid, }); + if (!result) throw new MonkeyError(404, "Result not found"); return replaceLegacyValues(result); } diff --git a/backend/src/utils/result.ts b/backend/src/utils/result.ts index 9628b4ab7..8b1b1f251 100644 --- a/backend/src/utils/result.ts +++ b/backend/src/utils/result.ts @@ -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> & { //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; } diff --git a/frontend/src/html/pages/test.html b/frontend/src/html/pages/test.html index 064e3adb9..6a1ac3802 100644 --- a/frontend/src/html/pages/test.html +++ b/frontend/src/html/pages/test.html @@ -331,6 +331,25 @@
+
+ + + + +
diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 34566a2be..4e9735ec3 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -66,6 +66,7 @@ + diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 18d9e4222..edf677763 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -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; diff --git a/frontend/src/ts/commandline/lists/min-burst.ts b/frontend/src/ts/commandline/lists/min-burst.ts index b3c6b3529..631663be3 100644 --- a/frontend/src/ts/commandline/lists/min-burst.ts +++ b/frontend/src/ts/commandline/lists/min-burst.ts @@ -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, diff --git a/frontend/src/ts/config-metadata.ts b/frontend/src/ts/config-metadata.ts index f73a169a0..cbd64a239 100644 --- a/frontend/src/ts/config-metadata.ts +++ b/frontend/src/ts/config-metadata.ts @@ -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: { diff --git a/frontend/src/ts/controllers/chart-controller.ts b/frontend/src/ts/controllers/chart-controller.ts index a7aa10087..117e914fe 100644 --- a/frontend/src/ts/controllers/chart-controller.ts +++ b/frontend/src/ts/controllers/chart-controller.ts @@ -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 { + //@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(); } diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 04bc8a02d..78f78a230 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -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, diff --git a/frontend/src/ts/modals/dev-options.ts b/frontend/src/ts/modals/dev-options.ts index 4551fa964..649d6ecba 100644 --- a/frontend/src/ts/modals/dev-options.ts +++ b/frontend/src/ts/modals/dev-options.ts @@ -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 { }, 500); void modal.hide(); }); + modalEl + .querySelector(".toggleFakeChartData") + ?.addEventListener("click", () => { + toggleUserFakeChartData(); + }); } const modal = new AnimatedModal({ diff --git a/frontend/src/ts/modals/mini-result-chart.ts b/frontend/src/ts/modals/mini-result-chart.ts index a189d1dab..6ed2b7e1f 100644 --- a/frontend/src/ts/modals/mini-result-chart.ts +++ b/frontend/src/ts/modals/mini-result-chart.ts @@ -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(); diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index afd466303..e0f041c05 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -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 { + 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 { } } - 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 { 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 { 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 { }); } + 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 { @@ -195,9 +322,9 @@ export async function updateGraphPBLine(): Promise { 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 { padding: 3, borderRadius: 3, position: "center", - content: `PB: ${chartlpb}`, + content: ` PB: ${chartlpb} `, display: true, }, }); @@ -632,9 +759,9 @@ async function updateTags(dontSave: boolean): Promise { 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); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index ad8918796..d74a77222 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -722,7 +722,8 @@ export async function retrySavingResult(): Promise { } function buildCompletedEvent( - difficultyFailed: boolean + stats: TestStats.Stats, + rawPerSecond: number[] ): Omit { //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 { 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); diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 38924e21c..e00f151b8 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -18,7 +18,7 @@ type CharCount = { correctSpaces: number; }; -type Stats = { +export type Stats = { wpm: number; wpmRaw: number; acc: number; diff --git a/frontend/src/ts/utils/arrays.ts b/frontend/src/ts/utils/arrays.ts index 8a84d8217..5c18e8f7f 100644 --- a/frontend/src/ts/utils/arrays.ts +++ b/frontend/src/ts/utils/arrays.ts @@ -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 Fisher–Yates algorithm. * This function mutates the input array. diff --git a/packages/schemas/src/results.ts b/packages/schemas/src/results.ts index 35210513c..ce8d632a9 100644 --- a/packages/schemas/src/results.ts +++ b/packages/schemas/src/results.ts @@ -17,9 +17,16 @@ export const IncompleteTestSchema = z.object({ }); export type IncompleteTest = z.infer; +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; + 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;