DOM + FPS Indicator (Draggable + Minimize)

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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);
  });
})();