generated from Labyricorn/labyricorn-project-template
Post Step 4 Completion
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Planetarium — composition root. Last file to load.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Shell = window.XZBTShell;
|
||||
|
||||
var announcer = new Shell.Announcer({
|
||||
node: Shell.el('announcement'),
|
||||
idleText: 'Dome idle \u2014 awaiting instruction.',
|
||||
maxLength: 120
|
||||
});
|
||||
|
||||
var exhibit = new window.PlanetariumExhibit({
|
||||
canvas: Shell.el('sky-canvas'),
|
||||
announcer: announcer
|
||||
});
|
||||
|
||||
var ui = new window.PlanetariumUI(exhibit, announcer);
|
||||
|
||||
var core = window.PlanetariumContract.create(exhibit, ui);
|
||||
|
||||
var transport = new window.XZBTHostTransport({
|
||||
core: core,
|
||||
onMessage: function (request, response) {
|
||||
if (response.type === 'hello.result') {
|
||||
Shell.setText(Shell.el('contract-status'), 'host attached');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
exhibit.start();
|
||||
|
||||
window.addEventListener('resize', function () { exhibit.resize(); });
|
||||
|
||||
window.__exhibit = {
|
||||
core: core,
|
||||
exhibit: exhibit,
|
||||
ui: ui,
|
||||
transport: transport,
|
||||
announcer: announcer
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Planetarium — XZBT Exhibit Contract 5.2 adapter.
|
||||
*
|
||||
* Declares the catalog, binds targets to real exhibit services, and routes
|
||||
* the native UI through the canonical mutation path. No state of its own.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Core = window.XZBTContractCore;
|
||||
|
||||
var IDENTITY = {
|
||||
product: 'Planetarium',
|
||||
version: '1.0.0',
|
||||
build: 'reference-exhibit'
|
||||
};
|
||||
|
||||
var DESCRIPTORS = [
|
||||
{
|
||||
id: 'sky.constellation',
|
||||
kind: 'selection',
|
||||
label: 'Constellation',
|
||||
description: 'Constellation currently tracked and drawn.',
|
||||
options: [
|
||||
{ value: 'orion', label: 'Orion' },
|
||||
{ value: 'cygnus', label: 'Cygnus' },
|
||||
{ value: 'scorpius', label: 'Scorpius' },
|
||||
{ value: 'ursa-major', label: 'Ursa Major' },
|
||||
{ value: 'cassiopeia', label: 'Cassiopeia' },
|
||||
{ value: 'lyra', label: 'Lyra' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.magnitude-limit',
|
||||
kind: 'range',
|
||||
label: 'Magnitude limit',
|
||||
description: 'Faintest stellar magnitude rendered. Higher values show more stars.',
|
||||
min: 1, max: 6, step: 0.1, unit: 'mag',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.azimuth',
|
||||
kind: 'range',
|
||||
label: 'Azimuth',
|
||||
description: 'Compass bearing of the view, 0 to 1 of a full turn.',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.altitude',
|
||||
kind: 'range',
|
||||
label: 'Altitude',
|
||||
description: 'Elevation of the view above the horizon, 0 to 1.',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.time-scale',
|
||||
kind: 'range',
|
||||
label: 'Time scale',
|
||||
description: 'Rate at which sky time advances.',
|
||||
min: 0, max: 4, step: 0.05, unit: 'x',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.filter',
|
||||
kind: 'selection',
|
||||
label: 'Filter',
|
||||
description: 'Optical filter applied to the view.',
|
||||
options: [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'light-pollution', label: 'Light pollution' },
|
||||
{ value: 'h-alpha', label: 'H-alpha' },
|
||||
{ value: 'oxygen-iii', label: 'Oxygen III' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.tracking',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Tracking',
|
||||
description: 'Whether the mount is tracking the selected constellation.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.paused',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Sky frozen',
|
||||
description: 'Whether sky time is frozen.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.advance-time',
|
||||
kind: 'impulse',
|
||||
label: 'Advance time',
|
||||
description: 'Advance sky time. Optional argument: minutes (1 to 240).',
|
||||
arguments: [
|
||||
{ name: 'minutes', type: 'number', required: false, min: 1, max: 240, default: 10 }
|
||||
],
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.reset-sky',
|
||||
kind: 'impulse',
|
||||
label: 'Reset sky',
|
||||
description: 'Return sky time to epoch.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.toggle-tracking',
|
||||
kind: 'impulse',
|
||||
label: 'Toggle tracking',
|
||||
description: 'Engage or disengage the mount.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.visible-stars',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Visible stars',
|
||||
description: 'Stars currently above the magnitude limit.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.sky-time',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
unit: 'h',
|
||||
label: 'Sky time',
|
||||
description: 'Elapsed sky time in hours.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.moon-phase',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Moon phase',
|
||||
description: 'Lunar phase as a fraction of the cycle.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
}
|
||||
];
|
||||
|
||||
function createPlanetariumContract(exhibit, ui) {
|
||||
var catalog = new Core.Catalog(DESCRIPTORS);
|
||||
|
||||
var capabilities = new Core.CapabilityRegistry();
|
||||
capabilities.declare('render', 'ready');
|
||||
|
||||
var setters = {
|
||||
'sky.constellation': function (v) { return exhibit.setConstellation(v); },
|
||||
'sky.magnitude-limit': function (v) { return exhibit.setMagnitudeLimit(v); },
|
||||
'sky.azimuth': function (v) { return exhibit.setAzimuth(v); },
|
||||
'sky.altitude': function (v) { return exhibit.setAltitude(v); },
|
||||
'sky.time-scale': function (v) { return exhibit.setTimeScale(v); },
|
||||
'sky.filter': function (v) { return exhibit.setFilter(v); },
|
||||
'sky.tracking': function (v) { return exhibit.setTracking(v); },
|
||||
'sky.paused': function (v) { return exhibit.setPaused(v); }
|
||||
};
|
||||
|
||||
var readers = {};
|
||||
var ids = catalog.ids();
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
(function (id) {
|
||||
readers[id] = function () { return exhibit.read(id); };
|
||||
})(ids[i]);
|
||||
}
|
||||
|
||||
var actions = {
|
||||
'action.advance-time': function (args) { return exhibit.advanceTime(args); },
|
||||
'action.reset-sky': function () { return exhibit.resetSky(); },
|
||||
'action.toggle-tracking': function () { return exhibit.toggleTracking(); }
|
||||
};
|
||||
|
||||
var core = new Core.ContractCore({
|
||||
identity: IDENTITY,
|
||||
catalog: catalog,
|
||||
capabilities: capabilities,
|
||||
setters: setters,
|
||||
readers: readers,
|
||||
actions: actions
|
||||
});
|
||||
|
||||
ui.dispatchOverride = function (targetId, value) {
|
||||
core.applyMutation(targetId, value, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
ui.invokeOverride = function (targetId, args) {
|
||||
core.invokeAction(targetId, args || {}, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
|
||||
exhibit.onChange = function () { ui.sync(); };
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
window.PlanetariumContract = { create: createPlanetariumContract, IDENTITY: IDENTITY };
|
||||
})();
|
||||
@@ -0,0 +1,564 @@
|
||||
/*
|
||||
* 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 };
|
||||
})();
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Planetarium — XZBT Reference Exhibit</title>
|
||||
<link rel="stylesheet" href="../shared/exhibit-shell.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="exhibit">
|
||||
<header class="exhibit-header">
|
||||
<h1>Planetarium</h1>
|
||||
<span class="subtitle">Sky dome — live ephemeris</span>
|
||||
<span class="status" id="contract-status">standalone</span>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<canvas id="sky-canvas" aria-label="Planetarium sky view"></canvas>
|
||||
</main>
|
||||
|
||||
<aside class="panel">
|
||||
<section>
|
||||
<h2>Target</h2>
|
||||
<div class="control">
|
||||
<label for="constellation-select">Constellation</label>
|
||||
<select id="constellation-select"></select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="magnitude-range">
|
||||
<span>Magnitude limit</span>
|
||||
<span class="readout" id="magnitude-readout">mag ≤ 4.5</span>
|
||||
</label>
|
||||
<input type="range" id="magnitude-range" min="1" max="6" step="0.1" value="4.5">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Pointing</h2>
|
||||
<div class="control">
|
||||
<label for="azimuth-range">
|
||||
<span>Azimuth</span>
|
||||
<span class="readout" id="azimuth-readout">90°</span>
|
||||
</label>
|
||||
<input type="range" id="azimuth-range" min="0" max="1" step="0.01" value="0.25">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="altitude-range">
|
||||
<span>Altitude</span>
|
||||
<span class="readout" id="altitude-readout">180°</span>
|
||||
</label>
|
||||
<input type="range" id="altitude-range" min="0" max="1" step="0.01" value="0.5">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="timescale-range">
|
||||
<span>Time scale</span>
|
||||
<span class="readout" id="timescale-readout">0.50×</span>
|
||||
</label>
|
||||
<input type="range" id="timescale-range" min="0" max="4" step="0.05" value="0.5">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Optics</h2>
|
||||
<div class="control">
|
||||
<label for="filter-select">Filter</label>
|
||||
<select id="filter-select"></select>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span>Mount tracking</span>
|
||||
<button id="tracking-button" type="button" aria-pressed="true">Tracking</button>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span>Sky time</span>
|
||||
<button id="pause-button" type="button" aria-pressed="false">Freeze sky</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Actions</h2>
|
||||
<div class="button-row">
|
||||
<button id="advance-button" type="button">Advance 30 min</button>
|
||||
<button id="reset-button" type="button">Reset sky</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<footer class="exhibit-footer">
|
||||
<span class="announcement-label">Dome</span>
|
||||
<span class="announcement-text is-idle" id="announcement">Dome idle — awaiting instruction.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../shared/contract-core.js"></script>
|
||||
<script src="../shared/exhibit-shell.js"></script>
|
||||
<script src="../shared/host-transport.js"></script>
|
||||
<script src="exhibit.js"></script>
|
||||
<script src="contract-adapter.js"></script>
|
||||
<script src="boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Planetarium — domain styling only. */
|
||||
|
||||
body {
|
||||
background: #05070f;
|
||||
color: #e2ecff;
|
||||
}
|
||||
|
||||
.exhibit-header {
|
||||
background: linear-gradient(90deg, rgba(60, 70, 150, 0.35), rgba(255, 255, 255, 0.03));
|
||||
border-color: rgba(150, 180, 255, 0.22);
|
||||
}
|
||||
|
||||
.exhibit-header h1 { color: #cdd9ff; }
|
||||
|
||||
.stage {
|
||||
border-color: rgba(150, 180, 255, 0.25);
|
||||
box-shadow: inset 0 0 80px rgba(0, 0, 30, 0.9);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(40, 50, 110, 0.22);
|
||||
border-color: rgba(150, 180, 255, 0.18);
|
||||
}
|
||||
|
||||
.panel h2 { color: #a9bcff; }
|
||||
|
||||
input[type="range"] { accent-color: #8fa8ff; }
|
||||
|
||||
button {
|
||||
background: rgba(143, 168, 255, 0.12);
|
||||
border-color: rgba(150, 180, 255, 0.35);
|
||||
color: #e2ecff;
|
||||
}
|
||||
|
||||
button:hover { background: rgba(143, 168, 255, 0.24); }
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
background: rgba(143, 168, 255, 0.4);
|
||||
border-color: rgba(190, 210, 255, 0.7);
|
||||
}
|
||||
|
||||
select {
|
||||
background: rgba(6, 10, 26, 0.85);
|
||||
border-color: rgba(150, 180, 255, 0.3);
|
||||
}
|
||||
|
||||
.exhibit-footer {
|
||||
background: rgba(40, 50, 110, 0.22);
|
||||
border-color: rgba(150, 180, 255, 0.18);
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-label { color: #a9bcff; opacity: 0.7; }
|
||||
|
||||
.exhibit-header .status { color: #a9bcff; }
|
||||
Reference in New Issue
Block a user