COD Forecast Overlay (Ultimate)

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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