Graph display — NekStyle extension
// ==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();
})();