DOM + FPS Indicator (Draggable + Minimize)

DOM/FPS индикатор: перетаскивание, двойной клик — компактный кружок, позиция и состояние сохраняются; спарклайн ms/кадр (опц.)

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         DOM + FPS Indicator (Draggable + Minimize)
// @namespace    https://github.com/aket0r/
// @version      2.1b
// @description  DOM/FPS индикатор: перетаскивание, двойной клик — компактный кружок, позиция и состояние сохраняются; спарклайн ms/кадр (опц.)
// @author       aket0r
// @match        http://*/*
// @match        https://*/*
// @exclude      https://chat.openai.com/*
// @exclude      https://chatgpt.com/*
// @grant        none
// @license      MIT
// @icon         https://raw.githubusercontent.com/aket0r/dom-indicator-loading/main/DOM-indicator-loading.png
// ==/UserScript==

(() => {
  'use strict';

  // ===== Настройки =====
  const DOM_THRESHOLDS = { warn: 15000, danger: 30000 };
  const DOM_UPDATE_EVERY_MS = 1000;

  const FPS_ENABLED = true;
  const FPS_WINDOW = 60;
  const FPS_UI_UPDATE_MS = 1000;

  const SPARKLINE_ENABLED = true;
  const SPARK = {
    length: 120,
    width: 140,
    height: 28,
    padX: 4,
    padY: 3,
    clampMs: { min: 8, max: 100 }
  };

  const LS_KEY = 'dom_fps_indicator_state_v12'; // позиция/минимизация

  // ===== Ранний выход для iframes =====
  if (window.top !== window.self) return;

  // ===== Состояние UI (позиция/минимизация) =====
  const state = loadState() || { x: null, y: null, minimized: false };

  function saveState() {
    try { localStorage.setItem(LS_KEY, JSON.stringify(state)); } catch {}
  }
  function loadState() {
    try { return JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch { return null; }
  }

  // ===== FPS-модуль =====
  const FPSMeter = (() => {
    let rafId = null;
    let last = 0;
    let samples = [];
    let lastUiUpdate = 0;
    const msBuf = [];

    function loop(ts) {
      if (!last) last = ts;
      const delta = ts - last;
      last = ts;

      if (delta > 0 && delta < 250) {
        const fps = 1000 / delta;
        samples.push(fps);
        if (samples.length > FPS_WINDOW) samples.shift();

        if (SPARKLINE_ENABLED) {
          msBuf.push(delta);
          if (msBuf.length > SPARK.length) msBuf.shift();
        }
      }

      if (FPS_ENABLED && ts - lastUiUpdate >= FPS_UI_UPDATE_MS) {
        lastUiUpdate = ts;
        updateFPSLine(getStats(), msBuf);
        if (SPARKLINE_ENABLED) drawSparkline(msBuf);
      }

      rafId = requestAnimationFrame(loop);
    }

    function getStats() {
      if (samples.length === 0) return { avg: 0, min: 0, max: 0 };
      let sum = 0, min = Infinity, max = -Infinity;
      for (const v of samples) { sum += v; if (v < min) min = v; if (v > max) max = v; }
      return { avg: sum / samples.length, min, max };
    }

    function start() {
      if (rafId != null || !FPS_ENABLED) return;
      samples = [];
      last = 0;
      lastUiUpdate = 0;
      rafId = requestAnimationFrame(loop);
    }

    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') last = performance.now();
    });

    return { start };
  })();

  // ===== UI бейдж (перетаскиваемый + компактный режим) =====
  function initBadge() {
    console.log(`%c[${new Date().toLocaleString()}] DOM + FPS indicator loaded.`, 'color: lime;');

    let badge = document.getElementById('dom-indicator');
    if (badge) return;

    // Контейнер
    badge = document.createElement('div');
    badge.id = 'dom-indicator';
    badge.style.cssText = `
      position: fixed;
      bottom: 20px;
      right: 80px;
      background: #222;
      color: #0f0;
      font-family: monospace;
      padding: 6px 10px;
      border-radius: 8px;
      font-size: 13px;
      z-index: 2147483647;
      box-shadow: 0 0 4px rgba(0,0,0,0.4);
      user-select: none;
      pointer-events: auto;  /* ВАЖНО: кликабельно */
      line-height: 1.25;
      white-space: nowrap;
      cursor: grab;          /* перетаскивание */
    `;

    // Внутреннее содержимое
    const domLine = document.createElement('div');
    domLine.id = 'dom-line';
    domLine.textContent = 'DOM nodes: loading...';

    const fpsLine = document.createElement('div');
    fpsLine.id = 'fps-line';
    if (FPS_ENABLED) fpsLine.textContent = 'FPS: --.- (ms: --.-)';

    const spark = document.createElement('canvas');
    spark.id = 'fps-spark';
    spark.width = SPARK.width;
    spark.height = SPARK.height;
    spark.style.cssText = 'display:block;margin-top:4px;opacity:.9;';

    badge.appendChild(domLine);
    if (FPS_ENABLED) badge.appendChild(fpsLine);
    if (SPARKLINE_ENABLED) badge.appendChild(spark);
    document.body.prepend(badge);

    // Применить сохранённую позицию/режим
    if (state.x !== null && state.y !== null) {
      applyPosition(badge, state.x, state.y);
    } else {
      // дефолтная позиция — правый-низ (уже задана через bottom/right)
      clampToViewport(badge);
    }
    if (state.minimized) {
      setMinimized(badge, true);
    }

    // Обработчики перетаскивания
    makeDraggable(badge);

    // Двойной клик — переключить режим (полный/компакт)
    badge.addEventListener('dblclick', () => {
      setMinimized(badge, !state.minimized);
      saveState();
    });

    // Запуск DOM-счётчика
    setInterval(updateDOMLine, DOM_UPDATE_EVERY_MS);

    // На ресайз — не уезжаем за экран
    window.addEventListener('resize', () => clampToViewport(badge));
  }

  function setMinimized(badge, minimized) {
    state.minimized = minimized;

    const domLine = badge.querySelector('#dom-line');
    const fpsLine = badge.querySelector('#fps-line');
    const spark = badge.querySelector('#fps-spark');

    if (minimized) {
      // Компактный кружок — только число DOM
      badge.style.width = '42px';
      badge.style.height = '42px';
      badge.style.borderRadius = '999px';
      badge.style.padding = '0';
      badge.style.display = 'flex';
      badge.style.alignItems = 'center';
      badge.style.justifyContent = 'center';
      badge.style.cursor = 'grab';

      // Покажем только число (без «DOM nodes: »)
      const count = document.querySelectorAll('*').length;
      domLine.textContent = `${count}`;
      domLine.style.display = 'block';
      domLine.style.fontWeight = '700';
      domLine.style.fontSize = '14px';

      if (fpsLine) fpsLine.style.display = 'none';
      if (spark) spark.style.display = 'none';
    } else {
      // Полный режим
      badge.style.width = '';
      badge.style.height = '';
      badge.style.borderRadius = '8px';
      badge.style.padding = '6px 10px';
      badge.style.display = 'block';
      badge.style.cursor = 'grab';

      // Вернём текстовую метку
      const count = document.querySelectorAll('*').length;
      domLine.textContent = `DOM nodes: ${count}`;
      domLine.style.fontWeight = '';
      domLine.style.fontSize = '';

      if (fpsLine) fpsLine.style.display = '';
      if (spark && SPARKLINE_ENABLED) spark.style.display = 'block';
    }
  }

  function updateDOMLine() {
    const badge = document.getElementById('dom-indicator');
    const domLine = document.getElementById('dom-line');
    if (!badge || !domLine) return;

    const count = document.querySelectorAll('*').length;
    if (state.minimized) {
      domLine.textContent = `${count}`;
    } else {
      domLine.textContent = `DOM nodes: ${count}`;
    }

    if (count > DOM_THRESHOLDS.danger) {
      badge.style.color = '#f55';
      badge.style.background = '#300';
    } else if (count > DOM_THRESHOLDS.warn) {
      badge.style.color = '#ff0';
      badge.style.background = '#442';
    } else {
      badge.style.color = '#0f0';
      badge.style.background = '#222';
    }
  }

  function updateFPSLine(stats, msBuf) {
    if (!FPS_ENABLED) return;
    const el = document.getElementById('fps-line');
    const badge = document.getElementById('dom-indicator');
    if (!el || !badge) return;

    const avg = stats.avg || 0;
    const ms = avg > 0 ? (1000 / avg) : 0;
    if (!state.minimized) {
      el.textContent = `FPS: ${avg.toFixed(1)} (ms: ${ms.toFixed(1)})`;
    }

    // Лёгкая подсветка по усреднённому ms, если фон дефолтный
    const bg = badge.style.background;
    const looksDefault = !bg || bg === '#222' || bg === 'rgb(34, 34, 34)';
    if (looksDefault) {
      if (ms <= 18) {
        badge.style.background = '#1f2a1f';
        badge.style.color = '#aef1ae';
      } else if (ms <= 25) {
        badge.style.background = '#2a281f';
        badge.style.color = '#ffe9a6';
      } else {
        badge.style.background = '#2a1f1f';
        badge.style.color = '#ffb3b3';
      }
    }
  }

  // ===== Спарклайн =====
  function drawSparkline(msBuf) {
    if (!SPARKLINE_ENABLED || state.minimized) return;
    const canvas = document.getElementById('fps-spark');
    if (!canvas) return;
    const ctx = canvas.getContext('2d', { alpha: true });
    ctx.imageSmoothingEnabled = false;

    const W = canvas.width, H = canvas.height;
    const px = SPARK.padX, py = SPARK.padY;
    const plotW = W - px * 2, plotH = H - py * 2;

    ctx.clearRect(0, 0, W, H);
    if (!msBuf || msBuf.length < 2) return;

    let min = Math.min(...msBuf);
    let max = Math.max(...msBuf);
    min = Math.max(min, SPARK.clampMs.min);
    max = Math.min(Math.max(max, min + 1), SPARK.clampMs.max);

    ctx.globalAlpha = 0.15;
    ctx.fillStyle = '#ffffff';
    const ms60 = 1000 / 60, ms30 = 1000 / 30;
    const y60 = py + (plotH * (max - ms60) / (max - min));
    const y30 = py + (plotH * (max - ms30) / (max - min));
    ctx.fillRect(px, Math.max(py, Math.min(H - py - 1, y60)), plotW, 1);
    ctx.fillRect(px, Math.max(py, Math.min(H - py - 1, y30)), plotW, 1);
    ctx.globalAlpha = 1;

    const lastMs = msBuf[msBuf.length - 1];
    const stroke = lastMs <= 18 ? '#aef1ae' : lastMs <= 25 ? '#ffe9a6' : '#ffb3b3';

    ctx.lineWidth = 1;
    ctx.strokeStyle = stroke;
    ctx.beginPath();
    for (let i = 0; i < msBuf.length; i++) {
      const ms = Math.min(Math.max(msBuf[i], min), max);
      const x = px + (i / (SPARK.length - 1)) * plotW;
      const y = py + (plotH * (max - ms) / (max - min));
      if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    }
    ctx.stroke();

    const grad = ctx.createLinearGradient(0, py, 0, H - py);
    grad.addColorStop(0, 'rgba(255,255,255,0.18)');
    grad.addColorStop(1, 'rgba(255,255,255,0.02)');
    ctx.fillStyle = grad;
    ctx.lineTo(px + plotW, H - py);
    ctx.lineTo(px, H - py);
    ctx.closePath();
    ctx.fill();
  }

  // ===== Перетаскивание =====
  function makeDraggable(el) {
    let dragging = false;
    let startX = 0, startY = 0;
    let startLeft = 0, startTop = 0;

    // Если позиция сохранена — используем left/top, а не bottom/right
    if (state.x !== null && state.y !== null) {
      el.style.left = `${state.x}px`;
      el.style.top = `${state.y}px`;
      el.style.right = 'auto';
      el.style.bottom = 'auto';
    }

    const onDown = (clientX, clientY) => {
      dragging = true;
      el.style.cursor = 'grabbing';

      const rect = el.getBoundingClientRect();
      startX = clientX;
      startY = clientY;
      startLeft = rect.left + window.scrollX;
      startTop = rect.top + window.scrollY;

      // Переключаемся на абсолютные координаты
      el.style.left = `${startLeft}px`;
      el.style.top = `${startTop}px`;
      el.style.right = 'auto';
      el.style.bottom = 'auto';

      document.addEventListener('mousemove', onMouseMove);
      document.addEventListener('mouseup', onMouseUp);
      document.addEventListener('touchmove', onTouchMove, { passive: false });
      document.addEventListener('touchend', onTouchEnd);
    };

    const onMouseDown = (e) => {
      // ЛКМ
      if (e.button !== 0) return;
      onDown(e.clientX, e.clientY);
      e.preventDefault();
    };

    const onTouchStart = (e) => {
      const t = e.touches[0];
      if (!t) return;
      onDown(t.clientX, t.clientY);
    };

    const onMove = (clientX, clientY) => {
      if (!dragging) return;
      const dx = clientX - startX;
      const dy = clientY - startY;

      const newLeft = startLeft + dx;
      const newTop = startTop + dy;

      applyPosition(el, newLeft, newTop);
    };

    const onMouseMove = (e) => {
      onMove(e.clientX, e.clientY);
      e.preventDefault();
    };

    const onTouchMove = (e) => {
      const t = e.touches[0];
      if (!t) return;
      onMove(t.clientX, t.clientY);
      e.preventDefault();
    };

    const finishDrag = () => {
      if (!dragging) return;
      dragging = false;
      el.style.cursor = 'grab';
      clampToViewport(el);
      saveState();
      document.removeEventListener('mousemove', onMouseMove);
      document.removeEventListener('mouseup', onMouseUp);
      document.removeEventListener('touchmove', onTouchMove);
      document.removeEventListener('touchend', onTouchEnd);
    };

    const onMouseUp = () => finishDrag();
    const onTouchEnd = () => finishDrag();

    el.addEventListener('mousedown', onMouseDown);
    el.addEventListener('touchstart', onTouchStart, { passive: true });
  }

  function applyPosition(el, x, y) {
    // Безопасные границы (с учётом размеров элемента)
    const rect = el.getBoundingClientRect();
    const minLeft = 0;
    const minTop = 0;
    const maxLeft = window.innerWidth - rect.width;
    const maxTop = window.innerHeight - rect.height;

    const clampedX = Math.max(minLeft, Math.min(x, maxLeft)) | 0;
    const clampedY = Math.max(minTop, Math.min(y, maxTop)) | 0;

    el.style.left = `${clampedX}px`;
    el.style.top = `${clampedY}px`;
    el.style.right = 'auto';
    el.style.bottom = 'auto';

    state.x = clampedX;
    state.y = clampedY;
  }

  function clampToViewport(el) {
    if (state.x === null || state.y === null) return;
    applyPosition(el, state.x, state.y);
    saveState();
  }

  // ===== Старт =====
  window.addEventListener('load', () => {
    setTimeout(() => {
      initBadge();
      if (FPS_ENABLED) FPSMeter.start();
    }, 200);
  });
})();