feat: add sentence wpm stage

This commit is contained in:
2025-01-16 20:41:00 +01:00
parent 77e2d2b20e
commit 9266702cbb

View File

@@ -4,14 +4,14 @@
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte"; import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder"; import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { shuffleInPlace } from "$lib/util/shuffle"; import { shuffleInPlace } from "$lib/util/shuffle";
import TrackWpm from "$lib/charrecorder/TrackWpm.svelte"; import { fade, fly, slide } from "svelte/transition";
import { fly } from "svelte/transition";
import TrackChords from "$lib/charrecorder/TrackChords.svelte"; import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import ChordHud from "$lib/charrecorder/ChordHud.svelte"; import ChordHud from "$lib/charrecorder/ChordHud.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types"; import type { InferredChord } from "$lib/charrecorder/core/types";
import { onMount } from "svelte"; import { onMount } from "svelte";
import TrackText from "$lib/charrecorder/TrackText.svelte"; import TrackText from "$lib/charrecorder/TrackText.svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { expoIn, expoOut } from "svelte/easing";
function viaLocalStorage<T>(key: string, initial: T) { function viaLocalStorage<T>(key: string, initial: T) {
try { try {
@@ -21,28 +21,42 @@
} }
} }
let masteryThresholds: [slow: number, fast: number][] = $state( let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
viaLocalStorage("mastery-thresholds", [ viaLocalStorage("mastery-thresholds", [
[1500, 1050], [1500, 1050, "Words"],
[3000, 2500], [3000, 2500, "Pairs"],
[5000, 3500], [5000, 3500, "Trios"],
[6000, 5000],
]), ]),
); );
const avgWordLength = 5;
function reset() {
localStorage.removeItem("mastery-thresholds");
localStorage.removeItem("idle-timeout");
window.location.reload();
}
let inputSentence = $derived( let inputSentence = $derived(
(browser && $page.url.searchParams.get("sentence")) || "Hello World", (browser && $page.url.searchParams.get("sentence")) || "Hello World",
); );
let wpmTarget = $derived(
(browser && Number($page.url.searchParams.get("wpm"))) || 250,
);
let devTools = $derived( let devTools = $derived(
browser && $page.url.searchParams.get("dev") === "true", browser && $page.url.searchParams.get("dev") === "true",
); );
let sentenceWords = $derived(inputSentence.split(" ")); 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 currentWord = $state("");
let wordStats = new SvelteMap<string, number[]>(); let wordStats = new SvelteMap<string, number[]>();
let wordMastery = new SvelteMap<string, number>(); let wordMastery = new SvelteMap<string, number>();
let text = $state(""); let text = $state("");
let level = $state(0); let level = $state(0);
let lastWPM = $state(0);
let bestWPM = $state(0); let bestWPM = $state(0);
let wpm = $state(0); let wpm = $state(0);
let chords: InferredChord[] = $state([]); let chords: InferredChord[] = $state([]);
@@ -59,8 +73,8 @@
}); });
$effect(() => { $effect(() => {
if (lastWPM > bestWPM) { if (wpm > bestWPM) {
bestWPM = lastWPM; bestWPM = wpm;
} }
}); });
@@ -107,8 +121,8 @@
$effect(() => { $effect(() => {
for (const [word, speeds] of wordStats.entries()) { for (const [word, speeds] of wordStats.entries()) {
const level = word.split(" ").length - 1; const level = word.split(" ").length - 1;
const masteryThreshold = const masteryThreshold = masteryThresholds[level];
masteryThresholds[level] ?? masteryThresholds.at(-1)!; if (masteryThreshold === undefined) continue;
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length; const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
wordMastery.set( wordMastery.set(
word, word,
@@ -125,15 +139,22 @@
} }
}); });
let progress = $derived.by(() => { let progress = $derived(
return words.length > 0 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.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
words.length words.length
: 0; : 0,
}); );
let mastered = $derived(
words.length > 0
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
: 0,
);
$effect(() => { $effect(() => {
if (progress === 1 && level < masteryThresholds.length - 1) { if (progress === 1 && level < masteryThresholds.length) {
level++; level++;
} }
}); });
@@ -149,7 +170,6 @@
nextWord = word; nextWord = word;
break; break;
} }
text = "";
currentWord = nextWord; currentWord = nextWord;
recorder = new ReplayRecorder(nextWord); recorder = new ReplayRecorder(nextWord);
} }
@@ -159,18 +179,30 @@
const replay = recorder.finish(false); const replay = recorder.finish(false);
const elapsed = replay.finish - replay.start! - idleTime; const elapsed = replay.finish - replay.start! - idleTime;
if (elapsed < masteryThresholds[level]![0]) { if (elapsed < masteryThresholds[level]![0]) {
lastWPM = wpm;
const prevStats = wordStats.get(currentWord) ?? []; const prevStats = wordStats.get(currentWord) ?? [];
prevStats.push(elapsed); prevStats.push(elapsed);
wordStats.set(currentWord, prevStats.slice(-10)); wordStats.set(currentWord, prevStats.slice(-10));
} }
text = "";
setTimeout(() => {
selectNextWord(); selectNextWord();
});
} }
$effect(() => { $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) { function onkey(event: KeyboardEvent) {
@@ -189,7 +221,7 @@
<h1>Sentence Trainer</h1> <h1>Sentence Trainer</h1>
<div class="levels"> <div class="levels">
{#each masteryThresholds as _, i} {#each masteryThresholds as [, , title], i}
<button <button
class:active={level === i} class:active={level === i}
class:mastered={i < level || progress === 1} class:mastered={i < level || progress === 1}
@@ -199,17 +231,42 @@
selectNextWord(); selectNextWord();
}} }}
> >
Level {i + 1} {title}
</button> </button>
{/each} {/each}
<button
class:active={level === masteryThresholds.length}
class:mastered={masteryThresholds.length < level || progress === 1}
class="threshold"
onclick={() => {
level = masteryThresholds.length;
selectNextWord();
}}
>
{wpmTarget} WPM
</button>
{#each masteryThresholds as _, i} {#each masteryThresholds as _, i}
<div <div
class="progress" class="progress"
style:--progress="{-100 * style:--progress="{-100 *
(1 - (level === i ? progress : i < level ? 1 : 0))}%" (1 - (level === i ? progress : i < level ? 1 : 0))}%"
style:--mastered="{-100 *
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
class:active={level === i} class:active={level === i}
></div> ></div>
{/each} {/each}
<div
class="progress"
style:--progress="-100%"
style:--mastered="{-100 *
(1 -
(level === masteryThresholds.length
? progress
: masteryThresholds.length < level
? 1
: 0))}%"
class:active={level === masteryThresholds.length}
></div>
</div> </div>
<div class="sentence"> <div class="sentence">
{#each sentenceWords as _, i} {#each sentenceWords as _, i}
@@ -253,6 +310,55 @@
{/if} {/if}
{/each} {/each}
</div> </div>
{#if level === masteryThresholds.length}
{@const maxDigits = 4}
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
<div class="finish" transition:slide>
<div
class="wpm"
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
style:opacity={progress}
style:font-size="3rem"
style:color="var(--md-sys-color-{progress === 1
? 'primary'
: 'on-background'})"
style:scale={(progress + 0.5) / 2}
>
{#each indices as i}
{@const char = wpmString[i]}
{#key char}
<div
style:grid-column={i + 1}
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
>
{char}
</div>
{/key}
{/each}
<div style:grid-column={maxDigits + 3} style:justify-self="start">
WPM
</div>
</div>
<div
class="wpm"
style:grid-template-columns="4ch 1ch auto"
style:font-size="1.5rem"
>
{#key wpm}
<div
style:grid-column={1}
style:justify-self="end"
transition:fade={{ duration: 200 }}
>
{Math.floor(wpm)}
</div>
{/key}
<div style:grid-column={3} style:justify-self="start">WPM</div>
</div>
</div>
{/if}
<ChordHud {chords} /> <ChordHud {chords} />
<div class="container"> <div class="container">
<div <div
@@ -262,30 +368,48 @@
tabindex="0" tabindex="0"
role="textbox" role="textbox"
> >
{#if level === masteryThresholds.length - 1 && progress === 1}
<div class="finish" in:fly={{ y: -50, duration: 500 }}>
You have mastered this sentence!
</div>
{:else}
{#key recorder} {#key recorder}
<div <div class="input" transition:fade={{ duration: 200 }}>
class="input"
out:fly={{ y: 50, duration: 200 }}
in:fly={{ y: -50, duration: 500 }}
>
<CharRecorder replay={recorder.player} cursor={true} keys={true}> <CharRecorder replay={recorder.player} cursor={true} keys={true}>
<TrackText bind:text /> <TrackText bind:text />
<TrackWpm bind:wpm />
<TrackChords bind:chords /> <TrackChords bind:chords />
</CharRecorder> </CharRecorder>
</div> </div>
{/key} {/key}
{/if}
</div> </div>
</div> </div>
{#if devTools} {#if devTools}
<div>Dev Tools</div> <div>Dev Tools</div>
<button onclick={reset}>Reset</button>
<label>Idle Time <input bind:value={idleTime} /></label> <label>Idle Time <input bind:value={idleTime} /></label>
<table>
<tbody>
<tr>
<th>Total</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(totalMs)}</span
>ms</td
>
</tr>
<tr>
<th>Char</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerChar)}</span
>ms</td
>
</tr>
<tr>
<th>Word</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerWord)}</span
>ms</td
>
</tr>
</tbody>
</table>
<table> <table>
<tbody> <tbody>
{#each masteryThresholds as _, i} {#each masteryThresholds as _, i}
@@ -293,6 +417,7 @@
<th>L{i + 1}</th> <th>L{i + 1}</th>
<td><input bind:value={masteryThresholds[i]![0]} /></td> <td><input bind:value={masteryThresholds[i]![0]} /></td>
<td><input bind:value={masteryThresholds[i]![1]} /></td> <td><input bind:value={masteryThresholds[i]![1]} /></td>
<td><input bind:value={masteryThresholds[i]![2]} /></td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -330,13 +455,22 @@
} }
} }
.finish { .wpm {
font-weight: bold; width: min-content;
display: grid;
transition: scale 0.2s ease;
* {
grid-row: 1; grid-row: 1;
grid-column: 1; }
color: var(--md-sys-color-primary); }
text-align: center;
font-size: 1.5rem; .finish {
display: grid;
grid-template-rows: repeat(2, 1fr);
font-weight: bold;
justify-items: center;
align-items: center;
} }
.sentence { .sentence {
@@ -371,15 +505,25 @@
overflow: hidden; overflow: hidden;
grid-row: 2; grid-row: 2;
&::before,
&::after { &::after {
position: absolute;
content: ""; content: "";
display: block; display: block;
height: 100%; height: 100%;
width: 100%; width: 100%;
background: var(--md-sys-color-primary);
transform: translateX(var(--progress));
transition: transform 0.2s; 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 { .threshold {
@@ -387,6 +531,7 @@
justify-self: center; justify-self: center;
opacity: 0.5; opacity: 0.5;
transition: opacity 0.2s; transition: opacity 0.2s;
grid-row: 1;
&.mastered, &.mastered,
&.active { &.active {