diff --git a/CLAUDE.md b/CLAUDE.md index 48d7e47..eb18254 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,8 +34,10 @@ plugin/ main.qml # Plasma WallpaperItem — hosts everything dancer.html # Self-contained animation (HTML + CSS + JS) starcatalog.js # Generated star data (Yale BSC) — see tools/build_catalog.py + milkyway.jpg # Gaia all-sky map (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO) — see tools/build_skymap.py tools/ build_catalog.py # Regenerates starcatalog.js from the Yale Bright Star Catalog + build_skymap.py # Regenerates milkyway.jpg (Hammer -> plate carrée) from ESA's Gaia map ``` ### main.qml — the Plasma host @@ -50,7 +52,7 @@ Pure HTML/CSS/JS, no external dependencies. All visuals are self-contained: | Layer | Technique | |---|---| -| Realistic sky (stars + Milky Way) | ``; real-time alt-az projection of `starcatalog.js` (2,887 Yale BSC stars) + seeded procedural Milky Way for the configured lat/long (default Toledo-PR). Static layer pre-rendered offscreen, bright stars twinkle per frame; reprojects every 30 s. Debug: `window.__skyTimeOffset` (ms) fast-forwards the sky | +| Realistic sky (stars + Milky Way) | ``; real-time alt-az projection of `starcatalog.js` (2,887 Yale BSC stars) + per-pixel sampling of the real Gaia all-sky map (`milkyway.jpg`) in galactic coords, for the configured lat/long (default Toledo-PR), view facing east. Static layer pre-rendered offscreen, bright stars twinkle per frame; reprojects every 30 s. Debug: `window.__skyTimeOffset` (ms) fast-forwards the sky | | Nebula blobs, grid floor, horizon | CSS animations | | DANCEBOT-9000 robot | Pure CSS (divs + `@keyframes`) | | Chest EQ bars & VU meter | JS-driven `requestAnimationFrame` | diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html index 53e5f14..1c1a9d6 100644 --- a/plugin/contents/ui/dancer.html +++ b/plugin/contents/ui/dancer.html @@ -1264,161 +1264,99 @@ function glareFactor(x, y, W, H, on) { return Math.max(0, Math.min(1, (d - 150) / 240)); } -/* ── Via Láctea: partículas geradas em coords galácticas (RNG com semente, - estável entre reloads) e convertidas p/ equatorial J2000 uma única vez ── */ -function mulberry32(seed) { - return function () { - seed |= 0; seed = seed + 0x6D2B79F5 | 0; - let t = Math.imul(seed ^ seed >>> 15, 1 | seed); - t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; - return ((t ^ t >>> 14) >>> 0) / 4294967296; - }; -} +/* ── Via Láctea REAL: mapa all-sky do Gaia ("Gaia's sky in colour", + ESA/Gaia/DPAC, CC BY-SA 3.0 IGO — reprojetado por tools/build_skymap.py). + Cada pixel do céu é amostrado em (l,b) galácticas reais via tempo sideral: + é a banda fotografada, nascendo/girando/se pondo com o céu de verdade ── */ +const MW_INTENSITY = 0.62; // ganho global da banda +const MW_BLACK = 52; // nível de preto (remove o véu cinza do mapa) +const MW_SCALE = 4; // amostra a 1/4 da resolução (difuso -> upscale ok) -/* Transposta da matriz equatorial->galáctica (Hipparcos, J2000) */ -const G2E = [ - [-0.0548755604, 0.4941094279, -0.8676661490], - [-0.8734370902, -0.4448296300, -0.1980763734], - [-0.4838350155, 0.7469822445, 0.4559837762], +/* Matriz equatorial -> galáctica (Hipparcos, J2000) */ +const E2G = [ + [-0.0548755604, -0.8734370902, -0.4838350155], + [ 0.4941094279, -0.4448296300, 0.7469822445], + [-0.8676661490, -0.1980763734, 0.4559837762], ]; -/* Visual de astrofoto de longa exposição: grão estelar fino + brilho difuso - com gradiente térmico (núcleo âmbar -> anticentro azul) + faixas de poeira */ -function galToEq(l, b) { - const cb = Math.cos(b * DEG); - const gx = cb * Math.cos(l * DEG), gy = cb * Math.sin(l * DEG), gz = Math.sin(b * DEG); - return { - ra: (Math.atan2( - G2E[1][0] * gx + G2E[1][1] * gy + G2E[1][2] * gz, - G2E[0][0] * gx + G2E[0][1] * gy + G2E[0][2] * gz) / DEG + 360) % 360, - dec: Math.asin(G2E[2][0] * gx + G2E[2][1] * gy + G2E[2][2] * gz) / DEG, - }; -} - -/* Cor por distância angular ao centro galáctico: creme-âmbar -> neutro -> azul */ -function mwColor(dl) { - const t = Math.min(1, dl / 140); - const mix = (a, b2, c) => Math.round(t < 0.5 ? a + (b2 - a) * t * 2 : b2 + (c - b2) * (t - 0.5) * 2); - return [mix(255, 236, 186), mix(226, 224, 205), mix(188, 214, 242)]; -} - -/* Nuvens escuras discretas em posições reais aproximadas - (Saco de Carvão junto ao Cruzeiro, região do Cachimbo em Ofiúco...) */ -const MW_DARKS = [ - { l: 303, b: -1.5, rl: 3.5, rb: 3.0, s: 0.85 }, // Saco de Carvão - { l: 357, b: 4.5, rl: 5.0, rb: 2.2, s: 0.80 }, // Cachimbo (Ofiúco) - { l: 31, b: -1.0, rl: 4.0, rb: 1.8, s: 0.75 }, - { l: 16, b: 2.5, rl: 3.0, rb: 1.6, s: 0.70 }, - { l: 344, b: -2.5, rl: 3.2, rb: 1.8, s: 0.70 }, -]; - -/* Contribuição de uma faixa de poeira com borda suave (0 = fora, s = centro) */ -function laneDark(b, center, halfw, strength) { - const d = Math.abs(b - center) / Math.max(halfw, 0.3); - return d >= 1 ? 0 : strength * (1 - d * d) ** 2; -} - -/* Poeira: 1 = céu livre, ->0.08 = poeira densa. Caminhos e larguras - irregulares (senos incomensuráveis) + escuridão em manchas, não uniforme */ -function mwDust(l, b, dl) { - if (dl > 105) return 1; - const fade = Math.min(1, (105 - dl) / 35); // fenda some longe do centro - const x = l * DEG; - const wob1 = Math.sin(x * 2.2) + 0.6 * Math.sin(x * 4.9 + 1.7) + - 0.35 * Math.sin(x * 9.3 + 0.6); - const wob2 = Math.sin(x * 1.4 + 1.7) + 0.6 * Math.sin(x * 3.7 + 4.2); - const patch1 = 0.62 + 0.38 * Math.sin(x * 1.1 + 2.9) * Math.sin(x * 2.6 + 0.4); - const patch2 = 0.55 + 0.45 * Math.sin(x * 0.9 + 5.1); - let dark = laneDark(b, 0.9 * wob1, 2.1 + 0.9 * Math.sin(x * 3.3 + 2.2), - 0.9 * patch1); - dark = Math.max(dark, laneDark(b, -2.4 + 0.7 * wob2, - 1.4 + 0.7 * Math.sin(x * 2.8 + 1.1), - 0.8 * patch2)); - for (const d of MW_DARKS) { - const dlc = Math.min(Math.abs(l - d.l), 360 - Math.abs(l - d.l)); - const dd = (dlc / d.rl) ** 2 + ((b - d.b) / d.rb) ** 2; - if (dd < 1) dark = Math.max(dark, d.s * (1 - dd)); - } - return 1 - Math.min(0.92, dark * fade); -} - -/* Nuvens estelares (Scutum, Sagitário, Carina...) — adensamentos fixos na banda */ -const MW_CLOUDS = []; -const mwGlow = []; // blobs difusos {ra,dec,size,alpha,ci} -const mwGrain = []; // grão estelar fino {ra,dec,alpha,rgb} -(function buildMilkyWay() { - const rand = mulberry32(0x5EEDED); - const gauss = () => { - const u = Math.max(rand(), 1e-9), v = rand(); - return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); - }; - for (let i = 0; i < 14; i++) { - const l = (rand() * 160 - 80 + 360) % 360; // nuvens no lado do centro - MW_CLOUDS.push({ l, b: gauss() * 2.5, r: 2 + rand() * 4 }); - } - const cloudBoost = (l, b) => { - let boost = 0; - for (const c of MW_CLOUDS) { - const dlc = Math.min(Math.abs(l - c.l), 360 - Math.abs(l - c.l)); - const d2 = (dlc / c.r) ** 2 + ((b - c.b) / c.r) ** 2; - if (d2 < 1) boost = Math.max(boost, 1 - d2); - } - return boost; - }; - - let guard = 0; - while (mwGlow.length < 3600 && guard++ < 300000) { - const l = rand() * 360; - const dl = Math.min(l, 360 - l); - const central = Math.exp(-((dl / 75) ** 2)); - if (rand() > 0.28 + 0.72 * central) continue; - /* bojo central: banda mais gorda perto do núcleo */ - const b = gauss() * (5 + 3 * central + 4 * Math.exp(-((dl / 22) ** 2))); - const dust = mwDust(l, b, dl); - const boost = cloudBoost(l, b); - const alpha = (0.35 + 0.65 * central) * dust * (1 + 1.2 * boost) * - (0.028 + rand() * 0.05); - if (alpha < 0.005) continue; - const eq = galToEq(l, b); - mwGlow.push({ ra: eq.ra, dec: eq.dec, size: 14 + rand() * 30, - alpha, ci: mwColor(dl) }); - } - - guard = 0; - while (mwGrain.length < 9500 && guard++ < 500000) { - const l = rand() * 360; - const dl = Math.min(l, 360 - l); - const central = Math.exp(-((dl / 80) ** 2)); - if (rand() > 0.22 + 0.78 * central) continue; - const b = gauss() * (4.5 + 2.5 * central + 3.5 * Math.exp(-((dl / 22) ** 2))); - const dust = mwDust(l, b, dl); - if (rand() > dust + 0.06) continue; // poeira esconde o grão - const boost = cloudBoost(l, b); - if (boost < 0.3 && rand() < 0.25 * central) continue; - const eq = galToEq(l, b); - mwGrain.push({ ra: eq.ra, dec: eq.dec, - alpha: 0.10 + rand() * 0.30 + 0.2 * boost, - ci: mwColor(dl) }); - } -})(); - -/* Sprites do brilho difuso: quente / neutro / frio (escolhido por cor) */ -function makeMwSprite(r, g, b) { +const mwTex = { data: null, w: 0, h: 0 }; +const mwImg = new Image(); +mwImg.onload = () => { const c = document.createElement('canvas'); - c.width = c.height = 64; - const s = c.getContext('2d'); - const grad = s.createRadialGradient(32, 32, 0, 32, 32, 32); - grad.addColorStop(0, `rgba(${r},${g},${b},1)`); - grad.addColorStop(0.5, `rgba(${r},${g},${b},0.4)`); - grad.addColorStop(1, `rgba(${r},${g},${b},0)`); - s.fillStyle = grad; - s.fillRect(0, 0, 64, 64); - return c; + c.width = mwImg.width; c.height = mwImg.height; + const s = c.getContext('2d', { willReadFrequently: true }); + s.drawImage(mwImg, 0, 0); + mwTex.data = s.getImageData(0, 0, c.width, c.height).data; + mwTex.w = c.width; mwTex.h = c.height; + projectSky(); // primeira renderização já com a textura +}; +mwImg.src = 'milkyway.jpg'; + +/* Grade pré-computada por pixel (a 1/MW_SCALE): vetor equatorial no frame + LST=0 + extinção + clarão. Por passo só muda a rotação sideral (Rz) */ +const mwCanvas = document.createElement('canvas'); +let mwGrid = null; +function buildMwGrid() { + const W = canvas.width, H = canvas.height; + if (!W || !H) { mwGrid = null; return; } + const horizonPx = H * HORIZON_F; + const gw = Math.ceil(W / MW_SCALE), gh = Math.ceil(horizonPx / MW_SCALE); + const n = gw * gh; + const wx = new Float32Array(n), wy = new Float32Array(n), wz = new Float32Array(n); + const ext = new Float32Array(n), glr = new Float32Array(n); + const cl = Math.cos(obsLat * DEG), sl = Math.sin(obsLat * DEG); + let i = 0; + for (let gy = 0; gy < gh; gy++) { + const sy = (gy + 0.5) * MW_SCALE; + const alt = (1 - sy / horizonPx) * ALT_TOP; + const ca = Math.cos(alt * DEG), sa = Math.sin(alt * DEG); + for (let gx = 0; gx < gw; gx++, i++) { + const sx = (gx + 0.5) * MW_SCALE; + const az = (VIEW_AZ + (sx / W - 0.5) * FOV_AZ) * DEG; + const xh = ca * Math.cos(az), yh = ca * Math.sin(az); + wx[i] = -sl * xh + cl * sa; // cosδ·cosH + wy[i] = -yh; // cosδ·sinH + wz[i] = cl * xh + sl * sa; // sinδ + ext[i] = extinction(alt); + glr[i] = glareFactor(sx, sy, W, H, true); + } + } + mwCanvas.width = gw; mwCanvas.height = gh; + mwGrid = { w: gw, h: gh, wx, wy, wz, ext, glare: glr, + img: mwCanvas.getContext('2d').createImageData(gw, gh) }; +} + +/* Amostra o mapa galáctico para o LST dado e pinta o mwCanvas */ +function renderMilkyWay(lstDeg, glareOn) { + if (!mwGrid || !mwTex.data) return false; + const { wx, wy, wz, ext, glare, img } = mwGrid; + const cL = Math.cos(lstDeg * DEG), sL = Math.sin(lstDeg * DEG); + /* galáctico = E2G · Rz\'(LST), com RA = LST − H */ + const m00 = E2G[0][0] * cL + E2G[0][1] * sL, m01 = E2G[0][0] * sL - E2G[0][1] * cL, m02 = E2G[0][2]; + const m10 = E2G[1][0] * cL + E2G[1][1] * sL, m11 = E2G[1][0] * sL - E2G[1][1] * cL, m12 = E2G[1][2]; + const m20 = E2G[2][0] * cL + E2G[2][1] * sL, m21 = E2G[2][0] * sL - E2G[2][1] * cL, m22 = E2G[2][2]; + const tw = mwTex.w, thh = mwTex.h, tex = mwTex.data, px = img.data; + const INV2PI = 1 / (2 * Math.PI), INVPI = 1 / Math.PI, HPI = Math.PI / 2; + for (let i = 0, j = 0; i < wx.length; i++, j += 4) { + const gx2 = m00 * wx[i] + m01 * wy[i] + m02 * wz[i]; + const gy2 = m10 * wx[i] + m11 * wy[i] + m12 * wz[i]; + const gz2 = m20 * wx[i] + m21 * wy[i] + m22 * wz[i]; + const l = Math.atan2(gy2, gx2); // rad, + p/ esquerda no mapa + const b = Math.asin(Math.max(-1, Math.min(1, gz2))); + let tx = ((Math.PI - l) * INV2PI * tw) | 0; + let ty = ((HPI - b) * INVPI * thh) | 0; + if (tx >= tw) tx = tw - 1; + if (ty >= thh) ty = thh - 1; + const k = (ty * tw + tx) * 4; + const f = ext[i] * (glareOn ? glare[i] : 1) * MW_INTENSITY; + px[j] = Math.max(0, tex[k] - MW_BLACK) * f; + px[j + 1] = Math.max(0, tex[k + 1] - MW_BLACK) * f; + px[j + 2] = Math.max(0, tex[k + 2] - MW_BLACK) * f; + px[j + 3] = 255; + } + mwCanvas.getContext('2d').putImageData(img, 0, 0); + return true; } -const mwSprites = [makeMwSprite(255, 226, 188), // quente (núcleo) - makeMwSprite(236, 224, 214), // neutro - makeMwSprite(188, 214, 242)]; // frio (anticentro) -function spriteFor(ci) { return mwSprites[ci[2] > ci[0] ? 2 : (ci[0] > 240 ? 0 : 1)]; } /* ── Passo de projeção: recalcula posições de tela (a cada 30s o céu gira ~0.125°, imperceptível). Camada estática (Via Láctea + estrelas fracas) @@ -1439,27 +1377,12 @@ function projectSky() { !document.body.classList.contains('chuva'); twinkList = []; - bctx.globalCompositeOperation = 'lighter'; - for (const p of mwGlow) { - const aa = altAz(p.ra, p.dec, lst); - const sc = skyToScreen(aa.alt, aa.az, W, horizonPx); - if (!sc) continue; - const a = p.alpha * extinction(aa.alt) * glareFactor(sc.x, sc.y, W, H, glareOn); - if (a < 0.002) continue; - bctx.globalAlpha = a; - bctx.drawImage(spriteFor(p.ci), sc.x - p.size / 2, sc.y - p.size / 2, p.size, p.size); + if (renderMilkyWay(lst, glareOn)) { + bctx.globalCompositeOperation = 'lighter'; + bctx.imageSmoothingEnabled = true; + bctx.drawImage(mwCanvas, 0, 0, W, horizonPx); + bctx.globalCompositeOperation = 'source-over'; } - bctx.globalAlpha = 1; - for (const p of mwGrain) { - const aa = altAz(p.ra, p.dec, lst); - const sc = skyToScreen(aa.alt, aa.az, W, horizonPx); - if (!sc) continue; - const a = p.alpha * extinction(aa.alt) * glareFactor(sc.x, sc.y, W, H, glareOn); - if (a < 0.01) continue; - bctx.fillStyle = `rgba(${p.ci[0]},${p.ci[1]},${p.ci[2]},${a.toFixed(3)})`; - bctx.fillRect(sc.x - 0.5, sc.y - 0.5, 1, 1); - } - bctx.globalCompositeOperation = 'source-over'; for (let i = 0; i < STAR_DATA.length; i++) { const [ra, dec, mag, ci] = STAR_DATA[i]; @@ -1498,12 +1421,14 @@ function setObserverLocation(lat, lon) { if (lat === obsLat && lon === obsLon) return; obsLat = lat; obsLon = lon; + buildMwGrid(); // grade depende da latitude projectSky(); } function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; + buildMwGrid(); projectSky(); } diff --git a/plugin/contents/ui/milkyway.jpg b/plugin/contents/ui/milkyway.jpg new file mode 100644 index 0000000..c14b1f7 Binary files /dev/null and b/plugin/contents/ui/milkyway.jpg differ diff --git a/tools/build_skymap.py b/tools/build_skymap.py new file mode 100644 index 0000000..08a4f70 --- /dev/null +++ b/tools/build_skymap.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Gera plugin/contents/ui/milkyway.jpg a partir do mapa all-sky do Gaia. + +Fonte: "Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO), 8000x4000, +projeção HAMMER em coordenadas galácticas (elipse, centro l=0/b=0, l crescendo +para a esquerda, b=+90 no topo). Este script reprojeta para plate carrée +(mesma convenção de l/b) e reduz para textura leve. + +O dancer.html amostra a textura por pixel via (l, b) reais — a banda é a +Via Láctea fotografada de verdade, projetada em tempo real. + +Requer Pillow e numpy. Uso: python3 tools/build_skymap.py +""" + +import io +import math +import urllib.request +from pathlib import Path + +import numpy as np +from PIL import Image + +URL = ("https://upload.wikimedia.org/wikipedia/commons/e/ea/" + "Gaia%E2%80%99s_sky_in_colour_ESA393127.png") +CACHE = Path.home() / ".cache/skeledance/gaia_allsky.png" +OUT = Path(__file__).resolve().parent.parent / "plugin/contents/ui/milkyway.jpg" +SIZE = (3072, 1536) # textura final (plate carrée 2:1) +OVERSAMPLE = 2 # reprojeta a 2x e reduz com LANCZOS (anti-aliasing) +QUALITY = 85 + + +def fetch() -> bytes: + if CACHE.exists(): + print(f"Usando cache {CACHE}") + return CACHE.read_bytes() + print(f"Baixando {URL} ...") + req = urllib.request.Request(URL, headers={"User-Agent": "skeledance-build/1.0"}) + raw = urllib.request.urlopen(req, timeout=180).read() + CACHE.parent.mkdir(parents=True, exist_ok=True) + CACHE.write_bytes(raw) + print(f" {len(raw) / 1e6:.1f} MB (cacheado)") + return raw + + +def main(): + Image.MAX_IMAGE_PIXELS = None + src = np.asarray(Image.open(io.BytesIO(fetch())).convert("RGB")) + sh, sw = src.shape[:2] + + tw, th = SIZE[0] * OVERSAMPLE, SIZE[1] * OVERSAMPLE + # Grade de saída plate carrée: l = 180 -> -180 (esq -> dir), b = 90 -> -90 + l = np.deg2rad(180 - (np.arange(tw) + 0.5) * 360 / tw) + b = np.deg2rad(90 - (np.arange(th) + 0.5) * 180 / th) + L, B = np.meshgrid(l, b) + + # Projeção Hammer direta -> posição na imagem-fonte + denom = np.sqrt(1 + np.cos(B) * np.cos(L / 2)) + x = 2 * math.sqrt(2) * np.cos(B) * np.sin(L / 2) / denom # + = esquerda + y = math.sqrt(2) * np.sin(B) / denom # + = cima + sx = np.clip(((1 - x / (2 * math.sqrt(2))) * sw / 2).astype(int), 0, sw - 1) + sy = np.clip(((1 - y / math.sqrt(2)) * sh / 2).astype(int), 0, sh - 1) + + out = Image.fromarray(src[sy, sx]) + out = out.resize(SIZE, Image.LANCZOS) + out.save(OUT, "JPEG", quality=QUALITY, optimize=True) + print(f"{OUT} ({OUT.stat().st_size / 1024:.0f} KB, {SIZE[0]}x{SIZE[1]})") + + +if __name__ == "__main__": + main()