diff --git a/README.md b/README.md
index 1f36aba..a7b1d14 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,43 @@
# PublicCloud – himlen över Örebro
-Källkoden till **https://publiccloud.se**: en helskärmshimmel som följer solen, månen och det aktuella vädret, med fyra informationsrutor runt en klocka.
+Källkoden till **https://publiccloud.se**: en helskärmshimmel som följer solen, månen och det aktuella vädret, med en klocka i mitten och fyra glasrutor runt den.
## Vad sidan gör
-- **Himlen följer klockan.** Dagfilmen spelas från verklig soluppgång till solnedgång och nattfilmen från solnedgång till soluppgång, en bildruta åt gången (en genomspelning per period, med mjuk övertoning mellan bildrutor).
-- **Vädret läggs ovanpå** (Open-Meteo, var 10:e minut): klart (molnen tonas bort), växlande (solen genom molnen), mulet, dimma, duggregn, regn, kraftigt regn med droppar på "glaset", åska med blixtar, snö.
+**Himlen**
+- **Dag och natt följer klockan.** Dagfilmen spelas från verklig soluppgång till solnedgång och nattfilmen från solnedgång till soluppgång, en bildruta åt gången, med mjuk övertoning mellan rutorna.
+- **Vädret läggs ovanpå** (Open-Meteo, var 10:e minut): klart (molnen tonas bort), växlande (solen genom molnen), mulet, dimma, duggregn, regn och kraftigt regn med droppar som rinner på "glaset", åska med blixtar och snö.
- **Vind** (hastighet, byar, riktning): snett regn, blåsande snö och droppar, vindstråk, höstlöv (sep–nov) och byar. Vyn är vänd mot söder, så västlig vind blåser åt vänster.
-- **Natten:** månen med rätt fas och position (skymd bakom molnen vid växlande molnighet), glittrande stjärnor, mörkt molntäcke vid mulet, **norrsken** från NOAA:s Kp-index.
-- **Fyra rutor:** *Just nu* (väder, temperatur, vind, måne, sol, plats) · *Plats* (karta, "Utgå från min plats" via GPS, klick i kartan, standard Örebro slott) · *Kväll & natt* (betyg för stjärnhimlen, norrskenschans, meteorregn, fullmåne/nymåne) · *Fotoljus* (gyllene och blå timmen med väder, månens upp- och nedgång, dimma, regnbåge, nattlysande moln).
+
+**Natten**
+- **Månen** med rätt fas och position. Vid växlande molnighet skärs den av molnen i filmen, eftersom molnen ligger i lagret ovanför månen.
+- **Stjärnor** som glittrar realistiskt (scintillation, utan strålkors).
+- **Mulet** ger ett mörkt molntäcke.
+- **Norrsken** styrs av NOAA:s Kp-index: gröna band med ljus underkant som kommer lågt från horisonten och sveper upp över himlen.
+- **Nymåne-överraskning:** en egen SVG-siluett av en fotograf som sitter på en månskära med kameran (`img/moon-photographer.svg`). Den visas bara vid nymåne, när det är mörkt och klart.
+
+**Fyra rutor**
+- **Just nu:** väder, temperatur, vind, måne, sol och plats.
+- **Plats:** karta med zoom (+/−), knappen "Utgå från min plats" (GPS), klick i kartan för att välja plats, och standardplatsen Örebro slott. Allt räknas om för vald plats.
+- **Kväll & natt:** betyg för stjärnhimlen, chans till norrsken (Kp nu och prognos), meteorregn samt fullmåne och nymåne.
+- **Fotoljus:** gyllene och blå timmen med väderprognos, månens upp- och nedgång, dimma, regnbåge och nattlysande moln.
+
+Textstorleken anpassas automatiskt så att allt får plats. På mobil staplas rutorna.
+
+**Prototyp (`?lager=1`):** tre procedurella molnlager som driver med vinden på olika djup (månen och solen ligger mellan lagren), samt animerade SVG-väderikoner i rutorna.
## Struktur
```
site/
- public/index.html hela sidan (HTML, CSS, JS – inga byggsteg)
- public/_headers cache-regler (filmerna cachas i ett år → nytt filnamn vid ny film)
- public/video/ day-v2 / night som WebM (VP9) + MP4 (H.264, reserv för äldre Safari)
- worker.js Cloudflare Worker: statiska filer + HTTP Range (206) för /video/* (krävs av Safari/iOS)
- wrangler.jsonc Worker-konfiguration (namn publiccloud-sky)
+ public/index.html hela sidan (HTML, CSS, JS – inga byggsteg)
+ public/img/moon-photographer.svg nymåne-överraskningen
+ public/_headers cache-regler (filmerna cachas i ett år → nytt filnamn vid ny film)
+ public/video/ day-v2 / night som WebM (VP9) + MP4 (H.264, reserv för äldre Safari)
+ worker.js Cloudflare Worker: statiska filer + HTTP Range (206) för /video/* (krävs av Safari/iOS)
+ wrangler.jsonc Worker-konfiguration (namn publiccloud-sky)
tools/
- upscale-slowmo.sh hur filmerna togs fram: Real-ESRGAN x4plus (uppskalning) + RIFE v4.6 (slowmotion)
+ upscale-slowmo.sh hur filmerna togs fram: Real-ESRGAN x4plus (uppskalning) + RIFE v4.6 (slowmotion)
```
## Driftsättning
@@ -38,11 +55,13 @@ npx wrangler deploy
| Parameter | Exempel | Effekt |
|---|---|---|
-| `t` | `?t=2026-09-26T23:00%2B02:00` | startar klockan vid en viss tid |
+| `t` | `?t=2026-10-10T23:30%2B02:00` | startar klockan vid en viss tid |
| `speed` | `?speed=600` | kör klockan snabbare |
| `weather` | `?weather=thunder` | tvingar väder: clear, partly, overcast, fog, drizzle, rain, heavyrain, thunder, snow |
| `wind`, `winddir` | `?wind=75&winddir=270` | tvingar vind (km/h, grader) |
-| `kp` | `?kp=6.5` | tvingar Kp-index (norrsken) |
+| `kp` | `?kp=7` | tvingar Kp-index (norrsken) |
+| `nymane` | `?nymane=1` | visar nymåne-överraskningen oavsett månfas (på natten) |
+| `lager` | `?lager=1` | molnlager + animerade ikoner (prototyp) |
| `instant` | `?instant=1` | inga övertoningar (för skärmdumpar) |
| `debug` | `?debug=1` | visar bildruta, sol, väder, vind, måne, norrsken |
diff --git a/site/public/img/moon-photographer.svg b/site/public/img/moon-photographer.svg
new file mode 100644
index 0000000..0ccfddc
--- /dev/null
+++ b/site/public/img/moon-photographer.svg
@@ -0,0 +1,59 @@
+
diff --git a/site/public/index.html b/site/public/index.html
index d76dc86..1ad176f 100644
--- a/site/public/index.html
+++ b/site/public/index.html
@@ -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 @@
+
+
+
+
@@ -127,6 +160,7 @@
Just nu
+
–
@@ -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') => ``;
+ 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 += ``; }
+ return `${rays}`
+ + ``;
+ }
+ 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 ``
+ + ``;
+ }
+ const cloudG = (tx, ty, sc, fill = 'rgba(255,255,255,.95)', anim = true) =>
+ ``;
+ 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' ? ``
+ : ``; }
+ return o;
+ }
+ function wxIcon(state, isNight, phase, cls = 'wx-icon') {
+ const body = isNight ? moonG(24, 24, 11, phase) + ``
+ : 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) + `` : 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) => ``).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) + ``, cls);
+ case 'snow': return svg(cloudG(0, -6, 1, grey) + fallers('snow', 5), cls);
+ }
+ return '';
+ }
+ const auroraIco = () => svg(['#5dffa8', '#40e0a0', '#a07aff'].map((c, i) =>
+ ``).join(''));
+ const goldenIco = () => svg(`${sunG(32, 44, 12)}`);
+ 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`