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> |