/* * Planetarium — exhibit behaviour. * * Owns the sky model, the ephemeris, the canvas rendering, and the native * controls. No contract awareness: no target IDs, no events, no revisions. * * The exhibit's own services (absolute setters and real actions) are what * the contract layer binds to, so the native UI and a future host share one * code path. */ (function () { 'use strict'; var TAU = Math.PI * 2; var CATALOG = [ { id: 'orion', label: 'Orion', stars: 9, brightness: 1.0, hue: '#cfe4ff' }, { id: 'cygnus', label: 'Cygnus', stars: 7, brightness: 0.85, hue: '#dbe8ff' }, { id: 'scorpius', label: 'Scorpius', stars: 8, brightness: 0.95, hue: '#ffe0c4' }, { id: 'ursa-major', label: 'Ursa Major', stars: 7, brightness: 0.8, hue: '#e6f0ff' }, { id: 'cassiopeia', label: 'Cassiopeia', stars: 5, brightness: 0.75, hue: '#f0e6ff' }, { id: 'lyra', label: 'Lyra', stars: 6, brightness: 0.9, hue: '#e8f4ff' } ]; var BODIES = [ { id: 'moon', label: 'Moon', radius: 0.055, period: 0.09, color: '#e8eef7', phase: 0.2 }, { id: 'mars', label: 'Mars', radius: 0.085, period: 0.16, color: '#e07a52', phase: 1.1 }, { id: 'jupiter', label: 'Jupiter', radius: 0.115, period: 0.27, color: '#e6c79a', phase: 2.4 }, { id: 'saturn', label: 'Saturn', radius: 0.145, period: 0.41, color: '#e8d9a8', phase: 4.0, ring: true } ]; var FILTERS = [ { id: 'none', label: 'None', tint: null, gain: 1.0 }, { id: 'light-pollution', label: 'Light pollution', tint: '#3a2a1a', gain: 0.55 }, { id: 'h-alpha', label: 'H-alpha', tint: '#5a1020', gain: 0.8 }, { id: 'oxygen-iii', label: 'Oxygen III', tint: '#0d3a4a', gain: 0.8 } ]; var MAX_MAGNITUDE = 6.0; var MIN_MAGNITUDE = 1.0; function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); } function findById(list, id) { for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i]; return null; } /* Deterministic pseudo-random so a constellation always looks the same. */ function hash(n) { var x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); } /* ------------------------------------------------------------------ * * Sky model * ------------------------------------------------------------------ */ function Sky() { this.constellation = 'orion'; this.magnitudeLimit = 4.5; this.azimuth = 0.25; this.altitude = 0.5; this.timeScale = 0.5; this.filter = 'none'; this.tracking = true; this.paused = false; this.skyTime = 0; this.stars = []; this._buildStars(); } Sky.prototype._buildStars = function () { this.stars = []; var count = 420; for (var i = 0; i < count; i++) { var mag = MIN_MAGNITUDE + hash(i * 3.1) * (MAX_MAGNITUDE - MIN_MAGNITUDE); this.stars.push({ x: hash(i * 1.7), y: hash(i * 2.3) * 0.92, mag: mag, twinkle: hash(i * 5.9) * TAU, hue: hash(i * 7.3) > 0.82 ? '#ffd9b0' : (hash(i * 9.1) > 0.7 ? '#c9d8ff' : '#ffffff') }); } }; Sky.prototype.step = function (dt) { if (this.paused) return; this.skyTime += dt * this.timeScale; }; /** Position of a body on the sky dome, in normalised 0..1 coordinates. */ Sky.prototype.bodyPosition = function (body) { var angle = body.phase + this.skyTime * TAU * body.period; var cx = 0.5 + (this.azimuth - 0.5) * 0.6; var cy = 0.5 + (this.altitude - 0.5) * 0.6; return { x: cx + Math.cos(angle) * body.radius * 3.2, y: cy + Math.sin(angle) * body.radius * 1.9 }; }; /* ------------------------------------------------------------------ * * Rendering * ------------------------------------------------------------------ */ function Renderer(canvas, sky) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.sky = sky; this.width = 0; this.height = 0; this.resize(); } Renderer.prototype.resize = function () { var rect = this.canvas.getBoundingClientRect(); var dpr = window.devicePixelRatio || 1; this.width = Math.max(1, Math.round(rect.width)); this.height = Math.max(1, Math.round(rect.height)); this.canvas.width = Math.round(this.width * dpr); this.canvas.height = Math.round(this.height * dpr); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; Renderer.prototype.draw = function () { var ctx = this.ctx; var w = this.width; var h = this.height; var sky = this.sky; var filter = findById(FILTERS, sky.filter); var constellation = findById(CATALOG, sky.constellation); ctx.clearRect(0, 0, w, h); /* Sky dome. */ var grad = ctx.createRadialGradient(w * 0.5, h * 0.55, 0, w * 0.5, h * 0.55, Math.max(w, h) * 0.75); grad.addColorStop(0, '#0b1226'); grad.addColorStop(1, '#03050c'); ctx.fillStyle = grad; ctx.fillRect(0, 0, w, h); /* Horizon glow. */ var horizon = ctx.createLinearGradient(0, h * 0.72, 0, h); horizon.addColorStop(0, 'rgba(20,40,80,0)'); horizon.addColorStop(1, 'rgba(30,60,110,0.35)'); ctx.fillStyle = horizon; ctx.fillRect(0, h * 0.72, w, h * 0.28); /* Stars, filtered by the magnitude limit. */ for (var i = 0; i < sky.stars.length; i++) { var s = sky.stars[i]; if (s.mag > sky.magnitudeLimit) continue; var norm = 1 - (s.mag - MIN_MAGNITUDE) / (MAX_MAGNITUDE - MIN_MAGNITUDE); var alpha = clamp(norm * 0.95 + 0.05, 0, 1) * filter.gain; var twinkle = 0.85 + Math.sin(sky.skyTime * 2.4 + s.twinkle) * 0.15; var r = 0.6 + norm * 1.9; ctx.globalAlpha = clamp(alpha * twinkle, 0, 1); ctx.fillStyle = s.hue; ctx.beginPath(); ctx.arc(s.x * w, s.y * h, r, 0, TAU); ctx.fill(); } ctx.globalAlpha = 1; /* The selected constellation, drawn as a connected figure. */ var baseX = (0.5 + (sky.azimuth - 0.5) * 0.7) * w; var baseY = (0.42 + (sky.altitude - 0.5) * 0.5) * h; var scale = Math.min(w, h) * 0.16; var points = []; for (var k = 0; k < constellation.stars; k++) { var angle = (k / constellation.stars) * TAU + hash(k * 4.4) * 0.6; var dist = 0.35 + hash(k * 6.1) * 0.65; points.push({ x: baseX + Math.cos(angle) * dist * scale, y: baseY + Math.sin(angle) * dist * scale * 0.7 }); } ctx.strokeStyle = 'rgba(150,200,255,' + (0.28 * filter.gain) + ')'; ctx.lineWidth = 1; ctx.beginPath(); for (var p = 0; p < points.length; p++) { var next = points[(p + 1) % points.length]; ctx.moveTo(points[p].x, points[p].y); ctx.lineTo(next.x, next.y); } ctx.stroke(); for (var q = 0; q < points.length; q++) { ctx.fillStyle = constellation.hue; ctx.globalAlpha = clamp(constellation.brightness * filter.gain, 0, 1); ctx.beginPath(); ctx.arc(points[q].x, points[q].y, 2.4, 0, TAU); ctx.fill(); } ctx.globalAlpha = 1; /* Label the constellation with textContent-equivalent canvas text. */ ctx.fillStyle = 'rgba(190,220,255,0.75)'; ctx.font = '600 12px "Segoe UI", sans-serif'; ctx.fillText(constellation.label, baseX - scale * 0.5, baseY + scale * 1.15); /* Solar-system bodies. */ for (var b = 0; b < BODIES.length; b++) { var body = BODIES[b]; var pos = sky.bodyPosition(body); var bx = pos.x * w; var by = pos.y * h; var br = body.radius * Math.min(w, h) * 0.5; ctx.globalAlpha = clamp(filter.gain, 0, 1); ctx.fillStyle = body.color; ctx.beginPath(); ctx.arc(bx, by, br, 0, TAU); ctx.fill(); if (body.ring) { ctx.strokeStyle = 'rgba(232,217,168,0.7)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(bx, by, br * 1.9, br * 0.6, 0.4, 0, TAU); ctx.stroke(); } ctx.fillStyle = 'rgba(200,220,255,0.6)'; ctx.font = '11px "Segoe UI", sans-serif'; ctx.fillText(body.label, bx + br + 5, by + 4); ctx.globalAlpha = 1; } /* Filter tint over the whole frame. */ if (filter.tint) { ctx.globalAlpha = 0.22; ctx.fillStyle = filter.tint; ctx.fillRect(0, 0, w, h); ctx.globalAlpha = 1; } /* Tracking reticle. */ if (sky.tracking) { ctx.strokeStyle = 'rgba(120,200,255,0.5)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(baseX, baseY, scale * 1.35, 0, TAU); ctx.stroke(); ctx.beginPath(); ctx.moveTo(baseX - scale * 1.5, baseY); ctx.lineTo(baseX - scale * 1.2, baseY); ctx.moveTo(baseX + scale * 1.2, baseY); ctx.lineTo(baseX + scale * 1.5, baseY); ctx.stroke(); } if (sky.paused) { ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.font = '600 13px "Segoe UI", sans-serif'; ctx.fillText('SKY FROZEN', 14, 24); } }; /* ------------------------------------------------------------------ * * Exhibit * ------------------------------------------------------------------ */ function PlanetariumExhibit(options) { this.canvas = options.canvas; this.sky = new Sky(); this.renderer = new Renderer(this.canvas, this.sky); this.announcer = options.announcer; this.onChange = options.onChange || function () {}; this._lastFrame = 0; this._running = false; this._boundFrame = this._frame.bind(this); } PlanetariumExhibit.prototype.start = function () { if (this._running) return; this._running = true; this._lastFrame = performance.now(); requestAnimationFrame(this._boundFrame); }; PlanetariumExhibit.prototype._frame = function (now) { if (!this._running) return; var dt = Math.min(0.05, (now - this._lastFrame) / 1000); this._lastFrame = now; this.sky.step(dt); this.renderer.draw(); requestAnimationFrame(this._boundFrame); }; PlanetariumExhibit.prototype.resize = function () { this.renderer.resize(); }; /* ------------------------------------------------------------------ * * Absolute, idempotent setters * ------------------------------------------------------------------ */ PlanetariumExhibit.prototype.setConstellation = function (id) { if (this.sky.constellation === id) return { changed: false }; this.sky.constellation = id; this.onChange(); return { changed: true }; }; PlanetariumExhibit.prototype.setMagnitudeLimit = function (value) { if (this.sky.magnitudeLimit === value) return { changed: false }; this.sky.magnitudeLimit = value; this.onChange(); return { changed: true }; }; PlanetariumExhibit.prototype.setAzimuth = function (value) { if (this.sky.azimuth === value) return { changed: false }; this.sky.azimuth = value; this.onChange(); return { changed: true }; }; PlanetariumExhibit.prototype.setAltitude = function (value) { if (this.sky.altitude === value) return { changed: false }; this.sky.altitude = value; this.onChange(); return { changed: true }; }; PlanetariumExhibit.prototype.setTimeScale = function (value) { if (this.sky.timeScale === value) return { changed: false }; this.sky.timeScale = value; this.onChange(); return { changed: true }; }; PlanetariumExhibit.prototype.setFilter = function (id) { if (this.sky.filter === id) return { changed: false }; this.sky.filter = id; this.onChange(); return { changed: true }; }; PlanetariumExhibit.prototype.setTracking = function (on) { if (this.sky.tracking === on) return { changed: false }; this.sky.tracking = on; this.onChange(); return { changed: true }; }; PlanetariumExhibit.prototype.setPaused = function (on) { if (this.sky.paused === on) return { changed: false }; this.sky.paused = on; this.onChange(); return { changed: true }; }; /* ------------------------------------------------------------------ * * Real actions * ------------------------------------------------------------------ */ PlanetariumExhibit.prototype.advanceTime = function (args) { var minutes = 10; if (args && typeof args.minutes === 'number') { if (!isFinite(args.minutes) || args.minutes < 1 || args.minutes > 240) { return { ok: false, code: 'INVALID_ARGUMENTS', message: 'minutes must be between 1 and 240.' }; } minutes = Math.round(args.minutes); } this.sky.skyTime += minutes / 60; this.announcer.flash('Advanced sky time by ' + minutes + ' minutes.'); this.onChange(); return { ok: true, args: { minutes: minutes } }; }; PlanetariumExhibit.prototype.resetSky = function () { this.sky.skyTime = 0; this.announcer.flash('Sky time reset to epoch.'); this.onChange(); return { ok: true }; }; PlanetariumExhibit.prototype.toggleTracking = function () { this.sky.tracking = !this.sky.tracking; this.announcer.flash(this.sky.tracking ? 'Tracking engaged.' : 'Tracking disengaged.'); this.onChange(); return { ok: true }; }; /* ------------------------------------------------------------------ * * Reads * ------------------------------------------------------------------ */ PlanetariumExhibit.prototype.read = function (targetId) { var s = this.sky; switch (targetId) { case 'sky.constellation': return s.constellation; case 'sky.magnitude-limit': return s.magnitudeLimit; case 'sky.azimuth': return s.azimuth; case 'sky.altitude': return s.altitude; case 'sky.time-scale': return s.timeScale; case 'sky.filter': return s.filter; case 'sky.tracking': return s.tracking; case 'sky.paused': return s.paused; case 'telemetry.visible-stars': return this._visibleStarCount(); case 'telemetry.sky-time': return Math.round(s.skyTime * 100) / 100; case 'telemetry.moon-phase': return Math.round(((s.skyTime * 0.09) % 1) * 100) / 100; default: return null; } }; PlanetariumExhibit.prototype._visibleStarCount = function () { var count = 0; for (var i = 0; i < this.sky.stars.length; i++) { if (this.sky.stars[i].mag <= this.sky.magnitudeLimit) count++; } return count; }; /* ------------------------------------------------------------------ * * Native UI * ------------------------------------------------------------------ */ function PlanetariumUI(exhibit, announcer) { this.exhibit = exhibit; this.announcer = announcer; this.nodes = { constellation: document.getElementById('constellation-select'), magnitude: document.getElementById('magnitude-range'), magnitudeReadout: document.getElementById('magnitude-readout'), azimuth: document.getElementById('azimuth-range'), azimuthReadout: document.getElementById('azimuth-readout'), altitude: document.getElementById('altitude-range'), altitudeReadout: document.getElementById('altitude-readout'), timeScale: document.getElementById('timescale-range'), timeScaleReadout: document.getElementById('timescale-readout'), filter: document.getElementById('filter-select'), tracking: document.getElementById('tracking-button'), pause: document.getElementById('pause-button'), advance: document.getElementById('advance-button'), reset: document.getElementById('reset-button') }; this._buildOptions(); this._bind(); this.sync(); } PlanetariumUI.prototype._buildOptions = function () { var c = this.nodes.constellation; for (var i = 0; i < CATALOG.length; i++) { var opt = document.createElement('option'); opt.value = CATALOG[i].id; opt.textContent = CATALOG[i].label; c.appendChild(opt); } var f = this.nodes.filter; for (var j = 0; j < FILTERS.length; j++) { var fopt = document.createElement('option'); fopt.value = FILTERS[j].id; fopt.textContent = FILTERS[j].label; f.appendChild(fopt); } }; PlanetariumUI.prototype._bind = function () { var self = this; var ex = this.exhibit; this.nodes.constellation.addEventListener('change', function (e) { self.dispatch('sky.constellation', e.target.value); }); this.nodes.magnitude.addEventListener('input', function (e) { self.dispatch('sky.magnitude-limit', Number(e.target.value)); }); this.nodes.azimuth.addEventListener('input', function (e) { self.dispatch('sky.azimuth', Number(e.target.value)); }); this.nodes.altitude.addEventListener('input', function (e) { self.dispatch('sky.altitude', Number(e.target.value)); }); this.nodes.timeScale.addEventListener('input', function (e) { self.dispatch('sky.time-scale', Number(e.target.value)); }); this.nodes.filter.addEventListener('change', function (e) { self.dispatch('sky.filter', e.target.value); }); this.nodes.tracking.addEventListener('click', function () { self.dispatch('sky.tracking', !ex.sky.tracking); }); this.nodes.pause.addEventListener('click', function () { self.dispatch('sky.paused', !ex.sky.paused); }); this.nodes.advance.addEventListener('click', function () { self.invoke('action.advance-time', { minutes: 30 }); }); this.nodes.reset.addEventListener('click', function () { self.invoke('action.reset-sky'); }); }; PlanetariumUI.prototype.dispatch = function (targetId, value) { if (typeof this.dispatchOverride === 'function') { this.dispatchOverride(targetId, value); return; } this._fallbackSet(targetId, value); }; PlanetariumUI.prototype.invoke = function (targetId, args) { if (typeof this.invokeOverride === 'function') { this.invokeOverride(targetId, args); return; } this._fallbackInvoke(targetId, args); }; PlanetariumUI.prototype._fallbackSet = function (targetId, value) { var ex = this.exhibit; switch (targetId) { case 'sky.constellation': ex.setConstellation(value); break; case 'sky.magnitude-limit': ex.setMagnitudeLimit(value); break; case 'sky.azimuth': ex.setAzimuth(value); break; case 'sky.altitude': ex.setAltitude(value); break; case 'sky.time-scale': ex.setTimeScale(value); break; case 'sky.filter': ex.setFilter(value); break; case 'sky.tracking': ex.setTracking(value); break; case 'sky.paused': ex.setPaused(value); break; default: break; } this.sync(); }; PlanetariumUI.prototype._fallbackInvoke = function (targetId, args) { var ex = this.exhibit; if (targetId === 'action.advance-time') ex.advanceTime(args || {}); else if (targetId === 'action.reset-sky') ex.resetSky(); else if (targetId === 'action.toggle-tracking') ex.toggleTracking(); this.sync(); }; PlanetariumUI.prototype.sync = function () { var s = this.exhibit.sky; var n = this.nodes; var Shell = window.XZBTShell; if (n.constellation.value !== s.constellation) n.constellation.value = s.constellation; if (Number(n.magnitude.value) !== s.magnitudeLimit) n.magnitude.value = String(s.magnitudeLimit); if (Number(n.azimuth.value) !== s.azimuth) n.azimuth.value = String(s.azimuth); if (Number(n.altitude.value) !== s.altitude) n.altitude.value = String(s.altitude); if (Number(n.timeScale.value) !== s.timeScale) n.timeScale.value = String(s.timeScale); if (n.filter.value !== s.filter) n.filter.value = s.filter; n.magnitudeReadout.textContent = 'mag \u2264 ' + Shell.formatNumber(s.magnitudeLimit, 1); n.azimuthReadout.textContent = Shell.formatDegrees(s.azimuth); n.altitudeReadout.textContent = Shell.formatDegrees(s.altitude); n.timeScaleReadout.textContent = Shell.formatNumber(s.timeScale, 2) + '\u00d7'; n.tracking.setAttribute('aria-pressed', s.tracking ? 'true' : 'false'); n.pause.setAttribute('aria-pressed', s.paused ? 'true' : 'false'); n.pause.textContent = s.paused ? 'Resume sky' : 'Freeze sky'; }; window.PlanetariumExhibit = PlanetariumExhibit; window.PlanetariumUI = PlanetariumUI; window.PlanetariumData = { CATALOG: CATALOG, BODIES: BODIES, FILTERS: FILTERS }; })();