// ==UserScript==
// @name KG_WebTypeStats
// @namespace KG_WebTypeStats
// @version 0.72
// @description Записывает все нажатия клавиш в процессе геймплея для дальнейшего статистического анализа. Работает только с полем ввода набираемого в заезде текста.
// @author un4given (111001)
// @license GNU GPLv3
// @match http*://*.klavogonki.ru/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=klavogonki.ru
// @grant none
// ==/UserScript==
// ⏱️🎹📖💼📜🗃️📂🙈
(function() {
'use strict';
// --------- !!! DO NOT MODIFY ANYTHING ABOVE THIS LINE UNLESS YOU ARE AWARE OF WHAT YOU ARE DOING !!! -------
// some internal settings\constants
const MAX_LAST_WTS_COUNT = 100; //limit history of autosaved WTSs
const WTS_PANEL_TITLE = 'Статистика набора'; // заголовок панели в заезде (справа)
const NETTO_HINT = 'Реальная скорость (она же средняя)';
const BRUTTO_HINT = 'Гипотетическая скорость без учёта опечаток и их исправлений';
const ERROR_COUNT_HINT = 'Количество серий исправлений\n(может отличаться от количества ошибок на КГ)';
const TYPE_TIME_HINT = 'Время набора текста';
const CORRECT_TYPED_CHARS_HINT = 'Правильно набранные знаки';
const INCORRECT_TYPED_CHARS_HINT = 'Ошибочно набранные знаки';
const CLOSE_BUTTON_HINT = 'Между прочим, кнопка [Esc] тоже работает!';
const WTS_PANEL_READY_HINT = 'Система записи клавожмяков готова!';
const WTS_PANEL_RECORDING_HINT = 'Тихо! Идёт запись клавожмяков...';
const WTS_PANEL_RECORDING_SUSPENDED_HINT = 'Запись клавожмяков приостановлена...';
const WTS_PANEL_FAIL_MSG = 'Упс! Что-то пошло не так :(';
const TOAST_LIFETIME = 2000; // in ms
const TOAST_INVALID_PASTE_DATA = 'Фу, что вы в меня пихаете!';
const TOAST_CLIPBOARD_COPY_OK = 'Скопировано!';
const TOAST_CLIPBOARD_COPY_FAIL = 'Ха! А копировать-то и нечего...';
const TOAST_NOTHING_TO_SAVE = 'Чё-т нечего сохранять!';
const TOAST_NOTHING_TO_PUBLISH = 'Чё-т нечего публиковать!';
const TOAST_SOMETHING_WENT_WRONG = 'Что-то пошло не так...';
const TOAST_USER_NOT_LOGGED_IN = 'Сперва надо залогиниться!';
const TOAST_BLOG_HIDDEN_POST_ADDED = 'Спрятано в БЖ!';
const TOAST_BLOG_POST_ADDED = 'Опубликовано в БЖ!';
const MENU_OPENFILE_HINT = "Открыть файл с WTS-кой (можно несколько) или архив целиком.\nЕсли кликать с Shift'ом, то открываемые файлы будут добавляться к загруженным ранее.";
const MENU_SAVEFILE_HINT = 'Сохранить текущую WTS-ку в файл.';
const MENU_SAVEARCHIVE_HINT = 'Сохранить весь набор WTS-ок из архива или из загруженных файлов.';
const MENU_PUBLISHBLOG_HINT = "Опубликовать текущую WTS-ку в бортжурналe.\nЕсли кликать с Shift'ом, то запись будет публичной, иначе − скрытой.\nКлик с Alt'ом − опубликовать в формате JSON.";
const MENU_HELP_HINT = "Отправиться в БЖ к унчу за FAQ'ом/обсуждениями";
// custom game mode names
const GAME_MODES = {
normal: 'Обычка',
abra: 'Абра',
referats: 'Яндекс.Рефераты',
noerror: 'Безошибка',
marathon: 'Марик',
chars: 'Буквы',
digits: 'Цифры',
sprint: 'Спринт',
// custom name for unknown game mode
unknown: 'Неведома зверушка',
};
//
const POPULAR_VOCS = {
192: 'Частотка',
1789: 'Короткие тексты',
5539: 'English',
6018: 'Миник',
25856: 'Соточка',
// continue yourself
};
const FAST_DELAY_THRESHOLD = 15; // (in ms!): all delays below this threshold will be marked yellow in text
const DISABLE_CTRL_SHORTCUTS = false; // disable all Ctrl+[anykey (except 'A') \ anydigit] while in-game typing
// --------- !!! DO NOT MODIFY ANYTHING BELOW THIS LINE UNLESS YOU ARE AWARE OF WHAT YOU ARE DOING !!! -------
/*
KNOWN BUGS \ NUANCES:
1) calculated speed slightly differs from speed, calculated on site (for different reasons)
2) speed calculates from first keypress, not from actual game start
3) Ctrl+Backspace behaviour is set to Chrome/Windows OS (sorryyyyy)
4) no processing of weird\unusual corrections (like ctrl+a, shift+←→, home→del→end, etc.)
5) in some cases there might be some keypresses registered right after game end (e.g.: you pressed last [.] and accidentally slipped to [/] at the end of the game)
6) 2 b continued...
*/
const AM_EMPTY = 0, AM_INGAME = 1, AM_ARCHIVE = 2, AM_FILES = 3; // app modes
const MWC_EMPTY = 0, MWC_CHARTS = 1; // main window content types
const META_KEY = (navigator.platform === "Win32")?'Win':((navigator.platform === "MacIntel")?'Cmd':'Meta');
const ALT_KEY = (navigator.platform === "MacIntel")?'Opt':'Alt';
const MIN_LAYOUT_DETECTION_SAMPLES = 10;
const WTS_FORMAT_VERSION = 1;
const CUT_START_MARK = '…]';
const CUT_END_MARK = '[…';
const HTML_VISIBLE_SPACE = '◻';
const MD_VISIBLE_SPACE = '⎵'; //␣ ˽ ⎵
const MODAL_ID = 'wts-draggable-window';
const STORAGE_POS_KEY = 'WTS_MODAL_POSITION';
const STORAGE_TEXT_CONTROL_OPTIONS_KEY = 'WTS_TEXT_CONTROL_OPTIONS';
const DEFAULT_TEXT_CONTROL_OPTIONS = {'hide-fast': true, 'hide-err': true, 'hide-corr': false};
const UPLOT_CSS = 'https://unpkg.com/[email protected]/dist/uPlot.min.css';
const UPLOT_JS = 'https://unpkg.com/[email protected]/dist/uPlot.iife.min.js';
const CHART_WIDTH = 760;
const CHART_HEIGHT = 280;
const SPEEDCHART_Y_SCALE = 'static'; //could be either 'static' or 'dynamic';
const HISTOGRAM_BIN_SIZE = 20;
const HISTOGRAM_MAX_X = 400;
const HISTOGRAM_MAX_Y = 0.3;
const ColorUtils = {
// ===== HELPERS =====
hexToHsl: function (hex) {
hex = hex.replace(/^#/, "");
if (hex.length === 3) {
hex = hex.split("").map(c => c + c).join("");
}
let r = parseInt(hex.substr(0, 2), 16) / 255;
let g = parseInt(hex.substr(2, 2), 16) / 255;
let b = parseInt(hex.substr(4, 2), 16) / 255;
let max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
let d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h * 360, s * 100, l * 100];
},
hslToHex: function (h, s, l) {
s /= 100;
l /= 100;
let c = (1 - Math.abs(2 * l - 1)) * s;
let x = c * (1 - Math.abs((h / 60) % 2 - 1));
let m = l - c / 2;
let r = 0, g = 0, b = 0;
if (0 <= h && h < 60) { r = c; g = x; b = 0; }
else if (60 <= h && h < 120) { r = x; g = c; b = 0; }
else if (120 <= h && h < 180) { r = 0; g = c; b = x; }
else if (180 <= h && h < 240) { r = 0; g = x; b = c; }
else if (240 <= h && h < 300) { r = x; g = 0; b = c; }
else if (300 <= h && h < 360) { r = c; g = 0; b = x; }
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
return "#" + [r, g, b].map(x => x.toString(16).padStart(2, "0")).join("");
},
// ===== GENERATORS =====
// Lightness gradient (light → dark)
generateTints: function (baseHex, steps = 16) {
let [h, s, l] = this.hexToHsl(baseHex);
let colors = [];
let lightStart = 90; // very light (90%)
let lightEnd = 20; // dark (20%)
for (let i = 0; i < steps; i++) {
let li = lightStart + (lightEnd - lightStart) * (i / (steps - 1));
colors.push(this.hslToHex(h, s, li));
}
return colors;
},
// Hue gradient (full 360° from baseH)
generateHues: function (baseH, baseS = 100, baseL = 50, steps = 16) {
let colors = [];
for (let i = 0; i < steps; i++) {
let hi = (baseH + 360 * (i / steps)) % 360;
colors.push(this.hslToHex(hi, baseS, baseL));
}
return colors;
}
};
let __appMode = AM_EMPTY;
let __WTSData = [];
let __WTSInfo = {};
let __WTSKeyMap = {};
let __isGameStarted = false;
let __isGameFinished = false;
let __isGameFailed = false; // this is only for noerror mode
let __isQual = false; // freaking qualification with infinite number of retries 🤬
let __isWTSAddedToArchive = false; //we have qualification and error work mode, so we should add WTS to archive only one time.
let __gameStartTime = 0;
let __gameFirstKeyTime = 0;
let __gameEndTime = 0;
let __gameDuration = 0;
let __gameSpeed = 0;
let __gameErrorCount = 0;
let __archive = []; // for local WTS archive (in localStorage)
let __files = []; // same, but for opened\pasted files
// ------ ENTRY POINT ------
//apply CSS as fast as possible
injectCSS();
// include oonch.js framework :D
function oO(s) {
var m = {
'#': 'getElementById',
'.': 'getElementsByClassName',
'@': 'getElementsByName',
'=': 'getElementsByTagName',
'*': 'querySelectorAll'
}[s[0]];
return (typeof m != 'undefined')? document[m](s.slice(1)) : document.getElementById(s);
};
// perform initialization
let lastMS = 0;
if (!localStorage.WTS_ARCHIVE) {
localStorage.WTS_ARCHIVE = JSON.stringify([]);
}
const __isInGame = (/\/g\//.test(location.href))? location.href.split('gmid=')[1] : null; // contains gameID, just in case :)
if (__isInGame && localStorage.getItem('curWTS'))
{
//cleanup previous leftovers
localStorage.removeItem('curWTS');
}
// temporarily (or not, lol!)
['#userpanel-level-container', '#stats-block'].forEach(id => {
const el = document.body.querySelector(id);
if (el) {
el.onclick = (e) => {if (!['A', 'SELECT', 'OPTION'].includes(e.target.nodeName)) showWTS()};
}
});
// show WTS window on Alt+S / close on Esc
document.addEventListener("keydown", (e) => {
let modal = oO(`#${MODAL_ID}`);
if (!modal && e.altKey && e.code == 'KeyS') {
showWTS();
} else if (modal && e.key == 'Escape') {
// do not forget to close uPlot tooltips, if any
const tooltips = oO('.wts-chart-tooltip');
for (let tt of tooltips) {
tt.style.display = "none";
}
modal.remove();
}
});
// if we are in game, we need to create side panel and attach event listeners to input text field:
if (__isInGame) {
// create rightside panel after 0.5sec
setTimeout(() => {
const params = oO("#params");
if (params) {
const panel = document.createElement('div');
panel.id = 'wts-side-panel';
// panel.style.backgroundColor = getComputedStyle(params).backgroundColor; // ← enable this, if you are still using KTS with color-themes
panel.innerHTML = `
<div class="wts-side-panel-content">
<span id="wts-rec" class="ready" title="${WTS_PANEL_READY_HINT}"></span>
<h4>${WTS_PANEL_TITLE}</h4>
<div id="wts-side-panel-stats"></div>
</div>`;
params.parentNode.insertBefore(panel, params.nextSibling);
}
}, 500);
// listen for focus event: first setFocus is basically a game start, so we need to perform some initialization
oO("#inputtext").addEventListener("focus", (e) => {
//enable rec button
oO('#wts-rec').classList.remove('pause', 'ready');
oO('#wts-rec').classList.add('blink');
oO('#wts-rec').title = WTS_PANEL_RECORDING_HINT;
if (!__isGameStarted) {
__isGameStarted = true;
__isQual = /, квалификация,/.test(oO('#gamedesc').innerText);
__gameStartTime = Date.now();
}
// if game finished but we fall into onFocus again, then we either playing qualification (correcting errors) or doing error work, I guess...
if (__isGameFinished && __isQual) {
lastMS = performance.now();
}
});
// attach event listener to input text field
oO("#inputtext").addEventListener("keydown", (e) => {
let MS = performance.now();
//check if event is trusted (I am aware that this «protection» is kinda shitty and could be easily bypassed!)
if (!e.isTrusted) return;
//skip unnecessary keys
if (['Meta', 'Shift', 'Control', 'Alt'].includes(e.key)) return;
//skip Alt + [any printable character]
if (e.altKey && e.key.length == 1) return;
//disable ctrl+[b-z0-9\-\=] shortcuts, if needed
//awsh~~, Ctrl+W can not be disabled this way :(
if (DISABLE_CTRL_SHORTCUTS && e.ctrlKey && e.code != 'KeyA' && (e.code.startsWith('Key') || e.code.startsWith('Digit') || ['-', '='].includes(e.key))) {
e.preventDefault();
e.stopPropagation();
return;
}
if (!lastMS) {
lastMS = MS;
// save WTS info for future use
const ver = WTS_FORMAT_VERSION;
const time = +Date.now();
const uid = (typeof __user__ !== 'undefined')? __user__ : 0;
let type = oO('#gamedesc').children[0].className.replace('gametype-', '') || "unknown";
if (type == 'voc') {
type += `-${oO('#gamedesc').children[0].children[0].href.replace(/[^0-9]+/g, '')}`;
}
__WTSInfo = {ver, time, uid, type};
__gameFirstKeyTime = Date.now();
}
//do not register repeats (except for backspace)
if (!e.repeat || e.key === 'Backspace') {
if (e.key === 'Backspace' && !e.target.value) {
return; // !!!experimental: do not register corrections when input field is empty
}
// build keymap for detecting keyboard layout later
if (e.code.startsWith('Key')) {
__WTSKeyMap[e.code] = e.key;
}
let prefix = '';
let key = e.key; // assign to Event.key by default, but we may change it later in some cases
// preprocess special combinations before saving (like ctrl+backspace, ctrl+a, shift+home, etc)
if (e.ctrlKey && (e.code === 'KeyA')) {
prefix = 'Ctrl+';
key = `A:${e.target.value.length}`; //experimental feature for future use
} else if ((e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) && ['Backspace', 'Delete', 'Home', 'End', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
if (e.metaKey) prefix += `${META_KEY}+`;
if (e.ctrlKey) prefix += 'Ctrl+';
if (e.altKey) prefix += `${ALT_KEY}+`;
if (e.shiftKey) prefix += 'Shift+';
}
__WTSData.push({
[prefix + key]: Math.trunc((MS - lastMS)*1000+0.5)/1000
});
}
lastMS = MS;
});
oO("#inputtext").addEventListener("blur", (e) => {
//disable rec button
oO('#wts-rec').classList.remove('blink');
oO('#wts-rec').classList.add('pause');
oO('#wts-rec').title = WTS_PANEL_RECORDING_SUSPENDED_HINT;
if (__WTSData.length)
{
const data = __WTSData; //we need to do that way, so result object will have all the __WTSInfo fields and data:xxx
localStorage.curWTS = JSON.stringify({...__WTSInfo, data}); //store to localStorage only when input field loses focus (normally it means end of the game)
}
__isGameFinished = e.target.parentElement.parentElement.style.display == 'none';
if (__isGameFinished) {
__gameEndTime = Date.now();
if (localStorage.curWTS) {
//if we are here, then at least 1 keypress has been recorded, though it can be useless (like backspace only)
let curWTS = JSON.parse(localStorage.curWTS);
//show stats on right side panel
const stats = collectSpeedStats(annotateKeypresses(curWTS.data));
oO('#wts-side-panel-stats').innerHTML = `
<table style='width: 100%; margin-top: 10px;'>
<tr>
<td width="22%">Скорость: </td><td width="33%"><span title="${NETTO_HINT}">${stats.nettoCPM.toFixed(0)}</span>${(stats.nettoCPM != stats.bruttoCPM)?` <span title="${BRUTTO_HINT}">(${stats.bruttoCPM.toFixed(0)})</span>`:''}</td>
<td width="15%">Время: </td><td width="30%"><span title="${TYPE_TIME_HINT}">${stats.totalTimeStr}</span></td>
</tr>
<tr>
<td>Ошибки: </td><td>${stats.correctionSeries}</td>
<td>Знаки: </td><td>${stats.correctCount}${(stats.errorCount)?` <span title="Ошибочно набранные знаки">(+${stats.errorCount})</span>`:''}</td>
</tr>
<tr><td colspan=4 align="center" style="padding-top: 4px;"><a href="#" onclick="showWTS()" style="text-decoration: none; border-bottom: 1px dashed">Посмотреть детальную статистику</a></td></tr>
</table>`
setTimeout(() => {
//finalize game
oO('#wts-rec').classList.remove('blink', 'pause');
oO('#wts-rec').title = '';
// failed in noerror mode
if ((__WTSInfo.type == 'noerror') && oO('*#players .you .noerror-fail').length) {
__isGameFailed = true;
return;
}
// failed in qualification
if (__isQual) {
const res = document.querySelector('#players .you .rating div');
if (res && res.innerText == 'Результат не зачтен') {
__isGameFailed = true;
return;
}
}
// add curWTS to archive
// (BTW, curWTS is still available, because it was declared in closure, just FYI)
if (curWTS.data.length) {
// add any other info, if needed:
curWTS.sysInfo = {
rawStart:__gameStartTime,
firstKey: __gameFirstKeyTime - __gameStartTime,
rawDuration: __gameEndTime - __gameStartTime,
keybLayout: KeybLayout.detect(__WTSKeyMap)
};
if (__isQual) {
curWTS.sysInfo.isQual = 1;
curWTS.sysInfo.qualTextLength = oO('#inputtext').value.length;
}
if (curWTS.sysInfo.rawDuration >= 6 * 60 * 1000) {
return; // do not store this WTS, because we are probably AFK
}
const gameStat = document.querySelector('#players .you .stats');
if (gameStat) {
curWTS.sysInfo.kgDur = gameStat.children[0].innerText;
curWTS.sysInfo.kgSpeed = parseFloat(gameStat.children[1].innerText.split(' ')[0]).toFixed(0);
curWTS.sysInfo.kgErrorCount = parseInt(gameStat.children[2].innerText.split(' ')[0]);
}
// one final save with all gathered information
localStorage.curWTS = JSON.stringify(curWTS);
if (!__isWTSAddedToArchive) {
let tmpArchive = JSON.parse(localStorage.WTS_ARCHIVE);
if (tmpArchive.length >= MAX_LAST_WTS_COUNT) {
while (tmpArchive.length >= MAX_LAST_WTS_COUNT) {
tmpArchive.shift();
}
}
tmpArchive.push(curWTS);
localStorage.WTS_ARCHIVE = JSON.stringify(tmpArchive);
__isWTSAddedToArchive = true;
}
}
}, 2000); // 2s should be enough to get game results
} else {
oO('#wts-side-panel-stats').innerHTML = `<span>${WTS_PANEL_FAIL_MSG}</span>`;
}
}
});
}
// ---------------------------------------
//auxiliary functions (partly made with AI)
function annotateKeypresses(sequence) {
const flat = sequence.map(obj => {
const key = Object.keys(obj)[0];
const delay = obj[key];
//perform simple checks against possible XSS attacks
if (key.length > 1 && /[<> ]/.test(key)) {
throw new Error("WTS appears to be corrupted: unknown key");
}
if (typeof delay !== 'number') {
throw new Error("WTS appears to be corrupted: delay is not a number");
}
return {
key,
delay,
mark: null,
deleted: false
};
});
const history = [];
for (let i = 0; i < flat.length; i++) {
const entry = flat[i];
const { key } = entry;
if (key === 'Backspace' || key === 'Shift+Backspace') {
// Удаляем последний не-deleted символ (исключая correction)
for (let j = history.length - 1; j >= 0; j--) {
if (!history[j].deleted && history[j].mark !== 'correction') {
history[j].deleted = true;
break;
}
}
entry.mark = 'correction';
} else if (key === 'Ctrl+Backspace' || key === 'Opt+Backspace') {
let j = history.length - 1;
const skip = h => h.deleted || h.mark === 'correction';
// Юникод-«слово»: буквы (включая кириллицу), цифры, подчёркивание
const isSpace = k => k === ' ';
const isWord = k => /[\p{L}\p{N}_]/u.test(k);
const isPunct = k=> /[…,:;'"«»“”‘’!@#%&*(){}<>\.\-\/\\\?\[\]]/.test(k); // F.M.B (x3)
// дойти до последнего актуального символа
while (j >= 0 && skip(history[j])) j--;
// 1) удалить хвостовые пробелы (если есть)
while (j >= 0 && !skip(history[j]) && isSpace(history[j].key)) {
history[j].deleted = true;
j--;
}
// перескочить удалённые/коррекции, если попались
while (j >= 0 && skip(history[j])) j--;
// 2) если дальше пунктуация — снести весь её блок; иначе — слово
if (j >= 0 && !skip(history[j])) {
if (isPunct(history[j].key)) {
// снести целиком подряд идущую пунктуацию (например, "..." или ",—")
while (j >= 0 && (isPunct(history[j].key) || skip(history[j].key))) {
history[j].deleted = true;
j--;
}
} else if (isWord(history[j].key)) {
// снести целиком слово (буквенно-цифровой блок)
while (j >= 0 && (isWord(history[j].key) || skip(history[j].key))) {
history[j].deleted = true;
j--;
}
} else {
// на всякий случай: одиночный символ непонятной категории
history[j].deleted = true;
}
}
entry.mark = 'correction';
} else if (key.length === 1) {
// Буква или пробел
history.push(entry);
// Метку поставим потом
} else {
// Остальные спецклавиши
entry.mark = 'control';
}
}
// Второй проход — корректные и ошибочные символы
for (const entry of flat) {
if (entry.mark) continue;
entry.mark = entry.deleted ? 'error' : 'correct';
}
return flat;
}
function collectSpeedStats(annotatedData, range=null) {
let correctCount = 0;
let errorCount = 0;
let totalTime = 0;
let partialTime = 0;
let correctTime = 0;
let correctionSeries = 0;
let isPrevCorrection = false;
for (const { mark, delay } of annotatedData) {
totalTime += delay;
if (range) {
if ((totalTime < (range.min-1)*1000) || (totalTime > (range.max * 1000))) continue;
}
partialTime += delay;
if (mark === 'correct') {
correctCount++;
correctTime += delay;
} else if (mark === 'error') {
errorCount++;
}
// count correction series
const isCorrection = (mark === 'correction') || (mark === 'error');
if (!isPrevCorrection && isCorrection) {
correctionSeries++;
}
isPrevCorrection = isCorrection;
}
const totalSeconds = partialTime / 1000;
const totalMinutes = totalSeconds / 60;
const correctMinutes = correctTime / 1000 / 60;
const nettoCPM = totalMinutes > 0 ? +(correctCount / totalMinutes).toFixed(2) : 0;
const bruttoCPM = correctMinutes > 0 ? +(correctCount / correctMinutes).toFixed(2) : 0;
return {
correctCount,
errorCount,
correctionSeries,
totalTimeSec: +totalSeconds.toFixed(2),
totalTimeStr: formatDecimal(formatTime(+totalSeconds.toFixed(2))),
nettoCPM,
bruttoCPM,
isPartial: totalTime != partialTime
};
}
function collectDelayStats(annotatedData, range=null) {
const correct = annotatedData.filter(p => (p.mark === 'correct'));
const idxStart = range?.idxStart || 0;
const idxEnd = range?.idxEnd || correct.length - 1;
const isPartial = (range)? (correct.length != (range.idxEnd - range.idxStart + 1)) : false;
let totalTime = 0;
if (!isPartial) {
//need to collect totalTime also
for (const { delay } of annotatedData) {
totalTime += delay;
}
}
const delays = [];
for (let i = idxStart; i <= idxEnd; i++ ) {
const { delay } = correct[i];
if (delay) {
delays.push(delay);
}
}
const correctTime = delays.reduce((a,v) => a+v, 0);
const totalChars = idxEnd - idxStart + 1;
const min = Math.min(...delays);
const max = Math.max(...delays);
const avg = correctTime / delays.length;
const nettoCPM = (isPartial)? 0 : +(totalChars * 60000 / totalTime).toFixed(2);
const bruttoCPM = +(totalChars * 60000 / correctTime).toFixed(2);
const diffSpeedStr = (!isPartial && totalTime != correctTime)? formatDecimal((bruttoCPM - nettoCPM).toFixed(2)) : null;
const diffTimeStr = (!isPartial && totalTime != correctTime)? formatDecimal(formatTime(+((totalTime - correctTime) / 1000).toFixed(3))) : null;
return {
min,
max,
avg,
totalChars,
bruttoCPM,
correctTimeSec: +(correctTime / 1000).toFixed(3),
correctTimeStr: formatDecimal(formatTime(+(correctTime / 1000).toFixed(3))),
diffSpeedStr: diffSpeedStr,
diffTimeStr: diffTimeStr,
isPartial: isPartial
};
}
function collectHistStats(annotatedData) {
const correct = annotatedData.filter(p => (p.mark === 'correct'));
const delays = [];
for (const { delay } of correct) {
if (delay) {
delays.push(delay);
}
}
return Stat.analyzeDelays(delays);
}
function buildText(annotatedData) {
let textHTML = ''; // text for speedChart (WITH errors/corrections)
let textHTMLClean = ''; // text for delayChart (WITHOUT errors/corrections)
let text = ''; // restored original text
let prevSec = -1;
let totalTime = 0;
let lastMark = '';
let curCharIdx = 0;
for (const { key, mark, delay } of annotatedData) {
totalTime += delay;
let curSec = Math.floor(totalTime / 1000);
if (curSec != prevSec) {
if (prevSec != -1) {
textHTML += '</span>';
//we should fill the gap with empty spans in order to be consistent with the chart's x-value
if (curSec - prevSec > 1) {
for (let i = prevSec + 1; i < curSec; i++) {
textHTML += `<span class='s s${i+1} idle'></span>`;
}
}
}
textHTML += `<span class='s s${curSec+1}'>`;
prevSec = curSec;
}
if (mark === 'correct') {
if (delay && (delay < FAST_DELAY_THRESHOLD)) {
textHTML += `<span class='fast' title='${delay}ms'>${key}</span>`;
textHTMLClean += `<span class='c c${curCharIdx++}'><span class='fast' title='${delay}ms'>${key}</span></span>`;
} else {
textHTML += key;
textHTMLClean += `<span class='c c${curCharIdx++}' title='${delay}ms'>${key}</span>`;
}
text += key;
} else if (mark === 'error') {
textHTML += `<span class='err'>${key == ' ' ? HTML_VISIBLE_SPACE : key}</span>`;
} else { // correction
textHTML += `<span class='corr' title='${delay}ms'>${key.replace(/Backspace/, '🠈')}</span>`;
}
lastMark = mark;
}
textHTML += '</span>';
return {
textHTML,
textHTMLClean,
text,
};
}
function buildHistText(annotatedData, cutValue) {
let textHTMLClean = ''; // text for histChart (WITHOUT errors/corrections)
for (const { key, mark, delay } of annotatedData) {
if (mark !== 'correct') continue;
const gradIdx = (delay && delay <= cutValue)? Math.floor(delay / HISTOGRAM_BIN_SIZE) : Math.floor(HISTOGRAM_MAX_X / HISTOGRAM_BIN_SIZE);
textHTMLClean += `<span class="grad${gradIdx}" title="${delay}ms">${key}</span>`;
}
return textHTMLClean;
}
// this will be needed later, for detecting duplicate data while loading \ pasting
function makeHash(data) {
let hash = 0;
for (let i=0; i < data.length; i++) {
const char = data.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash >>> 0;
}
// --- DRAGGABLE MODAL WINDOW FUNCTIONS --- //
function clamp(val, min, max) {
return Math.min(Math.max(val, min), max);
}
async function loadUPlotIfNeeded(callback) {
if (window.uPlot) return callback();
// inject uPlot CSS
if (!document.querySelector(`link[href="${UPLOT_CSS}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = UPLOT_CSS;
document.head.appendChild(link);
}
// inject uPlot JS
const script = document.createElement('script');
script.src = UPLOT_JS;
script.onload = callback;
document.head.appendChild(script);
}
function showMainWindow(contentHTML, afterRender) {
let modal = oO(`#${MODAL_ID}`);
if (modal) modal.remove(); // close previous instance, if any
modal = document.createElement('div');
modal.id = MODAL_ID;
modal.tabIndex = -1; // this is for receiving keydown events
// listen for onPaste event to display WTS directly from clipboard
modal.addEventListener("paste", (e) => {
e = e || window.event;
let clipboardData, pastedData;
// Stop data actually being pasted
e.preventDefault();
// Get pasted data via clipboard API
clipboardData = e.clipboardData || window.clipboardData;
pastedData = clipboardData.getData('Text');
// let hash = makeHash(pastedData);
let fullWTS;
try {
fullWTS = JSON.parse(pastedData);
} catch(e) {
showToast(TOAST_INVALID_PASTE_DATA, 'err');
return;
}
if (!fullWTS.data) {
//TODO: remake this bullshit (or remove this at all!)
let newWTS = {};
newWTS.data = fullWTS;
newWTS.type = 'unknown';
newWTS.uid = 0;
newWTS.time = +Date.now();
fullWTS = newWTS;
}
__files.push(fullWTS);
let newIdx = 0;
let sel;
if (__appMode != AM_FILES) {
setAppMode(AM_FILES);
sel = oO('#wts-file-list');
} else {
let dummySelHTML = createWTSListElement('dummy', __files, null);
dummySelHTML = dummySelHTML.replace(/<\/?select.*?>/g, ''); // ha-ha, genius, lol!
sel = oO('#wts-file-list');
sel.innerHTML = dummySelHTML;
newIdx = __files.length - 1;
}
sel.selectedIndex = newIdx;
sel.dispatchEvent(new Event('change')); //trigger onChange event
sel.focus();
});
// Ctrl+C useful handler for copying current WTS into clipboard
modal.addEventListener("copy", (e) => {
if (window.getSelection().toString().length) return; // do not copy WTS, if we have selected something on the page
e = e || window.event;
// Stop data actually being copied
e.preventDefault();
if (lastRenderedWTS) {
navigator.clipboard.writeText(JSON.stringify(lastRenderedWTS));
showToast(TOAST_CLIPBOARD_COPY_OK);
} else {
showToast(TOAST_CLIPBOARD_COPY_FAIL, 'err');
};
});
// переключение графиков стрелками ← → и по alt+1..3, а также обработка клавиши del и шорткатов меню
modal.addEventListener("keydown", (e) => {
if (!chartFrames.length) return;
let isCaptured = false;
// switch charts with alt+1..3 of with ← →
if (e.altKey && ['1', '2', '3'].includes(e.key)) {
e.preventDefault();
const newFrameIndex = parseInt(e.key) - 1;
if (newFrameIndex != currentFrameIndex) {
showFrame(newFrameIndex);
} else {
// showToast('Дак мы уже тут!', 'warn');
}
} else if ((e.key === "ArrowRight") && (currentFrameIndex < chartFrames.length - 1)) {
showFrame(currentFrameIndex + 1);
} else if ((e.key === "ArrowLeft") && (currentFrameIndex > 0)) {
showFrame(currentFrameIndex - 1);
} else if (e.key === 'Delete' && __appMode == AM_FILES && __files.length) {
// process 'Del' button in files mode
e.preventDefault();
const sel = oO('#wts-file-list');
let curIdx = sel.value;
if (e.shiftKey) {
let type = __files[curIdx].type;
__files = __files.filter(wts => {
return (e.ctrlKey)?
(wts.type == type): // delete all, EXCEPT of same type as current
(wts.type != type); // delete all of same type
});
curIdx = 0;
} else if (e.ctrlKey) {
// delete all, EXCEPT current
__files = [__files[curIdx]]; //lol!
curIdx = 0;
} else {
// delete single
__files.splice(curIdx, 1);
}
if (__files.length) {
let dummySelHTML = createWTSListElement('dummy', __files, null);
dummySelHTML = dummySelHTML.replace(/<\/?select.*?>/g, '');
sel.innerHTML = dummySelHTML;
let newIdx = Math.min(curIdx, __files.length - 1);
sel.selectedIndex = newIdx;
sel.dispatchEvent(new Event('change')); //trigger onChange event
sel.focus();
} else {
__archive = JSON.parse(localStorage.WTS_ARCHIVE).reverse();
if (__archive.length) {
setAppMode(AM_ARCHIVE);
} else {
setAppMode(AM_EMPTY);
}
}
} else if (e.ctrlKey || e.metaKey) {
const shortCuts = Menu.ctrlShortCuts;
if (Object.keys(shortCuts).includes(e.code)) {
e.preventDefault();
Menu[shortCuts[e.code]](e);
}
}
if (['ArrowRight', 'ArrowLeft'].includes(e.key)) {
e.preventDefault();
}
});
const header = document.createElement('div');
header.className = 'wts-header';
header.id = 'wts-header';
let menuHTML = `
<div class="wts-menu-wrapper">
<span class="wts-button">☰</span>
<div class="wts-menu">
<div class="wts-menu-header">Чего изволите?</div>
<a href="#" data-action="openFile" title="${MENU_OPENFILE_HINT}">Открыть...</a>
<hr>
<a href="#" data-action="saveToFile" title="${MENU_SAVEFILE_HINT}">Сохранить файл</a>
<a href="#" data-action="saveArchive" title="${MENU_SAVEARCHIVE_HINT}">Сохранить текущий архив</a>
<hr>
<a href="#" data-action="publishToBlog" title="${MENU_PUBLISHBLOG_HINT}">Опубликовать в БЖ</a>
<hr>
<a href="https://klavogonki.ru/u/#/111001/journal/68a8aea56271aec5a58b4567" title="${MENU_HELP_HINT}">Памагити!!!</a>
</div>
</div>
`;
header.innerHTML = `<span class="wts-header-title"></span><span class="wts-header-info"></span><span class="wts-emptyspace"></span>${menuHTML}<span class="wts-close" title="${CLOSE_BUTTON_HINT}">×</span>`;
// set handlers for each menu item, based on data-action attribute
let links = header.querySelectorAll('.wts-menu a');
for (let link of links) {
link.onclick = (e) => {
const action = e.target.getAttribute('data-action');
if (action) {
if (Menu[action]) {
Menu[action](e);
} else {
showToast('Not implemented yet', 'err');
}
e.preventDefault();
e.stopPropagation();
}
//magic! © mr Bean
e.target.parentElement.style.display = 'none';
setTimeout(()=>{
e.target.parentElement.style.display = '';
oO(`#${MODAL_ID}`).focus();
}, 500);
if (!action) {
window.open(e.target.href, '_blank');
}
}
}
header.onclick = (e) => {
e.preventDefault();
e.target.parentElement.focus();
}
//TODO: remove in future! (or not)
header.ondblclick = (e) => {
if (!['SPAN', 'DIV'].includes(e.target.nodeName)) {
return;
}
if (lastRenderedWTS) {
navigator.clipboard.writeText(JSON.stringify(lastRenderedWTS));
showToast(TOAST_CLIPBOARD_COPY_OK);
} else {
showToast(TOAST_CLIPBOARD_COPY_FAIL, 'err');
};
};
const content = document.createElement('div');
content.className = 'wts-content';
content.innerHTML = contentHTML;
const toast = document.createElement('div');
toast.id = 'wts-toast';
const overlay = document.createElement('div');
overlay.id = 'wts-overlay';
overlay.className = 'wts-overlay';
overlay.style.display = 'none';
overlay.innerHTML = `
<div class="wts-progress-box">
<div class="wts-progress-text" id="wts-progress-text">
Подготовка...
</div>
<div class="wts-progress-bar">
<div class="wts-progress-fill" id="wts-progress-fill"></div>
</div>
<button id="wts-cancel" style="display:none;">Отменить</button>
</div>
`;
const fileOpener = document.createElement('input');
fileOpener.id = 'fileOpener';
fileOpener.type = 'file';
fileOpener.multiple = true;
fileOpener.accept = '.wts,.wtsa';
fileOpener.addEventListener('change', async (e) => {
let fileList = e.target.files;
let newIdx = 0;
if (!fileList.length) {
hideOverlay();
return;
}
const isAppending = e.target.getAttribute('data-append') === "true";
if (!isAppending) {
__files = [];
newIdx = 0;
} else {
if (__files.length) newIdx = __files.length;
}
showOverlay();
setIndeterminate();
const fileArray = Array.from(fileList);
const filteredFileArray = fileArray.filter(f=>{
const ext = f.name.split('.').pop();
return ['wts', 'wtsa'].includes(ext);
});
let totalFiles = filteredFileArray.length;
let processedFiles = 0;
const processingPromises = Array.from(fileList).map(async (file) => {
const reader = new FileReader();
const promise = new Promise((resolve) => {
reader.onload = (e) => resolve(e.target.result);
reader.readAsText(file);
});
const content = await promise;
try {
let jsonData = JSON.parse(content);
if (Array.isArray(jsonData)) {
__files.push(...jsonData);
} else {
__files.push(jsonData);
}
processedFiles++;
} catch(e) {
console.log(`[WTS]: Error processing '${file.name}'`);
}
return { name: file.name, content };
});
await Promise.all(processingPromises);
e.target.value = ''; //reset fileOpener
hideOverlay();
if (__files.length) {
setAppMode(AM_FILES);
const sel = oO('#wts-file-list');
sel.selectedIndex = newIdx;
sel.dispatchEvent(new Event('change')); //trigger onChange event
sel.focus();
} else {
setAppMode(AM_EMPTY);
setWindowHeaderTitle('🙈 Что-то нажалось и всё исчезло!');
}
const plurals = (isAppending)?['файл добавлен', 'файла добавлено', 'файлов добавлено']:['файл загружен', 'файла загружено', 'файлов загружено'];
if (processedFiles != totalFiles) {
showToast(`${processedFiles} ${getPluralForm(processedFiles, plurals)}, ${totalFiles - processedFiles} ${getPluralForm(totalFiles-processedFiles, ['проскипан', 'проскипано', 'проскипано'])}`, 'warn');
} else {
showToast(`${processedFiles} ${getPluralForm(processedFiles, plurals)}`);
}
});
fileOpener.addEventListener('cancel', () => {
hideOverlay();
});
header.querySelector('.wts-close').onclick = () => modal.remove();
modal.appendChild(header);
modal.appendChild(content);
modal.appendChild(toast);
modal.appendChild(overlay);
modal.appendChild(fileOpener);
document.body.appendChild(modal);
modal.focus();
// Restore window position
const saved = localStorage.getItem(STORAGE_POS_KEY);
let pos = saved ? JSON.parse(saved) : {
left: 100,
top: 50
};
setPosition(pos.left, pos.top);
// Dragging
let isDragging = false,
offsetX = 0,
offsetY = 0;
header.addEventListener('click', (e) => {
if (__appMode == AM_ARCHIVE || __appMode == AM_FILES) {
const sel = e.currentTarget.getElementsByTagName('SELECT')[0];
if (sel) {
sel.focus();
}
} else {
header.parentElement.focus();
}
});
header.addEventListener('mousedown', (e) => {
if (!['SPAN', 'DIV'].includes(e.target.nodeName)) {
return;
}
isDragging = true;
offsetX = e.clientX - modal.offsetLeft;
offsetY = e.clientY - modal.offsetTop;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
const maxX = window.innerWidth - modal.offsetWidth;
const maxY = window.innerHeight - modal.offsetHeight;
const left = clamp(e.clientX - offsetX, 0, maxX);
const top = clamp(e.clientY - offsetY, 0, maxY);
setPosition(left, top);
}
function onMouseUp() {
if (!isDragging) return;
isDragging = false;
localStorage.setItem(STORAGE_POS_KEY, JSON.stringify({
left: modal.offsetLeft,
top: modal.offsetTop
}));
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
function setPosition(left, top) {
modal.style.left = `${left}px`;
modal.style.top = `${top}px`;
}
// Execute callback after window insertion and positioning
if (typeof afterRender === 'function') {
setTimeout(() => afterRender(), 50); // wait for DOM rerender
}
}
function showToast(text, type='ok') {
const toast = oO('#wts-toast');
toast.removeAttribute('class'); //clear previous state
toast.innerHTML = text;
toast.classList.add(type);
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, TOAST_LIFETIME);
}
function setAppMode(mode) {
switch (mode) {
case AM_INGAME: {
if (localStorage.curWTS) {
__appMode = AM_INGAME;
setWindowHeaderTitle(`🎹 Текущий ${(__isGameFinished && !__isGameFailed)?'заезд':'недоезд'}`);
setWindowHeaderInfo('');
setMainWindowContent(MWC_CHARTS);
renderWTSCharts(JSON.parse(localStorage.curWTS));
} else {
setAppMode(AM_EMPTY);
}
break;
}
case AM_ARCHIVE: {
if (__archive.length) {
__appMode = AM_ARCHIVE;
setWindowHeaderTitle(`📜 Архив: `);
const selectorHTML = createWTSListElement('archive', __archive, 'date');
setWindowHeaderInfo(selectorHTML, true);
// set onchange event on newly created selector
const sel = oO('#wts-archive-list');
sel.onchange = (e) => {
renderWTSCharts(__archive[e.target.value]);
};
setMainWindowContent(MWC_CHARTS);
sel.dispatchEvent(new Event('change')); //trigger onChange event
sel.focus();
} else {
setAppMode(AM_EMPTY);
}
break;
}
case AM_FILES: {
if (__files.length) {
__appMode = AM_FILES;
setWindowHeaderTitle(`📂 Загруженное: `);
const selectorHTML = createWTSListElement('file', __files, null);
setWindowHeaderInfo(selectorHTML, true);
// set onchange event on newly created selector
const sel = oO('#wts-file-list');
sel.onchange = (e) => {
renderWTSCharts(__files[e.target.value]);
};
setMainWindowContent(MWC_CHARTS);
sel.dispatchEvent(new Event('change')); //trigger onChange event
sel.focus();
} else {
setAppMode(AM_EMPTY);
}
break;
}
case AM_EMPTY:
default: {
__appMode = AM_EMPTY;
setWindowHeaderTitle('🙈 тут ничего нет!');
setWindowHeaderInfo('');
setMainWindowContent(MWC_EMPTY);
break;
}
}
}
function setWindowHeaderTitle(title, forceHTML=false) {
const el = oO('#wts-header').querySelector('.wts-header-title');
if (forceHTML) {
el.innerHTML = title;
} else {
el.textContent = title;
}
}
function setWindowHeaderInfo(info, forceHTML=false) {
const el = oO('#wts-header').querySelector('.wts-header-info');
if (forceHTML) {
el.innerHTML = info;
} else {
el.textContent = info;
}
}
// --- Overlay functions ---
const cancelBtn = oO("#wts-cancel");
// show overlay
function showOverlay() {
oO("#wts-overlay").style.display = "flex";
}
// hide overlay
function hideOverlay() {
oO("#wts-overlay").style.display = "none";
const fillEl = oO("#wts-progress-fill");
fillEl.classList.remove("indeterminate");
fillEl.style.width = "0";
}
// indeterminate mode
function setIndeterminate(message = "Обработка...") {
oO("#wts-progress-text").textContent = message;
const fillEl = oO("#wts-progress-fill");
fillEl.classList.add("indeterminate");
fillEl.style.width = "30%"; // фикс ширина, анимация делает остальное
}
// determinate mode
function setProgress(current, total, message = "") {
const percent = Math.round((current/total)*100);
const fillEl = oO("#wts-progress-fill");
oO("#wts-progress-text").textContent = message || `Файл ${current} из ${total} (${percent}%)`;
fillEl.classList.remove("indeterminate");
fillEl.style.width = percent + "%";
}
function getGameTypeStr(type) {
let gameTypeStr;
if (type.match(/voc-/)) {
const vocID = type.replace('voc-', '');
gameTypeStr = POPULAR_VOCS[vocID] || `Словарь #${vocID}`;
} else {
gameTypeStr = GAME_MODES[type] || GAME_MODES.unknown;
}
return gameTypeStr;
}
function formatDecimal(f) {
const parts = f.toString().split('.');
return (parts.length == 2) ? `${parts[0]}<span>.${parts[1]}</span>` : f;
}
function formatTime(seconds, fractionDigits = 2, forceShowMinutes = false, forceShowFraction = true) {
const scale = 10 ** fractionDigits;
const total = Math.round(seconds * scale);
const secs = Math.floor(total / scale);
const minutes = Math.floor(secs / 60);
const mm = String(minutes).padStart(1, "0");
const ss = String(secs % 60).padStart(2, "0");
const frac = String(total % scale).padStart(fractionDigits, "0");
const timeCore = (minutes > 0 || forceShowMinutes) ?
`${mm}:${ss}` : String(secs % 60);
return (fractionDigits > 0 && ( +frac !== 0 || forceShowFraction)) ?
`${timeCore}.${frac}` : timeCore;
}
function getPluralForm(cnt, titles)
{
const cases = [2, 0, 1, 1, 1, 2];
return titles[ (cnt%100 > 4 && cnt%100 < 20)? 2: cases[Math.min(cnt%10, 5)] ];
}
// menu actions & shortcuts
const Menu = {
ctrlShortCuts: {
'KeyO':'openFile',
'KeyS':'saveFile',
'KeyB':'publishToBlog',
},
openFile: function(e) {
oO('#fileOpener').setAttribute('data-append', e.shiftKey); //sets true, if shift was pressed while clicking menu tiem, or false otherwise;
oO('#fileOpener').click(e);
},
// master function for saving, kinda virtual
saveFile: function(e) {
if (e.shiftKey) {
this.saveArchive();
} else {
this.saveToFile();
}
},
// save single WTS to file (Ctrl+S)
saveToFile: function() {
if (!lastRenderedWTS) {
showToast(TOAST_NOTHING_TO_SAVE, 'err');
return;
}
const stats = collectSpeedStats(annotatedData); // if we have lastRenderedWTS, then we should have annotatedData
let data = JSON.stringify(lastRenderedWTS);
let fileName = `${getGameTypeStr(lastRenderedWTS.type)} (${stats.nettoCPM.toFixed(0)}-${stats.correctionSeries}).wts`;
this._saveFile(data, fileName);
},
// save archive \ temporarily loaded files (Ctrl+Shift+S)
saveArchive: function() {
let data = null;
if (__appMode == AM_FILES && __files.length) {
data = JSON.stringify(__files);
} else if (__archive.length) {
data = localStorage.WTS_ARCHIVE;
} else {
showToast(TOAST_NOTHING_TO_SAVE, 'err');
return;
}
const d = new Date();
const date = `${d.getFullYear()}${(d.getMonth()+1).toString().padStart(2,'0')}${d.getDate().toString().padStart(2,'0')}`;
const time = `${d.getHours().toString().padStart(2, '0')}${d.getMinutes().toString().padStart(2, '0')}${d.getSeconds().toString().padStart(2, '0')}`;
const fileName = (__appMode == AM_FILES)? `wts-collection-${date}.wtsa` : `wts-archive-${date}.wtsa`;
this._saveFile(data, fileName);
},
// publish currently rendered WTS to blog (Ctrl+B → hidden post, Ctrl+Shift+B → public post)
publishToBlog: function (e) {
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
if (typeof __user__ === 'undefined') {
showToast(TOAST_USER_NOT_LOGGED_IN, 'err');
return;
}
if (!lastRenderedWTS) {
showToast(TOAST_NOTHING_TO_PUBLISH, 'err');
return;
}
const isHidden = !e.shiftKey;
const isJSON = e.altKey;
showOverlay();
if (isJSON) {
setIndeterminate((isHidden)?'Прячем JSON в БЖ...':'Публикуем JSON в БЖ...');
} else {
setIndeterminate((isHidden)?'Прячем в БЖ...':'Публикуем в БЖ...');
}
let textContent = '';
if (isJSON) {
textContent = '```\n' + JSON.stringify(lastRenderedWTS) + '\n```';
} else {
const stats = collectSpeedStats(annotatedData);
const timeStr = formatTime(stats.totalTimeSec, 1, true, false); //force show minutes, but do not show fraction when fraction == 0
const texts = buildText(annotatedData);
// IDK why header is not centered, maybe glitch in CSS?
// that's why we skipped header and make it with regular cells
textContent += `| | | | | |\n`;
textContent += "| :---: | :---: | :---: | :---: | :---: |\n";
textContent += `| **${stats.nettoCPM.toFixed(0)}** | **${stats.correctionSeries}** | ${stats.bruttoCPM.toFixed(0)} | ${timeStr} | ${stats.correctCount} ${(stats.errorCount)?`(+${stats.errorCount})`:''} |\n`;
textContent += "| `скорость` | `ошибки` | `брутто` | `время` | `знаки` |\n\n";
//TODO: reset speedChart scale?
const pic1 = oO('wts-chart0').querySelector('canvas').toDataURL('image/webp');
textContent += `\n\n`;
let textHTML = texts.textHTML;
let mdText = textHTML
.replaceAll(HTML_VISIBLE_SPACE, MD_VISIBLE_SPACE)
.replaceAll(/<span class='err'>(.+?)<\/span>/g, "~~$1~~")
.replaceAll(/<span class='corr'.+?>.+?<\/span>/g, '')
.replaceAll(/<span class='fast'.+?>(.+?)<\/span>/g, '$1')
.replaceAll(/<span class='s.+?'>(.*?)<\/span>/g, '$1')
.replaceAll('~~~~', '')
textContent += `> ${mdText}`;
}
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/profile/add-journal-post");
xhr.setRequestHeader("X-XSRF-TOKEN", getCookie('XSRF-TOKEN'));
xhr.onload = () => {
if (this.status !== 200) {
showToast(TOAST_SOMETHING_WENT_WRONG, 'err');
}
hideOverlay();
showToast(isHidden? TOAST_BLOG_HIDDEN_POST_ADDED : TOAST_BLOG_POST_ADDED);
};
xhr.send(JSON.stringify({
userId: __user__,
text: textContent,
hidden: isHidden,
}));
},
// common function for saving files
_saveFile: function(data, fileName) {
const blob = new Blob([data], { type: "text/plain;charset=utf-8" });
const blobURL = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobURL;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
//cleanup
setTimeout(() => {
a.remove();
URL.revokeObjectURL(blobURL);
}, 1000);
}
}
const KeybLayout = {
layouts: {
// === Карты раскладок ===
// EN
qwerty: {
descr: 'QWERTY',
KeyQ:"q", KeyW:"w", KeyE:"e", KeyR:"r", KeyT:"t", KeyY:"y", KeyU:"u", KeyI:"i", KeyO:"o", KeyP:"p",
KeyA:"a", KeyS:"s", KeyD:"d", KeyF:"f", KeyG:"g", KeyH:"h", KeyJ:"j", KeyK:"k", KeyL:"l",
KeyZ:"z", KeyX:"x", KeyC:"c", KeyV:"v", KeyB:"b", KeyN:"n", KeyM:"m"
},
qwertz: {
descr: 'QWERTZ',
KeyQ:"q", KeyW:"w", KeyE:"e", KeyR:"r", KeyT:"t", KeyY:"z", KeyU:"u", KeyI:"i", KeyO:"o", KeyP:"p",
KeyA:"a", KeyS:"s", KeyD:"d", KeyF:"f", KeyG:"g", KeyH:"h", KeyJ:"j", KeyK:"k", KeyL:"l",
KeyZ:"y", KeyX:"x", KeyC:"c", KeyV:"v", KeyB:"b", KeyN:"n", KeyM:"m"
},
azerty: {
descr: 'AZERTY',
KeyA:"q", KeyZ:"w", KeyE:"e", KeyR:"r", KeyT:"t", KeyY:"y", KeyU:"u", KeyI:"i", KeyO:"o", KeyP:"p",
KeyQ:"a", KeyS:"s", KeyD:"d", KeyF:"f", KeyG:"g", KeyH:"h", KeyJ:"j", KeyK:"k", KeyL:"l", KeyM:"m",
KeyW:"z", KeyX:"x", KeyC:"c", KeyV:"v", KeyB:"b", KeyN:"n"
},
dvorak: {
descr: 'Dvorak',
KeyQ:"'", KeyW:",", KeyE:".", KeyR:"p", KeyT:"y", KeyY:"f", KeyU:"g", KeyI:"c", KeyO:"r", KeyP:"l",
KeyA:"a", KeyS:"o", KeyD:"e", KeyF:"u", KeyG:"i", KeyH:"d", KeyJ:"h", KeyK:"t", KeyL:"n",
KeyZ:";", KeyX:"q", KeyC:"j", KeyV:"k", KeyB:"x", KeyN:"b", KeyM:"m"
},
colemak: {
descr: 'Colemak',
KeyQ:"q", KeyW:"w", KeyE:"f", KeyR:"p", KeyT:"g", KeyY:"j", KeyU:"l", KeyI:"u", KeyO:"y", KeyP:";",
KeyA:"a", KeyS:"r", KeyD:"s", KeyF:"t", KeyG:"d", KeyH:"h", KeyJ:"n", KeyK:"e", KeyL:"i",
KeyZ:"z", KeyX:"x", KeyC:"c", KeyV:"v", KeyB:"b", KeyN:"k", KeyM:"m"
},
// RU
ru_jcuken: {
descr: 'ЙЦУКЕН',
KeyQ:"й", KeyW:"ц", KeyE:"у", KeyR:"к", KeyT:"е", KeyY:"н", KeyU:"г", KeyI:"ш", KeyO:"щ", KeyP:"з",
KeyA:"ф", KeyS:"ы", KeyD:"в", KeyF:"а", KeyG:"п", KeyH:"р", KeyJ:"о", KeyK:"л", KeyL:"д",
KeyZ:"я", KeyX:"ч", KeyC:"с", KeyV:"м", KeyB:"и", KeyN:"т", KeyM:"ь"
},
ru_diktor: {
descr: 'Диктор',
KeyQ:"я", KeyW:"ч", KeyE:"о", KeyR:"л", KeyT:"д", KeyY:"у", KeyU:"т", KeyI:"ь", KeyO:"б", KeyP:"ю",
KeyA:"а", KeyS:"и", KeyD:"е", KeyF:"н", KeyG:"к", KeyH:"р", KeyJ:"с", KeyK:"в", KeyL:"м",
KeyZ:"ж", KeyX:"з", KeyC:"й", KeyV:"ф", KeyB:"г", KeyN:"ш", KeyM:"ц"
},
ru_phonetic: {
descr: 'Русская фонетическая',
KeyQ:"я", KeyW:"ш", KeyE:"е", KeyR:"р", KeyT:"т", KeyY:"ы", KeyU:"у", KeyI:"и", KeyO:"о", KeyP:"п",
KeyA:"а", KeyS:"с", KeyD:"д", KeyF:"ф", KeyG:"г", KeyH:"ч", KeyJ:"й", KeyK:"к", KeyL:"л",
KeyZ:"з", KeyX:"ь", KeyC:"ц", KeyV:"ж", KeyB:"б", KeyN:"н", KeyM:"м"
},
},
getLayouts: function() {
return this.layouts;
},
parseLayoutStr: function(s) {
let result = '';
const parts=s.split(':');
try {
const name = this.getLayoutDescr(parts[0]);
const score = parseFloat(parts[1]);
if (isNaN(score) || score<0 || score>1) return false;
if (score == 1) {
result = name;
} else {
result = `Не определена. Возможно ${name}, но это не точно (${(score*100).toFixed(0)}%)`;
}
} catch(e) {
result = 'Ошибка в данных';
}
return result;
},
getLayoutDescr: function(name) {
const layouts = this.getLayouts();
return layouts[name]?.descr || 'Неизвестна';
},
// === Функция детекции ===
detect: function (samples) {
const samplesArray = Object.entries(samples);
if (samplesArray.length < MIN_LAYOUT_DETECTION_SAMPLES) return 'not enough data';
const results = [];
const layoutsArray = Object.entries(this.getLayouts());
for (let [name, map] of layoutsArray) {
let total = 0, match = 0;
for (let [code, key] of samplesArray) {
if (map[code]) {
total++;
if (map[code].toLowerCase() === key.toLowerCase()) {
match++;
}
}
}
if (total > 0) {
results.push({
name,
score: +(match / total).toFixed(2)
});
}
}
const sorted = results.sort((a, b) => b.score - a.score);
return (sorted.length)? `${sorted[0].name}:${sorted[0].score}`:false;
}
}
function setMainWindowContent(contentType=MWC_EMPTY) {
let contentHTML = '';
switch (contentType) {
case MWC_EMPTY:
contentHTML = `
<div style="height: 400px; display: flex; flex-direction: column; justify-content: center; align-items: anchor-center;">
<h4>Сорри, а показывать-то и нечего!</h4>
<p><i><b>«Нельзя впихнуть невпихуемое и визуализнуть невизуализуемое»</b> © Ун Фо Гив</i></p>
<p>Для того, чтобы отобразить что-нибудь ненужное, надо сначала получить что-нибудь ненужное, а у нас данных нет. Данные можно получить либо проехав заезд, либо открыв файл из менюшки (☰), либо воткнув JSON-чик через Ctrl+V :)</p>
</div>
`;
break;
case MWC_CHARTS:
contentHTML = `
<div id="wts-frames">
<div class="wts-frame active">
<div id="wts-stats0" class="wts-stats"></div>
<div id="wts-chart0" class="wts-chart"></div>
<div id="wts-text-controls"><div><input type="checkbox" id="hide-fast"><label for="hide-fast" title="помечать нажатия с паузой < ${FAST_DELAY_THRESHOLD} мс">быстрые нажатия</label><input type="checkbox" id="hide-err"><label for="hide-err" title="показывать ошибочно набранные символы">опечатки</label><input type="checkbox" id="hide-corr"><label for="hide-corr" title="показывать нажатия служебных клавиш">доп. клавиши</label></div></div>
<div id="wts-text0" class='wts-text'></div>
</div>
<div class="wts-frame">
<div id="wts-stats1" class="wts-stats"></div>
<div id="wts-chart1" class="wts-chart"></div>
<div id="wts-text1" class='wts-text'></div>
</div>
<div class="wts-frame">
<div id="wts-stats2" class="wts-stats"></div>
<div id="wts-chart2" class="wts-chart"></div>
<div id="wts-text2" class='wts-text'></div>
</div>
</div>
<div class="wts-overlay" id="wts-overlay" style="display:none;">
<div class="wts-progress-box">
<div class="wts-progress-text" id="wts-progress-text">
Подготовка...
</div>
<div class="wts-progress-bar">
<div class="wts-progress-fill" id="wts-progress-fill"></div>
</div>
<button id="wts-cancel" style="display:none;">Отменить</button>
</div>
</div>
`;
currentFrameIndex = 0;
break;
}
//set content
oO(`${MODAL_ID}`).querySelector('.wts-content').innerHTML = contentHTML;
//add event listeners
switch (contentType) {
case MWC_CHARTS:
oO('#wts-text-controls').addEventListener("change", e=> {
oO('#wts-text0').classList.toggle(e.target.id, !e.target.checked);
let allCBs = oO('#wts-text-controls').getElementsByTagName('input');
let tcOptions = {};
for (let cb of allCBs) {
const {id, checked} = cb;
tcOptions[id] = checked;
}
// save options
localStorage.setItem(STORAGE_TEXT_CONTROL_OPTIONS_KEY, JSON.stringify(tcOptions));
if (e.target.id == 'hide-err') {
//redraw chart
Charts[0].series[3].show = e.target.checked;
Charts[0].redraw(false);
}
// return focus to our window for correct processing ← →
oO(`${MODAL_ID}`).focus();
});
break;
}
}
function postInitMainWindow() {
let mode = AM_EMPTY; // default
// set appMode
if (__isInGame && localStorage.curWTS) {
mode = AM_INGAME;
} else {
__archive = JSON.parse(localStorage.getItem('WTS_ARCHIVE') || "[]").reverse();
if (__archive.length) {
mode = AM_ARCHIVE;
} else {
mode = AM_EMPTY;
}
}
setAppMode(mode);
}
function createWTSListElement(id, archive, delimiter='date') {
let selectHTML = `<select id='wts-${id}-list'>`;
let i = 0;
const dateOpts = {month: 'long', day: 'numeric'};
let prevDate = new Date().toLocaleDateString('ru-RU', dateOpts);
for (let wts of archive) {
const datetime = new Date(wts.time)
const date = datetime.toLocaleDateString('ru-RU', dateOpts);;
const time = datetime.toLocaleTimeString().substr(0, 5);
if (delimiter) {
switch (delimiter) {
case 'date':
if (date != prevDate) {
selectHTML += `<option disabled>-- ${date} --</option>`;
prevDate = date;
}
break;
case 'file':
//TODO: implement later (or not)
break;
}
}
let tmpAnnotated = annotateKeypresses(wts.data);
let stats = collectSpeedStats(tmpAnnotated);
const isQual = wts.sysInfo?.isQual || false;
// sanitize wts.type for preventing possible XSS
let classNamePostfix = wts.type.split('-')[0];
if (classNamePostfix != 'voc' && !GAME_MODES[classNamePostfix]) classNamePostfix = 'normal';
selectHTML += `<option class='gametype-${classNamePostfix}' value='${i++}' title='${time}\n${date}'>${i}. ${getGameTypeStr(wts.type)}${isQual?'*':''} ${stats.nettoCPM.toFixed(0)}/${stats.correctionSeries}</option>`;
}
selectHTML += '</select>';
return selectHTML;
}
// --- uPlot FUNCTIONS --- //
let lastRenderedWTS = null;
let annotatedData = null;
let Charts = [];
let TextSpans = [];
let currentFrameIndex = 0;
let chartFrames;
function showFrame(index) {
chartFrames.forEach((cf, i) => {
cf.classList.toggle("active", i === index);
});
currentFrameIndex = index;
}
function setTextTrackers0(u) {
let i=1;
for (let el of TextSpans[0]) {
const left = u.valToPos(i, 'x');
const top = u.valToPos(u.data[1][i-1], 'y');
el.onmouseover = () => { u.setCursor({left: left, top: top})}
el.onmouseout = () =>{u.setCursor({left: -10, top: -10})}
i++;
}
}
function setTextTrackers1(u) {
let i=0;
for (let el of TextSpans[1]) {
const left = u.valToPos(i, 'x');
const top = u.valToPos(u.data[1][i], 'y');
if (i) {
el.onmouseover = () => { u.setCursor({left: left, top: top})}
el.onmouseout = () =>{u.setCursor({left: -10, top: -10})}
}
i++;
}
}
function renderSpeedStats(stats) {
const isPartial = stats.isPartial;
const timeScaleStr = +(stats.totalTimeSec.toFixed(2))<60?'сек':'мин';
const el = oO('#wts-stats0');
el.classList.toggle('partial', isPartial);
el.nextElementSibling.classList.toggle('partial', isPartial);
el.innerHTML = `
<div title='${NETTO_HINT}'><span>${formatDecimal(stats.nettoCPM)}</span>скорость, зн/мин</div>
<div title='${ERROR_COUNT_HINT}'><span>${stats.correctionSeries}</span>ошибки</div>
<div title='${BRUTTO_HINT}'><span>${formatDecimal(stats.bruttoCPM)}</span>брутто, зн/мин</div>
<div title='${TYPE_TIME_HINT}'><span>${stats.totalTimeStr}</span>время, ${timeScaleStr}</div>
<div><div><span title='${CORRECT_TYPED_CHARS_HINT}'>${stats.correctCount}${(stats.errorCount)?`<span title='${INCORRECT_TYPED_CHARS_HINT}'> (+${stats.errorCount})</span>`:''}</span></div>знаки</div>
`;
}
function renderDelayStats(stats) {
const isPartial = stats.isPartial;
const isSameSpeed = !stats.diffSpeedStr;
const timeScaleStr = +(stats.correctTimeSec.toFixed(2))<60?'сек':'мин';
const el = oO('#wts-stats1');
el.classList.toggle('partial', isPartial);
el.nextElementSibling.classList.toggle('partial', isPartial);
el.innerHTML = `
<div title="${(isSameSpeed)?NETTO_HINT:BRUTTO_HINT}"><div><span>${formatDecimal(stats.bruttoCPM)}</span>${(stats.diffSpeedStr)?` <span title="Потери скорости из-за опечаток и их исправлений">(-${stats.diffSpeedStr})</span>`:''}</div>${(!isPartial && isSameSpeed)?'скорость':'брутто'}, зн/мин</div>
<div><div><span title="Минимальная пауза между нажатиями">${stats.min.toFixed(0)}</span> / <span title="Средняя пауза между нажатиями">${stats.avg.toFixed(0)}</span> / <span title="Максимальная пауза между нажатиями">${stats.max.toFixed(0)}</span></div>паузы (мин / ср / макс), мс</div>
<div><div><span title="Время набора только правильного текста">${stats.correctTimeStr}</span>${(stats.diffTimeStr)?` <span title="Время, затраченное на опечатки и их исправления">(+${stats.diffTimeStr})</span>`:''}</div>время, ${timeScaleStr}</div>
<div><span title="Количество правильно набранных знаков">${stats.totalChars}</span>знаки</div>
`;
}
function renderHistStats(stats, eId) {
const params = [
['mean', 0, 'среднее', null],
['median', 0, 'медиана', null],
['sd', 0, 'СО', null],
['cv', 0, 'КВ', '%'],
['iqr', 0, 'IQR', null],
['min', 0, 'минимум', null],
['max', 0, 'максимум', null],
];
let contentHTML = '';
for (let p of params) {
const val = (p[3]=='%')?`${(stats[p[0]].val*100).toFixed(p[1])}` : stats[p[0]].val.toFixed(p[1]);
const descr = stats[p[0]].descr || '';
const hint = stats[p[0]].hint || '';
const name = p[2]? p[2] : p[0];
contentHTML += `<div title="${descr}"><span title="${hint}">${val}${((p[3])?p[3]:'')}</span>${name}</div>`;
}
oO('#wts-stats2').innerHTML = contentHTML;
}
// --- MAIN CHARTS RENDER FUNCTION ---
function renderWTSCharts(fullWTS) {
lastRenderedWTS = null;
chartFrames = document.querySelectorAll(".wts-frame");
// destroy previous charts, if any
if (Charts.length) {
for (let chart of Charts) {
chart.destroy();
}
Charts = [];
}
const rawData = fullWTS.data;
annotatedData = annotateKeypresses(rawData);
// fill #wts-statsX elements:
renderSpeedStats(collectSpeedStats(annotatedData));
renderDelayStats(collectDelayStats(annotatedData));
renderHistStats(collectHistStats(annotatedData));
const texts = buildText(annotatedData);
const histCD = getHistChartData(annotatedData);
const histText = buildHistText(annotatedData, histCD.cutValue);
// fill #wts-textX elements:
oO('#wts-text0').innerHTML = `<div>${texts.textHTML}</div>`;
TextSpans[0] = oO('*#wts-text0 span.s');
oO('#wts-text1').innerHTML = `<div>${texts.textHTMLClean}</div>`;
TextSpans[1] = oO('*#wts-text1 span.c');
oO('#wts-text2').innerHTML = `<div>${histText}</div>`;
// TextSpans[2] = oO('*#wts-text2 span'); // not used
// set text control checkboxes:
const tcOptions = JSON.parse(localStorage.getItem(STORAGE_TEXT_CONTROL_OPTIONS_KEY)) || DEFAULT_TEXT_CONTROL_OPTIONS;
for (const opt in tcOptions) {
oO('#wts-text0').classList.toggle(opt, !tcOptions[opt]);
oO(`#${opt}`).checked = tcOptions[opt];
oO(`#${opt}`).disabled = !oO('#wts-text0').querySelectorAll(opt.replace('hide-', '.')).length;
}
const opts0 = getSpeedChartOpts();
const data0 = getSpeedChartData(annotatedData);
if (SPEEDCHART_Y_SCALE == 'dynamic') {
delete opts0.axes[1].splits;
delete opts0.axes[1].values;
delete opts0.axes[2].splits;
delete opts0.axes[2].values;
opts0.axes[1].incrs=opts0.axes[2].incrs=[10, 25, 50, 100, 150, 200];
opts0.axes[1].space=opts0.axes[2].space=20;
opts0.scales.y.range = [Math.min(...data0[2])*0.95, Math.max(...data0[2])*1.05];
opts0.scales.y.auto=true;
}
Charts.push( new uPlot(opts0, data0, oO('#wts-chart0')) );
const opts1 = getDelaysChartOpts();
const data1 = getDelaysChartData(annotatedData);
Charts.push( new uPlot(opts1, data1, oO('#wts-chart1')) );
const opts2 = getHistChartOpts();
const data2 = histCD.data;
Charts.push( new uPlot(opts2, data2, oO('#wts-chart2')) );
lastRenderedWTS = fullWTS;
}
// --- SPEED CHART ---
function getSpeedChartOpts() {
const css = getComputedStyle(document.documentElement);
const baseColor = css.getPropertyValue('--base-color');
const errorColor = css.getPropertyValue('--error-color');
return {
width: CHART_WIDTH,
height: CHART_HEIGHT,
legend: {
show: false,
},
scales: {
x: {
time: false,
range: (u, newMin, newMax) => {
let curMin = u.scales.x.min;
let curMax = u.scales.x.max;
if (newMax - newMin < 1) {
return [curMin, curMax];
}
return [newMin, newMax];
}
},
y: {
range: [0, 1100],
font: '14px, Tahoma',
},
},
series: [{
//label: "Время, с",
},
{
//label: "Мгновенная скорость, зн/мин",
show: SPEEDCHART_Y_SCALE == 'static',
stroke: '#cccccc',
fill: '#eeeeee',
width: 1,
paths: uPlot.paths.spline(),
},
{
//label: "Скорость, зн/мин",
stroke: baseColor,
width: 3,
},
{
// stroke: `${errorColor}`,
show: oO('#hide-err').checked, //checkbox should be already set!
width: 3,
points: {
show: true,
size: 10,
fill: `${errorColor}`,
},
},
],
axes: [{
font: '12px Tahoma',
stroke: '#888888',
values: (u, ticks) => ticks.map(v => `${formatTime(v, 0, true)}`),
incrs:[1,2,3,4,5,10,20,30,60,120,240],
grid: {
stroke: '#88888866',
width: 1,
},
ticks: {
stroke: '#88888866',
width: 1,
},
},
{
side: 1,
scale: 'y',
font: '14px Tahoma',
stroke: '#330000',
grid: {
stroke: '#33000066',
width: 1,
},
ticks: {
stroke: '#33000066',
width: 1,
},
values: (u, ticks) => ticks.map(v => `${((v/100)%2==1)?v:''}`), //disable default formatting
splits: () => [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100],
},
{
side: 3,
scale: 'y',
font: '14px Tahoma',
stroke: '#330000',
values: (u, ticks) => ticks.map(v => `${((v/100)%2==1)?v:''}`), //disable default formatting
splits: () => [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100],
grid: {
show: false,
},
ticks: {
stroke: '#33000066',
width: 1,
},
},
],
hooks: {
init: [
u => {
let axisEls = u.root.querySelectorAll('.u-axis');
// set x axis event listener
let el = axisEls[0];
el.addEventListener('mousedown', e => {
let x0 = e.clientX;
let scaleKey = u.axes[0].scale;
let scale = u.scales[scaleKey];
let { min, max } = scale;
let diff = max - min;
let unitsPerPx = diff / (u.bbox.width / uPlot.pxRatio);
let mousemove = e => {
let dx = e.clientX - x0;
let shiftxBy = dx * unitsPerPx;
let newMin = min - shiftxBy;
let newMax = max - shiftxBy;
if (newMin < 1) {
newMin = 1; newMax = newMin + diff;
} else if (newMax > u.data[0].length) {
newMax = u.data[0].length;
newMin = newMax - diff;
}
u.setScale(scaleKey, {
min: newMin,
max: newMax
});
};
let mouseup = e => {
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mousemove', mouseup);
};
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);
});
},
],
ready: [
(u) => {
setTextTrackers0(u);
const ttInfo = oO('#wts-chart-tooltip') || document.createElement("div");
ttInfo.id = 'wts-chart-tooltip';
ttInfo.className = 'wts-chart-tooltip';
ttInfo.style.display = "none";
document.body.appendChild(ttInfo);
/*
const ttMagGlass = oO('#wts-chart-mag-glass') || document.createElement("div");
ttMagGlass.id = 'wts-chart-mag-glass';
ttMagGlass.className = 'wts-chart-tooltip';
ttMagGlass.style.display = "none";
document.body.appendChild(ttMagGlass);
*/
u.over.addEventListener("mousemove", e => {
const { left, top } = u.over.getBoundingClientRect();
const x = e.clientX - left;
const y = e.clientY - top;
const idx = u.posToIdx(x);
if (idx >= 0) {
let spans = TextSpans[0];
for (let i = 0; i < spans.length; i++) {
if (i == idx) {
spans[i].classList.add('wts-track-current');
spans[i].scrollIntoView({block: 'center', behavior: 'smooth'});
} else {
spans[i].classList.remove('wts-track-current');
}
if (i<idx) {
spans[i].classList.add('wts-track-selection');
} else {
spans[i].classList.remove('wts-track-selection');
}
}
}
if (idx >= 0 && idx < u.data[0].length) {
const totalChars = u.data[4][idx];
const totalCorrectChars = u.data[5][idx];
const totalErrorChars = totalChars - totalCorrectChars;
const instantChars = (idx) ? (totalChars - u.data[4][idx-1]) : totalChars;
const instantCorrectChars = (idx) ? (totalCorrectChars - u.data[5][idx-1]) : totalCorrectChars;
const instantErrorChars = instantChars - instantCorrectChars;
ttInfo.innerHTML =
`<span class='time'>${formatTime(u.data[0][idx], 0, true)}</span>` +
`Скорость: <span>${u.data[2][idx].toFixed(0)}</span> зн/мин<br>` +
`За эту секунду: <span>${u.data[1][idx].toFixed(0)}</span> зн/мин<hr>` +
`Знаков: <span>${totalCorrectChars}</span>${(totalErrorChars)?` (+${totalErrorChars} ${getPluralForm(totalErrorChars, ['удалённый', 'удалённых', 'удалённых'])})`:''}<br>` +
`За эту секунду: <span>${instantCorrectChars}</span>${(instantErrorChars)?` (+${instantErrorChars} ${getPluralForm(instantErrorChars, ['удалённый', 'удалённых', 'удалённых'])})`:''}`;
ttInfo.style.left = `${e.clientX + 10}px`;
ttInfo.style.top = `${e.clientY + 10}px`;
ttInfo.style.display = "block";
/*
const curEl = document.getElementsByClassName('wts-track-current');
if (curEl.length && curEl[0].innerHTML.length) {
ttMagGlass.innerHTML =
`${(idx>0)? '◂' : ''}${curEl[0].innerHTML}${(idx < u.data[0].length-1) ? '▸' : ''}`;
ttMagGlass.style.left = `${e.clientX + 10}px`;
ttMagGlass.style.top = `${e.clientY + 20 + ttInfo.offsetHeight}px`;
ttMagGlass.style.display = "block";
} else {
ttMagGlass.style.display = "none";
}
*/
} else {
ttInfo.style.display = "none";
}
});
u.over.addEventListener("mouseleave", () => {
ttInfo.style.display = "none";
// ttMagGlass.style.display = "none";
let spans = TextSpans[0];
for (let i = 0; i < spans.length; i++) {
spans[i].classList.remove('wts-track-selection', 'wts-track-current');
}
});
}
],
setScale: [
(u) => {
setTextTrackers0(u);
const idxStart = Math.ceil(u.scales.x.min);
const idxEnd = Math.floor(u.scales.x.max);
// update stats for selected interval
const stats = collectSpeedStats(annotatedData, {min: idxStart, max: idxEnd});
renderSpeedStats(stats);
let spans = TextSpans[0];
for (let i = 0; i < spans.length; i++) {
spans[i].classList.remove('wts-track-hide', 'wts-track-start', 'wts-track-end');
if (! ((i+1 >= idxStart-1) && (i+1 <= idxEnd+1)) ) {
spans[i].classList.add('wts-track-hide');
} else {
if (i+1 == idxStart-1) spans[i].classList.add('wts-track-start');
if (i+1 == idxEnd+1) spans[i].classList.add('wts-track-end');
}
}
}
]
}
};
}
function getSpeedChartData(annotatedData) {
let totalTime = 0;
const points = [];
for (const { key, delay, mark } of annotatedData) {
totalTime += delay;
points.push({
key,
delay,
mark,
time: totalTime / 1000 // в секундах
});
}
// Квантуем по секундам
const duration = Math.ceil(totalTime / 1000); // общая длительность в секундах
const xVals = [];
const yInstant = []; // мгновенная скорость (по количеству набранных знаков за 1 секунду)
const yAvg = []; // средняя скорость
const yErr = []; // для указания мест ошибок на графике мгновенной скорости
const yTotalCount = []; // сколько всего знаков к этому времени
const yCorrectCount = []; // сколько всего правильно набранных знаков к этому времени
let prevErrCount = 0;
// Стартуем с 1, потому что в нулевой секунде нечего считать, по сути
for (let t = 1; t <= duration; t++) {
xVals.push(t);
// Найдём символы, набранные ПО эту секунду включительно
const pressed = points.filter(p => p.time <= t);
const correctTyped = pressed.filter(p => (p.mark === 'correct'));
const correctCount = correctTyped.length;
const errorTyped = pressed.filter(p => (p.mark === 'error'));
const errorCount = errorTyped.length;
const totalCount = correctCount + errorCount;
// Средняя скорость
let time = (t < duration) ? t : totalTime / 1000;
const avgSpeed = totalCount > 0 && t > 0 ? (correctCount / time) * 60 : 0;
// Мгновенная скорость
let prevSecCount = (t == 1) ? correctCount : correctCount - yCorrectCount[yCorrectCount.length - 1];
if (t == duration) {
prevSecCount += (duration>1)? (yInstant[yInstant.length-1]/60) : 0;
}
let lastSecCorrection = (duration>1)? t-2:0;
let instSpeed = (t < duration)? (prevSecCount * 60) : ((prevSecCount * 60) / (time - lastSecCorrection)); //last second really pissed me off!
// push the data!
yInstant.push(instSpeed);
yAvg.push(avgSpeed);
yErr.push((errorCount != prevErrCount) ? avgSpeed : null);
// these two are used only for tooltips
yTotalCount.push(totalCount);
yCorrectCount.push(correctCount);
prevErrCount = errorCount;
}
return [
xVals,
yInstant,
yAvg,
yErr,
yTotalCount,
yCorrectCount,
];
}
// --- DELAYS CHART ---
function getDelaysChartOpts() {
const css = getComputedStyle(document.documentElement);
const fastDelayColor = css.getPropertyValue('--fast-delay-color');
return {
width: CHART_WIDTH,
height: CHART_HEIGHT,
legend: {
show: false,
},
scales: {
x: {
time: false,
range: (u, newMin, newMax) => {
let curMin = u.scales.x.min;
let curMax = u.scales.x.max;
if (newMax - newMin < 5) {
return [curMin, curMax];
}
return [newMin, newMax];
}
},
y: {
range: [0, 300],
},
},
series: [
{},
{
//label: "delays",
stroke: '#33000066',
width: 1,
},
{
points: {
show: true,
size: 8,
fill: fastDelayColor,
},
}
],
axes: [
{
font: '12px Tahoma',
stroke: '#888888',
scale: 'x',
incrs: [1, 2, 3, 5, 10, 15, 20, 40, 60, 100, 200, 400, 500],
values: (u, ticks) => ticks.map(v => `${v}`), //disable default formatting
grid: {
width: 1,
},
ticks: {
width: 1,
},
},
{
font: '12px Tahoma',
stroke: '#330000cc',
scale: 'y',
values: (u, ticks) => ticks.map(v => `${v}ms`), //disable default formatting
splits: () => [0, FAST_DELAY_THRESHOLD, 50, 100, 150, 200, 250, 300],
grid: {
width: 1,
stroke: '#33000022',
},
ticks: {
width: 1,
stroke: '#33000022',
},
},
],
hooks: {
init: [
u => {
let axisEls = u.root.querySelectorAll('.u-axis');
// set x axis event listener
let el = axisEls[0];
el.addEventListener('mousedown', e => {
let x0 = e.clientX;
let scaleKey = u.axes[0].scale;
let scale = u.scales[scaleKey];
let { min, max } = scale;
let diff = max - min;
let unitsPerPx = diff / (u.bbox.width / uPlot.pxRatio);
let mousemove = e => {
let dx = e.clientX - x0;
let shiftxBy = dx * unitsPerPx;
let newMin = min - shiftxBy;
let newMax = max - shiftxBy;
if (newMin < 0) {
newMin = 0; newMax = newMin + diff;
} else if (newMax > u.data[0].length - 1) {
newMax = u.data[0].length - 1;
newMin = newMax - diff;
}
u.setScale(scaleKey, {
min: newMin,
max: newMax
});
};
let mouseup = e => {
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mousemove', mouseup);
};
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);
});
},
],
ready: [
(u) => {
setTextTrackers1(u);
const ttInfo = document.getElementById('wts-chart-tooltip') || document.createElement("div");
ttInfo.id = 'wts-chart-tooltip';
ttInfo.className = 'wts-chart-tooltip';
ttInfo.style.display = "none";
document.body.appendChild(ttInfo);
const ttMagGlass = document.getElementById('wts-chart-mag-glass') || document.createElement("div");
ttMagGlass.id = 'wts-chart-mag-glass';
ttMagGlass.className = 'wts-chart-tooltip';
ttMagGlass.style.display = "none";
document.body.appendChild(ttMagGlass);
u.over.addEventListener("mousemove", e => {
const { left, top } = u.over.getBoundingClientRect();
const x = e.clientX - left;
const y = e.clientY - top;
const idx = u.posToIdx(x);
if (idx > 0 && idx < u.data[0].length) {
const prevKey = ` ${u.data[3][idx-1] == ' ' ? ' ' : u.data[3][idx-1]} `;
const nextKey = ` ${u.data[3][idx] == ' ' ? ' ' : u.data[3][idx]} `;
const delay = parseInt(u.data[1][idx].toFixed(0));
ttMagGlass.innerHTML =`
<div style="display: flex; align-items: center;">
<span class="wts-tt-prev-key">${prevKey}</span>
<span class="wts-tt-delay${(delay < FAST_DELAY_THRESHOLD)?' fast':''}">${delay} ms</span>
<span class="wts-tt-next-key">${nextKey}</span>
</div>`;
ttMagGlass.style.left = `${e.clientX + 10}px`;
ttMagGlass.style.top = `${e.clientY + 10}px`;
ttMagGlass.style.display = "block";
} else {
ttMagGlass.style.display = "none";
}
if (idx >= 0) {
let spans = TextSpans[1];
for (let i = 0; i < spans.length; i++) {
if (idx && ((i == idx) || (i + 1 == idx))) {
spans[i].classList.add('wts-track-current');
spans[i].scrollIntoView({block: 'center', behavior: 'smooth'});
} else {
spans[i].classList.remove('wts-track-current');
}
}
}
});
u.over.addEventListener("mouseleave", () => {
ttInfo.style.display = "none";
ttMagGlass.style.display = "none";
let spans = TextSpans[1];
for (let i = 0; i < spans.length; i++) {
spans[i].classList.remove('wts-track-current');
}
});
}
],
setScale: [
(u) => {
setTextTrackers1(u);
const idxStart = Math.ceil(u.scales.x.min);
const idxEnd = Math.floor(u.scales.x.max);
renderDelayStats(collectDelayStats(annotatedData, {idxStart, idxEnd}));
let spans = TextSpans[1];
for (let i = 0; i < spans.length; i++) {
spans[i].classList.remove('wts-track-hide', 'wts-track-start', 'wts-track-end');
if ( (i < idxStart - 2) || (i > idxEnd + 1) ) {
spans[i].classList.add('wts-track-hide');
}
if (i == idxStart - 2) spans[i].classList.add('wts-track-start');
if (i == idxEnd + 1) spans[i].classList.add('wts-track-end');
}
}
]
}
};
}
function getDelaysChartData(annotatedData) {
const xVals = [];
const yVals = [];
const fast = [];
const cVals = [];
let i=0;
for (const obj of annotatedData) {
const { key, delay, mark } = obj;
if (mark === 'correct') {
xVals.push(i);
yVals.push((i)?delay:null);
fast.push(i && (delay < FAST_DELAY_THRESHOLD)? delay:null);
cVals.push(key);
i++;
}
}
return [
xVals,
yVals,
fast,
cVals
];
}
// --- HISTOGRAM CHART ---
function getHistChartOpts() {
let isSelecting = false;
let startX;
const css = getComputedStyle(document.documentElement);
const baseColor = css.getPropertyValue('--base-color');
return {
width: CHART_WIDTH,
height: CHART_HEIGHT,
legend: {
show: false,
},
scales: {
x: {
auto: false,
time: false,
range: [0, HISTOGRAM_MAX_X + HISTOGRAM_BIN_SIZE], // last one for outliers
},
y: {
auto: false,
range: [0, HISTOGRAM_MAX_Y],
},
},
series: [
{},
{
fill: '#cf8282',
width: 1,
paths: uPlot.paths.bars(),
points: {show: false},
},
{
fill: baseColor,
width: 1,
paths: uPlot.paths.bars(),
points: {show: false},
},
],
axes: [
{
scale: 'x',
values: (u, ticks) => ticks.map(v => `${(v<=HISTOGRAM_MAX_X)?v:'outliers'}`), //disable default formatting
font: '12px Tahoma',
stroke: '#888888',
grid: {
width: 1,
},
ticks: {
width: 1,
},
splits: ()=>{
let ret = [];
for (let i=0; i<=(HISTOGRAM_MAX_X + HISTOGRAM_BIN_SIZE); i+=(HISTOGRAM_BIN_SIZE<20)?2*HISTOGRAM_BIN_SIZE:HISTOGRAM_BIN_SIZE)
{
ret.push(i)
};
return ret;
}
},
{
scale: 'y',
values: (u, ticks) => ticks.map(v => `${(v*100).toFixed(0)}%`), //disable default formatting
font: '12px Tahoma',
stroke: '#330000',
grid: {
width: 1,
},
ticks: {
width: 1,
}
},
],
hooks: {
ready: [
(u) => {
const ttInfo = document.getElementById('wts-chart-tooltip') || document.createElement("div");
ttInfo.id = 'wts-chart-tooltip';
ttInfo.className = 'wts-chart-tooltip';
ttInfo.style.display = "none";
document.body.appendChild(ttInfo);
u.over.addEventListener("mousemove", e => {
const { left, top } = u.over.getBoundingClientRect();
const x = e.clientX - left;
const y = e.clientY - top;
const idx = u.posToIdx(x);
if (idx >= 0 && idx < u.data[0].length && (u.data[1][idx] || u.data[2][idx])) {
ttInfo.innerHTML =
(idx<u.data[0].length-1)?
`<span>${(100*u.data[1][idx]).toFixed(1)}%</span> межклавишных пауз<br>в интервале <span>${idx*HISTOGRAM_BIN_SIZE}−${(idx+1)*HISTOGRAM_BIN_SIZE}</span> ms`:
`<span>${(100*u.data[2][idx]).toFixed(1)}%</span> выбросов, не вошедших<br>в основную гистограмму<br>(паузы >${u.data[3].toFixed(0)} ms)`;
ttInfo.style.left = `${e.clientX + 10}px`;
ttInfo.style.top = `${e.clientY + 10}px`;
ttInfo.style.display = "block";
} else {
ttInfo.style.display = "none";
}
const startIdx = Math.min(u.posToIdx(startX), idx);
const endIdx = Math.max(u.posToIdx(startX), idx);
for (let i = 0; i < Math.floor(HISTOGRAM_MAX_X/HISTOGRAM_BIN_SIZE)+1; i++) {
if (isSelecting) {
oO('#wts-text2').classList.toggle(`grad${i}`, (i >= startIdx) && (i <= endIdx));
} else {
oO('#wts-text2').classList.toggle(`grad${i}`, i == idx);
}
}
});
u.over.addEventListener("mouseleave", () => {
ttInfo.style.display = "none";
isSelecting = false;
startX = null;
for (let i = 0; i < Math.floor(HISTOGRAM_MAX_X/HISTOGRAM_BIN_SIZE)+1; i++) {
oO('#wts-text2').classList.remove(`grad${i}`);
}
});
u.over.addEventListener("mousedown", (e) => {
isSelecting = true;
const { left } = u.over.getBoundingClientRect();
startX = e.clientX - left;
});
u.over.addEventListener("mouseup", (e) => {
isSelecting = false;
const { left } = u.over.getBoundingClientRect();
const x = e.clientX - left;
const idx = u.posToIdx(x);
for (let i = 0; i < Math.floor(HISTOGRAM_MAX_X/HISTOGRAM_BIN_SIZE)+1; i++) {
oO('#wts-text2').classList.remove(`grad${i}`);
oO('#wts-text2').classList.toggle(`grad${i}`, i == idx);
}
});
}
],
}
};
}
function getHistChartData(annotatedData) {
const delays = [];
for (const obj of annotatedData) {
const { delay, mark } = obj;
if (mark === 'correct' && delay) {
delays.push(delay);
}
}
const { bins, outliers, cutValue } = Stat.prepareHistogramData(delays, {fixedBinSize:HISTOGRAM_BIN_SIZE, percentileCut: 0.97});
const binLength = bins.x.length;
const maxLength = Math.floor(HISTOGRAM_MAX_X / HISTOGRAM_BIN_SIZE) + 1;
bins.x.length = maxLength;
for (let i = binLength; i < maxLength; i++) {
bins.x[i] = (HISTOGRAM_BIN_SIZE>>1) + i*HISTOGRAM_BIN_SIZE;
}
bins.y.length = maxLength;
bins.y.fill(0, binLength, maxLength)
//add another series for outliers
const y2 = [];
y2.length = maxLength;
y2.fill(0, 0, maxLength);
y2[maxLength-1] = outliers.length / delays.length;
return {
data:[
bins.x,
bins.y,
y2,
cutValue
],
cutValue
}
}
function showWTS() {
loadUPlotIfNeeded(() => {
showMainWindow('<div style="height: 400px; line-height: 1.5em; font-size:16px; font-family: Tahoma, sans-serif; color: #003300;">Wake up, Neo...<br>The Matrix has you...</div>', postInitMainWindow);
});
}
//just for debug
//window.oO = oO;
//window.setAppMode = setAppMode;
//window.Charts = Charts;
//window.__files = __files;
window.showWTS = showWTS;
const Stat = {
prepareHistogramData: function(values, options = {}) {
const {
percentileCut = null, // например, 0.98 для 98% обрезки
useIQR = true, // использовать ли метод IQR
iqrMultiplier = 1.5, // множитель для IQR
fixedBinSize = null, // фиксированная ширина бакета (мс)
normalize = true, // нормировать ли частоты
fixedRange = null // [minX, maxX] диапазон по X
} = options;
// 1. Сортировка
let data = values.slice().sort((a, b) => a - b);
const n = data.length;
if (n < 2) return { bins: [], binSize: 0, outliers: [], cutValue: null };
// 2. Верхняя граница (если нет fixedRange)
let cutValue;
if (fixedRange) {
cutValue = fixedRange[1];
} else if (percentileCut !== null) {
const idx = Math.floor(percentileCut * n);
cutValue = data[idx];
} else if (useIQR) {
const q1 = data[Math.floor(0.25 * n)];
const q3 = data[Math.floor(0.75 * n)];
const iqr = q3 - q1;
cutValue = q3 + iqrMultiplier * iqr;
} else {
cutValue = data[n - 1];
}
// 3. Основные данные и выбросы
let mainData, outliers;
if (fixedRange) {
mainData = data.filter(v => v >= fixedRange[0] && v <= fixedRange[1]);
outliers = data.filter(v => v < fixedRange[0] || v > fixedRange[1]);
} else {
mainData = data.filter(v => v <= cutValue);
outliers = data.filter(v => v > cutValue);
}
// 4. Ширина бакета
let binSize;
if (fixedBinSize && fixedBinSize > 0) {
binSize = fixedBinSize;
} else {
const q1 = mainData[Math.floor(0.25 * mainData.length)];
const q3 = mainData[Math.floor(0.75 * mainData.length)];
const iqr = q3 - q1;
binSize = (2 * iqr) / Math.cbrt(mainData.length) || 1;
}
// 5. Границы диапазона для построения
const min = fixedRange ? fixedRange[0] : mainData[0];
const max = fixedRange ? fixedRange[1] : mainData[mainData.length - 1];
// const binCount = Math.ceil((max - min) / binSize);
const binCount = Math.ceil(max / binSize);
const bins = new Array(binCount).fill(0);
mainData.forEach(v => {
// const idx = Math.min(Math.floor((v - min) / binSize), binCount - 1);
const idx = Math.min(Math.floor(v / binSize), binCount - 1);
bins[idx]++;
});
// 6. Нормализация
let y = bins.slice();
if (normalize) {
const total = mainData.length;
if (total > 0) {
y = y.map(count => count / total);
}
}
// 7. X и Y
const x = [];
for (let i = 0; i < binCount; i++) {
// const center = min + (i + 0.5) * binSize;
const center = (i + 0.5) * binSize;
x.push(center);
}
return {
bins: { x, y },
binSize,
outliers,
cutValue
};
},
analyzeDelays: function(delays) {
if (!delays || delays.length < 2) {
return null;
}
const sorted = [...delays].sort((a, b) => a - b);
const n = sorted.length;
const mean = sorted.reduce((a, b) => a + b, 0) / n;
const median = (n % 2 === 0)
? (sorted[n / 2 - 1] + sorted[n / 2]) / 2
: sorted[(n - 1) / 2];
const variance = sorted.reduce((a, b) => a + (b - mean) ** 2, 0) / n;
const sd = Math.sqrt(variance);
const cv = mean !== 0 ? sd / mean : 0;
const q1 = sorted[Math.floor(n * 0.25)];
const q3 = sorted[Math.floor(n * 0.75)];
const iqr = q3 - q1;
const min = sorted[0];
const max = sorted[n - 1];
return {
mean: {
val: mean,
descr: "Средняя пауза между нажатиями. Чем меньше − тем быстрее набор.",
hint: ""
},
median: {
val: median,
descr: "Устойчивая альтернатива среднему. Менее чувствительна к выбросам.",
hint: ""
},
sd: {
val: sd,
descr: "Стандартное отклонение. Чем меньше − тем стабильнее ритм.",
hint: ""
},
cv: {
val: cv,
descr: "Коэффициент вариации или аритмия. Чем меньше − тем ритмичнее набор.",
hint: ""
},
iqr: {
val: iqr,
descr: "Межквартильный размах. Характеризует разброс значений в интервале от 25% до 75%",
hint: ""
},
min: {
val: min,
descr: "Минимальная пауза между нажатиями.",
hint: ""
},
max: {
val: max,
descr: "Максимальная пауза между нажатиями.",
hint: ""
}
};
}
};
// --- !!! no significant code below this line, only auxiliary functions !!! ---
// --- CSS ---
// TODO: minimize css
function injectCSS() {
const style = document.createElement('style');
style.textContent = `
:root {
--main-font-family: Tahoma, sans-serif;
--text-font-size: 11pt; /* 16px */
--base-color: #883333;
--highlighter-color: #a2ee55;
--partial-indicator-color: #aaf0f0;
--fast-delay-color: #ffd900;
--error-color: #ff0000;
}
#wts-side-panel {
background-color: #F8F4E6;
border-radius: 10px;
margin: 10px 0;
line-height: 1.6em;
}
.wts-side-panel-content {
padding: 10px;
}
#wts-rec {
position: absolute;
visibility: hidden;
display: block;
background: radial-gradient(#ff3333 40%, #666666);
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 2px #000000;
cursor: help;
}
#wts-rec.blink {
visibility: visible;
animation: blink 1s infinite;
z-index: 9999; /* 😈 <[MWAHAHA] */
}
@keyframes blink {
0%, 100% {opacity: 0}
25% {opacity: 1}
}
#wts-rec.pause {
visibility: visible;
background: radial-gradient(#ffa500 40%, #666666);
}
#wts-rec.ready {
visibility: visible;
background: radial-gradient(#66aa66 40%, #666666);
}
#${MODAL_ID} {
background: #ffffff;
color: #330000;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
overflow: auto;
position: fixed;
width: 800px;
max-height: 95vh;
z-index: 9999;
display: flex;
flex-direction: column;
outline: none;
font-family: var(--main-font-family);
font-size: 10pt;
line-height: 1.4em;
}
#${MODAL_ID} .wts-header {
padding: 10px 15px;
background: #f0f0f0;
cursor: move;
user-select: none;
font-size: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
#${MODAL_ID} .wts-header select {
font-size: 16px;
margin-left: 4px;
color: #333333;
outline: none;
padding-right: 10px;
padding-bottom: 1px;
}
#${MODAL_ID} .wts-emptyspace {
flex-grow: 1;
}
#${MODAL_ID} .wts-button {
font-size: 20px;
line-height: 24px;
color: #888888;
cursor: pointer;
margin-left: 10px;
width: 28px;
text-align: center;
}
#${MODAL_ID} .wts-button:hover {
color: #000000;
}
#${MODAL_ID} .wts-close {
font-size: 32px;
line-height: 24px;
color: #888;
cursor: pointer;
margin-left: 10px;
width: 40px;
text-align: center;
border-radius: 5px;
transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
}
#${MODAL_ID} .wts-close:hover {
color: #ffffff;
background-color: #cc3333;
}
#${MODAL_ID} .wts-menu-wrapper {
position: relative;
display: inline-block;
}
#${MODAL_ID} .wts-menu {
display: none;
position: absolute;
top: 0; /* начинаем прямо с верха кнопки */
right: 0; /* чтобы выпадало вправо от края */
background: #fff;
border: 1px solid #aaa;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
white-space: nowrap;
z-index: 1000;
overflow: hidden;
}
#${MODAL_ID} .wts-menu-header {
background: #f0f0f0;
padding: 4px 10px;
font-weight: bold;
font-size: 14px;
border-bottom: 1px solid #ddd;
}
#${MODAL_ID} .wts-menu a {
display: block;
padding: 4px 10px;
text-decoration: none;
color: #333333;
font-size: 14px;
}
#${MODAL_ID} .wts-menu a:hover {
background: #8053dd;
color: #ffffff;
}
/* магия hover */
#${MODAL_ID} .wts-menu-wrapper:hover .wts-menu {
display: block;
}
#${MODAL_ID} .wts-content {
padding: 15px;
overflow-y: auto;
}
#${MODAL_ID} input[type="file"] {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
#${MODAL_ID} input[type="checkbox"], label {
margin: 0px;
}
#${MODAL_ID} input[type="checkbox"][disabled] + label {
cursor: not-allowed;
opacity: 0.3;
}
#${MODAL_ID} hr {
margin: 5px auto;
height: 1px;
width: 90%;
border: 0;
border-top: 1px solid #f0f0f0;
}
#wts-frames {
overflow: hidden;
position: relative;
}
.wts-frame {
display:none;
opacity: 0;
pointer-events: none;
}
.wts-frame.active {
display: block;
opacity: 1;
pointer-events: auto;
}
#${MODAL_ID} .wts-stats {
width: 100%;
background-image: linear-gradient(#ffffff, 50%, #f0f0f0);
border-radius: 0 0 10px 10px; /*background-color: #fafafa;*/
color: #888888;
display: flex;
justify-content: center;
align-items: flex-end;
}
#${MODAL_ID} .wts-stats > div {
padding: 0px 20px 5px 20px;
display: flex;
flex-direction: column;
align-items: center;
}
#${MODAL_ID} .wts-stats span {
font-size: 12pt;
color: #330000;
}
#${MODAL_ID} .wts-stats span span {
font-size: 10pt;
padding: 0;
}
#${MODAL_ID} .wts-stats.partial {
background-image: linear-gradient(#ffffff, 50%, var(--partial-indicator-color)); /* f0f0ff */
border-radius: 0;
}
#${MODAL_ID} .wts-chart {
width: ${CHART_WIDTH}px;
height: ${CHART_HEIGHT}px;
margin-left: auto;
margin-right: auto;
}
#${MODAL_ID} .wts-chart.partial {
border-radius: 0 0 20px 20px;
box-shadow: 0 2px 5px 3px var(--partial-indicator-color);
}
#${MODAL_ID} #wts-chart1 {
padding-left: 8px;
}
.wts-chart-tooltip {
position: fixed;
font-size: 8pt;
background: #ffffff; /*opacity: 0.9;*/
color: #666;
padding: 4px 6px;
border: 1px solid #cccccc;
border-radius: 5px;
z-index: 10000;
pointer-events: none;
}
#wts-chart-tooltip {
width: 190px;
}
#wts-chart-tooltip span {
font-size: 10pt;
color: #300;
}
#wts-chart-tooltip span.time {
font-size: 8pt;
color: #fff;
padding: 2px 4px;
position: absolute;
right: 5px;
background-color: var(--base-color);
border-radius: 4px;
}
.wts-chart-tooltip hr {
margin: 5px 0 2px 0;
}
#wts-chart-mag-glass {
background-image: linear-gradient(#eeeeee, #ffffff, #eeeeee);
font-size: 16px;
border-radius: 8px;
box-shadow: 0 0 2px;
}
.wts-tt-prev-key, .wts-tt-next-key {
background: var(--base-color);
color: #ffffff;
padding: 5px;
border-radius: 10px;
font-size: 18px;
margin: 5px;
min-width: 36px;
text-align: center;
}
.wts-tt-delay {
background: #f0f0f0;
padding: 2px 5px;
border-radius: 4px;
font-size: 12px;
min-width: 50px;
text-align: center;
}
.wts-tt-delay.fast {
background-color: var(--fast-delay-color);
color: #333333;
}
#wts-text-controls {
width: 100%;
display: flex;
justify-content: flex-end;
}
#wts-text-controls div {
display: flex;
align-items: center;
padding: 5px 20px 2px 20px;
}
#wts-text-controls label {
font-weight: normal;
font-size: 12px;
margin-left: -20px;
padding: 2px 5px;
padding-left: 25px;
margin-right: 30px;
background-color: #f0f0f0;
border-radius: 5px;
}
#hide-fast + label {
background-color: #ffea92;
color: #330000;
}
#hide-err + label {
background-color: #ff8d7b;
color: #ffffff;
}
#hide-corr + label {
background-color: #a86c62;
color: #ffffff;
}
#wts-text-controls input[type="checkbox"] {
z-index: 1;
}
.wts-text {
width: 740px;
font-size: var(--text-font-size);
padding: 2px 5px 2px 30px;
margin: 0 auto;
display: flex;
justify-content: center;
}
.wts-text div {
white-space: pre-wrap;
overflow-wrap: break-word;
text-align: justify;
max-height: 45vh;
overflow-y: auto;
padding: 5px 25px 10px 10px;
}
.wts-text span.s:hover {
background-color: var(--highlighter-color);
position: relative;
border-radius: 5px;
padding: 1px 2px;
margin: -1px -2px;
}
.wts-text .err {
text-decoration: line-through;
color: var(--error-color);
}
.wts-text.hide-err .err {
display: none;
}
.wts-text .corr {
font-weight: bold;
color: #ffffff;
background-color: #660000;
font-size: 9px;
line-height: 12px;
border-radius: 4px;
padding: 0 2px;
margin: 0 1px;
cursor: help;
position: relative;
top: -1px;
}
.wts-text .corr:hover {
position: relative;
padding: 2px 4px;
margin: -2px -1px;
}
.wts-text.hide-corr .corr {
display: none;
}
.wts-text .fast {
color: #333333;
background-color: var(--fast-delay-color);
cursor: help;
}
.wts-text .fast:hover {
position: relative;
padding: 2px 4px;
margin: -2px -4px;
border-radius: 5px;
box-shadow: 0 0 2px;
}
.wts-text .fast:hover:before {
content: attr(data-prevkey);
}
.wts-text.hide-fast .fast {
color: unset;
background-color: unset;
cursor: unset;
}
.wts-text.hide-fast .fast:hover {
position: unset;
padding: unset;
margin: unset;
border-radius: unset;
box-shadow: unset;
}
.wts-text.hide-fast .fast:hover:before {
content: '';
}
#wts-text2 {
line-height: 1.8em;
/* font-size: 16px; */
}
.wts-track-selection {
background-color: #eeeeee;
} /*#def2e0*/
.wts-track-selection:first-of-type {
border-radius: 5px 0 0 5px;
padding-left: 3px;
margin-left: -3px;
}
.wts-track-current {
background-color: var(--highlighter-color);
border-radius: 0 5px 5px 0;
padding-right: 3px;
margin-right: -3px;
}
.wts-track-current,
.wts-track-selection,
#wts-text span.s {
transition: background-color 0.3s ease-in-out;
}
.c.wts-track-current {
border-radius: 0;
border: 4px solid var(--base-color);
border-width: 0 0 4px 0;
position: relative;
background: var(--highlighter-color);
}
.wts-track-hide {
display: none;
}
.wts-track-start,
.wts-track-end {
font-size: 0;
line-height: 0px; /* this is important for some reason!*/
pointer-events: none;
color: #888888;
display: inline-block;
}
.wts-track-start:after, .wts-track-end:before {
font-size: var(--text-font-size);
}
.wts-track-start:after {
content: '${CUT_START_MARK}';
}
.wts-track-end:before {
content: '${CUT_END_MARK}';
}
#wts-toast {
visibility: hidden;
background-color: #f0f0f0;
color: #000000;
min-width: 220px;
margin-left: -110px;
text-align: center;
border-radius: 30px;
padding: 10px;
pointer-events: none;
position: absolute;
z-index: 10001;
top: 0px;
left: 50%;
font-size: 12pt;
opacity: 0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out, top 0.3s;
}
#wts-toast.ok {
background-color: #33aa33; /* 66ee66 */
color: #ffffff;
}
#wts-toast.warn {
background-color: #ffcc33;
color: #333333;
}
#wts-toast.err {
background-color: var(--error-color);
color: #ffffff;
}
#wts-toast.show {
visibility: visible;
opacity: 1;
top: 30px;
}
.wts-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
}
.wts-progress-box {
background: #fff;
padding: 20px;
border-radius: 12px;
text-align: center;
min-width: 260px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-family: sans-serif;
}
.wts-progress-text {
margin-bottom: 12px;
font-size: 14px;
}
.wts-progress-bar {
width: 100%;
height: 12px;
background: #ddd;
border-radius: 6px;
overflow: hidden;
position: relative;
}
.wts-progress-fill {
height: 100%;
width: 0;
background: #4caf50;
transition: width 0.3s ease;
}
/* анимация "indeterminate" */
.wts-progress-fill.indeterminate {
position: absolute;
width: 30%;
left: -30%;
animation: wts-indeterminate 1.2s infinite linear;
}
@keyframes wts-indeterminate {
0% { left: -30%; }
50% { left: 100%; }
100% { left: 100%; }
}
`;
// generate gradients for histogram text
const maxLen = Math.floor(HISTOGRAM_MAX_X / HISTOGRAM_BIN_SIZE) + 1;
const gradient = ColorUtils.generateTints('#666666', maxLen);
for (let i = 0; i < maxLen; i++) {
const color = (i < maxLen - 1)?gradient[i]:'var(--base-color)';
style.textContent += `.wts-text .grad${i} {color: #aaaaaa; border: 6px solid ${color}; border-width: 0 0 6px 0; transition: color 0.3s ease-in-out;}\n\n`;
style.textContent += `.wts-text.grad${i} .grad${i} {color: unset; border-width: 0 0 9px 0; position: relative; top: -3px}\n\n`;
}
document.head.appendChild(style);
}
})();