Fyra glasrutor, väder-/natteffekter och nymåne-överraskning

- Fyra rutor (Just nu, Plats med karta/GPS/platsval, Kväll & natt, Fotoljus),
  centrerad text som anpassas efter skärmstorlek, staplat på mobil
- Månen skärs av molnen vid växlande molnighet (molnen är lagret ovanför)
- Realistiskt stjärnglitter (scintillation) och norrsken i djupled efter
  SMHI-referens (grön ljus underkant, S-svep, mjukt sken)
- Nymåne-överraskning: egen SVG-siluett av en fotograf på månskäran
- Prototyp ?lager=1: procedurella molnlager med parallax + animerade ikoner
- README uppdaterad med funktioner och testparametrar

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Linus Olofsson
2026-09-24 23:35:19 +02:00
co-authored by Claude Opus 5.5
parent 5adc31cff2
commit ee727204b1
3 changed files with 342 additions and 24 deletions
+251 -11
View File
@@ -27,6 +27,11 @@
#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%);
@@ -95,6 +100,30 @@
#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;
@@ -111,9 +140,13 @@
<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>
@@ -127,6 +160,7 @@
</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>
@@ -177,6 +211,8 @@
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') === '1'; // prototype: cloud layers + animated icons
if (LAGER) document.body.classList.add('lager');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------- sun (SunCalc formulas) ----------
@@ -460,6 +496,9 @@
$('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;
}
@@ -479,12 +518,35 @@
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]];
@@ -567,22 +629,66 @@
// aurora: pre-rendered colour strip (green base -> violet top), drawn as shimmering curtains
const strip = document.createElement('canvas'); strip.width = 1; strip.height = 256;
(() => { const g = strip.getContext('2d'), gr = g.createLinearGradient(0, 256, 0, 0);
gr.addColorStop(0, 'rgba(120,255,190,0)'); gr.addColorStop(.04, 'rgba(110,255,180,1)'); gr.addColorStop(.35, 'rgba(60,230,150,.75)');
gr.addColorStop(.7, 'rgba(130,90,255,.4)'); gr.addColorStop(1, 'rgba(150,80,255,0)');
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); })();
const CURTAINS = [{ y: .34, a: .07, k: .004, sp: .25, h: .32, o: 0 }, { y: .42, a: .05, k: .006, sp: -.18, h: .26, o: 2 },
{ y: .27, a: .06, k: .003, sp: .12, h: .22, o: 4 }];
let auroraSkip = 0;
// 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';
for (const c of CURTAINS) {
for (let x = 0; x < W; x += 3) {
const base = H * (c.y + c.a * Math.sin(x * c.k + t * c.sp + c.o) + .03 * Math.sin(x * c.k * 3.1 - t * c.sp * 1.7));
const hgt = H * c.h * (.55 + .45 * Math.sin(x * c.k * 2.3 + t * .4 + c.o));
g.globalAlpha = auroraOn * .55 * (.3 + .7 * (.5 + .5 * Math.sin(x * .045 + t * 1.3 + c.o)));
g.drawImage(strip, x, base - hgt, 3, hgt);
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';
}
@@ -678,6 +784,8 @@
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)` : '';
@@ -929,12 +1037,144 @@
}
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`