diff --git a/CLAUDE.md b/CLAUDE.md index eb18254..48d7e47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,10 +34,8 @@ 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 @@ -52,7 +50,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) + 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 | +| 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 | | 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 1c1a9d6..53e5f14 100644 --- a/plugin/contents/ui/dancer.html +++ b/plugin/contents/ui/dancer.html @@ -1264,99 +1264,161 @@ function glareFactor(x, y, W, H, on) { return Math.max(0, Math.min(1, (d - 150) / 240)); } -/* ── 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) +/* ── 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; + }; +} -/* 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], +/* 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], ]; -const mwTex = { data: null, w: 0, h: 0 }; -const mwImg = new Image(); -mwImg.onload = () => { - const c = document.createElement('canvas'); - 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'; +/* 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, + }; +} -/* 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); +/* 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); } - } - mwCanvas.width = gw; mwCanvas.height = gh; - mwGrid = { w: gw, h: gh, wx, wy, wz, ext, glare: glr, - img: mwCanvas.getContext('2d').createImageData(gw, gh) }; -} + return boost; + }; -/* 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; + 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) }); } - mwCanvas.getContext('2d').putImageData(img, 0, 0); - return true; + + 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 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; } +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) @@ -1377,12 +1439,27 @@ function projectSky() { !document.body.classList.contains('chuva'); twinkList = []; - if (renderMilkyWay(lst, glareOn)) { - bctx.globalCompositeOperation = 'lighter'; - bctx.imageSmoothingEnabled = true; - bctx.drawImage(mwCanvas, 0, 0, W, horizonPx); - bctx.globalCompositeOperation = 'source-over'; + 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); } + 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]; @@ -1421,14 +1498,12 @@ 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 deleted file mode 100644 index c14b1f7..0000000 Binary files a/plugin/contents/ui/milkyway.jpg and /dev/null differ diff --git a/tools/build_skymap.py b/tools/build_skymap.py deleted file mode 100644 index 08a4f70..0000000 --- a/tools/build_skymap.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/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()