publiccloud.se: himmel som följer sol, måne och väder i Örebro

Frame-by-frame dag/natt-filmer, väderlager (Open-Meteo), vind, måne,
norrsken (NOAA Kp), fyra informationsrutor med karta och platsval.
Cloudflare Worker med Range-stöd för video.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Linus Olofsson
2026-09-24 20:55:18 +02:00
co-authored by Claude Opus 5.5
commit dde15001d4
11 changed files with 1063 additions and 0 deletions
+947
View File
@@ -0,0 +1,947 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>publiccloud.se</title>
<meta name="description" content="Himlen över Örebro – följer solen och det aktuella vädret.">
<meta name="theme-color" content="#0b0d14">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css"
integrity="sha512-Zcn6bjR/8RZbLEpLIeOwNtzREBAJnUKESxces60Mpoj+2okopSAcSUIUOseddDm0cxnGQzxIR7vJgsLZbdLE3w==" crossorigin="anonymous">
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"
integrity="sha512-BwHfrr4c9kmRkLw6iXFdzcdWV/PGkVgiIyIWLLlTSXzWQzxuSg4DiQUCpauz/EWjgk5TYQqX/kvn9pG1NpYfqg==" crossorigin="anonymous"></script>
<style>
:root { --bg: #0b0d14; --fg: rgba(255,255,255,.85); }
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
/* desktop: a 3-row grid (panels above, clock in the middle, panels below) so nothing can overlap the clock */
main { position: fixed; inset: 0; z-index: 5; box-sizing: border-box; padding: 3.5vh 3vw; pointer-events: none;
display: grid; grid-template-columns: min(31vw, 520px) 1fr min(31vw, 520px); grid-template-rows: 1fr auto 1fr;
grid-template-areas: "tl . tr" "clock clock clock" "bl . br"; gap: 2.2vh 2vw; }
main > * { pointer-events: auto; }
main #clock { position: static; inset: auto; margin: 0; grid-area: clock; justify-self: center; }
.layer { position: fixed; inset: 0; pointer-events: none; }
#sky { transition: filter 8s ease; }
#sky video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; }
/* weather layers, each faded in/out by opacity */
#clearsky, #stars, #aurora, #moon, #nightdeck, #sun, #haze, #precip, #flash, #bolt { opacity: 0; transition: opacity 8s ease; }
#aurora { mix-blend-mode: screen; }
#moon { width: 22vmin; height: 22vmin; inset: auto; left: 0; top: 0; transform: translate(-50%, -50%);
transition: opacity 8s ease, left 60s linear, top 60s linear, filter 8s ease; }
#nightdeck { background: linear-gradient(to bottom, rgba(14,17,26,.92), rgba(28,32,42,.9)); }
#sun {
width: 70vmin; height: 70vmin; inset: auto; left: 0; top: 0; transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(255,255,244,1) 0 5%, rgba(255,246,214,.9) 8%,
rgba(255,226,160,.45) 20%, rgba(255,210,140,.15) 38%, rgba(255,200,120,0) 62%);
transition: opacity 8s ease, left 60s linear, top 60s linear;
}
#sun.through { mix-blend-mode: screen; }
#haze { background: linear-gradient(to bottom, rgba(200,205,212,.55), rgba(225,228,232,.8)); }
#flash { background: #eef2ff; transition: none; }
#bolt { transition: opacity .5s ease-out; }
#clock {
position: fixed; inset: 0; z-index: 5; margin: auto; height: fit-content; width: fit-content;
max-width: calc(100vw - 32px); box-sizing: border-box;
display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 28px;
padding: 14px 26px; border-radius: 14px;
background: rgba(10, 14, 28, .28); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
color: #fff; text-shadow: 0 1px 8px rgba(0,0,0,.45);
font: 500 clamp(15px, 2vw, 22px)/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-variant-numeric: tabular-nums; letter-spacing: .02em;
}
#clock span { white-space: nowrap; }
#clock b { font-weight: 400; opacity: .75; margin-right: .4em; }
/* four glass panels around the clock (same glass as the clock) */
.panel {
position: relative; width: auto; box-sizing: border-box; padding: 2.2vh 30px 2vh; text-align: center; min-width: 0;
border-radius: 14px; background: rgba(10, 14, 28, .32); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
color: #fff; text-shadow: 0 1px 8px rgba(0,0,0,.45);
font: 400 var(--fs, 17px)/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-variant-numeric: tabular-nums;
}
.panel.tl { grid-area: tl; align-self: start; } .panel.tr { grid-area: tr; align-self: start; }
.panel.bl { grid-area: bl; align-self: end; } .panel.br { grid-area: br; align-self: end; }
.panel h2 { margin: 0 0 12px; font-size: .78em; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; opacity: .7; }
.panel h3 { margin: 14px 0 4px; font-size: 1em; font-weight: 600; }
.panel .big { font-size: 1.9em; font-weight: 500; line-height: 1.2; margin-bottom: 12px; }
/* label | value pairs meet in the middle of the panel */
.panel dl { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; margin: 0; }
.panel dt { opacity: .7; text-align: right; } .panel dd { margin: 0; text-align: left; }
.panel ul { margin: 4px 0 0; padding: 0; list-style: none; } .panel li { margin: 3px 0; }
.panel .note { opacity: .62; font-size: .82em; margin-top: 8px; }
.panel .row { display: flex; justify-content: center; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; }
.grade { display: inline-block; padding: 1px 9px; border-radius: 999px; font-size: .82em; background: rgba(255,255,255,.16); white-space: nowrap; }
.grade.good { background: rgba(80, 200, 140, .38); } .grade.ok { background: rgba(230, 190, 80, .38); } .grade.poor { background: rgba(225, 95, 85, .38); }
#map { height: clamp(110px, 22vh, 270px); border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: #1b2030; }
#map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(.85) contrast(.9) saturate(.5); }
#map .leaflet-control-attribution { background: rgba(10,14,28,.6); color: rgba(255,255,255,.7); font-size: 10px; }
#map .leaflet-control-attribution a { color: #9cc3ff; }
.btns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; justify-content: center; }
.panel button { font: inherit; font-size: .9em; color: #fff; background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.28);
border-radius: 8px; padding: 6px 12px; cursor: pointer; }
.panel button:hover { background: rgba(255,255,255,.24); } .panel button:disabled { opacity: .45; cursor: default; }
.panel button:focus-visible { outline: 2px solid #9cc3ff; outline-offset: 2px; }
/* narrow / portrait screens: clock first, panels stacked below, page scrolls over the fixed sky */
@media (max-width: 1000px), (max-aspect-ratio: 5/4), (max-height: 640px) {
html, body { height: auto; min-height: 100%; overflow-x: hidden; overflow-y: auto; }
main { position: relative; inset: auto; display: block; padding: 0 16px 24px; pointer-events: auto; }
.panel { font-size: 16px; padding: 22px 24px; }
#clock { position: relative; inset: auto; margin: 16px auto; }
.panel, .panel.tl, .panel.tr, .panel.bl, .panel.br { position: relative; inset: auto; width: auto; max-width: 560px; margin: 12px auto; }
}
.instant, .instant * { transition: none !important; } /* ?instant=1: no fades (for screenshots/tests) */
#debug {
position: fixed; left: 50%; top: 8px; transform: translateX(-50%); z-index: 10; display: none;
font: 12px/1.5 ui-monospace, Consolas, monospace; color: var(--fg);
background: rgba(0,0,0,.55); padding: 8px 10px; border-radius: 6px; white-space: pre;
}
</style>
</head>
<body>
<div id="sky" class="layer" aria-hidden="true">
<video id="l0" muted playsinline preload="auto"></video>
<video id="l1" muted playsinline preload="auto"></video>
</div>
<div id="clearsky" class="layer"></div>
<canvas id="stars" class="layer"></canvas>
<canvas id="aurora" class="layer"></canvas>
<canvas id="moon" class="layer"></canvas>
<div id="nightdeck" class="layer"></div>
<div id="sun" class="layer"></div>
<div id="haze" class="layer"></div>
<canvas id="precip" class="layer"></canvas>
<canvas id="bolt" class="layer"></canvas>
<div id="flash" class="layer"></div>
<main>
<div id="clock" role="timer" aria-live="off">
<span><b>Date:</b><span id="c-date"></span></span>
<span><b>Sunrise:</b><span id="c-rise"></span></span>
<span><b>Sunset:</b><span id="c-set"></span></span>
<span><b>Current Time:</b><span id="c-now"></span></span>
</div>
<section class="panel tl" aria-label="Vädret just nu">
<h2>Just nu</h2>
<div class="big" id="p-now-title">–</div>
<dl id="p-now"></dl>
<div class="note" id="p-now-note"></div>
</section>
<section class="panel tr" aria-label="Plats">
<h2>Plats</h2>
<div id="map" role="application" aria-label="Karta – klicka för att välja plats"></div>
<div class="row"><strong id="p-place">Örebro slott</strong><span class="note" id="p-coords" style="margin:0"></span></div>
<div class="btns">
<button id="b-mine" type="button">Utgå från min plats</button>
<button id="b-home" type="button">Örebro slott</button>
</div>
<div class="note" id="p-loc-note">Klicka i kartan för att välja en annan plats.</div>
</section>
<section class="panel bl" aria-label="Natten">
<h2>Kväll &amp; natt</h2>
<div id="p-night"></div>
</section>
<section class="panel br" aria-label="Fotoljus">
<h2>Fotoljus</h2>
<div id="p-photo"></div>
</section>
</main>
<div id="debug"></div>
<script>
(() => {
// Örebro, Sweden (59°16′26″N 15°12′27″E). Day = sunrise..sunset, night = sunset..sunrise.
// The picture follows the clock frame by frame (one play-through per period); the weather in Örebro is layered on top.
// Location: default Örebro slott (59°16′26″N 15°12′55″E, Wikipedia). A visitor can switch to their own position
// (GPS) or click the map; everything (sun, moon, weather, aurora) is then computed for that place. Saved per browser.
const HOME = { lat: 59.27389, lng: 15.21528, name: 'Örebro slott', home: true };
let loc = HOME;
try { const saved = JSON.parse(localStorage.getItem('pc-loc') || 'null'); if (saved && isFinite(saved.lat) && isFinite(saved.lng)) loc = saved; } catch (_) {}
let LAT = loc.lat, LNG = loc.lng;
const FPS = 24;
const webm = document.createElement('video').canPlayType('video/webm; codecs="vp9"') !== '';
const ext = webm ? 'webm' : 'mp4';
const CLIPS = { day: { src: `video/day-v2.${ext}`, frames: 380 }, night: { src: `video/night.${ext}`, frames: 450 } };
// ?t=2026-09-24T19:30 starts the clock there, ?speed=600 runs it faster, ?debug=1 shows state,
// ?weather=clear|partly|overcast|fog|drizzle|rain|heavyrain|thunder|snow forces a weather for testing
const q = new URLSearchParams(location.search);
const t0Real = Date.now();
const t0Sim = q.has('t') ? new Date(q.get('t')).getTime() : t0Real;
const speed = Math.max(1, Number(q.get('speed')) || 1);
const now = () => t0Sim + (Date.now() - t0Real) * speed;
const $ = id => document.getElementById(id);
const dbg = $('debug');
if (q.has('debug')) dbg.style.display = 'block';
if (q.has('instant')) document.body.classList.add('instant');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------- sun (SunCalc formulas) ----------
const rad = Math.PI / 180, dayMs = 864e5, J1970 = 2440588, J2000 = 2451545, J0 = 0.0009, e = rad * 23.4397;
const toDays = d => d / dayMs - 0.5 + J1970 - J2000;
const fromJ = j => (j + 0.5 - J1970) * dayMs;
const meanAnomaly = d => rad * (357.5291 + 0.98560028 * d);
const eclLong = M => M + rad * (1.9148 * Math.sin(M) + 0.02 * Math.sin(2 * M) + 0.0003 * Math.sin(3 * M)) + rad * 102.9372 + Math.PI;
function sunTimes(ms) {
const lw = rad * -LNG, phi = rad * LAT, d = toDays(ms);
const n = Math.round(d - J0 - lw / (2 * Math.PI));
const ds = J0 + lw / (2 * Math.PI) + n;
const M = meanAnomaly(ds), L = eclLong(M);
const dec = Math.asin(Math.sin(e) * Math.sin(L));
const transit = a => J2000 + a + 0.0053 * Math.sin(M) - 0.0069 * Math.sin(2 * L);
const w = Math.acos((Math.sin(-0.833 * rad) - Math.sin(phi) * Math.sin(dec)) / (Math.cos(phi) * Math.cos(dec)));
const jNoon = transit(ds), jSet = transit(J0 + (w + lw) / (2 * Math.PI) + n);
return { rise: fromJ(jNoon - (jSet - jNoon)), set: fromJ(jSet) };
}
function sunPos(ms) { // altitude above the horizon, azimuth from south (west positive), degrees
const lw = rad * -LNG, phi = rad * LAT, d = toDays(ms);
const L = eclLong(meanAnomaly(d));
const dec = Math.asin(Math.sin(e) * Math.sin(L));
const ra = Math.atan2(Math.sin(L) * Math.cos(e), Math.cos(L));
const H = rad * (280.16 + 360.9856235 * d) - lw - ra;
return {
alt: Math.asin(Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H)) / rad,
az: Math.atan2(Math.sin(H), Math.cos(H) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi)) / rad,
};
}
function period(t) {
const st = sunTimes(t);
if (!isFinite(st.rise) || !isFinite(st.set)) // midnight sun / polar night
return { kind: sunPos(t).alt > 0 ? 'day' : 'night', start: t - dayMs / 2, end: t + dayMs / 2 };
const ev = [];
for (const k of [-1, 0, 1]) {
const s = sunTimes(t + k * dayMs);
ev.push({ t: s.rise, kind: 'day' }, { t: s.set, kind: 'night' });
}
ev.sort((a, b) => a.t - b.t);
for (let i = 0; i < ev.length - 1; i++)
if (ev[i].t <= t && t < ev[i + 1].t) return { kind: ev[i].kind, start: ev[i].t, end: ev[i + 1].t };
}
// ---------- clock: Swedish time (Europe/Stockholm = the time zone, not the location) ----------
// Swedish time at Örebro slott; the visitor's own time zone when they use their own place
let TZ, fDate, fTime, fHM, fDay;
function setTZ(tz) {
TZ = tz;
fDate = new Intl.DateTimeFormat('sv-SE', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' });
fTime = new Intl.DateTimeFormat('sv-SE', { timeZone: TZ, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
fHM = new Intl.DateTimeFormat('sv-SE', { timeZone: TZ, hour: '2-digit', minute: '2-digit', hour12: false });
fDay = new Intl.DateTimeFormat('sv-SE', { timeZone: TZ, weekday: 'short', day: 'numeric', month: 'short' });
}
setTZ(loc.home ? 'Europe/Stockholm' : (Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Stockholm'));
let sunDay = null;
function clock(t) {
const day = fDate.format(t);
if (day !== sunDay) {
const [y, m, d] = day.split('-').map(Number);
const s = sunTimes(Date.UTC(y, m - 1, d, 11)); sunDay = day;
$('c-date').textContent = day;
$('c-rise').textContent = fTime.format(s.rise);
$('c-set').textContent = fTime.format(s.set);
}
$('c-now').textContent = fTime.format(t);
}
// ---------- picture: two stacked layers, base = frame k, top = frame k+1 at opacity = fraction ----------
const layers = [0, 1].map(i => ({ v: $('l' + i), key: null, ready: false, primed: false }));
let base = 0;
function show(layer, clip, frame, cb) {
const key = clip + ':' + frame;
if (layer.key === key) { if (layer.ready) cb && cb(); return; }
layer.key = key; layer.ready = false;
const v = layer.v, target = (frame + 0.5) / FPS;
const seek = () => {
v.addEventListener('seeked', () => { if (layer.key === key) { layer.ready = true; cb && cb(); } }, { once: true });
v.currentTime = target;
};
if (!v.src.endsWith(CLIPS[clip].src)) {
v.src = CLIPS[clip].src;
v.addEventListener('loadeddata', () => {
// iOS only paints a paused video after it has played once
if (!layer.primed) { layer.primed = true; v.play().then(() => { v.pause(); seek(); }).catch(seek); }
else seek();
}, { once: true });
v.load();
} else seek();
}
let lastK = null, cur = null;
function picture(t) {
const p = period(t), clip = CLIPS[p.kind];
const pos = (t - p.start) / (p.end - p.start) * clip.frames;
const k = Math.min(clip.frames - 1, Math.floor(pos)), frac = pos - Math.floor(pos);
const next = k + 1 < clip.frames ? [p.kind, k + 1] : [p.kind === 'day' ? 'night' : 'day', 0];
const key = p.kind + ':' + k;
if (key !== lastK) { if (layers[1 - base].key === key) base = 1 - base; lastK = key; }
const b = layers[base], tp = layers[1 - base];
show(b, p.kind, k, () => { b.v.style.zIndex = 1; b.v.style.opacity = 1; });
tp.v.style.zIndex = 2;
show(tp, next[0], next[1]);
tp.v.style.opacity = tp.ready && tp.key === next.join(':') ? frac : 0;
cur = { kind: p.kind, k, n: clip.frames };
}
// ---------- weather ----------
// WMO weather codes -> a named state; each state -> how strongly each effect is on
function stateFrom(w) {
const c = w.weather_code, cc = w.cloud_cover ?? 50;
if ([95, 96, 99].includes(c)) return 'thunder';
if ([71, 73, 75, 77, 85, 86].includes(c)) return 'snow';
if ([65, 67, 82].includes(c)) return 'heavyrain';
if ([61, 63, 66, 80, 81].includes(c)) return 'rain';
if ([51, 53, 55, 56, 57].includes(c)) return 'drizzle';
if ([45, 48].includes(c)) return 'fog';
if (c === 3 || cc >= 85) return 'overcast';
if (c === 0 && cc < 20) return 'clear';
return 'partly';
}
const FX = { // clear = clouds fade away; sun = glow; grey = duller clouds; haze; rain/snow intensity; thunder
clear: { clear: .88, sun: 1, grey: 0, haze: 0, rain: 0, snow: 0 },
partly: { clear: 0, sun: .8, grey: 0, haze: 0, rain: 0, snow: 0 },
overcast: { clear: 0, sun: 0, grey: .55, haze: .12, rain: 0, snow: 0 },
fog: { clear: 0, sun: 0, grey: .45, haze: .6, rain: 0, snow: 0 },
drizzle: { clear: 0, sun: 0, grey: .6, haze: .1, rain: .3, snow: 0 },
rain: { clear: 0, sun: 0, grey: .7, haze: .1, rain: .65, snow: 0 },
heavyrain: { clear: 0, sun: 0, grey: .8, haze: .15, rain: 1, snow: 0 },
thunder: { clear: 0, sun: 0, grey: .85, haze: .1, rain: .9, snow: 0, thunder: true },
snow: { clear: 0, sun: 0, grey: .35, haze: .15, rain: 0, snow: .8 },
};
const NAMES = { clear: 'Clear', partly: 'Partly cloudy', overcast: 'Overcast', fog: 'Fog', drizzle: 'Drizzle',
rain: 'Rain', heavyrain: 'Heavy rain', thunder: 'Thunderstorm', snow: 'Snow' };
let wx = { state: q.get('weather') in FX ? q.get('weather') : 'partly', temp: null, code: null, cc: null, src: 'default',
wind: 0, gust: 0, dir: 270 };
function forceWind() { // ?wind=70&winddir=270 overrides the wind for testing
if (!q.has('wind')) return;
wx.wind = Number(q.get('wind')) || 0; wx.gust = wx.wind * 1.4; wx.dir = Number(q.get('winddir') ?? 270);
}
forceWind();
async function fetchWeather() {
const forced = q.get('weather') in FX;
try {
// Open-Meteo straight from the browser (CORS allowed; Cloudflare Workers get TLS 525 toward it)
const url = `https://api.open-meteo.com/v1/forecast?latitude=${LAT.toFixed(4)}&longitude=${LNG.toFixed(4)}`
+ '&current=temperature_2m,weather_code,cloud_cover,precipitation,is_day,wind_speed_10m,wind_gusts_10m,wind_direction_10m'
+ '&hourly=cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,visibility,precipitation_probability,weather_code,temperature_2m,dew_point_2m'
+ '&forecast_days=3&timezone=GMT';
const res = await (await fetch(url)).json(), w = res.current || {}, hh = res.hourly || { time: [] };
hourly = hh.time.map((tm, i) => ({ t: Date.parse(tm + 'Z'), cc: hh.cloud_cover[i], low: hh.cloud_cover_low[i], vis: hh.visibility[i],
pp: hh.precipitation_probability[i], code: hh.weather_code[i], temp: hh.temperature_2m[i], dew: hh.dew_point_2m[i] }));
if (w.weather_code === undefined) throw new Error(w.error || 'no data');
wx = { state: forced ? q.get('weather') : stateFrom(w), temp: w.temperature_2m, code: w.weather_code, cc: w.cloud_cover,
src: (forced ? 'forced + ' : '') + 'Open-Meteo ' + w.time, at: Date.parse(w.time + 'Z'),
wind: w.wind_speed_10m ?? 0, gust: w.wind_gusts_10m ?? 0, dir: w.wind_direction_10m ?? 270 };
forceWind();
} catch (err) { wx.src = 'error: ' + err.message; } // keep the last known weather
renderPanels();
}
let hourly = [];
// colors of an open sky for a given sun altitude (used when the clouds fade away)
const mix = (a, b, f) => a.map((x, i) => Math.round(x + (b[i] - x) * Math.min(1, Math.max(0, f))));
const rgb = c => `rgb(${c.join(',')})`;
function skyColors(h) {
const day = [[52, 118, 205], [150, 196, 238]], low = [[88, 118, 190], [246, 178, 122]],
twi = [[28, 40, 86], [214, 128, 104]], night = [[5, 8, 20], [22, 30, 62]];
if (h >= 12) return day;
if (h >= 0) return [0, 1].map(i => mix(low[i], day[i], h / 12));
if (h >= -6) return [0, 1].map(i => mix(twi[i], low[i], (h + 6) / 6));
return [0, 1].map(i => mix(night[i], twi[i], (h + 18) / 12));
}
// Wind: level 0 below 20 km/h, 1 at storm (~65 km/h mean or ~95 gusts). The view faces south, so a west wind
// (from 270°) blows things to the LEFT: screen x-direction = sin(direction the wind comes from).
const windLevel = () => Math.min(1, Math.max(0, (Math.max(wx.wind, wx.gust * .68) - 20) / 45));
const windName = l => l <= 0 ? 'calm' : l < .43 ? 'breezy' : l < .93 ? 'windy' : 'storm';
function windDirX() { const x = Math.sin(wx.dir * rad); return Math.abs(x) < .45 ? (x < 0 ? -.45 : .45) : x; }
let gustT = 0, gustLen = 0, gustNext = 3; // gusts: bell-shaped bursts every few seconds when windy
function gustFactor(dt, w) {
if (w < .35) return 1;
gustNext -= dt;
if (gustNext <= 0 && gustT <= 0) { gustLen = rnd(1.5, 3.5); gustT = gustLen; gustNext = rnd(3, 9) * (1.3 - w); }
if (gustT > 0) { gustT -= dt; return 1 + (.5 + w) * Math.sin(Math.PI * (1 - gustT / gustLen)); }
return 1;
}
// ---------- moon (SunCalc formulas): position + phase ----------
function moonCoords(d) {
const L = rad * (218.316 + 13.176396 * d), M = rad * (134.963 + 13.064993 * d), F = rad * (93.272 + 13.229350 * d);
const l = L + rad * 6.289 * Math.sin(M), b = rad * 5.128 * Math.sin(F), dist = 385001 - 20905 * Math.cos(M);
return { ra: Math.atan2(Math.sin(l) * Math.cos(e) - Math.tan(b) * Math.sin(e), Math.cos(l)),
dec: Math.asin(Math.sin(b) * Math.cos(e) + Math.cos(b) * Math.sin(e) * Math.sin(l)), dist };
}
function moonPos(ms) {
const lw = rad * -LNG, phi = rad * LAT, d = toDays(ms), c = moonCoords(d);
const H = rad * (280.16 + 360.9856235 * d) - lw - c.ra;
return { alt: Math.asin(Math.sin(phi) * Math.sin(c.dec) + Math.cos(phi) * Math.cos(c.dec) * Math.cos(H)) / rad,
az: Math.atan2(Math.sin(H), Math.cos(H) * Math.sin(phi) - Math.tan(c.dec) * Math.cos(phi)) / rad };
}
function moonPhase(ms) { // phase 0 = new, .25 first quarter, .5 full, .75 last quarter
const d = toDays(ms), M = meanAnomaly(d), L = eclLong(M);
const s = { dec: Math.asin(Math.sin(e) * Math.sin(L)), ra: Math.atan2(Math.sin(L) * Math.cos(e), Math.cos(L)) };
const m = moonCoords(d), sdist = 149598000;
const phi = Math.acos(Math.sin(s.dec) * Math.sin(m.dec) + Math.cos(s.dec) * Math.cos(m.dec) * Math.cos(s.ra - m.ra));
const inc = Math.atan2(sdist * Math.sin(phi), m.dist - sdist * Math.cos(phi));
const angle = Math.atan2(Math.cos(s.dec) * Math.sin(s.ra - m.ra),
Math.sin(s.dec) * Math.cos(m.dec) - Math.cos(s.dec) * Math.sin(m.dec) * Math.cos(s.ra - m.ra));
return { fraction: (1 + Math.cos(inc)) / 2, phase: .5 + .5 * inc * (angle < 0 ? -1 : 1) / Math.PI };
}
const phaseName = p => p < .03 || p > .97 ? 'nymåne' : p < .22 ? 'växande skära' : p < .28 ? 'halvmåne (första kvarter)'
: p < .47 ? 'växande' : p < .53 ? 'fullmåne' : p < .72 ? 'avtagande' : p < .78 ? 'halvmåne (sista kvarter)' : 'avtagande skära';
// Kp index (NOAA SWPC, updated every minute) -> aurora. ?kp=6 forces it for testing.
let kp = q.has('kp') ? Number(q.get('kp')) : 0, kpSrc = q.has('kp') ? 'forced' : '-';
async function fetchKp() {
if (q.has('kp')) return;
try {
const a = await (await fetch('https://services.swpc.noaa.gov/json/planetary_k_index_1m.json')).json();
const last = a[a.length - 1]; kp = Number(last.estimated_kp ?? last.kp_index) || 0; kpSrc = 'NOAA ' + last.time_tag;
} catch (err) { kpSrc = 'error: ' + err.message; }
}
let kpForecast = []; // 3-hour slots, NOAA SWPC
async function fetchKpForecast() {
try {
const a = await (await fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json')).json();
kpForecast = a.filter(r => r && r.time_tag).map(r => ({ t: Date.parse(r.time_tag.replace(' ', 'T') + 'Z'), kp: Number(r.kp) }));
} catch (_) { kpForecast = []; }
renderPanels();
}
// Örebro (~59°N): aurora faint from Kp 4, clear from Kp 5, strong from Kp 6.5
const auroraLevel = () => Math.min(1, Math.max(0, (kp - 3.5) / 3));
let sunPlaced = false, moonPlaced = false, lastPhase = -1, moonW = 0, auroraOn = 0, starsOn = false, moonNow = null;
let night = false;
function applyWeather(t) {
const fx = FX[wx.state], s = sunPos(t), up = s.alt > -0.833;
const [top, hor] = skyColors(s.alt);
$('clearsky').style.background = `linear-gradient(to bottom, ${rgb(top)}, ${rgb(hor)} 85%)`;
$('clearsky').style.opacity = fx.clear;
$('stars').style.opacity = !up && fx.clear ? .9 : 0; // (stars twinkle in animate)
// sun: x from azimuth (east left, west right), y from altitude; behind/through clouds when partly cloudy
const sun = $('sun');
if (!sunPlaced) sun.style.transition = 'opacity 8s ease'; // first placement: jump there, don't glide in from the corner
sun.style.left = (50 + Math.max(-45, Math.min(45, s.az / 2))) + '%';
sun.style.top = (82 - Math.max(0, Math.min(55, s.alt)) * 1.2) + '%';
if (!sunPlaced) { getComputedStyle(sun).left; sun.style.transition = ''; sunPlaced = true; }
const k = cur && cur.kind === 'day' ? cur.k : -1; // dawn/golden/sunset parts of the day video have their own sun
const blue = wx.state === 'clear' ? 1 : Math.min(1, Math.max(0, (k - 150) / 15)) * Math.min(1, Math.max(0, (285 - k) / 15));
sun.style.opacity = up ? fx.sun * blue * (s.alt < 3 ? .6 : 1) : 0;
sun.classList.toggle('through', wx.state !== 'clear');
const g = fx.grey;
$('sky').style.filter = `saturate(${(1 - .75 * g).toFixed(2)}) brightness(${(1 - .32 * g).toFixed(2)}) contrast(${(1 - .12 * g).toFixed(2)})`;
night = s.alt < -0.833; const dark = Math.min(1, Math.max(0, (-s.alt - 0.8) / 8)); // 0 = daylight .. 1 = dark night
// night: overcast/rain/snow cover the stars of the night video with a dark cloud deck
const DECK = { overcast: .75, fog: .35, drizzle: .7, rain: .8, heavyrain: .88, thunder: .88, snow: .55 };
$('nightdeck').style.opacity = night ? (DECK[wx.state] || 0) * dark : 0;
$('haze').style.background = night ? 'linear-gradient(to bottom, rgba(40,44,54,.6), rgba(60,64,74,.8))'
: 'linear-gradient(to bottom, rgba(200,205,212,.55), rgba(225,228,232,.8))';
$('haze').style.opacity = fx.haze;
$('precip').style.opacity = fx.rain || fx.snow || windLevel() > .35 ? 1 - .3 * dark : 0; // drops dimmer in the dark
// moon: real phase and position; sharp when clear, glowing through when partly cloudy, faint in fog, hidden otherwise
const mp = moonPos(t), ph = moonPhase(t), moon = $('moon');
const MOONVIS = { clear: 1, partly: 1, fog: .25 };
const mvis = mp.alt > 0 && ph.fraction > .02 ? (MOONVIS[wx.state] || 0) * Math.max(.25, dark) : 0;
if (!moonPlaced) moon.style.transition = 'opacity 8s ease';
moon.style.left = (50 + Math.max(-45, Math.min(45, mp.az / 2))) + '%';
moon.style.top = (82 - Math.max(0, Math.min(55, mp.alt)) * 1.2) + '%';
if (!moonPlaced) { getComputedStyle(moon).left; moon.style.transition = ''; moonPlaced = true; }
moon.style.opacity = mvis;
occludeOn = wx.state === 'partly' && night;
// a bright moon lights up the clouds of the night video a little
if (night && wx.state === 'partly' && mp.alt > 0)
$('sky').style.filter += ` brightness(${(1 + .18 * ph.fraction).toFixed(2)})`;
moon.style.filter = wx.state === 'fog' ? 'blur(6px)' : occludeOn ? $('sky').style.filter : 'none'; // cloud pixels match the sky
if (Math.abs(ph.phase - lastPhase) > .002 || moonW !== W) { drawMoon(ph.phase); lastPhase = ph.phase; moonW = W; }
else if (mvis > 0 && performance.now() - lastCompose > 2000) composeMoon(); // clouds drift, the moon moves: redo every 2 s
// aurora: dark (sun below −10°), and clear or partly clear
const AUR = { clear: 1, partly: .55 };
auroraOn = auroraLevel() * (AUR[wx.state] || 0) * Math.min(1, Math.max(0, (-s.alt - 10) / 4));
$('aurora').style.opacity = auroraOn > 0 ? 1 : 0;
starsOn = night && fx.clear > 0;
moonNow = { mp, ph };
return s;
}
// ---------- canvases: stars, rain (falling + drops running down the glass), snow, lightning ----------
const DPR = Math.min(1.5, devicePixelRatio || 1);
const cvs = ['stars', 'precip', 'bolt', 'aurora'].map($);
let W = 0, H = 0;
function resize() {
W = innerWidth; H = innerHeight;
for (const c of cvs) { c.width = W * DPR; c.height = H * DPR; c.getContext('2d').setTransform(DPR, 0, 0, DPR, 0, 0); }
starList.length = 0;
for (let i = 0; i < W * H / 2200; i++)
starList.push({ x: Math.random() * W, y: Math.random() * H * .85, r: Math.random() ** 3 * 1.5 + .3,
a: .25 + Math.random() * .75, tw: Math.random() * 3 + .5, p: Math.random() * 6.28 });
drawStars(0);
const m = $('moon'), px = Math.round(Math.min(W, H) * .22 * DPR);
m.width = m.height = px; lastPhase = -1;
}
const starList = [];
function drawStars(t) { // twinkle: each star's brightness breathes at its own pace
const g = $('stars').getContext('2d'); g.clearRect(0, 0, W, H);
for (const st of starList) {
g.fillStyle = `rgba(255,255,255,${(st.a * (.7 + .3 * Math.sin(t * st.tw + st.p))).toFixed(2)})`;
g.beginPath(); g.arc(st.x, st.y, st.r, 0, 7); g.fill();
}
}
// the moon: lit part from the phase (waxing = lit on the right, as seen from the northern hemisphere), faint earthshine, craters
const CRATERS = [[-.3, -.2, .18], [.25, .12, .22], [-.05, .38, .14], [.38, -.34, .1], [-.42, .22, .12], [.06, -.08, .09], [.18, -.5, .08]];
const moonBase = document.createElement('canvas');
function drawMoon(phase) {
const c = moonBase; c.width = c.height = $('moon').width;
const g = c.getContext('2d'), S = c.width, r = S * .2;
g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, S, S); g.translate(S / 2, S / 2);
g.fillStyle = 'rgba(70,80,100,.18)'; g.beginPath(); g.arc(0, 0, r, 0, 7); g.fill(); // earthshine on the dark side
if (phase > .5) g.scale(-1, 1); // waning: mirror, lit on the left
const k = Math.cos(2 * Math.PI * phase); // >0 crescent, <0 gibbous
g.beginPath(); g.moveTo(0, -r);
g.arc(0, 0, r, -Math.PI / 2, Math.PI / 2, false);
g.ellipse(0, 0, Math.abs(k) * r, r, 0, Math.PI / 2, -Math.PI / 2, k > 0);
g.closePath();
g.save(); g.shadowColor = 'rgba(235,240,255,.85)'; g.shadowBlur = r * .9;
const lg = g.createRadialGradient(-r * .3, -r * .3, r * .1, 0, 0, r);
lg.addColorStop(0, '#fbfaf2'); lg.addColorStop(1, '#d8d6cc');
g.fillStyle = lg; g.fill(); g.restore();
g.save(); g.clip(); // craters only on the lit part
g.setTransform(1, 0, 0, 1, S / 2, S / 2);
for (const [x, y, cr] of CRATERS) { g.fillStyle = 'rgba(150,148,138,.28)'; g.beginPath(); g.arc(x * r, y * r, cr * r, 0, 7); g.fill(); }
g.restore();
composeMoon();
}
const occA = document.createElement('canvas'), occB = document.createElement('canvas');
let occludeOn = false, lastCompose = 0;
function composeMoon() {
const c = $('moon'), g = c.getContext('2d', { willReadFrequently: true }), S = c.width;
g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, S, S); g.drawImage(moonBase, 0, 0);
lastCompose = performance.now();
if (!occludeOn || !S) return;
const vids = [$('l0'), $('l1')].filter(v => v.readyState >= 2 && v.videoWidth && +(v.style.opacity || 0) > 0)
.sort((a, b) => (+a.style.zIndex || 0) - (+b.style.zIndex || 0));
if (!vids.length) return;
// where the moon canvas sits on screen -> the matching part of the (object-fit: cover) video frame
const r = c.getBoundingClientRect(), vw = vids[0].videoWidth, vh = vids[0].videoHeight;
const sc = Math.max(W / vw, H / vh), ox = (W - vw * sc) / 2, oy = (H - vh * sc) / 2;
const sx = (r.left - ox) / sc, sy = (r.top - oy) / sc, sw = r.width / sc, sh = r.height / sc;
occA.width = occA.height = occB.width = occB.height = S;
const a = occA.getContext('2d', { willReadFrequently: true }), b = occB.getContext('2d', { willReadFrequently: true });
for (const v of vids) { a.globalAlpha = +v.style.opacity; a.drawImage(v, sx, sy, sw, sh, 0, 0, S, S); } // same blend as on screen
a.globalAlpha = 1;
b.filter = `blur(${Math.max(2, Math.round(S / 60))}px)`; b.drawImage(occA, 0, 0); // stars vanish, clouds stay
const col = a.getImageData(0, 0, S, S).data, msk = b.getImageData(0, 0, S, S).data;
const img = g.getImageData(0, 0, S, S), d = img.data;
for (let i = 0; i < d.length; i += 4) {
if (!d[i + 3]) continue;
let k = (.2126 * msk[i] + .7152 * msk[i + 1] + .0722 * msk[i + 2] - 50) / 80; // dark sky -> 0, bright cloud -> 1
if (k <= 0) continue; if (k > 1) k = 1; k = k * k * (3 - 2 * k);
d[i] += (col[i] - d[i]) * k; d[i + 1] += (col[i + 1] - d[i + 1]) * k; d[i + 2] += (col[i + 2] - d[i + 2]) * k;
}
g.putImageData(img, 0, 0);
}
resize(); addEventListener('resize', resize);
const pctx = $('precip').getContext('2d');
const streaks = [], drops = [], flakes = [], leaves = [], gusts = [];
const LEAF = ['#c8641e', '#e0a030', '#a0421a', '#d98c2b', '#8a5a2b', '#b8862d'];
const autumn = () => { const m = +fDate.format(now()).slice(5, 7); return m >= 9 && m <= 11; };
const rnd = (a, b) => a + Math.random() * (b - a);
function newDrop(big, anyY) {
const r = big ? rnd(5, 11) : rnd(1.8, 4.5);
return { x: rnd(0, W), y: anyY ? rnd(0, H) : rnd(-20, H * .9), r, vy: 0, slide: big && Math.random() < .7, life: rnd(10, 40), trail: 0 };
}
function drawDrop(d) {
const ry = d.r * (d.slide && d.vy > 20 ? 1.35 : 1);
if (d.trail > 2) { // wet trail left by a running drop
pctx.strokeStyle = 'rgba(225,235,248,.16)'; pctx.lineWidth = d.r * .7; pctx.lineCap = 'round';
pctx.beginPath(); pctx.moveTo(d.x - (d.lean || 0) * d.trail, d.y - d.trail); pctx.lineTo(d.x, d.y - ry * .6); pctx.stroke();
}
pctx.fillStyle = 'rgba(0,0,0,.16)'; // soft shadow under the drop
pctx.beginPath(); pctx.ellipse(d.x + d.r * .12, d.y + ry * .22, d.r * .9, ry, 0, 0, 7); pctx.fill();
const g = pctx.createRadialGradient(d.x - d.r * .35, d.y - ry * .45, d.r * .05, d.x, d.y, Math.max(d.r, ry));
g.addColorStop(0, 'rgba(255,255,255,.9)'); g.addColorStop(.25, 'rgba(240,245,252,.35)');
g.addColorStop(.7, 'rgba(150,165,185,.22)'); g.addColorStop(1, 'rgba(15,25,40,.6)');
pctx.fillStyle = g; pctx.beginPath(); pctx.ellipse(d.x, d.y, d.r * .9, ry, 0, 0, 7); pctx.fill();
pctx.strokeStyle = 'rgba(255,255,255,.28)'; pctx.lineWidth = .8; pctx.stroke();
}
// aurora: pre-rendered colour strip (green base -> violet top), drawn as shimmering curtains
const strip = document.createElement('canvas'); strip.width = 1; strip.height = 256;
(() => { const g = strip.getContext('2d'), gr = g.createLinearGradient(0, 256, 0, 0);
gr.addColorStop(0, 'rgba(120,255,190,0)'); gr.addColorStop(.04, 'rgba(110,255,180,1)'); gr.addColorStop(.35, 'rgba(60,230,150,.75)');
gr.addColorStop(.7, 'rgba(130,90,255,.4)'); gr.addColorStop(1, 'rgba(150,80,255,0)');
g.fillStyle = gr; g.fillRect(0, 0, 1, 256); })();
const CURTAINS = [{ y: .34, a: .07, k: .004, sp: .25, h: .32, o: 0 }, { y: .42, a: .05, k: .006, sp: -.18, h: .26, o: 2 },
{ y: .27, a: .06, k: .003, sp: .12, h: .22, o: 4 }];
let auroraSkip = 0;
function drawAurora(t) {
const g = $('aurora').getContext('2d'); g.clearRect(0, 0, W, H);
g.globalCompositeOperation = 'lighter';
for (const c of CURTAINS) {
for (let x = 0; x < W; x += 3) {
const base = H * (c.y + c.a * Math.sin(x * c.k + t * c.sp + c.o) + .03 * Math.sin(x * c.k * 3.1 - t * c.sp * 1.7));
const hgt = H * c.h * (.55 + .45 * Math.sin(x * c.k * 2.3 + t * .4 + c.o));
g.globalAlpha = auroraOn * .55 * (.3 + .7 * (.5 + .5 * Math.sin(x * .045 + t * 1.3 + c.o)));
g.drawImage(strip, x, base - hgt, 3, hgt);
}
}
g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
}
let lastFrame = performance.now();
function animate(nowMs) {
const dt = Math.min(.05, (nowMs - lastFrame) / 1000); lastFrame = nowMs;
const fx = FX[wx.state], w = windLevel(), gf = gustFactor(dt, w), push = windDirX() * w * gf; // push: sideways, about -1.5..1.5
pctx.clearRect(0, 0, W, H);
if (fx.rain && !document.hidden) {
// falling rain behind the glass
const want = Math.round(W * H / 2200 * fx.rain);
while (streaks.length < want) streaks.push({ x: rnd(-50, W), y: rnd(-H, H), l: rnd(22, 48), v: rnd(1000, 1700) });
streaks.length = Math.min(streaks.length, want);
pctx.strokeStyle = 'rgba(215,225,240,.42)'; pctx.lineWidth = 1.2; pctx.lineCap = 'butt'; pctx.beginPath();
for (const s of streaks) {
const slant = .12 + push * 1.1; // rain falls at an angle in the wind
s.y += s.v * dt; s.x += s.v * dt * slant;
if (s.y > H) { s.y = rnd(-80, -10); s.x = rnd(-W * .6, W * 1.6); }
if (s.x < -60 || s.x > W + 60) s.x = s.x < 0 ? W + rnd(0, 50) : -rnd(0, 50);
pctx.moveTo(s.x, s.y); pctx.lineTo(s.x - s.l * slant, s.y - s.l);
}
pctx.stroke();
// drops on the glass: small ones sit still, big ones run down and leave a trail
const wantDrops = Math.round(W * H / 3200 * fx.rain);
if (!drops.length) for (let i = 0; i < wantDrops * .6; i++) drops.push(newDrop(Math.random() < .2, true));
if (drops.length < wantDrops && Math.random() < .5 + fx.rain * .5) drops.push(newDrop(Math.random() < .22));
for (let i = drops.length - 1; i >= 0; i--) {
const d = drops[i];
d.life -= dt;
if (d.slide) {
d.vy = Math.min(d.vy + rnd(20, 60) * dt, 140); d.y += d.vy * dt; d.x += rnd(-.4, .4) + push * 70 * dt; // wind pushes drops sideways
d.trail = Math.min(d.trail + d.vy * dt, 160); d.lean = push * .45;
if (Math.random() < .08) drops.push({ x: d.x + rnd(-1, 1), y: d.y - d.r * 1.5, r: d.r * rnd(.2, .35), vy: 0, slide: false, life: rnd(2, 6) });
}
if (d.life <= 0 || d.y - d.r > H) { drops.splice(i, 1); continue; }
drawDrop(d);
}
} else if (drops.length || streaks.length) { drops.length = 0; streaks.length = 0; }
if (fx.snow && !document.hidden) {
const want = Math.round(W * H / 3500 * fx.snow);
while (flakes.length < want) flakes.push({ x: rnd(0, W), y: rnd(-H, H), r: rnd(1, 3.4), v: rnd(30, 90), p: rnd(0, 6.28) });
flakes.length = Math.min(flakes.length, want);
pctx.fillStyle = 'rgba(255,255,255,.85)'; pctx.beginPath();
for (const f of flakes) {
f.y += f.v * dt * (1 + w * .6); f.p += dt; f.x += Math.sin(f.p) * 18 * dt + push * 260 * dt; // blown sideways
if (f.y > H + 5) { f.y = -5; f.x = rnd(-W * .5, W * 1.5); }
if (f.x < -10) f.x = W + 5; else if (f.x > W + 10) f.x = -5;
pctx.moveTo(f.x + f.r, f.y); pctx.arc(f.x, f.y, f.r, 0, 7);
}
pctx.fill();
} else flakes.length = 0;
// wind streaks: faint lines of air sweeping across (windy and up), stronger in gusts
const dir = windDirX() < 0 ? -1 : 1;
const wantG = w > .35 && !document.hidden ? Math.round((W * H / 60000) * w * gf) : 0;
while (gusts.length < wantG) gusts.push({ x: dir < 0 ? W + rnd(0, W * .5) : -rnd(0, W * .5), y: rnd(0, H), l: rnd(120, 380), v: rnd(700, 1300), a: rnd(8, 26), p: rnd(0, 6.28) });
if (gusts.length > wantG) gusts.length = wantG;
if (gusts.length) {
pctx.lineWidth = 1; pctx.lineCap = 'round';
pctx.strokeStyle = `rgba(255,255,255,${(.06 + .1 * w).toFixed(3)})`;
for (const g of gusts) {
g.x += dir * g.v * dt * (.6 + .6 * gf); g.p += dt * 2;
if (dir < 0 ? g.x + g.l < -20 : g.x - g.l > W + 20) { g.x = dir < 0 ? W + rnd(20, 300) : -rnd(20, 300); g.y = rnd(0, H); }
const y = g.y + Math.sin(g.p) * g.a;
pctx.beginPath(); pctx.moveTo(g.x, y);
pctx.quadraticCurveTo(g.x - dir * g.l * .5, y - g.a * .6, g.x - dir * g.l, y + g.a * .3); pctx.stroke();
}
}
// autumn leaves (Sep–Nov) when it's windy and not snowing
const wantL = w > .4 && !fx.snow && autumn() && !document.hidden ? Math.round(6 + 34 * w) : 0;
const firstFill = !leaves.length; // when the wind picks up, start with leaves already across the screen
while (leaves.length < wantL) {
leaves.push({ x: firstFill ? rnd(0, W) : dir < 0 ? W + rnd(10, 400) : -rnd(10, 400), y: rnd(-H * .1, H * .9), s: rnd(6, 13), c: LEAF[Math.random() * LEAF.length | 0],
vx: rnd(160, 380), vy: rnd(-20, 50), r: rnd(0, 6.28), vr: rnd(-4, 4), f: rnd(0, 6.28), vf: rnd(3, 8) });
}
if (leaves.length > wantL) leaves.length = wantL;
for (const l of leaves) {
l.x += dir * l.vx * dt * (.5 + .7 * w) * gf; l.y += (l.vy + Math.sin(l.f) * 60) * dt; l.r += l.vr * dt; l.f += l.vf * dt;
if (dir < 0 ? l.x < -30 : l.x > W + 30) { l.x = dir < 0 ? W + rnd(10, 300) : -rnd(10, 300); l.y = rnd(-H * .1, H * .9); }
if (l.y > H + 30) l.y = -20;
pctx.save(); pctx.translate(l.x, l.y); pctx.rotate(l.r); pctx.scale(1, Math.max(.15, Math.abs(Math.cos(l.f)))); // tumbling
pctx.fillStyle = l.c; pctx.beginPath(); pctx.ellipse(0, 0, l.s, l.s * .55, 0, 0, 7); pctx.fill();
pctx.strokeStyle = 'rgba(60,30,10,.45)'; pctx.lineWidth = .8; pctx.beginPath(); pctx.moveTo(-l.s * 1.25, 0); pctx.lineTo(l.s, 0); pctx.stroke();
pctx.restore();
}
// night sky: twinkling stars (when shown) and the aurora, ~15–30 fps is plenty
const ts = nowMs / 1000;
if (++auroraSkip % 2 === 0 && !document.hidden) {
if (starsOn) drawStars(ts);
if (auroraOn > 0) drawAurora(ts); else if (auroraSkip % 60 === 0) $('aurora').getContext('2d').clearRect(0, 0, W, H);
}
// storm: the picture sways a little in the gusts
const sway = w > .9 && !reducedMotion ? windDirX() * (gf - 1) * .8 : 0;
$('sky').style.transform = sway ? `translateX(${sway.toFixed(3)}%) scale(1.03)` : '';
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
// lightning: one soft flash plus a bolt, every 6–20 s. No flicker bursts (photosensitivity), off with reduced motion.
const bctx = $('bolt').getContext('2d');
function bolt() {
bctx.clearRect(0, 0, W, H);
const seg = (x, y, len, depth) => {
bctx.beginPath(); bctx.moveTo(x, y);
for (let i = 0; i < len; i++) {
x += rnd(-28, 28); y += rnd(18, 42); bctx.lineTo(x, y);
if (depth < 2 && Math.random() < .12) { const [sx, sy] = [x, y]; bctx.stroke(); seg(sx, sy, len / 3 | 0, depth + 1); bctx.beginPath(); bctx.moveTo(x, y); }
}
bctx.stroke();
};
bctx.strokeStyle = 'rgba(235,240,255,.95)'; bctx.shadowColor = 'rgba(170,190,255,.9)'; bctx.shadowBlur = 18; bctx.lineWidth = 2.2;
seg(rnd(W * .15, W * .85), 0, rnd(10, 18) | 0, 0);
const b = $('bolt'), f = $('flash');
b.style.transition = 'none'; b.style.opacity = 1;
f.style.transition = 'none'; f.style.opacity = .45;
getComputedStyle(f).opacity;
setTimeout(() => {
f.style.transition = 'opacity .7s ease-out'; f.style.opacity = 0;
b.style.transition = 'opacity .5s ease-out'; b.style.opacity = 0;
}, 90);
}
(function thunderLoop() {
if (FX[wx.state].thunder && !reducedMotion && !document.hidden) bolt();
setTimeout(thunderLoop, rnd(6000, 20000));
})();
// ---------- panels ----------
const NAMES_SV = { clear: 'Klart', partly: 'Växlande molnighet', overcast: 'Mulet', fog: 'Dimma', drizzle: 'Duggregn',
rain: 'Regn', heavyrain: 'Kraftigt regn', thunder: 'Åska', snow: 'Snö' };
const COMPASS = ['N', 'NO', 'O', 'SO', 'S', 'SV', 'V', 'NV'];
const esc = x => String(x).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const hm = ms => fHM.format(ms);
const dur = min => min >= 60 ? `${Math.floor(min / 60)} h ${Math.round(min % 60)} min` : `${Math.round(min)} min`;
const pct = x => Math.round(x) + ' %';
const grade = (label, cls) => `<span class="grade ${cls}">${esc(label)}</span>`;
// intervals where the sun's altitude is inside [lo, hi], scanned minute by minute from now to +36 h
function sunWindows(t0, lo, hi) {
const out = []; let open = null;
for (let t = t0 - 3 * 36e5; t < t0 + 36 * 36e5; t += 6e4) {
const a = sunPos(t).alt, inside = a >= lo && a <= hi;
if (inside && !open) open = { a: t, rising: sunPos(t + 6e4).alt > a };
if (!inside && open) { open.b = t; out.push(open); open = null; }
}
if (open) { open.b = t0 + 36 * 36e5; out.push(open); }
return out.filter(w => w.b > t0);
}
function hoursIn(a, b) { return hourly.filter(h => h.t >= a - 18e5 && h.t <= b + 18e5); }
function avg(list, k) { const v = list.map(h => h[k]).filter(x => x != null); return v.length ? v.reduce((x, y) => x + y, 0) / v.length : null; }
function skyVerdict(a, b) { // weather during a time window, from the hourly forecast
const hs = hoursIn(a, b); if (!hs.length) return grade('ingen prognos', '');
const cc = avg(hs, 'cc'), pp = Math.max(...hs.map(h => h.pp ?? 0));
if (pp >= 50) return grade(`regn troligt (${pp} %)`, 'poor');
if (cc < 25) return grade(`klart · moln ${pct(cc)}`, 'good');
if (cc < 60) return grade(`växlande · moln ${pct(cc)}`, 'ok');
if (cc < 85) return grade(`mest molnigt · ${pct(cc)}`, 'poor');
return grade(`mulet · ${pct(cc)}`, 'poor');
}
function moonEvents(t0) { // next moonrise / moonset within 30 h
let rise = null, set = null, prev = moonPos(t0).alt;
for (let t = t0 + 3e5; t < t0 + 30 * 36e5 && !(rise && set); t += 3e5) {
const a = moonPos(t).alt;
if (prev < 0 && a >= 0 && !rise) rise = t; if (prev >= 0 && a < 0 && !set) set = t; prev = a;
}
return { rise, set };
}
function nextPhase(t0, target) { // next time the phase passes target (0 new, .5 full)
let prev = moonPhase(t0).phase;
for (let t = t0 + 36e5; t < t0 + 32 * dayMs; t += 36e5) {
const p = moonPhase(t).phase;
if (target === 0 ? prev > .9 && p < .1 : prev < target && p >= target) return t;
prev = p;
}
return null;
}
// peak nights (start date), American Meteor Society / IMO calendars
const METEORS = [['Kvadrantiderna', 1, 3], ['Lyriderna', 4, 21], ['Perseiderna', 8, 12], ['Drakoniderna', 10, 8],
['Orioniderna', 10, 21], ['Södra tauriderna', 11, 4], ['Norra tauriderna', 11, 11], ['Leoniderna', 11, 16],
['Geminiderna', 12, 13], ['Ursiderna', 12, 21]];
function upcomingMeteors(t0, days) {
const y = +fDate.format(t0).slice(0, 4), out = [];
for (const yy of [y, y + 1]) for (const [n, m, d] of METEORS) {
const at = Date.UTC(yy, m - 1, d, 20); // evening of the peak night
const inDays = (at - t0) / dayMs;
if (inDays > -1 && inDays <= days) out.push({ n, at, inDays });
}
return out.sort((a, b) => a.at - b.at);
}
// aurora from Örebro needs roughly Kp 5; further north less, further south more (rough rule of thumb)
const kpNeeded = () => Math.min(9, Math.max(1, 5 - (LAT - 59.3) * .45));
function renderPanels() {
if (!fHM) return;
const t = now(), s = sunPos(t), ph = moonPhase(t), mp = moonPos(t);
// ---- just nu ----
const dirTxt = COMPASS[Math.round(((wx.dir % 360) + 360) % 360 / 45) % 8];
const ms = v => (v / 3.6).toFixed(v / 3.6 < 10 ? 1 : 0);
$('p-now-title').textContent = wx.code == null && !(q.get('weather') in FX) ? 'Hämtar väder…' : NAMES_SV[wx.state];
$('p-now').innerHTML = [
['Temperatur', wx.temp != null ? `${wx.temp.toFixed(1)} °C` : '–'],
['Molnighet', wx.cc != null ? pct(wx.cc) : '–'],
['Vind', `${ms(wx.wind)} m/s från ${dirTxt}` + (wx.gust > wx.wind + 5 ? ` · byar ${ms(wx.gust)} m/s` : '')],
['Månen', `${phaseName(ph.phase)}, ${pct(ph.fraction * 100)} belyst · ${mp.alt > 0 ? 'uppe' : 'under horisonten'}`],
['Solen', s.alt > 0 ? `${s.alt.toFixed(0)}° över horisonten` : `${(-s.alt).toFixed(0)}° under horisonten`],
['Plats', esc(loc.name || 'Vald plats')],
].map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
$('p-now-note').textContent = wx.at ? `Väder från Open-Meteo, ${hm(wx.at)} · ${LAT.toFixed(4)}°N ${LNG.toFixed(4)}°E` : '';
// ---- kväll & natt ----
let dark = sunWindows(t, -90, -12)[0], darkLabel = 'Mörkt';
if (!dark) { dark = sunWindows(t, -90, -6)[0]; darkLabel = 'Ljus natt'; }
let nightHtml = '';
if (dark) {
const hs = hoursIn(dark.a, dark.b), cc = avg(hs, 'cc');
let up = 0, n = 0; for (let x = dark.a; x < dark.b; x += 6e5) { n++; if (moonPos(x).alt > 0) up++; }
const moonShare = n ? up / n : 0, mph = moonPhase((dark.a + dark.b) / 2);
const score = (cc == null ? .5 : 1 - cc / 100) * (1 - .6 * mph.fraction * moonShare) * (darkLabel === 'Mörkt' ? 1 : .6);
const [lbl, cls] = score >= .7 ? ['Utmärkt', 'good'] : score >= .45 ? ['Bra', 'ok'] : score >= .25 ? ['Måttlig', 'ok'] : ['Dålig', 'poor'];
const moonTxt = mph.fraction < .1 || moonShare === 0 ? 'månen stör inte' : `månen ${pct(mph.fraction * 100)} belyst, uppe ${pct(moonShare * 100)} av natten`;
nightHtml += `<div class="row"><h3 style="margin-top:0">Stjärnhimlen</h3>${grade(lbl, cls)}</div>
<div>${darkLabel} ${hm(dark.a)}–${hm(dark.b)} · moln ${cc == null ? '–' : pct(cc)} · ${moonTxt}</div>`;
// aurora
const need = kpNeeded(), slots = kpForecast.filter(k => k.t + 3 * 36e5 > dark.a && k.t < dark.b);
const kpMax = Math.max(kp, ...slots.map(k => k.kp));
let aur;
if (LAT < 45) aur = [`Syns inte på den här breddgraden`, 'poor'];
else {
const d = kpMax - need;
aur = d >= 1 ? ['Stor chans', 'good'] : d >= 0 ? ['God chans', 'good'] : d >= -1 ? ['Liten chans', 'ok'] : ['Ingen chans', 'poor'];
if (d >= -1 && cc != null && cc > 80) aur = [aur[0] + ', men molnigt', 'ok'];
}
nightHtml += `<div class="row"><h3>Norrsken</h3>${grade(aur[0], aur[1])}</div>
<div>Kp nu ${kp.toFixed(1)} · max i natt ${kpMax.toFixed(1)} · här behövs ca Kp ${need.toFixed(0)}</div>`;
} else nightHtml += `<div>Ingen mörk natt här just nu (midnattssol).</div>`;
// events
const ev = [];
for (const m of upcomingMeteors(t, 30)) {
const mf = moonPhase(m.at).fraction;
ev.push(`${m.n}: natten ${fDay.format(m.at)}${m.inDays < 1 ? ' (i natt)' : ` (om ${Math.ceil(m.inDays)} d)`}${mf > .6 ? ' – ljus måne stör' : ''}`);
}
const full = nextPhase(t, .5), neu = nextPhase(t, 0);
if (full) ev.push(`Fullmåne ${fDay.format(full)}`);
if (neu) ev.push(`Nymåne ${fDay.format(neu)} – mörkast för stjärnor`);
if (dark) { const cc = avg(hoursIn(dark.a, dark.b), 'cc'); if (cc != null && cc < 20 && ph.fraction < .3) ev.unshift('<strong>Extra stjärnklart i natt</strong>'); }
if (kpForecast.some(k => k.t > t && k.t < t + dayMs && k.kp >= kpNeeded())) ev.unshift('<strong>Norrsken möjligt inom ett dygn</strong>');
nightHtml += `<h3>Händelser</h3><ul>${ev.slice(0, 5).map(x => `<li>${x}</li>`).join('')}</ul>`;
$('p-night').innerHTML = nightHtml;
// ---- fotoljus ----
const gold = sunWindows(t, -4, 6), blue = sunWindows(t, -6, -4);
const firstOf = (list, rising) => list.find(w => w.rising === rising);
const line = (title, w) => w ? `<div class="row"><h3>${title}</h3>${skyVerdict(w.a, w.b)}</div>
<div>${fDay.format(w.a)} ${hm(w.a)}–${hm(w.b)} · ${dur((w.b - w.a) / 6e4)}</div>` : '';
let photo = line('Gyllene timmen, morgon', firstOf(gold, true)) + line('Gyllene timmen, kväll', firstOf(gold, false));
const bm = firstOf(blue, true), be = firstOf(blue, false);
if (bm || be) photo += `<h3>Blå timmen</h3><div>${[bm && `morgon ${hm(bm.a)}–${hm(bm.b)}`, be && `kväll ${hm(be.a)}–${hm(be.b)}`].filter(Boolean).join(' · ')}</div>`;
const extra = [], me = moonEvents(t);
const when = x => (fDate.format(x) === fDate.format(t) ? '' : fDay.format(x) + ' ') + hm(x); // add the day when it isn't today
if (me.rise || me.set) extra.push('Månen ' + [me.rise && [me.rise, `går upp ${when(me.rise)}`], me.set && [me.set, `går ner ${when(me.set)}`]]
.filter(Boolean).sort((x, y) => x[0] - y[0]).map(x => x[1]).join(', ')); // in time order
const evening = firstOf(gold, false);
if (evening && me.rise && ph.fraction > .9 && Math.abs(me.rise - evening.b) < 90 * 6e4) extra.push('<strong>Fullmånen går upp i skymningen</strong>');
const morning = firstOf(gold, true);
if (morning) {
const hs = hoursIn(morning.a - 36e5, morning.b);
if (hs.some(h => (h.vis != null && h.vis < 1000) || (h.temp != null && h.dew != null && h.temp - h.dew < 1 && (h.cc ?? 100) < 70)))
extra.push('<strong>Dimma trolig i gryningen</strong> – bra för stämningsbilder');
}
const rb = hourly.find(h => h.t > t && h.t < t + 12 * 36e5 && (h.pp ?? 0) >= 40 && (h.cc ?? 100) < 75 && sunPos(h.t).alt > 5 && sunPos(h.t).alt < 42);
if (rb) extra.push(`Chans till regnbåge runt ${hm(rb.t)}`);
const md = fDate.format(t).slice(5);
if (md >= '05-25' && md <= '08-10' && LAT > 50 && LAT < 65) extra.push('Säsong för nattlysande moln (titta mot norr efter midnatt)');
photo += `<h3>Övrigt</h3><ul>${(extra.length ? extra : ['Inga särskilda ljusfenomen just nu']).map(x => `<li>${x}</li>`).join('')}</ul>`;
$('p-photo').innerHTML = photo;
fitPanels();
}
// ---------- location: map, own position, Örebro slott ----------
let map = null, marker = null;
function initMap() {
if (!window.L || map) return;
map = L.map('map', { zoomControl: false, scrollWheelZoom: false }).setView([LAT, LNG], 11);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' }).addTo(map);
marker = L.circleMarker([LAT, LNG], { radius: 7, color: '#fff', weight: 2, fillColor: '#ffcf5c', fillOpacity: .9 }).addTo(map);
map.on('click', e => setLocation({ lat: e.latlng.lat, lng: e.latlng.lng, name: null, home: false }));
}
addEventListener('load', initMap);
async function placeName(lat, lng) { // OpenStreetMap Nominatim, only when the place changes
try {
const r = await (await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&zoom=14&accept-language=sv&lat=${lat.toFixed(5)}&lon=${lng.toFixed(5)}`)).json();
const a = r.address || {};
const part = a.suburb || a.village || a.town || a.hamlet || a.neighbourhood || a.city_district;
const city = a.city || a.town || a.municipality || a.county;
return [part, city].filter((x, i, arr) => x && arr.indexOf(x) === i).join(', ') || r.display_name || 'Vald plats';
} catch (_) { return 'Vald plats'; }
}
function showLoc() {
$('p-place').textContent = loc.name || 'Vald plats';
$('p-coords').textContent = `${LAT.toFixed(3)}°, ${LNG.toFixed(3)}°`;
$('b-home').disabled = !!loc.home;
if (map) { map.setView([LAT, LNG], map.getZoom()); marker.setLatLng([LAT, LNG]); }
}
async function setLocation(l) {
loc = l; LAT = l.lat; LNG = l.lng; sunDay = null; lastPhase = -1;
setTZ(l.home ? 'Europe/Stockholm' : (Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Stockholm'));
showLoc(); tick(); fetchWeather();
if (!l.name) { l.name = await placeName(l.lat, l.lng); if (loc === l) showLoc(); }
try { localStorage.setItem('pc-loc', JSON.stringify(l)); } catch (_) {}
renderPanels();
}
$('b-home').addEventListener('click', () => { $('p-loc-note').textContent = 'Klicka i kartan för att välja en annan plats.'; setLocation({ ...HOME }); });
$('b-mine').addEventListener('click', () => {
if (!navigator.geolocation) { $('p-loc-note').textContent = 'Din webbläsare kan inte dela position.'; return; }
$('p-loc-note').textContent = 'Hämtar din position…';
navigator.geolocation.getCurrentPosition(
p => { $('p-loc-note').textContent = `Din position (±${Math.round(p.coords.accuracy)} m). Sparas bara i din webbläsare.`;
setLocation({ lat: p.coords.latitude, lng: p.coords.longitude, name: null, home: false }); },
err => { $('p-loc-note').textContent = err.code === 1 ? 'Du nekade positionsdelning – vi utgår från Örebro slott.' : 'Kunde inte hämta din position.'; },
{ enableHighAccuracy: false, timeout: 15000, maximumAge: 10 * 60 * 1000 });
});
showLoc();
// largest text that still fits: start at min(20px, 1.2vw, 2.2vh) and step down until no panel runs off the screen
const STACKED = '(max-width: 1000px), (max-aspect-ratio: 5/4), (max-height: 640px)';
let fitting = false;
function fitPanels() {
if (fitting) return; fitting = true;
const root = document.documentElement.style;
if (matchMedia(STACKED).matches) { root.removeProperty('--fs'); fitting = false; return; }
const panels = [...document.querySelectorAll('.panel')];
let fs = Math.min(20, innerWidth * .012, innerHeight * .022);
for (let i = 0; i < 40; i++) {
root.setProperty('--fs', fs.toFixed(1) + 'px');
if (Math.max(...panels.map(el => el.getBoundingClientRect().bottom)) <= innerHeight - 8 || fs <= 11) break;
fs -= .5;
}
fitting = false;
}
addEventListener('resize', fitPanels);
// ---------- main loop ----------
function tick() {
const t = now();
clock(t);
picture(t);
const s = applyWeather(t);
if (q.has('debug'))
dbg.textContent = `bild ${cur.kind} ${cur.k + 1}/${cur.n} (${ext})\nsol ${s.alt.toFixed(1)}° höjd, ${s.az.toFixed(0)}° azimut\n`
+ `väder ${NAMES[wx.state]} kod ${wx.code ?? '-'} moln ${wx.cc ?? '-'}% ${wx.temp ?? '-'}°C\n`
+ `vind ${wx.wind} km/h, byar ${wx.gust} km/h, från ${wx.dir}° → ${windName(windLevel())}\n`
+ `måne ${moonNow ? phaseName(moonNow.ph.phase) + ', ' + Math.round(moonNow.ph.fraction * 100) + '% belyst, höjd ' + moonNow.mp.alt.toFixed(1) + '°' : '-'}\n`
+ `norrsken Kp ${kp.toFixed(2)} (${kpSrc}) → ${auroraOn > 0 ? 'syns ' + Math.round(auroraOn * 100) + '%' : 'nej'}\nkälla ${wx.src}`;
}
fetchWeather().then(tick);
setInterval(fetchWeather, 10 * 60 * 1000);
fetchKp(); setInterval(fetchKp, 10 * 60 * 1000);
fetchKpForecast(); setInterval(fetchKpForecast, 30 * 60 * 1000);
setInterval(renderPanels, 30 * 1000);
tick();
setInterval(tick, 250);
document.addEventListener('visibilitychange', () => { if (!document.hidden) { fetchWeather(); tick(); } });
})();
</script>
</body>
</html>