diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html
index 2129cda..2cfa64d 100644
--- a/plugin/contents/ui/dancer.html
+++ b/plugin/contents/ui/dancer.html
@@ -1264,195 +1264,133 @@ function glareFactor(x, y, W, H, on) {
return Math.max(0, Math.min(1, (d - 150) / 240));
}
-/* 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],
];
-/* ── Via Láctea procedural contínua ──
- A textura é calculada no espaço galáctico em uma camada de baixa resolução.
- Assim a faixa, o bojo e a poeira acompanham a projeção real sem denunciar
- sprites circulares ou custar um passe em resolução cheia. */
-const MW_RENDER_SCALE = 0.50;
-const MW_NOISE_PERIODS = [5, 10, 20, 40, 80];
-const mwLayer = document.createElement('canvas');
+/* ── Via Láctea fotográfica em alta resolução ──
+ "Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO), reprojetada
+ para plate carrée por tools/build_skymap.py. A geometria é real; apenas
+ exposição, contraste e saturação são estilizados para o wallpaper. */
+const MW_BLACK = 46;
+const MW_EXPOSURE = 1.16;
+const MW_SAMPLE_SCALE = 1;
+const mwTex = { data: null, w: 0, h: 0 };
+const mwCanvas = document.createElement('canvas');
+let mwGrid = null;
-function mwHash(ix, iy) {
- let h = Math.imul(ix, 374761393) + Math.imul(iy, 668265263);
- h = Math.imul(h ^ (h >>> 13), 1274126177);
- return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
-}
+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();
+};
+mwImg.onerror = () => console.error('[skeledance] falha ao carregar milkyway.jpg');
+mwImg.src = 'milkyway.jpg';
-function mwValueNoise(x, y, periodX) {
- const fx = Math.floor(x), fy = Math.floor(y);
- const tx0 = x - fx, ty0 = y - fy;
- const tx = tx0 * tx0 * (3 - 2 * tx0);
- const ty = ty0 * ty0 * (3 - 2 * ty0);
- const x0 = ((fx % periodX) + periodX) % periodX;
- const x1 = (x0 + 1) % periodX;
- const a = mwHash(x0, fy);
- const b = mwHash(x1, fy);
- const c = mwHash(x0, fy + 1);
- const d = mwHash(x1, fy + 1);
- const top = a + (b - a) * tx;
- const bot = c + (d - c) * tx;
- return top + (bot - top) * ty;
-}
-
-/* Ruído periódico em longitude: não deixa emenda em l=0°/360°. */
-function mwFbm(l, b) {
- let value = 0, norm = 0, amp = 1;
- for (const period of MW_NOISE_PERIODS) {
- value += amp * mwValueNoise(l / 360 * period, (b + 90) / 360 * period, period);
- norm += amp;
- amp *= 0.50;
- }
- return value / norm;
-}
-
-function mwFineNoise(l, b) {
- const n1 = mwValueNoise(l / 360 * 160, (b + 90) / 360 * 160, 160);
- const n2 = mwValueNoise(l / 360 * 320, (b + 90) / 360 * 320, 320);
- return n1 * 0.68 + n2 * 0.32;
-}
-
-function mwAngularDistance(l, target) {
- const d = Math.abs(l - target);
- return Math.min(d, 360 - d);
-}
-
-function mwCloud(l, b, cl, cb, wl, wb, strength) {
- const x = mwAngularDistance(l, cl) / wl;
- const y = (b - cb) / wb;
- return strength * Math.exp(-0.5 * (x * x + y * y));
-}
-
-/* Densidade luminosa em coordenadas galácticas. O detalhe vem do ruído,
- enquanto as formas grandes continuam astronomicamente reconhecíveis. */
-function mwDensity(l, b) {
- const dl = Math.min(l, 360 - l);
- const core = Math.exp(-((dl / 72) ** 2));
- const cloud = mwFbm(l, b);
- const fine = mwFineNoise(l, b);
- const warpedB = b - (cloud - 0.5) * (2.2 + 1.8 * core);
- const width = 3.7 + 5.8 * core + 1.3 * cloud;
- const plane = Math.exp(-0.5 * (warpedB / width) ** 2);
-
- /* Luz integrada: halo largo, nuvens irregulares e grão fino de estrelas. */
- let density = plane * (0.20 + 1.45 * cloud ** 2.1);
- density += 0.12 * Math.exp(-0.5 * (warpedB / (width * 2.9)) ** 2) *
- (0.35 + 0.65 * cloud);
- density += plane * Math.max(0, fine - 0.46) ** 2 * 3.8;
-
- /* Grandes nuvens estelares reais, exageradas como numa longa exposição. */
- density += mwCloud(l, b, 0, 0.0, 20, 11, 1.55); // bojo de Sagitário
- density += mwCloud(l, b, 25, 0.5, 10, 4, 0.62); // Scutum
- density += mwCloud(l, b, 330, -0.7, 11, 5, 0.52);
- density += mwCloud(l, b, 285, -0.4, 13, 5, 0.55); // Carina
- density += mwCloud(l, b, 80, 0.8, 12, 4, 0.38); // Cygnus
-
- /* Grande Fenda: duas trilhas estreitas, deformadas e com força irregular. */
- const riftReach = 1 - Math.max(0, Math.min(1, (dl - 70) / 55));
- const lr = l * DEG;
- const riftA = 0.6 + 1.55 * Math.sin(lr * 2.1 + 0.5) + 0.55 * Math.sin(lr * 7.0);
- const riftB = -2.5 + 0.85 * Math.sin(lr * 1.4 + 2.2);
- const darkA = Math.exp(-0.5 * ((b - riftA) / (0.85 + 0.75 * fine)) ** 2) *
- (0.58 + 0.22 * fine);
- const darkB = Math.exp(-0.5 * ((b - riftB) / 1.10) ** 2) * 0.44;
- /* Poeira reduz contraste, mas nunca perfura a faixa luminosa. */
- density *= 1 - Math.min(0.38, (darkA + darkB) * riftReach);
- density += plane * (0.045 + 0.075 * fine);
-
- /* Saco de Carvão junto ao Cruzeiro do Sul. */
- const coal = Math.exp(-0.5 * ((mwAngularDistance(l, 303) / 3.8) ** 2 +
- ((b + 1.5) / 2.8) ** 2));
- density *= 1 - 0.38 * coal;
-
- return Math.max(0, density * (0.36 + 0.64 * Math.exp(-((dl / 110) ** 2))));
-}
-
-function mwWriteRgb(pixels, offset, l, b) {
- const dl = Math.min(l, 360 - l);
- const t = Math.min(1, dl / 150);
- const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
- let r, g, bl;
- if (t < 0.5) {
- r = 255 + (210 - 255) * u;
- g = 178 + (181 - 178) * u;
- bl = 112 + (246 - 112) * u;
- } else {
- r = 210 + (105 - 210) * u;
- g = 181 + (197 - 181) * u;
- bl = 246 + (255 - 246) * u;
- }
- /* Pequena separação cromática transversal: âmbar/magenta de um lado,
- azul-ciano do outro, como numa astrofoto com exposição puxada. */
- const tint = 0.5 + 0.5 * Math.sin(l * DEG * 1.7 + b * DEG * 8.0 + 1.1);
- const edge = Math.min(1, Math.abs(b) / 14);
- r += 24 * (1 - tint) * (0.35 + edge);
- g -= 12 * (1 - tint) * edge;
- bl += 18 * tint * (0.35 + edge);
- pixels[offset] = Math.max(0, Math.min(255, r));
- pixels[offset + 1] = Math.max(0, Math.min(255, g));
- pixels[offset + 2] = Math.max(0, Math.min(255, bl));
-}
-
-function renderMilkyWay(W, H, horizonPx, lst, glareOn) {
- const rw = Math.max(1, Math.ceil(W * MW_RENDER_SCALE));
- const rh = Math.max(1, Math.ceil(horizonPx * MW_RENDER_SCALE));
- if (mwLayer.width !== rw || mwLayer.height !== rh) {
- mwLayer.width = rw;
- mwLayer.height = rh;
- }
- const mctx = mwLayer.getContext('2d');
- const image = mctx.createImageData(rw, rh);
- const pixels = image.data;
- const sl = Math.sin(obsLat * DEG), cl = Math.cos(obsLat * DEG);
- const lstRad = lst * DEG, cosLst = Math.cos(lstRad), sinLst = Math.sin(lstRad);
-
- const azSin = new Float32Array(rw), azCos = new Float32Array(rw);
- for (let x = 0; x < rw; x++) {
- const az = (VIEW_AZ + ((x + 0.5) / rw - 0.5) * FOV_AZ) * DEG;
- azSin[x] = Math.sin(az);
- azCos[x] = Math.cos(az);
- }
-
- let offset = 0;
- for (let y = 0; y < rh; y++) {
- const screenY = (y + 0.5) / rh * horizonPx;
- const alt = ALT_TOP * (1 - screenY / horizonPx);
- const sa = Math.sin(alt * DEG), ca = Math.cos(alt * DEG);
- const ext = extinction(alt);
- for (let x = 0; x < rw; x++, offset += 4) {
- const north = ca * azCos[x], east = ca * azSin[x];
- const hourX = -north * sl + sa * cl;
- const ez = north * cl + sa * sl;
- const ex = cosLst * hourX - sinLst * east;
- const ey = sinLst * hourX + cosLst * east;
-
- /* E2G = transposta de G2E. */
- const gx = G2E[0][0] * ex + G2E[1][0] * ey + G2E[2][0] * ez;
- const gy = G2E[0][1] * ex + G2E[1][1] * ey + G2E[2][1] * ez;
- const gz = G2E[0][2] * ex + G2E[1][2] * ey + G2E[2][2] * ez;
- const l = (Math.atan2(gy, gx) / DEG + 360) % 360;
- const b = Math.asin(Math.max(-1, Math.min(1, gz))) / DEG;
- if (Math.abs(b) > 35) continue;
-
- const density = mwDensity(l, b);
- if (density < 0.012) continue;
- const screenX = (x + 0.5) / rw * W;
- const glare = glareFactor(screenX, screenY, W, H, glareOn);
- /* Curva de exposição comprime o núcleo sem apagar estruturas tênues. */
- const exposure = 1 - Math.exp(-density * 0.72);
- const alpha = Math.min(0.46, exposure * 0.42) * ext * glare;
- if (alpha < 0.002) continue;
- mwWriteRgb(pixels, offset, l, b);
- pixels[offset + 3] = alpha * 255;
+/* Vetores do viewport em LST=0. O passo de 30 s só aplica uma rotação e
+ amostra a fotografia; trigonometria e efeitos de tela ficam em cache. */
+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_SAMPLE_SCALE);
+ const gh = Math.ceil(horizonPx / MW_SAMPLE_SCALE);
+ const n = gw * gh;
+ const wx = new Float32Array(n), wy = new Float32Array(n), wz = new Float32Array(n);
+ const ext = new Float32Array(n), glare = 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_SAMPLE_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_SAMPLE_SCALE;
+ const az = (VIEW_AZ + (sx / W - 0.5) * FOV_AZ) * DEG;
+ const north = ca * Math.cos(az), east = ca * Math.sin(az);
+ wx[i] = -sl * north + cl * sa;
+ wy[i] = -east;
+ wz[i] = cl * north + sl * sa;
+ ext[i] = extinction(alt);
+ glare[i] = glareFactor(sx, sy, W, H, true);
}
}
- mctx.putImageData(image, 0, 0);
+ mwCanvas.width = gw;
+ mwCanvas.height = gh;
+ mwGrid = { w: gw, h: gh, wx, wy, wz, ext, glare,
+ img: mwCanvas.getContext('2d').createImageData(gw, gh) };
+}
+
+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);
+ const m00 = E2G[0][0] * cL + E2G[0][1] * sL;
+ const m01 = E2G[0][0] * sL - E2G[0][1] * cL;
+ const m02 = E2G[0][2];
+ const m10 = E2G[1][0] * cL + E2G[1][1] * sL;
+ const m11 = E2G[1][0] * sL - E2G[1][1] * cL;
+ const m12 = E2G[1][2];
+ const m20 = E2G[2][0] * cL + E2G[2][1] * sL;
+ const m21 = E2G[2][0] * sL - E2G[2][1] * cL;
+ const m22 = E2G[2][2];
+ const tw = mwTex.w, th = mwTex.h, tex = mwTex.data, px = img.data;
+ const inv2pi = 1 / (2 * Math.PI), invpi = 1 / Math.PI;
+
+ for (let i = 0, j = 0; i < wx.length; i++, j += 4) {
+ const gx = m00 * wx[i] + m01 * wy[i] + m02 * wz[i];
+ const gy = m10 * wx[i] + m11 * wy[i] + m12 * wz[i];
+ const gz = m20 * wx[i] + m21 * wy[i] + m22 * wz[i];
+ const l = Math.atan2(gy, gx);
+ const b = Math.asin(Math.max(-1, Math.min(1, gz)));
+ const absB = Math.abs(b / DEG);
+ if (absB >= 42) { px[j + 3] = 0; continue; }
+
+ let tx = ((Math.PI - l) * inv2pi * tw) | 0;
+ let ty = ((Math.PI / 2 - b) * invpi * th) | 0;
+ if (tx >= tw) tx = tw - 1;
+ if (ty >= th) ty = th - 1;
+ const k = (ty * tw + tx) * 4;
+ const sr = tex[k], sg = tex[k + 1], sb = tex[k + 2];
+ const lum = sr * 0.2126 + sg * 0.7152 + sb * 0.0722;
+ let signal = (lum - MW_BLACK) / (255 - MW_BLACK);
+ if (signal <= 0) { px[j + 3] = 0; continue; }
+ signal = Math.pow(Math.min(1, signal), 0.72);
+
+ let mask = 1;
+ if (absB > 18) {
+ mask = (42 - absB) / 24;
+ mask = mask * mask * (3 - 2 * mask);
+ }
+ const alpha = Math.min(1, signal * MW_EXPOSURE) * ext[i] *
+ (glareOn ? glare[i] : 1) * mask;
+ if (alpha < 0.004) { px[j + 3] = 0; continue; }
+
+ /* Revelação digital: preto profundo, saturação cromática e sombras frias. */
+ let r = Math.max(0, sr - 34) * 1.58;
+ let g = Math.max(0, sg - 34) * 1.58;
+ let bl = Math.max(0, sb - 34) * 1.58;
+ const outLum = r * 0.2126 + g * 0.7152 + bl * 0.0722;
+ r = outLum + (r - outLum) * 1.48 + 10 * (1 - signal) + 16 * signal;
+ g = outLum + (g - outLum) * 1.48;
+ bl = outLum + (bl - outLum) * 1.48 + 24 * (1 - signal);
+ px[j] = Math.max(0, Math.min(255, r));
+ px[j + 1] = Math.max(0, Math.min(255, g));
+ px[j + 2] = Math.max(0, Math.min(255, bl));
+ px[j + 3] = alpha * 255;
+ }
+ mwCanvas.getContext('2d').putImageData(img, 0, 0);
+ return true;
}
/* ── Passo de projeção: recalcula posições de tela (a cada 30s o céu gira
@@ -1474,18 +1412,19 @@ function projectSky() {
!document.body.classList.contains('chuva');
twinkList = [];
- renderMilkyWay(W, H, horizonPx, lst, glareOn);
- bctx.imageSmoothingEnabled = true;
- bctx.imageSmoothingQuality = 'high';
- bctx.save();
- bctx.globalCompositeOperation = 'lighter';
- bctx.globalAlpha = 0.40;
- bctx.filter = `blur(${Math.max(10, H * 0.014)}px) saturate(180%)`;
- bctx.drawImage(mwLayer, 0, 0, W, horizonPx);
- bctx.globalAlpha = 0.96;
- bctx.filter = 'contrast(122%) saturate(150%)';
- bctx.drawImage(mwLayer, 0, 0, W, horizonPx);
- bctx.restore();
+ if (renderMilkyWay(lst, glareOn)) {
+ bctx.imageSmoothingEnabled = true;
+ bctx.imageSmoothingQuality = 'high';
+ bctx.save();
+ bctx.globalCompositeOperation = 'lighter';
+ bctx.globalAlpha = 0.22;
+ bctx.filter = `blur(${Math.max(7, H * 0.009)}px) saturate(150%)`;
+ bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
+ bctx.globalAlpha = 0.94;
+ bctx.filter = 'contrast(116%) saturate(158%)';
+ bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
+ bctx.restore();
+ }
for (let i = 0; i < STAR_DATA.length; i++) {
const [ra, dec, mag, ci] = STAR_DATA[i];
@@ -1524,12 +1463,14 @@ function setObserverLocation(lat, lon) {
if (lat === obsLat && lon === obsLon) return;
obsLat = lat;
obsLon = lon;
+ buildMwGrid();
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..8c7a411
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..a96447d
--- /dev/null
+++ b/tools/build_skymap.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Gera a textura galáctica de alta resolução usada pelo wallpaper.
+
+Fonte: "Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO),
+8000x4000 em projeção Hammer e coordenadas galácticas. A saída é plate
+carrée, adequada para amostragem direta por longitude/latitude no canvas.
+
+O processamento é feito em blocos para não manter várias grades 6K inteiras
+na memória. Requer Pillow e numpy.
+"""
+
+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 = (6144, 3072)
+BLOCK_ROWS = 96
+QUALITY = 92
+
+
+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/2.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() -> None:
+ Image.MAX_IMAGE_PIXELS = None
+ src = np.asarray(Image.open(io.BytesIO(fetch())).convert("RGB"))
+ sh, sw = src.shape[:2]
+ tw, th = SIZE
+ out = np.empty((th, tw, 3), dtype=np.uint8)
+
+ # Longitude cresce para a esquerda para coincidir com a imagem Gaia.
+ lon = np.deg2rad(180 - (np.arange(tw, dtype=np.float64) + 0.5) * 360 / tw)
+
+ for y_start in range(0, th, BLOCK_ROWS):
+ y_stop = min(th, y_start + BLOCK_ROWS)
+ lat = np.deg2rad(
+ 90 - (np.arange(y_start, y_stop, dtype=np.float64) + 0.5) * 180 / th
+ )
+ lon_grid, lat_grid = np.meshgrid(lon, lat)
+
+ denom = np.sqrt(1 + np.cos(lat_grid) * np.cos(lon_grid / 2))
+ hx = 2 * math.sqrt(2) * np.cos(lat_grid) * np.sin(lon_grid / 2) / denom
+ hy = math.sqrt(2) * np.sin(lat_grid) / denom
+ sx = (1 - hx / (2 * math.sqrt(2))) * sw / 2
+ sy = (1 - hy / math.sqrt(2)) * sh / 2
+
+ # Amostragem bilinear preserva filamentos sem serrilhar a reprojeção.
+ x0 = np.clip(np.floor(sx).astype(np.int32), 0, sw - 1)
+ y0 = np.clip(np.floor(sy).astype(np.int32), 0, sh - 1)
+ x1 = np.minimum(x0 + 1, sw - 1)
+ y1 = np.minimum(y0 + 1, sh - 1)
+ fx = (sx - x0)[..., None]
+ fy = (sy - y0)[..., None]
+ top = src[y0, x0] * (1 - fx) + src[y0, x1] * fx
+ bottom = src[y1, x0] * (1 - fx) + src[y1, x1] * fx
+ out[y_start:y_stop] = np.clip(top * (1 - fy) + bottom * fy, 0, 255)
+ print(f"\rReprojetando: {y_stop * 100 // th:3d}%", end="", flush=True)
+
+ print()
+ Image.fromarray(out).save(OUT, "JPEG", quality=QUALITY, optimize=True)
+ print(f"{OUT} ({OUT.stat().st_size / 1024 / 1024:.1f} MB, {tw}x{th})")
+
+
+if __name__ == "__main__":
+ main()