From ee727204b1f65565b30a544a021423d054631270 Mon Sep 17 00:00:00 2001 From: Linus Olofsson Date: Thu, 24 Sep 2026 23:35:19 +0200 Subject: [PATCH] =?UTF-8?q?Fyra=20glasrutor,=20v=C3=A4der-/natteffekter=20?= =?UTF-8?q?och=20nym=C3=A5ne-=C3=B6verraskning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 45 +++-- site/public/img/moon-photographer.svg | 59 ++++++ site/public/index.html | 262 ++++++++++++++++++++++++-- 3 files changed, 342 insertions(+), 24 deletions(-) create mode 100644 site/public/img/moon-photographer.svg 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`