DeepCo NekGraph

Graph display — NekStyle extension

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         DeepCo NekGraph
// @namespace    http://tampermonkey.net/
// @version      1.7.1
// @description  Graph display — NekStyle extension
// @match        https://deepco.app/*
// @license      MIT
// @grant        none
// @icon         https://www.google.com/s2/favicons?sz=64&domain=deepco.app
// ==/UserScript==

(function () {
    'use strict';

    const EXTENSION_ID = 'nek-graph';
    const EXTENSION_LABEL = 'NekGraph';
    const EXTENSION_VERSION = GM?.info?.script?.version || '0';
    const BUTTON_COLOR = '#f43098';
    const INJECT_TARGET = 'footer.footer>nav';
    const INJECT_BEFORE_ID = 'tip-area';

    const SEC = 1000;
    const MIN = 60 * SEC;
    const HOUR = 60 * MIN;
    const DAY = 24 * HOUR;

    const RELOAD_TIMEOUT = 5 * MIN;
    let LAST_POOL = Date.now();

    const aggregationLevels = [
        { key: '5m', label: 'Last 5 minutes', limit: 5 * MIN, res: 15 * SEC },
        { key: '15m', label: 'Last 15 minutes', limit: 15 * MIN, res: 45 * SEC },
        { key: '1h', label: 'Last 1 hour', limit: 1 * HOUR, res: 3 * MIN },
        { key: '4h', label: 'Last 4 hours', limit: 4 * HOUR, res: 12 * MIN },
        { key: '12h', label: 'Last 12 hours', limit: 12 * HOUR, res: 30 * MIN },
        { key: '24h', label: 'Last 24 hours', limit: 24 * HOUR, res: 1 * HOUR },
        { key: '7d', label: 'Last 7 days', limit: 7 * DAY, res: 8 * HOUR },
        { key: '30d', label: 'Last 30 days', limit: 30 * DAY, res: 1 * DAY },
        { key: '90d', label: 'Last 90 days', limit: 90 * DAY, res: 7 * DAY },
    ];

    const NICE_STEPS = [
        15 * SEC,
        30 * SEC,
        1 * MIN,
        2 * MIN,
        5 * MIN,
        10 * MIN,
        15 * MIN,
        30 * MIN,
        1 * HOUR,
        2 * HOUR,
        3 * HOUR,
        6 * HOUR,
        12 * HOUR,
        1 * DAY,
        2 * DAY,
        7 * DAY,
    ];

    class MetricStorage {
        constructor(dbName = "MetricsDB") {
            this.dbName = dbName;
            this.db = null;
        }

        async init() {
            return new Promise((resolve, reject) => {
                const request = indexedDB.open(this.dbName, 5);
                request.onupgradeneeded = (e) => {
                    const db = e.target.result;
                    if (!db.objectStoreNames.contains("buckets")) {
                        const store = db.createObjectStore("buckets", { keyPath: ["metric", "level", "startTime"] });
                        store.createIndex("by_time", ["metric", "level", "startTime"]);
                    }
                    if (!db.objectStoreNames.contains("metadata")) db.createObjectStore("metadata");
                };
                request.onsuccess = (e) => { this.db = e.target.result; resolve(); };
                request.onerror = (e) => reject(e.target.error);
            });
        }

        async saveBucket(metric, level, bucket) {
            if (!this.db) return Promise.resolve();
            return new Promise((resolve, reject) => {
                const tx = this.db.transaction("buckets", "readwrite");
                const req = tx.objectStore("buckets").put({ ...bucket, metric, level });
                req.onsuccess = () => resolve();
                req.onerror = () => reject(req.error);
            });
        }

        async getHistory(metric, level, since) {
            if (!this.db) return [];
            const tx = this.db.transaction("buckets", "readonly");
            const index = tx.objectStore("buckets").index("by_time");
            const range = IDBKeyRange.bound([metric, level, since], [metric, level, Infinity]);
            return new Promise(res => {
                const req = index.getAll(range);
                req.onsuccess = () => res(req.result);
                req.onerror = () => res([]);
            });
        }

        async cleanup(metric, level, cutoff) {
            if (!this.db) return Promise.resolve();
            return new Promise((resolve, reject) => {
                const tx = this.db.transaction("buckets", "readwrite");
                const index = tx.objectStore("buckets").index("by_time");
                const range = IDBKeyRange.bound([metric, level, 0], [metric, level, cutoff]);
                const request = index.openCursor(range);
                request.onsuccess = (e) => {
                    const cursor = e.target.result;
                    if (cursor) {
                        cursor.delete();
                        cursor.continue();
                    } else {
                        resolve();
                    }
                };
                request.onerror = () => reject(request.error);
            });
        }

        async saveMetadata(name, data) {
            if (!this.db) return;
            const tx = this.db.transaction("metadata", "readwrite");
            tx.objectStore("metadata").put(data, name);
        }

        async loadMetadata(name) {
            if (!this.db) return null;
            const tx = this.db.transaction("metadata", "readonly");
            return new Promise(res => {
                const req = tx.objectStore("metadata").get(name);
                req.onsuccess = () => res(req.result);
                req.onerror = () => res(null);
            });
        }

        async resetDatabase() {
            if (this.db) {
                this.db.close();
                this.db = null;
            }

            await new Promise((resolve, reject) => {
                const request = indexedDB.deleteDatabase(this.dbName);

                request.onsuccess = () => {
                    console.log(`Database "${this.dbName}" deleted.`);
                    resolve();
                };

                request.onerror = (e) => {
                    console.error("Error when deleting database :", e.target.error);
                    reject(e.target.error);
                };

                request.onblocked = () => {
                    console.warn("Blocked when deleting database.");
                };
            });

            await this.init();
            console.log("Reinitialized database and ready to use.");
        }
    }

    class AggregationLevel {
        constructor(name, levelKey, config, storage) {
            this.name = name;
            this.levelKey = levelKey;
            this.storage = storage;
            this.res = config.res;
            this.limit = config.limit;
            this.awake = false;
            this.cache = [];
            this.active = this._emptyBucket();
            this._initialized = false;
            this.isUpdating=false;
        }

        _emptyBucket(t = 0, initialMode = null) {
            return {
                startTime: t,
                sumV: 0,
                sumT: 0,
                rate:0,
                hasReset: false,
                modeChanged: false,
                currentMode: initialMode,
            };
        }

        async _init() {
            if (this._initialized) return;
            const h = await this.storage.getHistory(
                this.name,
                this.levelKey,
                Date.now() - this.limit,
            );
            if (h && h.length > 0) {
                const last = h[h.length - 1];
                this.active = last;
            }
            this._initialized = true;
        }

        async wake() {
            if (this.awake) return;
            const since = Date.now() - this.limit;
            const history = await this.storage.getHistory(
                this.name,
                this.levelKey,
                since,
            );
            this.cache = history.map((b) => ({
                t: b.startTime,
                rate: b.sumT > 0 ? b.sumV / (b.sumT / 1000) : 0,
                hasReset: b.hasReset,
                modeChanged: b.modeChanged,
                currentMode: b.currentMode,
                duration: b.duration,
                sumT: b.sumT
            }));
            this.awake = true;
        }

        sleep() {
            this.cache = [];
            this.awake = false;
        }

        async update(dv, dt, timestamp, isReset, modeValue, modeChanged) {
            if (this.isUpdating) return;
            this.isUpdating=true;
            if (!this._initialized) await this._init();
            const active = this.active;

            if (active.startTime === 0) {
                active.startTime = timestamp - dt;
                active.currentMode = modeValue;
            }

            if (modeChanged) active.modeChanged = true;
            if (isReset) active.hasReset = true;

            active.sumV += dv;
            active.sumT += dt;
            active.currentMode = modeValue;
            const rate = active.sumT > 0 ? active.sumV / (active.sumT / 1000) : 0;
            active.rate = rate
            const currentSpan = timestamp - active.startTime;
            active.duration = currentSpan

            if (this.awake) {
                const last = this.cache[this.cache.length-1];
                if(last?.t === active.startTime){
                    last.duration = currentSpan
                    last.sumT = active.sumT
                    last.rate = rate
                    last.hasReset = active.hasReset
                    last.modeChanged = active.modeChanged
                    last.currentMode = active.currentMode
                } else {
                    this.cache.push({
                        t: active.startTime,
                        duration: currentSpan,
                        sumT: active.sumT,
                        rate,
                        hasReset: active.hasReset,
                        modeChanged: active.modeChanged,
                        currentMode: active.currentMode
                    });
                }
                this.cache = this.cache.filter(p => p.t >= timestamp - this.limit);
            }



            if (currentSpan >= this.res) {

                const bucketToSave = { ...active, duration: currentSpan};
                this.active = this._emptyBucket(timestamp, modeValue);
                await this.storage.saveBucket(this.name, this.levelKey, bucketToSave);
                this.storage.cleanup(this.name, this.levelKey, timestamp - this.limit)
                await this.storage.saveBucket(this.name, this.levelKey, this.active);
            } else {
                await this.storage.saveBucket(this.name, this.levelKey, active);
            }
            this.isUpdating=false;
        }
        async getSeries() {
            if (!this._initialized) await this._init();
            if (!this.awake) await this.wake();

            const now = Date.now();

            const formatForDisplay = (bucket) => {
                const start = bucket.startTime || bucket.t;

                return {
                    ...bucket,
                    t: start + ((bucket.duration ?? bucket.sumT) / 2),
                    startTime: start,
                    duration: bucket.duration ?? bucket.sumT
                };
            };

            const history = this.cache.map((p) => formatForDisplay(p));
            return history;
        }

    }
    class MetricTracker {
        constructor(name, storage) {
            this.name = name;
            this.storage = storage;
            this.lastPoint = null;
            this.lastMode = null;
            this.levels = Object.fromEntries(
                aggregationLevels.map(({ key, label, limit, res }) => [
                    key,
                    new AggregationLevel(name, key, { res, limit }, storage)
                ])
            );
            this.isUpdating = false;
        }

        async init() {
            const meta = await this.storage.loadMetadata(this.name);
            if (meta) {
                this.lastPoint = meta.point;
                this.lastMode = meta.mode;
            }
        }

        async add(value, modeValue, timestamp = Date.now()) {
            if(this.isUpdating) return;
            this.isUpdating=true;
            if (this.lastPoint) {
                let dv = value - this.lastPoint.v;
                const dt = timestamp - this.lastPoint.t;
                if(
                   dt > RELOAD_TIMEOUT
                   && Date.now() - LAST_POOL > RELOAD_TIMEOUT
                  ){
                    window.location.reload();
                    throw "Reload page to not have weird datas";
                }
                if (dt < 20 ) {
                    this.isUpdating=false;
                    return
                }
                if(dv < 0){ // skip
                    this.lastPoint.v = value;
                    this.isUpdating=false;
                    return;
                }
                if(dv == 0){ // skip
                    this.isUpdating=false;
                    return;
                }

                const modeChanged = this.lastMode !== null && modeValue !== this.lastMode;
                const prom = [];
                for (const key in this.levels) {
                    prom.push(this.levels[key].update(dv, dt, timestamp, false, modeValue, modeChanged).catch((e) => {console.error(e)}));
                }
                await Promise.all(prom);

            }
            this.lastPoint = { v: value, t: timestamp };
            this.lastMode = modeValue;
            this.storage.saveMetadata(this.name, { point: this.lastPoint, mode: this.lastMode });
            this.isUpdating = false;
        }

        async getSeries(levelKey) {
            return this.levels[levelKey] ? await this.levels[levelKey].getSeries() : [];
        }

        getTimeRange(levelKey) {
            return this.levels[levelKey]?.limit;
        }
        getRes(levelKey) {
            return this.levels[levelKey]?.res;
        }

        sleep(levelKey) {
            if (this.levels[levelKey]) this.levels[levelKey].sleep();
        }
    }

    class MiniMonitor {
        constructor(canvasId, options = {}) {
            this.canvas = document.getElementById(canvasId);
            this.ctx = this.canvas.getContext('2d');
            this.options = {
                color: options.color || '#007bff',
                resetColor: options.resetColor || '#ff4757',
                modeColor: options.modeColor || '#6c5ce7',
                gridColor: options.gridColor || '#e0e0e033',
                textColor: options.textColor || '#999',
                padding: options.padding || 50,
                lineWidth: options.lineWidth || 2,
                baseUnitLabel: options.baseUnitLabel || 'DC',
            };
            this.data = [];
            this.bounds = { tMin: 0, tMax: 0, rMax: 0 };
            this.mouse = { x: 0, y: 0, active: false };
            this.unitLabel = "/sec";
            this.unitMult = 1;
            this.initEvents();
            this.initResizeObserver();
            this.limit = 0;
            this.active = undefined;
            this.initialized = false;
        }

        setupCanvas() {
            if (!this.canvas || !this.canvas.parentElement) return;
            const dpr = window.devicePixelRatio || 1;
            const rect = this.canvas.parentElement.getBoundingClientRect();
            const width = rect.width - 32;
            const height = 300;
            this.canvas.width = width * dpr;
            this.canvas.height = height * dpr;
            this.canvas.style.width = `${width}px`;
            this.canvas.style.height = `${height}px`;
            this.ctx.setTransform(1, 0, 0, 1, 0, 0);
            this.ctx.scale(dpr, dpr);
            this.initialized = true;
        }

        initResizeObserver() {
            const ro = new ResizeObserver(() => {
                this.setupCanvas()
                this.render();
            });
            ro.observe(this.canvas.parentElement);
        }

        initEvents() {
            const updateMouse = (e) => {
                const rect = this.canvas.getBoundingClientRect();
                this.mouse.x = e.clientX - rect.left;
                this.mouse.y = e.clientY - rect.top;
                this.mouse.active = true;
            };
            this.canvas.addEventListener('mousemove', updateMouse);
            this.canvas.addEventListener('mouseleave', () => {
                this.mouse.active = false;
            });
            window.addEventListener('resize', () => this.setupCanvas());
        }

        calculateTimeUnit(duration) {
            if (duration > DAY) return { unitLabel: "/day", unitMult: 86400 };
            else if (duration > HOUR * 2) return { unitLabel: "/hr", unitMult: 3600 };
            else if (duration > MIN * 10) return { unitLabel: "/min", unitMult: 60 };
            else return { unitLabel: "/sec", unitMult: 1 };
        }

        calculateValueScale(value) {
            const absValue = Math.abs(value);
            if (absValue >= 2e12) return { prefix: 'T', divisor: 1e12 };
            if (absValue >= 2e9) return { prefix: 'G', divisor: 1e9 };
            if (absValue >= 2e6) return { prefix: 'M', divisor: 1e6 };
            if (absValue >= 2e3) return { prefix: 'K', divisor: 1e3 };
            if (absValue >= 0.2) return { prefix: '', divisor: 1 };
            return { prefix: 'm', divisor: 1/1e3 };
        }

        setData(data, limit, res) {
            this.limit = limit;
            this.bucketRes = res;
            const newData = data.slice(0, data.length-1);
            this.active = data[data.length-1];
            const timeInfo = this.calculateTimeUnit(limit);
            const now = Date.now();
            this.tStep = this.calculateTimeStep(limit, Math.min(20,this.calculateTargetTicks(this.limit)));

            if (newData.length > 0) {
                const scaleInfo = this.calculateValueScale(Math.max(...newData.map(d => d.rate * timeInfo.unitMult)));
                this.unitLabel = scaleInfo.prefix + this.options.baseUnitLabel + timeInfo.unitLabel
                this.data = newData.map(d => ({ ...d, rate: d.rate * timeInfo.unitMult / scaleInfo.divisor }));
                const rates = this.data.map(d => d.rate);
                this.bounds = {
                    tMin: now - limit,
                    tMax: now,
                    rMax: Math.max(...rates) * 1.1 || 10,
                };
                this.active = {
                    ...this.active,
                    rate : this.active.rate * timeInfo.unitMult / scaleInfo.divisor
                }
            } else if(this.active) {
                const scaleInfo = this.calculateValueScale(this.active.rate * timeInfo.unitMult);
                this.unitLabel = scaleInfo.prefix + this.options.baseUnitLabel + timeInfo.unitLabel;
                this.data = [];
                this.active = {
                    ...this.active,
                    rate : this.active.rate * timeInfo.unitMult / scaleInfo.divisor
                }
                this.bounds = {
                    tMin: now - limit,
                    tMax: now,
                    rMax: this.active.rate * 1.5 || 10,
                };
            }
        }

        getX(t) {
            const width = parseInt(this.canvas.style.width) - (this.options.padding * 2);
            return this.options.padding + ((t - this.bounds.tMin) / (this.bounds.tMax - this.bounds.tMin)) * width;
        }

        getY(r) {
            const height = parseInt(this.canvas.style.height) - (this.options.padding * 2);
            return (parseInt(this.canvas.style.height) - this.options.padding) - (r / this.bounds.rMax) * height;
        }

        calculateStep(max) {
            const rawStep = max / 5;
            const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
            const res = rawStep / magnitude;
            let step = res < 1.5 ? 1 : res < 3 ? 2 : res < 7 ? 5 : 10;
            return step * magnitude;
        }

        calculateTargetTicks(limit) {
            const w = parseInt(this.canvas.style.width) || 400;
            const availableWidth = w - (this.options.padding * 2);

            let labelWidth = 50;
            try {
                this.ctx.font = '10px sans-serif';
                labelWidth = this.ctx.measureText(this.formatLabel(Date.now(), limit)).width + 20;
            } catch (e) {
                console.warn('measureText failed:', e);
            }

            return Math.max(3, Math.floor(availableWidth / labelWidth));
        }

        calculateTimeStep(diff, targetTicks = 15) {
            const rawStep = Math.floor(diff / targetTicks);

            for (const step of NICE_STEPS) {
                if (step >= rawStep) return step;
            }

            return NICE_STEPS[NICE_STEPS.length - 1];
        }

        formatLabel(t, diff) {
            const d = new Date(t);
            if (diff > 10 * DAY) return `${( '' + d.getDate()).padStart(2,'0')}/${ ('' + (d.getMonth() + 1)).padStart(2,'0')}`;
            if (diff > DAY) return `${( '' + d.getDate()).padStart(2,'0')}/${ ('' + (d.getMonth() + 1)).padStart(2,'0')} ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
            if (diff > HOUR) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
            return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
        }

        drawLine(x1, y1, x2, y2) {
            this.ctx.beginPath();
            this.ctx.moveTo(x1, y1);
            this.ctx.lineTo(x2, y2);
            this.ctx.stroke();
        }

        drawGrid() {
            const { ctx, options, bounds } = this;
            const w = parseInt(this.canvas.style.width);
            const h = parseInt(this.canvas.style.height);
            ctx.setLineDash([]);
            ctx.lineWidth = 0.5;
            ctx.strokeStyle = options.gridColor;
            ctx.fillStyle = options.textColor;
            ctx.font = '10px sans-serif';
            ctx.textBaseline = 'alphabetic';

            const rStep = this.calculateStep(bounds.rMax);
            for (let r = 0; r <= bounds.rMax; r += rStep) {
                const y = this.getY(r);
                if (y < options.padding || y > h - options.padding) continue;
                ctx.textAlign = 'right';
                ctx.fillText(r.toFixed(rStep < 1 ? 2 : 0), options.padding - 10, y + 3);
                this.drawLine(options.padding, y, w - options.padding, y);
            }
            ctx.fillText(this.unitLabel, options.padding - 10, options.padding - 20);


            const tStep = this.tStep;
            for (let t = Math.ceil(bounds.tMin / tStep) * tStep; t <= bounds.tMax; t += tStep) {
                const x = this.getX(t);
                if (x < options.padding || x > w - options.padding) continue;
                ctx.textAlign = 'center';
                ctx.fillText(this.formatLabel(t, this.limit), x, h - options.padding + 20);
                this.drawLine(x, options.padding, x, h - options.padding);
            }
        }

        render() {
            if(!this.initialized) return;
            if (this.canvas.width <= 0 || this.canvas.height <= 0) return;
            const { ctx, options } = this;
            const timeLimit = Date.now()-this.limit
            const data = this.data.filter(p => p.t >= timeLimit)
            this.tStep = this.calculateTimeStep(this.limit, Math.min(20,this.calculateTargetTicks(this.limit)));

            const w = parseInt(this.canvas.style.width);
            const h = parseInt(this.canvas.style.height);
            this.bounds.tMax = Date.now();
            this.bounds.tMin = timeLimit;

            ctx.clearRect(0, 0, w, h);
            if (data.length == 0 && !this.active) {
                ctx.fillStyle = 'rgba(119, 119, 119, 0.5)';
                ctx.font = '20px sans-serif';
                ctx.textAlign = 'center';
                ctx.fillText('NO DATA', w / 2, h / 2);
                return;
            }
            if (!this.isLeader) {
                ctx.fillStyle = 'rgba(119, 119, 119, 0.5)';
                ctx.font = '20px sans-serif';
                ctx.textAlign = 'center';
                ctx.fillText('⏸Paused', w / 2, h / 2 + 25);
                ctx.fillStyle = 'rgba(119, 119, 119, 0.5)';
                ctx.font = '10px sans-serif';
                ctx.textAlign = 'center';
                ctx.fillText('Recording from another tab', w / 2, h / 2);

                return;
            }


            this.drawGrid();
            this.drawRecorgingStatus()

            data.forEach(p => {
                if (p.modeChanged && !isNaN(p.currentMode)) {
                    const x = this.getX(p.t);
                    ctx.setLineDash([]);
                    ctx.strokeStyle = options.modeColor;
                    ctx.lineWidth = 1;
                    this.drawLine(x, options.padding - 10, x, h - options.padding);
                    ctx.fillStyle = options.modeColor;
                    ctx.font = 'bold 9px sans-serif';
                    ctx.textAlign = 'left';
                    ctx.fillText('DC+' + (p.currentMode - 1), x + 2, options.padding - 5);
                }
            });

            ctx.setLineDash([]);
            ctx.beginPath();
            ctx.strokeStyle = options.color;
            ctx.lineWidth = options.lineWidth;

            data.forEach((p, i) => {
                ctx.strokeStyle = options.color;

                const x1 = this.getX(p.t);
                const y1 = this.getY(p.rate);
                const halfDur1 = Math.abs(this.getX(p.t + p.duration / 2) - x1);
                const startX1 = x1 - halfDur1;
                const endX1 = x1 + halfDur1;

                if (i === 0) {
                    ctx.moveTo(Math.max(startX1, this.getX(timeLimit)), y1);
                    ctx.lineTo(x1, y1);
                    if(data.length == 1){
                        ctx.lineTo(endX1, y1);
                    }
                } else {
                    const prev = data[i - 1];
                    const x0 = this.getX(prev.t);
                    const y0 = this.getY(prev.rate);
                    const halfDur0 = Math.abs(this.getX(prev.t + prev.duration / 2) - x0);
                    const endX0 = x0 + halfDur0;

                    const type = prefs?.graphType || 'step';

                    if (type === 'smooth') {
                        const smoothDur = Math.min(halfDur0, halfDur1);
                        const offset = smoothDur / 2;

                        ctx.lineTo(endX0 - offset, y0);

                        const endCurveX = startX1 + offset;
                        const cpX = ( (endX0 - offset) + endCurveX ) / 2;

                        ctx.bezierCurveTo(
                            cpX, y0,
                            cpX, y1,
                            endCurveX, y1
                        );

                        ctx.lineTo(x1, y1);
                    }
                    else {
                        ctx.lineTo(endX0, y0);
                        ctx.lineTo(endX0, y1);
                        ctx.lineTo(x1, y1);
                    }

                    if (i === data.length - 1) {
                        ctx.lineTo(endX1, y1);
                    }
                }

            });

            ctx.stroke();

            if (prefs?.graphType === 'block' && data.length > 0) {
                const first = data[0];
                const last = data[data.length - 1];
                const yBase = this.getY(0);
                ctx.lineTo(this.getX(last.t + last.duration / 2), yBase);
                ctx.lineTo(this.getX(Math.max(first.startTime, timeLimit)), yBase);
                ctx.closePath();
                ctx.fillStyle = options.color + '33';
                ctx.fill();
            }

            const active = this.active
            if(active){
                ctx.beginPath();
                const x2 = this.getX(active.t);
                const y2 = this.getY(active.rate);
                const halfDur2 = Math.abs(this.getX(active.t + active.duration / 2) - x2);
                const endX2 = x2 + halfDur2;
                ctx.strokeStyle = options.color + '66';
                const top = this.getY(this.bounds.rMax);
                if(data.length){
                    const prev = data[data.length - 1];
                    const x0 = this.getX(prev.t);
                    const y0 = this.getY(prev.rate);
                    const halfDur0 = Math.abs(this.getX(prev.t + prev.duration / 2) - x0);
                    const endX0 = x0 + halfDur0;
                    ctx.moveTo(endX0, y0);
                    ctx.lineTo(x2 - halfDur2, Math.max(y2, top));
                } else {
                    ctx.moveTo(x2 - halfDur2, y2);
                }
                if(top < y2){
                    ctx.lineTo(x2 + halfDur2, y2);
                }
                ctx.stroke();
            }


            ctx.fillStyle = options.color;
            data.forEach(p => {
                ctx.beginPath();
                ctx.arc(this.getX(p.t), this.getY(p.rate), 3, 0, Math.PI * 2);
                ctx.fill();
            });

            if (this.mouse.active) this.drawTooltip(timeLimit);
        }

        scaleDurationToPixels(duration) {
            const range = this.bounds.tMax - this.bounds.tMin;
            if (range <= 0) return 0;
            const chartWidth = parseInt(this.canvas.style.width) - (this.options.padding * 2);
            return (duration / range) * chartWidth;
        }

        drawTooltipElement(ctx, x, y, items) {
            const padding = 10;
            const lineHeight = 16;
            const fontSize = 11;

            ctx.font = `bold ${fontSize}px sans-serif`;

            let maxWidth = 0;
            items.forEach(item => {
                const text = `${item.label}${item.label && item.value? ': ' : '' }${item.value}`;
                const width = ctx.measureText(text).width;
                if (width > maxWidth) maxWidth = width;
            });

            const boxW = maxWidth + (padding * 2);
            const boxH = (items.length * lineHeight) + padding;

            const boxX = x - (boxW / 2);
            const boxY = Math.max(0, y - ( boxH + 20 ));

            ctx.fillStyle = 'rgba(45, 52, 54, 0.95)';
            ctx.fillRect(boxX, boxY, boxW, boxH);

            ctx.textAlign = 'left';
            ctx.textBaseline = 'top';

            items.forEach((item, index) => {
                const currentY = boxY + padding + (index * lineHeight);

                if(item.label){
                    ctx.font = `${fontSize}px sans-serif`;
                    ctx.fillStyle = item.labelColor || '#fff';
                    ctx.fillText(item.label + (item.value !== '' ? ': ':''), boxX + padding, currentY);
                }
                ctx.font = `bold ${fontSize}px sans-serif`;
                const labelWidth = item.label ? ctx.measureText(item.label + ': ').width : 0;
                ctx.fillStyle = item.valueColor || '#fff';
                ctx.fillText(item.value, boxX + padding + labelWidth, currentY);
            });
        }

        drawTooltip(timeLimit) {
            const { ctx, options } = this;
            const data = [this.active, ...this.data.filter(p => p.t >= timeLimit)]
            if(!data?.length){return}
            const closest = data.reduce((prev, curr) => Math.abs(this.getX(curr.t) - this.mouse.x) < Math.abs(this.getX(prev.t) - this.mouse.x) ? curr : prev);
            const isActive = closest == this.active
            const duration = closest.duration;
            const completed = duration / this.bucketRes;
            const x = this.getX(closest.t), y = this.getY(closest.rate);
            const drawPoint = (x,y) => {
                ctx.setLineDash([]);
                ctx.beginPath();
                ctx.arc(x, y, 4, 0, Math.PI * 2);
                ctx.fillStyle = options.color;
                ctx.fill();
                ctx.strokeStyle = isActive ? '#f008' : '#fff';
                ctx.lineWidth = isActive ? 2 : 1;
                ctx.stroke();
            }
            drawPoint(x,y);

            ctx.beginPath();
            ctx.moveTo(this.getX(Math.max(closest.startTime, timeLimit)), this.getY(closest.rate));
            ctx.lineTo(this.getX(closest.startTime+duration), this.getY(closest.rate));
            ctx.lineTo(this.getX(closest.startTime+duration), this.getY(0));
            ctx.lineTo(this.getX(Math.max(closest.startTime, timeLimit)), this.getY(0));
            ctx.closePath();
            ctx.fillStyle = options.color+ (isActive ? '11' : '33' );
            ctx.fill();

            const tooltipItems = [
                {
                    label: 'Dep',
                    value: closest.currentMode ? `DC+${closest.currentMode - 1}` : 'N/A',
                    labelColor: '#fff',
                    valueColor: '#fff'
                },
                {
                    label: 'Rate', // fun random : isActive && completed<1 ? Math.max(0, closest.rate + (closest.rate * 0.001 * (Math.random()-0.5) * Math.pow(10,2*(1 - completed)))) :
                    value: `${( closest.rate ).toFixed(2)} ${this.unitLabel}`,
                    labelColor: options.color,
                    valueColor: options.color
                },
                {
                    label: '',
                    value: `${this.formatLabel(closest.t - duration / 2, this.limit)} - ${this.formatLabel( isActive ? this.bucketRes + closest.t - duration / 2 : closest.t + duration / 2, this.limit)}${isActive ? '...' : ''}`,
                    labelColor: '#ccc',
                    valueColor: '#ccc',
                },
                // {
                //     label: 'Precision',
                //     value: (100*closest.sumT/closest.duration).toFixed(2),
                //     labelColor: '#ccc',
                //     valueColor: '#ccc',
                // }
            ];
            if(isActive){
                tooltipItems.push({
                    label: completed>1 ? 'Ready': `In progress... [ ${(completed * 100).toFixed(0) }% ]`,
                    value: ``,
                    labelColor: completed>1 ? '#62ff7d' : '#ff627d',
                    valueColor: '#ff627d',
                })
            }
            this.drawTooltipElement(ctx, x, y, tooltipItems);
        }

        drawRecorgingStatus(){
        }
    }

    class TabLeader {
        constructor(channelName, { onGainLeadership, onLoseLeadership } = {}) {
            this._channel = new BroadcastChannel(channelName);
            this._isLeader = false;
            this._electTimeout = null;
            this._onGain = onGainLeadership ?? (() => {});
            this._onLose = onLoseLeadership ?? (() => {});

            this._channel.onmessage = ({ data }) => {
                if (data === 'takeover') {
                    clearTimeout(this._electTimeout);
                    this._electTimeout = null;
                    if (this._isLeader) {
                        this._isLeader = false;
                        this._onLose();
                    }
                } else if (data === 'leader-gone') {
                    this._electTimeout = setTimeout(
                        () => this._claim(),
                        Math.random() * 150 + 50
                    );
                }
            };

            window.addEventListener('beforeunload', () => this.destroy());
        }

        claim() {
            this._claim();
        }

        get isLeader() {
            return this._isLeader;
        }

        destroy() {
            clearTimeout(this._electTimeout);
            if (this._isLeader) {
                this._isLeader = false;
                this._channel.postMessage('leader-gone');
            }
            this._channel.close();
        }

        _claim() {
            this._electTimeout = null;
            this._isLeader = true;
            this._channel.postMessage('takeover');
            this._onGain();
        }
    }

    function hookToState(target, mapping, callback) {
        const gameState = {};

        for (const [alias, fnName] of Object.entries(mapping)) {
            const original = target[fnName];

            if (typeof original !== "function") {
                console.warn(`Skip ${fnName}: not a function`);
                continue;
            }

            target[fnName] = function (...args) {
                gameState[alias] = args;

                try {
                    callback?.(gameState, alias, fnName, args);
                } catch (e) {
                    console.error("Callback error:", e);
                }

                return original.apply(this, args);
            };
        }

        return gameState;
    }


    let storage, trackerDC, trackerRC, trackerRCP, trackerB, mainChart;
    let prefsjson = {};
    const gameState = hookToState(
        // eslint-disable-next-line
        window.gameUpdatesChannel,
        {
            rc: "updateRecursionHeader",
            dc: "updateCoins"
        },
    );
    try{
        prefsjson = JSON.parse(localStorage.getItem('nek-graph-prefs'))
    } catch(e){
        console.error(e)
    }
    let prefs = {
        type: 'DC',
        range: '1h',
        show: true,
        graphType: 'smooth',
        ...prefsjson,
    }
    function savePrefs(){
        localStorage.setItem('nek-graph-prefs', JSON.stringify(prefs))
    }
    let currentType = prefs.type;
    let currentRange = prefs.range;
    let leader, isLeader = false;

    async function initLogic() {
        storage = new MetricStorage();
        await storage.init();
        trackerDC = new MetricTracker("DC", storage);
        trackerRC = new MetricTracker("RC", storage);
        trackerRCP = new MetricTracker("RCP", storage);
        trackerB = new MetricTracker("B", storage);
        await Promise.all([trackerDC.init(), trackerRC.init(), trackerRCP.init(), trackerB.init()]);
        mainChart = new MiniMonitor('nek-main-canvas', { color: BUTTON_COLOR, resetColor: '#ff4757', modeColor: '#6c5ce7' });
        leader = new TabLeader('nek-graph-leader', {
            onGainLeadership: async () => {
                trackerDC = new MetricTracker("DC", storage);
                trackerRC = new MetricTracker("RC", storage);
                trackerRCP = new MetricTracker("RCP", storage);
                trackerB = new MetricTracker("B", storage);
                await Promise.all([trackerDC.init(), trackerRC.init(), trackerRCP.init(), trackerB.init()]);
                isLeader = true;
                updateGraph()
            },
            onLoseLeadership: () => {
                isLeader = false;
                trackerDC?.sleep(currentRange)
                trackerRC?.sleep(currentRange)
                trackerRCP?.sleep(currentRange)
                trackerB?.sleep(currentRange);
            },
        });
        leader.claim();
        let dep = null;
        setInterval(async () => {
            if (!isLeader) return;
            const data = scrapeData();
            dep = data.deptId;
            const dcVal = gameState.dc?.[0];
            const rcVal = parseFlexibleFloat(gameState.rc?.[0]);
            const rcpVal = gameState.dc?.[2];
            const awaitArray = [];
            if(!isNaN(dep) && dep != null) {
                if(!isNaN(dcVal) && dcVal !== 0) awaitArray.push(trackerDC.add(dcVal, dep));
                if(!isNaN(rcVal) && rcVal !== 0) awaitArray.push(trackerRC.add(rcVal, dep));
                if(!isNaN(rcpVal) && rcpVal !== 0) awaitArray.push(trackerRCP.add(rcpVal, dep));
                if(!isNaN(data.brokenBlocks) && data.brokenBlocks !== 0) awaitArray.push(trackerB.add(data.brokenBlocks, dep));
            }
            await Promise.all(awaitArray);
            updateGraph();
            LAST_POOL = Date.now()
        }, 2000);
        updateGraph();
    }

    const unitsInfos = {
        DC: {unit:'DC', color: '#01ee60'},
        RC:  {unit:'RC', color: '#f43098'},
        RCP:  {unit:'RCP', color: '#e430f4'},
        B:  {unit:'Blocks', color: '#dddddd'},
    }

    let updateGraphInProgress = false;
    async function updateGraph() {
        if (!mainChart || !prefs.show) return;
        if(updateGraphInProgress) { return; }
        updateGraphInProgress = true;
        const tracker = (currentType === 'DC') ? trackerDC
        : (currentType === 'RC') ? trackerRC
        : (currentType === 'RCP') ? trackerRCP
        : (currentType === 'B') ? trackerB
        : null;
        if(!tracker) {
            updateGraphInProgress = false ;
            return;
        }
        const series = await tracker.getSeries(currentRange);
        const limit = tracker.getTimeRange(currentRange);
        requestAnimationFrame(() => {
            mainChart.options.color = unitsInfos[currentType].color;
            mainChart.options.baseUnitLabel = unitsInfos[currentType].unit
            mainChart.setData(series, limit, tracker.getRes(currentRange));
            updateGraphInProgress = false;
        });
    }

    const graphWrapper = document.createElement('div');
    graphWrapper.id = 'nek-graph-wrapper';
    graphWrapper.className = 'card bg-base-200 border border-base-300 p-4 mb-4 mx-2';

    function renderGraphUI() {
        graphWrapper.innerHTML = `
            <canvas id="nek-main-canvas" style="width: 100%; height: 300px;"></canvas>
            <div class="flex flex-wrap items-center justify-between gap-2 mb-4 overflow-x-hidden">
                <div class="flex flex-wrap gap-1" id="graph-series-selector">
                    <button class="btn btn-xs btn-outline ${currentType === 'DC' ? 'btn-primary' : 'btn-active'}" data-type="DC" style="color: ${unitsInfos['DC'].color}">DC</button>
                    <button class="btn btn-xs btn-outline ${currentType === 'RC' ? 'btn-primary' : 'btn-active'}" data-type="RC" style="color: ${unitsInfos['RC'].color}">RC</button>
                    <button class="btn btn-xs btn-outline ${currentType === 'RCP' ? 'btn-primary' : 'btn-active'}" data-type="RCP" style="color: ${unitsInfos['RCP'].color}">RC Potential</button>
                    <button class="btn btn-xs btn-outline ${currentType === 'B' ? 'btn-primary' : 'btn-active'}" data-type="B" style="color: ${unitsInfos['B'].color}">Broken Blocks</button>
                </div>
                <div class="flex flex-wrap gap-1" id="graph-range-selector">
                    ${aggregationLevels.map(({ key, label }) => `
                        <button
                            class="tooltip btn btn-xs btn-outline ${currentRange === key ? 'btn-primary' : 'btn-active'}"
                            data-range="${key}"
                            data-tip="${label}"
                        >
                            ${key}
                        </button>
                    `).join('')}
                </div>
            </div>
        `;
    }
    renderGraphUI();
    if(!prefs.show){
        graphWrapper.classList.add('hidden');
    }

    graphWrapper.addEventListener('click', (e) => {
        const btn = e.target.closest('button');
        if (!btn) return;
        if (btn.dataset.type) {
            graphWrapper.querySelectorAll('#graph-series-selector .btn').forEach(b => b.classList.replace('btn-primary', 'btn-active'));
            btn.classList.replace('btn-active', 'btn-primary');
            trackerDC.sleep(currentRange);
            trackerRC.sleep(currentRange);
            trackerRCP.sleep(currentRange);
            trackerB.sleep(currentRange);
            currentType = btn.dataset.type;
            prefs.type = currentType;
            savePrefs();
        }
        if (btn.dataset.range) {
            graphWrapper.querySelectorAll('#graph-range-selector .btn').forEach(b => b.classList.replace('btn-primary', 'btn-active'));
            btn.classList.replace('btn-active', 'btn-primary');
            trackerDC.sleep(currentRange)
            trackerRC.sleep(currentRange)
            trackerRCP.sleep(currentRange)
            trackerB.sleep(currentRange);
            currentRange = btn.dataset.range;
            prefs.range = currentRange;
            savePrefs();
        }
        updateGraph();
    });

    function parseFlexibleFloat(str){
        if (!str) return 0;
        let clean = str.replace(/[^\d.,]/g, '');
        const lastDot = clean.lastIndexOf('.'), lastComma = clean.lastIndexOf(',');
        if (lastComma > lastDot) clean = clean.replace(/\./g, '').replace(',', '.');
        else if (lastDot > lastComma) clean = clean.replace(/,/g, '');
        return parseFloat(clean) || 0;
    }

    function scrapeData() {
        // const dcElem = document.querySelector('[data-role="dc-balance"]');
        // const rcElem = document.querySelector('[data-role="rc-potential"]');
        const statsLink = document.querySelector('a[title="View Department Statistics"]');
        // const workerElem = document.querySelector('.drawer-side aside a[href^="/workers/"]');
        const brokenBlocksElem = document.getElementById("tiles-defeated-badge")?.children[0];
        let brokenBlocks
        if(brokenBlocksElem){
            brokenBlocks = parseInt(brokenBlocksElem.textContent.split('/')[0].replace(/[,\.\s]/g,''))
        }
        // const dcVal = dcElem ? parseFlexibleFloat(dcElem.innerText) : 0;
        // const rcVal = rcElem ? parseFlexibleFloat(rcElem.innerText) : 0;
        let deptId = null;
        if (statsLink) {
            const match = statsLink.getAttribute('href').match(/\/departments\/(\d+)/);
            deptId = match ? match[1] : null;
        }
        // const workerid = workerElem.attributes.href.value.split('/')[2]
        return {
            // dcVal,
            // rcVal,
            deptId,
            //workerid,
            brokenBlocks
        };
    }

    const graphOptions = [
        { value: 'step', label: 'Step Graph' },
        { value: 'block', label: 'Block Graph' },
        { value: 'smooth', label: 'Smooth Graph' }
    ];

    function createMenuContent() {
        const container = document.createElement('div');
        container.style.cssText = 'padding: 5px 0;';

        const smoothRow = document.createElement('div');
        smoothRow.style.cssText = 'display: flex; gap: 8px; padding: 0 10px; margin-bottom: 8px; cursor: pointer;flex-direction: column;';

        graphOptions.forEach(option => {
            const wrapper = document.createElement('div');
            wrapper.style.cssText = 'display: flex; gap: 5px; margin-bottom: 4px;';

            const radio = document.createElement('input');
            radio.type = 'radio';
            radio.name = 'graph-type-group';
            radio.id = `graph-type-${option.value}`;
            radio.value = option.value;

            radio.checked = (prefs.graphType === option.value);
            radio.classList.add('radio', 'checkbox-xs');

            const label = document.createElement('label');
            label.htmlFor = radio.id;
            label.textContent = option.label;
            label.style.cssText = 'cursor: pointer; user-select: none;';

            radio.onchange = async () => {
                if (radio.checked) {
                    prefs.graphType = radio.value;
                    savePrefs();
                    updateGraph();
                }
            };

            wrapper.appendChild(radio);
            wrapper.appendChild(label);

            smoothRow.appendChild(wrapper);
        });

        container.appendChild(smoothRow);

        const toggleBtn = document.createElement('button');
        toggleBtn.textContent = prefs.show ? 'Hide':'Show';
        toggleBtn.classList.add("btn", "btn-xs", "btn-wide", 'my-3', "tooltip", "tooltip-right")
        toggleBtn.dataset.tip = "Hide the graph without stopping the data polling";
        toggleBtn.onclick = async () => {
            prefs.show = !prefs.show;
            savePrefs();
            toggleBtn.textContent = prefs.show ? 'Hide':'Show';
            if(!prefs.show){
                graphWrapper.classList.add('hidden');
                trackerDC.sleep(currentRange)
                trackerRC.sleep(currentRange)
                trackerRCP.sleep(currentRange)
                trackerB.sleep(currentRange)
            } else {
                graphWrapper.classList.remove('hidden');
                startRenderLoop();
            }
        };
        container.appendChild(toggleBtn);

        const resetBtn = document.createElement('button');
        resetBtn.textContent = 'Reset Database';
        resetBtn.classList.add("btn", "btn-error", "btn-xs", "btn-wide", 'my-3')
        resetBtn.onclick = async () => {
            if(storage){
                if (!confirm("Are you sure to reset the NekGraph Database ?")){ return; }
                await storage.resetDatabase();
                trackerDC.sleep(currentRange)
                trackerRC.sleep(currentRange)
                trackerRCP.sleep(currentRange)
                trackerB.sleep(currentRange)
                updateGraph();
            }
        };

        container.appendChild(resetBtn);

        const versionNote = document.createElement('div');
        versionNote.textContent = `${EXTENSION_LABEL} v${EXTENSION_VERSION}`;
        versionNote.style.cssText = 'font-size: 10px; color: #777; text-align: right; padding: 4px 10px 2px; pointer-events: none;';
        container.appendChild(versionNote);

        return container;
    }

    function createStandaloneWidget() {
        const wrapper = document.createElement('div');
        wrapper.style.cssText = 'position: relative; display: inline-block;';
        const btn = document.createElement('button');
        btn.textContent = EXTENSION_LABEL;
        btn.style.color = BUTTON_COLOR;
        btn.classList.add('btn', 'btn-ghost', 'btn-sm', 'text-primary');
        const popup = document.createElement('div');
        popup.style.cssText = 'display: none; position: absolute; bottom: calc(100% + 8px); left: 0; min-width: 200px; z-index: 1000; text-align: left;';
        popup.classList.add('card', 'bg-base-100', 'border', 'border-base-300', 'p-2', 'text-xs', 'shadow-xl');
        popup.appendChild(createMenuContent());
        btn.addEventListener('click', e => { e.stopPropagation(); popup.style.display = popup.style.display === 'none' ? 'block' : 'none'; });
        document.addEventListener('click', e => { if (!wrapper.contains(e.target)) popup.style.display = 'none'; });
        wrapper.append(btn, popup);
        return wrapper;
    }

    function startGraphObserver() {
        function tryInject() {
            const target = document.getElementById(INJECT_BEFORE_ID);
            if (target && graphWrapper.parentElement !== target.parentElement) {
                target.parentNode.insertBefore(graphWrapper, target);
                if (mainChart) mainChart.setupCanvas();
            }
        }
        tryInject();
        new MutationObserver(tryInject).observe(document.documentElement, { childList: true, subtree: true });
        document.addEventListener("visibilitychange", () => {
            if (document.hidden && requestAnimationFrameId) {
                cancelAnimationFrame(requestAnimationFrameId)
                requestAnimationFrameId = undefined;
            } else{
                startRenderLoop();
            }
        });
        startRenderLoop();
    }

    let requestAnimationFrameId;
    function startRenderLoop(){
        if(requestAnimationFrameId) {
            cancelAnimationFrame(requestAnimationFrameId);
            requestAnimationFrameId = undefined;
        }
        const renderLoop = () => {
            if(!prefs.show) {return}
            try{
                if(mainChart){
                    mainChart.isLeader = isLeader
                    mainChart.render();
                }
            } catch(e){ console.error(e) }
            requestAnimationFrameId = requestAnimationFrame(renderLoop);
        }
        renderLoop();

    }

    function injectStandaloneButton() {
        const widget = createStandaloneWidget();
        function tryInject() {
            const target = document.querySelector(INJECT_TARGET);
            if (target && widget.parentElement !== target) target.insertBefore(widget, target.firstChild);
        }
        tryInject();
        new MutationObserver(tryInject).observe(document.documentElement, { childList: true, subtree: true });
    }

    function registerWithNekStyle() {
        window.NekStyle.registerExtension({ id: EXTENSION_ID, label: EXTENSION_LABEL, color: BUTTON_COLOR, createContent: createMenuContent });
    }

    function init() {
        startGraphObserver();
        initLogic().catch(console.error);
        if (typeof window.NekStyle?.registerExtension === 'function') {
            registerWithNekStyle();
            return;
        }
        let registered = false;
        window.__NekStyleExtensions = window.__NekStyleExtensions || [];
        window.__NekStyleExtensions.push(() => { registered = true; registerWithNekStyle(); });
        setTimeout(() => { if (!registered) injectStandaloneButton(); }, 0);
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
})();