1451 lines
91 KiB
JavaScript
1451 lines
91 KiB
JavaScript
(() => {
|
||
'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', 'Клавиатура'], ['overlay', 'Оверлей'], ['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(),
|
||
overlay: () => {} })[v](); // страница оверлея статична: её разметка лежит в index.html
|
||
}
|
||
|
||
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 };
|
||
})();
|