Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1319 lines
87 KiB
HTML
1319 lines
87 KiB
HTML
<!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 & 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)}`
|
||
+ '¤t=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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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: '© <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>
|