chore: added the six digraphs 287a2f75
Steve · 2026-03-19 19:19 1 file(s) · +182 −92
src/App.svelte +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
  }