Files
PublicCloud/site/public/index.html
T
2026-09-25 00:32:57 +02:00

1319 lines
87 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="vendor/maplibre-6.11.2/maplibre-gl.css">
<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; }
/* new-moon surprise: the photographer on the crescent, floating gently high in the middle of the sky */
#newmoon { inset: auto; left: 57%; top: 25%; width: min(30vmin, 36vw); height: auto; transform: translate(-50%, -50%);
opacity: 0; transition: opacity 8s ease; animation: nm-float 11s ease-in-out infinite; }
@keyframes nm-float { 0%, 100% { transform: translate(-50%, -50%) rotate(-1deg); } 50% { transform: translate(-50%, calc(-50% - 7px)) rotate(1deg); } }
@media (prefers-reduced-motion: reduce) { #newmoon { animation: none; } }
#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); }
/* the globe (MapLibre, globe projection): transparent around the sphere so the glass shows through */
#map { height: clamp(150px, 26vh, 300px); border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: transparent; position: relative; }
#map .maplibregl-ctrl-attrib { background: rgba(10,14,28,.6); color: rgba(255,255,255,.7); font-size: 10px; }
#map .maplibregl-ctrl-attrib a { color: #9cc3ff; }
#map .maplibregl-ctrl-attrib-button { filter: invert(1); background-color: transparent; }
/* own + / − buttons in the glass style */
#map .gz { position: absolute; top: 8px; left: 8px; z-index: 2; display: flex; flex-direction: column;
border: 1px solid rgba(255,255,255,.28); border-radius: 8px; overflow: hidden; }
#map .gz button { width: 32px; height: 32px; padding: 0; border: 0; border-radius: 0; font: 400 20px/1 system-ui, sans-serif;
color: #fff; background: rgba(10,14,28,.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); cursor: pointer; }
#map .gz button + button { border-top: 1px solid rgba(255,255,255,.2); }
#map .gz button:hover { background: rgba(40,48,70,.85); }
.me-pin { width: 24px; height: 34px; filter: drop-shadow(0 2px 3px rgba(0,0,0,.55)); cursor: default; }
#pin-tip { position: absolute; z-index: 3; pointer-events: none; padding: 4px 9px; border-radius: 7px; font-size: .8em; white-space: nowrap;
background: rgba(10,14,28,.8); color: #fff; transform: translate(-50%, -140%); display: none; }
.panel .btns.small button { font-size: .82em; padding: 5px 10px; }
.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; }
}
/* ?lager=1 prototype: procedural cloud layers drifting over the video (back / mid / front) */
.cl { position: fixed; top: 0; left: 0; height: 100%; pointer-events: none; opacity: 0; transition: opacity 8s ease;
background-repeat: repeat; will-change: transform; }
/* animated weather icons (SVG) */
.wx-icon { display: none; margin: 0 auto 4px; width: 3.4em; height: 3.4em; }
.lager .wx-icon { display: block; }
.ico { display: none; width: 1.3em; height: 1.3em; vertical-align: -.28em; margin-right: .35em; }
.lager .ico { display: inline-block; }
.wx-icon *, .ico * { transform-box: view-box; transform-origin: 32px 32px; }
@keyframes ico-spin { to { transform: rotate(360deg); } }
@keyframes ico-fall { 0% { transform: translateY(-5px); opacity: 0; } 25% { opacity: 1; } 100% { transform: translateY(9px); opacity: 0; } }
@keyframes ico-drift { 0%, 100% { transform: translateX(-2px); } 50% { transform: translateX(2px); } }
@keyframes ico-flash { 0%, 84%, 100% { opacity: .2; } 88% { opacity: 1; } 94% { opacity: .35; } }
@keyframes ico-twinkle { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
@keyframes ico-wave { 0%, 100% { transform: translateY(0) scaleY(1); } 50% { transform: translateY(-1.5px) scaleY(1.12); } }
.i-rays { animation: ico-spin 50s linear infinite; }
.i-drop { animation: ico-fall 1.1s linear infinite; }
.i-flake { animation: ico-fall 2.6s linear infinite; }
.i-cloud { animation: ico-drift 6s ease-in-out infinite; }
.i-bolt { animation: ico-flash 4s ease-in-out infinite; }
.i-star { animation: ico-twinkle 3s ease-in-out infinite; }
.i-fog { animation: ico-drift 4s ease-in-out infinite; }
.i-wave { animation: ico-wave 3.5s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .wx-icon *, .ico * { animation: none !important; } }
.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>
<div id="clBack" class="cl" aria-hidden="true"></div>
<canvas id="moon" class="layer"></canvas>
<img id="newmoon" class="layer" src="img/moon-photographer.svg" alt="" aria-hidden="true">
<div id="nightdeck" class="layer"></div>
<div id="sun" class="layer"></div>
<div id="clMid" class="cl" aria-hidden="true"></div>
<div id="clFront" class="cl" aria-hidden="true"></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="wx-icon" id="p-now-icon" aria-hidden="true"></div>
<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="Jordglob – klicka för att välja plats"><div id="pin-tip"></div></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="btns small">
<button id="b-save" type="button">Spara som min plats</button>
<button id="b-share" type="button">Dela min pin</button>
</div>
<div class="note" id="p-loc-note">Klicka på globen för att välja en annan plats.</div>
<div class="note" id="p-pins-note"></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;
const store = { get(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (_) { return null; } },
set(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, JSON.stringify(v)); } catch (_) {} } };
if (!store.get('pc-home') && store.get('pc-loc')) store.set('pc-home', store.get('pc-loc')); // earlier versions auto-saved
store.set('pc-loc', null);
{ const saved = store.get('pc-home'); if (saved && isFinite(saved.lat) && isFinite(saved.lng)) loc = saved; }
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 LAGER = q.get('lager') !== '0'; // cloud layers + animated icons (default on; ?lager=0 turns them off)
if (LAGER) document.body.classList.add('lager');
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 };
// new-moon surprise (the real moon can't be seen): dark, and clear or partly clear. ?nymane=1 forces it for testing
const NM = { clear: .95, partly: .85 };
$('newmoon').style.opacity = (ph.fraction < .04 || q.has('nymane')) && s.alt < -6 ? (NM[wx.state] || 0) : 0;
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 = [];
const glints = []; let nextGlint = 0;
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();
}
// now and then a star scintillates (every 1.5–4 s, at most two at once)
if (t > nextGlint && glints.length < 2 && starList.length && !reducedMotion) {
const st = starList[Math.random() * starList.length | 0];
glints.push({ st, t0: t, len: 1.4 + Math.random() * 1.4 });
nextGlint = t + 1.5 + Math.random() * 2.5;
}
// realistic scintillation: no spikes, just the star flickering fast and irregularly in strength and a little in colour,
// with a small soft glow (like a bright star low in turbulent air)
for (let i = glints.length - 1; i >= 0; i--) {
const gl = glints[i], p = (t - gl.t0) / gl.len;
if (p >= 1) { glints.splice(i, 1); continue; }
const env = Math.sin(Math.PI * p) ** 1.5;
const flick = .55 + .45 * Math.sin(t * 31 + gl.st.p) * Math.sin(t * 17.3 + gl.st.p * 2); // irregular fast flicker
const k = env * (.35 + .65 * Math.max(0, flick));
const hue = Math.sin(t * 9 + gl.st.p * 3); // white <-> bluish / amber
const c = hue > .4 ? [200, 220, 255] : hue < -.4 ? [255, 228, 190] : [255, 255, 255];
const { x, y } = gl.st, R = 2.2 + 3.2 * k;
const halo = g.createRadialGradient(x, y, 0, x, y, R * 2.4);
halo.addColorStop(0, `rgba(${c},${(.95 * k).toFixed(2)})`); halo.addColorStop(.35, `rgba(${c},${(.35 * k).toFixed(2)})`);
halo.addColorStop(1, `rgba(${c},0)`);
g.fillStyle = halo; g.beginPath(); g.arc(x, y, R * 2.4, 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(190,255,140,0)'); gr.addColorStop(.015, 'rgba(205,255,150,1)'); gr.addColorStop(.06, 'rgba(120,255,110,.95)');
gr.addColorStop(.3, 'rgba(60,225,110,.6)'); gr.addColorStop(.62, 'rgba(40,190,110,.28)'); gr.addColorStop(.86, 'rgba(90,110,200,.1)');
gr.addColorStop(1, 'rgba(120,90,210,0)');
g.fillStyle = gr; g.fillRect(0, 0, 1, 256); })();
// Each ribbon runs from a far point near the horizon (small, faint) out toward the viewer (tall, bright), bending as it comes.
// ex/ey: where it leaves the picture; cx/cy: bend; sw: sway; ph: phase. Coordinates are fractions of the screen.
// so: where along the horizon the far end starts (offset from the vanishing point), so the ribbons don't meet in one point
const RIBBONS = [{ so: .34, sy: .02, ex: -.1, ey: .2, cx: .55, cy: .42, sw: .06, curl: .09, ph: 0 }, // long band sweeping up to the left
{ so: .22, sy: -.02, ex: .3, ey: -.12, cx: .62, cy: .2, sw: .07, curl: .12, ph: 2.1 }, // swirls up over the viewer
{ so: .44, sy: .03, ex: 1.1, ey: .08, cx: .86, cy: .36, sw: .05, curl: .07, ph: 4.2 }]; // short band out to the right
let auroraSkip = 0; const aurOff = document.createElement('canvas');
function drawAurora(t) {
const g = $('aurora').getContext('2d'); g.clearRect(0, 0, W, H);
g.globalCompositeOperation = 'lighter';
const vx = W * (.5 + .03 * Math.sin(t * .05)), vy = H * .72; // horizon reference, far away
// a faint green glow over the whole northern sky, like in SMHI's photo
const gb = Math.min(H, vy + H * .1), glow = g.createLinearGradient(0, gb, 0, 0);
glow.addColorStop(0, 'rgba(60,210,120,0)'); glow.addColorStop(.18, `rgba(60,210,120,${(.15 * auroraOn).toFixed(3)})`);
glow.addColorStop(.6, `rgba(40,170,110,${(.07 * auroraOn).toFixed(3)})`); glow.addColorStop(1, 'rgba(40,120,110,0)');
g.globalCompositeOperation = 'source-over'; g.fillStyle = glow; g.fillRect(0, 0, W, gb);
// curtains go to an offscreen canvas that is blurred onto the sky (no stripes); the crisp lower edges are drawn after
if (aurOff.width !== g.canvas.width || aurOff.height !== g.canvas.height) { aurOff.width = g.canvas.width; aurOff.height = g.canvas.height; }
const o = aurOff.getContext('2d'); o.setTransform(DPR, 0, 0, DPR, 0, 0); o.clearRect(0, 0, W, H); o.globalCompositeOperation = 'lighter';
const edges = [];
g.globalCompositeOperation = 'lighter';
for (const r of RIBBONS) {
const ex = W * r.ex, ey = H * r.ey, cx = W * r.cx, cy = H * r.cy;
const N = 260; let px = null, py = null; const pts = [];
for (let i = 0; i <= N; i++) {
const u = i / N, s = u * u; // perspective: far part compressed, near part stretched
const a = (1 - s) * (1 - s), b = 2 * (1 - s) * s, c = s * s; // quadratic Bézier far -> near
const sx = vx + W * r.so, sy = vy + H * r.sy; // this ribbon's own far end on the horizon
let x = a * sx + b * cx + c * ex, y = a * sy + b * cy + c * ey;
// sideways folds grow with nearness; pulses of light travel outward, from the depth toward the viewer
x += W * r.sw * s * (Math.sin(u * 7 + t * .35 + r.ph) + .45 * Math.sin(u * 19 - t * .6 + r.ph * 1.7));
y += H * r.curl * Math.sin(u * Math.PI * 1.6 + t * .12 + r.ph) * u; // S-shaped twist, growing toward the viewer
pts.push([x, y, u]);
const pulse = .5 + .5 * Math.sin(u * 18 - t * 1.4 + r.ph);
const hgt = H * (.015 + .42 * Math.pow(u, 1.6)) * (.75 + .25 * Math.sin(u * 11 + t * .5 + r.ph));
const wdt = 2 + 26 * u * u; // wider close to the viewer
// where samples bunch up (far away, or a ribbon heading straight up) dim each strip so light doesn't pile up into white
const seg = px === null ? wdt : Math.hypot(x - px, y - py); px = x; py = y;
const density = Math.min(1, seg / (wdt * .6) + .08);
const fadeIn = Math.min(1, u / .22); // the far end fades in out of the depth
o.globalAlpha = auroraOn * (.14 + .46 * u) * (.35 + .65 * pulse) * density * fadeIn * fadeIn;
o.drawImage(strip, x - wdt / 2, y - hgt, wdt * 1.6, hgt);
}
edges.push(pts);
}
g.globalAlpha = 1; g.filter = `blur(${Math.max(2, Math.round(Math.min(W, H) / 300))}px)`;
g.setTransform(1, 0, 0, 1, 0, 0); g.drawImage(aurOff, 0, 0); g.setTransform(DPR, 0, 0, DPR, 0, 0); g.filter = 'none';
for (const pts of edges) {
g.lineCap = 'round'; g.shadowColor = 'rgba(170,255,140,.9)';
g.lineJoin = 'round'; g.strokeStyle = 'rgb(200,255,160)';
for (let i = 0; i < pts.length - 1; i += 16) { // continuous chunks (no beads); brighter and wider toward the viewer
const seg = pts.slice(i, i + 17), u = seg[seg.length >> 1][2], fade = Math.min(1, u / .22);
g.globalAlpha = auroraOn * (.08 + .42 * u) * fade * fade; g.shadowBlur = 6 + 12 * u; g.lineWidth = 1.2 + 3 * u;
g.beginPath(); g.moveTo(seg[0][0], seg[0][1]); for (const [x, y] of seg) g.lineTo(x, y); g.stroke();
}
g.shadowBlur = 0;
}
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);
}
driftLayers(dt, w, gf);
// 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 ----------
// ---------- the globe: MapLibre GL (self-hosted), globe projection, day/night shading, shared city pins ----------
let map = null, marker = null, ml = null, pinData = { cities: [], total: 0 };
// the night side of the Earth right now: everything beyond the terminator (subsolar point from the same sun formulas)
function nightPolygon(t) {
// grid of 1.5° cells; sun altitude per cell centre; darkness grows through twilight (0 → −12°); equal cells in a row are merged.
// Stable on every date (a closed-form terminator line breaks down around the equinoxes, when the sun's declination ≈ 0).
const d = toDays(t), L = eclLong(meanAnomaly(d));
const dec = Math.asin(Math.sin(e) * Math.sin(L)), ra = Math.atan2(Math.sin(L) * Math.cos(e), Math.cos(L));
const lonS = (ra - rad * (280.16 + 360.9856235 * d)) / rad;
const feats = [], S = 1.5;
for (let la = -84; la < 84; la += S) {
const phi = (la + S / 2) * rad; let run = null;
const flush = () => { if (run && run.o > 0) feats.push({ type: 'Feature', properties: { o: run.o },
geometry: { type: 'Polygon', coordinates: [[[run.a, la], [run.b, la], [run.b, la + S], [run.a, la + S], [run.a, la]]] } }); run = null; };
for (let lo = -180; lo < 180; lo += S) {
const H = (lo + S / 2 - lonS) * rad;
const alt = Math.asin(Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H)) / rad;
const o = Math.round(Math.min(1, Math.max(0, (.5 - alt) / 12.5)) * 16) / 16; // 0 in daylight … 1 in full night, 16 steps
if (run && run.o === o) run.b = lo + S; else { flush(); run = { a: lo, b: lo + S, o }; }
}
flush();
}
return { type: 'FeatureCollection', features: feats };
}
function pinsGeo() {
return { type: 'FeatureCollection', features: pinData.cities.map(c => ({ type: 'Feature',
properties: { n: c.n, lat: c.lat, lng: c.lng, place: `${c.city}, ${c.country}`, label: `${c.city}, ${c.country} · ${c.n} ${c.n === 1 ? 'delad pin' : 'delade pins'}` },
geometry: { type: 'Point', coordinates: [c.lng, c.lat] } })) };
}
async function initMap() {
if (map) return;
try { ml = await import('./vendor/maplibre-6.11.2/maplibre-gl.mjs'); }
catch (_) { $('map').innerHTML = '<div class="note" style="padding:40px 10px">Globen kunde inte laddas.</div>'; return; }
try {
// start zoom so the whole globe fits the panel (globe diameter ≈ 512·2^z/π px)
const box = $('map').getBoundingClientRect(), fitZ = Math.log2(.62 * Math.min(box.width, box.height) * Math.PI / 512); // .62: comfortable margin (MapLibre draws the globe ~17 % larger than 512·2^z/π)
map = new ml.Map({ container: 'map', center: [LNG, LAT], zoom: fitZ, minZoom: fitZ - .4, attributionControl: { compact: true }, dragRotate: false,
style: { version: 8, projection: { type: 'globe' },
sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, maxzoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' },
night: { type: 'geojson', data: nightPolygon(now()) },
coast: { type: 'geojson', data: location.origin + '/vendor/ne/coast-110m.json' },
borders: { type: 'geojson', data: location.origin + '/vendor/ne/borders-110m.json' },
pins: { type: 'geojson', data: pinsGeo() } },
layers: [
{ id: 'osm', type: 'raster', source: 'osm', paint: { 'raster-brightness-max': .8, 'raster-saturation': .05, 'raster-contrast': .18 } },
{ id: 'night', type: 'fill', source: 'night', paint: { 'fill-color': '#040610', 'fill-opacity': ['*', .62, ['get', 'o']], 'fill-antialias': false } },
{ id: 'borders', type: 'line', source: 'borders', paint: { 'line-color': 'rgba(70,64,60,.6)', 'line-dasharray': [2, 1.5],
'line-width': ['interpolate', ['linear'], ['zoom'], 0, .5, 5, 1] } },
{ id: 'coast', type: 'line', source: 'coast', paint: { 'line-color': 'rgba(20,52,88,.9)',
'line-width': ['interpolate', ['linear'], ['zoom'], 0, .9, 5, 1.6] } },
{ id: 'pins-glow', type: 'circle', source: 'pins', paint: { 'circle-color': '#ffd76a', 'circle-opacity': .3, 'circle-blur': .9,
'circle-radius': ['+', 7, ['*', 3, ['sqrt', ['get', 'n']]]] } },
{ id: 'pins', type: 'circle', source: 'pins', paint: { 'circle-color': '#ffe7a3', 'circle-stroke-color': '#fff', 'circle-stroke-width': .8,
'circle-radius': ['+', 2.2, ['*', 1.1, ['sqrt', ['get', 'n']]]] } } ] } });
} catch (_) { $('map').insertAdjacentHTML('beforeend', '<div class="note" style="padding:40px 10px">Din webbläsare kan inte visa globen (WebGL).</div>'); return; }
$('map').insertAdjacentHTML('beforeend', '<div class="gz"><button type="button" id="gz-in" aria-label="Zooma in" title="Zooma in">+</button>'
+ '<button type="button" id="gz-out" aria-label="Zooma ut" title="Zooma ut">−</button></div>');
$('gz-in').onclick = () => map.zoomIn(); $('gz-out').onclick = () => map.zoomOut();
setInterval(() => map.getSource('night') && map.getSource('night').setData(nightPolygon(now())), 60 * 1000);
if (q.has('debug')) window.__globe = map; // test hook
map.on('load', () => { fetchPins(); $('map').dataset.proj = JSON.stringify(map.getProjection && map.getProjection()); }); // diagnostics
map.on('error', ev => { $('map').dataset.err = String(ev && ev.error && ev.error.message || ev).slice(0, 200); });
const el = document.createElement('div'); el.className = 'me-pin'; el.title = 'Din valda plats';
el.innerHTML = '<svg viewBox="0 0 24 34" width="24" height="34" aria-hidden="true"><path d="M12 33c-.6 0-1-.4-1.3-.9C8.6 27.6 1 19.6 1 12a11 11 0 0 1 22 0c0 7.6-7.6 15.6-9.7 20.1-.3.5-.7.9-1.3.9z" fill="#e63946" stroke="#8d1a24" stroke-width="1"/><circle cx="12" cy="12" r="4.2" fill="#fff"/></svg>';
marker = new ml.Marker({ element: el, anchor: 'bottom' }).setLngLat([LNG, LAT]).addTo(map);
const tip = $('pin-tip');
map.on('mousemove', 'pins', ev => { const f = ev.features[0]; tip.textContent = f.properties.label; tip.style.left = ev.point.x + 'px';
tip.style.top = ev.point.y + 'px'; tip.style.display = 'block'; map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'pins', () => { tip.style.display = 'none'; map.getCanvas().style.cursor = ''; });
map.on('click', ev => {
const hit = map.getLayer('pins') && map.queryRenderedFeatures(ev.point, { layers: ['pins', 'pins-glow'] })[0];
if (hit) { // a shared pin: jump straight to that city
const { lat, lng } = hit.properties; // exact stored position (rendered geometry is slightly quantized)
tip.style.display = 'none';
$('p-loc-note').textContent = `Du tittar nu på himlen över ${hit.properties.place}.`;
setLocation({ lat, lng, name: hit.properties.place, home: false });
return;
}
setLocation({ lat: ev.lngLat.lat, lng: ((ev.lngLat.lng + 540) % 360) - 180, name: null, home: false });
});
}
addEventListener('load', initMap);
// ---------- shared pins: an open, anonymous index at city level (server looks up the city; no free text) ----------
async function fetchPins() {
try { pinData = await (await fetch('/api/pins', { cache: 'no-store' })).json(); } catch (_) { return; }
if (map && map.getSource('pins')) map.getSource('pins').setData(pinsGeo());
const n = pinData.total || 0, c = (pinData.cities || []).length;
$('p-pins-note').textContent = n ? `${n} ${n === 1 ? 'person' : 'personer'} i ${c} ${c === 1 ? 'stad' : 'städer'} följer himlen här.` : '';
}
setInterval(fetchPins, 5 * 60 * 1000);
function showPinButtons() {
const mine = store.get('pc-pin'), saved = store.get('pc-home');
$('b-share').textContent = mine ? `Ta bort min pin (${mine.city})` : 'Dela min pin';
$('b-share').title = mine ? '' : 'Delas anonymt: bara stad och land, ingen exakt position. Du kan ta bort den när du vill.';
const same = saved && Math.abs(saved.lat - LAT) < 1e-6 && Math.abs(saved.lng - LNG) < 1e-6;
$('b-save').textContent = same ? 'Glöm min plats' : 'Spara som min plats';
}
$('b-save').addEventListener('click', () => {
const saved = store.get('pc-home'), same = saved && Math.abs(saved.lat - LAT) < 1e-6 && Math.abs(saved.lng - LNG) < 1e-6;
if (same) { store.set('pc-home', null); $('p-loc-note').textContent = 'Glömt – nästa gång startar sidan på Örebro slott.'; }
else { store.set('pc-home', { lat: LAT, lng: LNG, name: loc.name, home: !!loc.home });
$('p-loc-note').textContent = `Sparad! Sidan startar på ${loc.name || 'den här platsen'} nästa gång (bara i din webbläsare).`; }
showPinButtons();
});
$('b-share').addEventListener('click', async () => {
const mine = store.get('pc-pin'), btn = $('b-share'); btn.disabled = true;
try {
if (mine) {
await fetch('/api/pins', { method: 'DELETE', headers: { 'X-Pin-Token': mine.token } });
store.set('pc-pin', null); $('p-loc-note').textContent = 'Din pin är borttagen.';
} else {
const r = await fetch('/api/pins', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat: LAT, lng: LNG }) });
const d = await r.json();
if (!r.ok) { $('p-loc-note').textContent = d.error || 'Kunde inte dela just nu.'; return; }
store.set('pc-pin', { token: d.token, city: d.city, country: d.country });
$('p-loc-note').textContent = `Delad som ${d.city}, ${d.country} – bara stad och land, anonymt.`;
}
fetchPins();
} catch (_) { $('p-loc-note').textContent = 'Kunde inte nå servern just nu.'; }
finally { btn.disabled = false; showPinButtons(); }
});
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.easeTo({ center: [LNG, LAT], duration: 1200 }); marker.setLngLat([LNG, LAT]); }
showPinButtons();
}
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(); }
renderPanels();
}
$('b-home').addEventListener('click', () => { $('p-loc-note').textContent = 'Klicka på globen 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). Tryck "Spara som min plats" för att starta här nästa gång.`;
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);
// ---------- ?lager=1: animated SVG weather icons ----------
const CLOUD = 'M17 47h31a11 11 0 0 0 1-22 15 15 0 0 0-29-4 11 11 0 0 0-3 26z';
const svg = (inner, cls = 'ico') => `<svg class="${cls}" viewBox="0 0 64 64" aria-hidden="true">${inner}</svg>`;
function sunG(cx, cy, r) {
let rays = '';
for (let i = 0; i < 8; i++) { const a = i * Math.PI / 4, x1 = cx + Math.cos(a) * (r + 4), y1 = cy + Math.sin(a) * (r + 4),
x2 = cx + Math.cos(a) * (r + 9), y2 = cy + Math.sin(a) * (r + 9);
rays += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}"/>`; }
return `<g class="i-rays" style="transform-origin:${cx}px ${cy}px" stroke="#ffd76a" stroke-width="3" stroke-linecap="round">${rays}</g>`
+ `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#ffd76a"/>`;
}
function moonG(cx, cy, r, phase) { // same phase geometry as the canvas moon (waxing lit on the right)
const k = Math.cos(2 * Math.PI * phase), m = phase > .5 ? -1 : 1;
const lit = `M0 ${-r} A${r} ${r} 0 0 1 0 ${r} A${(Math.abs(k) * r).toFixed(2)} ${r} 0 0 ${k > 0 ? 0 : 1} 0 ${-r}Z`;
return `<g transform="translate(${cx} ${cy}) scale(${m} 1)"><circle r="${r}" fill="rgba(160,175,205,.22)"/>`
+ `<path d="${lit}" fill="#f4f1e6"/></g>`;
}
const cloudG = (tx, ty, sc, fill = 'rgba(255,255,255,.95)', anim = true) =>
`<g transform="translate(${tx} ${ty}) scale(${sc})"><path class="${anim ? 'i-cloud' : ''}" d="${CLOUD}" fill="${fill}"/></g>`;
function fallers(kind, n) {
let o = '';
for (let i = 0; i < n; i++) { const x = 22 + i * (22 / Math.max(1, n - 1)), d = (i * .37 % 1).toFixed(2);
o += kind === 'rain' ? `<line class="i-drop" style="animation-delay:-${d}s" x1="${x}" y1="50" x2="${x - 2}" y2="57" stroke="#9cc3ff" stroke-width="2.4" stroke-linecap="round"/>`
: `<circle class="i-flake" style="animation-delay:-${(d * 2.6).toFixed(2)}s" cx="${x}" cy="53" r="2.3" fill="#fff"/>`; }
return o;
}
function wxIcon(state, isNight, phase, cls = 'wx-icon') {
const body = isNight ? moonG(24, 24, 11, phase) + `<circle class="i-star" cx="50" cy="12" r="1.6" fill="#fff"/><circle class="i-star" style="animation-delay:-1.4s" cx="44" cy="22" r="1.1" fill="#fff"/>`
: sunG(24, 24, 9);
const grey = 'rgba(205,213,226,.95)', dark = 'rgba(150,160,178,.95)';
switch (state) {
case 'clear': return svg(isNight ? moonG(32, 32, 16, phase) + `<circle class="i-star" cx="54" cy="14" r="1.8" fill="#fff"/><circle class="i-star" style="animation-delay:-1s" cx="10" cy="18" r="1.3" fill="#fff"/><circle class="i-star" style="animation-delay:-2s" cx="52" cy="50" r="1.2" fill="#fff"/>` : sunG(32, 32, 12), cls);
case 'partly': return svg(body + cloudG(6, 6, .9), cls);
case 'overcast': return svg(cloudG(-4, -8, .85, grey) + cloudG(4, 2, 1, 'rgba(240,244,250,.97)'), cls);
case 'fog': return svg(cloudG(0, -8, .95, grey, false) + [48, 54, 60].map((y, i) => `<line class="i-fog" style="animation-delay:-${i * 1.3}s" x1="${12 + i * 3}" y1="${y}" x2="${52 - i * 3}" y2="${y}" stroke="#dfe5ee" stroke-width="3" stroke-linecap="round"/>`).join(''), cls);
case 'drizzle': return svg(cloudG(0, -6, 1, grey) + fallers('rain', 3), cls);
case 'rain': return svg(cloudG(0, -6, 1, dark) + fallers('rain', 4), cls);
case 'heavyrain': return svg(cloudG(0, -6, 1, dark) + fallers('rain', 6), cls);
case 'thunder': return svg(cloudG(0, -8, 1, dark) + `<polygon class="i-bolt" points="34,40 25,54 32,54 27,64 42,48 35,48 39,40" fill="#ffe27a"/>`, cls);
case 'snow': return svg(cloudG(0, -6, 1, grey) + fallers('snow', 5), cls);
}
return '';
}
const auroraIco = () => svg(['#5dffa8', '#40e0a0', '#a07aff'].map((c, i) =>
`<path class="i-wave" style="animation-delay:-${i * 1.1}s" d="M6 ${44 - i * 9} C18 ${30 - i * 9} 26 ${54 - i * 9} 38 ${40 - i * 9} S54 ${30 - i * 9} 60 ${38 - i * 9}" stroke="${c}" stroke-width="4" fill="none" stroke-linecap="round" opacity="${.9 - i * .2}"/>`).join(''));
const goldenIco = () => svg(`<clipPath id="hz"><rect width="64" height="44"/></clipPath><g clip-path="url(#hz)">${sunG(32, 44, 12)}</g><line x1="6" y1="45" x2="58" y2="45" stroke="#ffb86b" stroke-width="3" stroke-linecap="round"/>`);
function decorateIcons(t) {
if (!LAGER) return;
const up = sunPos(t).alt > -0.833, ph = moonPhase(t).phase;
const key = wx.state + up + ph.toFixed(2);
if ($('p-now-icon').dataset.k !== key) { $('p-now-icon').innerHTML = wxIcon(wx.state, !up, ph); $('p-now-icon').dataset.k = key; }
for (const h of document.querySelectorAll('#p-night h3, #p-photo h3')) {
if (h.querySelector('svg')) continue;
const tx = h.textContent;
const ic = tx.startsWith('Stjärnhimlen') ? svg(moonG(32, 32, 20, ph)) : tx.startsWith('Norrsken') ? auroraIco()
: tx.startsWith('Gyllene') ? goldenIco() : tx.startsWith('Blå timmen') ? svg(moonG(32, 32, 18, .3).replace('#f4f1e6', '#8fb3ff')) : '';
if (ic) h.insertAdjacentHTML('afterbegin', ic);
}
}
// ---------- ?lager=1: procedural cloud layers (periodic fBm noise -> seamless soft tiles) ----------
const CL = [{ el: $('clBack'), size: 1.7, speed: 5, seed: 11, cov: .85 }, { el: $('clMid'), size: 2.5, speed: 12, seed: 29, cov: .7 },
{ el: $('clFront'), size: 3.6, speed: 26, seed: 47, cov: .5 }];
const CLOP = { clear: [.05, .03, 0], partly: [.34, .24, .14], overcast: [.55, .42, .28], fog: [.3, .4, .5], drizzle: [.5, .38, .24],
rain: [.58, .44, .28], heavyrain: [.66, .5, .32], thunder: [.7, .54, .36], snow: [.5, .38, .24] };
const TW = 512, TH = 256;
function hash(x, y, o, sd) { let h = x * 374761393 + y * 668265263 + o * 2246822519 + sd * 3266489917; h = (h ^ (h >>> 13)) * 1274126177; return ((h ^ (h >>> 16)) >>> 0) / 4294967295; }
function noiseField(seed) { // periodic value-noise fBm, 5 octaves, tiles seamlessly in x and y
const f = new Float32Array(TW * TH); let norm = 0;
for (let o = 0; o < 5; o++) {
const px = 4 << o, py = 2 << o, amp = .5 ** o; norm += amp;
for (let y = 0; y < TH; y++) {
const gy = y / TH * py, y0 = Math.floor(gy), fy = gy - y0, sy = fy * fy * (3 - 2 * fy), y1 = (y0 + 1) % py;
for (let x = 0; x < TW; x++) {
const gx = x / TW * px, x0 = Math.floor(gx), fx = gx - x0, sx = fx * fx * (3 - 2 * fx), x1 = (x0 + 1) % px;
const a = hash(x0, y0, o, seed), b = hash(x1, y0, o, seed), c = hash(x0, y1, o, seed), d = hash(x1, y1, o, seed);
f[y * TW + x] += amp * (a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy);
}
}
}
for (let i = 0; i < f.length; i++) f[i] /= norm;
return f;
}
const fields = LAGER ? CL.map(l => noiseField(l.seed)) : [];
let clKey = '', clOff = [0, 0, 0], clUrls = [];
function cloudTint(alt, state, mf) { // colour of the drifting clouds from the sun's height, weather and moonlight
let c;
if (alt >= 10) c = [248, 249, 252];
else if (alt >= 0) c = mix([255, 208, 165], [248, 249, 252], alt / 10);
else if (alt >= -6) c = mix([150, 130, 160], [235, 170, 150], (alt + 6) / 6);
else c = [70 + 70 * mf, 78 + 72 * mf, 100 + 70 * mf].map(Math.round);
const k = { overcast: .84, fog: 1, drizzle: .78, rain: .66, heavyrain: .58, thunder: .5, snow: .95 }[state] ?? 1;
return c.map(v => Math.round(v * k));
}
function buildTiles(tint, cover) {
clUrls.forEach(u => URL.revokeObjectURL(u)); clUrls = [];
CL.forEach((l, i) => {
const cv = document.createElement('canvas'); cv.width = TW; cv.height = TH;
const g = cv.getContext('2d'), img = g.createImageData(TW, TH), d = img.data, f = fields[i];
const th = .66 - .22 * Math.min(1, cover * l.cov), soft = .13; // sparser, crisper edges: veils, not haze
for (let p = 0; p < f.length; p++) {
let a = (f[p] - th) / soft; if (a <= 0) continue; if (a > 1) a = 1; a = a * a * (3 - 2 * a);
const shade = .74 + .34 * Math.min(1, (f[p] - th) / .25); // denser core = brighter, edges darker -> some volume
d[p * 4] = Math.min(255, tint[0] * shade); d[p * 4 + 1] = Math.min(255, tint[1] * shade); d[p * 4 + 2] = Math.min(255, tint[2] * shade);
d[p * 4 + 3] = a * 255;
}
g.putImageData(img, 0, 0);
cv.toBlob(b => { const u = URL.createObjectURL(b); clUrls.push(u);
const w = Math.round(innerWidth * l.size / 2.2), h = Math.round(w / 2);
l.w = w; l.el.style.width = `calc(100% + ${w}px)`; l.el.style.backgroundSize = `${w}px ${h}px`; l.el.style.backgroundImage = `url(${u})`; });
});
}
function updateLayers(t) { // called from tick: opacity per weather, rebuild tiles when colour/cover changes
if (!LAGER) return;
const s = sunPos(t), mf = moonPhase(t).fraction, tint = cloudTint(s.alt, wx.state, mf);
const cover = (wx.cc ?? 50) / 100, key = tint.map(v => v >> 4).join(',') + '|' + Math.round(cover * 10) + '|' + innerWidth;
if (key !== clKey) { clKey = key; buildTiles(tint, cover); }
const op = CLOP[wx.state] || CLOP.partly;
CL.forEach((l, i) => l.el.style.opacity = op[i]);
}
function driftLayers(dt, w, gf) { // called from animate: drift with the wind, front faster (parallax)
if (!LAGER || reducedMotion) return;
const dir = windDirX() < 0 ? -1 : 1, v = (.4 + 2.6 * w) * gf, ts = performance.now() / 1000;
CL.forEach((l, i) => {
if (!l.w) return;
clOff[i] += dir * l.speed * v * dt;
const m = ((clOff[i] % l.w) + l.w) % l.w;
l.el.style.transform = `translate3d(${(m - l.w).toFixed(1)}px, ${(Math.sin(ts * .05 + i * 2) * 6).toFixed(1)}px, 0)`;
});
}
// ---------- main loop ----------
function tick() {
const t = now();
clock(t);
picture(t);
const s = applyWeather(t);
updateLayers(t); decorateIcons(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>