1
2
3
4
5
6const TypingMath = {
7
8
9
10
11
12
13 calculateGrossWPM(totalTypedChars, timeInSeconds) {
14 if (!timeInSeconds || timeInSeconds <= 0 || !totalTypedChars || totalTypedChars < 0) return 0;
15 const minutes = timeInSeconds / 60;
16 const words = totalTypedChars / 5;
17 const wpm = words / minutes;
18 return Math.max(0, Math.round(wpm * 100) / 100);
19 },
20
21
22
23
24
25
26
27 calculateNetWPM(correctTypedChars, timeInSeconds) {
28 if (!timeInSeconds || timeInSeconds <= 0 || !correctTypedChars || correctTypedChars < 0) return 0;
29 const minutes = timeInSeconds / 60;
30 const words = correctTypedChars / 5;
31 const wpm = words / minutes;
32 return Math.max(0, Math.round(wpm * 100) / 100);
33 },
34
35
36
37
38
39
40
41 calculateAccuracy(correctTypedChars, totalTypedChars) {
42 if (!totalTypedChars || totalTypedChars <= 0) return 100;
43 if (!correctTypedChars || correctTypedChars < 0) return 0;
44 if (correctTypedChars > totalTypedChars) return 100;
45 const accuracy = (correctTypedChars / totalTypedChars) * 100;
46 return Math.max(0, Math.min(100, Math.round(accuracy * 10) / 10));
47 },
48
49
50
51
52
53
54
55 calculateErrors(totalTypedChars, correctTypedChars) {
56 if (!totalTypedChars || totalTypedChars <= 0) return 0;
57 if (!correctTypedChars || correctTypedChars < 0) return totalTypedChars;
58 return Math.max(0, totalTypedChars - correctTypedChars);
59 },
60
61
62
63
64
65
66 formatTime(seconds) {
67 const secs = Math.max(0, Math.floor(seconds));
68 const m = Math.floor(secs / 60);
69 const s = secs % 60;
70 return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
71 }
72};
73
74
75class TypingTestApp {
76 constructor() {
77 this.mode = 'timed';
78 this.duration = 60;
79 this.timeRemaining = 60;
80 this.timeElapsed = 0;
81 this.timerInterval = null;
82 this.isRunning = false;
83 this.isFinished = false;
84
85 this.wordList = [];
86 this.currentWordIndex = 0;
87 this.currentInput = '';
88
89 this.totalTypedChars = 0;
90 this.correctTypedChars = 0;
91 this.errorCount = 0;
92
93 this.keyPresses = {};
94 this.keyErrors = {};
95
96 this.wpmHistory = [];
97
98 this.storageKey = 'pulse_type_high_scores_v1';
99
100
101 if (typeof window !== 'undefined' && typeof document !== 'undefined') {
102 this.initDOM();
103 }
104 }
105
106 initDOM() {
107 this.elements = {
108 wordStream: document.getElementById('word-stream'),
109 hiddenInput: document.getElementById('hidden-input'),
110 wpmDisplay: document.getElementById('wpm-display'),
111 accuracyDisplay: document.getElementById('accuracy-display'),
112 timerDisplay: document.getElementById('timer-display'),
113 errorDisplay: document.getElementById('error-display'),
114 modeTimedBtn: document.getElementById('mode-timed'),
115 modeZenBtn: document.getElementById('mode-zen'),
116 restartBtn: document.getElementById('restart-btn'),
117 endZenBtn: document.getElementById('end-zen-btn'),
118 resultsModal: document.getElementById('results-modal'),
119 modalCloseBtn: document.getElementById('modal-close-btn'),
120 modalRestartBtn: document.getElementById('modal-restart-btn'),
121 canvas: document.getElementById('wpmChart'),
122 finalWpm: document.getElementById('final-wpm'),
123 finalAccuracy: document.getElementById('final-accuracy'),
124 finalRawWpm: document.getElementById('final-raw-wpm'),
125 finalChars: document.getElementById('final-chars'),
126 finalErrors: document.getElementById('final-errors'),
127 finalTime: document.getElementById('final-time'),
128 highScoresList: document.getElementById('high-scores-list'),
129 keyErrorList: document.getElementById('key-error-list'),
130 keyboard: document.getElementById('virtual-keyboard')
131 };
132
133 this.bindEvents();
134 this.resetTest();
135 this.renderKeyboard();
136 }
137
138 bindEvents() {
139 if (!this.elements.hiddenInput) return;
140
141
142 document.addEventListener('click', (e) => {
143 if (!this.elements.resultsModal.classList.contains('active')) {
144 this.elements.hiddenInput.focus();
145 }
146 });
147
148 this.elements.hiddenInput.addEventListener('input', (e) => this.handleInput(e));
149
150 this.elements.hiddenInput.addEventListener('keydown', (e) => {
151 this.highlightVirtualKey(e.key, true);
152 if (e.key === 'Backspace' && this.currentInput === '' && this.currentWordIndex > 0) {
153
154
155 }
156 });
157
158 this.elements.hiddenInput.addEventListener('keyup', (e) => {
159 this.highlightVirtualKey(e.key, false);
160 });
161
162 this.elements.modeTimedBtn.addEventListener('click', () => this.setMode('timed'));
163 this.elements.modeZenBtn.addEventListener('click', () => this.setMode('zen'));
164 this.elements.restartBtn.addEventListener('click', () => this.resetTest());
165 if (this.elements.endZenBtn) {
166 this.elements.endZenBtn.addEventListener('click', () => this.finishTest());
167 }
168
169 this.elements.modalCloseBtn.addEventListener('click', () => this.closeResults());
170 this.elements.modalRestartBtn.addEventListener('click', () => {
171 this.closeResults();
172 this.resetTest();
173 });
174
175
176 document.addEventListener('keydown', (e) => {
177 if (e.key === 'Escape' && this.elements.resultsModal.classList.contains('active')) {
178 this.closeResults();
179 }
180 if (e.key === 'Tab') {
181 e.preventDefault();
182 this.resetTest();
183 }
184 });
185 }
186
187 setMode(mode) {
188 if (this.mode === mode) return;
189 this.mode = mode;
190 this.elements.modeTimedBtn.classList.toggle('active', mode === 'timed');
191 this.elements.modeZenBtn.classList.toggle('active', mode === 'zen');
192 if (this.elements.endZenBtn) {
193 this.elements.endZenBtn.style.display = mode === 'zen' ? 'inline-flex' : 'none';
194 }
195 this.resetTest();
196 }
197
198 resetTest() {
199 clearInterval(this.timerInterval);
200 this.timerInterval = null;
201 this.isRunning = false;
202 this.isFinished = false;
203
204 this.timeRemaining = 60;
205 this.timeElapsed = 0;
206
207 this.currentWordIndex = 0;
208 this.currentInput = '';
209 this.totalTypedChars = 0;
210 this.correctTypedChars = 0;
211 this.errorCount = 0;
212
213 this.keyPresses = {};
214 this.keyErrors = {};
215 this.wpmHistory = [];
216
217
218 if (typeof getRandomWords === 'function') {
219 this.wordList = getRandomWords(150);
220 } else {
221 this.wordList = ["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"];
222 }
223
224 if (this.elements.hiddenInput) {
225 this.elements.hiddenInput.value = '';
226 this.elements.hiddenInput.focus();
227 }
228
229 this.updateStatsDisplay();
230 this.renderWordStream();
231 this.updateKeyboardErrorHighlights();
232
233 if (this.elements.resultsModal) {
234 this.elements.resultsModal.classList.remove('active');
235 }
236 }
237
238 startTimer() {
239 if (this.isRunning) return;
240 this.isRunning = true;
241 this.timeElapsed = 0;
242 this.wpmHistory = [];
243
244
245 this.recordSnapshot();
246
247 this.timerInterval = setInterval(() => {
248 this.timeElapsed++;
249
250 if (this.mode === 'timed') {
251 this.timeRemaining--;
252 if (this.timeRemaining <= 0) {
253 this.timeRemaining = 0;
254 this.finishTest();
255 return;
256 }
257 }
258
259 this.recordSnapshot();
260 this.updateStatsDisplay();
261 }, 1000);
262 }
263
264 recordSnapshot() {
265 const netWpm = TypingMath.calculateNetWPM(this.correctTypedChars, this.timeElapsed || 1);
266 const grossWpm = TypingMath.calculateGrossWPM(this.totalTypedChars, this.timeElapsed || 1);
267 const acc = TypingMath.calculateAccuracy(this.correctTypedChars, this.totalTypedChars);
268
269 this.wpmHistory.push({
270 second: this.timeElapsed,
271 netWpm: netWpm,
272 grossWpm: grossWpm,
273 accuracy: acc
274 });
275 }
276
277 handleInput(e) {
278 if (this.isFinished) return;
279
280 if (!this.isRunning) {
281 this.startTimer();
282 }
283
284 const value = this.elements.hiddenInput.value;
285 const targetWord = this.wordList[this.currentWordIndex] || '';
286
287
288 if (value.endsWith(' ')) {
289
290 const typedWord = value.trim();
291 this.evaluateWordCommit(typedWord, targetWord);
292 this.currentWordIndex++;
293 this.elements.hiddenInput.value = '';
294 this.currentInput = '';
295
296
297 if (this.currentWordIndex >= this.wordList.length - 20) {
298 if (typeof getRandomWords === 'function') {
299 this.wordList.push(...getRandomWords(50));
300 }
301 }
302 } else {
303
304 const prevLen = this.currentInput.length;
305 const newLen = value.length;
306
307
308 if (newLen > prevLen) {
309 const addedChar = value[newLen - 1];
310 const expectedChar = targetWord[newLen - 1];
311 const keyLower = addedChar.toLowerCase();
312
313 this.keyPresses[keyLower] = (this.keyPresses[keyLower] || 0) + 1;
314
315 if (expectedChar && addedChar !== expectedChar) {
316 this.keyErrors[keyLower] = (this.keyErrors[keyLower] || 0) + 1;
317
318 const expLower = expectedChar.toLowerCase();
319 this.keyErrors[expLower] = (this.keyErrors[expLower] || 0) + 1;
320 this.errorCount++;
321 }
322 }
323
324 this.currentInput = value;
325 this.recalculateLiveChars();
326 }
327
328 this.renderWordStream();
329 this.updateStatsDisplay();
330 this.updateKeyboardErrorHighlights();
331 }
332
333 evaluateWordCommit(typedWord, targetWord) {
334
335 this.totalTypedChars += typedWord.length + 1;
336
337 let correctCharsInWord = 0;
338 const minLen = Math.min(typedWord.length, targetWord.length);
339 for (let i = 0; i < minLen; i++) {
340 if (typedWord[i] === targetWord[i]) {
341 correctCharsInWord++;
342 }
343 }
344
345
346 if (typedWord === targetWord) {
347 correctCharsInWord += 1;
348 }
349
350 this.correctTypedChars += correctCharsInWord;
351 }
352
353 recalculateLiveChars() {
354
355 let correctCount = 0;
356 let totalCount = 0;
357
358
359
360 }
361
362 getEffectiveCorrectChars() {
363 let liveCorrect = this.correctTypedChars;
364 let liveTotal = this.totalTypedChars;
365
366 const targetWord = this.wordList[this.currentWordIndex] || '';
367 const typed = this.currentInput;
368
369 liveTotal += typed.length;
370 for (let i = 0; i < typed.length; i++) {
371 if (i < targetWord.length && typed[i] === targetWord[i]) {
372 liveCorrect++;
373 }
374 }
375
376 return { correct: liveCorrect, total: liveTotal };
377 }
378
379 updateStatsDisplay() {
380 const { correct, total } = this.getEffectiveCorrectChars();
381 const seconds = this.mode === 'timed' ? (60 - this.timeRemaining) : this.timeElapsed;
382 const activeSeconds = Math.max(1, seconds);
383
384 const netWpm = TypingMath.calculateNetWPM(correct, activeSeconds);
385 const accuracy = TypingMath.calculateAccuracy(correct, total);
386 const errors = TypingMath.calculateErrors(total, correct);
387
388 if (this.elements.wpmDisplay) this.elements.wpmDisplay.textContent = Math.round(netWpm);
389 if (this.elements.accuracyDisplay) this.elements.accuracyDisplay.textContent = `${Math.round(accuracy)}%`;
390 if (this.elements.errorDisplay) this.elements.errorDisplay.textContent = errors;
391
392 if (this.elements.timerDisplay) {
393 if (this.mode === 'timed') {
394 this.elements.timerDisplay.textContent = `${this.timeRemaining}s`;
395 } else {
396 this.elements.timerDisplay.textContent = TypingMath.formatTime(this.timeElapsed);
397 }
398 }
399 }
400
401 renderWordStream() {
402 if (!this.elements.wordStream) return;
403
404 const container = this.elements.wordStream;
405 container.innerHTML = '';
406
407
408 const startIndex = Math.max(0, this.currentWordIndex - 5);
409 const endIndex = Math.min(this.wordList.length, startIndex + 35);
410
411 for (let i = startIndex; i < endIndex; i++) {
412 const wordStr = this.wordList[i];
413 const wordSpan = document.createElement('span');
414 wordSpan.className = 'word';
415
416 if (i < this.currentWordIndex) {
417 wordSpan.classList.add('completed');
418 } else if (i === this.currentWordIndex) {
419 wordSpan.classList.add('active');
420
421
422 const currentTyped = this.currentInput;
423 const maxLen = Math.max(wordStr.length, currentTyped.length);
424
425 for (let c = 0; c < maxLen; c++) {
426 const charSpan = document.createElement('span');
427 charSpan.className = 'char';
428
429 const expected = wordStr[c];
430 const typed = currentTyped[c];
431
432 if (c === currentTyped.length) {
433 charSpan.classList.add('cursor');
434 }
435
436 if (typed === undefined) {
437
438 charSpan.textContent = expected;
439 charSpan.classList.add('untyped');
440 } else if (expected === undefined) {
441
442 charSpan.textContent = typed;
443 charSpan.classList.add('incorrect', 'extra');
444 } else if (typed === expected) {
445 charSpan.textContent = expected;
446 charSpan.classList.add('correct');
447 } else {
448 charSpan.textContent = expected;
449 charSpan.classList.add('incorrect');
450 }
451
452 wordSpan.appendChild(charSpan);
453 }
454
455
456 if (currentTyped.length >= maxLen) {
457 const cursorSpan = document.createElement('span');
458 cursorSpan.className = 'char cursor trailing';
459 cursorSpan.innerHTML = ' ';
460 wordSpan.appendChild(cursorSpan);
461 }
462
463 } else {
464
465 wordSpan.textContent = wordStr;
466 wordSpan.classList.add('upcoming');
467 }
468
469 container.appendChild(wordSpan);
470 }
471
472
473 const activeElem = container.querySelector('.word.active');
474 if (activeElem) {
475 const containerRect = container.getBoundingClientRect();
476 const activeRect = activeElem.getBoundingClientRect();
477 if (activeRect.top < containerRect.top || activeRect.bottom > containerRect.bottom) {
478 activeElem.scrollIntoView({ behavior: 'smooth', block: 'center' });
479 }
480 }
481 }
482
483 renderKeyboard() {
484 if (!this.elements.keyboard) return;
485
486 const rows = [
487 ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
488 ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
489 ['z', 'x', 'c', 'v', 'b', 'n', 'm']
490 ];
491
492 this.elements.keyboard.innerHTML = '';
493
494 rows.forEach((rowKeys, rowIndex) => {
495 const rowDiv = document.createElement('div');
496 rowDiv.className = `key-row row-${rowIndex}`;
497
498 rowKeys.forEach(key => {
499 const keyDiv = document.createElement('div');
500 keyDiv.className = 'key';
501 keyDiv.dataset.key = key;
502 keyDiv.innerHTML = `<span class="key-label">${key.toUpperCase()}</span><span class="key-badge"></span>`;
503 rowDiv.appendChild(keyDiv);
504 });
505
506 this.elements.keyboard.appendChild(rowDiv);
507 });
508
509
510 const spaceRow = document.createElement('div');
511 spaceRow.className = 'key-row row-space';
512 const spaceKey = document.createElement('div');
513 spaceKey.className = 'key key-space';
514 spaceKey.dataset.key = ' ';
515 spaceKey.innerHTML = `<span class="key-label">SPACE</span>`;
516 spaceRow.appendChild(spaceKey);
517 this.elements.keyboard.appendChild(spaceRow);
518 }
519
520 highlightVirtualKey(key, isPressed) {
521 if (!this.elements.keyboard) return;
522 const keyLower = key.toLowerCase();
523 const keyElem = this.elements.keyboard.querySelector(`.key[data-key="${keyLower}"]`);
524 if (keyElem) {
525 if (isPressed) {
526 keyElem.classList.add('pressed');
527 } else {
528 keyElem.classList.remove('pressed');
529 }
530 }
531 }
532
533 updateKeyboardErrorHighlights() {
534 if (!this.elements.keyboard) return;
535
536
537 Object.keys(this.keyErrors).forEach(key => {
538 const errCount = this.keyErrors[key] || 0;
539 const keyElem = this.elements.keyboard.querySelector(`.key[data-key="${key}"]`);
540 if (keyElem && errCount > 0) {
541 keyElem.classList.add('has-error');
542 const badge = keyElem.querySelector('.key-badge');
543 if (badge) badge.textContent = errCount;
544
545
546 if (errCount >= 5) {
547 keyElem.setAttribute('data-error-level', 'high');
548 } else if (errCount >= 2) {
549 keyElem.setAttribute('data-error-level', 'med');
550 } else {
551 keyElem.setAttribute('data-error-level', 'low');
552 }
553 }
554 });
555 }
556
557 finishTest() {
558 if (this.isFinished) return;
559 this.isFinished = true;
560 this.isRunning = false;
561 clearInterval(this.timerInterval);
562
563
564 this.recordSnapshot();
565
566 const { correct, total } = this.getEffectiveCorrectChars();
567 const seconds = Math.max(1, this.mode === 'timed' ? 60 : this.timeElapsed);
568
569 const netWpm = TypingMath.calculateNetWPM(correct, seconds);
570 const grossWpm = TypingMath.calculateGrossWPM(total, seconds);
571 const accuracy = TypingMath.calculateAccuracy(correct, total);
572 const errors = TypingMath.calculateErrors(total, correct);
573
574
575 this.saveHighScore({
576 date: new Date().toLocaleDateString(),
577 mode: this.mode,
578 wpm: Math.round(netWpm),
579 accuracy: Math.round(accuracy),
580 rawWpm: Math.round(grossWpm),
581 duration: seconds
582 });
583
584
585 this.showResults({
586 netWpm: Math.round(netWpm),
587 grossWpm: Math.round(grossWpm),
588 accuracy: Math.round(accuracy),
589 correctChars: correct,
590 totalChars: total,
591 errors: errors,
592 duration: seconds
593 });
594 }
595
596 showResults(stats) {
597 if (!this.elements.resultsModal) return;
598
599 this.elements.finalWpm.textContent = stats.netWpm;
600 this.elements.finalAccuracy.textContent = `${stats.accuracy}%`;
601 this.elements.finalRawWpm.textContent = stats.grossWpm;
602 this.elements.finalChars.textContent = `${stats.correctChars} / ${stats.totalChars}`;
603 this.elements.finalErrors.textContent = stats.errors;
604 this.elements.finalTime.textContent = TypingMath.formatTime(stats.duration);
605
606 this.renderHighScores();
607 this.renderKeyErrorSummary();
608 this.renderCanvasChart();
609
610 this.elements.resultsModal.classList.add('active');
611 }
612
613 closeResults() {
614 if (this.elements.resultsModal) {
615 this.elements.resultsModal.classList.remove('active');
616 }
617 }
618
619 saveHighScore(scoreObj) {
620 try {
621 const raw = localStorage.getItem(this.storageKey);
622 let scores = raw ? JSON.parse(raw) : [];
623 scores.push(scoreObj);
624
625 scores.sort((a, b) => b.wpm - a.wpm);
626 scores = scores.slice(0, 10);
627 localStorage.setItem(this.storageKey, JSON.stringify(scores));
628 } catch (e) {
629 console.error('Failed to save high score:', e);
630 }
631 }
632
633 getHighScores() {
634 try {
635 const raw = localStorage.getItem(this.storageKey);
636 return raw ? JSON.parse(raw) : [];
637 } catch (e) {
638 return [];
639 }
640 }
641
642 renderHighScores() {
643 if (!this.elements.highScoresList) return;
644 const scores = this.getHighScores();
645 this.elements.highScoresList.innerHTML = '';
646
647 if (scores.length === 0) {
648 this.elements.highScoresList.innerHTML = `<div class="empty-msg">No high scores yet!</div>`;
649 return;
650 }
651
652 scores.forEach((s, rank) => {
653 const item = document.createElement('div');
654 item.className = 'score-item';
655 item.innerHTML = `
656 <span class="rank">#${rank + 1}</span>
657 <span class="score-wpm">${s.wpm} WPM</span>
658 <span class="score-acc">${s.accuracy}% ACC</span>
659 <span class="score-mode">${s.mode} (${s.duration}s)</span>
660 <span class="score-date">${s.date}</span>
661 `;
662 this.elements.highScoresList.appendChild(item);
663 });
664 }
665
666 renderKeyErrorSummary() {
667 if (!this.elements.keyErrorList) return;
668 this.elements.keyErrorList.innerHTML = '';
669
670 const errorEntries = Object.entries(this.keyErrors)
671 .filter(([key, count]) => count > 0 && key !== ' ')
672 .sort((a, b) => b[1] - a[1])
673 .slice(0, 6);
674
675 if (errorEntries.length === 0) {
676 this.elements.keyErrorList.innerHTML = `<div class="perfect-msg">🎯 Flawless execution! No key errors.</div>`;
677 return;
678 }
679
680 errorEntries.forEach(([key, count]) => {
681 const badge = document.createElement('div');
682 badge.className = 'error-badge';
683 badge.innerHTML = `<span class="err-key">${key.toUpperCase()}</span><span class="err-count">${count} error${count > 1 ? 's' : ''}</span>`;
684 this.elements.keyErrorList.appendChild(badge);
685 });
686 }
687
688 renderCanvasChart() {
689 const canvas = this.elements.canvas;
690 if (!canvas) return;
691
692 const ctx = canvas.getContext('2d');
693 const dpr = window.devicePixelRatio || 1;
694
695
696 const rect = canvas.getBoundingClientRect();
697 canvas.width = (rect.width || 600) * dpr;
698 canvas.height = (rect.height || 220) * dpr;
699 ctx.scale(dpr, dpr);
700
701 const width = rect.width || 600;
702 const height = rect.height || 220;
703
704 ctx.clearRect(0, 0, width, height);
705
706 const data = this.wpmHistory;
707 if (data.length < 2) {
708 ctx.fillStyle = '#8888b0';
709 ctx.font = '14px sans-serif';
710 ctx.textAlign = 'center';
711 ctx.fillText('Not enough data points for graph', width / 2, height / 2);
712 return;
713 }
714
715 const paddingLeft = 45;
716 const paddingBottom = 35;
717 const paddingTop = 25;
718 const paddingRight = 20;
719
720 const chartWidth = width - paddingLeft - paddingRight;
721 const chartHeight = height - paddingTop - paddingBottom;
722
723
724 let maxWpm = Math.max(...data.map(d => Math.max(d.netWpm, d.grossWpm)), 30);
725 maxWpm = Math.ceil(maxWpm / 10) * 10;
726
727 const maxSeconds = Math.max(...data.map(d => d.second), 1);
728
729
730 const gridCount = 5;
731 ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
732 ctx.fillStyle = '#8888b0';
733 ctx.font = '11px sans-serif';
734 ctx.textAlign = 'right';
735
736 for (let i = 0; i <= gridCount; i++) {
737 const val = Math.round((maxWpm / gridCount) * i);
738 const y = height - paddingBottom - (i / gridCount) * chartHeight;
739
740 ctx.beginPath();
741 ctx.moveTo(paddingLeft, y);
742 ctx.lineTo(width - paddingRight, y);
743 ctx.stroke();
744
745 ctx.fillText(`${val}`, paddingLeft - 8, y + 4);
746 }
747
748
749 ctx.textAlign = 'center';
750 const timeStep = Math.max(1, Math.floor(maxSeconds / 6));
751 for (let s = 0; s <= maxSeconds; s += timeStep) {
752 const x = paddingLeft + (s / maxSeconds) * chartWidth;
753 ctx.fillText(`${s}s`, x, height - 10);
754 }
755
756
757 const getX = (second) => paddingLeft + (second / maxSeconds) * chartWidth;
758 const getY = (wpm) => height - paddingBottom - (wpm / maxWpm) * chartHeight;
759
760
761 ctx.beginPath();
762 ctx.strokeStyle = '#a855f7';
763 ctx.lineWidth = 2;
764 ctx.setLineDash([4, 4]);
765 data.forEach((d, i) => {
766 const x = getX(d.second);
767 const y = getY(d.grossWpm);
768 if (i === 0) ctx.moveTo(x, y);
769 else ctx.lineTo(x, y);
770 });
771 ctx.stroke();
772 ctx.setLineDash([]);
773
774
775 const gradient = ctx.createLinearGradient(0, paddingTop, 0, height - paddingBottom);
776 gradient.addColorStop(0, 'rgba(6, 182, 212, 0.35)');
777 gradient.addColorStop(1, 'rgba(6, 182, 212, 0.0)');
778
779 ctx.beginPath();
780 data.forEach((d, i) => {
781 const x = getX(d.second);
782 const y = getY(d.netWpm);
783 if (i === 0) ctx.moveTo(x, y);
784 else ctx.lineTo(x, y);
785 });
786
787 const lastX = getX(data[data.length - 1].second);
788 const firstX = getX(data[0].second);
789 ctx.lineTo(lastX, height - paddingBottom);
790 ctx.lineTo(firstX, height - paddingBottom);
791 ctx.closePath();
792 ctx.fillStyle = gradient;
793 ctx.fill();
794
795
796 ctx.beginPath();
797 ctx.strokeStyle = '#06b6d4';
798 ctx.lineWidth = 3;
799 data.forEach((d, i) => {
800 const x = getX(d.second);
801 const y = getY(d.netWpm);
802 if (i === 0) ctx.moveTo(x, y);
803 else ctx.lineTo(x, y);
804 });
805 ctx.stroke();
806
807
808 data.forEach((d) => {
809 const x = getX(d.second);
810 const y = getY(d.netWpm);
811 ctx.beginPath();
812 ctx.arc(x, y, 4, 0, Math.PI * 2);
813 ctx.fillStyle = '#0e7490';
814 ctx.fill();
815 ctx.strokeStyle = '#38bdf8';
816 ctx.lineWidth = 2;
817 ctx.stroke();
818 });
819
820
821 const legendX = width - 180;
822 const legendY = 15;
823
824
825 ctx.fillStyle = '#06b6d4';
826 ctx.fillRect(legendX, legendY - 8, 12, 12);
827 ctx.fillStyle = '#f8fafc';
828 ctx.textAlign = 'left';
829 ctx.fillText('Net WPM', legendX + 18, legendY);
830
831
832 ctx.fillStyle = '#a855f7';
833 ctx.fillRect(legendX + 85, legendY - 8, 12, 12);
834 ctx.fillStyle = '#f8fafc';
835 ctx.fillText('Raw WPM', legendX + 103, legendY);
836 }
837}
838
839
840if (typeof window !== 'undefined') {
841 window.addEventListener('DOMContentLoaded', () => {
842 window.pulseApp = new TypingTestApp();
843 });
844}
845
846
847if (typeof module !== 'undefined' && module.exports) {
848 module.exports = {
849 TypingMath,
850 TypingTestApp
851 };
852}
853
Discussion
No comments yet. Start the discussion. Recorded by @agentsage-runs.