Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Bayesian Touch Typing Tutor - Learn Smarter, Type Faster</title> | |
| <style> | |
| :root { | |
| --bg:#0f111a; | |
| --card:#1f2235; | |
| --radius:16px; | |
| --primary:#7c5aff; | |
| --success:#4ade80; | |
| --error:#ef4444; | |
| font-family: system-ui,-apple-system,BlinkMacSystemFont,sans-serif; | |
| } | |
| *{box-sizing:border-box;} | |
| body{margin:0; background:linear-gradient(135deg,#0f111a,#1f2235); color:#e8ebf7; min-height:100vh;} | |
| /* Header styles */ | |
| .header { | |
| background: rgba(31, 34, 53, 0.8); | |
| backdrop-filter: blur(10px); | |
| padding: 24px; | |
| border-bottom: 1px solid rgba(255,255,255,0.1); | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| } | |
| .hero-section { | |
| text-align: center; | |
| padding: 40px 20px; | |
| background: linear-gradient(135deg, rgba(124, 90, 255, 0.1), rgba(74, 222, 128, 0.1)); | |
| margin-bottom: 24px; | |
| } | |
| .hero-title { | |
| font-size: 3rem; | |
| margin: 0 0 16px 0; | |
| background: linear-gradient(135deg, #7c5aff, #4ade80); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .hero-subtitle { | |
| font-size: 1.2rem; | |
| color: #aaa; | |
| margin-bottom: 32px; | |
| } | |
| .benefit-cards { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
| gap: 20px; | |
| max-width: 900px; | |
| margin: 0 auto 40px; | |
| } | |
| .benefit-card { | |
| background: rgba(31, 34, 53, 0.6); | |
| padding: 24px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(255,255,255,0.1); | |
| transition: all 0.3s; | |
| } | |
| .benefit-card:hover { | |
| transform: translateY(-4px); | |
| border-color: var(--primary); | |
| box-shadow: 0 8px 24px rgba(124, 90, 255, 0.2); | |
| } | |
| .benefit-icon { | |
| font-size: 2rem; | |
| margin-bottom: 12px; | |
| } | |
| .benefit-title { | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| color: var(--primary); | |
| } | |
| .benefit-desc { | |
| font-size: 0.9rem; | |
| color: #aaa; | |
| line-height: 1.5; | |
| } | |
| .container{max-width:1100px;margin:0 auto;padding:24px;display:grid;gap:24px;grid-template-columns:1fr 1fr;} | |
| .card{background:var(--card);padding:24px;border-radius:var(--radius);box-shadow:0 24px 60px -10px rgba(0,0,0,0.6);border:1px solid rgba(255,255,255,0.05);} | |
| h2{margin-top:0;font-size:1.3rem;display:flex;align-items:center;gap:8px;} | |
| textarea{ | |
| width:100%; | |
| background:#0f132d; | |
| border:2px solid #2a2f55; | |
| padding:16px; | |
| border-radius:8px; | |
| color:#fff; | |
| font-family:'Fira Code', 'Courier New', monospace; | |
| font-size:1.2rem; | |
| line-height:1.8; | |
| resize:none; | |
| transition: all 0.3s; | |
| } | |
| input{ | |
| width:100%; | |
| background:#0f132d; | |
| border:2px solid #2a2f55; | |
| padding:12px; | |
| border-radius:8px; | |
| color:#fff; | |
| font-family:'Fira Code', 'Courier New', monospace; | |
| resize:none; | |
| transition: all 0.3s; | |
| } | |
| input:focus, textarea:focus { | |
| outline: none; | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 3px rgba(124, 90, 255, 0.2); | |
| } | |
| .typing-container { | |
| background: #0f132d; | |
| border-radius: 12px; | |
| padding: 24px; | |
| margin-top: 16px; | |
| } | |
| .target-section { | |
| margin-bottom: 24px; | |
| } | |
| .target-display { | |
| background: #1f2235; | |
| padding: 20px; | |
| border-radius: 8px; | |
| font-family: 'Fira Code', 'Courier New', monospace; | |
| font-size: 1.3rem; | |
| line-height: 1.8; | |
| color: #e8ebf7; | |
| min-height: 80px; | |
| border: 2px solid #2a2f55; | |
| position: relative; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| .target-display .char { | |
| position: relative; | |
| transition: all 0.2s; | |
| } | |
| .target-display .typed { | |
| color: #4ade80; | |
| background: rgba(74, 222, 128, 0.1); | |
| } | |
| .target-display .error { | |
| color: #ef4444; | |
| background: rgba(239, 68, 68, 0.2); | |
| animation: shake 0.3s; | |
| } | |
| .target-display .current { | |
| background: rgba(124, 90, 255, 0.3); | |
| box-shadow: 0 0 0 2px var(--primary); | |
| animation: pulse 1s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.6; } | |
| } | |
| @keyframes shake { | |
| 0%, 100% { transform: translateX(0); } | |
| 25% { transform: translateX(-2px); } | |
| 75% { transform: translateX(2px); } | |
| } | |
| .typing-section { | |
| position: relative; | |
| } | |
| .typing-overlay { | |
| position: absolute; | |
| top: 40px; | |
| left: 0; | |
| right: 0; | |
| pointer-events: none; | |
| padding: 16px; | |
| font-family: 'Fira Code', 'Courier New', monospace; | |
| font-size: 1.2rem; | |
| line-height: 1.8; | |
| color: transparent; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| .typing-stats { | |
| display: flex; | |
| gap: 24px; | |
| margin-top: 20px; | |
| padding-top: 20px; | |
| border-top: 1px solid #2a2f55; | |
| } | |
| .stat-item { | |
| flex: 1; | |
| text-align: center; | |
| } | |
| .stat-label { | |
| display: block; | |
| font-size: 0.85rem; | |
| color: #888; | |
| margin-bottom: 4px; | |
| } | |
| .stat-value { | |
| display: block; | |
| font-size: 1.8rem; | |
| font-weight: 700; | |
| color: var(--primary); | |
| } | |
| button{ | |
| background:var(--primary); | |
| border:none; | |
| color:#fff; | |
| padding:12px 20px; | |
| border-radius:8px; | |
| cursor:pointer; | |
| font-weight:600; | |
| transition: all 0.3s; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 12px rgba(124, 90, 255, 0.4); | |
| } | |
| .keyboard{display:flex;flex-direction:column;gap:6px;margin-top:16px;} | |
| .key-row{display:flex;justify-content:center;gap:6px;} | |
| .key{ | |
| position:relative; | |
| width:52px; | |
| height:52px; | |
| border-radius:8px; | |
| display:flex; | |
| flex-direction:column; | |
| align-items:center; | |
| justify-content:center; | |
| font-weight:700; | |
| user-select:none; | |
| border:2px solid #2a2f55; | |
| transition: all 0.3s; | |
| cursor: default; | |
| } | |
| .key:hover { | |
| transform: scale(1.05); | |
| } | |
| .key-letter { | |
| font-size: 1.2rem; | |
| } | |
| .key-stats { | |
| font-size: 0.65rem; | |
| color: #aaa; | |
| margin-top: 2px; | |
| } | |
| .drill{ | |
| background:#0f132d; | |
| padding:16px; | |
| border-radius:8px; | |
| font-family:'Fira Code', monospace; | |
| font-size:1.1rem; | |
| line-height:1.6; | |
| border: 2px solid #2a2f55; | |
| } | |
| .explanation{ | |
| background:#0f132d; | |
| padding:16px; | |
| border-radius:8px; | |
| font-size:0.9rem; | |
| line-height:1.6; | |
| } | |
| .stats-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;} | |
| .stat-box{ | |
| background:#0f132d; | |
| padding:16px; | |
| border-radius:8px; | |
| border:1px solid #2a2f55; | |
| transition: all 0.3s; | |
| } | |
| .stat-box:hover { | |
| border-color: var(--primary); | |
| } | |
| .stat-key { | |
| font-size: 1.4rem; | |
| font-weight: 700; | |
| color: var(--primary); | |
| margin-bottom: 8px; | |
| } | |
| .footer{grid-column:1/-1;text-align:center;font-size:0.8rem;margin-top:40px;color:#999;} | |
| .highlight{ | |
| background:rgba(124, 90, 255, 0.2); | |
| padding:4px 8px; | |
| border-radius:6px; | |
| font-weight:600; | |
| color: var(--primary); | |
| } | |
| .error-flash { | |
| animation: errorFlash 0.5s; | |
| } | |
| @keyframes errorFlash { | |
| 0% { background-color: rgba(239, 68, 68, 0.3); } | |
| 100% { background-color: transparent; } | |
| } | |
| .success-flash { | |
| animation: successFlash 0.5s; | |
| } | |
| @keyframes successFlash { | |
| 0% { background-color: rgba(74, 222, 128, 0.3); } | |
| 100% { background-color: transparent; } | |
| } | |
| .progress-bar { | |
| height: 4px; | |
| background: #2a2f55; | |
| border-radius: 2px; | |
| overflow: hidden; | |
| margin-top: 8px; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, var(--primary), var(--success)); | |
| transition: width 0.3s; | |
| } | |
| .info-box { | |
| background: rgba(124, 90, 255, 0.1); | |
| border: 1px solid rgba(124, 90, 255, 0.3); | |
| padding: 12px; | |
| border-radius: 8px; | |
| margin-bottom: 16px; | |
| font-size: 0.9rem; | |
| } | |
| .tooltip { | |
| position: absolute; | |
| bottom: 100%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: #2a2f55; | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| font-size: 0.8rem; | |
| white-space: nowrap; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 0.3s; | |
| margin-bottom: 8px; | |
| z-index: 1000; | |
| } | |
| .key:hover .tooltip { | |
| opacity: 1; | |
| } | |
| @media (max-width: 768px) { | |
| .container { grid-template-columns: 1fr; } | |
| .hero-title { font-size: 2rem; } | |
| .key { width: 42px; height: 42px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div style="max-width:1100px;margin:0 auto;display:flex;gap:16px;flex-wrap:wrap;align-items:center;"> | |
| <div style="flex:1;min-width:280px;"> | |
| <h1 style="margin:0;font-size:1.5rem;">🧠 Bayesian Touch Typing Tutor</h1> | |
| </div> | |
| <div style="flex:1;min-width:280px;text-align:right;"> | |
| <button id="resetBtn">🔄 Reset All Stats</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="hero-section"> | |
| <h1 class="hero-title">Learn to Type Smarter, Not Harder</h1> | |
| <p class="hero-subtitle">Using advanced Bayesian statistics to create personalized typing exercises based on YOUR unique patterns</p> | |
| <div class="benefit-cards"> | |
| <div class="benefit-card"> | |
| <div class="benefit-icon">🎯</div> | |
| <div class="benefit-title">Adaptive Learning</div> | |
| <div class="benefit-desc">The tutor learns which keys you struggle with and creates custom drills targeting your weak spots</div> | |
| </div> | |
| <div class="benefit-card"> | |
| <div class="benefit-icon">📊</div> | |
| <div class="benefit-title">Uncertainty-Aware</div> | |
| <div class="benefit-desc">Knows when it needs more data about a key before making strong recommendations</div> | |
| </div> | |
| <div class="benefit-card"> | |
| <div class="benefit-icon">⚡</div> | |
| <div class="benefit-title">Real-Time Feedback</div> | |
| <div class="benefit-desc">Visual heatmap shows your error patterns instantly, with glow indicating confidence levels</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <div class="card" style="grid-column: 1 / -1;"> | |
| <h2>📝 Typing Area</h2> | |
| <div class="info-box"> | |
| 💡 <strong>How it works:</strong> Type the target text below. Each keystroke updates your personal error model using Bayesian inference. | |
| </div> | |
| <div class="typing-container"> | |
| <div class="target-section"> | |
| <label style="font-weight:600;color:#7c5aff;margin-bottom:8px;display:block;">Target Text:</label> | |
| <div class="target-display" id="targetDisplay"></div> | |
| <div style="margin-top:12px;"> | |
| <input id="target" value="the quick brown fox jumps over the lazy dog" placeholder="Enter custom text to practice" style="font-size:0.9rem;"> | |
| <button id="editTargetBtn" style="margin-left:8px;padding:8px 16px;font-size:0.9rem;">✏️ Edit</button> | |
| </div> | |
| </div> | |
| <div class="typing-section"> | |
| <label style="font-weight:600;color:#4ade80;margin-bottom:8px;display:block;">Your Typing:</label> | |
| <div class="typing-overlay" id="typingOverlay"></div> | |
| <textarea id="typed" rows="3" placeholder="Start typing the target text above..." spellcheck="false"></textarea> | |
| </div> | |
| <div class="typing-stats"> | |
| <div class="stat-item"> | |
| <span class="stat-label">WPM</span> | |
| <span class="stat-value" id="wpm">0</span> | |
| </div> | |
| <div class="stat-item"> | |
| <span class="stat-label">Accuracy</span> | |
| <span class="stat-value" id="accuracy">100%</span> | |
| </div> | |
| <div class="stat-item"> | |
| <span class="stat-label">Characters</span> | |
| <span class="stat-value" id="charCount">0/0</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="errorInfo" style="margin-top:16px;min-height:28px;font-weight:600;text-align:center;"></div> | |
| <div class="progress-bar"> | |
| <div class="progress-fill" id="progress" style="width:0%"></div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>🔥 Live Heatmap & Analysis</h2> | |
| <div style="display:flex;gap:16px;flex-wrap:wrap;"> | |
| <div style="flex:1;min-width:280px;"> | |
| <div class="keyboard" id="keyboard"></div> | |
| <div style="margin-top:12px;font-size:0.85rem;color:#aaa;"> | |
| <strong>Legend:</strong> Red = high error rate | Glow = uncertainty (need more data) | |
| </div> | |
| </div> | |
| <div style="flex:1;min-width:280px;"> | |
| <div class="explanation" id="explanation"> | |
| <div style="color:#aaa;">Keep typing to see your personalized analysis...</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>🎮 Adaptive Drill Generator</h2> | |
| <div style="margin-bottom:12px;color:#aaa;"> | |
| Generates practice text weighted by: <strong>error rate × (uncertainty + 0.1)</strong> | |
| </div> | |
| <div class="drill" id="drillText">Type at least 20 characters to generate your first personalized drill...</div> | |
| <div style="margin-top:12px;display:flex;gap:8px;"> | |
| <button id="regenDrill">🔄 New Drill</button> | |
| <button id="copyDrill">📋 Copy to Target</button> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>📈 Detailed Statistics</h2> | |
| <div style="margin-bottom:12px;font-size:0.9rem;color:#aaa;"> | |
| Home row keys shown. High error + low uncertainty = consistent problem to focus on. | |
| </div> | |
| <div class="stats-grid" id="statsGrid"></div> | |
| </div> | |
| <div class="footer"> | |
| <div>Powered by Beta-Binomial Bayesian inference with temporal decay (λ=0.995)</div> | |
| <div style="margin-top:4px;">All processing happens locally in your browser • No data leaves your device</div> | |
| </div> | |
| </div> | |
| <script> | |
| // Beta tracker with decay for temporal weighting | |
| class BetaTracker { | |
| constructor(alpha=1, beta=1, decay=0.995){ | |
| this.alpha = alpha; | |
| this.beta = beta; | |
| this.decay = decay; | |
| this.counts = {}; | |
| this.totalObservations = 0; | |
| this.recentErrors = []; | |
| } | |
| _ensure(k){ | |
| if(!this.counts[k]) { | |
| this.counts[k] = {e: this.alpha, s: this.beta}; | |
| } | |
| } | |
| decayAll(){ | |
| Object.values(this.counts).forEach(c => { | |
| c.e *= this.decay; | |
| c.s *= this.decay; | |
| }); | |
| } | |
| observe(k, error){ | |
| this.decayAll(); | |
| this._ensure(k); | |
| if(error) { | |
| this.counts[k].e += 1; | |
| this.recentErrors.push(k); | |
| if(this.recentErrors.length > 10) this.recentErrors.shift(); | |
| } else { | |
| this.counts[k].s += 1; | |
| } | |
| this.totalObservations++; | |
| } | |
| posterior(k){ | |
| this._ensure(k); | |
| const {e, s} = this.counts[k]; | |
| const mean = e / (e + s); | |
| const variance = (e * s) / ((e + s) * (e + s) * (e + s + 1)); | |
| const effectiveN = e + s - this.alpha - this.beta; | |
| return { | |
| mean, | |
| sd: Math.sqrt(variance), | |
| count: effectiveN, | |
| alpha: e, | |
| beta: s | |
| }; | |
| } | |
| getAllPosteriors() { | |
| const posteriors = {}; | |
| 'abcdefghijklmnopqrstuvwxyz'.split('').forEach(c => { | |
| posteriors[c] = this.posterior(c); | |
| }); | |
| return posteriors; | |
| } | |
| } | |
| // Common words organized by difficulty and letter patterns | |
| const WORD_CORPUS = { | |
| // High-frequency words (top 100 most common) | |
| common: [ | |
| 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'would', 'her', | |
| 'was', 'one', 'our', 'out', 'day', 'had', 'has', 'his', 'how', 'man', | |
| 'its', 'say', 'she', 'which', 'their', 'time', 'will', 'way', 'about', | |
| 'many', 'then', 'them', 'write', 'like', 'these', 'long', 'make', 'thing', | |
| 'see', 'him', 'two', 'look', 'more', 'go', 'come', 'number', 'sound', | |
| 'most', 'people', 'over', 'know', 'water', 'than', 'call', 'first' | |
| ], | |
| // Words with common digraphs | |
| digraphs: { | |
| 'th': ['the', 'that', 'this', 'they', 'there', 'think', 'through', 'three', 'thanks', 'thought'], | |
| 'ch': ['change', 'check', 'choice', 'choose', 'chair', 'chance', 'charge', 'cheap', 'church', 'chapter'], | |
| 'sh': ['should', 'show', 'share', 'short', 'shape', 'sharp', 'shift', 'shine', 'shock', 'shoot'], | |
| 'wh': ['what', 'when', 'where', 'which', 'while', 'white', 'whole', 'whose', 'wheel', 'whether'], | |
| 'qu': ['quick', 'quite', 'quiet', 'queen', 'question', 'quality', 'quarter', 'square', 'require', 'equal'], | |
| 'ing': ['thing', 'being', 'doing', 'going', 'making', 'taking', 'coming', 'looking', 'working', 'thinking'], | |
| 'er': ['other', 'after', 'never', 'every', 'under', 'number', 'perhaps', 'better', 'together', 'remember'], | |
| 'ed': ['called', 'looked', 'asked', 'needed', 'wanted', 'worked', 'lived', 'turned', 'started', 'seemed'] | |
| }, | |
| // Words by difficulty (based on hand movements) | |
| patterns: { | |
| homeRow: ['had', 'ask', 'dad', 'sad', 'lad', 'fad', 'gas', 'has', 'lag', 'sag'], | |
| topRow: ['were', 'your', 'trip', 'quit', 'power', 'write', 'quiet', 'worry', 'pretty', 'twenty'], | |
| bottomRow: ['can', 'man', 'been', 'came', 'name', 'mean', 'become', 'common', 'woman', 'human'], | |
| mixed: ['their', 'would', 'about', 'there', 'think', 'which', 'people', 'could', 'other', 'after'] | |
| }, | |
| // Common programming/tech words | |
| technical: [ | |
| 'function', 'variable', 'return', 'class', 'import', 'export', 'const', 'async', 'array', 'object', | |
| 'string', 'number', 'boolean', 'interface', 'public', 'private', 'static', 'method', 'property' | |
| ] | |
| }; | |
| // Sentence templates for more natural practice | |
| const SENTENCE_TEMPLATES = [ | |
| "The {adjective} {noun} {verb} {preposition} the {noun}.", | |
| "{pronoun} {verb} to {verb} the {adjective} {noun}.", | |
| "Can you {verb} the {noun} {preposition} the {adjective} {noun}?", | |
| "{number} {adjective} {noun}s {verb} {adverb} {preposition} the {noun}.", | |
| "The {noun} {verb} {adjective} and {adjective}." | |
| ]; | |
| const WORD_TYPES = { | |
| adjective: ['quick', 'brown', 'lazy', 'beautiful', 'small', 'large', 'happy', 'sad', 'fast', 'slow'], | |
| noun: ['fox', 'dog', 'cat', 'house', 'tree', 'book', 'computer', 'phone', 'desk', 'chair'], | |
| verb: ['jumps', 'runs', 'walks', 'sits', 'stands', 'writes', 'reads', 'types', 'thinks', 'works'], | |
| pronoun: ['I', 'you', 'he', 'she', 'we', 'they', 'it'], | |
| preposition: ['over', 'under', 'beside', 'through', 'across', 'behind', 'near', 'between'], | |
| adverb: ['quickly', 'slowly', 'carefully', 'happily', 'quietly', 'loudly'], | |
| number: ['two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'] | |
| }; | |
| // Enhanced drill generator | |
| function generateDrill(posteriors, length = 50) { | |
| if (tracker.totalObservations < 20) { | |
| // Start with common words for beginners | |
| return "Type these common words: the quick brown fox jumps over the lazy dog. Practice makes perfect!"; | |
| } | |
| // Calculate focus scores for each letter | |
| const letterScores = Object.entries(posteriors) | |
| .filter(([k, v]) => v.count > 0) | |
| .map(([k, v]) => ({ | |
| key: k, | |
| focus: v.mean * (v.sd + 0.1), | |
| errorRate: v.mean, | |
| uncertainty: v.sd | |
| })) | |
| .sort((a, b) => b.focus - a.focus); | |
| // Get top problematic letters | |
| const problemLetters = letterScores.slice(0, 5).map(s => s.key); | |
| // Categorize problem areas | |
| const problemDigraphs = []; | |
| const problemPatterns = []; | |
| // Check for problematic digraphs | |
| for (const [digraph, words] of Object.entries(WORD_CORPUS.digraphs)) { | |
| if (digraph.split('').some(letter => problemLetters.includes(letter))) { | |
| problemDigraphs.push({ pattern: digraph, words }); | |
| } | |
| } | |
| // Build adaptive drill | |
| let drill = []; | |
| let currentLength = 0; | |
| // Mix different types of practice | |
| while (currentLength < length) { | |
| const random = Math.random(); | |
| if (random < 0.4 && problemDigraphs.length > 0) { | |
| // 40% - Focus on problematic digraphs | |
| const digraph = problemDigraphs[Math.floor(Math.random() * problemDigraphs.length)]; | |
| const word = digraph.words[Math.floor(Math.random() * digraph.words.length)]; | |
| drill.push(word); | |
| currentLength += word.length + 1; | |
| } else if (random < 0.7) { | |
| // 30% - Words containing problem letters | |
| const targetLetter = problemLetters[Math.floor(Math.random() * Math.min(3, problemLetters.length))]; | |
| const candidates = [ | |
| ...WORD_CORPUS.common, | |
| ...Object.values(WORD_CORPUS.patterns).flat() | |
| ].filter(w => w.includes(targetLetter)); | |
| if (candidates.length > 0) { | |
| const word = candidates[Math.floor(Math.random() * candidates.length)]; | |
| drill.push(word); | |
| currentLength += word.length + 1; | |
| } | |
| } else if (random < 0.85) { | |
| // 15% - Common words for flow | |
| const word = WORD_CORPUS.common[Math.floor(Math.random() * WORD_CORPUS.common.length)]; | |
| drill.push(word); | |
| currentLength += word.length + 1; | |
| } else { | |
| // 15% - Generate a short sentence | |
| if (currentLength + 20 < length) { | |
| const sentence = generateSentence(problemLetters); | |
| drill.push(sentence); | |
| currentLength += sentence.length + 1; | |
| } | |
| } | |
| } | |
| // Format the drill nicely | |
| const drillText = drill.join(' ').trim(); | |
| // Add a note about what we're focusing on | |
| const focusNote = problemLetters.length > 0 | |
| ? `Focus areas: ${problemLetters.map(l => l.toUpperCase()).join(', ')} | ` | |
| : ''; | |
| return focusNote + drillText; | |
| } | |
| // Generate sentences with problem letters | |
| function generateSentence(problemLetters) { | |
| const template = SENTENCE_TEMPLATES[Math.floor(Math.random() * SENTENCE_TEMPLATES.length)]; | |
| let sentence = template; | |
| // Replace placeholders with words containing problem letters when possible | |
| for (const [type, words] of Object.entries(WORD_TYPES)) { | |
| if (sentence.includes(`{${type}}`)) { | |
| const candidates = words.filter(w => | |
| problemLetters.some(letter => w.includes(letter)) | |
| ); | |
| const wordList = candidates.length > 0 ? candidates : words; | |
| const word = wordList[Math.floor(Math.random() * wordList.length)]; | |
| sentence = sentence.replace(`{${type}}`, word); | |
| } | |
| } | |
| return sentence; | |
| } | |
| // Update the initial target text | |
| function getInitialText() { | |
| const introTexts = [ | |
| "Welcome to adaptive typing practice. Start with this sentence to build your profile.", | |
| "Type this paragraph to help me understand your typing patterns and create personalized drills.", | |
| "Every keystroke teaches me about your typing style. Let's begin with this warm-up text.", | |
| "Practice makes perfect. Begin typing to discover your unique strengths and challenges.", | |
| "Your personalized typing journey starts here. Type this text to establish your baseline." | |
| ]; | |
| return introTexts[Math.floor(Math.random() * introTexts.length)]; | |
| } | |
| // Keyboard layout | |
| const KEY_ROWS = [ | |
| ['q','w','e','r','t','y','u','i','o','p'], | |
| ['a','s','d','f','g','h','j','k','l'], | |
| ['z','x','c','v','b','n','m'] | |
| ]; | |
| const tracker = new BetaTracker(1, 1, 0.995); | |
| let lastTypedLength = 0; | |
| // DOM elements | |
| const targetInput = document.getElementById('target'); | |
| const targetDisplay = document.getElementById('targetDisplay'); | |
| const typedArea = document.getElementById('typed'); | |
| const typingOverlay = document.getElementById('typingOverlay'); | |
| const keyboardDiv = document.getElementById('keyboard'); | |
| const explanationDiv = document.getElementById('explanation'); | |
| const drillDiv = document.getElementById('drillText'); | |
| const regenBtn = document.getElementById('regenDrill'); | |
| const copyBtn = document.getElementById('copyDrill'); | |
| const editTargetBtn = document.getElementById('editTargetBtn'); | |
| const statsGrid = document.getElementById('statsGrid'); | |
| const errorInfo = document.getElementById('errorInfo'); | |
| const resetBtn = document.getElementById('resetBtn'); | |
| const progressBar = document.getElementById('progress'); | |
| const wpmDisplay = document.getElementById('wpm'); | |
| const accuracyDisplay = document.getElementById('accuracy'); | |
| const charCountDisplay = document.getElementById('charCount'); | |
| // Typing statistics | |
| let startTime = null; | |
| let correctChars = 0; | |
| let totalChars = 0; | |
| // Save/Load functionality | |
| function saveState() { | |
| localStorage.setItem('bayesianTypingState', JSON.stringify({ | |
| counts: tracker.counts, | |
| totalObservations: tracker.totalObservations | |
| })); | |
| } | |
| function loadState() { | |
| const saved = localStorage.getItem('bayesianTypingState'); | |
| if (saved) { | |
| const state = JSON.parse(saved); | |
| tracker.counts = state.counts || {}; | |
| tracker.totalObservations = state.totalObservations || 0; | |
| } | |
| } | |
| // Initialize keyboard DOM with tooltips | |
| function buildKeyboard(){ | |
| keyboardDiv.innerHTML = ''; | |
| KEY_ROWS.forEach(row => { | |
| const r = document.createElement('div'); | |
| r.className = 'key-row'; | |
| row.forEach(k => { | |
| const keyEl = document.createElement('div'); | |
| keyEl.className = 'key'; | |
| keyEl.dataset.key = k; | |
| keyEl.innerHTML = ` | |
| <div class="key-letter">${k.toUpperCase()}</div> | |
| <div class="key-stats">0.0%</div> | |
| <div class="tooltip">No data yet</div> | |
| `; | |
| r.appendChild(keyEl); | |
| }); | |
| keyboardDiv.appendChild(r); | |
| }); | |
| } | |
| function updateHeatmap(posteriors){ | |
| // Find max values for normalization | |
| let maxMean = 0, maxSD = 0; | |
| Object.values(posteriors).forEach(p => { | |
| maxMean = Math.max(maxMean, p.mean); | |
| maxSD = Math.max(maxSD, p.sd); | |
| }); | |
| KEY_ROWS.forEach(row => { | |
| row.forEach(k => { | |
| const p = posteriors[k]; | |
| const keyEl = keyboardDiv.querySelector(`[data-key='${k}']`); | |
| if(!p || !keyEl) return; | |
| const mean = p.mean; | |
| const sd = p.sd; | |
| // Color based on error rate | |
| const red = Math.min(255, Math.round(mean * 400)); | |
| const green = Math.max(0, 100 - Math.round(mean * 200)); | |
| // Glow based on uncertainty | |
| const glowIntensity = Math.min(1, sd * 5); | |
| const glowSize = Math.max(4, sd * 100); | |
| keyEl.style.background = `linear-gradient(135deg, | |
| rgba(${red}, ${green}, 50, ${Math.min(0.8, mean * 2)}) 0%, | |
| rgba(255, 255, 255, ${Math.min(0.1, sd * 0.5)}) 100%)`; | |
| if (sd > 0.02) { | |
| keyEl.style.boxShadow = `0 0 ${glowSize}px rgba(255, 255, 255, ${glowIntensity * 0.4})`; | |
| } else { | |
| keyEl.style.boxShadow = 'none'; | |
| } | |
| // Update stats display | |
| const statsEl = keyEl.querySelector('.key-stats'); | |
| if(statsEl) { | |
| statsEl.innerHTML = `${(mean * 100).toFixed(1)}%`; | |
| } | |
| // Update tooltip | |
| const tooltip = keyEl.querySelector('.tooltip'); | |
| if(tooltip) { | |
| tooltip.innerHTML = ` | |
| Error: ${(mean * 100).toFixed(1)}% ± ${(sd * 100).toFixed(1)}%<br> | |
| Observations: ${Math.round(p.count)} | |
| `; | |
| } | |
| }); | |
| }); | |
| } | |
| // Enhanced drill generator | |
| function generateDrill(posteriors, length = 80) { | |
| if (tracker.totalObservations < 20) { | |
| // Start with common words for beginners | |
| return "Type these common words: the quick brown fox jumps over the lazy dog. Practice makes perfect!"; | |
| } | |
| // Calculate focus scores for each letter | |
| const letterScores = Object.entries(posteriors) | |
| .filter(([k, v]) => v.count > 0) | |
| .map(([k, v]) => ({ | |
| key: k, | |
| focus: v.mean * (v.sd + 0.1), | |
| errorRate: v.mean, | |
| uncertainty: v.sd | |
| })) | |
| .sort((a, b) => b.focus - a.focus); | |
| // Get top problematic letters | |
| const problemLetters = letterScores.slice(0, 5).map(s => s.key); | |
| // Categorize problem areas | |
| const problemDigraphs = []; | |
| // Check for problematic digraphs | |
| for (const [digraph, words] of Object.entries(WORD_CORPUS.digraphs)) { | |
| if (digraph.split('').some(letter => problemLetters.includes(letter))) { | |
| problemDigraphs.push({ pattern: digraph, words }); | |
| } | |
| } | |
| // Build adaptive drill | |
| let drill = []; | |
| let currentLength = 0; | |
| // Mix different types of practice | |
| while (currentLength < length) { | |
| const random = Math.random(); | |
| if (random < 0.4 && problemDigraphs.length > 0) { | |
| // 40% - Focus on problematic digraphs | |
| const digraph = problemDigraphs[Math.floor(Math.random() * problemDigraphs.length)]; | |
| const word = digraph.words[Math.floor(Math.random() * digraph.words.length)]; | |
| drill.push(word); | |
| currentLength += word.length + 1; | |
| } else if (random < 0.7) { | |
| // 30% - Words containing problem letters | |
| const targetLetter = problemLetters[Math.floor(Math.random() * Math.min(3, problemLetters.length))]; | |
| const candidates = [ | |
| ...WORD_CORPUS.common, | |
| ...Object.values(WORD_CORPUS.patterns).flat() | |
| ].filter(w => w.includes(targetLetter)); | |
| if (candidates.length > 0) { | |
| const word = candidates[Math.floor(Math.random() * candidates.length)]; | |
| drill.push(word); | |
| currentLength += word.length + 1; | |
| } | |
| } else if (random < 0.85) { | |
| // 15% - Common words for flow | |
| const word = WORD_CORPUS.common[Math.floor(Math.random() * WORD_CORPUS.common.length)]; | |
| drill.push(word); | |
| currentLength += word.length + 1; | |
| } else { | |
| // 15% - Generate a short sentence | |
| if (currentLength + 20 < length) { | |
| const sentence = generateSentence(problemLetters); | |
| drill.push(sentence); | |
| currentLength += sentence.length + 1; | |
| } | |
| } | |
| } | |
| // Format the drill nicely | |
| const drillText = drill.join(' ').trim(); | |
| // Add a note about what we're focusing on | |
| const focusNote = problemLetters.length > 0 | |
| ? `Focus: ${problemLetters.slice(0,3).map(l => l.toUpperCase()).join(', ')} | ` | |
| : ''; | |
| return focusNote + drillText; | |
| } | |
| function updateExplanation(posteriors){ | |
| const arr = Object.entries(posteriors) | |
| .filter(([k, v]) => v.count > 0) | |
| .map(([k, v]) => ({k, ...v})); | |
| arr.sort((a, b) => b.mean - a.mean); | |
| if (tracker.totalObservations < 10) { | |
| explanationDiv.innerHTML = ` | |
| <div style="color:#aaa;"> | |
| Keep typing! I need at least 10 keystrokes to start analyzing your patterns. | |
| <div style="margin-top:8px;">Progress: ${tracker.totalObservations}/10</div> | |
| </div> | |
| `; | |
| return; | |
| } | |
| const top = arr.slice(0, 3); | |
| explanationDiv.innerHTML = ''; | |
| const title = document.createElement('div'); | |
| title.innerHTML = '<strong>Your Personal Typing Analysis:</strong>'; | |
| title.style.marginBottom = '12px'; | |
| explanationDiv.appendChild(title); | |
| top.forEach((t, i) => { | |
| const block = document.createElement('div'); | |
| block.style.marginTop = '8px'; | |
| let recommendation = ""; | |
| if (t.sd > 0.1) { | |
| recommendation = "Need more data for confidence."; | |
| } else if (t.mean > 0.15) { | |
| recommendation = "High priority for practice!"; | |
| } else if (t.mean > 0.05) { | |
| recommendation = "Room for improvement."; | |
| } else { | |
| recommendation = "Good accuracy!"; | |
| } | |
| block.innerHTML = ` | |
| ${i + 1}. <span class="highlight">${t.k.toUpperCase()}</span> | |
| - ${(t.mean * 100).toFixed(1)}% errors | |
| (±${(t.sd * 100).toFixed(1)}% uncertainty) | |
| <div style="font-size:0.85rem;color:#aaa;margin-top:4px;">${recommendation}</div> | |
| `; | |
| explanationDiv.appendChild(block); | |
| }); | |
| if (tracker.recentErrors.length > 0) { | |
| const recentDiv = document.createElement('div'); | |
| recentDiv.style.marginTop = '16px'; | |
| recentDiv.innerHTML = ` | |
| <div style="font-size:0.9rem;color:#ef4444;"> | |
| Recent mistakes: ${tracker.recentErrors.slice(-5).join(', ')} | |
| </div> | |
| `; | |
| explanationDiv.appendChild(recentDiv); | |
| } | |
| } | |
| function updateStats(posteriors){ | |
| statsGrid.innerHTML = ''; | |
| const homeRowKeys = ['a','s','d','f','j','k','l',';']; | |
| homeRowKeys.forEach(c => { | |
| if (c === ';') return; // Skip semicolon for now | |
| const p = posteriors[c]; | |
| const box = document.createElement('div'); | |
| box.className = 'stat-box'; | |
| // Color code based on performance | |
| let performanceClass = ''; | |
| if (p.mean < 0.05) { | |
| performanceClass = 'style="border-color: #4ade80;"'; | |
| } else if (p.mean > 0.15) { | |
| performanceClass = 'style="border-color: #ef4444;"'; | |
| } | |
| box.innerHTML = ` | |
| <div class="stat-key">${c.toUpperCase()}</div> | |
| <div style="font-size:0.85rem;"> | |
| <div>Error rate: ${(p.mean * 100).toFixed(2)}%</div> | |
| <div>Uncertainty: ±${(p.sd * 100).toFixed(2)}%</div> | |
| <div style="color:#aaa;">Observations: ${Math.round(p.count)}</div> | |
| </div> | |
| `; | |
| box.setAttribute('style', performanceClass); | |
| statsGrid.appendChild(box); | |
| }); | |
| } | |
| function refreshAll(){ | |
| const post = tracker.getAllPosteriors(); | |
| updateHeatmap(post); | |
| updateExplanation(post); | |
| const drillText = generateDrill(post, 80); | |
| drillDiv.textContent = drillText; | |
| updateStats(post); | |
| saveState(); | |
| } | |
| // Update target display with character-by-character rendering | |
| function updateTargetDisplay() { | |
| const target = targetInput.value; | |
| const typed = typedArea.value; | |
| let html = ''; | |
| for (let i = 0; i < target.length; i++) { | |
| const char = target[i]; | |
| let className = 'char'; | |
| if (i < typed.length) { | |
| if (typed[i] === char) { | |
| className += ' typed'; | |
| } else { | |
| className += ' error'; | |
| } | |
| } else if (i === typed.length) { | |
| className += ' current'; | |
| } | |
| html += `<span class="${className}">${char}</span>`; | |
| } | |
| targetDisplay.innerHTML = html; | |
| } | |
| // Main typing handler | |
| typedArea.addEventListener('input', e => { | |
| const typed = e.target.value; | |
| const target = targetInput.value; | |
| // Start timer on first character | |
| if (!startTime && typed.length > 0) { | |
| startTime = Date.now(); | |
| } | |
| // Update visual display | |
| updateTargetDisplay(); | |
| // Update progress bar | |
| const progress = Math.min(100, (typed.length / target.length) * 100); | |
| progressBar.style.width = progress + '%'; | |
| // Update character count | |
| charCountDisplay.textContent = `${typed.length}/${target.length}`; | |
| if(typed.length === 0) { | |
| errorInfo.textContent = ''; | |
| errorInfo.className = ''; | |
| lastTypedLength = 0; | |
| startTime = null; | |
| correctChars = 0; | |
| totalChars = 0; | |
| wpmDisplay.textContent = '0'; | |
| accuracyDisplay.textContent = '100%'; | |
| refreshAll(); | |
| return; | |
| } | |
| // Only process new characters | |
| if (typed.length > lastTypedLength) { | |
| for (let i = lastTypedLength; i < typed.length; i++) { | |
| const intended = (target[i] || '').toLowerCase(); | |
| const actual = typed[i].toLowerCase(); | |
| totalChars++; | |
| if(intended.match(/[a-z]/)) { | |
| const error = actual !== intended; | |
| tracker.observe(intended, error); | |
| if(error) { | |
| errorInfo.innerHTML = `❌ Mistake: typed '<strong>${actual}</strong>' instead of '<strong>${intended}</strong>'`; | |
| errorInfo.className = 'error-flash'; | |
| errorInfo.style.color = '#ef4444'; | |
| // Flash the key | |
| const keyEl = keyboardDiv.querySelector(`[data-key='${intended}']`); | |
| if (keyEl) { | |
| keyEl.classList.add('error-flash'); | |
| setTimeout(() => keyEl.classList.remove('error-flash'), 500); | |
| } | |
| } else { | |
| correctChars++; | |
| if (i === typed.length - 1) { | |
| errorInfo.innerHTML = `✓ Correct!`; | |
| errorInfo.className = 'success-flash'; | |
| errorInfo.style.color = '#4ade80'; | |
| } | |
| } | |
| } else if (intended === actual) { | |
| correctChars++; | |
| } | |
| } | |
| } | |
| lastTypedLength = typed.length; | |
| // Update WPM | |
| if (startTime && typed.length > 0) { | |
| const minutes = (Date.now() - startTime) / 60000; | |
| const words = typed.length / 5; // Standard: 5 chars = 1 word | |
| const wpm = Math.round(words / minutes); | |
| wpmDisplay.textContent = wpm; | |
| } | |
| // Update accuracy | |
| if (totalChars > 0) { | |
| const accuracy = (correctChars / totalChars * 100).toFixed(1); | |
| accuracyDisplay.textContent = accuracy + '%'; | |
| } | |
| // Check if completed | |
| if (typed === target && typed.length > 0) { | |
| const accuracy = (correctChars / totalChars * 100).toFixed(1); | |
| setTimeout(() => { | |
| errorInfo.innerHTML = `🎉 Perfect! Completed with ${accuracy}% accuracy at ${wpmDisplay.textContent} WPM!`; | |
| errorInfo.style.color = '#4ade80'; | |
| }, 100); | |
| } | |
| refreshAll(); | |
| }); | |
| // Handle backspace to track corrections | |
| typedArea.addEventListener('keydown', e => { | |
| if (e.key === 'Backspace') { | |
| lastTypedLength = Math.max(0, typedArea.value.length - 1); | |
| } | |
| }); | |
| // Button handlers | |
| regenBtn.addEventListener('click', () => { | |
| refreshAll(); | |
| const flashMsg = document.createElement('div'); | |
| flashMsg.style = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--primary);color:white;padding:16px 24px;border-radius:8px;z-index:1000;'; | |
| flashMsg.textContent = '✨ New drill generated!'; | |
| document.body.appendChild(flashMsg); | |
| setTimeout(() => flashMsg.remove(), 1500); | |
| }); | |
| copyBtn.addEventListener('click', () => { | |
| targetInput.value = drillDiv.textContent; | |
| typedArea.value = ''; | |
| typedArea.focus(); | |
| lastTypedLength = 0; | |
| startTime = null; | |
| correctChars = 0; | |
| totalChars = 0; | |
| progressBar.style.width = '0%'; | |
| updateTargetDisplay(); | |
| const flashMsg = document.createElement('div'); | |
| flashMsg.style = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--success);color:white;padding:16px 24px;border-radius:8px;z-index:1000;'; | |
| flashMsg.textContent = '📋 Drill copied to target!'; | |
| document.body.appendChild(flashMsg); | |
| setTimeout(() => flashMsg.remove(), 1500); | |
| }); | |
| editTargetBtn.addEventListener('click', () => { | |
| targetInput.style.display = targetInput.style.display === 'none' ? 'inline-block' : 'none'; | |
| if (targetInput.style.display === 'none') { | |
| editTargetBtn.textContent = '✏️ Edit'; | |
| } else { | |
| editTargetBtn.textContent = '✓ Done'; | |
| targetInput.focus(); | |
| } | |
| }); | |
| resetBtn.addEventListener('click', () => { | |
| if(confirm('This will reset all your typing statistics. Are you sure?')) { | |
| localStorage.removeItem('bayesianTypingState'); | |
| location.reload(); | |
| } | |
| }); | |
| // Allow custom target text | |
| targetInput.addEventListener('input', () => { | |
| typedArea.value = ''; | |
| lastTypedLength = 0; | |
| startTime = null; | |
| correctChars = 0; | |
| totalChars = 0; | |
| progressBar.style.width = '0%'; | |
| errorInfo.textContent = ''; | |
| updateTargetDisplay(); | |
| }); | |
| // Initialize | |
| loadState(); | |
| buildKeyboard(); | |
| refreshAll(); | |
| targetInput.style.display = 'none'; | |
| updateTargetDisplay(); | |
| // Focus on typing area | |
| typedArea.focus(); | |
| </script> | |
| </body> | |
| </html> |