From 9266702cbb9fb2e80d5e0e917759dc2f540f0ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thea=20Sch=C3=B6bl?= Date: Thu, 16 Jan 2025 20:41:00 +0100 Subject: [PATCH] feat: add sentence wpm stage --- src/routes/(app)/learn/sentence/+page.svelte | 245 +++++++++++++++---- 1 file changed, 195 insertions(+), 50 deletions(-) diff --git a/src/routes/(app)/learn/sentence/+page.svelte b/src/routes/(app)/learn/sentence/+page.svelte index 242fe6a2..200351ff 100644 --- a/src/routes/(app)/learn/sentence/+page.svelte +++ b/src/routes/(app)/learn/sentence/+page.svelte @@ -4,14 +4,14 @@ import CharRecorder from "$lib/charrecorder/CharRecorder.svelte"; import { ReplayRecorder } from "$lib/charrecorder/core/recorder"; import { shuffleInPlace } from "$lib/util/shuffle"; - import TrackWpm from "$lib/charrecorder/TrackWpm.svelte"; - import { fly } from "svelte/transition"; + import { fade, fly, slide } from "svelte/transition"; import TrackChords from "$lib/charrecorder/TrackChords.svelte"; import ChordHud from "$lib/charrecorder/ChordHud.svelte"; import type { InferredChord } from "$lib/charrecorder/core/types"; import { onMount } from "svelte"; import TrackText from "$lib/charrecorder/TrackText.svelte"; import { browser } from "$app/environment"; + import { expoIn, expoOut } from "svelte/easing"; function viaLocalStorage(key: string, initial: T) { try { @@ -21,28 +21,42 @@ } } - let masteryThresholds: [slow: number, fast: number][] = $state( + let masteryThresholds: [slow: number, fast: number, title: string][] = $state( viaLocalStorage("mastery-thresholds", [ - [1500, 1050], - [3000, 2500], - [5000, 3500], - [6000, 5000], + [1500, 1050, "Words"], + [3000, 2500, "Pairs"], + [5000, 3500, "Trios"], ]), ); + const avgWordLength = 5; + + function reset() { + localStorage.removeItem("mastery-thresholds"); + localStorage.removeItem("idle-timeout"); + window.location.reload(); + } + let inputSentence = $derived( (browser && $page.url.searchParams.get("sentence")) || "Hello World", ); + let wpmTarget = $derived( + (browser && Number($page.url.searchParams.get("wpm"))) || 250, + ); let devTools = $derived( browser && $page.url.searchParams.get("dev") === "true", ); let sentenceWords = $derived(inputSentence.split(" ")); + let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000); + let totalMs = $derived(inputSentence.length * msPerChar); + let msPerWord = $derived( + (inputSentence.length * msPerChar) / inputSentence.split(" ").length, + ); let currentWord = $state(""); let wordStats = new SvelteMap(); let wordMastery = new SvelteMap(); let text = $state(""); let level = $state(0); - let lastWPM = $state(0); let bestWPM = $state(0); let wpm = $state(0); let chords: InferredChord[] = $state([]); @@ -59,8 +73,8 @@ }); $effect(() => { - if (lastWPM > bestWPM) { - bestWPM = lastWPM; + if (wpm > bestWPM) { + bestWPM = wpm; } }); @@ -107,8 +121,8 @@ $effect(() => { for (const [word, speeds] of wordStats.entries()) { const level = word.split(" ").length - 1; - const masteryThreshold = - masteryThresholds[level] ?? masteryThresholds.at(-1)!; + const masteryThreshold = masteryThresholds[level]; + if (masteryThreshold === undefined) continue; const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length; wordMastery.set( word, @@ -125,15 +139,22 @@ } }); - let progress = $derived.by(() => { - return words.length > 0 - ? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) / + let progress = $derived( + level === masteryThresholds.length + ? Math.min(1, Math.max(0, bestWPM / wpmTarget)) + : words.length > 0 + ? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) / words.length - : 0; - }); + : 0, + ); + let mastered = $derived( + words.length > 0 + ? words.filter((it) => wordMastery.get(it) === 1).length / words.length + : 0, + ); $effect(() => { - if (progress === 1 && level < masteryThresholds.length - 1) { + if (progress === 1 && level < masteryThresholds.length) { level++; } }); @@ -149,7 +170,6 @@ nextWord = word; break; } - text = ""; currentWord = nextWord; recorder = new ReplayRecorder(nextWord); } @@ -159,18 +179,30 @@ const replay = recorder.finish(false); const elapsed = replay.finish - replay.start! - idleTime; if (elapsed < masteryThresholds[level]![0]) { - lastWPM = wpm; - const prevStats = wordStats.get(currentWord) ?? []; prevStats.push(elapsed); wordStats.set(currentWord, prevStats.slice(-10)); } - selectNextWord(); + text = ""; + setTimeout(() => { + selectNextWord(); + }); } $effect(() => { - if (idle && text && text.trim() === currentWord.trim()) checkInput(); + if (!idle || !text) return; + if (text.trim() !== currentWord.trim()) return; + if (level === masteryThresholds.length) { + const replay = recorder.finish(); + const elapsed = replay.finish - replay.start!; + text = ""; + recorder = new ReplayRecorder(currentWord); + console.log(elapsed, totalMs); + wpm = (totalMs / elapsed) * wpmTarget; + } else { + checkInput(); + } }); function onkey(event: KeyboardEvent) { @@ -189,7 +221,7 @@

Sentence Trainer

- {#each masteryThresholds as _, i} + {#each masteryThresholds as [, , title], i} {/each} + {#each masteryThresholds as _, i}
{/each} +
{#each sentenceWords as _, i} @@ -253,6 +310,55 @@ {/if} {/each}
+ {#if level === masteryThresholds.length} + {@const maxDigits = 4} + {@const indices = Array.from({ length: maxDigits }, (_, i) => i)} + {@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")} +
+
+ {#each indices as i} + {@const char = wpmString[i]} + {#key char} +
+ {char} +
+ {/key} + {/each} +
+ WPM +
+
+
+ {#key wpm} +
+ {Math.floor(wpm)} +
+ {/key} +
WPM
+
+
+ {/if}
- {#if level === masteryThresholds.length - 1 && progress === 1} -
- You have mastered this sentence! + {#key recorder} +
+ + + +
- {:else} - {#key recorder} -
- - - - - -
- {/key} - {/if} + {/key}
{#if devTools}
Dev Tools
+ + + + + + + + + + + + + + + + +
Total{Math.round(totalMs)}ms
Char{Math.round(msPerChar)}ms
Word{Math.round(msPerWord)}ms
{#each masteryThresholds as _, i} @@ -293,6 +417,7 @@ + {/each} @@ -330,13 +455,22 @@ } } + .wpm { + width: min-content; + display: grid; + transition: scale 0.2s ease; + + * { + grid-row: 1; + } + } + .finish { + display: grid; + grid-template-rows: repeat(2, 1fr); font-weight: bold; - grid-row: 1; - grid-column: 1; - color: var(--md-sys-color-primary); - text-align: center; - font-size: 1.5rem; + justify-items: center; + align-items: center; } .sentence { @@ -371,15 +505,25 @@ overflow: hidden; grid-row: 2; + &::before, &::after { + position: absolute; content: ""; display: block; height: 100%; width: 100%; - background: var(--md-sys-color-primary); - transform: translateX(var(--progress)); transition: transform 0.2s; } + + &::before { + background: var(--md-sys-color-outline); + transform: translateX(var(--progress)); + } + + &::after { + background: var(--md-sys-color-primary); + transform: translateX(var(--mastered)); + } } .threshold { @@ -387,6 +531,7 @@ justify-self: center; opacity: 0.5; transition: opacity 0.2s; + grid-row: 1; &.mastered, &.active {
L{i + 1}