(() => { 'use strict'; const TD = window.TD; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; const LANG_NAME = { ru: 'Русский', en: 'English' }; /* Мини-хелпер для создания DOM без innerHTML (пользовательский текст попадает только через textContent). */ const h = (tag, props, ...kids) => { const e = document.createElement(tag); for (const [k, v] of Object.entries(props || {})) { if (v == null || v === false) continue; if (k === 'class') e.className = v; else if (k === 'html') e.innerHTML = v; else if (k.startsWith('on')) e.addEventListener(k.slice(2), v); else e.setAttribute(k, v === true ? '' : v); } kids.flat(2).forEach(c => { if (c != null && c !== false) e.append(c.nodeType ? c : document.createTextNode(c)); }); return e; }; /* ====================================================================== */ /* Хранилище */ /* ====================================================================== */ const STORE_KEY = 'splitTrainer.v1'; const defaults = () => ({ settings: { theme: 'auto', showKb: true, fingerColors: true, kbStyle: 'stagger', sound: false, font: 'm', lang: 'ru', practice: { lang: 'ru', kind: 'words', size: 'm', caps: false, digits: false, punct: false, custom: '' } }, progress: {}, history: [], keys: { ru: {}, en: {} }, // Раскладка Vial: { file, layout:{layers,rows,cols}, names:[], enabled, split, numLayer:'auto'|n, labelLang } vial: null, // Аккаунт, под которым последний раз синхронизировали локальный прогресс (чтобы данные не переехали к другому человеку) accountOwner: null }); function loadState() { const d = defaults(); try { const o = JSON.parse(localStorage.getItem(STORE_KEY)); if (o) { Object.assign(d.settings, o.settings || {}); d.settings.practice = Object.assign(defaults().settings.practice, (o.settings || {}).practice || {}); d.progress = o.progress || {}; d.history = o.history || []; d.keys = Object.assign({ ru: {}, en: {} }, o.keys || {}); d.vial = o.vial && o.vial.layout ? o.vial : null; d.accountOwner = typeof o.accountOwner === 'string' ? o.accountOwner : null; } } catch (e) { /* localStorage может быть недоступен - работаем без сохранения */ } return d; } const S = loadState(); const save = () => { try { localStorage.setItem(STORE_KEY, JSON.stringify(S)); } catch (e) { /* ignore */ } }; function applyTheme() { const t = S.settings.theme; if (t === 'auto') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', t); } /* ====================================================================== */ /* Раскладки */ /* ====================================================================== */ const CODES = [ ['Backquote', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', 'Minus', 'Equal'], ['KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash'], ['KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote'], ['KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash'] ]; const LEFT_N = [6, 5, 5, 5]; // сколько клавиш каждого ряда у левой половины (зазор между 5|6, T|Y, G|H, B|N) const FINGERS = { lp: ['Backquote', 'Digit1', 'KeyQ', 'KeyA', 'KeyZ'], lr: ['Digit2', 'KeyW', 'KeyS', 'KeyX'], lm: ['Digit3', 'KeyE', 'KeyD', 'KeyC'], li: ['Digit4', 'Digit5', 'KeyR', 'KeyT', 'KeyF', 'KeyG', 'KeyV', 'KeyB'], ri: ['Digit6', 'Digit7', 'KeyY', 'KeyU', 'KeyH', 'KeyJ', 'KeyN', 'KeyM'], rm: ['Digit8', 'KeyI', 'KeyK', 'Comma'], rr: ['Digit9', 'KeyO', 'KeyL', 'Period'], rp: ['Digit0', 'Minus', 'Equal', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Slash'], th: ['Space'] }; const FINGER_OF = {}; for (const [f, codes] of Object.entries(FINGERS)) codes.forEach(c => { FINGER_OF[c] = f; }); const FINGER_NAME = { lp: 'левый мизинец', lr: 'левый безымянный', lm: 'левый средний', li: 'левый указательный', ri: 'правый указательный', rm: 'правый средний', rr: 'правый безымянный', rp: 'правый мизинец', th: 'большой палец' }; function buildLayout(lang) { const rows = [], charMap = {}, byCode = {}; TD.keyRows[lang].forEach((str, r) => { const row = []; str.split(' ').forEach((pair, i) => { const [base, shift] = [...pair]; const code = CODES[r][i]; const k = { code, base, shift, row: r, hand: i < LEFT_N[r] ? 'L' : 'R', finger: FINGER_OF[code] }; row.push(k); byCode[code] = k; charMap[base] = { k, shift: false }; if (shift !== base) charMap[shift] = { k, shift: true }; }); rows.push(row); }); charMap[' '] = { k: { code: 'Space', finger: 'th', hand: 'T' }, shift: false }; return { rows, charMap, byCode }; } const LAYOUT = { ru: buildLayout('ru'), en: buildLayout('en') }; /* Какой символ даёт физическая клавиша (по KeyboardEvent.code) в раскладке ОС; цифровой блок - при включённом NumLock. */ const NUMPAD = { Numpad0: '0', Numpad1: '1', Numpad2: '2', Numpad3: '3', Numpad4: '4', Numpad5: '5', Numpad6: '6', Numpad7: '7', Numpad8: '8', Numpad9: '9', NumpadDecimal: '.', NumpadAdd: '+', NumpadSubtract: '-', NumpadMultiply: '*', NumpadDivide: '/' }; function charOf(lang, code, shift) { if (NUMPAD[code]) return shift ? null : NUMPAD[code]; const k = LAYOUT[lang].byCode[code]; if (!k) return code === 'Space' ? ' ' : null; return shift ? k.shift : k.base; } /* ---------- Раскладка Vial пользователя ---------- */ let vialCache = null; function vialModel() { if (!S.vial) return null; if (!vialCache || vialCache.src !== S.vial.layout || vialCache.split !== S.vial.split) { try { vialCache = { src: S.vial.layout, split: S.vial.split, model: window.Vial.createModel(S.vial.layout, { split: S.vial.split }) }; } catch (e) { vialCache = null; return null; } } return vialCache.model; } const boardActive = () => !!(S.vial && S.vial.enabled && vialModel()); const layerName = l => (S.vial && S.vial.names && S.vial.names[l]) || window.Vial.guessName(vialModel(), l); function numLayer() { const m = vialModel(); if (!m) return null; const n = S.vial.numLayer; return n === 'auto' || n == null ? window.Vial.guessNumLayer(m) : (m.used.includes(+n) ? +n : null); } const isLetter = c => /\p{L}/u.test(c); const scriptOf = c => (/[а-яё]/i.test(c) ? 'ru' : /[a-z]/i.test(c) ? 'en' : null); const cap = w => w.charAt(0).toUpperCase() + w.slice(1); // "ё" исключаем: в словах она не встречается, а в упражнениях на неё обычно не хотят тратить время const lettersWhere = (lang, fn) => LAYOUT[lang].rows.flat().filter(k => isLetter(k.base) && k.base !== 'ё' && fn(k)).map(k => k.base).join(''); const handLetters = (lang, hand) => lettersWhere(lang, k => k.hand === hand); const fingerLetters = (lang, fs) => lettersWhere(lang, k => fs.includes(k.finger)); const centerLetters = lang => ['KeyT', 'KeyY', 'KeyG', 'KeyH', 'KeyB', 'KeyN'].map(c => LAYOUT[lang].byCode[c].base).join(''); /* ====================================================================== */ /* Генерация текстов */ /* ====================================================================== */ const WORDS = {}; for (const l of ['ru', 'en']) WORDS[l] = [...new Set(TD.words[l].split(/\s+/).filter(Boolean))]; const rnd = (a, b) => a + Math.floor(Math.random() * (b - a + 1)); const pick = a => a[Math.floor(Math.random() * a.length)]; const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = rnd(0, i); [a[i], a[j]] = [a[j], a[i]]; } return a; }; const pad2 = n => String(n).padStart(2, '0'); function isAlt(lang, w) { const cm = LAYOUT[lang].charMap; for (let i = 1; i < w.length; i++) { const a = cm[w[i - 1]], b = cm[w[i]]; if (!a || !b || a.k.hand === b.k.hand) return false; } return true; } /* Группы случайных символов: для первых уроков, где настоящих слов почти нет. */ function pseudoWord(chars, focus, min = 2, max = 5) { const arr = [...chars], len = rnd(min, max); let w = ''; for (let i = 0; i < len; i++) { let c; do { c = pick(arr); } while (arr.length > 1 && c === w[i - 1] && Math.random() < 0.8); w += c; } if (focus && ![...w].some(c => focus.includes(c))) { const p = rnd(0, w.length - 1); w = w.slice(0, p) + pick([...focus]) + w.slice(p + 1); } return w; } function drill(chars, len) { const out = []; let n = 0; while (n < len) { const w = pseudoWord(chars, null); out.push(w); n += w.length + 1; } return out.join(' '); } function digitGroups(chars, len) { const out = []; let n = 0; while (n < len) { const w = pseudoWord(chars, null, 2, 5); out.push(w); n += w.length + 1; } return out.join(' '); } function wordsText(L) { const lang = L.lang, target = L.len || 100; const allowed = new Set([...L.allowed]); const focus = L.focus ? new Set([...L.focus]) : null; const minLen = L.minLen || 2, maxLen = L.maxLen || 99; let pool = WORDS[lang].filter(w => w.length >= minLen && w.length <= maxLen && [...w].every(c => allowed.has(c))); if (L.filter === 'alt') pool = pool.filter(w => w.length >= 3 && isAlt(lang, w)); let fpool = pool; if (focus) { const need = L.minFocus || 1; const count = w => [...w].filter(c => focus.has(c)).length; fpool = pool.filter(w => count(w) >= need); if (fpool.length < 6 && need > 1) fpool = pool.filter(w => count(w) >= 1); } // pure: только настоящие слова, если их достаточно; иначе подмешиваем случайные группы букв const ratio = L.pure && pool.length >= 6 ? 1 : pool.length >= 30 ? 1 : pool.length >= 12 ? 0.75 : pool.length >= 4 ? 0.4 : 0; const focusP = L.pure ? 0.95 : 0.7; const out = []; let n = 0; while (n < target) { let w; if (pool.length && Math.random() < ratio) { const src = focus && fpool.length >= 4 && Math.random() < focusP ? fpool : pool; w = pick(src); if (w === out[out.length - 1] && src.length > 1) w = pick(src); } else { const lo = minLen < 3 ? 3 : minLen; w = pseudoWord(L.allowed, L.focus || null, lo, Math.max(lo, Math.min(maxLen, 5))); } out.push(w); n += w.length + 1; } return out.join(' '); } function fromPool(keys, lang, len) { const src = [].concat(keys).flatMap(k => TD.text[lang][k]); let bag = [], n = 0; const out = []; while (n < len) { if (!bag.length) bag = shuffle(src.slice()); const s = bag.pop(); out.push(s); n += s.length + 1; } return out.join(' '); } function fromTemplates(lang, len) { const fill = { y: () => rnd(1950, 2030), n: () => rnd(10, 9999), s: () => rnd(1, 30), m: () => rnd(1, 40), t: () => `${rnd(0, 23)}:${pad2(5 * rnd(0, 11))}`, d: () => (lang === 'ru' ? `${pad2(rnd(1, 28))}.${pad2(rnd(1, 12))}.${rnd(1990, 2030)}` : `${pad2(rnd(1, 12))}/${pad2(rnd(1, 28))}/${rnd(1990, 2030)}`), ph: () => (lang === 'ru' ? `8-9${rnd(10, 99)}-${rnd(100, 999)}-${pad2(rnd(0, 99))}-${pad2(rnd(0, 99))}` : `${rnd(200, 999)}-${rnd(100, 999)}-${rnd(1000, 9999)}`), p: () => (lang === 'ru' ? rnd(5, 999) : `${rnd(1, 99)}.${pad2(rnd(0, 99))}`) }; const raw = fromPool('num', lang, len); return raw.replace(/\{(\w+)\}/g, (_, k) => String(fill[k]())); } function numWords(lang, len) { const base = wordsText({ lang, allowed: [...new Set(lettersWhere(lang, () => true))].join(''), minLen: 2, maxLen: 7, len: len * 0.7 }).split(' '); const out = []; base.forEach((w, i) => { out.push(w); if (i % 3 === 2) out.push(String(rnd(1, 9999))); }); return out.join(' '); } const allLetters = lang => lettersWhere(lang, () => true); /* ---------- Упражнения на слои: слова, перемежающиеся числами и символами ---------- */ function layerSwitch(lang, len) { const words = WORDS[lang].filter(w => w.length >= 3 && w.length <= 5 && /^\p{L}+$/u.test(w)); const out = []; let n = 0; while (n < len) { const w = pick(words), num = String(rnd(10, 9999)); out.push(w, num); n += w.length + num.length + 2; } return out.join(' '); } function layerDigits(len) { const out = []; let n = 0; while (n < len) { const g = Array.from({ length: rnd(4, 8) }, () => rnd(0, 9)).join(''); out.push(g); n += g.length + 1; } return out.join(' '); } function layerMath(len) { const gens = [ () => { const a = rnd(10, 99), b = rnd(10, 99); return `${a} + ${b} = ${a + b}`; }, () => { const a = rnd(50, 99), b = rnd(10, 49); return `${a} - ${b} = ${a - b}`; }, () => { const a = rnd(2, 19), b = rnd(2, 19); return `${a} * ${b} = ${a * b}`; }, () => { const b = rnd(2, 12), q = rnd(2, 25); return `${b * q} / ${b} = ${q}`; }, () => { const a = rnd(1, 9), b = rnd(1, 9), c = rnd(2, 9); return `(${a} + ${b}) * ${c} = ${(a + b) * c}`; } ]; const out = []; let n = 0; while (n < len) { const s = pick(gens)(); out.push(s); n += s.length + 2; } return out.join('; '); } function layerMix(lang, len) { const gens = [() => fromPool(['easy', 'caps'], lang, 40), () => fromTemplates(lang, 40), () => layerMath(25) + '.', () => layerSwitch(lang, 30)]; const out = []; let n = 0; while (n < len) { const s = pick(gens)(); out.push(s); n += s.length + 1; } return out.join(' '); } function makeText(L) { switch (L.mode) { case 'lswitch': return layerSwitch(L.lang, L.len); case 'ldigits': return layerDigits(L.len); case 'lmath': return layerMath(L.len); case 'lmix': return layerMix(L.lang, L.len); case 'drill': return drill(L.chars, L.len); case 'nums': return digitGroups(L.chars, L.len); case 'words': return wordsText(L); case 'caps': return wordsText({ ...L, allowed: allLetters(L.lang), minLen: 2, maxLen: 8 }).split(' ').map(cap).join(' '); case 'upper': return wordsText({ ...L, allowed: allLetters(L.lang), minLen: 2, maxLen: 8 }).toUpperCase(); case 'numtext': return fromTemplates(L.lang, L.len); case 'numwords': return numWords(L.lang, L.len); case 'pool': return fromPool(L.pool, L.lang, L.len); default: throw new Error('unknown mode ' + L.mode); } } /* ---------- Практика ---------- */ function sanitizeCustom(text, lang) { const cm = LAYOUT[lang].charMap; let t = text.replace(/[–—]/g, '-').replace(/[«»“”„]/g, '"').replace(/[‘’]/g, "'").replace(/…/g, '...').replace(/\s+/g, ' ').trim(); let removed = 0; t = [...t].filter(c => { if (cm[c]) return true; removed++; return false; }).join('').replace(/ +/g, ' ').trim(); return { text: t.slice(0, 3000), removed }; } function decorate(words, o) { const out = []; words.forEach(w => { if (o.caps && Math.random() < 0.3) w = cap(w); if (o.digits && Math.random() < 0.18) out.push(String(rnd(1, 9999))); if (o.punct && Math.random() < 0.22) w += pick([',', '.', '?', '!', ':', ';']); out.push(w); }); let t = out.join(' '); if (o.punct && !/[.!?]$/.test(t)) t = t.replace(/[,:;]$/, '') + '.'; return t; } function practiceText(o) { const lang = o.lang, len = { s: 100, m: 250, l: 500 }[o.size] || 250; switch (o.kind) { case 'words': { const words = wordsText({ lang, allowed: allLetters(lang), minLen: 2, maxLen: 9, len }).split(' '); return decorate(words, o); } case 'sentences': return fromPool(['easy', 'caps', 'p1', 'p2', 'p3', 'p4', 'p5'], lang, len); case 'paragraphs': return fromPool('para', lang, len); case 'numbers': return digitGroups('0123456789', len); case 'numtext': return fromTemplates(lang, len); case 'layers': return layerMix(lang, len); case 'symbols': return fromPool(lang === 'en' ? ['sym1', 'sym2', 'sym3', 'code'] : ['sym1', 'sym2'], lang, len); default: return ''; } } /* ---------- Слабые клавиши ---------- */ function weakChars(lang) { const agg = {}; for (const [c, v] of Object.entries(S.keys[lang] || {})) { if (c === ' ') continue; const k = c.toLowerCase(); const a = agg[k] || (agg[k] = { n: 0, err: 0 }); a.n += v.n; a.err += v.err; } return Object.entries(agg) .map(([c, a]) => ({ c, err: a.err, rate: a.err / Math.max(1, a.n + a.err), total: a.n + a.err })) .filter(x => x.total >= 8 && x.err >= 2) .sort((a, b) => b.rate - a.rate || b.err - a.err) .slice(0, 6); } function weakText(lang, weak) { const letters = [...new Set(weak.map(w => w.c).filter(isLetter))]; const others = weak.map(w => w.c).filter(c => !isLetter(c)); const score = w => [...w].filter(c => letters.includes(c)).length; const pool = WORDS[lang].filter(w => score(w) > 0).sort((a, b) => score(b) - score(a)).slice(0, 80); const out = []; let n = 0; while (n < 220) { let w; const r = Math.random(); if (pool.length && r < 0.65) w = pick(pool); else if (others.length && r < 0.8) w = pseudoWord(others.join('') + letters.join(''), others.join(''), 2, 4); else w = pseudoWord(letters.join('') || others.join(''), null, 2, 4); out.push(w); n += w.length + 1; } return out.join(' '); } /* ====================================================================== */ /* Уроки */ /* ====================================================================== */ const STEPS = { en: { A: ['asdf', 'jkl;', 'gh'], B: ['ei', 'ru', 'ty', 'wo', 'qp'], C: ['bn', 'vm', 'cxz'] }, ru: { A: ['фыва', 'олдж', 'пр'], B: ['ен', 'кг', 'уш', 'цщ', 'йз', 'хъэ'], C: ['ит', 'мь', 'сб', 'чюя'] } }; const SEC = { A: ['Основной ряд', 'Исходная позиция: пальцы лежат на средней строке'], B: ['Верхний ряд', 'Постепенно добавляем буквы верхней строки'], C: ['Нижний ряд', 'Добавляем буквы нижней строки'], D: ['Сплит: руки и пальцы', 'Упражнения для разделённой клавиатуры: центр, одна рука, чередование рук, слабые пальцы'], E: ['Слова', 'Настоящие слова: от коротких к длинным'], F: ['Заглавные буквы', 'Shift нажимает рука, противоположная нужной клавише'], G: ['Цифры', 'Верхний цифровой ряд'], H: ['Знаки препинания', 'Точки, запятые, вопросы, дефис, кавычки и скобки'], I: ['Символы', 'Спецсимволы и код'], J: ['Тексты', 'Предложения и абзацы'], K: ['Слои вашей клавиатуры', 'Нужна раскладка Vial (раздел «Клавиатура»): текст вперемешку с числами и символами, которые лежат на других слоях'] }; function buildLessons(lang) { const st = STEPS[lang], L = []; const add = o => L.push({ lang, len: 100, ...o, id: `${lang}-${o.sec}${o.n}` }); const lt = s => s.replace(/[^\p{L}]/gu, ''); const up = s => [...s].map(c => c.toUpperCase()).join(' '); const home = st.A[0] + st.A[1]; add({ sec: 'A', n: 1, title: up(st.A[0]), desc: 'Левая рука: четыре пальца на основном ряду', mode: 'drill', chars: st.A[0], len: 60 }); add({ sec: 'A', n: 2, title: up(st.A[1]), desc: 'Правая рука: четыре пальца на основном ряду', mode: 'drill', chars: st.A[1], len: 60 }); add({ sec: 'A', n: 3, title: 'Обе руки', desc: 'Весь основной ряд, кроме центра', mode: 'drill', chars: home, len: 90 }); let acc = lt(home + st.A[2]); add({ sec: 'A', n: 4, title: '+ ' + up(st.A[2]), desc: 'Указательные пальцы тянутся к центру', mode: 'words', allowed: acc, focus: st.A[2], len: 100 }); st.B.forEach((step, k) => { acc += lt(step); add({ sec: 'B', n: k + 1, title: '+ ' + up(step), desc: 'Новые клавиши верхнего ряда', mode: 'words', allowed: acc, focus: lt(step), len: 110 }); }); add({ sec: 'B', n: st.B.length + 1, title: 'Весь верхний ряд', desc: 'Закрепляем верхний ряд', mode: 'words', allowed: acc, focus: lt(st.B.join('')), len: 140 }); st.C.forEach((step, k) => { acc += lt(step); add({ sec: 'C', n: k + 1, title: '+ ' + up(step), desc: 'Новые клавиши нижнего ряда', mode: 'words', allowed: acc, focus: lt(step), len: 110 }); }); add({ sec: 'C', n: st.C.length + 1, title: 'Все буквы', desc: 'Алфавит целиком', mode: 'words', allowed: acc, len: 160 }); const all = acc; add({ sec: 'D', n: 1, title: 'Центр: пары через зазор', desc: 'Клавиши у середины, на стыке половинок', mode: 'drill', chars: centerLetters(lang), len: 90 }); add({ sec: 'D', n: 2, pure: true, title: 'Слова через центр', desc: 'Слова с несколькими центральными клавишами', mode: 'words', allowed: all, focus: centerLetters(lang), minFocus: 2, len: 140 }); add({ sec: 'D', n: 3, pure: true, title: 'Только левая рука', desc: 'Слова, которые набираются одной рукой', mode: 'words', allowed: handLetters(lang, 'L'), minLen: 1, len: 100 }); add({ sec: 'D', n: 4, pure: true, title: 'Только правая рука', desc: 'То же для правой руки', mode: 'words', allowed: handLetters(lang, 'R'), minLen: 1, len: 100 }); add({ sec: 'D', n: 5, pure: true, title: 'Чередование рук', desc: 'Руки работают по очереди: самый быстрый ритм', mode: 'words', allowed: all, filter: 'alt', len: 150 }); add({ sec: 'D', n: 6, pure: true, title: 'Мизинцы', desc: 'Крайние колонки: самые слабые пальцы', mode: 'words', allowed: all, focus: fingerLetters(lang, ['lp', 'rp']), minFocus: 2, len: 140 }); add({ sec: 'D', n: 7, pure: true, title: 'Безымянные', desc: 'Второй по слабости палец', mode: 'words', allowed: all, focus: fingerLetters(lang, ['lr', 'rr']), minFocus: 2, len: 140 }); add({ sec: 'E', n: 1, pure: true, title: 'Короткие слова', desc: '2-4 буквы', mode: 'words', allowed: all, minLen: 2, maxLen: 4, len: 150 }); add({ sec: 'E', n: 2, pure: true, title: 'Средние слова', desc: '5-7 букв', mode: 'words', allowed: all, minLen: 5, maxLen: 7, len: 160 }); add({ sec: 'E', n: 3, pure: true, title: 'Длинные слова', desc: '8 букв и больше', mode: 'words', allowed: all, minLen: 8, maxLen: 20, len: 170 }); add({ sec: 'F', n: 1, title: 'Слова с заглавной', desc: 'Тренируем Shift обеими руками', mode: 'caps', len: 120, gm: 0.9 }); add({ sec: 'F', n: 2, title: 'Имена и города', desc: 'Предложения с заглавными буквами', mode: 'pool', pool: 'caps', len: 200, gm: 0.9 }); add({ sec: 'F', n: 3, title: 'ЗАГЛАВНЫЕ СЛОВА', desc: 'Shift зажат почти всё время', mode: 'upper', len: 100, gm: 0.85 }); add({ sec: 'G', n: 1, title: '1 2 3 4 5', desc: 'Левая рука на цифровом ряду', mode: 'nums', chars: '12345', len: 70, gm: 0.7 }); add({ sec: 'G', n: 2, title: '6 7 8 9 0', desc: 'Правая рука на цифровом ряду', mode: 'nums', chars: '67890', len: 70, gm: 0.7 }); add({ sec: 'G', n: 3, title: 'Все цифры', desc: 'Случайные числа', mode: 'nums', chars: '1234567890', len: 120, gm: 0.7 }); add({ sec: 'G', n: 4, title: 'Числа в тексте', desc: 'Даты, время, цены, телефоны', mode: 'numtext', len: 220, gm: 0.75 }); add({ sec: 'G', n: 5, title: 'Слова и цифры', desc: 'Переключение между буквами и цифрами', mode: 'numwords', len: 200, gm: 0.8 }); add({ sec: 'H', n: 1, title: 'Точка и запятая', desc: 'Самые частые знаки', mode: 'pool', pool: 'p1', len: 220, gm: 0.85 }); add({ sec: 'H', n: 2, title: 'Вопрос и восклицание', desc: 'Знаки ? и !', mode: 'pool', pool: 'p2', len: 200, gm: 0.8 }); add({ sec: 'H', n: 3, title: 'Двоеточие, ; и дефис', desc: 'Знаки : ; и -', mode: 'pool', pool: 'p3', len: 220, gm: 0.8 }); add({ sec: 'H', n: 4, title: 'Кавычки и скобки', desc: lang === 'en' ? 'Кавычки, скобки и апостроф' : 'Кавычки и скобки', mode: 'pool', pool: 'p4', len: 220, gm: 0.75 }); add({ sec: 'H', n: 5, title: 'Все знаки вместе', desc: 'Смешанные предложения', mode: 'pool', pool: 'p5', len: 260, gm: 0.8 }); if (lang === 'en') { add({ sec: 'I', n: 1, title: '@ # $ %', desc: 'Почта, хештеги, цены', mode: 'pool', pool: 'sym1', len: 150, gm: 0.6 }); add({ sec: 'I', n: 2, title: '& * ( ) _ + = ^', desc: 'Математика и имена переменных', mode: 'pool', pool: 'sym2', len: 150, gm: 0.6 }); add({ sec: 'I', n: 3, title: '[ ] { } < > / \\ |', desc: 'Скобки, пути и каналы', mode: 'pool', pool: 'sym3', len: 150, gm: 0.55 }); add({ sec: 'I', n: 4, title: 'Код', desc: 'Фрагменты кода: всё вперемешку', mode: 'pool', pool: 'code', len: 200, gm: 0.55 }); } else { add({ sec: 'I', n: 1, title: '№ % * ( ) ? :', desc: 'Спецсимволы русской раскладки', mode: 'pool', pool: 'sym1', len: 160, gm: 0.65 }); add({ sec: 'I', n: 2, title: 'Цифры и знаки', desc: 'Телефоны, даты, формулы', mode: 'pool', pool: 'sym2', len: 200, gm: 0.6 }); } add({ sec: 'J', n: 1, title: 'Короткие предложения', desc: 'Простые фразы', mode: 'pool', pool: 'easy', len: 180 }); add({ sec: 'J', n: 2, title: 'Предложения', desc: 'Заглавные, знаки, цифры', mode: 'pool', pool: ['easy', 'caps', 'p1', 'p2', 'p3', 'p4', 'p5'], len: 280 }); add({ sec: 'J', n: 3, title: 'Абзацы', desc: 'Связный текст', mode: 'pool', pool: 'para', len: 420 }); add({ sec: 'J', n: 4, title: 'Марафон', desc: 'Длинный текст на выносливость', mode: 'pool', pool: 'para', len: 800 }); // Слои: тренер подсказывает маршрут по вашей раскладке и просит пользоваться слоем с цифрами const lay = { layer: true, needsBoard: true }; add({ sec: 'K', n: 1, title: 'Слово - число - слово', desc: 'Переключаем слой туда и обратно', mode: 'lswitch', len: 130, gm: 0.55, ...lay }); add({ sec: 'K', n: 2, title: 'Длинные числа', desc: 'Включили слой с цифрами и печатаем числа подряд', mode: 'ldigits', len: 160, gm: 0.55, ...lay }); add({ sec: 'K', n: 3, title: 'Числа в тексте', desc: 'Даты, цены, телефоны среди слов', mode: 'numtext', len: 220, gm: 0.5, ...lay }); add({ sec: 'K', n: 4, title: 'Арифметика', desc: 'Цифры и знаки + - * / = ( )', mode: 'lmath', len: 200, gm: 0.5, ...lay }); add({ sec: 'K', n: 5, title: 'Текст с цифрами', desc: 'Связные фразы, где числа на каждом шагу', mode: 'pool', pool: 'ltext', len: 280, gm: 0.5, ...lay }); if (lang === 'en') { add({ sec: 'K', n: 6, title: 'Символы со слоя', desc: 'Скобки, = \\ | ~ и другие знаки, которых нет на базовом слое', mode: 'pool', pool: 'lsym', len: 220, gm: 0.45, ...lay }); add({ sec: 'K', n: 7, title: 'Код с цифрами', desc: 'Скобки, операторы и числа вперемешку', mode: 'pool', pool: 'lcode', len: 280, gm: 0.45, ...lay }); add({ sec: 'K', n: 8, title: 'Смесь', desc: 'Текст, числа, формулы - всё вместе', mode: 'lmix', len: 500, gm: 0.5, ...lay }); } else { add({ sec: 'K', n: 6, title: 'Смесь', desc: 'Текст, числа, формулы - всё вместе', mode: 'lmix', len: 500, gm: 0.5, ...lay }); } L.forEach((l, i) => { l.index = i; l.goal = Math.round(Math.min(230, 55 + i * 3.2) * (l.gm || 1)); }); return L; } const LESSONS = { ru: buildLessons('ru'), en: buildLessons('en') }; const LESSON_BY_ID = {}; for (const l of ['ru', 'en']) LESSONS[l].forEach(x => { LESSON_BY_ID[x.id] = x; }); const rateStars = (acc, cpm, goal) => (acc < 90 ? 0 : acc >= 98 && cpm >= goal * 1.25 ? 3 : acc >= 95 && cpm >= goal ? 2 : 1); const starsEl = n => h('span', { class: 'stars', 'aria-label': `${n} из 3 звёзд` }, [0, 1, 2].map(i => h('span', { class: i < n ? 'f' : '' }, i < n ? '★' : '☆'))); /* ====================================================================== */ /* Виртуальная клавиатура */ /* ====================================================================== */ function buildKeyboard(lang, opts = {}) { const lay = LAYOUT[lang]; const kb = h('div', { class: `kb${S.settings.kbStyle === 'ortho' ? ' ortho' : ''}${S.settings.fingerColors && !opts.heat ? ' fc' : ''}${opts.heat ? ' heat' : ''}` }); const map = {}; const reg = (code, e) => { (map[code] = map[code] || []).push(e); return e; }; const setW = (e, w) => { e.style.setProperty('--w', w); return e; }; const keyEl = k => { const letter = isLetter(k.base); const e = h('div', { class: 'key' + (k.code === 'KeyF' || k.code === 'KeyJ' ? ' bump' : ''), 'data-f': k.finger, 'data-code': k.code }, letter ? k.shift : [h('span', { class: 's' }, k.shift), h('span', null, k.base)]); if (k.code === 'Backslash') setW(e, 1.5); return reg(k.code, e); }; const mod = (code, label, w) => reg(code, setW(h('div', { class: 'key mod', 'data-code': code }, label), w)); const spacer = w => setW(h('div', { class: 'spacer' }), w); const modsL = [null, ['Tab', 'Tab', 1.5], ['CapsLock', 'Caps', 1.75], ['ShiftLeft', 'Shift', 2.25]]; const modsR = [['Backspace', '⌫', 2], null, ['Enter', 'Enter', 2.25], ['ShiftRight', 'Shift', 2.75]]; const offR = [0, 0.5, 0.75, 1.25]; ['L', 'R'].forEach(hand => { const half = h('div', { class: 'half ' + (hand === 'L' ? 'left' : 'right') }); for (let r = 0; r < 4; r++) { const row = h('div', { class: 'krow' }); if (hand === 'R' && offR[r]) row.append(spacer(offR[r])); if (hand === 'L' && modsL[r]) row.append(mod(...modsL[r])); lay.rows[r].filter(k => k.hand === hand).forEach(k => row.append(keyEl(k))); if (hand === 'R' && modsR[r]) row.append(mod(...modsR[r])); half.append(row); } const sp = reg('Space', setW(h('div', { class: 'key mod space', 'data-code': 'Space', 'data-f': 'th' }, 'Space'), 4)); half.append(h('div', { class: 'krow th' }, sp)); kb.append(half); if (hand === 'L') kb.append(h('div', { class: 'gap' })); }); kb.querySelector('.gap').remove(); return { el: kb, map }; } /* ====================================================================== */ /* Клавиатура пользователя (по файлу Vial) */ /* ====================================================================== */ const KEY_LABEL = { Escape: 'Esc', Tab: 'Tab', Backspace: '⌫', Enter: '⏎', NumpadEnter: '⏎', Space: '␣', CapsLock: 'Caps', ShiftLeft: '⇧', ShiftRight: '⇧', ControlLeft: 'Ctrl', ControlRight: 'Ctrl', AltLeft: 'Alt', AltRight: 'AltGr', MetaLeft: 'Win', MetaRight: 'Win', ContextMenu: 'Menu', ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓', Home: 'Home', End: 'End', PageUp: 'PgUp', PageDown: 'PgDn', Insert: 'Ins', Delete: 'Del', PrintScreen: 'PrtSc', ScrollLock: 'ScrLk', Pause: 'Pause', NumLock: 'Num' }; const LAYER_ACT = { mo: 'MO', tg: 'TG', to: 'TO', osl: 'OSL', tt: 'TT', df: 'DF' }; /* Подпись на клавише: что она делает на слое. Возвращает узлы и CSS-класс. */ function legend(p, lang) { if (!p) return { nodes: [], cls: 'none' }; switch (p.t) { case 'trns': return { nodes: ['▽'], cls: 'mod trns' }; case 'no': return { nodes: [], cls: 'none' }; case 'mo': case 'tg': case 'to': case 'osl': case 'tt': case 'df': return { nodes: [LAYER_ACT[p.t] + p.n, h('span', { class: 's' }, layerName(p.n))], cls: 'mod lay' }; case 'osm': return { nodes: ['OSM'], cls: 'mod' }; case 'key': { if (p.other) return { nodes: [p.raw.replace(/KC_/g, '').slice(0, 9)], cls: 'mod' }; const ch = charOf(lang, p.code, p.shift); const nodes = [], cls = ch && ch !== ' ' ? '' : 'mod'; nodes.push(ch && ch !== ' ' ? (isLetter(ch) ? ch.toUpperCase() : ch) : (KEY_LABEL[p.code] || p.code.replace(/^(Key|Digit|Numpad)/, ''))); if (p.holdLayer != null) nodes.push(h('span', { class: 's' }, '▸' + layerName(p.holdLayer))); else if (p.holdShift) nodes.push(h('span', { class: 's' }, 'держать ⇧')); return { nodes, cls }; } default: return { nodes: [p.label || (p.raw || '?').slice(0, 7)], cls: 'mod' }; } } function buildBoard(model) { const kb = h('div', { class: 'kb vial' + (S.settings.fingerColors ? ' fc' : '') }); const map = {}, bump = new Set(); model.positions.forEach(pos => { const p = model.parsed[0][pos]; if (p && p.t === 'key' && (p.code === 'KeyF' || p.code === 'KeyJ')) bump.add(pos); }); (model.split ? ['L', 'R'] : ['A']).forEach(hand => { const half = h('div', { class: 'half ' + (hand === 'R' ? 'right' : 'left') }); model.grid[hand].forEach(cells => { if (cells.every(p => p < 0)) return; const row = h('div', { class: 'krow' }); cells.forEach(pos => { if (pos < 0) { row.append(h('div', { class: 'spacer' })); return; } const e = h('div', { class: 'key', 'data-f': model.cells[pos].finger, 'data-pos': pos }); map[pos] = e; row.append(e); }); half.append(row); }); kb.append(half); }); return { el: kb, map, model, bump }; } /* Перерисовывает подписи: A - маска активных слоёв (с прозрачностью) или raw - один слой "как есть". */ function paintBoard(board, lang, A, raw) { const m = board.model; m.positions.forEach(pos => { const e = board.map[pos]; let p; if (raw != null) p = m.parsed[raw][pos]; else { const r = window.Vial.resolve(m, A, pos); p = r && r.p; } const lg = legend(p, lang); e.className = 'key ' + lg.cls + (board.bump.has(pos) ? ' bump' : ''); e.replaceChildren(...lg.nodes.map(n => (typeof n === 'string' ? document.createTextNode(n) : n))); }); } const ACT_WORD = { hold: 'держать', toggle: 'вкл/выкл', to: 'перейти', osl: 'тап' }; /* Подсвечивает маршрут: клавиша слоя (янтарная), Shift и сама клавиша (цветные). */ function markRoute(board, entry) { Object.values(board.map).forEach(e => e.classList.remove('next', 'act')); if (!entry || entry.pos == null) return; board.map[entry.pos].classList.add('next'); if (entry.shiftPos != null) board.map[entry.shiftPos].classList.add('next'); entry.actions.forEach(a => { const e = board.map[a.pos]; e.classList.add('act'); e.replaceChildren(a.label, h('span', { class: 's' }, ACT_WORD[a.kind])); }); } /* ====================================================================== */ /* Состояние приложения */ /* ====================================================================== */ let view = 'lessons'; let run = null; // текущая тренировка let lastRes = null; // последний результат let timer = null; let ui = {}; // ссылки на элементы экрана тренировки let tToast = null; function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(tToast); tToast = setTimeout(() => t.classList.remove('on'), 2600); } const NAV = [['lessons', 'Уроки'], ['practice', 'Практика'], ['keyboard', 'Клавиатура'], ['stats', 'Статистика'], ['settings', 'Настройки']]; function renderNav(active) { const nav = $('#nav'); nav.innerHTML = ''; NAV.forEach(([id, label]) => nav.append(h('button', { class: id === active ? 'on' : '', onclick: () => go(id) }, label))); } function stopTimer() { clearInterval(timer); timer = null; } function show(v) { if (view === 'train' && v !== 'train') { stopTimer(); if (run && !run.done) run = null; } view = v; $$('.view').forEach(x => x.classList.toggle('on', x.id === 'v-' + v)); const navKey = v === 'train' ? (run && run.kind !== 'lesson' ? 'practice' : 'lessons') : v === 'result' ? (lastRes && lastRes.kind !== 'lesson' ? 'practice' : 'lessons') : v; renderNav(navKey); window.scrollTo(0, 0); } function go(v) { show(v); ({ lessons: renderLessons, practice: renderPractice, keyboard: renderKeyboardPage, stats: renderStats, settings: renderSettings, account: () => window.Auth.render() })[v](); } const seg = (opts, value, onchange) => h('div', { class: 'seg', role: 'group' }, opts.map(([v, label]) => h('button', { class: v === value ? 'on' : '', 'aria-pressed': String(v === value), onclick: () => onchange(v) }, label))); /* ====================================================================== */ /* Экран: уроки */ /* ====================================================================== */ function renderLessons() { const root = $('#v-lessons'); root.innerHTML = ''; const lang = S.settings.lang, board = boardActive(); const list = LESSONS[lang], avail = list.filter(l => !l.needsBoard || board); const done = avail.filter(l => (S.progress[l.id] || {}).stars > 0).length; const stars = avail.reduce((s, l) => s + ((S.progress[l.id] || {}).stars || 0), 0); const next = avail.find(l => !((S.progress[l.id] || {}).stars > 0)) || avail[avail.length - 1]; root.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Уроки'), h('p', { class: 'muted' }, 'От основного ряда к цифрам, знакам и длинным текстам. Идите по порядку, но можно открыть любой урок.')), seg([['ru', 'Русский'], ['en', 'English']], lang, v => { S.settings.lang = v; save(); renderLessons(); }))); root.append(h('div', { class: 'tip' }, h('span', null, '💡'), h('span', null, 'Тренер читает символы, которые выдаёт ваша система, поэтому ', h('b', null, 'переключите раскладку Windows'), ' на нужный язык (Win+Пробел или Alt+Shift). Если включена не та, тренер подскажет.'))); root.append(h('div', { class: 'panel cont' }, h('div', { class: 'grow' }, h('div', { class: 'row between small' }, h('b', null, `Пройдено ${done} из ${avail.length}`), h('span', { class: 'muted' }, `★ ${stars} / ${avail.length * 3}`)), h('div', { class: 'progress-line', style: 'margin-top:8px' }, h('i', { style: `width:${(done / avail.length) * 100}%` }))), h('button', { class: 'btn primary', onclick: () => startLesson(next.id) }, done ? `Продолжить: ${next.title} →` : `Начать: ${next.title} →`))); Object.keys(SEC).forEach(sec => { const items = list.filter(l => l.sec === sec); if (!items.length) return; const locked = items[0].needsBoard && !board; root.append(h('div', { class: 'sec' }, h('div', { class: 'sec-head' }, h('span', { class: 'letter' }, sec), h('h2', null, SEC[sec][0]), h('span', { class: 'muted small' }, SEC[sec][1]), locked ? h('button', { class: 'btn', onclick: () => go('keyboard') }, 'Загрузить раскладку →') : null), h('div', { class: 'grid' }, items.map(l => { const p = S.progress[l.id], lock = l.needsBoard && !board; return h('button', { class: 'card' + (l.id === next.id ? ' next' : '') + (lock ? ' locked' : ''), onclick: () => startLesson(l.id) }, h('div', { class: 'num' }, h('span', null, `Урок ${l.index + 1}`), lock ? h('span', null, '🔒') : starsEl(p ? p.stars : 0)), h('div', { class: 'ttl' }, l.title), h('div', { class: 'dsc' }, l.desc), h('div', { class: 'best' }, lock ? 'Нужна раскладка Vial' : p ? `Лучшее: ${Math.round(p.best)} зн/мин · ${Math.round(p.acc)}%` : `Цель: ${l.goal} зн/мин`)); })))); }); } function startLesson(id) { const L = LESSON_BY_ID[id]; if (L.needsBoard && !boardActive()) { toast(S.vial ? 'Включите «Использовать в тренировках» в разделе «Клавиатура»' : 'Эти уроки нужны для вашей клавиатуры: загрузите файл Vial (.vil)'); go('keyboard'); return; } startRun({ kind: 'lesson', lang: L.lang, title: L.title, lesson: L, text: makeText(L), goal: L.goal, layer: !!L.layer }); } /* ====================================================================== */ /* Экран: практика */ /* ====================================================================== */ const KINDS = [['words', 'Слова'], ['sentences', 'Предложения'], ['paragraphs', 'Абзацы'], ['numbers', 'Случайные цифры'], ['numtext', 'Числа в тексте'], ['symbols', 'Символы и знаки'], ['layers', 'Слои: текст, числа и формулы (нужна раскладка Vial)'], ['custom', 'Свой текст']]; function renderPractice() { const root = $('#v-practice'); root.innerHTML = ''; const P = S.settings.practice; if (P.kind === 'layers' && !boardActive()) P.kind = 'words'; const upd = (k, v) => { P[k] = v; save(); renderPractice(); }; root.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Свободная практика'), h('p', { class: 'muted' }, 'Соберите упражнение под себя. Результаты идут в общую статистику.')))); const form = h('div', { class: 'panel form' }); form.append(h('div', { class: 'field' }, h('span', { class: 'lbl' }, 'Язык'), seg([['ru', 'Русский'], ['en', 'English']], P.lang, v => upd('lang', v)))); const sel = h('select', { id: 'kind', onchange: e => upd('kind', e.target.value) }, KINDS.filter(([v]) => v !== 'layers' || boardActive()).map(([v, l]) => h('option', { value: v, selected: v === P.kind }, l))); form.append(h('div', { class: 'field' }, h('label', { for: 'kind' }, 'Что печатать'), sel)); if (P.kind === 'words') { const chk = (k, label) => h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: P[k], onchange: e => { P[k] = e.target.checked; save(); } }), label); form.append(h('div', { class: 'field' }, h('span', { class: 'lbl' }, 'Добавить к словам'), h('div', { class: 'checks' }, chk('caps', 'Заглавные буквы'), chk('digits', 'Цифры'), chk('punct', 'Знаки препинания')))); } if (P.kind === 'custom') { form.append(h('div', { class: 'field' }, h('label', { for: 'custom' }, 'Ваш текст'), h('textarea', { id: 'custom', placeholder: 'Вставьте любой текст. Символы, которых нет на клавиатуре выбранной раскладки, будут убраны.', oninput: e => { P.custom = e.target.value; save(); } }, P.custom))); } else { form.append(h('div', { class: 'field' }, h('span', { class: 'lbl' }, 'Длина'), seg([['s', 'Короткая'], ['m', 'Средняя'], ['l', 'Длинная']], P.size, v => upd('size', v)))); } form.append(h('div', null, h('button', { class: 'btn primary', onclick: startPractice }, 'Начать →'))); root.append(form); } function startPractice() { const P = S.settings.practice; let text; if (P.kind === 'custom') { const r = sanitizeCustom(P.custom, P.lang); if (r.text.length < 5) { toast('Слишком короткий текст: нужно минимум 5 печатаемых символов'); return; } text = r.text; if (r.removed) toast(`Убрано символов, которых нет на раскладке: ${r.removed}`); } else { if (P.kind === 'layers' && !boardActive()) { toast('Для этого режима загрузите раскладку Vial (раздел «Клавиатура»)'); return; } text = practiceText(P); } startRun({ kind: 'practice', lang: P.lang, title: KINDS.find(k => k[0] === P.kind)[1].replace(/ \(.*\)/, ''), text, goal: 0, layer: P.kind === 'layers' }); } /* ====================================================================== */ /* Тренировка */ /* ====================================================================== */ function startRun(cfg) { stopTimer(); run = Object.assign({ i: 0, errors: 0, strokes: 0, t0: null, lastOk: null, done: false, lat: {}, kst: {}, conf: {}, buckets: [], ints: [], swMs: [], normMs: [] }, cfg); run.board = boardActive(); run.plan = null; if (run.board) { run.prefer = run.layer ? numLayer() : null; if (run.layer && run.prefer == null) toast('Слой с цифрами не найден - выберите его в разделе «Клавиатура». Пока маршрут строится как получится.'); buildPlan(0, 0); } if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); show('train'); renderTrain(); } /* Маршрут по слоям для текста начиная с позиции from; P - слои, которые считаем включёнными (маска постоянных слоёв). */ function buildPlan(from, P) { const res = window.Vial.plan(vialModel(), run.text.slice(from), { lang: run.lang, charOf, prefer: run.prefer, startP: P }); if (!run.plan) run.plan = []; run.plan.splice(from, run.plan.length - from, ...res.entries); } /* Пользователь говорит, на каком слое он сейчас на самом деле (прошивка нам этого не сообщает). */ function assumeLayer(l) { run.plan = run.plan.slice(0, run.i); buildPlan(run.i, l === 0 ? 0 : window.Vial.bit(l)); updateCursor(); } function restart() { if (!run) return; const c = run; if (c.kind === 'lesson') startLesson(c.lesson.id); else if (c.kind === 'weak') startWeak(c.lang); else startPractice(); } function renderTrain() { const root = $('#v-train'); root.innerHTML = ''; const fs = S.settings.font; ui = {}; const back = () => go(run.kind === 'lesson' ? 'lessons' : run.kind === 'weak' ? 'stats' : 'practice'); root.append(h('div', { class: 'train-top' }, h('button', { class: 'btn ghost', onclick: back }, '← Назад'), h('div', { class: 'train-title' }, h('span', { class: 'tag' }, LANG_NAME[run.lang]), h('b', null, run.title)), h('button', { class: 'btn ghost', onclick: restart }, '↻ Заново (Esc)'))); const chip = (key, label) => { const b = h('b'); ui[key] = b; return h('div', { class: 'chip' }, h('small', null, label), b); }; root.append(h('div', { class: 'chips' }, chip('cpm', 'Скорость'), chip('acc', 'Точность'), chip('time', 'Время'), chip('err', 'Ошибки'))); ui.bar = h('i'); root.append(h('div', { class: 'bar' }, ui.bar)); ui.warn = h('div', { class: 'warn', role: 'alert' }); root.append(ui.warn); ui.text = h('div', { class: `textbox fs-${fs}`, tabindex: '-1', 'aria-label': 'Текст для набора' }); run.spans = []; const words = run.text.split(' '); words.forEach((w, wi) => { const wrap = h('span', { class: 'w' }); [...w].forEach(c => { const s = h('span', { class: 'ch' }, c); run.spans.push(s); wrap.append(s); }); ui.text.append(wrap); if (wi < words.length - 1) { const sp = h('span', { class: 'ch sp' }, ' '); run.spans.push(sp); ui.text.append(sp, document.createElement('wbr')); } }); ui.go = h('div', { class: 'go' }, 'Таймер стартует с первой нажатой клавиши'); ui.text.append(ui.go); root.append(ui.text); ui.hint = h('div', { class: 'hint' }); root.append(ui.hint); if (run.board) { ui.layerbar = h('div', { class: 'layerbar' }); root.append(ui.layerbar); } ui.kbhost = h('div', { class: 'kbhost' }); root.append(ui.kbhost); if (S.settings.showKb) { if (run.board) ui.board = buildBoard(vialModel()); else ui.kb = buildKeyboard(run.lang); ui.kbhost.append((ui.board || ui.kb).el); if (S.settings.fingerColors) ui.kbhost.append(fingerLegend()); } root.append(h('div', { class: 'muted small foot-hint' }, 'Неверная клавиша не засчитывается: нажмите правильную, чтобы идти дальше. ', h('kbd', null, 'Esc'), ' - начать заново.')); updateCursor(); updateLive(); } function fingerLegend() { const hues = { lp: 350, lr: 24, lm: 48, li: 140, ri: 190, rm: 222, rr: 268, rp: 318 }; return h('div', { class: 'finger-legend' }, Object.entries(hues).map(([f, hue]) => h('span', { style: `--h:${hue}` }, FINGER_NAME[f]))); } function describeChar(lang, ch) { const m = LAYOUT[lang].charMap[ch]; if (!m) return ''; if (ch === ' ') return 'пробел - большой палец'; let t = FINGER_NAME[m.k.finger]; if (m.shift) t += ` + Shift ${m.k.hand === 'L' ? 'правой' : 'левой'} рукой`; return t; } function updateCursor() { const { spans, i, text, lang } = run; spans.forEach((s, idx) => s.classList.toggle('cur', idx === i)); const cur = spans[i]; if (cur) { const lh = parseFloat(getComputedStyle(ui.text).fontSize) * 1.9; ui.text.scrollTop = Math.max(0, cur.offsetTop - lh); const ch = text[i]; ui.hint.innerHTML = ''; if (run.board) showRoute(run.plan[i], ch); else { ui.hint.append(h('span', { class: 'big' }, ch === ' ' ? '␣' : ch), h('span', null, describeChar(lang, ch))); highlightNext(ch); } } ui.bar.style.width = (i / text.length) * 100 + '%'; } function highlightNext(ch) { if (!ui.kb) return; $$('.key.next', ui.kb.el).forEach(e => e.classList.remove('next')); const m = LAYOUT[run.lang].charMap[ch]; if (!m) return; (ui.kb.map[m.k.code] || []).forEach(e => e.classList.add('next')); if (m.shift) (ui.kb.map[m.k.hand === 'L' ? 'ShiftRight' : 'ShiftLeft'] || []).forEach(e => e.classList.add('next')); } function flashKey(code, cls) { if (ui.board) { // на своей клавиатуре знаем только, какую клавишу ждали const e = cls === 'hit' && run.plan[run.i] && ui.board.map[run.plan[run.i].pos]; if (e) { e.classList.add(cls); setTimeout(() => e.classList.remove(cls), 160); } return; } if (!ui.kb || !ui.kb.map[code]) return; ui.kb.map[code].forEach(e => { e.classList.add(cls); setTimeout(() => e.classList.remove(cls), 160); }); } /* ---------- Маршрут по слоям на клавиатуре пользователя ---------- */ function actionText(a, entry) { const name = `«${layerName(a.n)}»`; if (a.kind === 'hold') return `Удерживайте ${a.label} (слой ${name})`; if (a.kind === 'toggle') return `${entry.Pbefore & window.Vial.bit(a.n) ? 'Выключите' : 'Включите'} слой ${name}: ${a.label}`; if (a.kind === 'to') return `Перейдите на слой ${name}: ${a.label}`; return `Нажмите ${a.label} - одноразовый слой ${name}`; } function showRoute(entry, ch) { const m = vialModel(); ui.hint.append(h('span', { class: 'big' }, ch === ' ' ? '␣' : ch)); if (!entry || entry.pos == null) { ui.hint.append(h('span', { class: 'muted' }, 'Этого символа нет на вашей раскладке: пропустите упражнение или добавьте клавишу в Vial.')); } else { const steps = []; entry.actions.forEach(a => steps.push(h('span', { class: 'step act' }, actionText(a, entry)))); if (entry.shiftPos != null) steps.push(h('span', { class: 'step' }, `+ Shift: ${FINGER_NAME[m.cells[entry.shiftPos].finger]}`)); steps.push(h('span', { class: 'step key' }, `${FINGER_NAME[m.cells[entry.pos].finger]}${entry.layer ? ` · слой «${layerName(entry.layer)}»` : ''}`)); ui.hint.append(h('span', { class: 'steps' }, steps)); } if (ui.board) { paintBoard(ui.board, run.lang, entry ? entry.A : 1); markRoute(ui.board, entry); } renderLayerBar(entry); } function renderLayerBar(entry) { const m = vialModel(), bar = ui.layerbar; bar.innerHTML = ''; if (m.used.length < 2) return; const before = entry ? entry.Pbefore : 0, need = entry ? entry.A : 1; const V = window.Vial; bar.append(h('span', { class: 'muted small' }, 'Слой:')); m.used.forEach(l => { const isCur = l === 0 ? before === 0 : !!(before & V.bit(l)); const isNeed = l === 0 ? need === 1 : !!(need & V.bit(l)); bar.append(h('button', { class: 'lchip' + (isCur ? ' cur' : '') + (isNeed ? ' need' : ''), title: 'Нажмите, если клавиатура сейчас на этом слое: маршрут пересчитается', onclick: () => { assumeLayer(l); toast(`Считаю, что сейчас включён слой «${layerName(l)}»`); } }, layerName(l))); }); bar.append(h('span', { class: 'muted small' }, 'Клавиатура сама не сообщает слой: если сбились, нажмите на тот, где вы сейчас.')); } let audio; function beep() { if (!S.settings.sound) return; try { audio = audio || new (window.AudioContext || window.webkitAudioContext)(); const o = audio.createOscillator(), g = audio.createGain(); o.type = 'square'; o.frequency.value = 170; g.gain.value = 0.035; o.connect(g); g.connect(audio.destination); o.start(); o.stop(audio.currentTime + 0.07); } catch (e) { /* звук недоступен */ } } const fmtTime = sec => `${Math.floor(sec / 60)}:${pad2(Math.floor(sec % 60))}`; function updateLive() { if (!run || run.done || !ui.cpm) return; const el = run.t0 == null ? 0 : performance.now() - run.t0; ui.cpm.innerHTML = el > 1500 ? `${Math.round(run.i / (el / 60000))}зн/мин` : '–'; ui.acc.innerHTML = run.strokes ? `${Math.round((run.i / run.strokes) * 100)}%` : '100%'; ui.time.textContent = fmtTime(el / 1000); ui.err.textContent = run.errors; } function handleKey(e) { if (view === 'result') { if (e.key === 'Escape') { e.preventDefault(); go(lastRes.kind === 'lesson' ? 'lessons' : lastRes.kind === 'weak' ? 'stats' : 'practice'); } else if (e.key === 'Enter' && !(document.activeElement && document.activeElement.tagName === 'BUTTON')) { e.preventDefault(); lastRes.primary(); } return; } if (view !== 'train' || !run || run.done) return; if (e.key === 'Escape') { e.preventDefault(); restart(); return; } const altgr = e.getModifierState && e.getModifierState('AltGraph'); if ((e.ctrlKey || e.metaKey || e.altKey) && !altgr) return; if ([...e.key].length !== 1) return; // Shift, Backspace, Dead и т.п. e.preventDefault(); typeChar(e.key, e.code, e.getModifierState && e.getModifierState('CapsLock')); } function typeChar(ch, code, capsOn) { const exp = run.text[run.i]; const sc = scriptOf(ch); // Буква чужого алфавита - значит, включена не та раскладка. Это не ошибка набора. if (sc && sc !== run.lang) { ui.warn.textContent = `Сейчас включена ${sc === 'ru' ? 'русская' : 'английская'} раскладка, а нужна ${run.lang === 'ru' ? 'русская' : 'английская'}. Переключите её (Win+Пробел или Alt+Shift).`; ui.warn.classList.add('on'); return; } // Тот же символ, но другого регистра при включённом Caps Lock - не ошибка набора if (capsOn && ch !== exp && ch.toLowerCase() === exp.toLowerCase()) { ui.warn.textContent = 'Включён Caps Lock. Выключите его: тренер сам следит за заглавными буквами через Shift.'; ui.warn.classList.add('on'); return; } ui.warn.classList.remove('on'); const now = performance.now(); if (run.t0 == null) { run.t0 = now; run.lastOk = now; timer = setInterval(updateLive, 200); ui.go.remove(); } run.strokes++; const span = run.spans[run.i]; const ks = run.kst[exp] || (run.kst[exp] = { n: 0, err: 0, ms: 0, ln: 0 }); if (ch === exp) { const dt = now - run.lastOk; const swEntry = run.plan && run.plan[run.i] && run.plan[run.i].sw; if (run.i > 0 && dt < 5000 && run.plan) (swEntry ? run.swMs : run.normMs).push(dt); // Символы после переключения слоя не портят статистику клавиш и ровность ритма: их время считается отдельно if (run.i > 0 && dt < 3000 && !swEntry) { run.ints.push(dt); ks.ms += dt; ks.ln++; const l = run.lat[exp] || (run.lat[exp] = { sum: 0, n: 0 }); l.sum += dt; l.n++; } ks.n++; run.lastOk = now; const b = Math.floor((now - run.t0) / 1000); run.buckets[b] = (run.buckets[b] || 0) + 1; span.classList.remove('bad'); span.classList.add(span.dataset.err ? 'fix' : 'ok'); flashKey(code, 'hit'); run.i++; if (run.i >= run.text.length) { finish(now); return; } updateCursor(); } else { run.errors++; ks.err++; const ck = exp + '\u0001' + ch; run.conf[ck] = (run.conf[ck] || 0) + 1; span.dataset.err = '1'; span.classList.remove('bad'); void span.offsetWidth; span.classList.add('bad'); flashKey(code, 'miss'); beep(); } updateLive(); } /* ====================================================================== */ /* Завершение и результаты */ /* ====================================================================== */ const avg = a => a.reduce((s, x) => s + x, 0) / a.length; function finish(now) { stopTimer(); run.done = true; const n = run.text.length; const ms = Math.max(1, run.lastOk - run.t0); const cpm = n > 1 ? (n - 1) / (ms / 60000) : 0; const acc = (n / run.strokes) * 100; let consistency = null; const ints = run.ints.filter(x => x < 3000); if (ints.length >= 8) { const m = avg(ints), sd = Math.sqrt(avg(ints.map(x => (x - m) ** 2))); consistency = Math.max(0, Math.min(100, Math.round(100 * (1 - sd / m / 1.5)))); } const secs = Math.floor(ms / 1000), series = []; for (let s = 0; s < secs; s++) { const win = [run.buckets[s - 1], run.buckets[s], run.buckets[s + 1]].map(x => x || 0); series.push(Math.round(avg(win) * 60)); } const slow = Object.entries(run.lat).filter(([c, v]) => v.n >= 2 && c !== ' ').map(([c, v]) => ({ c, ms: v.sum / v.n })) .sort((a, b) => b.ms - a.ms).slice(0, 5); const errs = Object.entries(run.kst).filter(([, v]) => v.err > 0).map(([c, v]) => ({ c, n: v.err })).sort((a, b) => b.n - a.n).slice(0, 5); const conf = Object.entries(run.conf).map(([k, n]) => { const [exp, got] = k.split('\u0001'); return { exp, got, n }; }) .sort((a, b) => b.n - a.n).slice(0, 4); const res = { kind: run.kind, lang: run.lang, title: run.title, lesson: run.lesson || null, goal: run.goal, cpm, wpm: cpm / 5, acc, errors: run.errors, strokes: run.strokes, len: n, sec: ms / 1000, consistency, series, slow, errs, conf, stars: 0, record: false, prevBest: null, layer: null }; if (run.plan && run.plan.some(e => e.sw)) { const toggles = run.plan.filter(e => e.actions.some(a => a.kind === 'toggle' || a.kind === 'to')).length; const holds = run.plan.filter(e => e.actions.some(a => a.kind === 'hold' || a.kind === 'osl')).length; res.layer = { switches: toggles + holds, toggles, holds, prefer: run.prefer == null ? null : layerName(run.prefer), swMs: run.swMs.length >= 2 ? avg(run.swMs) : null, normMs: run.normMs.length >= 5 ? avg(run.normMs) : null, nSw: run.swMs.length }; } if (run.kind === 'lesson') { res.stars = rateStars(acc, cpm, run.goal); const p = S.progress[run.lesson.id] || (S.progress[run.lesson.id] = { best: 0, acc: 0, stars: 0, tries: 0 }); res.prevBest = p.tries ? p.best : null; res.record = p.tries > 0 && cpm > p.best && acc >= 90; p.tries++; p.best = Math.max(p.best, acc >= 90 ? cpm : 0); p.acc = Math.max(p.acc, acc >= 90 ? acc : 0); p.stars = Math.max(p.stars, res.stars); } // Общая статистика по клавишам const ksAll = S.keys[run.lang]; for (const [c, v] of Object.entries(run.kst)) { const a = ksAll[c] || (ksAll[c] = { n: 0, err: 0, ms: 0, ln: 0 }); a.n += v.n; a.err += v.err; a.ms += v.ms; a.ln += v.ln; } S.history.push({ t: Date.now(), id: run.lesson ? run.lesson.id : null, k: run.kind, lang: run.lang, cpm: +cpm.toFixed(1), acc: +acc.toFixed(1), err: run.errors, len: n, sec: +(ms / 1000).toFixed(1) }); if (S.history.length > 600) S.history.splice(0, S.history.length - 600); save(); if (window.Auth) window.Auth.afterRun(); // если выполнен вход, прогресс уйдёт на сервер // Что делать по Enter const lessons = run.lesson ? LESSONS[run.lang] : null; const nextL = lessons && res.stars > 0 ? lessons[run.lesson.index + 1] : null; res.primary = nextL ? () => startLesson(nextL.id) : () => restart(); res.nextL = nextL; lastRes = res; renderResult(); show('result'); } const tone = (v, good, mid) => (v >= good ? 'good' : v >= mid ? 'mid' : 'low'); function renderResult() { const r = lastRes, root = $('#v-result'); root.innerHTML = ''; let headline, sub; if (r.kind === 'lesson') { headline = r.stars === 3 ? 'Превосходно!' : r.stars === 2 ? 'Отлично!' : r.stars === 1 ? 'Урок пройден' : 'Нужно потренироваться'; sub = r.stars === 0 ? 'Точность ниже 90%. Печатайте медленнее: скорость вырастет сама, когда пальцы запомнят путь.' : r.acc < 95 ? 'Старайтесь печатать точнее: цель - не меньше 95%.' : r.stars < 3 ? 'Отличная точность. Теперь можно понемногу наращивать скорость.' : 'Скорость и точность на высоте.'; } else { headline = r.acc >= 97 ? 'Отличный результат!' : r.acc >= 92 ? 'Хороший результат' : 'Есть над чем поработать'; sub = r.acc < 92 ? 'Попробуйте снизить скорость и сосредоточиться на точности.' : 'Так держать.'; } root.append(h('div', { class: 'res-head' }, r.kind === 'lesson' ? h('div', null, starsEl(r.stars)) : null, h('h1', null, headline), h('p', { class: 'muted' }, `${LANG_NAME[r.lang]} · ${r.title}`), r.record ? h('span', { class: 'badge' }, '★ Личный рекорд') : null, h('p', { class: 'muted' }, sub))); const stat = (label, val, unit, extra, cls = '') => h('div', { class: 'stat ' + cls }, h('small', null, label), h('b', null, val, unit ? h('i', null, unit) : null), extra ? h('div', { class: 'sub' }, extra) : null); root.append(h('div', { class: 'cards' }, stat('Скорость', Math.round(r.cpm), 'зн/мин', `${r.wpm.toFixed(1)} слов/мин` + (r.prevBest ? ` · раньше ${Math.round(r.prevBest)}` : '')), stat('Точность', r.acc.toFixed(1), '%', `${r.strokes} нажатий`, tone(r.acc, 97, 92)), stat('Ошибки', r.errors, '', `на ${r.len} символов`, r.errors === 0 ? 'good' : ''), stat('Время', fmtTime(r.sec), '', `${r.len} символов`), r.consistency != null ? stat('Ровность ритма', r.consistency, '%', 'чем выше, тем стабильнее темп', tone(r.consistency, 70, 50)) : null)); if (r.kind === 'lesson') { const ok = (c, t) => h('div', { class: c ? 'y' : 'n' }, (c ? '✓ ' : '○ ') + t); root.append(h('div', { class: 'panel' }, h('h2', null, 'Звёзды'), h('div', { class: 'crit' }, ok(r.acc >= 90, 'Точность не менее 90%'), ok(r.acc >= 95 && r.cpm >= r.goal, `Точность не менее 95% и скорость от ${r.goal} зн/мин`), ok(r.acc >= 98 && r.cpm >= r.goal * 1.25, `Точность не менее 98% и скорость от ${Math.round(r.goal * 1.25)} зн/мин`)))); } if (r.layer) { const L = r.layer, cost = L.swMs != null && L.normMs != null ? L.swMs - L.normMs : null; root.append(h('div', { class: 'panel', style: 'margin-top:12px' }, h('h2', null, 'Переключение слоёв'), h('div', { class: 'cards', style: 'margin:12px 0 0' }, stat('Переключений', L.switches, '', `${L.toggles} включений слоя, ${L.holds} удержаний`), L.swMs != null ? stat('Символ после переключения', Math.round(L.swMs), 'мс', L.normMs != null ? `обычный символ: ${Math.round(L.normMs)} мс` : '') : null, cost != null ? stat('Цена переключения', (cost >= 0 ? '+' : '') + Math.round(cost), 'мс', cost > 400 ? 'много: тренируйте именно этот жест' : 'каждый раз против обычного символа', cost > 500 ? 'low' : cost > 250 ? 'mid' : 'good') : null), h('p', { class: 'muted small', style: 'margin:10px 0 0' }, L.prefer ? `Цифры и символы набирались через слой «${L.prefer}». ` : '', 'Тренер видит только набранные символы, а не нажатые клавиши слоя: время считается от предыдущего символа до символа сразу после переключения.'))); } if (r.series.length >= 3) { root.append(h('div', { class: 'panel', style: 'margin-top:12px' }, h('h2', null, 'Скорость по ходу упражнения'), h('div', { html: lineChart(r.series, { min: 0, fmt: v => Math.round(v), xl: ['0 с', `${r.series.length} с`], dots: false }) }))); } const kbd = c => h('kbd', null, c === ' ' ? '␣' : c); root.append(h('div', { class: 'two' }, h('div', { class: 'panel' }, h('h2', null, 'Самые медленные клавиши'), r.slow.length ? h('ul', { class: 'list' }, r.slow.map(x => h('li', null, kbd(x.c), h('span', { class: 'val' }, `${Math.round(x.ms)} мс`)))) : h('div', { class: 'empty' }, 'Мало данных')), h('div', { class: 'panel' }, h('h2', null, 'Где ошибались'), r.errs.length ? h('ul', { class: 'list' }, r.errs.map(x => h('li', null, kbd(x.c), h('span', { class: 'val' }, `${x.n} ${plural(x.n, 'ошибка', 'ошибки', 'ошибок')}`))), r.conf.length ? r.conf.map(x => h('li', { class: 'muted small' }, 'нужно ', kbd(x.exp), ' → нажато ', kbd(x.got), h('span', { class: 'val' }, '×' + x.n))) : null) : h('div', { class: 'empty' }, 'Без единой ошибки! 🎉')))); const acts = h('div', { class: 'actions' }); if (r.nextL) acts.append(h('button', { class: 'btn primary', onclick: r.primary }, `Дальше: ${r.nextL.title} →`)); acts.append(h('button', { class: r.nextL ? 'btn' : 'btn primary', onclick: restart }, '↻ Повторить')); acts.append(h('button', { class: 'btn', onclick: () => go(r.kind === 'lesson' ? 'lessons' : r.kind === 'weak' ? 'stats' : 'practice') }, r.kind === 'lesson' ? 'К урокам' : r.kind === 'weak' ? 'К статистике' : 'К практике')); root.append(acts, h('p', { class: 'muted small', style: 'text-align:center;margin-top:12px' }, h('kbd', null, 'Enter'), ' - продолжить, ', h('kbd', null, 'Esc'), ' - назад')); } const plural = (n, a, b, c) => { n = Math.abs(n) % 100; const m = n % 10; return n > 10 && n < 20 ? c : m > 1 && m < 5 ? b : m === 1 ? a : c; }; /* ---------- График ---------- */ function lineChart(vals, o = {}) { const n = vals.length; if (!n) return '
Пока нет данных
'; const W = 560, H = 190, L = 40, R = 14, T = 12, B = 26; const lo = Math.min(...vals), hi = Math.max(...vals); let min = o.min != null ? o.min : Math.max(0, Math.floor(lo * 0.9)), max = o.max != null ? o.max : Math.ceil(hi * 1.1); if (max - min < 4) max = min + 4; const fmt = o.fmt || (v => v); const x = i => L + (n === 1 ? (W - L - R) / 2 : (i * (W - L - R)) / (n - 1)); const y = v => T + (H - T - B) * (1 - (v - min) / (max - min)); const cls = o.cls || ''; let g = ''; for (let k = 0; k <= 4; k++) { const v = min + ((max - min) * k) / 4, yy = y(v); g += `${fmt(v)}`; } const pts = vals.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`); const line = n > 1 ? `` : ''; const dots = o.dots === false ? '' : vals.map((v, i) => `${fmt(v)}`).join(''); const xl = o.xl ? `${o.xl[0]}${o.xl[1]}` : ''; return `${g}${line}${dots}${xl}`; } /* ====================================================================== */ /* Экран: статистика */ /* ====================================================================== */ let statsLang = 'all', heatLang = null, heatMetric = 'err'; function renderStats() { const root = $('#v-stats'); root.innerHTML = ''; if (!heatLang) heatLang = S.settings.lang; const hist = S.history.filter(x => statsLang === 'all' || x.lang === statsLang); root.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Статистика'), h('p', { class: 'muted' }, 'Динамика скорости и точности, слабые места, история занятий.')), seg([['all', 'Все'], ['ru', 'Русский'], ['en', 'English']], statsLang, v => { statsLang = v; renderStats(); }))); if (!hist.length) { root.append(h('div', { class: 'panel empty' }, 'Здесь появится статистика после первого пройденного упражнения.')); } else { const last = hist.slice(-10); const totalSec = hist.reduce((s, x) => s + x.sec, 0), totalCh = hist.reduce((s, x) => s + x.len, 0); const stat = (l, v, u, sub) => h('div', { class: 'stat' }, h('small', null, l), h('b', null, v, u ? h('i', null, u) : null), sub ? h('div', { class: 'sub' }, sub) : null); root.append(h('div', { class: 'cards' }, stat('Упражнений', hist.length, '', `${totalCh} символов`), stat('Время за клавиатурой', fmtTime(totalSec), '', 'чистое время набора'), stat('Средняя скорость', Math.round(avg(last.map(x => x.cpm))), 'зн/мин', 'последние 10 упражнений'), stat('Рекорд скорости', Math.round(Math.max(...hist.filter(x => x.acc >= 90).map(x => x.cpm), 0)), 'зн/мин', 'при точности от 90%'), stat('Средняя точность', avg(last.map(x => x.acc)).toFixed(1), '%', 'последние 10 упражнений'))); const recent = hist.slice(-30); root.append(h('div', { class: 'two' }, h('div', { class: 'panel' }, h('h2', null, 'Скорость, зн/мин'), h('div', { html: lineChart(recent.map(x => x.cpm), { min: 0, fmt: v => Math.round(v), xl: ['', `последние ${recent.length}`] }) })), h('div', { class: 'panel' }, h('h2', null, 'Точность, %'), h('div', { html: lineChart(recent.map(x => x.acc), { min: Math.min(80, Math.floor(Math.min(...recent.map(x => x.acc)) / 5) * 5), max: 100, cls: 'b', fmt: v => Math.round(v), xl: ['', `последние ${recent.length}`] }) })))); } // Тепловая карта const weak = weakChars(heatLang); const heatPanel = h('div', { class: 'panel', style: 'margin-top:12px' }); heatPanel.append(h('div', { class: 'row between' }, h('h2', null, 'Карта клавиш'), h('div', { class: 'row' }, seg([['err', 'Ошибки'], ['speed', 'Медленные']], heatMetric, v => { heatMetric = v; renderStats(); }), seg([['ru', 'Русский'], ['en', 'English']], heatLang, v => { heatLang = v; renderStats(); })))); const kb = buildKeyboard(heatLang, { heat: true }); paintHeat(kb, heatLang); heatPanel.append(h('div', { class: 'kbhost' }, kb.el)); heatPanel.append(h('div', { class: 'legend' }, heatMetric === 'err' ? 'меньше ошибок' : 'быстрее', h('i'), heatMetric === 'err' ? 'больше ошибок' : 'медленнее')); heatPanel.append(h('div', { class: 'row between', style: 'margin-top:14px' }, h('div', { class: 'muted' }, weak.length ? ['Слабые клавиши: ', weak.map(w => h('kbd', { style: 'margin-right:4px' }, w.c))] : 'Пока мало данных: слабые клавиши появятся после нескольких упражнений.'), h('button', { class: 'btn primary', disabled: weak.length < 2, onclick: () => startWeak(heatLang) }, 'Тренировать слабые клавиши'))); root.append(heatPanel); if (hist.length) { const rows = hist.slice(-15).reverse(); root.append(h('div', { class: 'panel', style: 'margin-top:12px' }, h('h2', null, 'Последние упражнения'), h('div', { class: 'tbl' }, h('table', null, h('thead', null, h('tr', null, h('th', null, 'Когда'), h('th', null, 'Что'), h('th', { class: 'r' }, 'Зн/мин'), h('th', { class: 'r' }, 'Точность'), h('th', { class: 'r' }, 'Ошибки'), h('th', { class: 'r' }, 'Время'))), h('tbody', null, rows.map(x => { const L = x.id ? LESSON_BY_ID[x.id] : null; const what = L ? `${L.lang.toUpperCase()} · ${L.title}` : `${x.lang.toUpperCase()} · ${x.k === 'weak' ? 'слабые клавиши' : 'практика'}`; return h('tr', null, h('td', null, new Date(x.t).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })), h('td', null, what), h('td', { class: 'r' }, Math.round(x.cpm)), h('td', { class: 'r' }, x.acc.toFixed(1) + '%'), h('td', { class: 'r' }, x.err), h('td', { class: 'r' }, fmtTime(x.sec))); })))))); } root.append(h('div', { style: 'margin-top:20px' }, h('button', { class: 'btn danger', onclick: resetData }, 'Сбросить все данные'))); } function paintHeat(kb, lang) { const data = S.keys[lang] || {}; const metric = heatMetric; kb.el.style.setProperty('--hc', metric === 'err' ? 'var(--bad)' : 'var(--warn)'); Object.values(LAYOUT[lang].byCode).forEach(k => { const recs = [data[k.base], data[k.shift]].filter(Boolean); let heat = 0, has = false; if (metric === 'err') { const n = recs.reduce((s, r) => s + r.n, 0), er = recs.reduce((s, r) => s + r.err, 0); if (n + er >= 6) { has = true; heat = Math.min(1, er / (n + er) / 0.2); } } else { const ms = recs.reduce((s, r) => s + r.ms, 0), ln = recs.reduce((s, r) => s + r.ln, 0); if (ln >= 5) { has = true; heat = Math.max(0, Math.min(1, (ms / ln - 220) / 480)); } } (kb.map[k.code] || []).forEach(e => { e.style.setProperty('--heat', heat.toFixed(2)); if (!has) e.classList.add('nodata'); }); }); } function startWeak(lang) { const weak = weakChars(lang); if (weak.length < 2) { toast('Пока мало данных о слабых клавишах'); return; } startRun({ kind: 'weak', lang, title: 'Слабые клавиши', text: weakText(lang, weak), goal: 0 }); } async function resetData() { const account = !!(window.Auth && window.Auth.isLoggedIn()); const question = account ? 'Удалить прогресс, историю и статистику на этом устройстве и в вашем аккаунте на сервере? Это нельзя отменить. ' + 'На других устройствах, где вы входили, прогресс может вернуться при синхронизации: очистите его и там.' : 'Удалить весь прогресс, историю и статистику? Это нельзя отменить.'; if (!confirm(question)) return; // Без очистки на сервере прогресс вернулся бы при ближайшей синхронизации if (account && !(await window.Auth.clearServerProgress())) { toast('Не удалось очистить данные на сервере: попробуйте позже'); return; } const settings = S.settings, d = defaults(); S.progress = d.progress; S.history = d.history; S.keys = d.keys; S.settings = settings; save(); renderStats(); toast('Данные сброшены'); } /* ====================================================================== */ /* Экран: моя клавиатура (Vial) */ /* ====================================================================== */ let kbViewLayer = 0; function importVial(file) { const reader = new FileReader(); reader.onerror = () => toast('Не удалось прочитать файл'); reader.onload = () => { const r = window.Vial.parseFile(String(reader.result)); if (r.error) { toast(r.error); return; } S.vial = { file: file.name, layout: r.layout, names: [], enabled: true, split: true, numLayer: 'auto', labelLang: S.vial ? S.vial.labelLang : 'en' }; vialCache = null; const m = vialModel(); S.vial.names = m.layout.layers.map((_, l) => (m.used.includes(l) ? window.Vial.guessName(m, l) : '')); kbViewLayer = 0; save(); renderKeyboardPage(); toast(`Раскладка загружена: ${m.positions.length} клавиш, слоёв: ${m.used.length}`); }; reader.readAsText(file); } function renderKeyboardPage() { const root = $('#v-keyboard'); root.innerHTML = ''; root.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Моя клавиатура'), h('p', { class: 'muted' }, 'Загрузите раскладку из Vial: тренер покажет ваши клавиши и слои и подскажет, что и где нажимать, в том числе при переключении слоёв.')))); const input = h('input', { type: 'file', accept: '.vil,.json,application/json', class: 'sr-only', 'aria-label': 'Файл раскладки Vial', onchange: e => { if (e.target.files[0]) importVial(e.target.files[0]); e.target.value = ''; } }); root.append(input); const m = vialModel(); if (!m) { const zone = h('button', { class: 'drop', onclick: () => input.click() }, h('b', null, 'Выберите файл .vil или перетащите его сюда'), h('span', { class: 'muted small' }, 'В программе Vial: File → Save current layout. Файл остаётся у вас в браузере и никуда не отправляется.')); ['dragenter', 'dragover'].forEach(ev => zone.addEventListener(ev, e => { e.preventDefault(); zone.classList.add('over'); })); zone.addEventListener('dragleave', () => zone.classList.remove('over')); zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('over'); if (e.dataTransfer.files[0]) importVial(e.dataTransfer.files[0]); }); root.append(zone); root.append(h('div', { class: 'panel', style: 'margin-top:14px' }, h('h2', null, 'Что это даёт'), h('div', { class: 'crit', style: 'margin-top:10px' }, h('div', null, '• Экранная клавиатура рисуется по вашей клавиатуре: те же клавиши и те же пальцы.'), h('div', null, '• Если символ лежит на другом слое (цифры, скобки, знаки), тренер покажет, какую клавишу слоя нажать и где потом искать символ.'), h('div', null, '• Появляется раздел уроков «Слои вашей клавиатуры» и режим практики «Слои»: текст вперемешку с числами и символами.')))); return; } const V = S.vial, used = m.used; root.append(h('div', { class: 'panel row between' }, h('div', null, h('b', null, V.file || 'раскладка.vil'), h('div', { class: 'muted small' }, `Матрица ${m.R}×${m.C}, клавиш: ${m.positions.length}, слоёв: ${used.length} (в файле ${m.L})`)), h('div', { class: 'row' }, h('button', { class: 'btn', onclick: () => input.click() }, 'Заменить файл'), h('button', { class: 'btn danger', onclick: () => { if (confirm('Убрать загруженную раскладку?')) { S.vial = null; vialCache = null; save(); renderKeyboardPage(); } } }, 'Убрать')))); const set = (k, v) => { V[k] = v; if (k === 'split') vialCache = null; save(); renderKeyboardPage(); }; const row = (title, desc, ctl) => h('div', { class: 'setting' }, h('div', null, h('b', null, title), h('span', { class: 'muted small' }, desc)), ctl); const nl = window.Vial.guessNumLayer(m); const numSel = h('select', { 'aria-label': 'Слой с цифрами', style: 'width:auto', onchange: e => set('numLayer', e.target.value === 'auto' ? 'auto' : +e.target.value) }, h('option', { value: 'auto', selected: V.numLayer === 'auto' }, `Автоматически${nl != null ? ` (${layerName(nl)})` : ' (не найден)'}`), used.filter(l => l > 0).map(l => h('option', { value: l, selected: V.numLayer === l }, layerName(l)))); const nameInputs = h('div', { class: 'names' }, used.map(l => h('label', { class: 'nm' }, h('span', { class: 'muted small' }, `Слой ${l}`), h('input', { type: 'text', value: layerName(l), maxlength: 18, onchange: e => { V.names[l] = e.target.value.trim() || window.Vial.guessName(m, l); save(); renderKeyboardPage(); } })))); root.append(h('div', { class: 'panel', style: 'margin-top:12px' }, row('Использовать в тренировках', 'Экранная клавиатура и подсказки по слоям берутся из вашего файла', seg([[true, 'Вкл'], [false, 'Выкл']], V.enabled, v => set('enabled', v))), m.R % 2 === 0 ? row('Расположение клавиш', 'Первая половина строк матрицы - левая рука, вторая - правая (зеркально)', seg([[true, 'Две половинки'], [false, 'Один блок']], V.split, v => set('split', v))) : null, row('Слой с цифрами', 'Уроки на слои будут просить пользоваться именно им', numSel), row('Названия слоёв', 'Файл их не хранит - назовите как вам удобно', nameInputs))); // Просмотр слоёв if (!used.includes(kbViewLayer)) kbViewLayer = 0; const lang = V.labelLang || 'en'; const view = h('div', { class: 'panel', style: 'margin-top:12px' }); view.append(h('div', { class: 'row between' }, h('h2', null, 'Слои'), h('div', { class: 'row' }, seg(used.map(l => [l, layerName(l)]), kbViewLayer, v => { kbViewLayer = v; renderKeyboardPage(); }), seg([['en', 'English'], ['ru', 'Русский']], lang, v => set('labelLang', v))))); const board = buildBoard(m); paintBoard(board, lang, null, kbViewLayer); view.append(h('div', { class: 'kbhost' }, board.el), fingerLegend(), h('p', { class: 'muted small', style: 'text-align:center;margin:10px 0 0' }, 'Подписи показывают символ, который клавиша даёт при выбранной раскладке ОС. ▽ - прозрачная клавиша (работает как на слое ниже).')); root.append(view); // Какие символы недоступны const miss = {}; for (const l of ['en', 'ru']) miss[l] = window.Vial.unreachable(m, { lang: l, charOf }, Object.keys(LAYOUT[l].charMap)); root.append(h('div', { class: 'panel', style: 'margin-top:12px' }, h('h2', null, 'Доступность символов'), h('div', { class: 'crit', style: 'margin-top:10px' }, ['en', 'ru'].map(l => miss[l].length ? h('div', null, h('b', null, LANG_NAME[l] + ': '), 'нет способа набрать ', miss[l].map(c => h('kbd', { style: 'margin-right:4px' }, c))) : h('div', { class: 'y' }, `✓ ${LANG_NAME[l]}: все символы раскладки можно набрать (со слоями и Shift)`))))); root.append(h('div', { class: 'panel', style: 'margin-top:12px' }, h('h2', null, 'Как это работает'), h('div', { class: 'crit', style: 'margin-top:10px' }, h('div', null, '• Нажатия клавиш слоёв (TG, MO и т.п.) обрабатывает прошивка, до браузера они не доходят. Поэтому тренер считает, что вы выполняете его подсказки, а правильность проверяет по набранным символам.'), h('div', null, '• Если клавиатура оказалась на другом слое, чем думает тренер, нажмите на нужный слой над клавиатурой: маршрут пересчитается.'), h('div', null, '• Чтобы вынудить использовать слой, уроки раздела К выбирают для цифр и символов именно слой с цифрами, даже если цифры есть и на базовом.'), h('div', null, '• Для цифрового блока (KP_*) нужен включённый NumLock в системе.')))); } /* ====================================================================== */ /* Экран: настройки */ /* ====================================================================== */ function renderSettings() { const root = $('#v-settings'); root.innerHTML = ''; const set = (k, v) => { S.settings[k] = v; save(); applyTheme(); renderSettings(); }; const row = (title, desc, ctl) => h('div', { class: 'setting' }, h('div', null, h('b', null, title), h('span', { class: 'muted small' }, desc)), ctl); const toggle = (k) => seg([[true, 'Вкл'], [false, 'Выкл']], S.settings[k], v => set(k, v)); root.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Настройки')))); root.append(h('div', { class: 'panel' }, row('Тема', 'Оформление приложения', seg([['auto', 'Авто'], ['light', 'Светлая'], ['dark', 'Тёмная']], S.settings.theme, v => set('theme', v))), row('Экранная клавиатура', 'Подсвечивает следующую клавишу. Когда освоитесь - попробуйте выключить', toggle('showKb')), row('Цвета пальцев', 'Каждый палец своего цвета', toggle('fingerColors')), row('Вид клавиатуры', 'Сдвинутые ряды как на обычной или ровные колонки (ортолинейная)', seg([['stagger', 'Со сдвигом'], ['ortho', 'Ортолинейная']], S.settings.kbStyle, v => set('kbStyle', v))), row('Размер текста', 'Для строки, которую вы печатаете', seg([['s', 'Мелкий'], ['m', 'Средний'], ['l', 'Крупный']], S.settings.font, v => set('font', v))), row('Звук ошибки', 'Короткий сигнал при неверной клавише', toggle('sound')))); root.append(h('div', { class: 'panel', style: 'margin-top:12px' }, h('h2', null, 'Как считается'), h('div', { class: 'crit', style: 'margin-top:10px' }, h('div', null, h('b', null, 'Скорость'), ' - символов в минуту (зн/мин). Слов в минуту = зн/мин ÷ 5. Таймер идёт от первой клавиши до последней, ошибки отнимают время сами по себе.'), h('div', null, h('b', null, 'Точность'), ' - доля верных нажатий среди всех нажатий.'), h('div', null, h('b', null, 'Ровность ритма'), ' - насколько равномерны паузы между клавишами. Рваный темп - признак мест, которые пальцы ещё не выучили.'), h('div', null, h('b', null, 'Звёзды'), ' - ★ за точность от 90%, ★★ за 95% и целевую скорость урока, ★★★ за 98% и скорость на 25% выше цели.')))); } /* ====================================================================== */ /* Запуск */ /* ====================================================================== */ window.addEventListener('keydown', handleKey); applyTheme(); go('lessons'); // Аккаунт появляется только там, где рядом есть API (на сервере); локально сайт работает как раньше if (window.Auth) { window.Auth.init({ S, save, h, toast, go, addNav: label => { NAV.push(['account', label]); renderNav(view === 'train' || view === 'result' ? 'lessons' : view); }, setNavLabel: label => { const item = NAV.find(n => n[0] === 'account'); if (item) { item[1] = label; renderNav(view); } }, isView: name => view === name, refresh: () => { if (view === 'lessons') renderLessons(); else if (view === 'stats') renderStats(); }, }); } // Для отладочных проверок из консоли window.__trainer = { LESSONS, LAYOUT, makeText, practiceText, weakChars, S, charOf, vialModel }; })();