From 42b90a056c164def46bc30921ff20219584712ec Mon Sep 17 00:00:00 2001 From: darko Date: Sun, 11 Oct 2026 21:36:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BF=D0=BB=D0=B8=D1=82-=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D1=80:=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D0=B6=D1=91=D1=80=20=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BF=D0=BB=D0=B8=D1=82-=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Русская и английская раскладки, уроки от простых к сложным, цифры и знаки препинания, статистика, чтение раскладки Vial (.vil) и упражнения на переключение слоёв. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 4 + README.md | 16 + app.js | 1426 +++++++++++++++++++++++++++++++++++++++++++++++++ data.js | 224 ++++++++ index.html | 35 ++ styles.css | 303 +++++++++++ vial.js | 341 ++++++++++++ ИНСТРУКЦИЯ.md | 113 ++++ 8 files changed, 2462 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.js create mode 100644 data.js create mode 100644 index.html create mode 100644 styles.css create mode 100644 vial.js create mode 100644 ИНСТРУКЦИЯ.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87cdcfa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.claude/ +*.vil +Thumbs.db +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..237dce3 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Сплит-тренер + +Тренажёр слепой печати для сплит-клавиатур: русская и английская раскладки, цифры, знаки препинания, статистика, +чтение раскладки Vial (.vil) и упражнения на переключение слоёв. + +Без сборки и зависимостей: откройте `index.html` в браузере. + +Подробно: [ИНСТРУКЦИЯ.md](ИНСТРУКЦИЯ.md). + +## Файлы + +- `index.html` - запуск +- `app.js` - логика приложения +- `vial.js` - разбор файла Vial и маршруты по слоям +- `data.js` - раскладки, слова и тексты упражнений +- `styles.css` - оформление diff --git a/app.js b/app.js new file mode 100644 index 0000000..972769b --- /dev/null +++ b/app.js @@ -0,0 +1,1426 @@ +(() => { +'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 +}); +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; + } + } 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 })[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(); + + // Что делать по 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 }); +} + +function resetData() { + if (!confirm('Удалить весь прогресс, историю и статистику? Это нельзя отменить.')) 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'); + +// Для отладочных проверок из консоли +window.__trainer = { LESSONS, LAYOUT, makeText, practiceText, weakChars, S, charOf, vialModel }; +})(); diff --git a/data.js b/data.js new file mode 100644 index 0000000..373f3e4 --- /dev/null +++ b/data.js @@ -0,0 +1,224 @@ +/* Данные тренера: раскладки, списки слов и тексты для упражнений. + В русских текстах нет "ё", "«»" и "—": их нет на клавиатуре в привычных местах. */ +window.TD = { + /* Каждая клавиша - пара символов "без Shift" + "с Shift". Порядок = физические клавиши ряда. */ + keyRows: { + en: [ + "`~ 1! 2@ 3# 4$ 5% 6^ 7& 8* 9( 0) -_ =+", + "qQ wW eE rR tT yY uU iI oO pP [{ ]} \\|", + "aA sS dD fF gG hH jJ kK lL ;: '\"", + "zZ xX cC vV bB nN mM ,< .> /?" + ], + ru: [ + "ёЁ 1! 2\" 3№ 4; 5% 6: 7? 8* 9( 0) -_ =+", + "йЙ цЦ уУ кК еЕ нН гГ шШ щЩ зЗ хХ ъЪ \\/", + "фФ ыЫ вВ аА пП рР оО лЛ дД жЖ эЭ", + "яЯ чЧ сС мМ иИ тТ ьЬ бБ юЮ .," + ] + }, + + words: { + en: ` +a all ask add ads as dad sad fad fads lad lads fall falls flask flasks salad alfalfa sass lass +had has gas gash hash dash lash flash slash flag flags glad glass shall hall halls half hag hags gal gals gala gall shag asks +about above across after again against age ago air almost alone along already also always among and animal another answer any appear area arm around away +baby back bad ball bank base be bear beat beautiful because bed before begin behind believe below best better between big bird black blue board boat body book both box boy bread bring brother build busy buy +call came can car care carry case cat catch cause center chair change child children city class clean clear close cold color come common cook cool corner could country course cover cross cut +dark day dead deep did different dinner do dog door down draw dream dress drink drive drop dry during +each early earth east easy eat edge egg end enough even evening ever every eye +face fact family far farm fast father feel feet few field fight find fine finger fire first fish five floor flower fly follow food foot for forest forget form found four free friend from front full fun +game garden gave get girl give go gold gone good got grass great green ground group grow guess +hair hand happy hard hat have he head hear heart heavy help her here high hill his hold home hope horse hot hour house how however hundred hurry +idea if important in inside into iron is it job join jump just keep key kind king kitchen knew know +lady lake land large last late laugh lay lead learn leave left leg less let letter life light like line lip list listen little live long look lost lot loud love low +machine made main make man many map mark matter may me mean meet melt men middle might mile milk mind minute miss money month moon more morning most mother mountain mouth move much music must my +name near need never new next night nine no noise none north nose not note nothing notice now number +object ocean of off often oil old on once one only open or order other our out over own +page paint pair paper part party pass past path pay people pick picture piece place plan plant play please point pool poor power press pretty problem pull push put +quick quiet quite race rain ran reach read ready real red rest rich ride right ring river road rock room round rule run +sad safe said salt same sand sat saw say school sea season second see seem send sentence serve set seven several shape she ship shoe shop short should show side sign simple sing sister sit six size sky sleep slow small smile snow so soft some song soon sound south space speak special spell spend spring square stand star start stay step stick still stone stop store story street strong study such summer sun sure surface sweet +table take talk tall teach tell ten than thank that the their them then there these they thick thin thing think third this those though thought three through time tiny to today together told too took top toward town tree try turn two +under until up us use usual very voice wait walk wall want warm was wash watch water way we wear weather week weight well went were west what wheel when where which while white who whole why wide will wind window winter wish with without woman wonder wood word work world would write year yellow yes yet you young your +beautiful different important mountain together everything something sometimes remember question computer keyboard practice knowledge adventure afternoon yesterday tomorrow elephant restaurant telephone chocolate wonderful education information possibility +`, + ru: ` +ода вода папа дар жар вор вол вал лад ад ал ар овал аврал вдова водопад ложа падал фара дрова парад пара +яма мама имя кафе фея ива чума +торт горох голод дорого оно блюдо долго холод город ход год гол дно тон рот лорд +он она оно мы вы они мой твой наш ваш его ее их кто что где как так там тут вот вон еще уже или если но да нет не ни для про при над под без из от до за на по во об ко со бы же ли ну +дом мир год день ночь утро вечер час минута неделя месяц время дело жизнь рука нога голова глаз ухо нос рот лицо сердце слово речь место город село улица площадь двор сад парк лес поле луг гора река озеро море океан остров берег небо солнце луна звезда ветер дождь снег лед туман гроза облако земля вода огонь воздух камень песок трава цветок дерево лист корень птица рыба зверь собака кошка лошадь корова овца свинья мышь заяц волк лиса медведь +книга ручка тетрадь бумага стол стул кровать диван шкаф окно дверь стена пол потолок крыша комната кухня лампа часы телефон машина поезд самолет автобус дорога мост путь билет сумка ключ деньги цена магазин рынок хлеб молоко масло сыр мясо суп каша чай кофе сахар соль яблоко груша вишня лимон картофель морковь помидор огурец +семья мама папа брат сестра сын дочь муж жена друг подруга дед баба ребенок мальчик девочка мужчина женщина человек люди народ страна язык школа урок класс учитель ученик студент работа завод офис врач больница лекарство спорт игра футбол музыка песня танец картина фильм театр праздник подарок письмо новость вопрос ответ проблема идея мысль причина результат начало конец сила воля память мечта любовь радость страх правда сказка история +хороший плохой новый старый большой маленький высокий низкий длинный короткий широкий узкий светлый темный красный синий зеленый желтый белый черный серый быстрый медленный тихий громкий теплый холодный горячий сладкий горький кислый добрый злой умный сильный слабый веселый грустный красивый легкий трудный простой сложный свободный важный первый второй третий последний каждый другой самый такой +быть мочь сказать говорить знать стать идти хотеть видеть думать делать жить смотреть найти работать писать читать любить помнить понять ждать спросить ответить дать взять пойти начать держать слушать играть учить ходить бежать лежать сидеть стоять спать есть пить петь рисовать строить открыть закрыть купить продать помочь забыть решить спешить +когда тогда сейчас потом всегда никогда здесь там где куда откуда почему зачем очень сегодня завтра вчера рядом далеко близко быстро медленно хорошо плохо много мало +один два три четыре пять шесть семь восемь девять десять сто тысяча +клавиатура правильный интересный путешествие предложение произведение удовольствие разговаривать определенный замечательный самостоятельно образование информация возможность +объект подъезд объем съезд щука борщ товарищ площадь вещь общий пища ищу цирк цель центр цвет цена улица птица лицо юг юбка июнь июль яблоко ящик язык эхо этаж экран энергия эпоха +` + }, + + text: { + en: { + easy: [ + "The cat sat on the mat.", "We all like to read books.", "She has a red car.", "It is a sunny day.", + "The dog runs in the park.", "He drinks tea every morning.", "Birds sing in the spring.", "I have two brothers.", + "My house is near the river.", "They are playing a game.", "We went to the shop.", "The sun is bright today.", + "Snow falls in winter.", "Please open the window." + ], + caps: [ + "Anna and Peter live in London.", "Yesterday John flew to Paris.", "Mary works at a school in Boston.", + "The Nile flows through Egypt.", "Tom loves New York in the fall.", "We will visit Rome in July.", + "Mount Everest is the highest peak.", "Lucy and Oliver went to Tokyo.", "Canada is north of the United States.", + "Sarah reads Shakespeare on Sundays." + ], + p1: [ + "In the morning I drink tea, and in the evening I drink coffee.", "He came in, sat down, and opened the book.", + "When it rained, we stayed at home.", "I like autumn, because it is beautiful.", + "We bought bread, milk, cheese, and apples.", "If you want success, you must work.", + "She knew that he would come.", "First we ate, then we went for a walk.", + "The weather is nice, but the wind is cold.", "I do not know where he is, or what he does." + ], + p2: [ + "What is your name?", "Where are you going?", "Hooray, we won!", "What time is it?", "What are you doing tonight?", + "Careful, the road is slippery!", "Why are you so quiet?", "Will you come tomorrow?", "What a wonderful day!", + "Where is the station?", "Please, help me!", "Is that really true?" + ], + p3: [ + "He bought three things: a book, a pen, and a notebook.", "One wanted to sleep; the other wanted to eat.", + "Summer - the best season of the year.", "Here is my plan: wake up early, eat, and leave.", + "We waited for a long time; nobody came.", "Remember the main thing: patience and work.", + "A cat is a pet - it is soft and warm.", "She said: come tomorrow.", "It was a well-known, old-fashioned song." + ], + p4: [ + "He said: \"I will be back soon.\"", "The book \"Moby Dick\" is very long.", "My friend (he lives nearby) often visits us.", + "She answered: \"Of course, come in!\"", "It's a nice day, isn't it?", "I don't know what you're talking about.", + "The store (on the corner) has fresh bread.", "They said they'd come, but they haven't." + ], + p5: [ + "Wait, what? You've finished already - that's impressive!", "Dear Anna, thank you for the gift (it's lovely); see you on Friday!", + "\"Why not?\" he asked. \"Because it's late,\" she said.", "Ingredients: flour, sugar, eggs, and milk (2 cups).", + "Is it true? Yes - and it's only the beginning!", "Note: the shop opens at 9:30; it closes at 6.", + "Hurry up, the train leaves at 7:45!", "She whispered, \"Don't tell anyone (not even Tom).\"" + ], + para: [ + "Autumn came quietly. The leaves on the trees turned yellow and red, and in the mornings a light fog hangs over the river. People put on warm jackets, and children happily collect the prettiest leaves in the park.", + "A good habit is something we do every day without thinking. To learn to type fast, you do not need to practice for hours. Twenty minutes a day is enough, as long as you practice regularly and carefully.", + "A split keyboard lets you keep your hands at shoulder width. Your shoulders stay open and your wrists stay straight. At first typing feels strange, but after a couple of weeks your fingers remember the new positions of the keys.", + "There is an old library in our town. It is quiet, and it smells of paper and a little of coffee from the small cafe on the ground floor. In the evenings students come here to prepare for exams, and older people come to read the newspapers.", + "The sea was calm, and the boat slid slowly over the water. Far away we could see mountains covered with snow, and above our heads the gulls were crying. We were silent for a long time because words were not needed.", + "Every journey begins with a first step. Do not be afraid of mistakes: they show you what to work on. Accuracy is more important than speed, because speed comes by itself when your fingers know the way." + ], + num: [ + "In {y} there were {n} people in the hall.", "Call us at {ph} after {t}.", "The ticket costs ${p}, seat {m}, row {s}.", + "Meeting on {d} at {t}.", "Order number {n} was sent on {d}.", "Room {m} is on floor {s}.", "It is {n} miles to the town.", + "Page {s} of {m}.", "Her phone number is {ph}.", "The year {y} was long.", "We sold {n} items for ${p}." + ], + sym1: [ + "user@example.com", "#typing #practice #goals", "Total: $45.50 (20% off)", "Email john.doe@mail.org today", + "Save 15% on orders over $100", "@anna and @peter joined #team", "Price: $9.99 plus 8% tax", "50% of $200 is $100" + ], + sym2: [ + "2 + 2 = 4", "a^2 + b^2 = c^2", "Tom & Jerry", "snake_case_name = 5 * 6", "(a + b) * c = ac + bc", + "x = y + 1; z = x * 2", "R&D and Q&A teams", "total = (price * 3) + tax" + ], + sym3: [ + "array[0] = {1, 2, 3};", "C:\\Users\\Admin\\Documents", "a | b / c ~ d", "
", "~/projects/app/src", + "if (x > 5 && y < 3) { run(); }", "use `code` for inline text", "path/to/file.txt | sort > out.txt" + ], + /* Упражнения на слои: много цифр и символов, которых нет на базовом слое */ + lsym: [ + "a[3] = {1, 2, 3};", "x = y + 12 * (z - 4);", "path = \"C:\\\\temp\\\\log_07.txt\";", "arr[0] = arr[1] | 255;", + "total = [10, 20, 30]; sum = 60;", "if (n == 42) { ok = 1; }", "id=7&page=15&size=200", "cfg = { w: 640, h: 480 };", + "~/dev/app_2/src/main.c", "`npm run build` takes 90 sec", "a1 = [1, 2]; a2 = [3, 4]; // 14", "v = (a + b) * [c - 5] / 8;" + ], + lcode: [ + "for (let i = 0; i < 100; i++) { sum += arr[i]; }", "const rows = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];", + "if (a[2] >= 10 && b[3] <= 20) { total = a[2] + b[3]; }", "SELECT id, price FROM items WHERE price > 100;", + "x = {\"a\": 1, \"b\": [2, 3], \"c\": 4};", "let mask = (flags & 0xFF) | 0x10;", "path = `/api/v2/users/${id}/posts/5`;", + "while (n > 0) { s += n % 10; n = n / 10; }" + ], + ltext: [ + "In 2024 we sold 1250 books, 340 maps, and 78 posters for $4521.", "Room 214 is on floor 2; the key is in box 37.", + "Flight 805 leaves at 06:45 and lands at 09:10 on 12/05/2025.", "Call 555-0142 before 5, or 555-0187 after 6.", + "The recipe needs 3 eggs, 250 g of flour, 2 cups of milk, and 1 pinch of salt.", "Chapter 12 starts on page 187 and ends on page 203.", + "Order #4821: 6 items, total $93.50, delivery in 3 days.", "Score: 3 - 2 after 90 minutes, then 4 - 3 on penalties." + ], + code: [ + "for (let i = 0; i < 10; i++) { sum += i; }", "const name = 'Alex';", "function add(a, b) { return a + b; }", + "if (a >= b) { print(\"yes\"); } else { print(\"no\"); }", "SELECT * FROM users WHERE age > 18;", + "Home", "#include ", "x = [1, 2, 3]; y = {\"a\": 1};", + "git commit -m \"fix bug\"", "background: #ff8800; margin: 0 auto;" + ] + }, + + ru: { + easy: [ + "Мама мыла раму.", "Это наш дом.", "Зимой идет снег.", "Кошка спит на диване.", "Мы пошли в лес.", "Солнце светит ярко.", + "Он читает интересную книгу.", "Дети играют во дворе.", "Вечером мы пили чай.", "На столе лежит хлеб.", + "Река течет медленно.", "Птицы поют весной.", "В саду растут яблони.", "Брат купил новый телефон." + ], + caps: [ + "Анна и Борис живут в Москве.", "Вчера Иван приехал из Казани.", "Мария Петровна работает в школе.", + "Волга впадает в Каспийское море.", "Антон любит Санкт-Петербург.", "Летом мы поедем в Сочи.", + "Дмитрий Сергеевич читает Пушкина.", "Байкал самое глубокое озеро в мире.", "Олег и Ольга пошли в Эрмитаж.", + "Россия - самая большая страна." + ], + p1: [ + "Утром я пью чай, а вечером кофе.", "Он вошел в комнату, сел за стол и открыл книгу.", "Когда пошел дождь, мы остались дома.", + "Я люблю осень, потому что она красивая.", "Мы купили хлеб, молоко, сыр и яблоки.", "Если хочешь успеха, надо работать.", + "Он знал, что она придет.", "Сначала мы поели, потом пошли гулять.", "Погода хорошая, но ветер холодный.", + "Я не знаю, где он, и что он делает." + ], + p2: [ + "Как тебя зовут?", "Куда ты идешь?", "Ура, мы победили!", "Сколько сейчас времени?", "Что ты делаешь вечером?", + "Осторожно, дорога скользкая!", "Почему ты молчишь?", "Не может быть!", "Ты придешь завтра?", "Какой прекрасный день!", + "Где находится вокзал?", "Помогите, пожалуйста!" + ], + p3: [ + "Он купил три вещи: книгу, ручку и тетрадь.", "Один хотел спать; другой хотел есть.", "Москва - столица России.", + "Вот мой план: встать рано, позавтракать и выйти.", "Лето - лучшее время года.", "Мы ждали долго; никто не пришел.", + "Помни главное: терпение и труд.", "Кошка - домашнее животное.", "Она сказала: приходи завтра." + ], + p4: [ + "Он сказал: \"Я скоро вернусь\".", "Книга \"Война и мир\" очень длинная.", "Мой друг (он живет рядом) часто заходит в гости.", + "Она ответила: \"Конечно, приходи!\"", "Фильм \"Ирония судьбы\" смотрят в Новый год.", "В магазине (на углу) есть свежий хлеб.", + "Он прочитал статью (очень длинную) за час.", "Песня \"Катюша\" известна всем." + ], + p5: [ + "Подожди, что? Ты уже закончил - это здорово!", "Дорогая Анна, спасибо за подарок (он прекрасен); до встречи в пятницу!", + "\"Почему бы и нет?\" - спросил он. \"Потому что поздно\", - ответила она.", "Ингредиенты: мука, сахар, яйца и молоко (2 стакана).", + "Правда ли это? Да - и это только начало!", "Внимание: магазин открывается в 9:30; закрывается в 18:00.", + "Скорее, поезд отправляется в 7:45!", "Она шепнула: \"Никому не говори (даже Тому).\"" + ], + para: [ + "Осень пришла незаметно. Листья на деревьях стали желтыми и красными, а по утрам над рекой стоит легкий туман. Люди надели теплые куртки, а дети с радостью собирают в парке самые красивые листья.", + "Хорошая привычка - это то, что мы делаем каждый день, не думая. Чтобы научиться быстро печатать, не нужно заниматься по много часов. Достаточно двадцати минут в день, но регулярно и внимательно.", + "Сплит-клавиатура позволяет держать руки на ширине плеч. Плечи не сгибаются вперед, а запястья остаются прямыми. Сначала печатать непривычно, но через пару недель пальцы запоминают новое положение клавиш.", + "В нашем городе есть старая библиотека. В ней тихо, пахнет бумагой и немного кофе из маленькой кофейни на первом этаже. По вечерам здесь собираются студенты, чтобы подготовиться к экзаменам, и пожилые люди, чтобы почитать газеты.", + "Море было спокойным, и лодка медленно скользила по воде. Вдали виднелись горы, покрытые снегом, а над головой кричали чайки. Мы долго молчали, потому что слова были не нужны.", + "Каждый путь начинается с первого шага. Не бойтесь ошибок: они показывают, над чем стоит поработать. Точность важнее скорости, потому что скорость приходит сама, когда пальцы знают дорогу." + ], + num: [ + "В {y} году в зале было {n} человек.", "Звоните по телефону {ph} после {t}.", "Билет стоит {p} руб., ряд {s}, место {m}.", + "Встреча состоится {d} в {t}.", "Заказ номер {n} отправлен {d}.", "Комната {m} находится на {s} этаже.", + "До города {n} километров.", "Страница {s} из {m}.", "Ее номер телефона {ph}.", "Мы продали {n} штук за {p} руб." + ], + sym1: [ + "Заказ № 45 готов.", "Скидка 20% на все товары.", "Результат: 5 * 4 = 20.", "Счет матча 3 : 2 в нашу пользу.", + "Что? Где? Когда?", "(1) первый; (2) второй; (3) третий.", "100% гарантия качества!", "Билет № 17, место 24; цена 350 руб." + ], + ltext: [ + "В 2024 году мы продали 1250 книг, 340 карт и 78 плакатов на сумму 4521 руб.", "Комната 214 на втором этаже; ключ лежит в ячейке 37.", + "Рейс 805 вылетает в 06:45 и прилетает в 09:10 12.05.2025.", "Звоните 8-495-123-45-67 до 17:00 или 8-495-765-43-21 после 18:00.", + "Для рецепта нужно 3 яйца, 250 г муки, 2 стакана молока и 1 щепотка соли.", "Глава 12 начинается на странице 187 и заканчивается на 203.", + "Заказ № 4821: 6 товаров, итого 9350 руб. (скидка 15%), доставка через 3 дня.", "Счет 3 : 2 после 90 минут, потом 4 : 3 по пенальти." + ], + sym2: [ + "Звоните: 8-495-123-45-67; заказ № 7788.", "Дата: 12.05.2024; время: 14:30; зал 5.", "Выбирайте: да/нет, и/или, 50/50.", + "Формула: 2 + 3 * 4 = 14 (проверьте!).", "Цена: 1 200 руб. (скидка 15%).", "Рейс № 305: вылет 06:45, прилет 09:10.", + "Ответ: а = 5; б = 7 (всего 12).", "Адрес: ул. Ленина, д. 15, кв. 47." + ] + } + } +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..5a09376 --- /dev/null +++ b/index.html @@ -0,0 +1,35 @@ + + + + + + Сплит-тренер + + + + +
+
+ + Сплит-тренер +
+ +
+ +
+
+
+
+
+
+
+
+
+ +
+ + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..4e23ba8 --- /dev/null +++ b/styles.css @@ -0,0 +1,303 @@ +/* ---------- Темы ---------- */ +:root { + --bg: #f4f5f8; --panel: #ffffff; --panel2: #f0f2f7; --fg: #1b2130; --muted: #5d6779; --dim: #aab2c0; + --line: #e1e5ed; --accent: #3b6cff; --accent-fg: #fff; --accent-soft: rgba(59,108,255,.14); + --ok: #17996a; --warn: #d28a00; --bad: #e0443e; + --keybg: #ffffff; --keyline: #cfd5e0; --ks: 78%; --kl: 90%; + --shadow: 0 1px 2px rgba(20,30,60,.06), 0 6px 20px rgba(20,30,60,.05); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #11141b; --panel: #1a1f2a; --panel2: #222836; --fg: #e8ecf4; --muted: #9aa5ba; --dim: #4f596d; + --line: #2b3242; --accent: #6b8cff; --accent-fg: #0b1020; --accent-soft: rgba(107,140,255,.18); + --ok: #4cd08b; --warn: #f0b429; --bad: #ff6b6b; + --keybg: #232a38; --keyline: #323b4e; --ks: 36%; --kl: 24%; + --shadow: 0 1px 2px rgba(0,0,0,.3), 0 6px 20px rgba(0,0,0,.25); + } +} +:root[data-theme="dark"] { + --bg: #11141b; --panel: #1a1f2a; --panel2: #222836; --fg: #e8ecf4; --muted: #9aa5ba; --dim: #4f596d; + --line: #2b3242; --accent: #6b8cff; --accent-fg: #0b1020; --accent-soft: rgba(107,140,255,.18); + --ok: #4cd08b; --warn: #f0b429; --bad: #ff6b6b; + --keybg: #232a38; --keyline: #323b4e; --ks: 36%; --kl: 24%; + --shadow: 0 1px 2px rgba(0,0,0,.3), 0 6px 20px rgba(0,0,0,.25); +} + +* { box-sizing: border-box; } +html { color-scheme: light dark; } +:root[data-theme="light"] { color-scheme: light; } +:root[data-theme="dark"] { color-scheme: dark; } +body { + margin: 0; background: var(--bg); color: var(--fg); + font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; +} +button { font: inherit; color: inherit; cursor: pointer; } +h1, h2, h3 { margin: 0; line-height: 1.2; } +h1 { font-size: 26px; } +h2 { font-size: 18px; } +.muted { color: var(--muted); } +.small { font-size: 13px; } +kbd { + font: 600 .9em ui-monospace, "Cascadia Mono", Consolas, monospace; background: var(--panel2); + border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 0 .4em; +} + +/* ---------- Шапка и навигация ---------- */ +.top { + position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 10px max(16px, calc((100vw - 1120px) / 2)); background: color-mix(in srgb, var(--panel) 92%, transparent); + backdrop-filter: blur(8px); border-bottom: 1px solid var(--line); +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; color: var(--accent); } +.brand span { color: var(--fg); } +#nav { display: flex; gap: 4px; flex-wrap: wrap; } +#nav button { + border: 0; background: none; padding: 8px 14px; border-radius: 10px; color: var(--muted); font-weight: 550; +} +#nav button:hover { background: var(--panel2); color: var(--fg); } +#nav button.on { background: var(--accent-soft); color: var(--accent); } + +main { max-width: 1120px; margin: 0 auto; padding: 24px 16px 60px; } +.view { display: none; } +.view.on { display: block; animation: fade .18s ease-out; } +@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } + +/* ---------- Общие элементы ---------- */ +.btn { + border: 1px solid var(--line); background: var(--panel); border-radius: 10px; padding: 9px 16px; font-weight: 600; + transition: background .12s, transform .06s, border-color .12s; +} +.btn:hover { border-color: var(--accent); } +.btn:active { transform: translateY(1px); } +.btn.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } +.btn.primary:hover { filter: brightness(1.07); } +.btn.ghost { background: none; border-color: transparent; color: var(--muted); } +.btn.ghost:hover { background: var(--panel2); color: var(--fg); } +.btn.danger { color: var(--bad); } +.btn:focus-visible, .card:focus-visible, .seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 18px 20px; box-shadow: var(--shadow); } +.panel > h2 + * { margin-top: 12px; } +.row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.row.between { justify-content: space-between; } +.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 18px; } +.page-head p { margin: 6px 0 0; } + +.seg { display: inline-flex; background: var(--panel2); border-radius: 11px; padding: 3px; gap: 2px; } +.seg button { border: 0; background: none; padding: 6px 14px; border-radius: 9px; font-weight: 600; color: var(--muted); } +.seg button.on { background: var(--panel); color: var(--fg); box-shadow: 0 1px 3px rgba(0,0,0,.15); } + +.tag { display: inline-block; padding: 1px 9px; border-radius: 99px; background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 700; } +.stars { letter-spacing: 1px; color: var(--dim); white-space: nowrap; } +.stars .f { color: #f5b301; } + +#toast { + position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 20px); opacity: 0; pointer-events: none; + background: var(--fg); color: var(--bg); padding: 10px 18px; border-radius: 12px; font-weight: 550; transition: .2s; z-index: 50; +} +#toast.on { opacity: 1; transform: translate(-50%, 0); } + +/* ---------- Уроки ---------- */ +.tip { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 20px; padding: 12px 16px; background: var(--accent-soft); border-radius: 12px; font-size: 14px; } +.progress-line { height: 8px; border-radius: 99px; background: var(--panel2); overflow: hidden; } +.progress-line > i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), var(--ok)); border-radius: 99px; transition: width .3s; } +.cont { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 26px; } +.cont .grow { flex: 1 1 260px; } +.sec { margin-top: 28px; } +.sec-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; } +.sec-head .letter { + display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--accent-fg); + font-weight: 800; font-size: 14px; +} +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; } +.card { + text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; + display: flex; flex-direction: column; gap: 6px; min-height: 112px; transition: border-color .12s, transform .1s, box-shadow .12s; +} +.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); } +.card.next { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.card .num { font-size: 12px; color: var(--muted); font-weight: 650; display: flex; justify-content: space-between; } +.card .ttl { font-weight: 700; font-size: 16px; } +.card .dsc { font-size: 13px; color: var(--muted); flex: 1; } +.card .best { font-size: 12px; color: var(--muted); min-height: 16px; } + +/* ---------- Практика / настройки ---------- */ +.form { display: grid; gap: 18px; max-width: 640px; } +.field > label, .field > .lbl { display: block; font-weight: 650; margin-bottom: 6px; } +select, textarea { + width: 100%; font: inherit; color: var(--fg); background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; +} +textarea { min-height: 120px; resize: vertical; } +.checks { display: flex; gap: 8px 20px; flex-wrap: wrap; } +.check { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.check input { width: 18px; height: 18px; accent-color: var(--accent); } +.setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; } +.setting:last-child { border-bottom: 0; } +.setting b { display: block; } + +/* ---------- Экран тренировки ---------- */ +.train-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } +.train-title { display: flex; align-items: center; gap: 10px; font-size: 17px; } +.chips { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 12px; } +.chip { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 8px 14px; } +.chip small { display: block; color: var(--muted); font-size: 12px; } +.chip b { font-size: 22px; font-variant-numeric: tabular-nums; } +.chip b i { font-style: normal; font-size: 13px; color: var(--muted); font-weight: 500; margin-left: 3px; } +.bar { height: 5px; background: var(--panel2); border-radius: 99px; overflow: hidden; margin-bottom: 14px; } +.bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .12s; } +.warn { + display: none; margin-bottom: 12px; padding: 10px 14px; border-radius: 10px; font-weight: 550; + background: color-mix(in srgb, var(--warn) 18%, transparent); border: 1px solid var(--warn); +} +.warn.on { display: block; } + +.textbox { + --tfs: 28px; position: relative; overflow: hidden; scroll-behavior: smooth; background: var(--panel); border: 1px solid var(--line); + border-radius: 16px; padding: 0 22px; font: 500 var(--tfs)/1.9 ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace; + height: calc(var(--tfs) * 1.9 * 3 + 2px); box-shadow: var(--shadow); cursor: text; user-select: none; +} +.textbox.fs-s { --tfs: 22px; } .textbox.fs-l { --tfs: 34px; } +.textbox .w { display: inline-block; white-space: nowrap; } +.ch { color: var(--dim); border-radius: 4px; transition: color .1s; } +.ch.sp { display: inline-block; width: .62em; } +.ch.ok { color: var(--fg); opacity: .5; } +.ch.fix { color: var(--warn); } +.ch.cur { background: var(--accent-soft); color: var(--fg); box-shadow: 0 2px 0 var(--accent); } +.ch.cur.bad { animation: shake .22s; background: color-mix(in srgb, var(--bad) 30%, transparent); box-shadow: 0 2px 0 var(--bad); } +@keyframes shake { 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } } +.textbox .go { + position: absolute; right: 12px; bottom: 6px; font: 500 12px system-ui, sans-serif; color: var(--muted); + background: var(--panel); padding: 1px 8px; border-radius: 6px; border: 1px solid var(--line); +} + +.hint { display: flex; align-items: center; gap: 12px; margin: 14px 2px; color: var(--muted); min-height: 36px; flex-wrap: wrap; } +.hint .big { + display: inline-grid; place-items: center; min-width: 38px; height: 36px; padding: 0 10px; border-radius: 9px; background: var(--accent); + color: var(--accent-fg); font: 700 20px ui-monospace, "Cascadia Mono", Consolas, monospace; +} +.kbhost { margin-top: 6px; overflow-x: auto; padding-bottom: 6px; } +.foot-hint { text-align: center; margin-top: 10px; } + +/* ---------- Виртуальная клавиатура ---------- */ +.kb { --u: clamp(13px, calc((100vw - 64px) / 21), 46px); --gapc: calc(var(--u) * 1.5); + display: flex; justify-content: center; gap: var(--gapc); user-select: none; width: max-content; margin: 0 auto; } +.half { display: flex; flex-direction: column; gap: 4px; } +.krow { display: flex; gap: 4px; height: var(--u); } +.key, .spacer { width: calc(var(--w, 1) * (var(--u) + 4px) - 4px); flex: none; } +.spacer { height: 1px; } +.key { + position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1.05; + background: var(--keybg); border: 1px solid var(--keyline); border-bottom-width: 3px; border-radius: calc(var(--u) * .2); + font: 600 calc(var(--u) * .4)/1 system-ui, sans-serif; color: var(--fg); transition: background .08s, transform .08s, box-shadow .08s; +} +.key .s { font-size: .68em; color: var(--muted); } +.key.mod { color: var(--dim); font-size: calc(var(--u) * .27); font-weight: 500; } +.fc .key[data-f] { background: hsl(var(--h) var(--ks) var(--kl)); border-color: hsl(var(--h) calc(var(--ks) * .8) calc(var(--kl) - 12%)); } +.key[data-f="lp"] { --h: 350; } .key[data-f="lr"] { --h: 24; } .key[data-f="lm"] { --h: 48; } .key[data-f="li"] { --h: 140; } +.key[data-f="ri"] { --h: 190; } .key[data-f="rm"] { --h: 222; } .key[data-f="rr"] { --h: 268; } .key[data-f="rp"] { --h: 318; } +.key[data-f="th"] { --h: 215; } +.key.bump::after { content: ""; position: absolute; bottom: 4px; width: 38%; height: 2px; border-radius: 2px; background: currentColor; opacity: .55; } +.krow.th { height: calc(var(--u) * .9); } +.half.left .krow.th { justify-content: flex-end; } .half.right .krow.th { justify-content: flex-start; } +.ortho .spacer { display: none; } +.ortho .key.mod:not(.space) { --w: 1 !important; } +.ortho .key[data-code="Backslash"] { --w: 1 !important; } + +.key.next, .fc .key.next { + background: hsl(var(--h, 220) 92% 56%); border-color: hsl(var(--h, 220) 92% 40%); color: #fff; transform: translateY(-2px); + box-shadow: 0 0 0 3px hsl(var(--h, 220) 92% 56% / .35), 0 4px 14px hsl(var(--h, 220) 92% 40% / .5); z-index: 1; +} +.key.next .s { color: #fff; opacity: .85; } +.key.mod.next { --h: 220; } +.key.hit { background: color-mix(in srgb, var(--ok) 55%, var(--keybg)) !important; transform: translateY(1px); } +.key.miss { background: color-mix(in srgb, var(--bad) 65%, var(--keybg)) !important; transform: translateY(1px); } +.kb.heat .key:not(.mod) { background: color-mix(in oklab, var(--hc, var(--bad)) calc(var(--heat, 0) * 100%), var(--keybg)); border-color: var(--keyline); } +.kb.heat .key.nodata { opacity: .55; } +.legend { display: flex; align-items: center; gap: 8px; justify-content: center; margin-top: 10px; font-size: 12px; color: var(--muted); } +.legend i { display: block; width: 120px; height: 8px; border-radius: 99px; background: linear-gradient(90deg, var(--keybg), var(--hc, var(--bad))); border: 1px solid var(--keyline); } +.finger-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; justify-content: center; margin-top: 10px; font-size: 12px; color: var(--muted); } +.finger-legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; background: hsl(var(--h) 70% 55%); vertical-align: -1px; } + +/* ---------- Результат ---------- */ +.res-head { text-align: center; margin: 6px 0 22px; } +.res-head .stars { font-size: 38px; margin: 6px 0; } +.res-head .badge { display: inline-block; margin-top: 6px; padding: 3px 12px; border-radius: 99px; background: #f5b301; color: #2a2000; font-weight: 800; font-size: 13px; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; } +.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; box-shadow: var(--shadow); } +.stat small { display: block; color: var(--muted); font-size: 12.5px; } +.stat b { font-size: 32px; font-variant-numeric: tabular-nums; line-height: 1.2; } +.stat b i { font-style: normal; font-size: 14px; font-weight: 500; color: var(--muted); margin-left: 4px; } +.stat .sub { font-size: 12.5px; color: var(--muted); } +.stat.good b { color: var(--ok); } .stat.mid b { color: var(--warn); } .stat.low b { color: var(--bad); } +.two { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-top: 12px; } +.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.list li { display: flex; align-items: center; gap: 10px; } +.list li .val { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; } +.list kbd { min-width: 28px; text-align: center; } +.crit { display: grid; gap: 4px; font-size: 13.5px; } +.crit .y { color: var(--ok); } .crit .n { color: var(--muted); } +.actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-top: 22px; } +.chart { width: 100%; height: auto; display: block; } +.chart text { fill: var(--muted); font: 11px system-ui, sans-serif; } +.chart .grid { stroke: var(--line); stroke-width: 1; } +.chart .ln { fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; } +.chart .ln.b { stroke: var(--ok); } +.chart .ar { fill: var(--accent); opacity: .12; } .chart .ar.b { fill: var(--ok); } +.chart circle { fill: var(--panel); stroke: var(--accent); stroke-width: 2; } .chart circle.b { stroke: var(--ok); } +.empty { text-align: center; color: var(--muted); padding: 26px 8px; } + +/* ---------- Статистика ---------- */ +table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } +th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); font-size: 14px; } +th { color: var(--muted); font-weight: 600; font-size: 12.5px; } +td.r, th.r { text-align: right; } +.tbl { overflow-x: auto; } +.stack { display: grid; gap: 14px; } + +@media (max-width: 720px) { + .chips { grid-template-columns: repeat(2, 1fr); } + .textbox { padding: 0 14px; } + #nav button { padding: 7px 10px; } + .brand span { display: none; } + .textbox .go { display: none; } +} + +/* ---------- Клавиатура пользователя (Vial) и слои ---------- */ +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.kb.vial { --u: clamp(24px, calc((100vw - 64px) / 15), 56px); --gapc: calc(var(--u) * 2); } +.kb.vial .key { font-size: calc(var(--u) * .38); } +.kb.vial .key .s { max-width: 94%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .55em; } +.key.none { opacity: .3; } +.key.trns { color: var(--dim); } +.key.mod.lay { color: var(--accent); font-weight: 700; } +.key.act, .fc .key.act { + background: hsl(40 96% 56%) !important; border-color: hsl(40 90% 36%) !important; color: #1c1400 !important; transform: translateY(-2px); z-index: 1; + box-shadow: 0 0 0 3px hsl(40 96% 56% / .35), 0 4px 14px hsl(40 90% 36% / .5); animation: pulse 1.1s ease-in-out infinite; +} +.key.act .s { color: #1c1400 !important; opacity: .85; } +@keyframes pulse { 50% { box-shadow: 0 0 0 6px hsl(40 96% 56% / .2), 0 4px 14px hsl(40 90% 36% / .5); } } + +.layerbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 2px 0; } +.lchip { border: 1px solid var(--line); background: var(--panel); border-radius: 99px; padding: 3px 12px; font-size: 13px; font-weight: 600; color: var(--muted); } +.lchip:hover { border-color: var(--accent); } +.lchip.cur { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } +.lchip.need:not(.cur) { border: 2px solid hsl(40 96% 50%); color: var(--fg); } +.lchip.need.cur { box-shadow: 0 0 0 3px var(--accent-soft); } + +.steps { display: flex; gap: 8px; flex-wrap: wrap; } +.step { padding: 3px 12px; border-radius: 99px; background: var(--panel2); color: var(--fg); font-size: 14px; font-weight: 550; } +.step.act { background: hsl(40 96% 56% / .25); border: 1px solid hsl(40 96% 50%); } +.step.key { background: var(--accent-soft); color: var(--accent); } + +.drop { + display: flex; flex-direction: column; align-items: center; gap: 6px; width: 100%; padding: 42px 20px; text-align: center; color: var(--fg); + background: var(--panel); border: 2px dashed var(--keyline); border-radius: 16px; font: inherit; cursor: pointer; transition: .15s; +} +.drop b { font-size: 17px; } +.drop:hover, .drop.over { border-color: var(--accent); background: var(--accent-soft); } +.names { display: flex; gap: 10px; flex-wrap: wrap; } +.nm { display: flex; flex-direction: column; gap: 2px; } +.nm input { font: inherit; color: var(--fg); background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; width: 150px; } +.card.locked { opacity: .55; } +.sec-head .btn { padding: 3px 12px; font-size: 13px; } diff --git a/vial.js b/vial.js new file mode 100644 index 0000000..b5129e7 --- /dev/null +++ b/vial.js @@ -0,0 +1,341 @@ +/* Разбор раскладки Vial (.vil) и планировщик маршрутов по слоям. + Модуль не знает про DOM: язык/символы ему передаёт приложение через charOf(lang, code, shift). */ +(() => { +'use strict'; + +/* ---------- Коды клавиш QMK -> KeyboardEvent.code ---------- */ +const NAMES = { + ENTER: 'Enter', ENT: 'Enter', ESCAPE: 'Escape', ESC: 'Escape', BSPACE: 'Backspace', BSPC: 'Backspace', TAB: 'Tab', + SPACE: 'Space', SPC: 'Space', MINUS: 'Minus', MINS: 'Minus', EQUAL: 'Equal', EQL: 'Equal', + LBRACKET: 'BracketLeft', LBRC: 'BracketLeft', RBRACKET: 'BracketRight', RBRC: 'BracketRight', + BSLASH: 'Backslash', BSLS: 'Backslash', NONUS_HASH: 'Backslash', NUHS: 'Backslash', + SCOLON: 'Semicolon', SCLN: 'Semicolon', QUOTE: 'Quote', QUOT: 'Quote', GRAVE: 'Backquote', GRV: 'Backquote', + COMMA: 'Comma', COMM: 'Comma', DOT: 'Period', SLASH: 'Slash', SLSH: 'Slash', CAPSLOCK: 'CapsLock', CAPS: 'CapsLock', + PSCREEN: 'PrintScreen', PSCR: 'PrintScreen', SCROLLLOCK: 'ScrollLock', SLCK: 'ScrollLock', PAUSE: 'Pause', PAUS: 'Pause', BRK: 'Pause', + INSERT: 'Insert', INS: 'Insert', HOME: 'Home', PGUP: 'PageUp', DELETE: 'Delete', DEL: 'Delete', END: 'End', + PGDOWN: 'PageDown', PGDN: 'PageDown', RIGHT: 'ArrowRight', RGHT: 'ArrowRight', LEFT: 'ArrowLeft', DOWN: 'ArrowDown', UP: 'ArrowUp', + APPLICATION: 'ContextMenu', APP: 'ContextMenu', NUMLOCK: 'NumLock', NLCK: 'NumLock', + LCTRL: 'ControlLeft', LCTL: 'ControlLeft', LSHIFT: 'ShiftLeft', LSFT: 'ShiftLeft', LALT: 'AltLeft', LGUI: 'MetaLeft', LWIN: 'MetaLeft', LCMD: 'MetaLeft', + RCTRL: 'ControlRight', RCTL: 'ControlRight', RSHIFT: 'ShiftRight', RSFT: 'ShiftRight', RALT: 'AltRight', RGUI: 'MetaRight', RWIN: 'MetaRight', RCMD: 'MetaRight', + KP_DOT: 'NumpadDecimal', PDOT: 'NumpadDecimal', KP_PLUS: 'NumpadAdd', PPLS: 'NumpadAdd', KP_MINUS: 'NumpadSubtract', PMNS: 'NumpadSubtract', + KP_ASTERISK: 'NumpadMultiply', PAST: 'NumpadMultiply', KP_SLASH: 'NumpadDivide', PSLS: 'NumpadDivide', + KP_ENTER: 'NumpadEnter', PENT: 'NumpadEnter', KP_EQUAL: 'NumpadEqual', PEQL: 'NumpadEqual' +}; +const MEDIA = { MPRV: '⏮', MNXT: '⏭', MPLY: '⏯', MSTP: '⏹', MUTE: '🔇', VOLU: 'Vol+', VOLD: 'Vol-', BRIU: '☀+', BRID: '☀-', + MEDIA_PREV_TRACK: '⏮', MEDIA_NEXT_TRACK: '⏭', MEDIA_PLAY_PAUSE: '⏯', AUDIO_MUTE: '🔇', AUDIO_VOL_UP: 'Vol+', AUDIO_VOL_DOWN: 'Vol-', + BRIGHTNESS_UP: '☀+', BRIGHTNESS_DOWN: '☀-' }; + +function codeOfName(n) { + if (/^[A-Z]$/.test(n)) return 'Key' + n; + if (/^[0-9]$/.test(n)) return 'Digit' + n; + if (/^F([1-9]|1\d|2[0-4])$/.test(n)) return n; + if (NAMES[n]) return NAMES[n]; + const kp = /^(?:KP_|P)(\d)$/.exec(n); + return kp ? 'Numpad' + kp[1] : null; +} + +const SHIFT = 1, CTRL = 2, ALT = 4, GUI = 8; +const WRAP = { LSFT: SHIFT, RSFT: SHIFT, S: SHIFT, LCTL: CTRL, RCTL: CTRL, C: CTRL, LALT: ALT, RALT: ALT, A: ALT, ALGR: ALT, + LGUI: GUI, RGUI: GUI, G: GUI, LCMD: GUI, LWIN: GUI }; + +function keyP(code, raw) { + return { t: 'key', code, raw, shift: false, other: false, holdShift: false, holdLayer: null, + isMod: /^(Shift|Control|Alt|Meta)/.test(code), shiftSrc: /^Shift/.test(code) }; +} + +/* Разбирает одну клавишу в удобную структуру. null = клавиши в этой позиции нет (-1). */ +function parseKC(raw) { + if (raw === -1 || raw == null) return null; + const s = String(raw).trim(); + if (s === 'KC_TRNS' || s === 'KC_TRANSPARENT' || s === '_______') return { t: 'trns', raw: s }; + if (s === 'KC_NO' || s === 'XXXXXXX' || s === '') return { t: 'no', raw: s }; + let m; + if ((m = /^KC_(\w+)$/.exec(s))) { + const code = codeOfName(m[1]); + if (code) return keyP(code, s); + return { t: 'other', raw: s, label: MEDIA[m[1]] || null }; + } + if ((m = /^(MO|TG|TO|OSL|TT|DF|PDF)\((\d+)\)$/.exec(s))) return { t: m[1] === 'PDF' ? 'df' : m[1].toLowerCase(), n: +m[2], raw: s }; + if ((m = /^LT(\d+)\((.+)\)$/.exec(s)) || (m = /^LT\((\d+),\s*(.+)\)$/.exec(s))) { + const inner = parseKC(m[2]); + if (inner && inner.t === 'key') { inner.holdLayer = +m[1]; inner.raw = s; return inner; } + return { t: 'other', raw: s }; + } + if ((m = /^OSM\((.+)\)$/.exec(s))) return { t: 'osm', shift: /SFT|SHIFT/.test(m[1]), raw: s }; + if ((m = /^MT\((.+?),\s*(.+)\)$/.exec(s)) || (m = /^(?:[LR]?(?:SFT|CTL|ALT|GUI|WIN|CMD|OPT)|LCAG|ALL|MEH|HYPR|SGUI|LSG|LAG|RSG|RAG)_T\((.+)\)$/.exec(s))) { + const isMT = s.startsWith('MT('); + const inner = parseKC(isMT ? m[2] : m[1]); + if (inner && inner.t === 'key') { + inner.holdShift = isMT ? /SFT|SHIFT/.test(m[1]) : /^[LR]?SFT_T|^SGUI|^ALL|^MEH|^HYPR|^LSG|^RSG/.test(s); + inner.raw = s; return inner; + } + return { t: 'other', raw: s }; + } + if ((m = /^(\w+)\((.+)\)$/.exec(s)) && WRAP[m[1]] !== undefined) { + const inner = parseKC(m[2]); + if (inner && inner.t === 'key') { + const mods = WRAP[m[1]]; + if (mods & SHIFT) inner.shift = true; + if (mods & (CTRL | ALT | GUI)) inner.other = true; + inner.raw = s; return inner; + } + } + return { t: 'other', raw: s }; +} + +/* ---------- Файл .vil ---------- */ +function parseFile(text) { + let d; + try { d = JSON.parse(text); } catch (e) { return { error: 'Файл не похож на JSON. Нужен файл .vil из Vial (меню File → Save current layout).' }; } + if (!d || !Array.isArray(d.layout) || !d.layout.length || !Array.isArray(d.layout[0])) + return { error: 'В файле нет раскладки (поле "layout"). Это точно файл .vil из Vial?' }; + const rows = d.layout[0].length; + const cols = Math.max(...d.layout[0].map(r => (Array.isArray(r) ? r.length : 0))); + if (!rows || !cols || rows > 20 || cols > 20) return { error: 'Не удалось определить размер матрицы клавиатуры.' }; + const layers = d.layout.map(layer => { + const out = []; + for (let r = 0; r < rows; r++) { + const row = []; + for (let c = 0; c < cols; c++) { + const k = layer[r] ? layer[r][c] : -1; + row.push(k === -1 || k == null ? -1 : String(k)); + } + out.push(row); + } + return out; + }); + return { layout: { layers, rows, cols }, meta: { vialProtocol: d.vial_protocol || null } }; +} + +/* ---------- Модель клавиатуры ---------- */ +const bit = n => 1 << n; + +function createModel(layout, opts = {}) { + const { layers, rows: R, cols: C } = layout, L = layers.length; + const parsed = layers.map(layer => { const a = []; for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) a.push(parseKC(layer[r][c])); return a; }); + const present = Array.from({ length: R * C }, (_, pos) => parsed.some(l => l[pos] !== null)); + const split = opts.split !== false && R % 2 === 0 && R >= 2; + const cells = new Array(R * C).fill(null), grid = {}; + const rowHoles = r => { let n = 0; for (let c = 0; c < C; c++) if (!present[r * C + c]) n++; return n; }; + const letterOf = d => (d <= 1 ? 'i' : d === 2 ? 'm' : d === 3 ? 'r' : 'p'); + + if (split) { + const H = R / 2; + for (const hand of ['L', 'R']) grid[hand] = Array.from({ length: H }, () => Array(C).fill(-1)); + // Где у половинки внутренний край (у зазора): большие пальцы лежат под внутренними колонками. + // Если по ним не понять - считаем, что у обеих половинок внутренний край у старших столбцов матрицы (как у Silakka54). + const innerHigh = {}; + for (const hand of ['L', 'R']) { + const r = (hand === 'L' ? 0 : H) + H - 1, cols = []; + for (let c = 0; c < C; c++) if (present[r * C + c]) cols.push(c); + innerHigh[hand] = cols.length && cols.length < C ? cols.reduce((s, c) => s + c, 0) / cols.length > (C - 1) / 2 : true; + } + for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) { + const pos = r * C + c; if (!present[pos]) continue; + const hand = r < H ? 'L' : 'R', rr = r % H, ih = innerHigh[hand]; + const d = ih ? C - 1 - c : c; // расстояние до внутреннего края + const x = hand === 'L' ? C - 1 - d : d; // положение слева направо на экране + const thumb = rr === H - 1 && rowHoles(r) > 0; + grid[hand][rr][x] = pos; + cells[pos] = { hand, rr, x, thumb, finger: thumb ? 'th' : hand.toLowerCase() + letterOf(d) }; + } + } else { + grid.A = Array.from({ length: R }, () => Array(C).fill(-1)); + for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) { + const pos = r * C + c; if (!present[pos]) continue; + const hand = c < C / 2 ? 'L' : 'R', d = hand === 'L' ? Math.floor(C / 2) - 1 - c : c - Math.floor(C / 2); + const thumb = r === R - 1 && rowHoles(r) > 0; + grid.A[r][c] = pos; + cells[pos] = { hand, rr: r, x: c, thumb, finger: thumb ? 'th' : hand.toLowerCase() + letterOf(d) }; + } + } + const positions = []; present.forEach((p, i) => { if (p) positions.push(i); }); + + const model = { layout, L, R, C, parsed, present, positions, split, cells, grid }; + model.used = usedLayers(model); + return model; +} + +function usedLayers(model) { + const out = []; + for (let l = 0; l < model.L; l++) { + if (l === 0 || model.parsed[l].some(p => p && p.t !== 'trns' && p.t !== 'no')) out.push(l); + } + return out; +} + +const isDigitCode = c => /^(Digit\d|Numpad\d)$/.test(c); + +function guessNumLayer(model) { + let best = null, bestN = 3; + for (const l of model.used) { + if (l === 0) continue; + const n = model.parsed[l].filter(p => p && p.t === 'key' && isDigitCode(p.code)).length; + if (n > bestN) { best = l; bestN = n; } + } + return best; +} + +function guessName(model, l) { + if (l === 0) return 'Базовый'; + const ps = model.parsed[l].filter(Boolean); + const digits = ps.filter(p => p.t === 'key' && isDigitCode(p.code)).length; + const fn = ps.filter(p => p.t === 'key' && /^(F\d+|Arrow.*|Home|End|Page.*)$/.test(p.code)).length; + if (digits >= 6) return 'Цифры'; + if (fn >= 6) return 'Fn / навигация'; + return 'Слой ' + l; +} + +/* ---------- Разрешение клавиши с учётом активных слоёв ---------- */ +function resolve(model, A, pos) { + for (let l = model.L - 1; l >= 0; l--) { + if (!(A & bit(l)) && l !== 0) continue; + const p = model.parsed[l][pos]; + if (p === null) return null; + if (p.t !== 'trns') return { p, layer: l }; + } + return null; +} + +/* ---------- Планировщик ---------- */ +function actionLabel(p) { + return p.t === 'key' ? 'LT' + p.holdLayer : p.t.toUpperCase(); +} + +function ctxFor(model, o) { + return { model, lang: o.lang, charOf: o.charOf, prefer: o.prefer == null ? null : o.prefer, maxDepth: o.maxDepth == null ? 2 : o.maxDepth, + cache: new Map(), actCache: new Map(), shiftCache: new Map() }; +} + +function actionsUnder(ctx, A) { + if (ctx.actCache.has(A)) return ctx.actCache.get(A); + const seen = new Set(), out = []; + for (const pos of ctx.model.positions) { + const r = resolve(ctx.model, A, pos); if (!r) continue; + const p = r.p; let kind = null, n = null; + if (p.t === 'mo' || p.t === 'tt') { kind = 'hold'; n = p.n; } + else if (p.t === 'tg') { kind = 'toggle'; n = p.n; } + else if (p.t === 'to') { kind = 'to'; n = p.n; } + else if (p.t === 'osl') { kind = 'osl'; n = p.n; } + else if (p.t === 'key' && p.holdLayer != null) { kind = 'hold'; n = p.holdLayer; } + if (!kind || n >= ctx.model.L) continue; + if (kind === 'toggle' && n === 0) continue; + const k = kind + n; if (seen.has(k)) continue; seen.add(k); + out.push({ kind, n, pos, label: actionLabel(p) + (p.t === 'key' ? '' : '(' + n + ')') }); + } + ctx.actCache.set(A, out); return out; +} + +function shiftSources(ctx, A) { + if (ctx.shiftCache.has(A)) return ctx.shiftCache.get(A); + const out = []; + for (const pos of ctx.model.positions) { + const r = resolve(ctx.model, A, pos); if (!r) continue; + const p = r.p; + if ((p.t === 'key' && (p.shiftSrc || p.holdShift)) || (p.t === 'osm' && p.shift)) out.push(pos); + } + ctx.shiftCache.set(A, out); return out; +} + +function typeOptions(ctx, A, ch) { + const m = ctx.model, opts = []; + for (const pos of m.positions) { + const r = resolve(m, A, pos); + if (!r || r.p.t !== 'key' || r.p.other || r.p.isMod) continue; + const p = r.p; + if (ctx.charOf(ctx.lang, p.code, p.shift) === ch) { opts.push({ pos, layer: r.layer, extra: 0, shiftPos: null }); continue; } + if (!p.shift && ctx.charOf(ctx.lang, p.code, true) === ch) { + let best = null; + for (const sp of shiftSources(ctx, A)) { + if (sp === pos) continue; + const cost = m.cells[sp].hand === m.cells[pos].hand ? 1.5 : 1; + if (!best || cost < best.cost) best = { pos: sp, cost }; + } + if (best) opts.push({ pos, layer: r.layer, extra: best.cost, shiftPos: best.pos }); + } + } + return opts; +} + +/* Все способы напечатать ch из состояния постоянных слоёв P0: по одному лучшему на каждое итоговое состояние P'. */ +function search(ctx, P0, ch) { + const ck = P0 + ':' + ch; + if (ctx.cache.has(ck)) return ctx.cache.get(ck); + let frontier = [{ P: P0, H: 0, actions: [] }]; + const seen = new Set([P0 + ':0']), cands = []; + for (let depth = 0; depth <= ctx.maxDepth && frontier.length; depth++) { + const next = []; + for (const st of frontier) { + const A = 1 | st.P | st.H; + for (const o of typeOptions(ctx, A, ch)) cands.push({ P: st.P, A, actions: st.actions, opt: o, cost: st.actions.length + o.extra }); + if (depth === ctx.maxDepth) continue; + for (const act of actionsUnder(ctx, A)) { + let P = st.P, H = st.H; + if (act.kind === 'hold' || act.kind === 'osl') { if (A & bit(act.n)) continue; H |= bit(act.n); } + else if (act.kind === 'toggle') P ^= bit(act.n); + else P = act.n ? bit(act.n) : 0; + const k = P + ':' + H; if (seen.has(k)) continue; seen.add(k); + next.push({ P, H, actions: st.actions.concat(act) }); + } + } + frontier = next; + } + let use = cands; + if (ctx.prefer != null) { const pf = cands.filter(c => c.opt.layer === ctx.prefer); if (pf.length) use = pf; } + const best = new Map(); + for (const c of use) { + const b = best.get(c.P); + if (!b || c.cost < b.cost || (c.cost === b.cost && c.actions.length < b.actions.length)) best.set(c.P, c); + } + const res = [...best.values()]; + ctx.cache.set(ck, res); return res; +} + +/* Планирует весь текст: динамическое программирование по состоянию постоянных слоёв (минимум действий). */ +function plan(model, text, o) { + const ctx = ctxFor(model, o); + const chars = [...text], P0 = o.startP || 0; + let layer = new Map([[P0, { cost: 0 }]]); + const steps = []; + for (const ch of chars) { + const nxt = new Map(); + for (const [P, st] of layer) { + const res = search(ctx, P, ch); + if (!res.length) { + const total = st.cost + 0.001; + if (!nxt.has(P) || total < nxt.get(P).cost) nxt.set(P, { cost: total, from: P, cand: null }); + continue; + } + for (const c of res) { + const total = st.cost + c.cost; + if (!nxt.has(c.P) || total < nxt.get(c.P).cost) nxt.set(c.P, { cost: total, from: P, cand: c }); + } + } + steps.push(nxt); layer = nxt; + } + let P = null, bestCost = Infinity; + for (const [k, v] of layer) if (v.cost < bestCost) { bestCost = v.cost; P = k; } + const entries = new Array(chars.length); + for (let i = chars.length - 1; i >= 0; i--) { + const e = steps[i].get(P), c = e.cand; + entries[i] = c + ? { ch: chars[i], pos: c.opt.pos, shiftPos: c.opt.shiftPos, layer: c.opt.layer, actions: c.actions, A: c.A, Pbefore: e.from, Pafter: P, sw: c.actions.length > 0 } + : { ch: chars[i], pos: null, shiftPos: null, layer: 0, actions: [], A: 1 | e.from, Pbefore: e.from, Pafter: P, sw: false, unreachable: true }; + P = e.from; + } + const missing = new Set(entries.filter(e => e.unreachable).map(e => e.ch)); + return { + entries, missing, + switches: entries.filter(e => e.actions.some(a => a.kind === 'toggle' || a.kind === 'to')).length, + holds: entries.filter(e => e.actions.some(a => a.kind === 'hold' || a.kind === 'osl')).length + }; +} + +/* Какие из символов вообще нельзя напечатать на этой клавиатуре (из состояния «базовый слой»). */ +function unreachable(model, o, chars) { + const ctx = ctxFor(model, { ...o, prefer: null, maxDepth: 2 }); + return [...chars].filter(ch => !search(ctx, 0, ch).length); +} + +window.Vial = { parseFile, parseKC, createModel, resolve, plan, unreachable, guessNumLayer, guessName, usedLayers, bit, isDigitCode }; +})(); diff --git a/ИНСТРУКЦИЯ.md b/ИНСТРУКЦИЯ.md new file mode 100644 index 0000000..e950821 --- /dev/null +++ b/ИНСТРУКЦИЯ.md @@ -0,0 +1,113 @@ +# Сплит-тренер: как пользоваться + +## 1. Запуск + +1. Откройте файл `index.html` двойным кликом (лучше в Chrome или Edge). +2. Ничего устанавливать не нужно. Интернет не нужен. + +Прогресс и статистика хранятся в самом браузере. Не переносите папку с приложением в другое место и не меняйте браузер, иначе прогресс «потеряется» (он привязан к браузеру и адресу файла). + +## 2. Главное правило: раскладка Windows + +Тренер читает те символы, которые выдаёт система. Поэтому перед упражнением включите **ту раскладку, на которой печатаете**: + +- русский урок: русская раскладка; +- английский урок: английская. + +Переключение: `Win + Пробел` или `Alt + Shift`. Если включена не та, сверху появится жёлтая подсказка, ошибкой это не считается. + +## 3. Как проходит упражнение + +- Сверху текст, ниже экранная клавиатура. Следующая клавиша подсвечена, рядом написано, каким пальцем её нажимать. Если нужен Shift, подсвечивается Shift **противоположной** руки. +- Таймер запускается с первой нажатой клавиши. +- Неверная клавиша не засчитывается: курсор остаётся на месте, ошибка записывается. Нажмите правильную клавишу, чтобы идти дальше. +- `Esc` начинает упражнение заново. +- Если включён Caps Lock, тренер предупредит, а не будет считать это ошибками. + +## 4. Разделы + +### Уроки +45 уроков для каждого языка, от простого к сложному. Идти лучше по порядку, но открыть можно любой. + +| Раздел | Что тренируем | +|---|---| +| A. Основной ряд | Исходная позиция, указательные к центру | +| B. Верхний ряд | Новые буквы по 2–3 за урок | +| C. Нижний ряд | То же для нижней строки | +| D. Сплит | Центр на стыке половинок, слова одной рукой, чередование рук, мизинцы и безымянные | +| E. Слова | Короткие, средние, длинные | +| F. Заглавные | Работа с Shift | +| G. Цифры | Ряд цифр, числа, даты, телефоны | +| H. Знаки препинания | Точка, запятая, `? ! : ; -`, кавычки, скобки | +| I. Символы | `@ # $ % & * [ ] { }` и код (в русской раскладке: `№ % * ?`) | +| J. Тексты | Предложения, абзацы, «Марафон» | +| K. Слои | Только с загруженной раскладкой Vial (см. раздел 6) | + +**Звёзды:** ★ за точность от 90%; ★★ за точность от 95% и целевую скорость урока; ★★★ за 98% и скорость на 25% выше цели. Кнопка «Продолжить» сверху ведёт к первому непройденному уроку. + +### Практика +Собственные упражнения: слова (можно добавить заглавные, цифры, знаки), предложения, абзацы, цифры, символы, режим «Слои» и **свой текст** (вставьте любой, символы, которых нет на раскладке, будут убраны). + +### Статистика +- скорость, точность, время, рекорды, графики по последним упражнениям; +- **карта клавиш**: красным отмечены клавиши с ошибками (или медленные, если переключить); +- кнопка **«Тренировать слабые клавиши»**: тренер соберёт текст из ваших проблемных букв (нужно хотя бы несколько упражнений). + +### Настройки +Тема, экранная клавиатура вкл/выкл, цвета пальцев, вид клавиатуры (со сдвигом или ортолинейная), размер текста, звук ошибки. + +## 5. Что означают цифры в результате + +- **Скорость** в знаках в минуту (зн/мин). Слов в минуту = зн/мин ÷ 5. +- **Точность**: доля верных нажатий среди всех нажатий. +- **Ровность ритма**: насколько ровные паузы между клавишами. Рваный ритм показывает места, которые пальцы ещё не выучили. +- **Самые медленные клавиши** и **путаница** («нужно Ф, нажато Ы») подсказывают, что повторять. + +## 6. Ваша клавиатура из Vial и слои + +### Загрузка раскладки +1. В программе **Vial**: меню **File → Save current layout**, получится файл `.vil`. +2. В тренере откройте раздел **Клавиатура**, выберите файл или перетащите его в окно. +3. Проверьте, что слои распознаны. Слои можно переименовать (файл их названий не хранит). + +Настройки на этой странице: +- **Использовать в тренировках**: экранная клавиатура рисуется по вашему файлу. +- **Расположение клавиш**: «Две половинки» (по умолчанию) или «Один блок», если у вашей модели матрица устроена иначе и схема выглядит неправильно. +- **Слой с цифрами**: какой слой тренер будет просить использовать для цифр и символов. По умолчанию определяется сам. + +### Тренировка со слоями +- Уроки раздела **К** в списке уроков и режим **Практика → «Слои: текст, числа и формулы»**. +- Над клавиатурой видна полоска слоёв: сплошным закрашен слой, на котором тренер **считает**, что вы сейчас, а жёлтой рамкой отмечен нужный для следующего символа. +- Если нужно переключить слой, в подсказке написано действие (например, «Включите слой «Цифры»: TG(1)»), а нужная клавиша слоя мигает жёлтым. Дальше подсвечена сама клавиша символа на нужном слое. +- Уроки специально заставляют переключаться: цифры и символы нужно набирать через слой «Цифры», хотя на базовом слое есть цифровой ряд. В английских уроках есть символы `[ ] { } \ | = ~`, которых на базовом слое нет. +- В результатах появляется блок **«Переключение слоёв»**: число переключений и «цена переключения» (на сколько миллисекунд дольше символ сразу после смены слоя). Её и нужно сокращать. + +### Важно знать +- Нажатия клавиш слоёв (`TG`, `MO` и т. п.) обрабатывает прошивка клавиатуры, до тренера они **не доходят**. Тренер проверяет только набранные символы, а выполнение подсказок по слоям остаётся на вас. +- Если вы оказались на другом слое, чем думает тренер, нажмите на нужный слой над клавиатурой, и маршрут пересчитается. +- Для цифрового блока (`KP_*`) в системе должен быть включён NumLock. + +## 7. Как заниматься + +- Лучше **20 минут каждый день**, чем 2 часа раз в неделю. +- **Точность важнее скорости**: печатайте медленнее, чем хочется. Скорость придёт, когда пальцы запомнят путь. +- Если на уроке меньше ★: повторите его, а не переходите дальше. +- Раз в неделю заглядывайте в «Статистику» и запускайте **слабые клавиши**. +- Не смотрите на клавиши клавиатуры: подсказки на экране для этого и сделаны. Когда освоитесь, экранную клавиатуру можно выключить в «Настройках». + +## 8. Если что-то не так + +| Проблема | Что делать | +|---|---| +| Жёлтая подсказка про раскладку | Переключите раскладку Windows на нужный язык | +| Подсказка про Caps Lock | Выключите Caps Lock | +| Клавиши не реагируют | Кликните по странице: фокус должен быть в окне приложения, а не в адресной строке | +| Уроки К закрыты замком | Загрузите файл `.vil` в разделе «Клавиатура» и включите «Использовать в тренировках» | +| Схема клавиатуры выглядит неправильно | В «Клавиатура → Расположение клавиш» выберите «Один блок» | +| «Этого символа нет на вашей раскладке» | Такой символ нельзя набрать на вашей клавиатуре; добавьте его в Vial или пропустите упражнение | +| Прогресс пропал | Он хранится в браузере: проверьте, тем ли браузером и тем же файлом вы пользуетесь | +| Нужно всё обнулить | «Статистика» → «Сбросить все данные» | + +## 9. Файлы приложения + +`index.html` (запуск), `app.js` (логика), `vial.js` (чтение Vial и маршруты по слоям), `data.js` (раскладки, слова и тексты упражнений), `styles.css` (оформление). Слова и предложения для упражнений можно править в `data.js`.