Add local weather forecast with live-weather backgrounds

Visitors can save a postal code (international, via Zippopotam) to get the
local forecast from Open-Meteo — a header chip + a 5-day home-page card —
and the canvas background switches from the seasonal effect to live
conditions. Entirely client-side: no keys, no server logic. Geocode cached
permanently, forecast cached 2h. Celsius by default, Fahrenheit opt-in.

New canvas effects: clear (sun by day, shaded moon + stars at night),
clouds (blurred drifting sprites), snow, fog, and storm (rain + lightning).
Seasonal effects remain the no-location fallback.
This commit is contained in:
prosolis
2026-06-21 00:24:39 -07:00
parent 95f6e71933
commit 1a43616248
6 changed files with 659 additions and 17 deletions

View File

@@ -186,7 +186,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
} }
var ( var (
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves"} demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
demoIntensities = []string{"light", "medium", "heavy"} demoIntensities = []string{"light", "medium", "heavy"}
demoPhases = []string{"day", "dawn", "dusk", "night"} demoPhases = []string{"day", "dawn", "dusk", "night"}
) )

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,379 @@
// Live local weather. When the visitor saves a postal code we geocode it once
// (Zippopotam — free, no key), then poll Open-Meteo (free, no key) for the
// current conditions + a 5-day forecast. The result drives two bits of UI — a
// header chip and a home-page card — and overrides the seasonal canvas via
// PeteWeather.set(). Everything lives in the browser: no server calls, no keys.
//
// Politeness: the geocode never changes for a postal code, so it's cached
// forever. Forecasts are cached for 2 hours so a reload (or hopping between
// pages) doesn't re-hit Open-Meteo.
(function () {
var LOC_KEY = "pete.weather.loc.v1"; // {country, postal, lat, lon, name, unit}
var CACHE_KEY = "pete.weather.cache.v1"; // {key, fetchedAt, current, daily}
var TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
// ---- storage helpers -----------------------------------------------------
function readJSON(key) {
try { var r = localStorage.getItem(key); return r ? JSON.parse(r) : null; }
catch (e) { return null; }
}
function writeJSON(key, val) {
try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {}
}
function getLoc() { return readJSON(LOC_KEY); }
function setLoc(loc) {
if (loc) writeJSON(LOC_KEY, loc);
else { try { localStorage.removeItem(LOC_KEY); } catch (e) {} }
}
// ---- WMO weather code → canvas variant + label + emoji -------------------
// https://open-meteo.com/en/docs — `weather_code` is the WMO code.
function describe(code, isDay) {
var c = Number(code);
function r(variant, intensity, label, dayEmoji, nightEmoji) {
return { variant: variant, intensity: intensity, label: label,
emoji: (isDay === 0 && nightEmoji) ? nightEmoji : dayEmoji };
}
if (c === 0) return r("clear", "heavy", "Clear sky", "☀️", "🌙");
if (c === 1) return r("clouds", "light", "Mainly clear", "🌤️", "🌙");
if (c === 2) return r("clouds", "medium", "Partly cloudy", "⛅", "☁️");
if (c === 3) return r("clouds", "heavy", "Overcast", "☁️", "☁️");
if (c === 45 || c === 48) return r("fog", "heavy", "Fog", "🌫️", "🌫️");
if (c === 51) return r("rain", "light", "Light drizzle", "🌦️", "🌧️");
if (c === 53) return r("rain", "medium", "Drizzle", "🌦️", "🌧️");
if (c === 55) return r("rain", "heavy", "Heavy drizzle", "🌧️", "🌧️");
if (c === 56 || c === 57) return r("rain", "medium", "Freezing drizzle", "🌧️", "🌧️");
if (c === 61) return r("rain", "light", "Light rain", "🌦️", "🌧️");
if (c === 63) return r("rain", "medium", "Rain", "🌧️", "🌧️");
if (c === 65) return r("rain", "heavy", "Heavy rain", "🌧️", "🌧️");
if (c === 66 || c === 67) return r("rain", "medium", "Freezing rain", "🌧️", "🌧️");
if (c === 71) return r("snow", "light", "Light snow", "🌨️", "🌨️");
if (c === 73) return r("snow", "medium", "Snow", "🌨️", "🌨️");
if (c === 75) return r("snow", "heavy", "Heavy snow", "❄️", "❄️");
if (c === 77) return r("snow", "light", "Snow grains", "🌨️", "🌨️");
if (c === 80) return r("rain", "medium", "Light showers", "🌦️", "🌧️");
if (c === 81) return r("rain", "medium", "Showers", "🌧️", "🌧️");
if (c === 82) return r("rain", "heavy", "Heavy showers", "⛈️", "⛈️");
if (c === 85) return r("snow", "medium", "Snow showers", "🌨️", "🌨️");
if (c === 86) return r("snow", "heavy", "Heavy snow showers", "❄️", "❄️");
if (c === 95) return r("storm", "heavy", "Thunderstorm", "⛈️", "⛈️");
if (c === 96 || c === 99) return r("storm", "heavy", "Thunderstorm + hail", "⛈️", "⛈️");
return r("clouds", "medium", "—", "☁️", "☁️");
}
// ---- API calls -----------------------------------------------------------
function geocode(country, postal) {
var url = "https://api.zippopotam.us/" + encodeURIComponent(country.toLowerCase()) +
"/" + encodeURIComponent(postal);
return fetch(url).then(function (res) {
if (!res.ok) throw new Error("Unknown postal code");
return res.json();
}).then(function (data) {
var place = data.places && data.places[0];
if (!place) throw new Error("Unknown postal code");
return {
lat: parseFloat(place.latitude),
lon: parseFloat(place.longitude),
name: place["place name"] + (place["state abbreviation"] ? ", " + place["state abbreviation"] : "")
};
});
}
function fetchForecast(loc) {
var unit = loc.unit === "fahrenheit" ? "fahrenheit" : "celsius";
var url = "https://api.open-meteo.com/v1/forecast" +
"?latitude=" + loc.lat + "&longitude=" + loc.lon +
"&current=temperature_2m,weather_code,is_day" +
"&daily=weather_code,temperature_2m_max,temperature_2m_min" +
"&temperature_unit=" + unit +
"&timezone=auto&forecast_days=5";
return fetch(url).then(function (res) {
if (!res.ok) throw new Error("Forecast unavailable");
return res.json();
});
}
// Cache key ties a forecast to one location; changing location invalidates it.
function cacheKey(loc) { return loc.country + ":" + loc.postal + ":" + (loc.unit || "celsius"); }
function loadForecast(loc, force) {
var key = cacheKey(loc);
var cached = readJSON(CACHE_KEY);
var fresh = cached && cached.key === key && cached.fetchedAt &&
(nowMs() - cached.fetchedAt) < TTL_MS;
if (fresh && !force) return Promise.resolve(cached);
return fetchForecast(loc).then(function (data) {
var entry = {
key: key,
fetchedAt: nowMs(),
current: data.current,
daily: data.daily,
unitSymbol: (data.current_units && data.current_units.temperature_2m) || "°"
};
writeJSON(CACHE_KEY, entry);
return entry;
}).catch(function (err) {
// Stale-but-present beats nothing if the network blips.
if (cached && cached.key === key) return cached;
throw err;
});
}
function nowMs() { return new Date().getTime(); }
// ---- rendering -----------------------------------------------------------
var chipEl = document.querySelector("[data-weather-chip]");
var chipBtn = document.querySelector("[data-weather-loc]");
var cardMount = document.querySelector("[data-weather-card]");
function setChip(html, title) {
if (chipEl) chipEl.innerHTML = html;
if (chipBtn && title) chipBtn.title = title;
}
function temp(v, sym) { return Math.round(v) + (sym || "°"); }
function weekday(iso) {
var d = new Date(iso + "T00:00:00");
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()];
}
// The /weather page is a variant preview — never let the live forecast
// hijack the canvas the user is inspecting.
var isDemoPage = location.pathname === "/weather";
function applyBackground(desc) {
if (isDemoPage) return;
if (window.PeteWeather && typeof window.PeteWeather.set === "function") {
window.PeteWeather.set(desc.variant, desc.intensity);
}
}
function renderForecast(loc, entry) {
var cur = entry.current;
var sym = entry.unitSymbol;
var desc = describe(cur.weather_code, cur.is_day);
applyBackground(desc);
setChip(desc.emoji + " <span class=\"tabular-nums\">" + temp(cur.temperature_2m, sym) + "</span>",
loc.name + " · " + desc.label);
if (!cardMount) return;
var daily = entry.daily;
var days = "";
for (var i = 0; i < daily.time.length; i++) {
var dd = describe(daily.weather_code[i], 1);
days +=
'<div class="flex flex-col items-center gap-1 rounded-2xl px-2 py-2 min-w-0">' +
'<span class="text-xs font-semibold text-[color:var(--ink)]/60">' + (i === 0 ? "Today" : weekday(daily.time[i])) + '</span>' +
'<span class="text-2xl leading-none" aria-hidden="true">' + dd.emoji + '</span>' +
'<span class="text-sm font-bold tabular-nums">' + Math.round(daily.temperature_2m_max[i]) + '°</span>' +
'<span class="text-xs tabular-nums text-[color:var(--ink)]/50">' + Math.round(daily.temperature_2m_min[i]) + '°</span>' +
'</div>';
}
cardMount.innerHTML =
'<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">' +
'<div class="flex flex-wrap items-center justify-between gap-4 p-5">' +
'<div class="flex items-center gap-4 min-w-0">' +
'<span class="text-5xl leading-none" aria-hidden="true">' + desc.emoji + '</span>' +
'<div class="min-w-0">' +
'<div class="flex items-baseline gap-2">' +
'<span class="font-display text-4xl font-bold tabular-nums">' + temp(cur.temperature_2m, sym) + '</span>' +
'<span class="text-sm font-semibold text-[color:var(--ink)]/70 truncate">' + desc.label + '</span>' +
'</div>' +
'<div class="text-sm text-[color:var(--ink)]/60 truncate">📍 ' + escapeHtml(loc.name) + '</div>' +
'</div>' +
'</div>' +
'<button type="button" data-weather-loc class="shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5 border-2 border-[color:var(--ink)]/10 transition">Change location</button>' +
'</div>' +
'<div class="grid grid-cols-5 gap-1 border-t border-[color:var(--ink)]/10 bg-[color:var(--ink)]/[0.03] px-2 py-3">' + days + '</div>' +
'</div>';
// The injected "Change location" button needs the same handler.
var changeBtn = cardMount.querySelector("[data-weather-loc]");
if (changeBtn) changeBtn.addEventListener("click", openPopover);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function showError(msg) {
setChip("📍 Weather", "Set your location");
if (cardMount && getLoc()) {
cardMount.innerHTML =
'<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-5 text-center text-sm text-[color:var(--ink)]/60">' +
escapeHtml(msg) + ' · <button type="button" data-weather-loc class="font-semibold underline">try again</button>' +
'</div>';
var b = cardMount.querySelector("[data-weather-loc]");
if (b) b.addEventListener("click", openPopover);
}
}
// ---- location popover ----------------------------------------------------
// Built in JS so layout.html only needs the chip button. Class strings are
// full literals so Tailwind's content scan keeps them.
var popover = null;
var selectedUnit = "celsius"; // default for everyone; °F is opt-in
function setUnit(u) {
selectedUnit = u === "fahrenheit" ? "fahrenheit" : "celsius";
if (!popover) return;
popover.querySelectorAll("[data-loc-unit]").forEach(function (b) {
var on = b.getAttribute("data-loc-unit") === selectedUnit;
b.classList.toggle("bg-[color:var(--accent)]", on);
b.classList.toggle("text-white", on);
b.classList.toggle("text-[color:var(--ink)]/60", !on);
});
}
function defaultCountry() {
var lang = (navigator.language || "en-US");
var m = lang.match(/[-_]([A-Za-z]{2})$/);
return m ? m[1].toUpperCase() : "US";
}
function buildPopover() {
var dlg = document.createElement("div");
dlg.id = "pete-weather-loc";
dlg.className = "hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[12vh] px-4";
dlg.setAttribute("role", "dialog");
dlg.setAttribute("aria-modal", "true");
dlg.innerHTML =
'<div class="w-full max-w-sm rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">' +
'<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">' +
'<h2 class="font-display text-lg font-bold">Weather location</h2>' +
'<button type="button" data-loc-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>' +
'</div>' +
'<div class="p-5 space-y-3">' +
'<p class="text-xs text-[color:var(--ink)]/60">Enter your postal code to see the local forecast. The background follows the weather. Saved in this browser only.</p>' +
'<div class="flex gap-2">' +
'<select data-loc-country class="rounded-2xl border-2 border-[color:var(--ink)]/10 bg-[color:var(--bg)] px-3 py-2 text-sm font-semibold outline-none focus:border-[color:var(--accent)]"></select>' +
'<input data-loc-postal type="text" inputmode="text" autocomplete="postal-code" placeholder="Postal code" class="flex-1 min-w-0 rounded-2xl border-2 border-[color:var(--ink)]/10 bg-[color:var(--bg)] px-3 py-2 text-sm outline-none focus:border-[color:var(--accent)]">' +
'</div>' +
'<div class="flex items-center justify-between gap-2">' +
'<span class="text-xs font-semibold text-[color:var(--ink)]/60">Units</span>' +
'<div class="inline-flex rounded-full border-2 border-[color:var(--ink)]/10 p-0.5 text-xs font-bold">' +
'<button type="button" data-loc-unit="celsius" class="rounded-full px-3 py-1 transition">°C</button>' +
'<button type="button" data-loc-unit="fahrenheit" class="rounded-full px-3 py-1 transition">°F</button>' +
'</div>' +
'</div>' +
'<p data-loc-error class="hidden text-xs font-semibold text-red-500"></p>' +
'<div class="flex items-center justify-between gap-2 pt-1">' +
'<button type="button" data-loc-clear class="rounded-full px-3 py-2 text-xs font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]">Clear</button>' +
'<button type="button" data-loc-save class="rounded-full bg-[color:var(--accent)] px-5 py-2 text-sm font-bold text-white shadow-pete hover:brightness-105 transition disabled:opacity-50">Save</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(dlg);
// A small, friendly country list (Zippopotam supports ~60). Common ones up
// top; the user can type any postal code that matches their selected country.
var countries = [
["US", "🇺🇸 United States"], ["PT", "🇵🇹 Portugal"], ["GB", "🇬🇧 United Kingdom"],
["ES", "🇪🇸 Spain"], ["FR", "🇫🇷 France"], ["DE", "🇩🇪 Germany"], ["IT", "🇮🇹 Italy"],
["NL", "🇳🇱 Netherlands"], ["BE", "🇧🇪 Belgium"], ["CH", "🇨🇭 Switzerland"],
["AT", "🇦🇹 Austria"], ["IE", "🇮🇪 Ireland"], ["DK", "🇩🇰 Denmark"],
["SE", "🇸🇪 Sweden"], ["PL", "🇵🇱 Poland"], ["CZ", "🇨🇿 Czechia"],
["CA", "🇨🇦 Canada"], ["AU", "🇦🇺 Australia"], ["NZ", "🇳🇿 New Zealand"],
["BR", "🇧🇷 Brazil"], ["MX", "🇲🇽 Mexico"], ["JP", "🇯🇵 Japan"], ["IN", "🇮🇳 India"]
];
var sel = dlg.querySelector("[data-loc-country]");
countries.forEach(function (c) {
var o = document.createElement("option");
o.value = c[0]; o.textContent = c[1];
sel.appendChild(o);
});
dlg.addEventListener("click", function (e) { if (e.target === dlg) closePopover(); });
dlg.querySelector("[data-loc-close]").addEventListener("click", closePopover);
dlg.querySelector("[data-loc-clear]").addEventListener("click", function () {
setLoc(null);
try { localStorage.removeItem(CACHE_KEY); } catch (e) {}
closePopover();
// Hand the background back to the server's seasonal default.
if (window.PeteWeather) window.PeteWeather.set(seasonalDefault.variant, seasonalDefault.intensity);
setChip("📍 Weather", "Set your location");
if (cardMount) cardMount.innerHTML = "";
});
dlg.querySelectorAll("[data-loc-unit]").forEach(function (b) {
b.addEventListener("click", function () { setUnit(b.getAttribute("data-loc-unit")); });
});
dlg.querySelector("[data-loc-save]").addEventListener("click", onSave);
dlg.querySelector("[data-loc-postal]").addEventListener("keydown", function (e) {
if (e.key === "Enter") onSave();
});
return dlg;
}
function showLocError(msg) {
var el = popover.querySelector("[data-loc-error]");
if (!el) return;
if (msg) { el.textContent = msg; el.classList.remove("hidden"); }
else { el.classList.add("hidden"); }
}
function onSave() {
var country = popover.querySelector("[data-loc-country]").value;
var postal = popover.querySelector("[data-loc-postal]").value.trim();
var saveBtn = popover.querySelector("[data-loc-save]");
if (!postal) { showLocError("Enter a postal code."); return; }
showLocError("");
saveBtn.disabled = true; saveBtn.textContent = "…";
geocode(country, postal).then(function (geo) {
var loc = {
country: country, postal: postal,
lat: geo.lat, lon: geo.lon, name: geo.name,
unit: selectedUnit
};
setLoc(loc);
try { localStorage.removeItem(CACHE_KEY); } catch (e) {}
closePopover();
run(true);
}).catch(function (err) {
showLocError((err && err.message) || "Couldn't find that postal code.");
}).then(function () {
saveBtn.disabled = false; saveBtn.textContent = "Save";
});
}
function openPopover() {
if (!popover) popover = buildPopover();
var loc = getLoc();
popover.querySelector("[data-loc-country]").value = loc ? loc.country : defaultCountry();
popover.querySelector("[data-loc-postal]").value = loc ? loc.postal : "";
setUnit(loc && loc.unit ? loc.unit : "celsius");
showLocError("");
popover.classList.remove("hidden");
var inp = popover.querySelector("[data-loc-postal]");
if (inp) setTimeout(function () { inp.focus(); }, 30);
}
function closePopover() {
if (popover) popover.classList.add("hidden");
}
// ---- boot ----------------------------------------------------------------
// Remember the server's seasonal pick so "Clear" can restore it.
var seasonalDefault = {
variant: document.documentElement.dataset.weather || "",
intensity: document.documentElement.dataset.intensity || "heavy"
};
function run(force) {
var loc = getLoc();
if (!loc) { setChip("📍 Weather", "Set your location"); return; }
setChip("📍 " + escapeHtml(loc.name.split(",")[0]) + "…", "Loading forecast…");
loadForecast(loc, force)
.then(function (entry) { renderForecast(loc, entry); })
.catch(function () { showError("Forecast unavailable"); });
}
document.addEventListener("DOMContentLoaded", function () {
if (chipBtn) chipBtn.addEventListener("click", openPopover);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closePopover();
});
run(false);
});
})();

View File

@@ -1,13 +1,19 @@
// Canvas-based seasonal weather. The server picks variant + intensity from // Canvas-based weather rendered behind the page. Two drivers feed it:
// the Lisbon-local date; this script reads those off <html data-*> and //
// renders the appropriate particles. Each variant has a hand-drawn shape // 1. The server picks a *seasonal* variant + intensity from the Lisbon-local
// so silhouettes are recognizable instead of a generic blob. // date and writes them to <html data-weather / data-intensity>. This is the
// default when the visitor hasn't set a location.
// 2. weather-forecast.js, when a postal code is saved, fetches the real
// forecast and calls PeteWeather.set(variant, intensity) to override the
// background with live conditions.
//
// Each variant has a hand-drawn shape so silhouettes are recognizable instead
// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves.
// Live-weather variants add: clear, clouds, snow, fog, storm.
(function () { (function () {
var root = document.documentElement; var root = document.documentElement;
var canvas = document.getElementById("pete-weather"); var canvas = document.getElementById("pete-weather");
if (!canvas) return; if (!canvas) return;
var variant = root.dataset.weather;
if (!variant) return;
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var STORAGE_KEY = "pete-weather-off"; var STORAGE_KEY = "pete-weather-off";
@@ -47,22 +53,53 @@
resize(); resize();
window.addEventListener("resize", resize); window.addEventListener("resize", resize);
var counts = { rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38 }; var counts = {
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 }; rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
var N = Math.round((counts[variant] || 0) * (mults[root.dataset.intensity] || 1)); snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
};
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
function rand(a, b) { return a + Math.random() * (b - a); } function rand(a, b) { return a + Math.random() * (b - a); }
var variant = null; // current rendered variant
var intensity = "heavy"; // light | medium | heavy
var particles = [];
var flash = 0; // lightning flash decay (storm only)
var nextBolt = 2; // seconds until next lightning bolt (storm only)
function spawn(initial) { function spawn(initial) {
var p = {}; var p = {};
p.x = rand(0, W); p.x = rand(0, W);
p.y = initial ? rand(0, H) : rand(-40, -10); p.y = initial ? rand(0, H) : rand(-40, -10);
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
if (variant === "rain") { if (variant === "rain" || variant === "storm") {
p.vy = rand(700, 1100) * p.z; p.vy = rand(700, 1100) * p.z;
p.vx = -rand(60, 120); p.vx = -rand(60, 120);
p.len = rand(10, 18) * p.z; p.len = rand(10, 18) * p.z;
p.alpha = rand(0.25, 0.55); p.alpha = rand(0.25, 0.55);
} else if (variant === "snow") {
p.vy = rand(35, 75) * p.z;
p.swayAmp = rand(12, 34);
p.swayFreq = rand(0.3, 0.8);
p.swayPhase = rand(0, Math.PI * 2);
p.size = rand(2, 4.6) * p.z;
p.alpha = rand(0.65, 0.95);
} else if (variant === "clouds") {
// Soft puffs drifting across the upper sky.
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
p.x = initial ? rand(0, W) : -rand(120, 320);
p.vx = rand(8, 22);
p.size = rand(80, 170) * p.z;
p.alpha = rand(0.32, 0.55);
p.sprite = Math.floor(rand(0, 3));
} else if (variant === "fog") {
// Wide translucent bands creeping sideways.
p.y = rand(H * 0.1, H);
p.x = initial ? rand(0, W) : -rand(200, 500);
p.vx = rand(6, 16);
p.w = rand(260, 520);
p.h = rand(70, 150);
p.alpha = rand(0.05, 0.14);
} else if (variant === "petals" || variant === "jacaranda") { } else if (variant === "petals" || variant === "jacaranda") {
p.vy = rand(60, 120); p.vy = rand(60, 120);
p.swayAmp = rand(20, 50); p.swayAmp = rand(20, 50);
@@ -103,9 +140,6 @@
return p; return p;
} }
var particles = [];
for (var i = 0; i < N; i++) particles.push(spawn(true));
var night = root.dataset.phase === "night"; var night = root.dataset.phase === "night";
function refreshNight() { night = root.dataset.phase === "night"; } function refreshNight() { night = root.dataset.phase === "night"; }
@@ -126,6 +160,165 @@
ctx.stroke(); ctx.stroke();
} }
function drawSnow(p) {
// Soft round flake with a faint glow so it reads on light and dark palettes.
var col = night ? "235,242,255" : "255,255,255";
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
if (!night) {
// A thin cool outline keeps white flakes visible against cream backgrounds.
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
// offscreen canvases, then just drifted — cheap to animate.
var cloudSprites = null;
var cloudSpritesNight = null;
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
var cloudShapes = [
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
];
function makeCloudSprite(col, shape) {
var c = document.createElement("canvas");
var w = 320, h = 224;
c.width = w; c.height = h;
var cx = c.getContext("2d");
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
cx.fillStyle = "rgba(" + col + ",1)";
for (var i = 0; i < shape.length; i++) {
cx.beginPath();
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
cx.fill();
}
return c;
}
function ensureCloudSprites() {
if (cloudSprites && cloudSpritesNight === night) return;
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
// tone against the dark palette.
var col = night ? "206,216,236" : "150,164,190";
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
cloudSpritesNight = night;
}
function drawCloud(p) {
ensureCloudSprites();
var spr = cloudSprites[p.sprite % cloudSprites.length];
var w = p.size * 2.6;
var h = w * (spr.height / spr.width);
ctx.globalAlpha = p.alpha;
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
ctx.globalAlpha = 1;
}
function drawFog(p) {
var col = night ? "150,160,180" : "230,228,224";
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
g.addColorStop(0, "rgba(" + col + ",0)");
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
g.addColorStop(1, "rgba(" + col + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawMoon(cx, cy, mr) {
var TAU = Math.PI * 2;
ctx.save();
// Clip everything to the lunar disc so shading stays inside the sphere.
ctx.beginPath();
ctx.arc(cx, cy, mr, 0, TAU);
ctx.clip();
// Spherical body shading — light source upper-right, terminator lower-left.
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
body.addColorStop(0, "rgba(249,251,255,0.97)");
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
body.addColorStop(1, "rgba(140,151,180,0.85)");
ctx.fillStyle = body;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
limb.addColorStop(0, "rgba(18,24,46,0)");
limb.addColorStop(1, "rgba(18,24,46,0.4)");
ctx.fillStyle = limb;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
ctx.restore();
// Soft outer halo, drawn unclipped around the disc.
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
halo.addColorStop(0, "rgba(220,230,255,0.28)");
halo.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
ctx.fill();
}
function drawClearGlow(t) {
// No particles — render a single calm sun (day) or moon (night) glow that
// breathes very slowly, plus a few stars at night.
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
if (night) {
// Ambient sky glow around the moon.
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
g.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
drawMoon(cx, cy, r * 0.16);
} else {
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
gd.addColorStop(1, "rgba(255,214,120,0)");
ctx.fillStyle = gd;
ctx.fillRect(0, 0, W, H);
}
}
// Deterministic star field for clear nights (seeded so they don't jitter).
var stars = [];
function buildStars() {
stars = [];
var n = 60;
for (var i = 0; i < n; i++) {
// Cheap LCG-ish spread keyed by index — stable across frames.
var sx = ((i * 73 + 11) % 100) / 100;
var sy = ((i * 37 + 7) % 100) / 100;
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
}
}
function drawStars(t) {
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
ctx.beginPath();
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
ctx.fill();
}
}
function drawPetals(p) { function drawPetals(p) {
// Five-petal almond/cherry blossom viewed face-on, with a yellow center. // Five-petal almond/cherry blossom viewed face-on, with a yellow center.
var s = p.size; var s = p.size;
@@ -219,9 +412,30 @@
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
refreshNight(); refreshNight();
if (variant === "clear") {
drawClearGlow(t);
if (night) drawStars(t);
raf = requestAnimationFrame(step);
return;
}
// Storm: drive the lightning flash before drawing rain over it.
if (variant === "storm") {
nextBolt -= dt;
if (nextBolt <= 0) {
flash = 1;
nextBolt = rand(2.5, 7);
}
if (flash > 0) {
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
ctx.fillRect(0, 0, W, H);
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
}
}
for (var i = 0; i < particles.length; i++) { for (var i = 0; i < particles.length; i++) {
var p = particles[i]; var p = particles[i];
if (variant === "rain") { if (variant === "rain" || variant === "storm") {
p.x += p.vx * dt; p.x += p.vx * dt;
p.y += p.vy * dt; p.y += p.vy * dt;
if (p.y > H + 20 || p.x < -40) { if (p.y > H + 20 || p.x < -40) {
@@ -230,6 +444,21 @@
continue; continue;
} }
drawRain(p); drawRain(p);
} else if (variant === "snow") {
p.y += p.vy * dt;
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
var ox = p.x; p.x = ox + sxOff;
drawSnow(p);
p.x = ox;
} else if (variant === "clouds") {
p.x += p.vx * dt;
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
drawCloud(p);
} else if (variant === "fog") {
p.x += p.vx * dt;
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
drawFog(p);
} else if (variant === "motes") { } else if (variant === "motes") {
p.x += p.vx * dt; p.x += p.vx * dt;
p.y += p.vy * dt; p.y += p.vy * dt;
@@ -256,6 +485,7 @@
} }
raf = requestAnimationFrame(step); raf = requestAnimationFrame(step);
} }
function start() { function start() {
if (raf) return; if (raf) return;
last = performance.now(); last = performance.now();
@@ -267,9 +497,34 @@
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
} }
// Build the particle pool for a variant and (re)start rendering if enabled.
function configure(v, inten) {
variant = v || null;
intensity = inten || "heavy";
flash = 0; nextBolt = rand(1.5, 4);
particles = [];
if (variant === "clear") { buildStars(); }
if (!variant) { stop(); return; }
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
for (var i = 0; i < N; i++) particles.push(spawn(true));
if (enabled) start();
}
var enabled = !userDisabled() && !reducedMotion; var enabled = !userDisabled() && !reducedMotion;
// Public hook so weather-forecast.js can swap the seasonal default for live
// conditions. Passing a falsy variant clears the canvas.
window.PeteWeather = {
set: function (v, inten) {
root.dataset.weather = v || "";
if (inten) root.dataset.intensity = inten;
configure(v, inten || intensity);
},
isEnabled: function () { return enabled; }
};
syncBtn(enabled); syncBtn(enabled);
if (enabled) start(); configure(root.dataset.weather, root.dataset.intensity);
if (toggleBtn) { if (toggleBtn) {
toggleBtn.addEventListener("click", function () { toggleBtn.addEventListener("click", function () {

View File

@@ -11,6 +11,8 @@
</p> </p>
</section> </section>
<section data-weather-card class="mb-10 empty:hidden"></section>
{{if .JustPosted}} {{if .JustPosted}}
<section class="mb-10"> <section class="mb-10">
<div class="flex items-end justify-between gap-3 mb-4"> <div class="flex items-end justify-between gap-3 mb-4">

View File

@@ -60,6 +60,11 @@
<span class="sr-only">Toggle weather animation</span> <span class="sr-only">Toggle weather animation</span>
</button> </button>
{{end}} {{end}}
<button type="button" data-weather-loc
title="Set your location"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<span data-weather-chip class="tabular-nums">📍 Weather</span>
</button>
<div class="flex items-center gap-2 min-w-0 max-w-full"> <div class="flex items-center gap-2 min-w-0 max-w-full">
<button type="button" data-search-trigger title="Search (⌘K)" <button type="button" data-search-trigger title="Search (⌘K)"
class="flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition"> class="flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
@@ -145,6 +150,7 @@
<script>window.PETE_SOURCES = {{.AllSources}};</script> <script>window.PETE_SOURCES = {{.AllSources}};</script>
<script src="/static/js/weather.js" defer></script> <script src="/static/js/weather.js" defer></script>
<script src="/static/js/weather-forecast.js" defer></script>
<script src="/static/js/search.js" defer></script> <script src="/static/js/search.js" defer></script>
<script src="/static/js/settings.js" defer></script> <script src="/static/js/settings.js" defer></script>
</body> </body>