mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-18 16:02:57 +00:00
feat: basic chord trainer
fix: don't add chords from backup if identical chords already exist, fixes #30
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<h1>Layout Bootcamp</h1>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user