chore: added the six digraphs
287a2f75
1 file(s) · +182 −92
| 1 | 1 | <script> |
|
| 2 | - | let timings = []; |
|
| 3 | - | let keyDownTime = null; |
|
| 4 | - | let activeKey = null; |
|
| 2 | + | let pendingKeys = new Map(); |
|
| 3 | + | let lastCompleted = null; |
|
| 4 | + | let digraphs = []; |
|
| 5 | + | let typedText = ''; |
|
| 5 | 6 | let copied = false; |
|
| 7 | + | ||
| 8 | + | const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']); |
|
| 6 | 9 | ||
| 7 | 10 | function handleKeyDown(e) { |
|
| 8 | - | if (keyDownTime !== null) return; |
|
| 9 | - | keyDownTime = performance.now(); |
|
| 10 | - | activeKey = e.key === ' ' ? 'Space' : e.key; |
|
| 11 | + | if (MODIFIER_KEYS.has(e.key)) return; |
|
| 12 | + | if (pendingKeys.has(e.key)) return; |
|
| 13 | + | pendingKeys.set(e.key, { pressTime: performance.now() }); |
|
| 11 | 14 | } |
|
| 12 | 15 | ||
| 13 | 16 | function handleKeyUp(e) { |
|
| 14 | - | if (keyDownTime === null) return; |
|
| 15 | - | const duration = Math.round((performance.now() - keyDownTime) * 10) / 10; |
|
| 16 | - | const key = e.key === ' ' ? 'Space' : e.key; |
|
| 17 | - | timings = [{ key, duration, id: Date.now() }, ...timings]; |
|
| 18 | - | keyDownTime = null; |
|
| 19 | - | activeKey = null; |
|
| 17 | + | if (MODIFIER_KEYS.has(e.key)) return; |
|
| 18 | + | const pending = pendingKeys.get(e.key); |
|
| 19 | + | if (!pending) return; |
|
| 20 | + | pendingKeys.delete(e.key); |
|
| 21 | + | ||
| 22 | + | const releaseTime = performance.now(); |
|
| 23 | + | const currentEvent = { key: e.key, pressTime: pending.pressTime, releaseTime }; |
|
| 24 | + | ||
| 25 | + | if (lastCompleted) { |
|
| 26 | + | const k1 = lastCompleted; |
|
| 27 | + | const k2 = currentEvent; |
|
| 28 | + | const holdTime1 = round(k1.releaseTime - k1.pressTime); |
|
| 29 | + | const holdTime2 = round(k2.releaseTime - k2.pressTime); |
|
| 30 | + | const pressPress = round(k2.pressTime - k1.pressTime); |
|
| 31 | + | const releaseRelease = round(k2.releaseTime - k1.releaseTime); |
|
| 32 | + | const pressRelease = round(k2.releaseTime - k1.pressTime); |
|
| 33 | + | const releasePress = round(k2.pressTime - k1.releaseTime); |
|
| 34 | + | ||
| 35 | + | const label = (k) => (k === ' ' ? '␣' : k); |
|
| 36 | + | digraphs = [ |
|
| 37 | + | { |
|
| 38 | + | id: Date.now(), |
|
| 39 | + | keys: `${label(k1.key)} → ${label(k2.key)}`, |
|
| 40 | + | key1: k1.key, |
|
| 41 | + | key2: k2.key, |
|
| 42 | + | holdTime1, |
|
| 43 | + | holdTime2, |
|
| 44 | + | pressPress, |
|
| 45 | + | releaseRelease, |
|
| 46 | + | pressRelease, |
|
| 47 | + | releasePress, |
|
| 48 | + | }, |
|
| 49 | + | ...digraphs, |
|
| 50 | + | ]; |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | lastCompleted = currentEvent; |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | function round(v) { |
|
| 57 | + | return Math.round(v * 10) / 10; |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | function avg(arr, fn) { |
|
| 61 | + | if (arr.length === 0) return '—'; |
|
| 62 | + | return (arr.reduce((s, d) => s + fn(d), 0) / arr.length).toFixed(1); |
|
| 20 | 63 | } |
|
| 21 | 64 | ||
| 22 | 65 | function clear() { |
|
| 23 | - | timings = []; |
|
| 66 | + | digraphs = []; |
|
| 67 | + | lastCompleted = null; |
|
| 68 | + | pendingKeys = new Map(); |
|
| 69 | + | typedText = ''; |
|
| 24 | 70 | copied = false; |
|
| 25 | 71 | } |
|
| 26 | 72 | ||
| 30 | 76 | setTimeout(() => (copied = false), 2000); |
|
| 31 | 77 | } |
|
| 32 | 78 | ||
| 33 | - | $: avgMs = timings.length |
|
| 34 | - | ? (timings.reduce((s, t) => s + t.duration, 0) / timings.length).toFixed(1) |
|
| 35 | - | : null; |
|
| 79 | + | $: avgHT1 = avg(digraphs, (d) => d.holdTime1); |
|
| 80 | + | $: avgHT2 = avg(digraphs, (d) => d.holdTime2); |
|
| 81 | + | $: avgPP = avg(digraphs, (d) => d.pressPress); |
|
| 82 | + | $: avgRR = avg(digraphs, (d) => d.releaseRelease); |
|
| 83 | + | $: avgPR = avg(digraphs, (d) => d.pressRelease); |
|
| 84 | + | $: avgRP = avg(digraphs, (d) => d.releasePress); |
|
| 36 | 85 | ||
| 37 | 86 | $: jsonData = JSON.stringify( |
|
| 38 | - | timings.map((t) => ({ key: t.key, duration_ms: t.duration })), |
|
| 87 | + | digraphs.map((d) => ({ |
|
| 88 | + | keys: d.keys, |
|
| 89 | + | key_1: d.key1, |
|
| 90 | + | key_2: d.key2, |
|
| 91 | + | hold_time_1_ms: d.holdTime1, |
|
| 92 | + | hold_time_2_ms: d.holdTime2, |
|
| 93 | + | press_press_ms: d.pressPress, |
|
| 94 | + | release_release_ms: d.releaseRelease, |
|
| 95 | + | press_release_ms: d.pressRelease, |
|
| 96 | + | release_press_ms: d.releasePress, |
|
| 97 | + | })), |
|
| 39 | 98 | null, |
|
| 40 | 99 | 2 |
|
| 41 | 100 | ); |
|
| 45 | 104 | ||
| 46 | 105 | <main> |
|
| 47 | 106 | <div class="header"> |
|
| 48 | - | <h1>keypress timer</h1> |
|
| 49 | - | <p class="sub">hold any key, release to record</p> |
|
| 107 | + | <h1>keystroke dynamics</h1> |
|
| 108 | + | <p class="sub">type in the textarea to capture digraph timing features</p> |
|
| 50 | 109 | </div> |
|
| 51 | 110 | ||
| 52 | - | <div class="monitor"> |
|
| 53 | - | {#if activeKey} |
|
| 54 | - | <span class="live-key">{activeKey}</span> |
|
| 55 | - | <span class="live-label">holding...</span> |
|
| 56 | - | {:else if timings.length === 0} |
|
| 57 | - | <span class="idle">press a key</span> |
|
| 58 | - | {:else} |
|
| 59 | - | <span class="last-ms">{timings[0].duration}<span class="unit">ms</span></span> |
|
| 60 | - | <span class="live-label">last press</span> |
|
| 61 | - | {/if} |
|
| 62 | - | </div> |
|
| 111 | + | <textarea |
|
| 112 | + | bind:value={typedText} |
|
| 113 | + | placeholder="start typing..." |
|
| 114 | + | rows="4" |
|
| 115 | + | ></textarea> |
|
| 63 | 116 | ||
| 64 | - | {#if timings.length > 0} |
|
| 117 | + | {#if digraphs.length > 0} |
|
| 65 | 118 | <div class="stats-bar"> |
|
| 66 | 119 | <div class="stat"> |
|
| 67 | - | <span class="stat-val">{timings.length}</span> |
|
| 68 | - | <span class="stat-label">presses</span> |
|
| 120 | + | <span class="stat-val">{avgHT1}<span class="unit-sm">ms</span></span> |
|
| 121 | + | <span class="stat-label">hold time 1</span> |
|
| 69 | 122 | </div> |
|
| 70 | 123 | <div class="stat"> |
|
| 71 | - | <span class="stat-val">{avgMs}<span class="unit-sm">ms</span></span> |
|
| 72 | - | <span class="stat-label">avg hold</span> |
|
| 124 | + | <span class="stat-val">{avgHT2}<span class="unit-sm">ms</span></span> |
|
| 125 | + | <span class="stat-label">hold time 2</span> |
|
| 73 | 126 | </div> |
|
| 74 | 127 | <div class="stat"> |
|
| 75 | - | <span class="stat-val" |
|
| 76 | - | >{Math.min(...timings.map((t) => t.duration)).toFixed(1)}<span class="unit-sm" |
|
| 77 | - | >ms</span |
|
| 78 | - | ></span |
|
| 79 | - | > |
|
| 80 | - | <span class="stat-label">min</span> |
|
| 128 | + | <span class="stat-val">{avgPP}<span class="unit-sm">ms</span></span> |
|
| 129 | + | <span class="stat-label">press-press</span> |
|
| 81 | 130 | </div> |
|
| 82 | 131 | <div class="stat"> |
|
| 83 | - | <span class="stat-val" |
|
| 84 | - | >{Math.max(...timings.map((t) => t.duration)).toFixed(1)}<span class="unit-sm" |
|
| 85 | - | >ms</span |
|
| 86 | - | ></span |
|
| 87 | - | > |
|
| 88 | - | <span class="stat-label">max</span> |
|
| 132 | + | <span class="stat-val">{avgRR}<span class="unit-sm">ms</span></span> |
|
| 133 | + | <span class="stat-label">release-release</span> |
|
| 134 | + | </div> |
|
| 135 | + | <div class="stat"> |
|
| 136 | + | <span class="stat-val">{avgPR}<span class="unit-sm">ms</span></span> |
|
| 137 | + | <span class="stat-label">press-release</span> |
|
| 138 | + | </div> |
|
| 139 | + | <div class="stat"> |
|
| 140 | + | <span class="stat-val">{avgRP}<span class="unit-sm">ms</span></span> |
|
| 141 | + | <span class="stat-label">release-press</span> |
|
| 89 | 142 | </div> |
|
| 90 | 143 | </div> |
|
| 91 | 144 | ||
| 145 | + | <div class="digraph-table-wrap"> |
|
| 146 | + | <table class="digraph-table"> |
|
| 147 | + | <thead> |
|
| 148 | + | <tr> |
|
| 149 | + | <th>keys</th> |
|
| 150 | + | <th>HT1</th> |
|
| 151 | + | <th>HT2</th> |
|
| 152 | + | <th>PP</th> |
|
| 153 | + | <th>RR</th> |
|
| 154 | + | <th>PR</th> |
|
| 155 | + | <th>RP</th> |
|
| 156 | + | </tr> |
|
| 157 | + | </thead> |
|
| 158 | + | <tbody> |
|
| 159 | + | {#each digraphs as d (d.id)} |
|
| 160 | + | <tr> |
|
| 161 | + | <td class="keys-cell">{d.keys}</td> |
|
| 162 | + | <td>{d.holdTime1}</td> |
|
| 163 | + | <td>{d.holdTime2}</td> |
|
| 164 | + | <td>{d.pressPress}</td> |
|
| 165 | + | <td>{d.releaseRelease}</td> |
|
| 166 | + | <td>{d.pressRelease}</td> |
|
| 167 | + | <td>{d.releasePress}</td> |
|
| 168 | + | </tr> |
|
| 169 | + | {/each} |
|
| 170 | + | </tbody> |
|
| 171 | + | </table> |
|
| 172 | + | </div> |
|
| 173 | + | ||
| 92 | 174 | <div class="json-section"> |
|
| 93 | 175 | <div class="json-header"> |
|
| 94 | 176 | <span>data</span> |
|
| 129 | 211 | color: #888; |
|
| 130 | 212 | } |
|
| 131 | 213 | ||
| 132 | - | .monitor { |
|
| 214 | + | textarea { |
|
| 215 | + | width: 100%; |
|
| 133 | 216 | border: 1px solid #333; |
|
| 134 | - | padding: 2rem; |
|
| 135 | - | display: flex; |
|
| 136 | - | flex-direction: column; |
|
| 137 | - | align-items: center; |
|
| 138 | - | justify-content: center; |
|
| 139 | - | gap: 0.25rem; |
|
| 140 | - | min-height: 100px; |
|
| 141 | - | } |
|
| 142 | - | ||
| 143 | - | .live-key { |
|
| 144 | - | font-size: 2rem; |
|
| 145 | - | font-weight: 700; |
|
| 146 | - | line-height: 1; |
|
| 147 | - | animation: pulse 0.6s ease-in-out infinite alternate; |
|
| 217 | + | background: transparent; |
|
| 218 | + | color: inherit; |
|
| 219 | + | font-family: inherit; |
|
| 220 | + | font-size: 14px; |
|
| 221 | + | padding: 1rem; |
|
| 222 | + | resize: vertical; |
|
| 223 | + | outline: none; |
|
| 224 | + | box-sizing: border-box; |
|
| 148 | 225 | } |
|
| 149 | 226 | ||
| 150 | - | @keyframes pulse { |
|
| 151 | - | from { |
|
| 152 | - | opacity: 1; |
|
| 153 | - | } |
|
| 154 | - | to { |
|
| 155 | - | opacity: 0.5; |
|
| 156 | - | } |
|
| 157 | - | } |
|
| 158 | - | ||
| 159 | - | .live-label { |
|
| 160 | - | font-size: 12px; |
|
| 227 | + | textarea::placeholder { |
|
| 161 | 228 | color: #888; |
|
| 162 | 229 | } |
|
| 163 | 230 | ||
| 164 | - | .last-ms { |
|
| 165 | - | font-size: 2rem; |
|
| 166 | - | font-weight: 700; |
|
| 167 | - | line-height: 1; |
|
| 168 | - | } |
|
| 169 | - | ||
| 170 | - | .unit { |
|
| 171 | - | font-size: 12px; |
|
| 172 | - | color: #888; |
|
| 173 | - | margin-left: 2px; |
|
| 174 | - | } |
|
| 175 | - | ||
| 176 | - | .idle { |
|
| 177 | - | font-size: 12px; |
|
| 178 | - | color: #888; |
|
| 231 | + | textarea:focus { |
|
| 232 | + | border-color: #888; |
|
| 179 | 233 | } |
|
| 180 | 234 | ||
| 181 | 235 | .stats-bar { |
|
| 182 | 236 | display: grid; |
|
| 183 | - | grid-template-columns: repeat(4, 1fr); |
|
| 237 | + | grid-template-columns: repeat(6, 1fr); |
|
| 184 | 238 | border: 1px solid #333; |
|
| 185 | 239 | } |
|
| 186 | 240 | ||
| 213 | 267 | font-weight: 400; |
|
| 214 | 268 | } |
|
| 215 | 269 | ||
| 270 | + | .digraph-table-wrap { |
|
| 271 | + | border: 1px solid #333; |
|
| 272 | + | max-height: 300px; |
|
| 273 | + | overflow-y: auto; |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | .digraph-table { |
|
| 277 | + | width: 100%; |
|
| 278 | + | border-collapse: collapse; |
|
| 279 | + | font-size: 12px; |
|
| 280 | + | } |
|
| 281 | + | ||
| 282 | + | .digraph-table th, |
|
| 283 | + | .digraph-table td { |
|
| 284 | + | padding: 0.4rem 0.5rem; |
|
| 285 | + | text-align: center; |
|
| 286 | + | border-bottom: 1px solid #333; |
|
| 287 | + | } |
|
| 288 | + | ||
| 289 | + | .digraph-table th { |
|
| 290 | + | position: sticky; |
|
| 291 | + | top: 0; |
|
| 292 | + | background: #0a0a0a; |
|
| 293 | + | color: #888; |
|
| 294 | + | font-weight: 400; |
|
| 295 | + | font-size: 10px; |
|
| 296 | + | text-transform: uppercase; |
|
| 297 | + | letter-spacing: 0.05em; |
|
| 298 | + | } |
|
| 299 | + | ||
| 300 | + | .digraph-table .keys-cell { |
|
| 301 | + | text-align: left; |
|
| 302 | + | font-weight: 700; |
|
| 303 | + | } |
|
| 304 | + | ||
| 216 | 305 | .json-section { |
|
| 217 | 306 | display: flex; |
|
| 218 | 307 | flex-direction: column; |
|
| 251 | 340 | ||
| 252 | 341 | @media (max-width: 480px) { |
|
| 253 | 342 | .stats-bar { |
|
| 254 | - | grid-template-columns: repeat(2, 1fr); |
|
| 343 | + | grid-template-columns: repeat(3, 1fr); |
|
| 255 | 344 | } |
|
| 256 | 345 | ||
| 257 | - | .stat:nth-child(2) { |
|
| 346 | + | .stat:nth-child(3) { |
|
| 258 | 347 | border-right: none; |
|
| 259 | 348 | } |
|
| 260 | 349 | ||
| 261 | 350 | .stat:nth-child(1), |
|
| 262 | - | .stat:nth-child(2) { |
|
| 351 | + | .stat:nth-child(2), |
|
| 352 | + | .stat:nth-child(3) { |
|
| 263 | 353 | border-bottom: 1px solid #333; |
|
| 264 | 354 | } |
|
| 265 | 355 | } |
|