COD Forecast Overlay (Ultimate)

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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(); }
  });
})();