KG_WebTypeStats

Записывает все нажатия клавиш в процессе геймплея для дальнейшего статистического анализа. Работает только с полем ввода набираемого в заезде текста.

// ==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%">Скорость:&nbsp;</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%">Время:&nbsp;</td><td width="30%"><span title="${TYPE_TIME_HINT}">${stats.totalTimeStr}</span></td>
 </tr>
 <tr>
  <td>Ошибки:&nbsp;</td><td>${stats.correctionSeries}</td>
  <td>Знаки:&nbsp;</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}">&times;</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 += `![](${pic1})\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)? '&ltrif;' : ''}${curEl[0].innerHTML}${(idx < u.data[0].length-1) ? '&rtrif;' : ''}`;
                                    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 = `&nbsp;${u.data[3][idx-1] == ' ' ? '&nbsp;' : u.data[3][idx-1]}&nbsp;`;
                                const nextKey = `&nbsp;${u.data[3][idx] == ' ' ? '&nbsp;' : u.data[3][idx]}&nbsp;`;
                                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);
    }
})();