feat: basic chord trainer

fix: don't add chords from backup if identical chords already exist, fixes #30
This commit is contained in:
2023-11-18 18:35:59 +01:00
parent e84470d577
commit a0fe925ea9
6 changed files with 188 additions and 744 deletions

View File

@@ -109,7 +109,11 @@ export function restoreFromFile(
export function getChangesFromChordFile(file: CharaChordFile) {
const changes: Change[] = []
const existingChords = new Set(get(chords).map(({phrase, actions}) => JSON.stringify({phrase, actions})))
for (const [input, output] of file.chords) {
if (existingChords.has(JSON.stringify({actions: input, phrase: output}))) {
continue
}
changes.push({
type: ChangeType.Chord,
actions: input,

View File

@@ -0,0 +1 @@
<h1>Layout Bootcamp</h1>

View File

@@ -13,6 +13,10 @@
let pressedKeys = new Set<number>()
let editing = false
function compare(a: number, b: number) {
return a - b
}
function edit() {
pressedKeys = new Set()
editing = true
@@ -29,16 +33,12 @@
if (!editing) return
editing = false
if (pressedKeys.size < 2) return
if (!chord)
return dispatch(
"submit",
[...pressedKeys].sort((a, b) => a - b),
)
if (!chord) return dispatch("submit", [...pressedKeys].sort(compare))
changes.update(changes => {
changes.push({
type: ChangeType.Chord,
id: chord!.id,
actions: [...pressedKeys].sort((a, b) => a - b),
actions: [...pressedKeys].sort(compare),
phrase: chord!.phrase,
})
return changes
@@ -49,6 +49,7 @@
<button
class:deleted={chord && chord.phrase.length === 0}
class:edited={chord && chord.actionsChanged}
class:invalid={chord && chord.actions.toSorted(compare).some((it, i) => chord?.actions[i] !== it)}
on:click={edit}
on:keydown={keydown}
on:keyup={keyup}
@@ -58,10 +59,7 @@
{:else if !editing && !chord}
<span>{$LL.configure.chords.NEW_CHORD()}</span>
{/if}
<ActionString
display="keys"
actions={editing ? [...pressedKeys].sort((a, b) => a - b) : chord?.actions ?? []}
/>
<ActionString display="keys" actions={editing ? [...pressedKeys].sort(compare) : chord?.actions ?? []} />
<sup></sup>
</button>
@@ -117,6 +115,10 @@
}
}
.invalid {
color: var(--md-sys-color-error);
}
.deleted {
color: var(--md-sys-color-error);

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import {chords} from "$lib/undo-redo"
import Action from "$lib/components/Action.svelte"
import {onDestroy, onMount} from "svelte"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import {fly} from "svelte/transition"
import type {Chord} from "$lib/serial/chord"
const speedRating = [
[400, "+100", "excited", true],
[700, "+50", "satisfied", true],
[1400, "+25", "neutral", true],
[3000, "0", "dissatisfied", false],
[Infinity, "-50", "sad", false],
] as const
const accuracyRating = [
[2, "+100", "calm", true],
[3, "+50", "content", false],
[5, "+25", "stressed", false],
[7, "0", "frustrated", false],
[14, "-25", "very_dissatisfied", false],
[Infinity, "-50", "extremely_dissatisfied", false],
] as const
let next: Chord[] = []
let nextHandle: number
let took: number | undefined
let delta = 0
let speed: readonly [number, string, string, boolean] | undefined
let accuracy: readonly [number, string, string, boolean] | undefined
let progress = 0
let attempts = 0
let userInput = ""
onMount(() => {
runTest()
})
function runTest() {
if (took === undefined) {
took = performance.now()
delta = 0
attempts = 0
userInput = ""
if (next.length === 0) {
next = Array.from({length: 5}, () => $chords[Math.floor(Math.random() * $chords.length)])
} else {
next.shift()
next.push($chords[Math.floor(Math.random() * $chords.length)])
next = next
}
}
if (userInput === next[0].phrase.map(it => (it === 32 ? " " : KEYMAP_CODES[it]!.id)).join("") + " ") {
took = undefined
speed = speedRating.find(([max]) => delta <= max)
accuracy = accuracyRating.find(([max]) => attempts <= max)
progress++
} else {
delta = performance.now() - took
}
nextHandle = requestAnimationFrame(runTest)
}
let debounceTimer = 0
function backspace(event: KeyboardEvent) {
if (event.code === "Backspace") {
userInput = userInput.slice(0, -1)
}
}
function input(event: KeyboardEvent) {
const stamp = performance.now()
if (stamp - debounceTimer > 50) {
attempts++
}
debounceTimer = stamp
userInput += event.key
}
onDestroy(() => {
if (nextHandle) {
cancelAnimationFrame(nextHandle)
}
})
</script>
<svelte:window on:keydown={backspace} on:keypress={input} />
<h1>Vocabulary Trainer</h1>
{#if next[0]}
<div class="row">
{#key progress}
<div in:fly={{duration: 300, x: -48}} out:fly={{duration: 1000, x: 128}} class="rating">
{#if speed}
<span class="rating-item">
<span style:color="var(--md-sys-color-{speed[3] ? `primary` : `error`})" class="icon">timer</span>
{speed[1]}
<span class="icon">sentiment_{speed[2]}</span>
</span>
{/if}
{#if accuracy}
<span class="rating-item">
<span style:color="var(--md-sys-color-{accuracy[3] ? `primary` : `error`})" class="icon"
>target</span
>
{accuracy[1]}
<span class="icon">sentiment_{accuracy[2]}</span>
</span>
{/if}
</div>
{/key}
</div>
<div class="hint" style:opacity={delta > 3000 ? 1 : 0}>
{#each next[0].actions as action}
<Action {action} display="keys" />
{/each}
</div>
<div>
{userInput}
</div>
{#each next as chord, i}
<div class="words" style:opacity={1 - i / next.length}>
{#each chord.phrase as action}
<Action {action} />
{/each}
</div>
{/each}
{:else}
<p>You don't have any chords</p>
{/if}
<style lang="scss">
.row {
position: relative;
height: 48px;
}
.rating-item {
display: flex;
gap: 8px;
justify-content: flex-start;
}
.rating {
position: absolute;
left: -48px;
width: max-content;
}
</style>