COD Forecast Overlay (Ultimate)

A professional, highly customizable overlay for weather.cod.edu. Calculates Valid Time from Init + Forecast Hour.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         COD Forecast Overlay (Ultimate)
// @namespace    http://tampermonkey.net/
// @version      3.1.1
// @description  A professional, highly customizable overlay for weather.cod.edu. Calculates Valid Time from Init + Forecast Hour.
// @match        https://weather.cod.edu/forecast/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // --- STATE & CONFIGURATION ---
  let runNode = null;
  let sliderNode = null;
  let observer = null;
  let isMinimized = false;
  let isLocked = false;
  let tempSettings = {};

  const defaultSettings = {
    position: { top: 100, left: 200 },
    dimensions: { width: 500, padding: 30, borderRadius: 16 },
    font: { size: 72, family: "Saira Condensed" },
    theme: "dark",
    display: {
      opacity: 0.95,
      showDate: true,
      showAccent: true,
      showDelta: true,
      showTimezones: ["ET", "Local"], // Checkbox controlled
      dateTimeSpacing: 4,
    },
    timeFormat: "12",
    locked: false,
  };

  const themes = {
    dark: { bg: "rgba(20, 20, 25, 1)", text: "#ffffff", subtext: "#9ca3af", accent: "rgba(96, 165, 250, 0.5)", border: "rgba(255,255,255,0.08)" },
    light: { bg: "rgba(255, 255, 255, 1)", text: "#1f2937", subtext: "#6b7280", accent: "rgba(59, 130, 246, 0.5)", border: "rgba(0,0,0,0.08)" },
    minimal: { bg: "rgba(0, 0, 0, 1)", text: "#ffffff", subtext: "#ffffff", accent: "rgba(255, 255, 255, 0.3)", border: "rgba(255,255,255,0.15)" },
    contrast: { bg: "rgba(0, 0, 0, 1)", text: "#00ff00", subtext: "#ffff00", accent: "rgba(0, 255, 0, 0.5)", border: "rgba(0,255,0,0.3)" },
  };

  const fonts = {
    "Saira Condensed": "Saira+Condensed:wght@600;700",
    "Roboto Condensed": "Roboto+Condensed:wght@700",
    Oswald: "Oswald:wght@700",
    Montserrat: "Montserrat:wght@700",
  };

  // --- UTILITY FUNCTIONS ---
  function loadSettings() {
    try {
      const saved = localStorage.getItem("cod_overlay_settings_v3");
      if (saved) {
        const parsed = JSON.parse(saved);
        return {
          ...defaultSettings,
          ...parsed,
          dimensions: { ...defaultSettings.dimensions, ...parsed.dimensions },
          font: { ...defaultSettings.font, ...parsed.font },
          display: { ...defaultSettings.display, ...parsed.display },
        };
      }
    } catch (e) { console.error("Settings load failed", e); }
    return defaultSettings;
  }

  function saveSettings(s) { localStorage.setItem("cod_overlay_settings_v3", JSON.stringify(s)); }

  let settings = loadSettings();
  isLocked = settings.locked;

  function loadFont(fontFamily) {
    const fontId = `font-${fontFamily.replace(/\s+/g, "-")}`;
    if (document.getElementById(fontId) || !fonts[fontFamily]) return;
    const fontLink = document.createElement("link");
    fontLink.id = fontId; fontLink.rel = "stylesheet";
    fontLink.href = `https://fonts.googleapis.com/css2?family=${fonts[fontFamily]}&display=swap`;
    document.head.appendChild(fontLink);
  }
  loadFont(settings.font.family);

  // --- CREATE UI ELEMENTS ---
  const overlay = document.createElement("div");
  const controlsBar = document.createElement("div");
  const dateText = document.createElement("div");
  const timeContainer = document.createElement("div");
  const accentLine = document.createElement("div");
  const deltaText = document.createElement("div");
  const resizeHandle = document.createElement("div");

  function setupElements() {
    overlay.style.position = "fixed";
    overlay.style.top = settings.position.top + "px";
    overlay.style.left = settings.position.left + "px";
    overlay.style.zIndex = "99999";
    overlay.style.backdropFilter = "blur(10px)";
    overlay.style.boxShadow = "0 8px 32px rgba(0,0,0,0.8), 0 0 1px rgba(255,255,255,0.1) inset";
    overlay.style.display = "flex";
    overlay.style.flexDirection = "column";
    overlay.style.justifyContent = "center";
    overlay.style.alignItems = "center";
    overlay.style.gap = "8px";
    overlay.style.pointerEvents = "auto";
    overlay.style.transition = "all 0.3s ease, transform 0.2s ease";
    document.body.appendChild(overlay);

    controlsBar.style.position = "absolute";
    controlsBar.style.top = "8px";
    controlsBar.style.right = "8px";
    controlsBar.style.display = "flex";
    controlsBar.style.gap = "6px";
    controlsBar.style.opacity = "0";
    controlsBar.style.transition = "opacity 0.3s ease";
    overlay.appendChild(controlsBar);

    dateText.style.fontWeight = "600";
    dateText.style.textTransform = "uppercase";
    dateText.style.letterSpacing = "2px";
    overlay.appendChild(dateText);

    timeContainer.style.display = "flex";
    timeContainer.style.flexDirection = "column";
    timeContainer.style.alignItems = "center";
    overlay.appendChild(timeContainer);

    accentLine.style.width = "80%";
    accentLine.style.height = "2px";
    overlay.appendChild(accentLine);

    deltaText.style.fontWeight = "600";
    overlay.appendChild(deltaText);

    resizeHandle.style.position = "absolute";
    resizeHandle.style.bottom = "0";
    resizeHandle.style.right = "0";
    resizeHandle.style.width = "20px";
    resizeHandle.style.height = "20px";
    resizeHandle.style.cursor = "se-resize";
    overlay.appendChild(resizeHandle);
  }
  setupElements();

  function applyStyles(s) {
    const theme = themes[s.theme];
    const bgColor = theme.bg.replace(/, 1\)$/, `, ${s.display.opacity})`);
    loadFont(s.font.family);

    overlay.style.width = s.dimensions.width + "px";
    overlay.style.padding = `${s.dimensions.padding}px ${s.dimensions.padding * 1.33}px`;
    overlay.style.borderRadius = s.dimensions.borderRadius + "px";
    overlay.style.backgroundColor = bgColor;
    overlay.style.color = theme.text;
    overlay.style.fontFamily = `'${s.font.family}', sans-serif`;
    overlay.style.fontSize = s.font.size + "px";
    overlay.style.border = "1px solid " + theme.border;
    overlay.style.cursor = s.locked ? "default" : "move";

    dateText.style.display = s.display.showDate ? "block" : "none";
    dateText.style.fontSize = s.font.size * 0.39 + "px";
    dateText.style.color = theme.subtext;
    dateText.style.marginBottom = s.display.dateTimeSpacing + "px";

    accentLine.style.display = s.display.showAccent ? "block" : "none";
    accentLine.style.marginTop = s.display.dateTimeSpacing * 3 + "px";
    accentLine.style.background = `linear-gradient(90deg, transparent, ${theme.accent}, transparent)`;

    deltaText.style.display = s.display.showDelta ? "block" : "none";
    deltaText.style.fontSize = s.font.size * 0.25 + "px";
    deltaText.style.color = theme.subtext;
    deltaText.style.marginTop = s.display.dateTimeSpacing * 2 + "px";

    updateText();
  }

  // --- SETTINGS PANEL ---
  const settingsPanel = document.createElement("div");
  settingsPanel.style.position = "fixed";
  settingsPanel.style.top = "50%";
  settingsPanel.style.left = "50%";
  settingsPanel.style.transform = "translate(-50%, -50%)";
  settingsPanel.style.zIndex = 100000;
  settingsPanel.style.display = "none";
  settingsPanel.style.width = "500px";
  settingsPanel.style.maxHeight = "90vh";
  settingsPanel.style.overflowY = "auto";
  settingsPanel.style.boxShadow = "0 20px 60px rgba(0,0,0,0.9)";
  settingsPanel.style.backdropFilter = "blur(15px)";
  document.body.appendChild(settingsPanel);

  function createSettingRow(label, control) {
    return `<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
                <label style="font-size: 16px; opacity: 0.8;">${label}</label>
                <div style="width: 240px;">${control}</div>
            </div>`;
  }

  function buildSettingsPanel() {
    const s = tempSettings;
    const theme = themes[s.theme];
    settingsPanel.innerHTML = `
      <div style="background-color: ${theme.bg}; border: 2px solid ${theme.border}; border-radius: 16px; padding: 30px; color: ${theme.text}; font-family: sans-serif;">
        <h2 style="margin: 0 0 25px 0; font-size: 28px;">Overlay Settings</h2>
        
        <h3 style="font-size: 14px; text-transform: uppercase; color: ${theme.subtext}; margin-bottom: 15px; border-bottom: 1px solid ${theme.border}; padding-bottom: 5px;">Appearance</h3>
        ${createSettingRow("Theme", `<select id="setTheme" style="width:100%; padding:8px; border-radius:8px; background:rgba(255,255,255,0.1); color:inherit; border:1px solid ${theme.border};">${Object.keys(themes).map(t=>`<option value="${t}" ${s.theme===t?'selected':''}>${t.toUpperCase()}</option>`).join('')}</select>`)}
        ${createSettingRow("Font", `<select id="setFont" style="width:100%; padding:8px; border-radius:8px; background:rgba(255,255,255,0.1); color:inherit; border:1px solid ${theme.border};">${Object.keys(fonts).map(f=>`<option value="${f}" ${s.font.family===f?'selected':''}>${f}</option>`).join('')}</select>`)}
        ${createSettingRow("Font Size", `<input type="range" id="setFontSize" min="20" max="150" value="${s.font.size}" style="width:100%">`)}
        ${createSettingRow("Opacity", `<input type="range" id="setOpacity" min="30" max="100" value="${s.display.opacity*100}" style="width:100%">`)}

        <h3 style="font-size: 14px; text-transform: uppercase; color: ${theme.subtext}; margin: 25px 0 15px; border-bottom: 1px solid ${theme.border}; padding-bottom: 5px;">Content</h3>
        ${createSettingRow("Time Format", `<select id="setFormat" style="width:100%; padding:8px; border-radius:8px; background:rgba(255,255,255,0.1); color:inherit; border:1px solid ${theme.border};"><option value="12" ${s.timeFormat==='12'?'selected':''}>12-Hour</option><option value="24" ${s.timeFormat==='24'?'selected':''}>24-Hour</option></select>`)}
        ${createSettingRow("Show Timezones", `<div id="setTimezones" style="display:flex; gap:10px; justify-content:flex-end;">${['ET', 'UTC', 'Local'].map(tz=>`<label><input type="checkbox" value="${tz}" ${s.display.showTimezones.includes(tz)?'checked':''}> ${tz}</label>`).join('')}</div>`)}
        ${createSettingRow("Elements", `<div id="setElements" style="display:flex; gap:10px; justify-content:flex-end;">
            <label><input type="checkbox" id="showDate" ${s.display.showDate?'checked':''}> Date</label>
            <label><input type="checkbox" id="showAccent" ${s.display.showAccent?'checked':''}> Accent</label>
            <label><input type="checkbox" id="showDelta" ${s.display.showDelta?'checked':''}> Delta</label>
        </div>`)}

        <div style="margin-top: 30px; display: flex; gap: 12px; border-top: 1px solid ${theme.border}; padding-top: 20px;">
          <button id="saveSet" style="flex: 2; padding: 12px; border-radius: 8px; background: ${theme.accent.replace("0.5", "1")}; color: white; border: none; cursor: pointer; font-weight: bold;">Save & Reload</button>
          <button id="cancelSet" style="flex: 1; padding: 12px; border-radius: 8px; background: rgba(255,255,255,0.1); color: inherit; border: 1px solid ${theme.border}; cursor: pointer;">Cancel</button>
        </div>
      </div>`;
    
    // Listeners
    document.getElementById('setTheme').onchange = (e) => { tempSettings.theme = e.target.value; applyStyles(tempSettings); buildSettingsPanel(); };
    document.getElementById('setFont').onchange = (e) => { tempSettings.font.family = e.target.value; applyStyles(tempSettings); buildSettingsPanel(); };
    document.getElementById('setFontSize').oninput = (e) => { tempSettings.font.size = parseInt(e.target.value); applyStyles(tempSettings); };
    document.getElementById('setOpacity').oninput = (e) => { tempSettings.display.opacity = e.target.value / 100; applyStyles(tempSettings); };
    document.getElementById('setFormat').onchange = (e) => { tempSettings.timeFormat = e.target.value; applyStyles(tempSettings); };
    
    document.getElementById('setTimezones').onchange = () => {
        const checked = Array.from(document.querySelectorAll('#setTimezones input:checked')).map(i => i.value);
        tempSettings.display.showTimezones = checked;
        applyStyles(tempSettings);
    };

    const toggleEl = (id, prop) => { document.getElementById(id).onclick = (e) => { tempSettings.display[prop] = e.target.checked; applyStyles(tempSettings); }};
    toggleEl('showDate', 'showDate');
    toggleEl('showAccent', 'showAccent');
    toggleEl('showDelta', 'showDelta');

    document.getElementById('saveSet').onclick = () => { settings = JSON.parse(JSON.stringify(tempSettings)); saveSettings(settings); location.reload(); };
    document.getElementById('cancelSet').onclick = () => { applyStyles(settings); toggleSettings(); };
  }

  function toggleSettings() {
    if (settingsPanel.style.display === "none") {
      tempSettings = JSON.parse(JSON.stringify(settings));
      buildSettingsPanel();
      settingsPanel.style.display = "block";
    } else {
      settingsPanel.style.display = "none";
    }
  }

  // --- DATA PARSING ---

  function getCalculatedValidTime() {
    try {
      const runEl = document.getElementById('currun');
      const sliderEl = document.getElementById('slider');
      if (!runEl || !sliderEl) return null;

      // Parse Init (Format: "01/12/26 18Z 100%")
      const runText = runEl.innerText.trim();
      const parts = runText.split(/\s+/);
      const dateParts = parts[0].split('/'); 
      const initHour = parseInt(parts[1]); 
      
      const year = 2000 + parseInt(dateParts[2]);
      const month = parseInt(dateParts[0]) - 1;
      const day = parseInt(dateParts[1]);
      const initDateUTC = new Date(Date.UTC(year, month, day, initHour));

      // Parse Slider Offset (Format: "048")
      const offsetHours = parseInt(sliderEl.innerText);
      
      const validDate = new Date(initDateUTC.getTime() + (offsetHours * 3600000));
      return { validDate, initDateUTC };
    } catch (e) { return null; }
  }

  function updateText() {
    const data = getCalculatedValidTime();
    if (!data || isMinimized) return;

    const { validDate, initDateUTC } = data;
    const theme = themes[settings.theme];

    // Date
    dateText.innerText = validDate.toLocaleString("en-US", { weekday: "long", month: "short", day: "numeric", timeZone: "UTC" }) + " (Valid)";

    // Timezones
    timeContainer.innerHTML = "";
    settings.display.showTimezones.forEach(tz => {
        let label, zone;
        if (tz === "ET") { label = "ET"; zone = "America/New_York"; }
        else if (tz === "UTC") { label = "UTC"; zone = "UTC"; }
        else { label = "Local"; zone = Intl.DateTimeFormat().resolvedOptions().timeZone; }

        const timeStr = validDate.toLocaleString("en-US", {
            hour: settings.timeFormat === "12" ? "numeric" : "2-digit",
            minute: "2-digit",
            hour12: settings.timeFormat === "12",
            timeZone: zone
        }).replace(" ", "");

        const div = document.createElement("div");
        div.style.display = "flex"; div.style.alignItems = "center"; div.style.gap = "12px"; div.style.cursor = "pointer";
        
        const tSpan = document.createElement("span");
        tSpan.innerText = timeStr; tSpan.style.color = theme.text;
        
        const lSpan = document.createElement("span");
        lSpan.innerText = label; lSpan.style.fontSize = settings.font.size * 0.39 + "px"; lSpan.style.color = theme.subtext; lSpan.style.fontWeight = "600";
        
        div.append(tSpan, lSpan);
        div.onclick = () => {
            navigator.clipboard.writeText(`${validDate.toDateString()} ${timeStr} ${label}`);
            div.style.opacity = "0.5"; setTimeout(() => div.style.opacity = "1", 200);
        };
        timeContainer.appendChild(div);
    });

    // Delta
    const deltaMin = Math.floor((Date.now() - initDateUTC.getTime()) / 60000);
    if (deltaMin >= 0) {
        const h = Math.floor(deltaMin / 60);
        const m = deltaMin % 60;
        deltaText.innerText = `${h}h ${m}m since model run`;
    }
  }

  // --- INTERACTIONS ---
  function createBtn(icon, title, click) {
    const b = document.createElement("button");
    b.innerHTML = icon; b.title = title;
    Object.assign(b.style, { background: "rgba(255,255,255,0.1)", border: "1px solid rgba(255,255,255,0.2)", borderRadius: "6px", width: "32px", height: "32px", cursor: "pointer", color: "inherit", display: "flex", alignItems: "center", justifyContent: "center" });
    b.onclick = (e) => { e.stopPropagation(); click(); };
    return b;
  }

  const lockBtn = createBtn(isLocked ? "🔒" : "🔓", "Lock", () => { isLocked = !isLocked; settings.locked = isLocked; saveSettings(settings); applyStyles(settings); lockBtn.innerHTML = isLocked ? "🔒" : "🔓"; });
  const minBtn = createBtn("−", "Minimize", () => {
      isMinimized = !isMinimized;
      if (isMinimized) {
          [timeContainer, dateText, accentLine, deltaText].forEach(el => el.style.display = "none");
          overlay.style.width = "auto"; overlay.style.minWidth = "180px"; overlay.style.padding = "15px";
      } else { applyStyles(settings); overlay.style.minWidth = ""; }
  });
  const setBtn = createBtn("⚙️", "Settings", toggleSettings);
  controlsBar.append(lockBtn, minBtn, setBtn);

  // Dragging & Hover
  let isDragging = false, dragOffset = [0,0];
  overlay.onmousedown = (e) => {
    if (isLocked || e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') return;
    isDragging = true;
    dragOffset = [overlay.offsetLeft - e.clientX, overlay.offsetTop - e.clientY];
    overlay.style.transition = "none";
  };
  window.onmousemove = (e) => {
    if (isDragging) {
      overlay.style.left = (e.clientX + dragOffset[0]) + "px";
      overlay.style.top = (e.clientY + dragOffset[1]) + "px";
    }
  };
  window.onmouseup = () => {
    if (isDragging) {
      isDragging = false;
      overlay.style.transition = "all 0.3s ease, transform 0.2s ease";
      settings.position = { top: parseInt(overlay.style.top), left: parseInt(overlay.style.left) };
      saveSettings(settings);
    }
  };

  overlay.onmouseenter = () => { controlsBar.style.opacity = "1"; if(!isLocked) overlay.style.transform = "scale(1.02)"; };
  overlay.onmouseleave = () => { controlsBar.style.opacity = "0"; overlay.style.transform = "scale(1)"; };

  // --- INITIALIZATION ---
  function initObserver() {
    const rNode = document.getElementById('currun');
    const sNode = document.getElementById('slider');
    if (rNode && sNode && (rNode !== runNode || sNode !== sliderNode)) {
      runNode = rNode; sliderNode = sNode;
      if (observer) observer.disconnect();
      observer = new MutationObserver(updateText);
      observer.observe(runNode, { characterData: true, childList: true, subtree: true });
      observer.observe(sliderNode, { characterData: true, childList: true, subtree: true });
      updateText();
    }
  }

  applyStyles(settings);
  setInterval(initObserver, 1000);
  setInterval(updateText, 30000); // Periodic refresh for delta time

  document.addEventListener("keydown", (e) => {
    if (e.ctrlKey && e.shiftKey && e.key === "S") { e.preventDefault(); toggleSettings(); }
  });
})();