DOM + FPS Indicator (Draggable + Minimize)

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

// ==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);
  });
})();