Files
XZBT-NGN/test-fixtures/reference-exhibits/planetarium/exhibit.js
T
2026-09-14 07:57:18 -07:00

565 lines
20 KiB
JavaScript

/*
* 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 };
})();