Files
split-trainer/public/app.js
T
darko fcafe1ae15 feat: аккаунты по почте с подтверждением, сбросом пароля и синхронизацией прогресса
API аккаунтов на чистой стандартной библиотеке Python (server/splitapi):
- регистрация по почте: пароль задаётся по ссылке из письма, аккаунт создаётся только после подтверждения
- вход, выход, сброс пароля по почте (токены одноразовые, в базе только SHA-256), удаление аккаунта
- пароли scrypt, лимиты попыток по паре «адрес + IP», IP и аккаунту, защита от CSRF
- хранение прогресса пользователя с оптимистичной блокировкой версии
- SMTP через переменные окружения; без SMTP на публичном адресе API не стартует

Клиент: public/auth.js (вход, регистрация, сброс, синхронизация), public/sync.js
(чистое слияние прогресса). Без API сайт работает как раньше.

Развёртывание: Dockerfile.api, сервис splitapi в docker-compose, проксирование /api/
в nginx с ограничением частоты, deploy/install-api-debian.sh (systemd), .env.example.

Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния.
Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
2026-10-16 11:25:09 +03:00

1450 lines
91 KiB
JavaScript
Raw Blame History

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