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
+26 -7
View File
@@ -1,20 +1,37 @@
# 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/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)
@@ -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 |
+59
View File
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" >
<defs>
<!-- crescent = big circle minus an offset circle (lit on the right, open to the upper left) -->
<mask id="cres">
<rect width="400" height="400" fill="#000"/>
<circle cx="226" cy="198" r="152" fill="#fff"/>
<circle cx="170" cy="160" r="136" fill="#000"/>
</mask>
<radialGradient id="lit" cx="74%" cy="60%" r="62%">
<stop offset="0" stop-color="#f7f4ec"/>
<stop offset=".5" stop-color="#dedcd5"/>
<stop offset=".85" stop-color="#aab3c6"/>
<stop offset="1" stop-color="#7f8aa3"/>
</radialGradient>
<!-- mottled maria: soft fractal noise turned into faint darker patches -->
<filter id="maria" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency=".018" numOctaves="4" seed="7"/>
<feColorMatrix type="matrix" values="0 0 0 0 .38 0 0 0 0 .42 0 0 0 0 .52 2.2 0 0 0 -1.05"/>
<feGaussianBlur stdDeviation="1.4"/>
</filter>
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="10"/></filter>
</defs>
<g mask="url(#cres)">
<circle cx="226" cy="198" r="152" fill="#e8ecf6" opacity=".5" filter="url(#glow)"/>
<circle cx="226" cy="198" r="152" fill="url(#lit)"/>
<rect width="400" height="400" filter="url(#maria)" opacity=".55"/>
</g>
<!-- the photographer (silhouette), sitting on the inner ledge of the lower horn with the bright crescent behind his back,
legs dangling over the edge, camera to the eye aimed out at the night sky -->
<g fill="#060910">
<!-- far leg -->
<path d="M214 300 C200 302 186 303 176 304 C171 305 170 309 171 313 L173 349 C173 353 176 354 179 353 L181 314 C190 314 204 313 214 312 Z"/>
<path d="M168 349 h14 c3 0 4 5 0 6 h-17 c-3 0 -3 -6 3 -6 z"/>
<!-- near leg -->
<path d="M220 306 C206 308 192 309 182 310 C176 311 175 316 176 320 L177 356 C177 360 181 361 184 360 L186 321 C196 321 210 320 222 318 Z"/>
<path d="M172 356 h15 c3 0 4 5 0 6 h-18 c-3 0 -3 -6 3 -6 z"/>
<!-- torso in a jacket: rounded back against the moon, leaning a little forward -->
<path d="M226 309 C229 296 228 280 222 268 C217 258 209 253 200 254 C192 255 188 262 188 270 C188 284 191 297 194 306 C200 313 214 314 226 309 Z"/>
<!-- neck + head, cap with brim toward the camera side -->
<path d="M200 256 L203 246 L209 248 L207 258 Z"/>
<circle cx="203" cy="238" r="11.5"/>
<path d="M191 235 C193 224 205 220 213 226 C215 228 215 232 213 233 Z"/>
<path d="M191 233 l-8 1.5 c-1.5 .3 -1.5 2 0 2.2 l9 -.5 z"/>
<!-- arms: elbows down, hands up at the camera -->
<path d="M205 262 C198 268 190 276 186 280 C183 283 180 281 181 277 L184 270 C186 263 177 258 172 253 L177 248 C184 252 195 256 200 258 Z"/>
<path d="M214 266 C208 274 199 282 193 285 C190 287 187 284 189 281 L196 272 C191 266 186 262 181 258 L185 253 C192 257 204 261 210 262 Z"/>
<!-- camera: body, top prism, grip, lens hood -->
<rect x="164" y="243" width="24" height="16" rx="3"/>
<path d="M171 243 l2 -6 h8 l2 6 z"/>
<rect x="145" y="245" width="20" height="12" rx="2"/>
<rect x="140" y="243.5" width="7" height="15" rx="1.5"/>
<!-- strap -->
<path d="M186 258 C190 270 200 274 206 270" fill="none" stroke="#060910" stroke-width="1.6"/>
</g>
<!-- rim light: a thin moonlit edge on the side facing the crescent, so the figure also reads against the dark sky -->
<g fill="none" stroke="#dfe6f5" stroke-linecap="round" opacity=".6" stroke-width="1.2">
<path d="M225 305 C227 290 226 279 221 268"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+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`