JoelDueck.com Source

Artifact [3a6aca6743]
Login

Artifact 3a6aca674394dcf44a0c779f2c810be11ff8590296088175dbb7c3feaa53c85c:


//const nwsEndpoint = 'https://api.weather.gov/gridpoints/MPX/105,74/forecast/hourly?units=us';
const nwsStationId = 'KMIC';
const nwsApiNewsLink = '#'; //'https://github.com/weather-gov/api/discussions/688';
const CURRENT_SCHEMA = 2;

const CLOUD_COVERAGE = [
    ['CLR', 0, 'Clear'],
    ['SKC', 9, 'Clear (Observed)'],
    ['FEW', 18.75, 'Few clouds'],
    ['SCT', 37.5, 'Scattered clouds'],
    ['BKN', 68.5, 'Broken clouds'],
    ['OVC', 83.5, 'Overcast']
];

const COVERAGE_BY_CODE = Object.fromEntries(CLOUD_COVERAGE.map(([code, pct]) => [code, pct]));
const COVERAGE_CODES = CLOUD_COVERAGE.map(([code]) => code);
const COVERAGE_VALUES = CLOUD_COVERAGE.map(([, pct]) => pct);
const COVERAGE_DESCRIPTIONS = CLOUD_COVERAGE.map(([, , desc]) => desc);

function cloudCoverageToSliderValue(coverage) {
    // Convert cloud coverage percentage to slider position (0-4)
    // Use the discrete coverage codes for the slider (skip SKC duplicate)
    const sliderCodes = COVERAGE_CODES.filter(code => code !== 'SKC');
    const sliderValues = sliderCodes.map(code => COVERAGE_BY_CODE[code]);
    
    // Calculate midpoint boundaries between adjacent values
    const boundaries = [];
    for (let i = 0; i < sliderValues.length - 1; i++) {
        boundaries.push((sliderValues[i] + sliderValues[i + 1]) / 2);
    }
    
    for (let i = 0; i < boundaries.length; i++) {
        if (coverage <= boundaries[i]) {
            return i;
        }
    }
    return sliderCodes.length - 1; // OVC
}

function setCSSVars(degF, cloudCoverage) {
    // The temperature → hue formula
    var tempHue = 210 - (degF * 1.5);
    if(tempHue < 0) tempHue = 360 + tempHue;
    var accent = tempHue - 26;
    if(accent < 0) accent = accent + 360;

    var chroma = 85 - (cloudCoverage * 0.6);

    document.documentElement.style.setProperty('--base-temp', tempHue);
    document.documentElement.style.setProperty('--accent', accent);
    document.documentElement.style.setProperty('--chroma', chroma);
}

async function updateWeatherDiv() {
    const weatherDiv = document.getElementById('weather');
    const tempSlider = document.getElementById("tempRange");
    const cloudSlider = document.getElementById("cloudRange");

    weatherDiv.innerHTML = 'Getting weather…';
    try {
        const weather = await loadWeather();
        
        setCSSVars(weather.temp, weather.cloudCoverage);
        if(weather.status == "guess") {
            weatherDiv.innerHTML = `Robbinsdale, MN: <abbr title="National Weather Service API is on break right now">${weather.conditions}</abbr> <a href="/mnwx.html">📸</a>  `;
        } else {
            weatherDiv.innerHTML = `Robbinsdale, MN: ${properTemp(weather.temp)}, ${weather.conditions} <a href="/mnwx.html">📸</a>`;
        }
        tempSlider.value = weather.temp;
        cloudRange.value = cloudCoverageToSliderValue(weather.cloudCoverage);
    } catch (e) {
        weatherDiv.innerHTML = e.message;
    }
}

const updateButton = document.getElementById("reset-weather");
updateButton.onclick = updateWeatherDiv;

async function loadWeather() {
    let w = JSON.parse(localStorage.getItem('weather'));
    if(w === null || Date.now() - w.timestamp > 3600000 || !w.schema || w.schema < CURRENT_SCHEMA) {
        w = await fetchWeather();
        localStorage.setItem('weather', JSON.stringify(w));
    }
    return w;
}

const delay = (retryCount) => new Promise((res) => setTimeout(res, 10 ** retryCount));

function getMaxCloudCoverage(cloudLayers) {
    if (!cloudLayers || cloudLayers.length === 0) return 0;
    
    let maxCoverage = 0;
    for (const layer of cloudLayers) {
        const amount = layer.amount;
        if (amount && COVERAGE_BY_CODE[amount] !== undefined) {
            maxCoverage = Math.max(maxCoverage, COVERAGE_BY_CODE[amount]);
        }
    }
    return maxCoverage;
}

async function fetchWeather(retryCount = 0) {
    console.log('Updating weather data cache…');
    try {
        const response = await fetch(`https://api.weather.gov/stations/${nwsStationId}/observations/latest`);
        if(!response.ok) {
            const e = new Error(response.statusText);
            e.code = response.status;
            throw e; 
        }
        
        const weatherJson = await response.json();
        const weather = weatherJson.properties;
        const cloudCoverage = getMaxCloudCoverage(weather.cloudLayers);
        return { 
            "status": "success",
            "timestamp" : Date.now(),
            "temp" : Math.round(weather.temperature.value * 9/5 + 32),
            "conditions" : weather.textDescription || "Unknown conditions",
            "cloudCoverage": cloudCoverage,
            "schema": CURRENT_SCHEMA
        };
    } catch (e) {
        console.log(`Attempt ${retryCount} failed: ${e.message} / ${e.code}`);
        if (e.code >= 500 || e.message.match(/(?:Load failed|NetworkError)/gi) || retryCount > 3) {
            return guessConditions(); 
        } else {
            await delay(retryCount + 1);
            return fetchWeather(retryCount + 1);
        } 
    }
}

updateWeatherDiv();

document.getElementById("tempRange").oninput = function() {
    const weatherDiv = document.getElementById('weather');
    const cloudSlider = document.getElementById('cloudRange');
    setCSSVars(this.value, COVERAGE_VALUES[cloudRange.value]);
    weatherDiv.innerHTML = "Let’s pretend it’s " + properTemp(this.value) + ", " + COVERAGE_DESCRIPTIONS[cloudRange.value];
}

document.getElementById("cloudRange").oninput = function() {
    const weatherDiv = document.getElementById('weather');
    const tempSlider = document.getElementById("tempRange");
    
    setCSSVars(tempSlider.value, COVERAGE_VALUES[this.value]);
    weatherDiv.innerHTML = "Let's pretend it's " + properTemp(tempSlider.value) + ", " + COVERAGE_DESCRIPTIONS[this.value];
}

function properTemp(degF) {
    const temp = probablyInAmerica() ? degF + "°F" : 
                                       Math.round((degF - 32) * 5 / 9) + "°C";
    return temp.replaceAll("-", "&#x2212;"); // proper minus sign
}

function guessConditions() {
    // https://www.weather.gov/wrh/Climate?wfo=mpx
    const avgTemps = [16,20,33,47,59,69,74,71,63,49,34,15];
    const avgCloudCover = [68,65,60,55,50,40,31,31,45,55,65,68];
    const temperatureWords = [  
        ["January", "frosty", "frigid", "icy", "freezing"],
        ["February", "chill", "frostbitten", "snowy", "arctic"],
        ["March", "cold", "nippy", "sleety", "blustery"],
        ["April", "cool", "crisp", "rainy", "muddy"],
        ["May", "mild", "temperate", "gentle", "refreshing"],
        ["June", "warm", "balmy", "sunny", "humid"],
        ["July", "hot", "sultry", "sweltering", "muggy"],
        ["August", "scorching", "boiling", "roasting", "stifling"],
        ["September", "warm", "pleasant", "temperate", "golden"],
        ["October", "crisp", "cool", "refreshing", "autumnal"],
        ["November", "chilly", "frosty", "wintry", "dreary"],
        ["December", "frigid", "snowy", "icy", "frosty"]
    ];
    const d = new Date();
    console.log(avgTemps[d.getMonth()]);
    let tempWord = temperatureWords[d.getMonth()][Math.floor(Math.random() * 4) + 1];
    return {
        "status": "guess",
        "temp": avgTemps[d.getMonth()],
        "timestamp" : Date.now() - 3300000,
        "conditions": `Probably ${tempWord}<a href="${nwsApiNewsLink}"><sup>‡</sup></a>`,
        "cloudCoverage": avgCloudCover[d.getMonth()]
    };
}

const USTimeZoneCities = [
    "Wake", /* US minor outlying islands */
    "New_York", "Detroit", "Louisville", "Monticello", "Indianapolis", "Vincennes",
    "Winamac", "Marengo", "Petersburg", "Vevay", "Chicago", "Tell_City", "Knox",
    "Menominee", "Center", "New_Salem", "Beulah", "Denver", "Boise", "Phoenix",
    "Los_Angeles", "Anchorage", "Juneau", "Sitka", "Metlakatla", "Yakutat", "Nome",
    "Adak", "Honolulu"
];

function probablyInAmerica() {
    if (Intl) {
        const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
        const tzArr = userTimeZone.split("/");
        userCity = tzArr[tzArr.length - 1];
        return USTimeZoneCities.includes(userCity);
    } else {
        return false;
    }
}