diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6e8f43..8de05f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,10 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+
- Initial project scaffold from [Studio51 Standards](https://github.com/studio51/standards).
+- Cinematic arrival (`intro`): ~3.2s choreographed bloom on load (nebula → stars → sphere → atmosphere → land → night/aurora/orbits → beams), with rotation easing in from stillness.
+- Six cinematic canvas layers: orbiting moon, rare comet, shimmering constellations, ocean sun glint, HQ heartbeat wave, and city surges.
+- New visual toggles: sun glare at the subsolar limb, in-canvas parallax starfield, nebula haze, and pointer parallax look-offset.
+- Fling inertia — flick the globe and it glides, decaying back into auto-rotation (works while paused).
+- Scene-schema `cinema` section and control-panel toggles for all the new options.
### Changed
+- Canvas renderer performance: zero trig in the hot loops (precomputed sin/cos with angle-sum projection), tier-sorted land dots drawn as contiguous runs, low-res offscreen terminator upscaled to screen, and cached GeoJSON rebuilt only when the sun moves.
+- Adaptive quality in `BaseEngine`: an EMA of frame time nudges a dpr multiplier (down to 0.55) with cooldown hysteresis so weak GPUs get a softer image instead of dropped frames.
+- Richer visuals: deeper offset-lit ocean, wider atmospheric rim with a crisp shell line, 4-layer beams, twin impact rings, 3-pass aurora, warmer city lights.
+- Calmer defaults and pacing: rotation 4°/s, activity 2.4/s, meteor frequency 40%, slower/longer-lived meteors and beams.
+
### Fixed
[Unreleased]: https://github.com/studio51/orbit/commits/main
diff --git a/canvas/cinema.js b/canvas/cinema.js
new file mode 100644
index 0000000..a555933
--- /dev/null
+++ b/canvas/cinema.js
@@ -0,0 +1,493 @@
+/* games.directory globe — Canvas2D cinematic layers
+ *
+ * The wow extras: the Moon, the rare comet, constellations, the ocean sun
+ * glint, the HQ heartbeat and the city-surge marker. Same layer contract as
+ * canvas/layers.js; registered alongside the core layers. Rendering only —
+ * scheduling state machines live in each layer; orbital/sun math comes from
+ * the engine's fast projection fields.
+ */
+import { TAU, DEG, clamp, rgba } from '../shared/util.js';
+import { quadPoint } from '../shared/geometry.js';
+import { ADD, NORMAL, drawGlow } from './draw.js';
+
+const sm = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t)); // smoothstep
+
+// ======================================================================
+// The Moon — a small real moon on a distant orbit, passing behind the globe
+// ======================================================================
+const MOON_PERIOD = 95000; // ms per orbit
+export function moonLayer() {
+ let sprite = null;
+ function build() {
+ const S = 128,
+ r = 56;
+ sprite = document.createElement('canvas');
+ sprite.width = sprite.height = S;
+ const g = sprite.getContext('2d');
+ g.save();
+ g.beginPath();
+ g.arc(S / 2, S / 2, r, 0, TAU);
+ g.clip();
+ // sunlit body
+ const grad = g.createRadialGradient(S * 0.4, S * 0.36, r * 0.1, S / 2, S / 2, r);
+ grad.addColorStop(0, '#f0f3f7');
+ grad.addColorStop(0.55, '#c2cad6');
+ grad.addColorStop(0.85, '#8a94a6');
+ grad.addColorStop(1, '#5d6678');
+ g.fillStyle = grad;
+ g.fillRect(0, 0, S, S);
+ // maria / craters
+ const spots = [
+ [0.42, 0.3, 0.13, 0.18],
+ [0.6, 0.48, 0.1, 0.16],
+ [0.32, 0.56, 0.08, 0.2],
+ [0.55, 0.7, 0.12, 0.14],
+ [0.7, 0.28, 0.06, 0.2],
+ [0.45, 0.46, 0.05, 0.22],
+ ];
+ for (const [fx, fy, fr, a] of spots) {
+ g.fillStyle = `rgba(70,80,100,${a})`;
+ g.beginPath();
+ g.arc(S * fx, S * fy, r * fr, 0, TAU);
+ g.fill();
+ }
+ // night-side shadow (light from upper-left, matching the scene)
+ g.fillStyle = 'rgba(7,11,21,0.8)';
+ g.beginPath();
+ g.arc(S * 0.86, S * 0.78, r * 1.06, 0, TAU);
+ g.fill();
+ g.restore();
+ g.strokeStyle = 'rgba(235,242,255,0.12)';
+ g.lineWidth = 1;
+ g.beginPath();
+ g.arc(S / 2, S / 2, r - 0.5, 0, TAU);
+ g.stroke();
+ }
+ return {
+ name: 'moon',
+ z: 6,
+ visible: (e) => e.scene.moon,
+ draw(e) {
+ const { ctx, CX, CY, R, W, H, now } = e;
+ if (!sprite) build();
+ const ang = (now / MOON_PERIOD) * TAU + 0.9;
+ const tilt = -0.16,
+ ca = Math.cos(ang),
+ sa = Math.sin(ang);
+ const ex = ca * R * 2.3,
+ ey = sa * R * 0.8;
+ const mx = CX + ex * Math.cos(tilt) - ey * Math.sin(tilt);
+ const my = CY - R * 0.08 + ex * Math.sin(tilt) + ey * Math.cos(tilt);
+ const d = R * 0.27 * (1 - 0.12 * sa); // slightly smaller at the orbit's far side
+ if (mx < -d || mx > W + d || my < -d || my > H + d) return;
+ const a = e.introPhase(0.6, 0.95);
+ if (a <= 0) return;
+ ADD(ctx);
+ drawGlow(ctx, mx, my, d * 0.85, 0.14 * a);
+ NORMAL(ctx);
+ ctx.globalAlpha = a;
+ ctx.drawImage(sprite, mx - d / 2, my - d / 2, d, d);
+ ctx.globalAlpha = 1;
+ },
+ };
+}
+
+// ======================================================================
+// Rare comet — a slow long-tailed wanderer, once every few minutes
+// ======================================================================
+export function cometLayer() {
+ let active = false,
+ t0 = 0,
+ dur = 0,
+ p0,
+ cp,
+ p1,
+ nextAt = 0;
+ return {
+ name: 'comet',
+ z: -2.4,
+ simulate(e) {
+ if (!e.scene.comet) {
+ active = false;
+ return;
+ }
+ if (nextAt === 0) nextAt = e.now + 40000 + Math.random() * 30000;
+ if (!active && e.now >= nextAt && e.intro >= 1) {
+ active = true;
+ t0 = e.now;
+ dur = 22000 + Math.random() * 6000;
+ const { W, H } = e;
+ p0 = [W + 160, H * (0.08 + Math.random() * 0.22)];
+ p1 = [-200, H * (0.3 + Math.random() * 0.3)];
+ cp = [W * (0.4 + Math.random() * 0.2), -H * 0.05];
+ nextAt = e.now + 130000 + Math.random() * 110000;
+ }
+ if (active && e.now - t0 > dur) active = false;
+ },
+ draw(e) {
+ if (!active) return;
+ const { ctx, now } = e;
+ const u = (now - t0) / dur;
+ const env = Math.pow(Math.sin(Math.min(1, u) * Math.PI), 0.6);
+ const head = quadPoint(p0, cp, p1, u);
+ ADD(ctx);
+ // dust tail — tapering glow puffs trailing the head along the path
+ for (let k = 16; k >= 1; k--) {
+ const uk = Math.max(0, u - k * 0.014);
+ const [tx, ty] = quadPoint(p0, cp, p1, uk);
+ const f = 1 - k / 17;
+ drawGlow(ctx, tx, ty, f * 4.5 + 0.8, env * 0.38 * Math.pow(f, 1.5));
+ }
+ // ion line — a faint straight streak through the tail
+ const [bx, by] = quadPoint(p0, cp, p1, Math.max(0, u - 0.21));
+ const grad = ctx.createLinearGradient(bx, by, head[0], head[1]);
+ grad.addColorStop(0, 'rgba(160,210,255,0)');
+ grad.addColorStop(1, 'rgba(205,235,255,0.5)');
+ ctx.strokeStyle = grad;
+ ctx.lineWidth = 1.2;
+ ctx.lineCap = 'round';
+ ctx.globalAlpha = env * 0.6;
+ ctx.beginPath();
+ ctx.moveTo(bx, by);
+ ctx.lineTo(head[0], head[1]);
+ ctx.stroke();
+ // coma + core
+ drawGlow(ctx, head[0], head[1], 8, env * 0.9);
+ ctx.globalAlpha = env;
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(head[0], head[1], 1.8, 0, TAU);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ NORMAL(ctx);
+ },
+ };
+}
+
+// ======================================================================
+// Constellations — faint figures that shimmer into the starfield in turn
+// ======================================================================
+const FIGURES = [
+ {
+ pts: [
+ [0, 0.45],
+ [0.22, 0.1],
+ [0.45, 0.38],
+ [0.7, 0],
+ [1, 0.28],
+ ],
+ lines: [
+ [0, 1],
+ [1, 2],
+ [2, 3],
+ [3, 4],
+ ],
+ }, // Cassiopeia
+ {
+ pts: [
+ [0.2, 0],
+ [0.75, 0.05],
+ [0.36, 0.45],
+ [0.5, 0.5],
+ [0.64, 0.55],
+ [0.15, 1],
+ [0.85, 0.95],
+ ],
+ lines: [
+ [0, 2],
+ [1, 4],
+ [2, 3],
+ [3, 4],
+ [2, 5],
+ [4, 6],
+ ],
+ }, // Orion
+ {
+ pts: [
+ [0.5, 0],
+ [0.5, 0.35],
+ [0.5, 0.75],
+ [0.15, 0.45],
+ [0.85, 0.25],
+ [0.5, 1],
+ ],
+ lines: [
+ [0, 1],
+ [1, 2],
+ [2, 5],
+ [3, 1],
+ [1, 4],
+ ],
+ }, // Cygnus
+];
+const ANCHORS = [
+ [0.16, 0.2],
+ [0.78, 0.64],
+ [0.62, 0.12],
+];
+const CON_CYCLE = 42000;
+export function constellationsLayer() {
+ let placed = null;
+ function build(e) {
+ const s = Math.min(e.W, e.H) * 0.17;
+ placed = FIGURES.map((f, i) => ({
+ pts: f.pts.map(([fx, fy]) => [e.W * ANCHORS[i][0] + fx * s, e.H * ANCHORS[i][1] + fy * s]),
+ lines: f.lines,
+ }));
+ }
+ return {
+ name: 'constellations',
+ z: -2.6,
+ resize(e) {
+ build(e);
+ },
+ visible: (e) => e.scene.constellations,
+ draw(e) {
+ const { ctx, now } = e;
+ if (!placed) build(e);
+ const idx = Math.floor(now / CON_CYCLE) % placed.length;
+ const p = (now % CON_CYCLE) / CON_CYCLE;
+ const env = Math.min(sm(p / 0.06), 1 - sm((p - 0.5) / 0.12)) * e.introPhase(0.5, 0.9);
+ if (env <= 0) return;
+ const fig = placed[idx];
+ // bounded pointer parallax only — figures must not drift with rotation
+ const ox = -e.look.x * 3,
+ oy = e.look.y * 3;
+ const lp = clamp(p / 0.25, 0, 1) * fig.lines.length;
+ ADD(ctx);
+ ctx.strokeStyle = '#cfe0ff';
+ ctx.lineWidth = 0.9;
+ ctx.lineCap = 'round';
+ for (let j = 0; j < fig.lines.length; j++) {
+ const seg = clamp(lp - j, 0, 1);
+ if (seg <= 0) break;
+ const [a, b] = fig.lines[j];
+ const [ax, ay] = fig.pts[a],
+ [bx, by] = fig.pts[b];
+ ctx.globalAlpha = env * 0.28 * seg;
+ ctx.beginPath();
+ ctx.moveTo(ax + ox, ay + oy);
+ ctx.lineTo(ax + (bx - ax) * seg + ox, ay + (by - ay) * seg + oy);
+ ctx.stroke();
+ }
+ for (let j = 0; j < fig.pts.length; j++) {
+ const [x, y] = fig.pts[j];
+ const tw = 0.6 + 0.4 * Math.sin(now * 0.0016 + j * 1.7);
+ drawGlow(ctx, x + ox, y + oy, 4.5, env * 0.5 * tw);
+ ctx.globalAlpha = env * tw;
+ ctx.fillStyle = '#eaf2ff';
+ ctx.beginPath();
+ ctx.arc(x + ox, y + oy, 1.1, 0, TAU);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ NORMAL(ctx);
+ },
+ };
+}
+
+// ======================================================================
+// Ocean sun glint — soft specular sheen on the day side, tracking the sun
+// ======================================================================
+export function sunGlintLayer() {
+ let sprite = null;
+ function build() {
+ const S = 256;
+ sprite = document.createElement('canvas');
+ sprite.width = sprite.height = S;
+ const g = sprite.getContext('2d');
+ const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
+ grad.addColorStop(0, 'rgba(255,242,214,0.55)');
+ grad.addColorStop(0.3, 'rgba(255,230,190,0.18)');
+ grad.addColorStop(1, 'rgba(255,225,180,0)');
+ g.fillStyle = grad;
+ g.fillRect(0, 0, S, S);
+ }
+ return {
+ name: 'sunGlint',
+ z: 45,
+ visible: (e) => e.scene.sunGlint,
+ draw(e) {
+ const { ctx, CX, CY, R, now, proj } = e;
+ if (!sprite) build();
+ const s = proj.sunDir();
+ if (s.z <= 0.05) return;
+ const shimmer = 0.75 + 0.18 * Math.sin(now * 0.0021) + 0.07 * Math.sin(now * 0.00113 + 2);
+ const a = Math.pow(s.z, 1.6) * 0.5 * shimmer * e.introPhase(0.5, 0.85);
+ if (a <= 0.01) return;
+ const px = CX + R * s.x,
+ py = CY - R * s.y;
+ ADD(ctx);
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.scale(1.25, 0.85);
+ ctx.globalAlpha = a;
+ const r1 = R * 0.42;
+ ctx.drawImage(sprite, -r1, -r1, r1 * 2, r1 * 2);
+ ctx.globalAlpha = Math.min(1, a * 1.5);
+ const r2 = R * 0.16;
+ ctx.drawImage(sprite, -r2, -r2, r2 * 2, r2 * 2);
+ ctx.restore();
+ ctx.globalAlpha = 1;
+ NORMAL(ctx);
+ },
+ };
+}
+
+// ======================================================================
+// HQ heartbeat — a luminous wave rippling outward from HQ across the surface
+// ======================================================================
+const HB_PERIOD = 28000,
+ HB_DUR = 2800,
+ HB_MAX = 100 * DEG;
+const HB_N = 96;
+const HB_COS = new Float64Array(HB_N + 1),
+ HB_SIN = new Float64Array(HB_N + 1);
+for (let i = 0; i <= HB_N; i++) {
+ const a = (i / HB_N) * TAU;
+ HB_COS[i] = Math.cos(a);
+ HB_SIN[i] = Math.sin(a);
+}
+export function heartbeatLayer() {
+ let t0 = -1,
+ nextAt = 0,
+ hqTrig = null;
+ return {
+ name: 'heartbeat',
+ z: 58,
+ build(e) {
+ const [lng, lat] = e.data.HQ.lnglat;
+ const latR = lat * DEG,
+ lngR = lng * DEG;
+ hqTrig = {
+ sinLat: Math.sin(latR),
+ cosLat: Math.cos(latR),
+ sinLng: Math.sin(lngR),
+ cosLng: Math.cos(lngR),
+ };
+ },
+ visible: (e) => e.scene.heartbeat,
+ simulate(e) {
+ if (nextAt === 0) nextAt = e.now + 12000;
+ if (t0 < 0 && e.now >= nextAt && e.hqVisible && e.intro >= 1 && !e.sim.paused) {
+ t0 = e.now;
+ nextAt = e.now + HB_PERIOD;
+ }
+ if (t0 >= 0 && e.now - t0 > HB_DUR) t0 = -1;
+ },
+ draw(e) {
+ if (t0 < 0 || !hqTrig) return;
+ const { ctx, CX, CY, R, now, proj } = e;
+ const p = (now - t0) / HB_DUR;
+ const delta = sm(p) * HB_MAX;
+ const a = Math.pow(1 - p, 0.5) * Math.min(1, p * 8);
+ if (a <= 0.01) return;
+ // HQ's unit vector in view space (x right, y up, z toward viewer)
+ const { lon0, sinLat0, cosLat0 } = proj;
+ const cosLon0 = Math.cos(lon0),
+ sinLon0 = Math.sin(lon0);
+ const cd = hqTrig.cosLng * cosLon0 + hqTrig.sinLng * sinLon0;
+ const sd = hqTrig.sinLng * cosLon0 - hqTrig.cosLng * sinLon0;
+ const hx = hqTrig.cosLat * sd;
+ const hy = cosLat0 * hqTrig.sinLat - sinLat0 * hqTrig.cosLat * cd;
+ const hz = sinLat0 * hqTrig.sinLat + cosLat0 * hqTrig.cosLat * cd;
+ // orthonormal basis ⊥ HQ vector
+ let ux = hz,
+ uy = 0,
+ uz = -hx;
+ const ul = Math.hypot(ux, uz);
+ if (ul < 1e-4) {
+ ux = 1;
+ uy = 0;
+ uz = 0;
+ } else {
+ ux /= ul;
+ uz /= ul;
+ }
+ const vx = hy * uz - hz * uy,
+ vy = hz * ux - hx * uz,
+ vz = hx * uy - hy * ux;
+ const cD = Math.cos(delta),
+ sD = Math.sin(delta);
+ // trace the expanding small circle, breaking where it rounds the limb
+ ADD(ctx);
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ const path = new Path2D();
+ let pen = false;
+ for (let i = 0; i <= HB_N; i++) {
+ const ct = HB_COS[i],
+ st = HB_SIN[i];
+ const qz = hz * cD + (uz * ct + vz * st) * sD;
+ if (qz <= 0.02) {
+ pen = false;
+ continue;
+ }
+ const qx = hx * cD + (ux * ct + vx * st) * sD;
+ const qy = hy * cD + (uy * ct + vy * st) * sD;
+ const sxp = CX + R * qx,
+ syp = CY - R * qy;
+ if (!pen) {
+ path.moveTo(sxp, syp);
+ pen = true;
+ } else path.lineTo(sxp, syp);
+ }
+ ctx.strokeStyle = rgba('#5ad1ff', a * 0.22);
+ ctx.lineWidth = 5;
+ ctx.stroke(path);
+ ctx.strokeStyle = rgba('#8cdfff', a * 0.75);
+ ctx.lineWidth = 1.8;
+ ctx.stroke(path);
+ if (p < 0.3 && e.hq) drawGlow(ctx, e.hq[0], e.hq[1], 18, ((0.3 - p) / 0.3) * 0.5);
+ NORMAL(ctx);
+ },
+ };
+}
+
+// ======================================================================
+// Surge marker — pulsing rings + label on the city that's lighting up
+// ======================================================================
+export function surgeLayer() {
+ return {
+ name: 'surgeMarker',
+ z: 67,
+ visible: (e) => !!e.surge,
+ draw(e) {
+ const { ctx, now } = e;
+ const s = e.surge;
+ if (!s || !e.proj.visible(s.city.lnglat)) return;
+ const p0 = e.proj.forward(s.city.lnglat);
+ if (!p0) return;
+ const [x, y] = p0;
+ const a = Math.min(1, (now - s.t0) / 400) * Math.min(1, (s.until - now) / 600);
+ if (a <= 0) return;
+ for (const off of [0, 550]) {
+ const p = ((now + off) % 1100) / 1100;
+ ctx.globalAlpha = (1 - p) * 0.7 * a;
+ ctx.strokeStyle = '#7cd4ff';
+ ctx.lineWidth = 1.2;
+ ctx.beginPath();
+ ctx.arc(x, y, 3 + p * 22, 0, TAU);
+ ctx.stroke();
+ }
+ ctx.globalAlpha = 1;
+ ADD(ctx);
+ drawGlow(ctx, x, y, 10, 0.6 * a);
+ NORMAL(ctx);
+ ctx.globalAlpha = a;
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(x, y, 2.5, 0, TAU);
+ ctx.fill();
+ ctx.textBaseline = 'alphabetic';
+ ctx.lineJoin = 'round';
+ ctx.font = "600 12px 'Space Grotesk', system-ui, sans-serif";
+ ctx.strokeStyle = 'rgba(5,6,12,0.8)';
+ ctx.lineWidth = 3.5;
+ ctx.strokeText(s.city.name, x + 10, y - 8);
+ ctx.fillStyle = '#bfe9ff';
+ ctx.fillText(s.city.name, x + 10, y - 8);
+ ctx.globalAlpha = 1;
+ },
+ };
+}
diff --git a/canvas/draw.js b/canvas/draw.js
new file mode 100644
index 0000000..34e1e68
--- /dev/null
+++ b/canvas/draw.js
@@ -0,0 +1,62 @@
+/* games.directory globe — shared Canvas2D draw helpers
+ *
+ * Tiny rendering utilities used by both canvas/layers.js (the core layers)
+ * and canvas/cinema.js (the cinematic extras). Rendering only — no geometry,
+ * no simulation.
+ */
+
+export const ADD = (ctx) => (ctx.globalCompositeOperation = 'lighter');
+export const NORMAL = (ctx) => (ctx.globalCompositeOperation = 'source-over');
+
+// A cached soft white glow sprite, blitted with drawImage instead of the very
+// expensive per-point ctx.shadowBlur (which forces an offscreen blur per draw).
+let _glow = null;
+export function glowSprite() {
+ if (_glow) return _glow;
+ const S = 128,
+ c = document.createElement('canvas');
+ c.width = c.height = S;
+ const g = c.getContext('2d');
+ const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
+ grad.addColorStop(0, 'rgba(255,255,255,1)');
+ grad.addColorStop(0.25, 'rgba(255,255,255,0.55)');
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
+ g.fillStyle = grad;
+ g.fillRect(0, 0, S, S);
+ _glow = c;
+ return _glow;
+}
+// Soft glow of radius r at (x,y). Caller sets composite; this resets globalAlpha.
+export function drawGlow(ctx, x, y, r, alpha) {
+ ctx.globalAlpha = alpha;
+ ctx.drawImage(glowSprite(), x - r, y - r, r * 2, r * 2);
+ ctx.globalAlpha = 1;
+}
+// Render a layer's static art into an offscreen canvas once (device-pixel sized).
+export function makeSprite(e, paint) {
+ const c = document.createElement('canvas');
+ c.width = Math.max(2, Math.round(e.W * e.dpr));
+ c.height = Math.max(2, Math.round(e.H * e.dpr));
+ const g = c.getContext('2d');
+ g.setTransform(e.dpr, 0, 0, e.dpr, 0, 0);
+ paint(g);
+ return c;
+}
+// Blit a full-screen sprite 1:1 over the backing store (transform-independent).
+export function blit(ctx, sprite, alpha, additive) {
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ if (additive) ctx.globalCompositeOperation = 'lighter';
+ ctx.globalAlpha = alpha;
+ ctx.drawImage(sprite, 0, 0);
+ ctx.restore();
+}
+// Trace a list of flat [x,y,x,y,…] segments into a Path2D (one subpath each).
+export function pathFromSegments(segments) {
+ const p = new Path2D();
+ for (const s of segments) {
+ p.moveTo(s[0], s[1]);
+ for (let i = 2; i < s.length; i += 2) p.lineTo(s[i], s[i + 1]);
+ }
+ return p;
+}
diff --git a/canvas/layers.js b/canvas/layers.js
index 139ac7b..364cbc7 100644
--- a/canvas/layers.js
+++ b/canvas/layers.js
@@ -10,142 +10,374 @@
*
* Glows are additive (globalCompositeOperation='lighter' + layered strokes/
* sprites) rather than per-frame blur filters — the same look, far cheaper.
+ * Static art (sphere, atmosphere, nebula, glare) is baked into offscreen
+ * sprites on resize and blitted per frame; the day/night terminator renders
+ * into a low-res offscreen whose upscale gives a naturally soft twilight edge
+ * at a fraction of the fill cost.
*
* Depends on the global `d3` (graticule/terminator geo-paths).
*/
-import { TAU, easeInOutCubic, tint, rgba } from "../shared/util.js";
-import { DENSITY_STEP } from "../shared/config.js";
+import { TAU, easeInOutCubic, tint, rgba, clamp } from '../shared/util.js';
+import { DENSITY_STEP } from '../shared/config.js';
+import { ADD, NORMAL, drawGlow, makeSprite, blit, pathFromSegments } from './draw.js';
import {
- ORBIT_DEFS, computeOrbit, arcControl, quadSplit, quadPoint,
- buildLandDots, buildSpikes, pickNodes, auroraSpecs, auroraSegments,
-} from "../shared/geometry.js";
+ moonLayer,
+ cometLayer,
+ constellationsLayer,
+ sunGlintLayer,
+ heartbeatLayer,
+ surgeLayer,
+} from './cinema.js';
import {
- BEAM, pickBeam, beamEnvelope, spawnMeteorParams, stepMeteor, meteorOpacity,
- fireworkBurst, fireworkBarrage, stepFirework, fireworkAlpha,
-} from "../shared/sim.js";
-
-const ADD = (ctx) => (ctx.globalCompositeOperation = "lighter");
-const NORMAL = (ctx) => (ctx.globalCompositeOperation = "source-over");
+ ORBIT_DEFS,
+ computeOrbit,
+ arcControl,
+ quadSplit,
+ quadPoint,
+ buildLandDots,
+ buildSpikes,
+ pickNodes,
+ auroraSpecs,
+ auroraSegments,
+} from '../shared/geometry.js';
+import {
+ BEAM,
+ pickBeam,
+ beamEnvelope,
+ spawnMeteorParams,
+ stepMeteor,
+ meteorOpacity,
+ fireworkBurst,
+ fireworkBarrage,
+ stepFirework,
+ fireworkAlpha,
+} from '../shared/sim.js';
-// A cached soft white glow sprite, blitted with drawImage instead of the very
-// expensive per-point ctx.shadowBlur (which forces an offscreen blur per draw).
-let _glow = null;
-function glowSprite() {
- if (_glow) return _glow;
- const S = 128, c = document.createElement("canvas");
- c.width = c.height = S;
- const g = c.getContext("2d");
- const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
- grad.addColorStop(0, "rgba(255,255,255,1)");
- grad.addColorStop(0.25, "rgba(255,255,255,0.55)");
- grad.addColorStop(1, "rgba(255,255,255,0)");
- g.fillStyle = grad; g.fillRect(0, 0, S, S);
- _glow = c; return _glow;
-}
-// Soft glow of radius r at (x,y). Caller sets composite; this resets globalAlpha.
-function drawGlow(ctx, x, y, r, alpha) {
- ctx.globalAlpha = alpha;
- ctx.drawImage(glowSprite(), x - r, y - r, r * 2, r * 2);
- ctx.globalAlpha = 1;
-}
-// Render a layer's static art into an offscreen canvas once (device-pixel sized).
-function makeSprite(e, paint) {
- const c = document.createElement("canvas");
- c.width = Math.round(e.W * e.dpr); c.height = Math.round(e.H * e.dpr);
- const g = c.getContext("2d");
- g.setTransform(e.dpr, 0, 0, e.dpr, 0, 0);
- paint(g);
- return c;
-}
-// Blit a full-screen sprite 1:1 over the backing store (transform-independent).
-function blit(ctx, sprite, alpha, additive) {
- ctx.save();
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- if (additive) ctx.globalCompositeOperation = "lighter";
- ctx.globalAlpha = alpha;
- ctx.drawImage(sprite, 0, 0);
- ctx.restore();
+// ======================================================================
+// Nebula — vast, slow-drifting hue washes behind everything (cached)
+// ======================================================================
+export function nebulaLayer() {
+ let sprite = null,
+ sw = 0,
+ sh = 0;
+ function build(e) {
+ // baked at low res (it's pure soft gradient — invisible difference)
+ const Q = 0.2;
+ sw = e.W * 1.3;
+ sh = e.H * 1.3;
+ sprite = document.createElement('canvas');
+ sprite.width = Math.max(2, Math.round(sw * Q));
+ sprite.height = Math.max(2, Math.round(sh * Q));
+ const g = sprite.getContext('2d');
+ g.setTransform(Q, 0, 0, Q, 0, 0);
+ g.globalCompositeOperation = 'lighter';
+ const blob = (fx, fy, fr, stops) => {
+ const grad = g.createRadialGradient(sw * fx, sh * fy, 0, sw * fx, sh * fy, sw * fr);
+ for (const [o, c] of stops) grad.addColorStop(o, c);
+ g.fillStyle = grad;
+ g.fillRect(0, 0, sw, sh);
+ };
+ blob(0.74, 0.18, 0.42, [
+ [0, 'rgba(124,92,242,0.14)'],
+ [0.55, 'rgba(96,72,210,0.06)'],
+ [1, 'rgba(0,0,0,0)'],
+ ]);
+ blob(0.14, 0.8, 0.4, [
+ [0, 'rgba(48,108,222,0.12)'],
+ [0.55, 'rgba(40,86,190,0.05)'],
+ [1, 'rgba(0,0,0,0)'],
+ ]);
+ blob(0.46, 0.4, 0.52, [
+ [0, 'rgba(58,176,212,0.05)'],
+ [1, 'rgba(0,0,0,0)'],
+ ]);
+ }
+ return {
+ name: 'nebula',
+ z: -4,
+ resize(e) {
+ build(e);
+ },
+ visible: (e) => e.scene.nebula,
+ draw(e) {
+ const { ctx, now, W, H } = e;
+ if (!sprite) build(e);
+ const ia = e.introPhase(0, 0.35);
+ if (ia <= 0) return;
+ // slow autonomous drift + a bounded lean toward the pointer
+ const dx = Math.sin(now * 1.7e-5) * W * 0.015 - W * 0.15 - e.look.x * 4.5;
+ const dy = Math.cos(now * 1.3e-5) * H * 0.012 - H * 0.15 + e.look.y * 4.5;
+ ADD(ctx);
+ ctx.globalAlpha = ia;
+ ctx.drawImage(sprite, dx, dy, sw, sh);
+ ctx.globalAlpha = 1;
+ NORMAL(ctx);
+ },
+ };
}
-// Trace a list of flat [x,y,x,y,…] segments into a Path2D (one subpath each).
-function pathFromSegments(segments) {
- const p = new Path2D();
- for (const s of segments) {
- p.moveTo(s[0], s[1]);
- for (let i = 2; i < s.length; i += 2) p.lineTo(s[i], s[i + 1]);
+
+// ======================================================================
+// Starfield — in-canvas parallax stars that answer the globe's rotation
+// ======================================================================
+export function starfieldLayer() {
+ let n = 0,
+ x,
+ y,
+ r,
+ grp,
+ depth,
+ bright = [];
+ const COLS = ['#ffffff', '#dfe9ff', '#cfdcff'];
+ function build(e) {
+ n = clamp(Math.round((e.W * e.H) / 7000), 90, 280);
+ x = new Float32Array(n);
+ y = new Float32Array(n);
+ r = new Float32Array(n);
+ grp = new Uint8Array(n);
+ depth = new Float32Array(n);
+ for (let i = 0; i < n; i++) {
+ x[i] = Math.random() * e.W;
+ y[i] = Math.random() * e.H;
+ r[i] = 0.5 + Math.random() * 1.0;
+ grp[i] = (Math.random() * 3) | 0;
+ depth[i] = 0.35 + Math.random() * 0.65;
+ }
+ bright = [];
+ for (let i = 0; i < 12; i++) {
+ bright.push({
+ x: Math.random() * e.W,
+ y: Math.random() * e.H,
+ r: 1.1 + Math.random() * 1.2,
+ ph: Math.random() * TAU,
+ sp: 0.4 + Math.random() * 0.9,
+ depth: 0.55 + Math.random() * 0.45,
+ warm: Math.random() < 0.3,
+ cross: i < 4,
+ });
+ }
}
- return p;
+ return {
+ name: 'starfield',
+ z: -3,
+ resize(e) {
+ build(e);
+ },
+ visible: (e) => e.scene.parallaxStars,
+ draw(e) {
+ const { ctx, W, H, now } = e;
+ if (!x) build(e);
+ const rot = e.rotAcc,
+ K = 2.4; // unwrapped ° → px of parallax at depth 1
+ const tw = e.scene.starTwinkle;
+ ADD(ctx);
+ // dim stars: one fillStyle + one globalAlpha per twinkle group; each
+ // group resolves out of the darkness at a slightly different moment
+ for (let g = 0; g < 3; g++) {
+ const ia = e.introPhase(0.02 + g * 0.07, 0.45 + g * 0.05);
+ if (ia <= 0) continue;
+ ctx.fillStyle = COLS[g];
+ ctx.globalAlpha =
+ (tw ? 0.38 + 0.3 * (0.5 + 0.5 * Math.sin(now * 0.0008 + g * 2.1)) : 0.55) * ia;
+ for (let i = 0; i < n; i++) {
+ if (grp[i] !== g) continue;
+ let px = (x[i] - rot * K * depth[i]) % W;
+ if (px < 0) px += W;
+ ctx.fillRect(px - r[i] / 2, y[i] - r[i] / 2, r[i], r[i]);
+ }
+ }
+ ctx.globalAlpha = 1;
+ // bright stars: individual twinkle, soft glow, a few cross sparkles;
+ // they pop in last, at the tail of the arrival
+ const ib = e.introPhase(0.3, 0.58);
+ if (ib > 0)
+ for (const b of bright) {
+ let px = (b.x - rot * K * b.depth) % W;
+ if (px < 0) px += W;
+ const a =
+ (tw ? 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(now * 0.001 * b.sp + b.ph)) : 0.8) * ib;
+ const col = b.warm ? '#ffe7c2' : '#eaf2ff';
+ drawGlow(ctx, px, b.y, b.r * 3.2, a * 0.5);
+ ctx.globalAlpha = a;
+ ctx.fillStyle = col;
+ ctx.beginPath();
+ ctx.arc(px, b.y, b.r, 0, TAU);
+ ctx.fill();
+ if (b.cross) {
+ const L = b.r * 4.5;
+ ctx.globalAlpha = a * 0.4;
+ ctx.strokeStyle = col;
+ ctx.lineWidth = 0.8;
+ ctx.beginPath();
+ ctx.moveTo(px - L, b.y);
+ ctx.lineTo(px + L, b.y);
+ ctx.moveTo(px, b.y - L);
+ ctx.lineTo(px, b.y + L);
+ ctx.stroke();
+ }
+ }
+ ctx.globalAlpha = 1;
+ NORMAL(ctx);
+ },
+ };
}
// ======================================================================
-// Atmosphere — breathing rim glow + bottom bloom (cached sprites)
+// Atmosphere — breathing rim glow, bottom bloom + sun glare on the limb
// ======================================================================
export function atmosphereLayer() {
// Sprites bake the shape at reference strength; live atmos + pulse modulate
// them via globalAlpha at blit time, so tuning never rebuilds anything.
- let rim = null, bottom = null;
+ let rim = null,
+ bottom = null,
+ glare = null;
function build(e) {
const { CX, CY, R } = e;
rim = makeSprite(e, (g) => {
- const grad = g.createRadialGradient(CX, CY, R * 0.82, CX, CY, R * 1.16);
- grad.addColorStop(0, "rgba(90,209,255,0)");
- grad.addColorStop(0.80, "rgba(90,209,255,0)");
- grad.addColorStop(0.92, "rgba(110,200,255,0.55)");
- grad.addColorStop(0.97, "rgba(124,107,255,0.38)");
- grad.addColorStop(1, "rgba(124,107,255,0)");
- g.fillStyle = grad; g.beginPath(); g.arc(CX, CY, R * 1.16, 0, TAU); g.fill();
+ const grad = g.createRadialGradient(CX, CY, R * 0.78, CX, CY, R * 1.22);
+ grad.addColorStop(0, 'rgba(90,200,255,0)');
+ grad.addColorStop(0.4, 'rgba(96,200,255,0.10)');
+ grad.addColorStop(0.5, 'rgba(112,205,255,0.50)');
+ grad.addColorStop(0.62, 'rgba(132,145,255,0.30)');
+ grad.addColorStop(0.8, 'rgba(124,107,255,0.12)');
+ grad.addColorStop(1, 'rgba(124,107,255,0)');
+ g.fillStyle = grad;
+ g.beginPath();
+ g.arc(CX, CY, R * 1.22, 0, TAU);
+ g.fill();
+ // crisp atmosphere shell just outside the limb
+ g.globalCompositeOperation = 'lighter';
+ g.strokeStyle = 'rgba(170,225,255,0.28)';
+ g.lineWidth = 1.4;
+ g.beginPath();
+ g.arc(CX, CY, R * 1.005, 0, TAU);
+ g.stroke();
});
bottom = makeSprite(e, (g) => {
- g.translate(CX, CY + R * 0.86); g.scale(R * 0.62, R * 0.26);
+ g.translate(CX, CY + R * 0.86);
+ g.scale(R * 0.62, R * 0.26);
const grad = g.createRadialGradient(0, 0, 0, 0, 0, 1);
- grad.addColorStop(0, "rgba(180,215,255,0.55)");
- grad.addColorStop(0.4, "rgba(120,170,255,0.18)");
- grad.addColorStop(1, "rgba(0,0,0,0)");
- g.fillStyle = grad; g.beginPath(); g.arc(0, 0, 1, 0, TAU); g.fill();
+ grad.addColorStop(0, 'rgba(180,215,255,0.5)');
+ grad.addColorStop(0.4, 'rgba(120,170,255,0.16)');
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
+ g.fillStyle = grad;
+ g.beginPath();
+ g.arc(0, 0, 1, 0, TAU);
+ g.fill();
});
+ // warm scatter bloom, positioned each frame at the limb nearest the sun
+ const GS = 256;
+ glare = document.createElement('canvas');
+ glare.width = glare.height = GS;
+ const gg = glare.getContext('2d');
+ const grad = gg.createRadialGradient(GS / 2, GS / 2, 0, GS / 2, GS / 2, GS / 2);
+ grad.addColorStop(0, 'rgba(255,243,222,0.85)');
+ grad.addColorStop(0.25, 'rgba(255,214,160,0.32)');
+ grad.addColorStop(0.6, 'rgba(255,176,124,0.09)');
+ grad.addColorStop(1, 'rgba(255,170,120,0)');
+ gg.fillStyle = grad;
+ gg.fillRect(0, 0, GS, GS);
}
return {
- name: "atmosphere", z: 10,
- resize(e) { build(e); },
+ name: 'atmosphere',
+ z: 10,
+ resize(e) {
+ build(e);
+ },
visible: (e) => e.scene.atmos > 0,
draw(e) {
- const { ctx, scene, now } = e;
+ const { ctx, scene, now, CX, CY, R, proj } = e;
if (!rim) build(e);
- const pulse = scene.atmosPulse ? 0.82 + 0.18 * Math.sin(now * 0.0011) : 1;
- const bpulse = scene.atmosPulse ? 0.88 + 0.12 * Math.sin(now * 0.0011 + 1.2) : 1;
- blit(ctx, rim, Math.min(1, scene.atmos * pulse), true);
- blit(ctx, bottom, Math.min(1, scene.atmos * bpulse), true);
- ctx.globalAlpha = 1; NORMAL(ctx);
+ // ignition: the rim overshoots ~30% mid-arrival, then settles
+ const ig = e.introPhase(0.25, 0.68);
+ const ignite = ig >= 1 ? 1 : ig * (1 + 0.35 * Math.sin(ig * Math.PI));
+ const pulse = scene.atmosPulse ? 0.84 + 0.16 * Math.sin(now * 0.0009) : 1;
+ const bpulse = scene.atmosPulse ? 0.9 + 0.1 * Math.sin(now * 0.0009 + 1.2) : 1;
+ blit(ctx, rim, Math.min(1, scene.atmos * pulse * ignite), true);
+ blit(ctx, bottom, Math.min(1, scene.atmos * bpulse * e.introPhase(0.3, 0.7)), true);
+ // sun glare — sits UNDER the sphere disc, so the globe occludes it
+ // naturally and it reads as light scattering around the limb.
+ if (scene.sunGlare) {
+ const s = proj.sunDir();
+ const len = Math.hypot(s.x, s.y) || 1e-6;
+ const lx = s.z < 0 ? s.x / len : s.x,
+ ly = s.z < 0 ? s.y / len : s.y;
+ const px = CX + R * lx,
+ py = CY - R * ly;
+ const a =
+ clamp((s.z + 0.55) / 1.1, 0, 1) *
+ Math.min(1, scene.atmos) *
+ 0.7 *
+ e.introPhase(0.4, 0.75);
+ if (a > 0.01) {
+ ADD(ctx);
+ ctx.globalAlpha = a;
+ ctx.drawImage(glare, px - R * 0.95, py - R * 0.95, R * 1.9, R * 1.9);
+ ctx.globalAlpha = Math.min(1, a * 1.3);
+ ctx.drawImage(glare, px - R * 0.42, py - R * 0.42, R * 0.84, R * 0.84);
+ ctx.globalAlpha = 1;
+ }
+ }
+ NORMAL(ctx);
},
};
}
// ======================================================================
-// Sphere — dark globe disc + highlight (static → cached sprite)
+// Sphere — globe disc: offset-lit ocean depth + inner limb scatter (cached)
// ======================================================================
export function sphereLayer() {
let sprite = null;
function build(e) {
const { CX, CY, R } = e;
sprite = makeSprite(e, (g) => {
- const grad = g.createRadialGradient(CX - R * 0.2, CY - R * 0.28, R * 0.1, CX, CY, R);
- grad.addColorStop(0, "#0e1d33");
- grad.addColorStop(0.55, "#070f1f");
- grad.addColorStop(1, "#02040a");
- g.fillStyle = grad; g.beginPath(); g.arc(CX, CY, R, 0, TAU); g.fill();
- g.globalCompositeOperation = "lighter";
- const h = g.createRadialGradient(CX - R * 0.32, CY - R * 0.34, R * 0.05, CX - R * 0.32, CY - R * 0.34, R);
- h.addColorStop(0, "rgba(120,180,255,0.20)");
- h.addColorStop(0.45, "rgba(120,180,255,0.04)");
- h.addColorStop(1, "rgba(0,0,0,0)");
- g.fillStyle = h; g.beginPath(); g.arc(CX, CY, R, 0, TAU); g.fill();
+ const grad = g.createRadialGradient(CX - R * 0.22, CY - R * 0.3, R * 0.08, CX, CY, R);
+ grad.addColorStop(0, '#13294a');
+ grad.addColorStop(0.4, '#0c1b36');
+ grad.addColorStop(0.7, '#071226');
+ grad.addColorStop(0.92, '#040b18');
+ grad.addColorStop(1, '#030711');
+ g.fillStyle = grad;
+ g.beginPath();
+ g.arc(CX, CY, R, 0, TAU);
+ g.fill();
+ g.globalCompositeOperation = 'lighter';
+ // ambient key light, upper-left
+ const h = g.createRadialGradient(
+ CX - R * 0.32,
+ CY - R * 0.34,
+ R * 0.05,
+ CX - R * 0.32,
+ CY - R * 0.34,
+ R
+ );
+ h.addColorStop(0, 'rgba(120,180,255,0.22)');
+ h.addColorStop(0.45, 'rgba(120,180,255,0.05)');
+ h.addColorStop(1, 'rgba(0,0,0,0)');
+ g.fillStyle = h;
+ g.beginPath();
+ g.arc(CX, CY, R, 0, TAU);
+ g.fill();
+ // atmospheric scatter just INSIDE the limb — luminous shell depth
+ const s = g.createRadialGradient(CX, CY, R * 0.82, CX, CY, R);
+ s.addColorStop(0, 'rgba(90,170,255,0)');
+ s.addColorStop(0.72, 'rgba(90,170,255,0.05)');
+ s.addColorStop(0.94, 'rgba(110,190,255,0.16)');
+ s.addColorStop(1, 'rgba(140,210,255,0.26)');
+ g.fillStyle = s;
+ g.beginPath();
+ g.arc(CX, CY, R, 0, TAU);
+ g.fill();
});
}
return {
- name: "sphere", z: 30,
- resize(e) { build(e); },
+ name: 'sphere',
+ z: 30,
+ resize(e) {
+ build(e);
+ },
draw(e) {
if (!sprite) build(e);
- blit(e.ctx, sprite, 1, false);
+ blit(e.ctx, sprite, e.introPhase(0.12, 0.5), false);
e.ctx.globalAlpha = 1;
},
};
@@ -155,7 +387,8 @@ export function sphereLayer() {
// Orbital rings — split front/back around the globe (geometry shared)
// ======================================================================
// Memoised per frame so the back + front orbit layers share one computation.
-let orbitCache = null, orbitCacheFrame = -1;
+let orbitCache = null,
+ orbitCacheFrame = -1;
function getOrbits(e) {
if (orbitCacheFrame === e.frameCount) return orbitCache;
orbitCache = ORBIT_DEFS.map((cfg, o) => {
@@ -167,31 +400,45 @@ function getOrbits(e) {
}
export function orbitsBackLayer() {
return {
- name: "orbitsBack", z: 20,
+ name: 'orbitsBack',
+ z: 20,
visible: (e) => e.scene.orbits,
draw(e) {
const { ctx } = e;
- ctx.lineWidth = 0.7; ctx.strokeStyle = "rgba(111,155,214,0.16)";
+ const ia = e.introPhase(0.55, 0.85);
+ if (ia <= 0) return;
+ ctx.globalAlpha = ia;
+ ctx.lineWidth = 0.7;
+ ctx.strokeStyle = 'rgba(111,155,214,0.15)';
for (const ob of getOrbits(e)) ctx.stroke(ob.back);
+ ctx.globalAlpha = 1;
},
};
}
export function orbitsFrontLayer() {
return {
- name: "orbitsFront", z: 80,
+ name: 'orbitsFront',
+ z: 80,
visible: (e) => e.scene.orbits,
draw(e) {
const { ctx } = e;
+ const ia = e.introPhase(0.55, 0.85);
+ if (ia <= 0) return;
ADD(ctx);
- ctx.lineWidth = 0.85; ctx.strokeStyle = "rgba(188,214,255,0.5)";
+ ctx.globalAlpha = ia;
+ ctx.lineWidth = 0.85;
+ ctx.strokeStyle = 'rgba(188,214,255,0.44)';
const orbits = getOrbits(e);
for (const ob of orbits) ctx.stroke(ob.front);
for (const ob of orbits) {
if (!ob.sat) continue;
- const sa = ob.sat.front ? 1 : 0.15;
+ const sa = (ob.sat.front ? 1 : 0.15) * ia;
drawGlow(ctx, ob.sat.x, ob.sat.y, 6, sa);
- ctx.globalAlpha = sa; ctx.fillStyle = "#cce6ff";
- ctx.beginPath(); ctx.arc(ob.sat.x, ob.sat.y, 2, 0, TAU); ctx.fill();
+ ctx.globalAlpha = sa;
+ ctx.fillStyle = '#cce6ff';
+ ctx.beginPath();
+ ctx.arc(ob.sat.x, ob.sat.y, 2, 0, TAU);
+ ctx.fill();
ctx.globalAlpha = 1;
}
NORMAL(ctx);
@@ -205,20 +452,25 @@ export function orbitsFrontLayer() {
export function spikesLayer() {
let s = null;
return {
- name: "spikes", z: 35,
+ name: 'spikes',
+ z: 35,
visible: (e) => e.scene.corona && e.scene.coronaIntensity > 0,
- build() { s = buildSpikes(); },
+ build() {
+ s = buildSpikes();
+ },
draw(e) {
const { ctx, CX, CY, R, now, scene, proj } = e;
const { lon0, sinLat0, cosLat0 } = proj;
+ const cosLon0 = Math.cos(lon0),
+ sinLon0 = Math.sin(lon0);
const tt = now * 0.0016;
ADD(ctx);
ctx.beginPath();
for (let i = 0; i < s.n; i++) {
- const dlon = s.lng[i] - lon0, cd = Math.cos(dlon);
+ const cd = s.cosLng[i] * cosLon0 + s.sinLng[i] * sinLon0;
const cosc = sinLat0 * s.sin[i] + cosLat0 * s.cos[i] * cd;
if (cosc <= 0) continue;
- const sd = Math.sin(dlon);
+ const sd = s.sinLng[i] * cosLon0 - s.cosLng[i] * sinLon0;
const px = CX + R * (s.cos[i] * sd);
const py = CY - R * (cosLat0 * s.sin[i] - sinLat0 * s.cos[i] * cd);
const pulse = 0.7 + 0.3 * Math.sin(tt + s.phase[i]);
@@ -226,8 +478,12 @@ export function spikesLayer() {
ctx.moveTo(px, py);
ctx.lineTo(CX + (px - CX) * (1 + len), CY + (py - CY) * (1 + len));
}
- ctx.lineWidth = 0.6; ctx.lineCap = "round";
- ctx.strokeStyle = rgba("#9fc6ff", Math.min(1, scene.coronaIntensity * 1.6));
+ ctx.lineWidth = 0.6;
+ ctx.lineCap = 'round';
+ ctx.strokeStyle = rgba(
+ '#9fc6ff',
+ Math.min(1, scene.coronaIntensity * 1.6) * e.introPhase(0.55, 0.9)
+ );
ctx.stroke();
NORMAL(ctx);
},
@@ -240,13 +496,17 @@ export function spikesLayer() {
export function graticuleLayer() {
let path = null;
return {
- name: "graticule", z: 40,
+ name: 'graticule',
+ z: 40,
visible: (e) => e.scene.grid,
draw(e) {
const { ctx, proj } = e;
if (!path) path = d3.geoPath(proj.d3proj, ctx);
- ctx.beginPath(); path(proj.graticule());
- ctx.lineWidth = 0.6; ctx.strokeStyle = "rgba(140,160,210,0.34)"; ctx.stroke();
+ ctx.beginPath();
+ path(proj.graticule());
+ ctx.lineWidth = 0.6;
+ ctx.strokeStyle = 'rgba(140,160,210,0.30)';
+ ctx.stroke();
},
};
}
@@ -255,47 +515,87 @@ export function graticuleLayer() {
// Land — dotted relief; tags night-side city dots for cityLightsLayer
// ======================================================================
export function landLayer() {
- let d = null, sx, sy, vis;
+ let d = null,
+ sx,
+ sy,
+ vis;
return {
- name: "land", z: 50, rebuildOn: ["density"],
+ name: 'land',
+ z: 50,
+ rebuildOn: ['density'],
build(e) {
if (!e.data.landFeature) return;
d = buildLandDots(e.data.landFeature, DENSITY_STEP[e.scene.density] || 3.0);
- sx = new Float32Array(d.n); sy = new Float32Array(d.n); vis = new Uint8Array(d.n);
+ sx = new Float32Array(d.n);
+ sy = new Float32Array(d.n);
+ vis = new Uint8Array(d.n);
e.landDots = d.pts; // real [lng,lat] pairs, used to seed star nodes
- e.cityLights = { x: new Float32Array(d.n), y: new Float32Array(d.n), grp: new Uint8Array(d.n), n: 0 };
+ e.cityLights = {
+ x: new Float32Array(d.n),
+ y: new Float32Array(d.n),
+ grp: new Uint8Array(d.n),
+ n: 0,
+ };
},
draw(e) {
const { ctx, CX, CY, R, scene, proj } = e;
const { lon0, sinLat0, cosLat0 } = proj;
- const sun = proj.sun, cityOn = scene.dayNight && scene.cityLights;
- const cl = e.cityLights; let cn = 0;
- // pass A: project every front dot once; tag night-side city dots
+ const cosLon0 = Math.cos(lon0),
+ sinLon0 = Math.sin(lon0);
+ const sun = proj.sun,
+ cityOn = scene.dayNight && scene.cityLights;
+ const cl = e.cityLights;
+ let cn = 0;
+ const sinA = d.sin,
+ cosA = d.cos,
+ sLng = d.sinLng,
+ cLng = d.cosLng,
+ cityA = d.isCity,
+ grpA = d.grp;
+ // pass A: project every front dot once (no trig — angle-sum identities
+ // over the precomputed sin/cos tables); tag night-side city dots
for (let i = 0; i < d.n; i++) {
- const dlon = d.lng[i] - lon0, cd = Math.cos(dlon);
- const cosc = sinLat0 * d.sin[i] + cosLat0 * d.cos[i] * cd;
- if (cosc <= 0) { vis[i] = 0; continue; }
- const sd = Math.sin(dlon);
- sx[i] = CX + R * (d.cos[i] * sd);
- sy[i] = CY - R * (cosLat0 * d.sin[i] - sinLat0 * d.cos[i] * cd);
+ const cd = cLng[i] * cosLon0 + sLng[i] * sinLon0; // cos(lng − lon0)
+ const cosc = sinLat0 * sinA[i] + cosLat0 * cosA[i] * cd;
+ if (cosc <= 0) {
+ vis[i] = 0;
+ continue;
+ }
+ const sd = sLng[i] * cosLon0 - cLng[i] * sinLon0; // sin(lng − lon0)
+ sx[i] = CX + R * (cosA[i] * sd);
+ sy[i] = CY - R * (cosLat0 * sinA[i] - sinLat0 * cosA[i] * cd);
vis[i] = 1;
- if (cityOn && d.isCity[i]) {
- const cosSun = sun.sinLat * d.sin[i] + sun.cosLat * d.cos[i] * Math.cos(d.lng[i] - sun.lon);
- if (cosSun < 0.04) { cl.x[cn] = sx[i]; cl.y[cn] = sy[i]; cl.grp[cn] = d.grp[i]; cn++; }
+ if (cityOn && cityA[i]) {
+ const cosSun =
+ sun.sinLat * sinA[i] +
+ sun.cosLat * cosA[i] * (cLng[i] * sun.cosLon + sLng[i] * sun.sinLon);
+ if (cosSun < 0.04) {
+ cl.x[cn] = sx[i];
+ cl.y[cn] = sy[i];
+ cl.grp[cn] = grpA[i];
+ cn++;
+ }
}
}
cl.n = cn;
- // passes B–D: one fillStyle per relief tier
- const base = scene.dotSize, tex = scene.texture, lb = scene.landBright;
+ // passes B–D: dots are tier-SORTED, so each tier is one contiguous run
+ // with a single fillStyle and zero per-dot branching on tier
+ const base = scene.dotSize,
+ tex = scene.texture,
+ lb = scene.landBright * e.introPhase(0.35, 0.75);
const sizes = [base * (1 - tex), base, base * (1 + tex * 1.5)];
const alphas = [0.8 * lb, 0.92 * lb, 1.0 * lb];
ADD(ctx);
+ let start = 0;
for (let t = 0; t < 3; t++) {
- const sz = sizes[t], half = sz / 2;
- ctx.fillStyle = rgba("#bfe0ff", Math.min(1, alphas[t]));
- for (let i = 0; i < d.n; i++) {
- if (vis[i] && d.tier[i] === t) ctx.fillRect(sx[i] - half, sy[i] - half, sz, sz);
+ const end = d.tierEnd[t],
+ sz = sizes[t],
+ half = sz / 2;
+ ctx.fillStyle = rgba('#bfe0ff', Math.min(1, alphas[t]));
+ for (let i = start; i < end; i++) {
+ if (vis[i]) ctx.fillRect(sx[i] - half, sy[i] - half, sz, sz);
}
+ start = end;
}
NORMAL(ctx);
},
@@ -303,43 +603,69 @@ export function landLayer() {
}
// ======================================================================
-// Night — day/night terminator (twilight band + deep-night core)
+// Night — day/night terminator, rendered into a low-res offscreen whose
+// upscale gives a naturally soft twilight gradient (and costs ~1/20th the fill)
// ======================================================================
export function nightLayer() {
- let path = null;
+ let off = null,
+ g = null,
+ path = null;
+ const Q = 0.22; // offscreen scale — softness AND speed come from the same trick
+ function setup(e) {
+ off = document.createElement('canvas');
+ off.width = Math.max(2, Math.round(e.W * Q));
+ off.height = Math.max(2, Math.round(e.H * Q));
+ g = off.getContext('2d');
+ path = d3.geoPath(e.proj.d3proj, g);
+ }
return {
- name: "night", z: 55,
+ name: 'night',
+ z: 55,
+ resize(e) {
+ setup(e);
+ },
visible: (e) => e.scene.dayNight && e.scene.darkness > 0,
draw(e) {
- const { ctx, proj, scene } = e;
- if (!path) path = d3.geoPath(proj.d3proj, ctx);
- const dk = scene.darkness;
- ctx.fillStyle = `rgba(3,6,15,${Math.min(0.85, 0.5 * dk)})`;
- ctx.beginPath(); path(proj.nightShape()); ctx.fill();
- ctx.fillStyle = `rgba(2,4,10,${Math.min(0.85, 0.42 * dk)})`;
- ctx.beginPath(); path(proj.coreShape()); ctx.fill();
+ const { ctx, proj, scene, W, H } = e;
+ if (!off) setup(e);
+ const dk = scene.darkness * e.introPhase(0.45, 0.8);
+ g.setTransform(Q, 0, 0, Q, 0, 0);
+ g.clearRect(0, 0, W, H);
+ g.fillStyle = `rgba(4,7,17,${Math.min(0.85, 0.5 * dk)})`;
+ g.beginPath();
+ path(proj.nightShape());
+ g.fill();
+ g.fillStyle = `rgba(2,4,11,${Math.min(0.85, 0.42 * dk)})`;
+ g.beginPath();
+ path(proj.coreShape());
+ g.fill();
+ ctx.drawImage(off, 0, 0, W, H);
},
};
}
// ======================================================================
-// City lights — golden twinkle on the night-side dots tagged by landLayer
+// City lights — warm twinkle on the night-side dots tagged by landLayer
// ======================================================================
export function cityLightsLayer() {
return {
- name: "cityLights", z: 57,
+ name: 'cityLights',
+ z: 57,
visible: (e) => e.scene.dayNight && e.scene.cityLights && e.cityLights && e.cityLights.n > 0,
draw(e) {
const { ctx, now, scene } = e;
- const cl = e.cityLights, cb = scene.cityBright, sz = 3.1, half = sz / 2;
+ const cl = e.cityLights,
+ cb = scene.cityBright * e.introPhase(0.5, 0.85),
+ sz = 2.9,
+ half = sz / 2;
const tw = [
- Math.min(1.4, (0.55 + 0.45 * Math.sin(now * 0.0017)) * cb),
- Math.min(1.4, (0.55 + 0.45 * Math.sin(now * 0.0017 + 2.1)) * cb),
- Math.min(1.4, (0.55 + 0.45 * Math.sin(now * 0.0017 + 4.2)) * cb),
+ Math.min(1.4, (0.55 + 0.45 * Math.sin(now * 0.0014)) * cb),
+ Math.min(1.4, (0.55 + 0.45 * Math.sin(now * 0.0014 + 2.1)) * cb),
+ Math.min(1.4, (0.55 + 0.45 * Math.sin(now * 0.0014 + 4.2)) * cb),
];
ADD(ctx);
for (let g = 0; g < 3; g++) {
- ctx.fillStyle = rgba("#ffcf73", Math.min(1, tw[g]));
+ ctx.fillStyle = rgba('#ffd28a', Math.min(1, tw[g]));
for (let i = 0; i < cl.n; i++) {
if (cl.grp[i] === g) ctx.fillRect(cl.x[i] - half, cl.y[i] - half, sz, sz);
}
@@ -350,24 +676,35 @@ export function cityLightsLayer() {
}
// ======================================================================
-// Aurora — wobbling polar bands, front-only, soft additive glow
+// Aurora — wobbling polar bands, front-only, layered additive glow
// ======================================================================
export function auroraLayer() {
return {
- name: "aurora", z: 60,
+ name: 'aurora',
+ z: 60,
visible: (e) => e.scene.aurora && e.scene.auroraIntensity > 0,
draw(e) {
const { ctx, proj, CX, CY, R, now, scene } = e;
- const t = now * 0.0011, k = scene.auroraIntensity;
- ctx.lineCap = "round"; ctx.lineJoin = "round";
+ const t = now * 0.0011,
+ k = scene.auroraIntensity * e.introPhase(0.5, 0.85);
+ if (k <= 0) return;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
ADD(ctx);
for (const s of auroraSpecs(scene)) {
const op = Math.min(1, (s.op0 + 0.22 * Math.sin(t + s.opPh)) * k);
const segs = auroraSegments(proj, CX, CY, R, s.lat, s.amp, s.phase, now, scene.auroraSpeed);
const path = pathFromSegments(segs);
- // fat soft pass + bright core pass = glow without a filter
- ctx.lineWidth = s.width * 2.2; ctx.strokeStyle = rgba(s.col, op * 0.35); ctx.stroke(path);
- ctx.lineWidth = s.width; ctx.strokeStyle = rgba(s.col, op); ctx.stroke(path);
+ // veil + body + thin bright core = curtain glow without a filter
+ ctx.lineWidth = s.width * 2.6;
+ ctx.strokeStyle = rgba(s.col, op * 0.28);
+ ctx.stroke(path);
+ ctx.lineWidth = s.width * 1.1;
+ ctx.strokeStyle = rgba(s.col, op * 0.85);
+ ctx.stroke(path);
+ ctx.lineWidth = s.width * 0.4;
+ ctx.strokeStyle = rgba('#eafff4', op * 0.18);
+ ctx.stroke(path);
}
NORMAL(ctx);
},
@@ -380,9 +717,12 @@ export function auroraLayer() {
export function nodesLayer() {
let nodes = [];
return {
- name: "nodes", z: 65,
+ name: 'nodes',
+ z: 65,
visible: (e) => e.scene.nodes,
- build(e) { nodes = pickNodes(e.landDots); },
+ build(e) {
+ nodes = pickNodes(e.landDots);
+ },
draw(e) {
const { ctx, proj, now } = e;
const tt = now * 0.001;
@@ -392,9 +732,12 @@ export function nodesLayer() {
if (!p || !proj.visible(node.ll)) continue;
const tw = 0.35 + 0.65 * Math.abs(Math.sin(tt * node.sp + node.phase));
const r = node.size * (0.7 + 0.3 * tw);
- drawGlow(ctx, p[0], p[1], r * 3, tw * 0.55);
- ctx.globalAlpha = tw; ctx.fillStyle = "#eaf4ff";
- ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, TAU); ctx.fill();
+ drawGlow(ctx, p[0], p[1], r * 3, tw * 0.5);
+ ctx.globalAlpha = tw;
+ ctx.fillStyle = '#eaf4ff';
+ ctx.beginPath();
+ ctx.arc(p[0], p[1], r, 0, TAU);
+ ctx.fill();
}
ctx.globalAlpha = 1;
NORMAL(ctx);
@@ -408,23 +751,35 @@ export function nodesLayer() {
export function beamsLayer() {
let beams = [];
return {
- name: "beams", z: 90,
+ name: 'beams',
+ z: 90,
spawn(e) {
const pick = pickBeam(e);
if (!pick) return;
const ts = e.state.types[pick.type.id];
- ts.count++; e.bump(pick.type.id);
- e.emit("beam", { type: pick.type, city: pick.city, color: ts.color });
- beams.push({ type: pick.type, src: pick.city.lnglat, color: ts.color, t0: e.now, impacted: false });
+ ts.count++;
+ e.bump(pick.type.id);
+ e.emit('beam', { type: pick.type, city: pick.city, color: ts.color });
+ beams.push({
+ type: pick.type,
+ src: pick.city.lnglat,
+ color: ts.color,
+ t0: e.now,
+ impacted: false,
+ });
},
draw(e) {
const { ctx, CX, CY, R, now, scene, proj } = e;
- const hqp = e.hq, hqVisible = e.hqVisible;
- ctx.lineCap = "round";
+ const hqp = e.hq,
+ hqVisible = e.hqVisible;
+ ctx.lineCap = 'round';
for (let i = beams.length - 1; i >= 0; i--) {
const b = beams[i];
const age = now - b.t0;
- if (age > BEAM.LIFE_MS) { beams.splice(i, 1); continue; }
+ if (age > BEAM.LIFE_MS) {
+ beams.splice(i, 1);
+ continue;
+ }
const sp = proj.forward(b.src);
if (!sp || !hqp || !proj.visible(b.src) || !hqVisible) continue;
@@ -434,16 +789,34 @@ export function beamsLayer() {
let hx, hy;
const path = new Path2D();
path.moveTo(sp[0], sp[1]);
- if (t >= 1) { path.quadraticCurveTo(C[0], C[1], hqp[0], hqp[1]); hx = hqp[0]; hy = hqp[1]; }
- else { const s = quadSplit(sp, C, hqp, t); path.quadraticCurveTo(s.ax, s.ay, s.hx, s.hy); hx = s.hx; hy = s.hy; }
+ if (t >= 1) {
+ path.quadraticCurveTo(C[0], C[1], hqp[0], hqp[1]);
+ hx = hqp[0];
+ hy = hqp[1];
+ } else {
+ const s = quadSplit(sp, C, hqp, t);
+ path.quadraticCurveTo(s.ax, s.ay, s.hx, s.hy);
+ hx = s.hx;
+ hy = s.hy;
+ }
const op = beamEnvelope(age);
const special = b.type.id === e.sim.fwTrigger;
ctx.globalAlpha = op;
ADD(ctx);
- ctx.strokeStyle = rgba(b.color, special ? 0.55 : 0.22); ctx.lineWidth = special ? 13 : 11; ctx.stroke(path);
- ctx.strokeStyle = rgba(b.color, 0.95); ctx.lineWidth = special ? 3.4 : 2.6; ctx.stroke(path);
- ctx.strokeStyle = "rgba(255,255,255,0.9)"; ctx.lineWidth = special ? 1.5 : 1; ctx.stroke(path);
+ // bloom → halo → body → white-hot core
+ ctx.strokeStyle = rgba(b.color, 0.07);
+ ctx.lineWidth = special ? 22 : 17;
+ ctx.stroke(path);
+ ctx.strokeStyle = rgba(b.color, special ? 0.5 : 0.2);
+ ctx.lineWidth = special ? 12 : 10;
+ ctx.stroke(path);
+ ctx.strokeStyle = rgba(b.color, 0.95);
+ ctx.lineWidth = special ? 3.2 : 2.4;
+ ctx.stroke(path);
+ ctx.strokeStyle = 'rgba(255,255,255,0.9)';
+ ctx.lineWidth = special ? 1.4 : 1;
+ ctx.stroke(path);
// comet trail behind the head while drawing
if (scene.beamTrails && t < 1) {
@@ -453,18 +826,34 @@ export function beamsLayer() {
const [qx, qy] = quadPoint(sp, C, hqp, u0 + (t - u0) * (n / 6));
n === 0 ? trail.moveTo(qx, qy) : trail.lineTo(qx, qy);
}
- ctx.strokeStyle = tint(b.color); ctx.lineWidth = 4.2; ctx.stroke(trail);
+ ctx.strokeStyle = tint(b.color);
+ ctx.lineWidth = 3.8;
+ ctx.stroke(trail);
}
if (t < 1) {
- const hr = 4.2 + Math.sin(age / 90) * 0.7;
- drawGlow(ctx, hx, hy, hr * 2.6, op);
- ctx.globalAlpha = op; ctx.fillStyle = "#fff";
- ctx.beginPath(); ctx.arc(hx, hy, hr, 0, TAU); ctx.fill();
+ const hr = 4 + Math.sin(age / 90) * 0.7;
+ drawGlow(ctx, hx, hy, hr * 2.8, op);
+ ctx.globalAlpha = op;
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(hx, hy, hr, 0, TAU);
+ ctx.fill();
+ // tiny cross sparkle on the head
+ const L = hr * 2.2;
+ ctx.globalAlpha = op * 0.55;
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(hx - L, hy);
+ ctx.lineTo(hx + L, hy);
+ ctx.moveTo(hx, hy - L);
+ ctx.lineTo(hx, hy + L);
+ ctx.stroke();
} else if (!b.impacted) {
b.impacted = true;
- e.emit("impact", { p: hqp.slice(), color: b.color });
- if (e.sim.fireworks && special) e.emit("fireworks", { p: hqp.slice(), color: b.color });
+ e.emit('impact', { p: hqp.slice(), color: b.color });
+ if (e.sim.fireworks && special) e.emit('fireworks', { p: hqp.slice(), color: b.color });
}
ctx.globalAlpha = 1;
NORMAL(ctx);
@@ -474,26 +863,49 @@ export function beamsLayer() {
}
// ======================================================================
-// Impacts — expanding ring + burst where a beam lands
+// Impacts — glow flash + twin expanding rings where a beam lands
// ======================================================================
export function impactsLayer() {
let items = [];
+ const TTL = 700;
return {
- name: "impacts", z: 92,
- build(e) { items = []; e.on("impact", (d) => items.push({ x: d.p[0], y: d.p[1], color: d.color, t0: e.now })); },
+ name: 'impacts',
+ z: 92,
+ build(e) {
+ items = [];
+ e.on('impact', (d) => items.push({ x: d.p[0], y: d.p[1], color: d.color, t0: e.now }));
+ },
simulate(e) {
- for (let i = items.length - 1; i >= 0; i--) if ((e.now - items[i].t0) / 620 >= 1) items.splice(i, 1);
+ for (let i = items.length - 1; i >= 0; i--)
+ if ((e.now - items[i].t0) / TTL >= 1) items.splice(i, 1);
},
draw(e) {
const { ctx, R, now } = e;
ADD(ctx);
for (const it of items) {
- const a = (now - it.t0) / 620, ee = 1 - Math.pow(1 - a, 2);
+ const a = (now - it.t0) / TTL,
+ ee = 1 - Math.pow(1 - a, 2);
+ drawGlow(ctx, it.x, it.y, 8 + ee * 16, (1 - a) * 0.7);
ctx.globalAlpha = 1 - a;
- ctx.strokeStyle = it.color; ctx.lineWidth = 1.6;
- ctx.beginPath(); ctx.arc(it.x, it.y, 5 + ee * R * 0.18, 0, TAU); ctx.stroke();
- ctx.globalAlpha = (1 - a) * 0.9; ctx.fillStyle = it.color;
- ctx.beginPath(); ctx.arc(it.x, it.y, 3 + ee * 6, 0, TAU); ctx.fill();
+ ctx.strokeStyle = it.color;
+ ctx.lineWidth = 1.6;
+ ctx.beginPath();
+ ctx.arc(it.x, it.y, 5 + ee * R * 0.17, 0, TAU);
+ ctx.stroke();
+ const a2 = clamp(a * 1.4 - 0.4, 0, 1),
+ ee2 = 1 - Math.pow(1 - a2, 2);
+ if (a2 > 0) {
+ ctx.globalAlpha = (1 - a2) * 0.5;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(it.x, it.y, 4 + ee2 * R * 0.1, 0, TAU);
+ ctx.stroke();
+ }
+ ctx.globalAlpha = (1 - a) * 0.9;
+ ctx.fillStyle = it.color;
+ ctx.beginPath();
+ ctx.arc(it.x, it.y, 3 + ee * 6, 0, TAU);
+ ctx.fill();
}
ctx.globalAlpha = 1;
NORMAL(ctx);
@@ -512,10 +924,11 @@ export function fireworksLayer() {
for (const s of spec.sparks) parts.push(Object.assign({ x: cx, y: cy }, s));
}
return {
- name: "fireworks", z: 94,
+ name: 'fireworks',
+ z: 94,
build(e) {
parts = [];
- e.on("fireworks", (d) => {
+ e.on('fireworks', (d) => {
const [x, y] = d.p;
for (const b of fireworkBarrage(e.R, d.color)) {
const fire = () => burst(e, x + b.dx, y + b.dy, b.color, b.scale);
@@ -525,8 +938,12 @@ export function fireworksLayer() {
},
simulate(e) {
for (let i = parts.length - 1; i >= 0; i--) {
- const p = parts[i]; p.t += e.dt;
- if (p.t >= p.ttl) { parts.splice(i, 1); continue; }
+ const p = parts[i];
+ p.t += e.dt;
+ if (p.t >= p.ttl) {
+ parts.splice(i, 1);
+ continue;
+ }
stepFirework(p, e.dt, e.R);
}
},
@@ -536,9 +953,11 @@ export function fireworksLayer() {
ADD(ctx);
for (const p of parts) {
ctx.globalAlpha = fireworkAlpha(p);
- ctx.fillStyle = p.flash ? "#fff" : p.color;
+ ctx.fillStyle = p.flash ? '#fff' : p.color;
const r = p.flash ? 3 + (p.t / p.ttl) * p.grow : p.r;
- ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, TAU); ctx.fill();
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, r, 0, TAU);
+ ctx.fill();
}
ctx.globalAlpha = 1;
NORMAL(ctx);
@@ -550,35 +969,51 @@ export function fireworksLayer() {
// Shooting stars — meteors streaking through deep space (behind globe)
// ======================================================================
export function meteorsLayer() {
- let meteors = [], acc = 0;
+ let meteors = [],
+ acc = 0;
return {
- name: "meteors", z: 0,
+ name: 'meteors',
+ z: 0,
simulate(e) {
- if (e.scene.shootingStars) {
+ if (e.scene.shootingStars && e.intro >= 0.9) {
acc += e.dt * (0.12 + e.scene.meteorRate * 1.5);
let guard = 0;
- while (acc >= 1 && guard < 3) { meteors.push(spawnMeteorParams(e.W, e.H)); acc -= 1; guard++; }
+ while (acc >= 1 && guard < 3) {
+ meteors.push(spawnMeteorParams(e.W, e.H));
+ acc -= 1;
+ guard++;
+ }
} else acc = 0;
- for (let i = meteors.length - 1; i >= 0; i--) if (!stepMeteor(meteors[i], e.dt, e.H)) meteors.splice(i, 1);
+ for (let i = meteors.length - 1; i >= 0; i--)
+ if (!stepMeteor(meteors[i], e.dt, e.H)) meteors.splice(i, 1);
},
draw(e) {
if (!meteors.length) return;
const { ctx } = e;
- ADD(ctx); ctx.lineCap = "round";
+ ADD(ctx);
+ ctx.lineCap = 'round';
for (const m of meteors) {
const sp = Math.hypot(m.vx, m.vy) || 1;
- const tx = m.x - (m.vx / sp) * m.len, ty = m.y - (m.vy / sp) * m.len;
+ const tx = m.x - (m.vx / sp) * m.len,
+ ty = m.y - (m.vy / sp) * m.len;
const op = meteorOpacity(m);
ctx.globalAlpha = op;
const grad = ctx.createLinearGradient(tx, ty, m.x, m.y);
- grad.addColorStop(0, "rgba(255,255,255,0)");
- grad.addColorStop(0.55, "rgba(207,227,255,0.55)");
- grad.addColorStop(1, "rgba(255,255,255,1)");
- ctx.strokeStyle = grad; ctx.lineWidth = m.w;
- ctx.beginPath(); ctx.moveTo(tx, ty); ctx.lineTo(m.x, m.y); ctx.stroke();
+ grad.addColorStop(0, 'rgba(255,255,255,0)');
+ grad.addColorStop(0.55, 'rgba(207,227,255,0.5)');
+ grad.addColorStop(1, 'rgba(255,255,255,1)');
+ ctx.strokeStyle = grad;
+ ctx.lineWidth = m.w;
+ ctx.beginPath();
+ ctx.moveTo(tx, ty);
+ ctx.lineTo(m.x, m.y);
+ ctx.stroke();
drawGlow(ctx, m.x, m.y, m.hr * 3, op);
- ctx.globalAlpha = op; ctx.fillStyle = "#fff";
- ctx.beginPath(); ctx.arc(m.x, m.y, m.hr, 0, TAU); ctx.fill();
+ ctx.globalAlpha = op;
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(m.x, m.y, m.hr, 0, TAU);
+ ctx.fill();
}
ctx.globalAlpha = 1;
NORMAL(ctx);
@@ -591,29 +1026,47 @@ export function meteorsLayer() {
// ======================================================================
export function hqLayer() {
return {
- name: "hq", z: 96,
+ name: 'hq',
+ z: 96,
draw(e) {
const { ctx, now } = e;
if (!e.hqVisible || !e.hq) return;
+ const ia = e.introPhase(0.72, 0.95);
+ if (ia <= 0) return;
const [x, y] = e.hq;
for (const off of [0, 1300]) {
const p = ((now + off) % 2600) / 2600;
- ctx.globalAlpha = (1 - p) * 0.9;
- ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.4;
- ctx.beginPath(); ctx.arc(x, y, 5 + p * 29, 0, TAU); ctx.stroke();
+ ctx.globalAlpha = (1 - p) * 0.8 * ia;
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 1.3;
+ ctx.beginPath();
+ ctx.arc(x, y, 5 + p * 29, 0, TAU);
+ ctx.stroke();
}
ctx.globalAlpha = 1;
- ADD(ctx); drawGlow(ctx, x, y, 11, 0.9); NORMAL(ctx);
- ctx.fillStyle = "#fff";
- ctx.beginPath(); ctx.arc(x, y, 4.5, 0, TAU); ctx.fill();
+ ADD(ctx);
+ drawGlow(ctx, x, y, 11, 0.9 * ia);
+ NORMAL(ctx);
+ ctx.globalAlpha = ia;
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(x, y, 4.5, 0, TAU);
+ ctx.fill();
// label
- ctx.textBaseline = "alphabetic"; ctx.lineJoin = "round"; ctx.strokeStyle = "rgba(5,6,12,0.8)";
+ ctx.textBaseline = 'alphabetic';
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = 'rgba(5,6,12,0.8)';
ctx.font = "600 14px 'Space Grotesk', system-ui, sans-serif";
- ctx.lineWidth = 3.5; ctx.strokeText(e.data.HQ.name, x + 12, y + 4);
- ctx.fillStyle = "#fff"; ctx.fillText(e.data.HQ.name, x + 12, y + 4);
+ ctx.lineWidth = 3.5;
+ ctx.strokeText(e.data.HQ.name, x + 12, y + 4);
+ ctx.fillStyle = '#fff';
+ ctx.fillText(e.data.HQ.name, x + 12, y + 4);
ctx.font = "11px 'JetBrains Mono', ui-monospace, monospace";
- ctx.lineWidth = 3.5; ctx.strokeText(e.data.HQ.city, x + 12, y + 20);
- ctx.fillStyle = "#5ad1ff"; ctx.fillText(e.data.HQ.city, x + 12, y + 20);
+ ctx.lineWidth = 3.5;
+ ctx.strokeText(e.data.HQ.city, x + 12, y + 20);
+ ctx.fillStyle = '#5ad1ff';
+ ctx.fillText(e.data.HQ.city, x + 12, y + 20);
+ ctx.globalAlpha = 1;
},
};
}
@@ -621,9 +1074,29 @@ export function hqLayer() {
// ----------------------------------------------------------------------
export function registerDefaultLayers(engine) {
[
- meteorsLayer(), atmosphereLayer(), orbitsBackLayer(), sphereLayer(),
- spikesLayer(), graticuleLayer(), landLayer(), nightLayer(), cityLightsLayer(),
- auroraLayer(), nodesLayer(), orbitsFrontLayer(), beamsLayer(),
- impactsLayer(), fireworksLayer(), hqLayer(),
+ nebulaLayer(),
+ starfieldLayer(),
+ constellationsLayer(),
+ cometLayer(),
+ meteorsLayer(),
+ moonLayer(),
+ atmosphereLayer(),
+ orbitsBackLayer(),
+ sphereLayer(),
+ spikesLayer(),
+ graticuleLayer(),
+ sunGlintLayer(),
+ landLayer(),
+ nightLayer(),
+ cityLightsLayer(),
+ heartbeatLayer(),
+ auroraLayer(),
+ nodesLayer(),
+ surgeLayer(),
+ orbitsFrontLayer(),
+ beamsLayer(),
+ impactsLayer(),
+ fireworksLayer(),
+ hqLayer(),
].forEach((l) => engine.register(l));
}
diff --git a/canvas/main.js b/canvas/main.js
index 7e6141b..6df6f3b 100644
--- a/canvas/main.js
+++ b/canvas/main.js
@@ -10,80 +10,100 @@
* • ?demo the demo's own localStorage
* • otherwise schema defaults
*/
-import { HQ, ACTIVITY_TYPES, CITIES, VERBS, LAND_URLS } from "../shared/data.js";
-import { SIM_DEFAULTS, resolveScene } from "../shared/config.js";
-import { buildScenePanel, buildActivityControls, buildBaseControls, createTicker } from "../shared/ui.js";
-import { createFpsMeter } from "../shared/fps.js";
-import { Engine } from "./engine.js";
-import { registerDefaultLayers } from "./layers.js";
+import { HQ, ACTIVITY_TYPES, CITIES, VERBS, LAND_URLS } from '../shared/data.js';
+import { SIM_DEFAULTS, resolveScene } from '../shared/config.js';
+import {
+ buildScenePanel,
+ buildActivityControls,
+ buildBaseControls,
+ createTicker,
+} from '../shared/ui.js';
+import { createFpsMeter } from '../shared/fps.js';
+import { Engine } from './engine.js';
+import { registerDefaultLayers } from './layers.js';
const params = new URLSearchParams(location.search);
-const demo = params.has("demo");
-document.body.classList.toggle("demo", demo);
+const demo = params.has('demo');
+document.body.classList.toggle('demo', demo);
-const canvas = document.getElementById("globe-canvas");
+const canvas = document.getElementById('globe-canvas');
const sim = { ...SIM_DEFAULTS };
const data = { HQ, ACTIVITY_TYPES, CITIES, landFeature: null };
-const scene = await resolveScene({ demo, configUrl: params.get("config"), inline: window.__GD_SCENE__ });
+const scene = await resolveScene({
+ demo,
+ configUrl: params.get('config'),
+ inline: window.__GD_SCENE__,
+});
const engine = new Engine({ canvas, scene, sim, data });
registerDefaultLayers(engine);
function applyStarfield() {
- const stars = document.querySelectorAll(".stars");
- const drift = ["drift1 140s linear infinite", "drift2 200s linear infinite"];
- const twk = ["tw1 5.5s ease-in-out infinite", "tw2 7s ease-in-out infinite"];
+ const stars = document.querySelectorAll('.stars');
+ const drift = ['drift1 140s linear infinite', 'drift2 200s linear infinite'];
+ const twk = ['tw1 5.5s ease-in-out infinite', 'tw2 7s ease-in-out infinite'];
stars.forEach((el, i) => {
- el.style.animation = [scene.starDrift ? drift[i] : "", scene.starTwinkle ? twk[i] : ""]
- .filter(Boolean).join(", ");
+ el.style.animation = [scene.starDrift ? drift[i] : '', scene.starTwinkle ? twk[i] : '']
+ .filter(Boolean)
+ .join(', ');
});
}
applyStarfield();
// Live ticker is part of the hero spectacle — shown in both modes.
-const ticker = createTicker(document.getElementById("ticker"), VERBS);
-engine.on("beam", ({ type, city, color }) => ticker.push(type, city.name, color));
+const ticker = createTicker(document.getElementById('ticker'), VERBS);
+engine.on('beam', ({ type, city, color }) => ticker.push(type, city.name, color));
+engine.on('surge', ({ city }) => ticker.special(`${city.name} is lighting up right now`));
// Controls + FPS meter are demo-only; the production hero stays clean.
if (demo) {
- engine.fps = createFpsMeter("canvas");
+ engine.fps = createFpsMeter('canvas');
const activities = buildActivityControls({
- list: document.getElementById("activity-list"), types: ACTIVITY_TYPES, state: engine.state,
+ list: document.getElementById('activity-list'),
+ types: ACTIVITY_TYPES,
+ state: engine.state,
});
engine.onCount((id) => activities.bump(id));
buildBaseControls({ sim, types: ACTIVITY_TYPES });
buildScenePanel({
- host: document.getElementById("scene"),
- toggle: document.getElementById("scene-toggle"),
+ host: document.getElementById('scene'),
+ toggle: document.getElementById('scene-toggle'),
scene,
onChange: (key, structural) => {
if (structural) engine.rebuildFor(key);
engine.applyScene();
- if (key === "starDrift" || key === "starTwinkle") applyStarfield();
+ if (key === 'starDrift' || key === 'starTwinkle') applyStarfield();
+ if (key === 'intro' && scene.intro) engine.replayIntro(); // toggle on → replay the arrival
},
});
}
// ---- load world topology, then go --------------------------------------
function fail(msg) {
- const l = document.getElementById("loading");
- if (l) l.innerHTML = `
Could not load world map data.
${msg}
`;
+ const l = document.getElementById('loading');
+ if (l)
+ l.innerHTML = `Could not load world map data.
${msg}
`;
}
async function loadLand() {
for (const url of LAND_URLS) {
try {
const r = await fetch(url);
- if (!r.ok) throw new Error("HTTP " + r.status);
+ if (!r.ok) throw new Error('HTTP ' + r.status);
const topo = await r.json();
return topojson.feature(topo, topo.objects.land);
- } catch (e) { /* try next source */ }
+ } catch (e) {
+ /* try next source */
+ }
}
return null;
}
loadLand().then((feature) => {
- if (!feature) { fail("all map sources unreachable"); return; }
+ if (!feature) {
+ fail('all map sources unreachable');
+ return;
+ }
data.landFeature = feature;
- document.getElementById("loading").style.display = "none";
+ document.getElementById('loading').style.display = 'none';
engine.start();
});
diff --git a/shared/config.js b/shared/config.js
index b7af6e8..525459f 100644
--- a/shared/config.js
+++ b/shared/config.js
@@ -6,7 +6,7 @@
* `resolveScene()` — the single entry point that produces a validated scene from
* whichever source applies (platform API, inline embed, or the demo's localStorage).
*/
-import { SCENE_SCHEMA, sceneDefaults, sanitizeScene } from "./scene-schema.js";
+import { SCENE_SCHEMA, sceneDefaults, sanitizeScene } from './scene-schema.js';
export { SCENE_SCHEMA, sanitizeScene };
@@ -16,10 +16,10 @@ export const SCENE_DEFAULTS = sceneDefaults();
// Live activity simulation defaults (driven by the demo's base control panel).
export const SIM_DEFAULTS = {
paused: false,
- rotSpeed: 6, // degrees / second (auto-rotate)
- rate: 3.0, // activities per second
- fireworks: true, // celebratory burst on the trigger event
- fwTrigger: "completed", // which activity sets off fireworks
+ rotSpeed: 4, // degrees / second (auto-rotate) — calm and majestic
+ rate: 2.4, // activities per second
+ fireworks: true, // celebratory burst on the trigger event
+ fwTrigger: 'completed', // which activity sets off fireworks
};
// Land-dot grid spacing per density setting (smaller = denser).
@@ -27,23 +27,30 @@ export const DENSITY_STEP = { sparse: 3.8, med: 3.0, dense: 2.4 };
// Aurora colour schemes: [glow stroke, veil stroke].
export const AURORA_SCHEMES = {
- gv: ["#5cffb0", "#b58cff"],
- emerald: ["#3dffa0", "#7affd1"],
- rose: ["#ff7ab0", "#b58cff"],
+ gv: ['#5cffb0', '#b58cff'],
+ emerald: ['#3dffa0', '#7affd1'],
+ rose: ['#ff7ab0', '#b58cff'],
};
export const STORAGE = {
- scene: "gd-globe-scene",
- sceneOpen: "gd-globe-scene-open",
+ scene: 'gd-globe-scene',
+ sceneOpen: 'gd-globe-scene-open',
};
// ---- demo-mode persistence (localStorage) ------------------------------
export function loadScene() {
- try { return sanitizeScene(JSON.parse(localStorage.getItem(STORAGE.scene) || "{}")); }
- catch (e) { return sceneDefaults(); }
+ try {
+ return sanitizeScene(JSON.parse(localStorage.getItem(STORAGE.scene) || '{}'));
+ } catch (e) {
+ return sceneDefaults();
+ }
}
export function saveScene(scene) {
- try { localStorage.setItem(STORAGE.scene, JSON.stringify(scene)); } catch (e) { /* ignore */ }
+ try {
+ localStorage.setItem(STORAGE.scene, JSON.stringify(scene));
+ } catch (e) {
+ /* ignore */
+ }
}
// ---- where the scene comes from ----------------------------------------
@@ -51,12 +58,14 @@ export function saveScene(scene) {
// Every path runs through sanitizeScene(), so the renderer only ever sees a
// fully-validated, in-bounds scene.
export async function resolveScene({ demo = false, configUrl = null, inline = null } = {}) {
- if (inline && typeof inline === "object") return sanitizeScene(inline);
+ if (inline && typeof inline === 'object') return sanitizeScene(inline);
if (configUrl) {
try {
- const r = await fetch(configUrl, { headers: { Accept: "application/json" } });
+ const r = await fetch(configUrl, { headers: { Accept: 'application/json' } });
if (r.ok) return sanitizeScene(await r.json());
- } catch (e) { /* fall through to defaults */ }
+ } catch (e) {
+ /* fall through to defaults */
+ }
return sceneDefaults();
}
if (demo) return loadScene();
diff --git a/shared/engine.js b/shared/engine.js
index f4e8813..099df5f 100644
--- a/shared/engine.js
+++ b/shared/engine.js
@@ -1,9 +1,11 @@
/* games.directory globe — BaseEngine (shared by both renderers)
*
* All the backend-agnostic machinery lives here: the event bus, the ordered
- * layer registry, the clock, auto-rotation, the beam-spawn cadence, drag-to-spin,
- * the sun, and the per-activity counters. A renderer subclasses this and fills in
- * five small hooks for the bits that genuinely differ between Canvas and SVG:
+ * layer registry, the clock, auto-rotation, the beam-spawn cadence, drag-to-spin
+ * (with fling inertia), pointer parallax, the cinematic arrival sequence, the
+ * city-surge scheduler, the sun, adaptive render quality, and the per-activity
+ * counters. A renderer subclasses this and fills in five small hooks for the
+ * bits that genuinely differ between Canvas and SVG:
*
* viewportRect() → the element's bounding rect (sizing source)
* resizeBackend() → size the canvas / set the SVG viewBox
@@ -11,6 +13,23 @@
* renderFrame() → paint one frame (canvas clears+draws; svg mutates nodes)
* applyScene() → map scene → element styles (no-op for canvas)
*
+ * Adaptive quality: an EMA of raw frame time nudges `quality` (a multiplier on
+ * the device-pixel ratio) down when frames run long and back up when there's
+ * headroom, with a cooldown so it never oscillates. A resize re-bakes sprites
+ * at the new backing resolution; CSS size never changes, so it's invisible
+ * except as a slightly softer image on weak GPUs — and a steady 60fps.
+ *
+ * Cinematic arrival: `intro` ramps 0→1 over ~3.2s after start. Layers read
+ * eased sub-windows of it via `introPhase(a, b)` to choreograph the bloom
+ * (stars resolve → sphere fades up → atmosphere ignites → land blooms → beams
+ * begin). When `scene.intro` is false, intro is pinned at 1.
+ *
+ * Pointer parallax: the pointer's position eases into a small look offset
+ * that's ADDED to the rotation at projection time (never written back into
+ * `rotation`), so the whole globe leans gently toward the cursor while drag
+ * and auto-rotate stay untouched. `rotAcc` is the unwrapped, look-inclusive
+ * longitude — background layers key their parallax off it.
+ *
* Members written with `#` are true private state — internal to the engine and
* not part of the API. The hooks above are deliberately public: they're the
* subclass extension surface (`#`-private methods can't be overridden).
@@ -18,17 +37,36 @@
* Layer contract:
* { name, z, rebuildOn?, build?(e), resize?(e), simulate?(e), visible?(e), draw(e) }
*/
-import { Projection } from "./geo.js";
-import { SCENE_DEFAULTS } from "./config.js";
-import { ACTIVITY_TYPES } from "./data.js";
+import { Projection } from './geo.js';
+import { SCENE_DEFAULTS } from './config.js';
+import { ACTIVITY_TYPES } from './data.js';
+
+const QUALITY_MIN = 0.55; // never drop below ~half-res backing store
+const EMA_SLOW_MS = 21; // sustained frames slower than this → step down
+const EMA_FAST_MS = 14.5; // sustained frames faster than this → step up
+const COOLDOWN_FRAMES = 150; // min frames between quality changes (~2.5s)
+
+const INTRO_MS = 3200; // cinematic arrival duration
+const INTRO_BEAMS_AT = 0.85; // beams hold until the scene has mostly bloomed
+
+const LOOK_DEG = 3.2; // max pointer-parallax lean, degrees
+const SURGE_FIRST_MS = 35000; // first city surge after load
+const SURGE_EVERY_MS = [50000, 90000]; // min..max between surges
+const SURGE_LEN_MS = 6500; // how long a surge lasts
export class BaseEngine {
#handlers = {};
#byName = {};
#hooks = [];
- #drag = { active: false, x: 0, y: 0 };
+ #drag = { active: false, x: 0, y: 0, vx: 0, t: 0 };
+ #fling = 0;
#spawnAcc = 0;
#last = 0;
+ #ema = 16.7;
+ #cooldown = 60;
+ #introT0 = 0;
+ #prevLam = null;
+ #nextSurge = 0;
constructor({ scene, sim, data }) {
this.scene = scene || { ...SCENE_DEFAULTS };
@@ -42,21 +80,46 @@ export class BaseEngine {
});
this.proj = new Projection();
- this.rotation = this.proj.rotation; // shared array
+ this.rotation = this.proj.rotation.slice(); // engine-owned; proj gets rotation+look each frame
// viewport (CSS px)
- this.W = 0; this.H = 0; this.CX = 0; this.CY = 0; this.R = 0; this.dpr = 1;
+ this.W = 0;
+ this.H = 0;
+ this.CX = 0;
+ this.CY = 0;
+ this.R = 0;
+ this.dpr = 1;
+ this.quality = 1; // adaptive multiplier on dpr (see header note)
// clock + per-frame shared values
- this.now = 0; this.dt = 0; this.frameCount = 0;
- this.hq = null; this.hqVisible = false;
+ this.now = 0;
+ this.dt = 0;
+ this.frameCount = 0;
+ this.hq = null;
+ this.hqVisible = false;
+
+ // cinematic arrival progress (0→1; pinned at 1 when scene.intro is off)
+ this.intro = 1;
+
+ // pointer parallax: target (set by pointermove) and eased current offset
+ this.look = { x: 0, y: 0, tx: 0, ty: 0 };
+ // unwrapped, look-inclusive longitude — parallax driver for background layers
+ this.rotAcc = 0;
+
+ // active city surge: { city, t0, until } or null
+ this.surge = null;
this.layers = [];
}
// ---- events --------------------------------------------------------
- on(evt, fn) { (this.#handlers[evt] || (this.#handlers[evt] = [])).push(fn); }
- emit(evt, payload) { const h = this.#handlers[evt]; if (h) for (const fn of h) fn(payload); }
+ on(evt, fn) {
+ (this.#handlers[evt] || (this.#handlers[evt] = [])).push(fn);
+ }
+ emit(evt, payload) {
+ const h = this.#handlers[evt];
+ if (h) for (const fn of h) fn(payload);
+ }
// ---- layers --------------------------------------------------------
register(layer) {
@@ -65,9 +128,15 @@ export class BaseEngine {
this.layers.sort((a, b) => a.z - b.z);
return layer;
}
- layer(name) { return this.#byName[name]; }
- build() { for (const l of this.layers) l.build && l.build(this); }
- resizeLayers() { for (const l of this.layers) l.resize && l.resize(this); }
+ layer(name) {
+ return this.#byName[name];
+ }
+ build() {
+ for (const l of this.layers) l.build && l.build(this);
+ }
+ resizeLayers() {
+ for (const l of this.layers) l.resize && l.resize(this);
+ }
rebuildFor(key) {
for (const l of this.layers) {
if (l.build && l.rebuildOn && l.rebuildOn.includes(key)) l.build(this);
@@ -75,16 +144,36 @@ export class BaseEngine {
}
// ---- per-activity counters ----------------------------------------
- onCount(fn) { this.#hooks.push(fn); }
- bump(id) { for (const fn of this.#hooks) fn(id); }
+ onCount(fn) {
+ this.#hooks.push(fn);
+ }
+ bump(id) {
+ for (const fn of this.#hooks) fn(id);
+ }
+
+ // ---- cinematic arrival ----------------------------------------------
+ // Eased progress through the sub-window [a, b] of the intro (fractions of
+ // INTRO_MS). Returns 1 once the intro is over — zero cost in steady state.
+ introPhase(a, b) {
+ if (this.intro >= 1) return 1;
+ const p = (this.intro - a) / (b - a);
+ if (p <= 0) return 0;
+ if (p >= 1) return 1;
+ return p * p * (3 - 2 * p); // smoothstep
+ }
+ replayIntro() {
+ this.#introT0 = performance.now();
+ }
// ---- viewport ------------------------------------------------------
resize() {
const rect = this.viewportRect();
- this.W = rect.width; this.H = rect.height;
- this.CX = this.W * 0.5; this.CY = this.H * 0.5;
+ this.W = rect.width;
+ this.H = rect.height;
+ this.CX = this.W * 0.5;
+ this.CY = this.H * 0.5;
this.R = Math.min(this.W, this.H) * 0.36;
- this.dpr = Math.min(2, window.devicePixelRatio || 1);
+ this.dpr = Math.min(2, window.devicePixelRatio || 1) * this.quality;
this.resizeBackend();
this.proj.setViewport(this.R, this.CX, this.CY);
this.resizeLayers();
@@ -92,35 +181,88 @@ export class BaseEngine {
// ---- main loop -----------------------------------------------------
start() {
- this.build(); // create/look-up state & nodes first
- this.resize(); // size the viewport, then position layers
- this.applyScene(); // map scene → element styles (svg only)
- window.addEventListener("resize", () => this.resize());
+ this.build(); // create/look-up state & nodes first
+ this.resize(); // size the viewport, then position layers
+ this.applyScene(); // map scene → element styles (svg only)
+ window.addEventListener('resize', () => this.resize());
+ document.addEventListener('visibilitychange', () => {
+ this.#last = performance.now();
+ });
this.#bindDrag();
+ this.#bindPointer();
+ this.#introT0 = performance.now();
+ this.#nextSurge = performance.now() + SURGE_FIRST_MS;
this.proj.setRotation(this.rotation[0], this.rotation[1], this.rotation[2]);
this.proj.updateSun(performance.now());
- requestAnimationFrame((t) => { this.#last = t; this.#frame(t); });
+ requestAnimationFrame((t) => {
+ this.#last = t;
+ this.#frame(t);
+ });
}
#frame(now) {
- const dt = Math.min(0.05, (now - this.#last) / 1000);
- this.#last = now; this.now = now; this.dt = dt; this.frameCount++;
+ const raw = now - this.#last;
+ const dt = Math.min(0.05, raw / 1000);
+ this.#last = now;
+ this.now = now;
+ this.dt = dt;
+ this.frameCount++;
+
+ this.#adaptQuality(raw);
+
+ // cinematic arrival progress
+ this.intro = this.scene.intro === false ? 1 : Math.min(1, (now - this.#introT0) / INTRO_MS);
const sim = this.sim;
- if (!sim.paused) {
- if (!this.#drag.active) {
- this.rotation[0] += sim.rotSpeed * dt;
- if (this.rotation[0] > 180) this.rotation[0] -= 360;
- if (this.rotation[0] < -180) this.rotation[0] += 360;
+ if (!this.#drag.active) {
+ // fling inertia glides on top of auto-rotation (works even when paused)
+ if (this.#fling) {
+ this.rotation[0] += this.#fling * dt;
+ this.#fling *= Math.exp(-2.6 * dt);
+ if (Math.abs(this.#fling) < 0.25) this.#fling = 0;
}
- this.#spawnAcc += dt * sim.rate;
+ // rotation eases in from stillness during the arrival
+ if (!sim.paused) this.rotation[0] += sim.rotSpeed * dt * this.introPhase(0, 0.8);
+ if (this.rotation[0] > 180) this.rotation[0] -= 360;
+ if (this.rotation[0] < -180) this.rotation[0] += 360;
+ }
+
+ // pointer parallax eases toward its target (or back to rest)
+ const lk = this.look,
+ ease = Math.min(1, dt * 2.5);
+ const want = this.scene.parallax !== false && !this.#drag.active;
+ lk.x += ((want ? lk.tx : 0) - lk.x) * ease;
+ lk.y += ((want ? lk.ty : 0) - lk.y) * ease;
+
+ // surges: occasionally one city erupts
+ this.#scheduleSurge(now);
+
+ if (!sim.paused && this.intro >= INTRO_BEAMS_AT) {
+ const mult = this.surge ? 2.6 : 1;
+ this.#spawnAcc += dt * sim.rate * mult;
let guard = 0;
const beams = this.#byName.beams;
- while (this.#spawnAcc >= 1 && guard < 12) { beams && beams.spawn(this); this.#spawnAcc -= 1; guard++; }
- }
- this.proj.setRotation(this.rotation[0], this.rotation[1], this.rotation[2]);
+ while (this.#spawnAcc >= 1 && guard < 12) {
+ beams && beams.spawn(this);
+ this.#spawnAcc -= 1;
+ guard++;
+ }
+ } else this.#spawnAcc = 0;
+
+ // displayed rotation = engine rotation + look offset (never written back)
+ const lam = this.rotation[0] + lk.x;
+ const phi = Math.max(-90, Math.min(90, this.rotation[1] + lk.y));
+ this.proj.setRotation(lam, phi, this.rotation[2]);
this.proj.updateSun(now);
+ // unwrapped longitude for background parallax
+ if (this.#prevLam === null) this.#prevLam = lam;
+ let dr = lam - this.#prevLam;
+ if (dr > 180) dr -= 360;
+ else if (dr < -180) dr += 360;
+ this.rotAcc += dr;
+ this.#prevLam = lam;
+
this.hq = this.proj.forward(this.data.HQ.lnglat);
this.hqVisible = !!this.hq && this.proj.visible(this.data.HQ.lnglat);
@@ -131,31 +273,109 @@ export class BaseEngine {
requestAnimationFrame((t) => this.#frame(t));
}
- // ---- drag to spin --------------------------------------------------
+ // ---- surges ----------------------------------------------------------
+ #scheduleSurge(now) {
+ if (this.surge && now > this.surge.until) this.surge = null;
+ if (this.surge || this.sim.paused || this.intro < 1) return;
+ if (this.scene.surges === false || now < this.#nextSurge) return;
+ // pick a city on the visible hemisphere
+ const cities = this.data.CITIES;
+ for (let k = 0; k < 10; k++) {
+ const c = cities[(Math.random() * cities.length) | 0];
+ if (this.proj.visible(c.lnglat)) {
+ this.surge = { city: c, t0: now, until: now + SURGE_LEN_MS };
+ this.emit('surge', { city: c });
+ break;
+ }
+ }
+ this.#nextSurge =
+ now + SURGE_EVERY_MS[0] + Math.random() * (SURGE_EVERY_MS[1] - SURGE_EVERY_MS[0]);
+ }
+
+ // EMA of raw frame time → nudge quality down/up with hysteresis + cooldown.
+ #adaptQuality(rawMs) {
+ if (rawMs <= 0 || rawMs > 250) return; // tab was hidden / first frame
+ this.#ema += (Math.min(rawMs, 80) - this.#ema) * 0.05;
+ if (--this.#cooldown > 0) return;
+ if (this.#ema > EMA_SLOW_MS && this.quality > QUALITY_MIN) {
+ this.quality = Math.max(QUALITY_MIN, this.quality - 0.15);
+ this.#cooldown = COOLDOWN_FRAMES;
+ this.resize();
+ } else if (this.#ema < EMA_FAST_MS && this.quality < 1) {
+ this.quality = Math.min(1, this.quality + 0.15);
+ this.#cooldown = COOLDOWN_FRAMES * 2; // step up more cautiously
+ this.resize();
+ }
+ }
+
+ // ---- pointer parallax ------------------------------------------------
+ #bindPointer() {
+ window.addEventListener('mousemove', (e) => {
+ if (this.#drag.active || !this.W) return;
+ this.look.tx = ((e.clientX / this.W) * 2 - 1) * LOOK_DEG;
+ this.look.ty = -((e.clientY / this.H) * 2 - 1) * LOOK_DEG;
+ });
+ window.addEventListener('mouseleave', () => {
+ this.look.tx = 0;
+ this.look.ty = 0;
+ });
+ }
+
+ // ---- drag to spin (with fling) --------------------------------------
#bindDrag() {
- const c = this.dragTarget(), d = this.#drag;
+ const c = this.dragTarget(),
+ d = this.#drag;
const pt = (e) => (e.touches ? e.touches[0] : e);
- const down = (e) => { d.active = true; const p = pt(e); d.x = p.clientX; d.y = p.clientY; c.classList.add("grabbing"); e.preventDefault(); };
+ const down = (e) => {
+ d.active = true;
+ this.#fling = 0;
+ const p = pt(e);
+ d.x = p.clientX;
+ d.y = p.clientY;
+ d.vx = 0;
+ d.t = performance.now();
+ c.classList.add('grabbing');
+ e.preventDefault();
+ };
const move = (e) => {
if (!d.active) return;
- const p = pt(e), k = 0.26;
- this.rotation[0] += (p.clientX - d.x) * k;
+ const p = pt(e),
+ k = 0.26;
+ const ddeg = (p.clientX - d.x) * k;
+ this.rotation[0] += ddeg;
this.rotation[1] = Math.max(-90, Math.min(90, this.rotation[1] - (p.clientY - d.y) * k));
- d.x = p.clientX; d.y = p.clientY;
+ const t = performance.now(),
+ dts = Math.max(0.008, (t - d.t) / 1000);
+ d.vx = d.vx * 0.75 + (ddeg / dts) * 0.25; // smoothed angular velocity (°/s)
+ d.x = p.clientX;
+ d.y = p.clientY;
+ d.t = t;
+ };
+ const up = () => {
+ if (!d.active) return;
+ d.active = false;
+ c.classList.remove('grabbing');
+ // recent movement → glide; stale velocity (held still) → no fling
+ if (performance.now() - d.t < 90) this.#fling = Math.max(-200, Math.min(200, d.vx));
};
- const up = () => { d.active = false; c.classList.remove("grabbing"); };
- c.addEventListener("mousedown", down);
- window.addEventListener("mousemove", move);
- window.addEventListener("mouseup", up);
- c.addEventListener("touchstart", down, { passive: false });
- window.addEventListener("touchmove", move, { passive: false });
- window.addEventListener("touchend", up);
+ c.addEventListener('mousedown', down);
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ c.addEventListener('touchstart', down, { passive: false });
+ window.addEventListener('touchmove', move, { passive: false });
+ window.addEventListener('touchend', up);
}
// ---- backend hooks (overridden by subclasses) ---------------------
- viewportRect() { throw new Error("viewportRect not implemented"); }
+ viewportRect() {
+ throw new Error('viewportRect not implemented');
+ }
resizeBackend() {}
- dragTarget() { throw new Error("dragTarget not implemented"); }
- renderFrame() { throw new Error("renderFrame not implemented"); }
+ dragTarget() {
+ throw new Error('dragTarget not implemented');
+ }
+ renderFrame() {
+ throw new Error('renderFrame not implemented');
+ }
applyScene() {}
}
diff --git a/shared/geo.js b/shared/geo.js
index 14a7dac..732cf0e 100644
--- a/shared/geo.js
+++ b/shared/geo.js
@@ -8,28 +8,41 @@
* Hot-loop layers read the public fields (lon0, sinLat0, cosLat0, cx, cy, R)
* and inline the projection. Cold paths (HQ, source cities) use forward().
*
+ * The graticule and the night/core circles are cached: the graticule is fixed
+ * config, and the night shapes only change when the sun moves (~1/s), so
+ * nothing rebuilds GeoJSON per frame.
+ *
* Depends on the global `d3` (geo module).
*/
-import { DEG } from "./util.js";
+import { DEG } from './util.js';
export class Projection {
#lastSun = -1e9;
+ #graticule = null;
+ #nightShape = null;
+ #coreShape = null;
constructor() {
this.d3proj = d3.geoOrthographic().clipAngle(90).precision(0.4);
- this.rotation = [-10, -25, 0]; // [lambda, phi, gamma]; start near Europe/Atlantic
- this.antisolar = [180, 0]; // night-hemisphere centre; moved by updateSun()
+ this.rotation = [-10, -25, 0]; // [lambda, phi, gamma]; start near Europe/Atlantic
+ this.antisolar = [180, 0]; // night-hemisphere centre; moved by updateSun()
- this.R = 0; this.cx = 0; this.cy = 0;
- this.lon0 = 0; this.sinLat0 = 0; this.cosLat0 = 1;
+ this.R = 0;
+ this.cx = 0;
+ this.cy = 0;
+ this.lon0 = 0;
+ this.sinLat0 = 0;
+ this.cosLat0 = 1;
- this.sun = { lon: 0, sinLat: 0, cosLat: 1 };
+ this.sun = { lon: 0, sinLat: 0, cosLat: 1, sinLon: 0, cosLon: 1 };
this.setRotation(this.rotation[0], this.rotation[1], this.rotation[2]); // initial sync
}
setViewport(R, cx, cy) {
- this.R = R; this.cx = cx; this.cy = cy;
+ this.R = R;
+ this.cx = cx;
+ this.cy = cy;
this.d3proj.scale(R).translate([cx, cy]);
}
@@ -58,29 +71,58 @@ export class Projection {
return d3.geoDistance(lnglat, c) < Math.PI / 2 - 0.04;
}
- // GeoJSON shapes for a d3.geoPath. The projection applies the rotation, so
- // these depend only on fixed config (+ the antisolar centre for night/core).
- graticule() { return d3.geoGraticule().step([20, 20])(); }
- nightShape() { return d3.geoCircle().radius(90).center(this.antisolar)(); } // twilight edge
- coreShape() { return d3.geoCircle().radius(116).center(this.antisolar)(); } // deep-night core
+ // GeoJSON shapes for a d3.geoPath. The graticule is fixed; the night/core
+ // circles are rebuilt only when the sun moves (see updateSun).
+ graticule() {
+ if (!this.#graticule) this.#graticule = d3.geoGraticule().step([20, 20])();
+ return this.#graticule;
+ }
+ nightShape() {
+ if (!this.#nightShape) this.#nightShape = d3.geoCircle().radius(90).center(this.antisolar)();
+ return this.#nightShape;
+ }
+ coreShape() {
+ if (!this.#coreShape) this.#coreShape = d3.geoCircle().radius(116).center(this.antisolar)();
+ return this.#coreShape;
+ }
+
+ // Unit direction of the subsolar point in view space: x → screen right,
+ // y → screen up (flip for canvas), z → toward the viewer. Lets renderers
+ // place limb glare / scale daylight effects without d3 (and even when the
+ // subsolar point is on the far hemisphere, where forward() returns null).
+ sunDir() {
+ const dlon = this.sun.lon - this.lon0;
+ const cd = Math.cos(dlon),
+ sd = Math.sin(dlon);
+ return {
+ x: this.sun.cosLat * sd,
+ y: this.cosLat0 * this.sun.sinLat - this.sinLat0 * this.sun.cosLat * cd,
+ z: this.sinLat0 * this.sun.sinLat + this.cosLat0 * this.sun.cosLat * cd,
+ };
+ }
// Real subsolar point from the clock; recomputed ~1/s (the sun barely moves).
updateSun(now) {
if (now - this.#lastSun < 1000) return;
this.#lastSun = now;
-
+
const dt = new Date();
const utc = dt.getUTCHours() + dt.getUTCMinutes() / 60 + dt.getUTCSeconds() / 3600;
const lonDeg = -15 * (utc - 12);
const start = Date.UTC(dt.getUTCFullYear(), 0, 0);
const doy = Math.floor((dt - start) / 86400000);
const declDeg = -23.44 * Math.cos((360 / 365) * (doy + 10) * DEG);
-
- this.sun.lon = lonDeg * DEG;
+
+ const lonR = lonDeg * DEG;
+ this.sun.lon = lonR;
+ this.sun.sinLon = Math.sin(lonR);
+ this.sun.cosLon = Math.cos(lonR);
this.sun.sinLat = Math.sin(declDeg * DEG);
this.sun.cosLat = Math.cos(declDeg * DEG);
this.antisolar = [lonDeg + 180, -declDeg];
+ this.#nightShape = null; // rebuild lazily with the new centre
+ this.#coreShape = null;
}
}
diff --git a/shared/geometry.js b/shared/geometry.js
index 13f7140..c93e2f0 100644
--- a/shared/geometry.js
+++ b/shared/geometry.js
@@ -3,14 +3,26 @@
* Pure functions that turn the projection + scene into coordinates. No DOM, no
* canvas, no rendering — each renderer takes these points/arrays and paints them
* its own way. Depends on the global `d3` (geoContains) for land-dot seeding.
+ *
+ * Perf notes:
+ * • Land dots / spikes precompute sin/cos of BOTH lat and lng at build time,
+ * so the per-frame projection needs zero trig calls per point — just a few
+ * multiplies (cos/sin of a longitude difference via the angle-sum identity).
+ * • Land dots are sorted by relief tier at build time (`tierEnd` ranges), so
+ * the renderer draws each tier as one contiguous run with one fillStyle and
+ * no per-dot branch.
+ * • The aurora samples fixed longitudes, so their sin/cos live in a table.
*/
-import { DEG, TAU } from "./util.js";
-import { AURORA_SCHEMES } from "./config.js";
+import { DEG, TAU } from './util.js';
+import { AURORA_SCHEMES } from './config.js';
// ---- beam arc (screen-space lifted quadratic) --------------------------
export function arcControl(a, b, CX, CY, R) {
- const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
- const vx = mx - CX, vy = my - CY, vlen = Math.hypot(vx, vy) || 1;
+ const mx = (a[0] + b[0]) / 2,
+ my = (a[1] + b[1]) / 2;
+ const vx = mx - CX,
+ vy = my - CY,
+ vlen = Math.hypot(vx, vy) || 1;
const dist = Math.hypot(b[0] - a[0], b[1] - a[1]);
const lift = Math.min(R * 0.9, dist * 0.42 + R * 0.12);
return [mx + (vx / vlen) * lift, my + (vy / vlen) * lift];
@@ -18,55 +30,111 @@ export function arcControl(a, b, CX, CY, R) {
// De Casteljau split of the quadratic (p0,cp,p1) at t → the partial curve's
// control point (ax,ay) and its endpoint / beam head (hx,hy).
export function quadSplit(p0, cp, p1, t) {
- const ax = p0[0] + (cp[0] - p0[0]) * t, ay = p0[1] + (cp[1] - p0[1]) * t;
- const bx = cp[0] + (p1[0] - cp[0]) * t, by = cp[1] + (p1[1] - cp[1]) * t;
+ const ax = p0[0] + (cp[0] - p0[0]) * t,
+ ay = p0[1] + (cp[1] - p0[1]) * t;
+ const bx = cp[0] + (p1[0] - cp[0]) * t,
+ by = cp[1] + (p1[1] - cp[1]) * t;
return { ax, ay, hx: ax + (bx - ax) * t, hy: ay + (by - ay) * t };
}
// Point on the quadratic at parameter u.
export function quadPoint(p0, cp, p1, u) {
const iu = 1 - u;
- return [iu * iu * p0[0] + 2 * iu * u * cp[0] + u * u * p1[0],
- iu * iu * p0[1] + 2 * iu * u * cp[1] + u * u * p1[1]];
+ return [
+ iu * iu * p0[0] + 2 * iu * u * cp[0] + u * u * p1[0],
+ iu * iu * p0[1] + 2 * iu * u * cp[1] + u * u * p1[1],
+ ];
}
// ---- dotted land -------------------------------------------------------
+// Returns tier-SORTED arrays (small → large) plus `tierEnd` — the exclusive
+// end index of each tier — so renderers can draw contiguous runs. The legacy
+// fields (`lng`, `tier`) are kept for compatibility with the SVG build.
export function buildLandDots(feature, step) {
- const sinA = [], cosA = [], lngA = [], cityA = [], grpA = [], tierA = [], pts = [];
+ const recs = [];
for (let lat = -84; lat <= 84; lat += step) {
const ringStep = step / Math.max(0.18, Math.cos(lat * DEG));
for (let l = -180; l < 180; l += ringStep) {
if (!d3.geoContains(feature, [l, lat])) continue;
- const latR = lat * DEG;
- sinA.push(Math.sin(latR)); cosA.push(Math.cos(latR)); lngA.push(l * DEG);
- pts.push([l, lat]);
- cityA.push(Math.random() < 0.45 ? 1 : 0);
- grpA.push((Math.random() * 3) | 0);
- // size tier for relief texture: ~46% small, ~37% medium, ~17% large
+ const latR = lat * DEG,
+ lngR = l * DEG;
const r = Math.random();
- tierA.push(r < 0.46 ? 0 : (r < 0.83 ? 1 : 2));
+ recs.push({
+ sin: Math.sin(latR),
+ cos: Math.cos(latR),
+ lng: lngR,
+ sinLng: Math.sin(lngR),
+ cosLng: Math.cos(lngR),
+ city: Math.random() < 0.45 ? 1 : 0,
+ grp: (Math.random() * 3) | 0,
+ tier: r < 0.46 ? 0 : r < 0.83 ? 1 : 2,
+ pt: [l, lat],
+ });
}
}
- return {
- sin: Float64Array.from(sinA), cos: Float64Array.from(cosA), lng: Float64Array.from(lngA),
- isCity: Uint8Array.from(cityA), grp: Uint8Array.from(grpA), tier: Uint8Array.from(tierA),
- n: sinA.length, pts,
+ recs.sort((a, b) => a.tier - b.tier);
+ const n = recs.length;
+ const d = {
+ sin: new Float32Array(n),
+ cos: new Float32Array(n),
+ lng: new Float32Array(n),
+ sinLng: new Float32Array(n),
+ cosLng: new Float32Array(n),
+ isCity: new Uint8Array(n),
+ grp: new Uint8Array(n),
+ tier: new Uint8Array(n),
+ tierEnd: [0, 0, n],
+ n,
+ pts: new Array(n),
};
+ for (let i = 0; i < n; i++) {
+ const r = recs[i];
+ d.sin[i] = r.sin;
+ d.cos[i] = r.cos;
+ d.lng[i] = r.lng;
+ d.sinLng[i] = r.sinLng;
+ d.cosLng[i] = r.cosLng;
+ d.isCity[i] = r.city;
+ d.grp[i] = r.grp;
+ d.tier[i] = r.tier;
+ d.pts[i] = r.pt;
+ if (r.tier === 0) d.tierEnd[0] = i + 1;
+ if (r.tier <= 1) d.tierEnd[1] = i + 1;
+ }
+ return d;
}
// ---- corona spikes -----------------------------------------------------
export function buildSpikes(step = 7) {
- const sinA = [], cosA = [], lngA = [], lenA = [], phA = [];
+ const sinA = [],
+ cosA = [],
+ lngA = [],
+ sinLngA = [],
+ cosLngA = [],
+ lenA = [],
+ phA = [];
for (let lat = -86; lat <= 86; lat += step) {
const ringStep = step / Math.max(0.16, Math.cos(lat * DEG));
for (let l = -180; l < 180; l += ringStep) {
- const latR = lat * DEG;
- sinA.push(Math.sin(latR)); cosA.push(Math.cos(latR)); lngA.push(l * DEG);
- lenA.push(0.25 + Math.random() * 0.95); phA.push(Math.random() * TAU);
+ const latR = lat * DEG,
+ lngR = l * DEG;
+ sinA.push(Math.sin(latR));
+ cosA.push(Math.cos(latR));
+ lngA.push(lngR);
+ sinLngA.push(Math.sin(lngR));
+ cosLngA.push(Math.cos(lngR));
+ lenA.push(0.25 + Math.random() * 0.95);
+ phA.push(Math.random() * TAU);
}
}
return {
- sin: Float64Array.from(sinA), cos: Float64Array.from(cosA), lng: Float64Array.from(lngA),
- lenF: Float64Array.from(lenA), phase: Float64Array.from(phA), n: sinA.length,
+ sin: Float32Array.from(sinA),
+ cos: Float32Array.from(cosA),
+ lng: Float32Array.from(lngA),
+ sinLng: Float32Array.from(sinLngA),
+ cosLng: Float32Array.from(cosLngA),
+ lenF: Float32Array.from(lenA),
+ phase: Float32Array.from(phA),
+ n: sinA.length,
};
}
@@ -77,7 +145,9 @@ export function pickNodes(pts, count = 18) {
for (let i = 0; i < count; i++) {
nodes.push({
ll: pts[(Math.random() * pts.length) | 0],
- phase: Math.random() * TAU, sp: 1.5 + Math.random() * 2.5, size: 1.3 + Math.random() * 1.6,
+ phase: Math.random() * TAU,
+ sp: 1.5 + Math.random() * 2.5,
+ size: 1.3 + Math.random() * 1.6,
});
}
return nodes;
@@ -86,28 +156,52 @@ export function pickNodes(pts, count = 18) {
// ---- orbital rings -----------------------------------------------------
export const ORBIT_DEFS = [
{ rf: 1.16, incl: 1.15, yaw0: 0.4, spin: 0.05, sat: true },
- { rf: 1.24, incl: 0.6, yaw0: 2.1, spin: -0.035, sat: false },
- { rf: 1.10, incl: 1.45, yaw0: 1.2, spin: 0.06, sat: true },
+ { rf: 1.24, incl: 0.6, yaw0: 2.1, spin: -0.035, sat: false },
+ { rf: 1.1, incl: 1.45, yaw0: 1.2, spin: 0.06, sat: true },
{ rf: 1.32, incl: 0.95, yaw0: 3.0, spin: -0.025, sat: false },
- { rf: 1.19, incl: 0.3, yaw0: 0.8, spin: 0.04, sat: false },
+ { rf: 1.19, incl: 0.3, yaw0: 0.8, spin: 0.04, sat: false },
];
// Front/back point lists for one ring at time `now`. o = index (drives sat phase).
export function computeOrbit(cfg, o, R, CX, CY, now, N = 84) {
const Rr = R * cfg.rf;
- const ci = Math.cos(cfg.incl), si = Math.sin(cfg.incl);
+ const ci = Math.cos(cfg.incl),
+ si = Math.sin(cfg.incl);
const yaw = cfg.yaw0 + now * 0.001 * cfg.spin * 6;
- const cy_ = Math.cos(yaw), sy = Math.sin(yaw);
- const front = [], back = [];
- let fSeg = null, bSeg = null, sat = null;
- const satIdx = ((now * 0.0004 * (o + 1)) % 1) * N | 0;
+ const cy_ = Math.cos(yaw),
+ sy = Math.sin(yaw);
+ const front = [],
+ back = [];
+ let fSeg = null,
+ bSeg = null,
+ sat = null;
+ const satIdx = (((now * 0.0004 * (o + 1)) % 1) * N) | 0;
for (let k = 0; k <= N; k++) {
const a = (k / N) * TAU;
- const x0 = Math.cos(a), y0 = Math.sin(a);
- const y1 = y0 * ci, z1 = y0 * si, x1 = x0; // tilt around X (z0 = 0)
- const x2 = x1 * cy_ + z1 * sy, z2 = -x1 * sy + z1 * cy_, y2 = y1; // spin around Y
- const sx = CX + x2 * Rr, syc = CY - y2 * Rr;
- if (z2 >= 0) { if (!fSeg) { fSeg = []; front.push(fSeg); } fSeg.push(sx, syc); bSeg = null; }
- else { if (!bSeg) { bSeg = []; back.push(bSeg); } bSeg.push(sx, syc); fSeg = null; }
+ const x0 = Math.cos(a),
+ y0 = Math.sin(a);
+ const y1 = y0 * ci,
+ z1 = y0 * si,
+ x1 = x0; // tilt around X (z0 = 0)
+ const x2 = x1 * cy_ + z1 * sy,
+ z2 = -x1 * sy + z1 * cy_,
+ y2 = y1; // spin around Y
+ const sx = CX + x2 * Rr,
+ syc = CY - y2 * Rr;
+ if (z2 >= 0) {
+ if (!fSeg) {
+ fSeg = [];
+ front.push(fSeg);
+ }
+ fSeg.push(sx, syc);
+ bSeg = null;
+ } else {
+ if (!bSeg) {
+ bSeg = [];
+ back.push(bSeg);
+ }
+ bSeg.push(sx, syc);
+ fSeg = null;
+ }
if (cfg.sat && k === satIdx) sat = { x: sx, y: syc, front: z2 >= 0 };
}
return { front, back, sat };
@@ -118,28 +212,54 @@ export function auroraSpecs(scene) {
const sch = AURORA_SCHEMES[scene.auroraScheme] || AURORA_SCHEMES.gv;
const bl = scene.auroraLat;
return [
- { lat: bl, amp: 4.5, phase: 0, col: sch[0], width: 5.5, op0: 0.34, opPh: 0 },
- { lat: bl + 2.5, amp: 5.5, phase: 1.6, col: sch[1], width: 3.5, op0: 0.30, opPh: 1.7 },
- { lat: -bl, amp: 4.5, phase: 2.4, col: sch[0], width: 5.5, op0: 0.34, opPh: 3.1 },
- { lat: -bl - 2.5, amp: 5.5, phase: 3.9, col: sch[1], width: 3.5, op0: 0.30, opPh: 4.6 },
+ { lat: bl, amp: 4.5, phase: 0, col: sch[0], width: 5.5, op0: 0.34, opPh: 0 },
+ { lat: bl + 2.5, amp: 5.5, phase: 1.6, col: sch[1], width: 3.5, op0: 0.3, opPh: 1.7 },
+ { lat: -bl, amp: 4.5, phase: 2.4, col: sch[0], width: 5.5, op0: 0.34, opPh: 3.1 },
+ { lat: -bl - 2.5, amp: 5.5, phase: 3.9, col: sch[1], width: 3.5, op0: 0.3, opPh: 4.6 },
];
}
+// Fixed longitude sample points for the aurora bands (4° apart) — their sin/cos
+// never change, so they live in a build-once table.
+const AUR_N = 91;
+const AUR_LNG = new Float64Array(AUR_N),
+ AUR_SIN = new Float64Array(AUR_N),
+ AUR_COS = new Float64Array(AUR_N);
+for (let i = 0; i < AUR_N; i++) {
+ const lngR = (-180 + i * 4) * DEG;
+ AUR_LNG[i] = lngR;
+ AUR_SIN[i] = Math.sin(lngR);
+ AUR_COS[i] = Math.cos(lngR);
+}
// Front-only polyline segments for one band (split where it crosses the limb).
export function auroraSegments(proj, CX, CY, R, baseLat, amp, phase, now, speed) {
const { lon0, sinLat0, cosLat0 } = proj;
+ const cosLon0 = Math.cos(lon0),
+ sinLon0 = Math.sin(lon0);
const t = now * 0.0006 * speed;
- const segs = []; let seg = null;
- for (let l = -180; l <= 180; l += 4) {
- const lngR = l * DEG;
- const lat = baseLat + amp * Math.sin(lngR * 3 + t * 6 + phase) + amp * 0.5 * Math.sin(lngR * 7 - t * 4 + phase);
- const latR = lat * DEG, sinL = Math.sin(latR), cosL = Math.cos(latR);
- const dlon = lngR - lon0, cd = Math.cos(dlon);
+ const segs = [];
+ let seg = null;
+ for (let i = 0; i < AUR_N; i++) {
+ const lngR = AUR_LNG[i];
+ const lat =
+ baseLat +
+ amp * Math.sin(lngR * 3 + t * 6 + phase) +
+ amp * 0.5 * Math.sin(lngR * 7 - t * 4 + phase);
+ const latR = lat * DEG,
+ sinL = Math.sin(latR),
+ cosL = Math.cos(latR);
+ const cd = AUR_COS[i] * cosLon0 + AUR_SIN[i] * sinLon0; // cos(lng − lon0)
const cosc = sinLat0 * sinL + cosLat0 * cosL * cd;
- if (cosc <= 0.02) { seg = null; continue; } // back / limb → break
- const sd = Math.sin(dlon);
+ if (cosc <= 0.02) {
+ seg = null;
+ continue;
+ } // back / limb → break
+ const sd = AUR_SIN[i] * cosLon0 - AUR_COS[i] * sinLon0; // sin(lng − lon0)
const px = CX + R * (cosL * sd);
const py = CY - R * (cosLat0 * sinL - sinLat0 * cosL * cd);
- if (!seg) { seg = []; segs.push(seg); }
+ if (!seg) {
+ seg = [];
+ segs.push(seg);
+ }
seg.push(px, py);
}
return segs;
diff --git a/shared/scene-schema.js b/shared/scene-schema.js
index daa6282..54e6223 100644
--- a/shared/scene-schema.js
+++ b/shared/scene-schema.js
@@ -25,54 +25,203 @@ export const SCENE_SCHEMA = {
version: 1,
sections: [
{
- id: "texture", title: "Texture", fields: [
- { key: "dotSize", type: "range", label: "Dot size", default: 2.9, min: 1.5, max: 4.5, step: 0.1, unit: "px", decimals: 1 },
- { key: "texture", type: "range", label: "Relief texture", default: 0.32, min: 0, max: 0.6, step: 0.02, display: "pctOfMax" },
- { key: "landBright", type: "range", label: "Land brightness", default: 1, min: 0.4, max: 1, step: 0.05, display: "pct" },
- { key: "density", type: "select", label: "Dot density", default: "med",
- options: [{ value: "sparse", label: "Sparse" }, { value: "med", label: "Medium" }, { value: "dense", label: "Dense" }] },
- { key: "grid", type: "toggle", label: "Lat / long grid", default: false },
+ id: 'texture',
+ title: 'Texture',
+ fields: [
+ {
+ key: 'dotSize',
+ type: 'range',
+ label: 'Dot size',
+ default: 2.9,
+ min: 1.5,
+ max: 4.5,
+ step: 0.1,
+ unit: 'px',
+ decimals: 1,
+ },
+ {
+ key: 'texture',
+ type: 'range',
+ label: 'Relief texture',
+ default: 0.32,
+ min: 0,
+ max: 0.6,
+ step: 0.02,
+ display: 'pctOfMax',
+ },
+ {
+ key: 'landBright',
+ type: 'range',
+ label: 'Land brightness',
+ default: 1,
+ min: 0.4,
+ max: 1,
+ step: 0.05,
+ display: 'pct',
+ },
+ {
+ key: 'density',
+ type: 'select',
+ label: 'Dot density',
+ default: 'med',
+ options: [
+ { value: 'sparse', label: 'Sparse' },
+ { value: 'med', label: 'Medium' },
+ { value: 'dense', label: 'Dense' },
+ ],
+ },
+ { key: 'grid', type: 'toggle', label: 'Lat / long grid', default: false },
],
},
{
- id: "atmosphere", title: "Atmosphere", fields: [
- { key: "atmos", type: "range", label: "Atmospheric glow", default: 1, min: 0, max: 2, step: 0.1, display: "pctOfMax" },
+ id: 'atmosphere',
+ title: 'Atmosphere',
+ fields: [
+ {
+ key: 'atmos',
+ type: 'range',
+ label: 'Atmospheric glow',
+ default: 1,
+ min: 0,
+ max: 2,
+ step: 0.1,
+ display: 'pctOfMax',
+ },
+ { key: 'sunGlare', type: 'toggle', label: 'Sun glare on limb', default: true },
],
},
{
- id: "daynight", title: "Day & night", fields: [
- { key: "dayNight", type: "toggle", label: "Day / night shadow", default: true },
- { key: "darkness", type: "range", label: "Night darkness", default: 0.55, min: 0, max: 0.9, step: 0.05, display: "pctOfMax" },
- { key: "cityLights", type: "toggle", label: "City lights", default: true },
- { key: "cityBright", type: "range", label: "City brightness", default: 1, min: 0.3, max: 1.4, step: 0.05, display: "pctOfMax" },
+ id: 'daynight',
+ title: 'Day & night',
+ fields: [
+ { key: 'dayNight', type: 'toggle', label: 'Day / night shadow', default: true },
+ {
+ key: 'darkness',
+ type: 'range',
+ label: 'Night darkness',
+ default: 0.55,
+ min: 0,
+ max: 0.9,
+ step: 0.05,
+ display: 'pctOfMax',
+ },
+ { key: 'cityLights', type: 'toggle', label: 'City lights', default: true },
+ {
+ key: 'cityBright',
+ type: 'range',
+ label: 'City brightness',
+ default: 1,
+ min: 0.3,
+ max: 1.4,
+ step: 0.05,
+ display: 'pctOfMax',
+ },
+ { key: 'sunGlint', type: 'toggle', label: 'Ocean sun glint', default: true },
],
},
{
- id: "aurora", title: "Aurora", fields: [
- { key: "aurora", type: "toggle", label: "Aurora", default: true },
- { key: "auroraIntensity", type: "range", label: "Intensity", default: 1, min: 0, max: 1.5, step: 0.05, display: "pctOfMax" },
- { key: "auroraLat", type: "range", label: "Latitude", default: 71, min: 55, max: 82, step: 1, unit: "°", decimals: 0 },
- { key: "auroraSpeed", type: "range", label: "Speed", default: 1, min: 0, max: 3, step: 0.1, unit: "×", decimals: 1 },
- { key: "auroraScheme", type: "select", label: "Colour", default: "gv",
- options: [{ value: "gv", label: "Green·Violet" }, { value: "emerald", label: "Emerald" }, { value: "rose", label: "Rose" }] },
+ id: 'aurora',
+ title: 'Aurora',
+ fields: [
+ { key: 'aurora', type: 'toggle', label: 'Aurora', default: true },
+ {
+ key: 'auroraIntensity',
+ type: 'range',
+ label: 'Intensity',
+ default: 1,
+ min: 0,
+ max: 1.5,
+ step: 0.05,
+ display: 'pctOfMax',
+ },
+ {
+ key: 'auroraLat',
+ type: 'range',
+ label: 'Latitude',
+ default: 71,
+ min: 55,
+ max: 82,
+ step: 1,
+ unit: '°',
+ decimals: 0,
+ },
+ {
+ key: 'auroraSpeed',
+ type: 'range',
+ label: 'Speed',
+ default: 1,
+ min: 0,
+ max: 3,
+ step: 0.1,
+ unit: '×',
+ decimals: 1,
+ },
+ {
+ key: 'auroraScheme',
+ type: 'select',
+ label: 'Colour',
+ default: 'gv',
+ options: [
+ { value: 'gv', label: 'Green·Violet' },
+ { value: 'emerald', label: 'Emerald' },
+ { value: 'rose', label: 'Rose' },
+ ],
+ },
],
},
{
- id: "effects", title: "Effects", fields: [
- { key: "corona", type: "toggle", label: "Edge corona", default: true },
- { key: "coronaIntensity", type: "range", label: "Corona intensity", default: 0.1, min: 0, max: 0.4, step: 0.02, display: "pctOfMax" },
- { key: "nodes", type: "toggle", label: "Star nodes", default: true },
- { key: "orbits", type: "toggle", label: "Orbital rings", default: true },
+ id: 'effects',
+ title: 'Effects',
+ fields: [
+ { key: 'corona', type: 'toggle', label: 'Edge corona', default: true },
+ {
+ key: 'coronaIntensity',
+ type: 'range',
+ label: 'Corona intensity',
+ default: 0.1,
+ min: 0,
+ max: 0.4,
+ step: 0.02,
+ display: 'pctOfMax',
+ },
+ { key: 'nodes', type: 'toggle', label: 'Star nodes', default: true },
+ { key: 'orbits', type: 'toggle', label: 'Orbital rings', default: true },
],
},
{
- id: "cosmos", title: "Cosmos", fields: [
- { key: "shootingStars", type: "toggle", label: "Shooting stars", default: true },
- { key: "meteorRate", type: "range", label: "Meteor frequency", default: 0.5, min: 0, max: 1, step: 0.05, display: "pct" },
- { key: "beamTrails", type: "toggle", label: "Comet beam trails", default: true },
- { key: "atmosPulse", type: "toggle", label: "Atmosphere pulse", default: true },
- { key: "starTwinkle", type: "toggle", label: "Star twinkle", default: true },
- { key: "starDrift", type: "toggle", label: "Star drift", default: true },
+ id: 'cinema',
+ title: 'Cinema',
+ fields: [
+ { key: 'intro', type: 'toggle', label: 'Cinematic arrival', default: true },
+ { key: 'parallax', type: 'toggle', label: 'Pointer parallax', default: true },
+ { key: 'heartbeat', type: 'toggle', label: 'HQ heartbeat', default: true },
+ { key: 'surges', type: 'toggle', label: 'City surges', default: true },
+ ],
+ },
+ {
+ id: 'cosmos',
+ title: 'Cosmos',
+ fields: [
+ { key: 'shootingStars', type: 'toggle', label: 'Shooting stars', default: true },
+ {
+ key: 'meteorRate',
+ type: 'range',
+ label: 'Meteor frequency',
+ default: 0.4,
+ min: 0,
+ max: 1,
+ step: 0.05,
+ display: 'pct',
+ },
+ { key: 'parallaxStars', type: 'toggle', label: 'Parallax stars', default: true },
+ { key: 'nebula', type: 'toggle', label: 'Nebula haze', default: true },
+ { key: 'moon', type: 'toggle', label: 'The Moon', default: true },
+ { key: 'comet', type: 'toggle', label: 'Rare comet', default: true },
+ { key: 'constellations', type: 'toggle', label: 'Constellations', default: true },
+ { key: 'beamTrails', type: 'toggle', label: 'Comet beam trails', default: true },
+ { key: 'atmosPulse', type: 'toggle', label: 'Atmosphere pulse', default: true },
+ { key: 'starTwinkle', type: 'toggle', label: 'Star twinkle', default: true },
+ { key: 'starDrift', type: 'toggle', label: 'Star drift', default: true },
],
},
],
@@ -94,19 +243,21 @@ export function sceneDefaults() {
// clamped/snapped to its bounds (range), checked against options (select), or
// forced to boolean (toggle). Missing/invalid values fall back to the default.
export function sanitizeScene(input) {
- const src = input && typeof input === "object" ? input : {};
+ const src = input && typeof input === 'object' ? input : {};
const out = {};
for (const f of sceneFields()) {
const v = src[f.key];
- if (f.type === "toggle") {
- out[f.key] = typeof v === "boolean" ? v : f.default;
- } else if (f.type === "select") {
+ if (f.type === 'toggle') {
+ out[f.key] = typeof v === 'boolean' ? v : f.default;
+ } else if (f.type === 'select') {
out[f.key] = f.options.some((o) => o.value === v) ? v : f.default;
- } else { // range
+ } else {
+ // range
let n = Number(v);
if (!Number.isFinite(n)) n = f.default;
n = Math.min(f.max, Math.max(f.min, n));
- if (f.step) n = Math.min(f.max, Math.max(f.min, f.min + Math.round((n - f.min) / f.step) * f.step));
+ if (f.step)
+ n = Math.min(f.max, Math.max(f.min, f.min + Math.round((n - f.min) / f.step) * f.step));
out[f.key] = Number(n.toFixed(6)); // kill binary-float dust from snapping
}
}
@@ -115,7 +266,7 @@ export function sanitizeScene(input) {
// Human-readable read-out for a range field (used by the demo panel only).
export function formatValue(field, value) {
- if (field.display === "pct") return Math.round(value * 100) + "%";
- if (field.display === "pctOfMax") return Math.round((value / field.max) * 100) + "%";
- return Number(value).toFixed(field.decimals ?? 0) + (field.unit || "");
+ if (field.display === 'pct') return Math.round(value * 100) + '%';
+ if (field.display === 'pctOfMax') return Math.round((value / field.max) * 100) + '%';
+ return Number(value).toFixed(field.decimals ?? 0) + (field.unit || '');
}
diff --git a/shared/sim.js b/shared/sim.js
index b4b5574..1580207 100644
--- a/shared/sim.js
+++ b/shared/sim.js
@@ -4,22 +4,35 @@
* renderers own the visual objects (canvas data / svg nodes) but defer *what
* happens* to these helpers, so the behaviour can't drift between builds.
*/
-import { weightedPick, tint } from "./util.js";
+import { weightedPick, tint } from './util.js';
// ---- beams -------------------------------------------------------------
-export const BEAM = { DRAW_MS: 1500, HOLD_MS: 700, FADE_MS: 950 };
+// Calm-and-majestic pacing: a slow, graceful draw with a long dissolve.
+export const BEAM = { DRAW_MS: 1800, HOLD_MS: 700, FADE_MS: 1100 };
BEAM.LIFE_MS = BEAM.DRAW_MS + BEAM.HOLD_MS + BEAM.FADE_MS;
// Choose the next beam's activity type (weighted) + a visible source city.
+// During a city surge, ~70% of beams originate from the surging city.
export function pickBeam(e) {
if (!e.proj.visible(e.data.HQ.lnglat)) return null; // can't land if HQ faces away
const enabled = e.data.ACTIVITY_TYPES.filter((t) => e.state.types[t.id].enabled);
if (!enabled.length) return null;
const type = weightedPick(enabled, (t) => e.state.types[t.id].weight || 1);
let city = null;
- for (let k = 0; k < 8; k++) {
+ if (
+ e.surge &&
+ e.surge.until > e.now &&
+ Math.random() < 0.7 &&
+ e.proj.visible(e.surge.city.lnglat)
+ ) {
+ city = e.surge.city;
+ }
+ for (let k = 0; !city && k < 8; k++) {
const c = e.data.CITIES[(Math.random() * e.data.CITIES.length) | 0];
- if (e.proj.visible(c.lnglat)) { city = c; break; }
+ if (e.proj.visible(c.lnglat)) {
+ city = c;
+ break;
+ }
}
return city ? { type, city } : null;
}
@@ -35,18 +48,25 @@ export function spawnMeteorParams(W, H) {
const x = fromRight ? W + 40 : Math.random() * W * 0.9;
const y = fromRight ? Math.random() * H * 0.6 : -40;
const ang = (Math.random() * 0.5 + 0.62) * Math.PI; // ~112°–203°: down & left
- const speed = (W + H) * (0.34 + Math.random() * 0.30);
+ const speed = (W + H) * (0.26 + Math.random() * 0.22);
return {
- x, y, vx: Math.cos(ang) * speed, vy: Math.abs(Math.sin(ang)) * speed,
- len: 90 + Math.random() * 170, w: 1.3 + Math.random() * 1.1, hr: 1.4 + Math.random() * 1.2,
- t: 0, ttl: 0.9 + Math.random() * 0.7,
+ x,
+ y,
+ vx: Math.cos(ang) * speed,
+ vy: Math.abs(Math.sin(ang)) * speed,
+ len: 90 + Math.random() * 170,
+ w: 1.3 + Math.random() * 1.1,
+ hr: 1.4 + Math.random() * 1.2,
+ t: 0,
+ ttl: 1.1 + Math.random() * 0.8,
};
}
// Advance a meteor; returns false once it should be removed.
export function stepMeteor(m, dt, H) {
m.t += dt;
if (m.t >= m.ttl || m.x < -120 || m.y > H + 120) return false;
- m.x += m.vx * dt; m.y += m.vy * dt;
+ m.x += m.vx * dt;
+ m.y += m.vy * dt;
return true;
}
export function meteorOpacity(m) {
@@ -59,16 +79,22 @@ export function meteorOpacity(m) {
// ---- fireworks ---------------------------------------------------------
// One burst: a flash spec + N spark specs (numeric only; caller sets x/y = cx/cy).
export function fireworkBurst(R, scale = 1, color) {
- const cols = [color, "#ffffff", tint(color)];
+ const cols = [color, '#ffffff', tint(color)];
const N = Math.round(48 * scale);
const sparks = [];
for (let i = 0; i < N; i++) {
- const ang = Math.random() * Math.PI * 2, core = i < N * 0.28;
+ const ang = Math.random() * Math.PI * 2,
+ core = i < N * 0.28;
const speed = (0.42 + Math.random() * 0.95) * R * 1.7 * scale * (core ? 1.3 : 1);
sparks.push({
- vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed,
- r: core ? 1.5 : 2.1, color: core ? "#fff" : cols[(Math.random() * cols.length) | 0],
- t: 0, ttl: 0.9 + Math.random() * 0.8, twk: Math.random() * 10, twinkle: Math.random() < 0.55,
+ vx: Math.cos(ang) * speed,
+ vy: Math.sin(ang) * speed,
+ r: core ? 1.5 : 2.1,
+ color: core ? '#fff' : cols[(Math.random() * cols.length) | 0],
+ t: 0,
+ ttl: 0.9 + Math.random() * 0.8,
+ twk: Math.random() * 10,
+ twinkle: Math.random() < 0.55,
});
}
return { flash: { t: 0, ttl: 0.34, grow: R * 0.46 * scale }, sparks };
@@ -77,17 +103,20 @@ export function fireworkBurst(R, scale = 1, color) {
export function fireworkBarrage(R, color) {
const rx = (m) => (Math.random() * 2 - 1) * R * m;
return [
- { delay: 0, dx: 0, dy: 0, color, scale: 1 },
- { delay: 210, dx: rx(0.20), dy: -R * 0.14 * Math.random() - R * 0.04, color, scale: 0.72 },
- { delay: 410, dx: rx(0.24), dy: -R * 0.02, color: tint(color), scale: 0.66 },
+ { delay: 0, dx: 0, dy: 0, color, scale: 1 },
+ { delay: 210, dx: rx(0.2), dy: -R * 0.14 * Math.random() - R * 0.04, color, scale: 0.72 },
+ { delay: 410, dx: rx(0.24), dy: -R * 0.02, color: tint(color), scale: 0.66 },
];
}
// Integrate one spark (gravity + drag). No-op for the flash.
export function stepFirework(p, dt, R) {
if (p.flash) return;
const drag = Math.max(0, 1 - 2.4 * dt);
- p.vx *= drag; p.vy *= drag; p.vy += R * 1.25 * dt;
- p.x += p.vx * dt; p.y += p.vy * dt;
+ p.vx *= drag;
+ p.vy *= drag;
+ p.vy += R * 1.25 * dt;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
}
export function fireworkAlpha(p) {
const a = p.t / p.ttl;
diff --git a/shared/ui.css b/shared/ui.css
index a290739..fa0f1ad 100644
--- a/shared/ui.css
+++ b/shared/ui.css
@@ -1,210 +1,755 @@
/* games.directory globe — shared chrome (backend-agnostic).
* Renderer-specific styling lives in canvas/canvas.css and svg/scene.css. */
-:root{
- --bg:#05060c;
- --ink:#eaf0ff;
- --muted:#8a93b2;
- --line:rgba(140,160,210,.14);
- --panel:rgba(14,18,32,.72);
- --panel-brd:rgba(150,170,220,.16);
- --accent:#5ad1ff;
- --sans:"Space Grotesk",system-ui,sans-serif;
- --mono:"JetBrains Mono",ui-monospace,monospace;
-}
-*{box-sizing:border-box}
-html,body{margin:0;height:100%}
-body{
+:root {
+ --bg: #04050b;
+ --ink: #eaf0ff;
+ --muted: #8a93b2;
+ --line: rgba(140, 160, 210, 0.14);
+ --panel: rgba(14, 18, 32, 0.72);
+ --panel-brd: rgba(150, 170, 220, 0.16);
+ --accent: #5ad1ff;
+ --sans: 'Space Grotesk', system-ui, sans-serif;
+ --mono: 'JetBrains Mono', ui-monospace, monospace;
+}
+* {
+ box-sizing: border-box;
+}
+html,
+body {
+ margin: 0;
+ height: 100%;
+}
+body {
background:
- radial-gradient(1200px 800px at 70% -10%, rgba(40,70,130,.22), transparent 60%),
- radial-gradient(900px 700px at 15% 110%, rgba(70,40,120,.18), transparent 55%),
- var(--bg);
- color:var(--ink);
- font-family:var(--sans);
- overflow:hidden;
- -webkit-font-smoothing:antialiased;
+ radial-gradient(1200px 800px at 70% -10%, rgba(40, 70, 130, 0.2), transparent 60%),
+ radial-gradient(900px 700px at 15% 110%, rgba(70, 40, 120, 0.16), transparent 55%),
+ radial-gradient(140% 100% at 50% 120%, rgba(8, 12, 26, 0.6), transparent 70%), var(--bg);
+ color: var(--ink);
+ font-family: var(--sans);
+ overflow: hidden;
+ -webkit-font-smoothing: antialiased;
}
/* faint parallax starfield (DOM in both renderers) */
-.stars{position:fixed;inset:-8%;pointer-events:none;opacity:.6;
+.stars {
+ position: fixed;
+ inset: -8%;
+ pointer-events: none;
+ opacity: 0.6;
background-image:
- radial-gradient(1px 1px at 20% 30%, rgba(255,255,255,.7), transparent),
- radial-gradient(1px 1px at 80% 20%, rgba(255,255,255,.5), transparent),
- radial-gradient(1px 1px at 60% 70%, rgba(255,255,255,.45), transparent),
- radial-gradient(1px 1px at 35% 80%, rgba(255,255,255,.5), transparent),
- radial-gradient(1.4px 1.4px at 90% 60%, rgba(255,255,255,.55), transparent),
- radial-gradient(1px 1px at 10% 65%, rgba(255,255,255,.4), transparent),
- radial-gradient(1.2px 1.2px at 50% 12%, rgba(255,255,255,.5), transparent);}
-.stars2{opacity:.42;
+ radial-gradient(1px 1px at 20% 30%, rgba(255, 255, 255, 0.7), transparent),
+ radial-gradient(1px 1px at 80% 20%, rgba(255, 255, 255, 0.5), transparent),
+ radial-gradient(1px 1px at 60% 70%, rgba(255, 255, 255, 0.45), transparent),
+ radial-gradient(1px 1px at 35% 80%, rgba(255, 255, 255, 0.5), transparent),
+ radial-gradient(1.4px 1.4px at 90% 60%, rgba(255, 255, 255, 0.55), transparent),
+ radial-gradient(1px 1px at 10% 65%, rgba(255, 255, 255, 0.4), transparent),
+ radial-gradient(1.2px 1.2px at 50% 12%, rgba(255, 255, 255, 0.5), transparent);
+}
+.stars2 {
+ opacity: 0.42;
background-image:
- radial-gradient(1px 1px at 12% 18%, rgba(255,255,255,.6), transparent),
- radial-gradient(1.3px 1.3px at 72% 42%, rgba(200,224,255,.7), transparent),
- radial-gradient(1px 1px at 44% 58%, rgba(255,255,255,.5), transparent),
- radial-gradient(1px 1px at 88% 84%, rgba(255,255,255,.45), transparent),
- radial-gradient(1.1px 1.1px at 28% 90%, rgba(255,255,255,.5), transparent),
- radial-gradient(1px 1px at 64% 8%, rgba(255,255,255,.45), transparent);}
-@keyframes drift1{from{transform:translate3d(0,0,0)}to{transform:translate3d(-2%,1.5%,0)}}
-@keyframes drift2{from{transform:translate3d(0,0,0)}to{transform:translate3d(2.4%,-1.8%,0)}}
-@keyframes tw1{0%,100%{opacity:.6}50%{opacity:.34}}
-@keyframes tw2{0%,100%{opacity:.42}50%{opacity:.68}}
+ radial-gradient(1px 1px at 12% 18%, rgba(255, 255, 255, 0.6), transparent),
+ radial-gradient(1.3px 1.3px at 72% 42%, rgba(200, 224, 255, 0.7), transparent),
+ radial-gradient(1px 1px at 44% 58%, rgba(255, 255, 255, 0.5), transparent),
+ radial-gradient(1px 1px at 88% 84%, rgba(255, 255, 255, 0.45), transparent),
+ radial-gradient(1.1px 1.1px at 28% 90%, rgba(255, 255, 255, 0.5), transparent),
+ radial-gradient(1px 1px at 64% 8%, rgba(255, 255, 255, 0.45), transparent);
+}
+@keyframes drift1 {
+ from {
+ transform: translate3d(0, 0, 0);
+ }
+ to {
+ transform: translate3d(-2%, 1.5%, 0);
+ }
+}
+@keyframes drift2 {
+ from {
+ transform: translate3d(0, 0, 0);
+ }
+ to {
+ transform: translate3d(2.4%, -1.8%, 0);
+ }
+}
+@keyframes tw1 {
+ 0%,
+ 100% {
+ opacity: 0.6;
+ }
+ 50% {
+ opacity: 0.34;
+ }
+}
+@keyframes tw2 {
+ 0%,
+ 100% {
+ opacity: 0.42;
+ }
+ 50% {
+ opacity: 0.68;
+ }
+}
-.stage{position:fixed;inset:0}
+.stage {
+ position: fixed;
+ inset: 0;
+}
/* brand mark */
-.brand{position:fixed;top:26px;left:30px;z-index:5}
-.brand .mark{display:flex;align-items:center;gap:11px}
-.brand .glyph{width:30px;height:30px;border-radius:8px;
- background:linear-gradient(140deg,#5ad1ff,#7c6bff);
- display:grid;place-items:center;color:#04060e;font-weight:700;font-size:17px;
- box-shadow:0 6px 24px rgba(90,140,255,.35)}
-.brand .name{font-weight:600;font-size:18px;letter-spacing:-.01em}
-.brand .name b{color:var(--accent);font-weight:600}
-.brand .tag{margin:7px 0 0 41px;font-family:var(--mono);font-size:11px;color:var(--muted);
- letter-spacing:.06em;text-transform:uppercase}
+.brand {
+ position: fixed;
+ top: 26px;
+ left: 30px;
+ z-index: 5;
+}
+.brand .mark {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+}
+.brand .glyph {
+ width: 30px;
+ height: 30px;
+ border-radius: 8px;
+ background: linear-gradient(140deg, #5ad1ff, #7c6bff);
+ display: grid;
+ place-items: center;
+ color: #04060e;
+ font-weight: 700;
+ font-size: 17px;
+ box-shadow: 0 6px 24px rgba(90, 140, 255, 0.35);
+}
+.brand .name {
+ font-weight: 600;
+ font-size: 18px;
+ letter-spacing: -0.01em;
+}
+.brand .name b {
+ color: var(--accent);
+ font-weight: 600;
+}
+.brand .tag {
+ margin: 7px 0 0 41px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
/* control panel — demo only; the clean production hero hides it entirely */
-body:not(.demo) .panel{display:none}
-.panel{position:fixed;left:30px;bottom:28px;z-index:6;width:312px;
- background:var(--panel);border:1px solid var(--panel-brd);border-radius:16px;
- backdrop-filter:blur(18px) saturate(1.2);-webkit-backdrop-filter:blur(18px) saturate(1.2);
- box-shadow:0 24px 60px rgba(0,0,0,.5);padding:16px 16px 14px}
-.panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
-.live{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;
- letter-spacing:.14em;color:var(--muted)}
-.live .blip{width:8px;height:8px;border-radius:50%;background:#36d399;
- box-shadow:0 0 0 0 rgba(54,211,153,.6);animation:blip 1.8s ease-out infinite}
-@keyframes blip{0%{box-shadow:0 0 0 0 rgba(54,211,153,.55)}100%{box-shadow:0 0 0 9px rgba(54,211,153,0)}}
+body:not(.demo) .panel {
+ display: none;
+}
+.panel {
+ position: fixed;
+ left: 30px;
+ bottom: 28px;
+ z-index: 6;
+ width: 312px;
+ background: var(--panel);
+ border: 1px solid var(--panel-brd);
+ border-radius: 16px;
+ backdrop-filter: blur(18px) saturate(1.2);
+ -webkit-backdrop-filter: blur(18px) saturate(1.2);
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
+ padding: 16px 16px 14px;
+}
+.panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+.live {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ color: var(--muted);
+}
+.live .blip {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #36d399;
+ box-shadow: 0 0 0 0 rgba(54, 211, 153, 0.6);
+ animation: blip 1.8s ease-out infinite;
+}
+@keyframes blip {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(54, 211, 153, 0.55);
+ }
+ 100% {
+ box-shadow: 0 0 0 9px rgba(54, 211, 153, 0);
+ }
+}
-.total{display:flex;align-items:baseline;gap:9px;margin-bottom:14px}
-.total #total-count{font-family:var(--mono);font-weight:600;font-size:34px;line-height:1;
- color:#fff;font-variant-numeric:tabular-nums}
-.total .total-lbl{font-size:12px;color:var(--muted)}
+.total {
+ display: flex;
+ align-items: baseline;
+ gap: 9px;
+ margin-bottom: 14px;
+}
+.total #total-count {
+ font-family: var(--mono);
+ font-weight: 600;
+ font-size: 34px;
+ line-height: 1;
+ color: #fff;
+ font-variant-numeric: tabular-nums;
+}
+.total .total-lbl {
+ font-size: 12px;
+ color: var(--muted);
+}
-.act-row{display:grid;grid-template-columns:22px 1fr auto 26px;align-items:center;gap:10px;
- padding:5px 0}
-.act-row.off{opacity:.4}
-.swatch{position:relative;width:18px;height:18px;border-radius:5px;background:var(--c);
- cursor:pointer;box-shadow:0 0 10px -1px var(--c), inset 0 0 0 1px rgba(255,255,255,.18)}
-.swatch input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;border:0;padding:0}
-.act-name{font-size:13px;color:var(--ink)}
-.act-count{font-family:var(--mono);font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
-.eye{appearance:none;border:1px solid var(--line);background:transparent;border-radius:6px;
- width:26px;height:22px;display:grid;place-items:center;cursor:pointer;padding:0;transition:.15s}
-.eye .eye-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);
- box-shadow:0 0 8px var(--accent);transition:.15s}
-.eye[aria-pressed="false"] .eye-dot{background:#444b63;box-shadow:none}
-.eye:hover{border-color:var(--accent)}
+.act-row {
+ display: grid;
+ grid-template-columns: 22px 1fr auto 26px;
+ align-items: center;
+ gap: 10px;
+ padding: 5px 0;
+}
+.act-row.off {
+ opacity: 0.4;
+}
+.swatch {
+ position: relative;
+ width: 18px;
+ height: 18px;
+ border-radius: 5px;
+ background: var(--c);
+ cursor: pointer;
+ box-shadow:
+ 0 0 10px -1px var(--c),
+ inset 0 0 0 1px rgba(255, 255, 255, 0.18);
+}
+.swatch input {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ cursor: pointer;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ padding: 0;
+}
+.act-name {
+ font-size: 13px;
+ color: var(--ink);
+}
+.act-count {
+ font-family: var(--mono);
+ font-size: 13px;
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+.eye {
+ appearance: none;
+ border: 1px solid var(--line);
+ background: transparent;
+ border-radius: 6px;
+ width: 26px;
+ height: 22px;
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+ padding: 0;
+ transition: 0.15s;
+}
+.eye .eye-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 8px var(--accent);
+ transition: 0.15s;
+}
+.eye[aria-pressed='false'] .eye-dot {
+ background: #444b63;
+ box-shadow: none;
+}
+.eye:hover {
+ border-color: var(--accent);
+}
-.divider{height:1px;background:var(--line);margin:12px 0}
+.divider {
+ height: 1px;
+ background: var(--line);
+ margin: 12px 0;
+}
-.ctrl{margin:9px 0}
-.ctrl-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
-.ctrl-top label{font-size:12px;color:var(--muted)}
-.ctrl-top .val{font-family:var(--mono);font-size:12px;color:var(--accent)}
-input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:4px;border-radius:3px;
- background:linear-gradient(90deg,var(--accent),#7c6bff);outline:none;cursor:pointer}
-input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:15px;height:15px;border-radius:50%;
- background:#fff;box-shadow:0 0 0 4px rgba(90,209,255,.25);cursor:pointer}
-input[type=range]::-moz-range-thumb{width:15px;height:15px;border-radius:50%;background:#fff;border:0;
- box-shadow:0 0 0 4px rgba(90,209,255,.25);cursor:pointer}
+.ctrl {
+ margin: 9px 0;
+}
+.ctrl-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
+}
+.ctrl-top label {
+ font-size: 12px;
+ color: var(--muted);
+}
+.ctrl-top .val {
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--accent);
+}
+input[type='range'] {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 4px;
+ border-radius: 3px;
+ background: linear-gradient(90deg, var(--accent), #7c6bff);
+ outline: none;
+ cursor: pointer;
+}
+input[type='range']::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 15px;
+ height: 15px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 0 0 4px rgba(90, 209, 255, 0.25);
+ cursor: pointer;
+}
+input[type='range']::-moz-range-thumb {
+ width: 15px;
+ height: 15px;
+ border-radius: 50%;
+ background: #fff;
+ border: 0;
+ box-shadow: 0 0 0 4px rgba(90, 209, 255, 0.25);
+ cursor: pointer;
+}
-.row-btns{display:flex;gap:8px;margin-top:13px}
-.btn{flex:1;appearance:none;border:1px solid var(--panel-brd);background:rgba(255,255,255,.04);
- color:var(--ink);font-family:var(--sans);font-size:13px;font-weight:500;border-radius:10px;
- padding:9px 10px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:7px;
- transition:.15s}
-.btn:hover{background:rgba(255,255,255,.09);border-color:var(--accent)}
-.btn .ico{width:12px;height:12px;display:inline-block}
-#pause .pp-pause{display:inline-block}#pause .pp-play{display:none}
-#pause.paused .pp-pause{display:none}#pause.paused .pp-play{display:inline-block}
+.row-btns {
+ display: flex;
+ gap: 8px;
+ margin-top: 13px;
+}
+.btn {
+ flex: 1;
+ appearance: none;
+ border: 1px solid var(--panel-brd);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: 13px;
+ font-weight: 500;
+ border-radius: 10px;
+ padding: 9px 10px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ transition: 0.15s;
+}
+.btn:hover {
+ background: rgba(255, 255, 255, 0.09);
+ border-color: var(--accent);
+}
+.btn .ico {
+ width: 12px;
+ height: 12px;
+ display: inline-block;
+}
+#pause .pp-pause {
+ display: inline-block;
+}
+#pause .pp-play {
+ display: none;
+}
+#pause.paused .pp-pause {
+ display: none;
+}
+#pause.paused .pp-play {
+ display: inline-block;
+}
-.fw-row{display:grid;grid-template-columns:26px auto 1fr;align-items:center;gap:9px;margin-top:12px}
-.fw-row.off{opacity:.5}
-.fw-label{font-size:13px;color:var(--ink);white-space:nowrap}
-.fw-select{justify-self:end;background:rgba(255,255,255,.05);color:var(--ink);
- border:1px solid var(--panel-brd);border-radius:8px;padding:6px 8px;font-family:var(--sans);
- font-size:12px;cursor:pointer;max-width:130px;-webkit-appearance:none;appearance:none}
-.fw-select:focus{outline:none;border-color:var(--accent)}
-.fw-select option{background:#0e1220;color:var(--ink)}
-.hint{margin-top:11px;font-family:var(--mono);font-size:10.5px;color:var(--muted);
- text-align:center;letter-spacing:.04em;opacity:.8}
+.fw-row {
+ display: grid;
+ grid-template-columns: 26px auto 1fr;
+ align-items: center;
+ gap: 9px;
+ margin-top: 12px;
+}
+.fw-row.off {
+ opacity: 0.5;
+}
+.fw-label {
+ font-size: 13px;
+ color: var(--ink);
+ white-space: nowrap;
+}
+.fw-select {
+ justify-self: end;
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--ink);
+ border: 1px solid var(--panel-brd);
+ border-radius: 8px;
+ padding: 6px 8px;
+ font-family: var(--sans);
+ font-size: 12px;
+ cursor: pointer;
+ max-width: 130px;
+ -webkit-appearance: none;
+ appearance: none;
+}
+.fw-select:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.fw-select option {
+ background: #0e1220;
+ color: var(--ink);
+}
+.hint {
+ margin-top: 11px;
+ font-family: var(--mono);
+ font-size: 10.5px;
+ color: var(--muted);
+ text-align: center;
+ letter-spacing: 0.04em;
+ opacity: 0.8;
+}
/* scene settings (expander) */
-.scene-expander{margin-top:13px;width:100%;appearance:none;cursor:pointer;
- display:flex;align-items:center;gap:9px;padding:10px 12px;border-radius:11px;
- border:1px solid var(--panel-brd);background:rgba(255,255,255,.04);color:var(--ink);
- font-family:var(--sans);font-size:13px;font-weight:500;transition:.15s}
-.scene-expander:hover{background:rgba(255,255,255,.09);border-color:var(--accent)}
-.scene-expander .gear-ico{width:15px;height:15px;color:var(--accent);flex:none}
-.scene-expander .se-label{flex:1;text-align:left}
-.scene-expander .chev{width:13px;height:13px;color:var(--muted);transition:transform .2s}
-.scene-expander[aria-pressed="true"]{border-color:var(--accent);background:rgba(90,209,255,.08)}
-.scene-expander[aria-pressed="true"] .chev{transform:rotate(180deg);color:var(--accent)}
-.scene{margin-top:12px;border-top:1px solid var(--line);padding-top:6px;
- max-height:46vh;overflow-y:auto;overflow-x:hidden}
-.scene::-webkit-scrollbar{width:7px}
-.scene::-webkit-scrollbar-thumb{background:rgba(150,170,220,.22);border-radius:4px}
-.scene[hidden]{display:none}
-.sec-h{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;
- color:var(--muted);margin:14px 0 6px;opacity:.8}
-.sec-h:first-child{margin-top:6px}
-.sc-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:5px 0}
-.sc-row .sc-lbl{font-size:12.5px;color:var(--ink)}
-.sc-slider{padding:2px 0 9px}
-.sc-slider .sc-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
-.sc-slider .sc-lbl{font-size:12.5px;color:var(--muted)}
-.sc-slider .sc-val{font-family:var(--mono);font-size:11.5px;color:var(--accent)}
-.sc-seg{display:flex;gap:4px;background:rgba(255,255,255,.04);border:1px solid var(--line);
- border-radius:9px;padding:3px}
-.sc-seg button{flex:1;appearance:none;border:0;background:transparent;color:var(--muted);
- font-family:var(--sans);font-size:11.5px;padding:5px 8px;border-radius:6px;cursor:pointer;transition:.13s;white-space:nowrap}
-.sc-seg button.on{background:rgba(90,209,255,.16);color:var(--ink)}
-.sw{appearance:none;border:0;width:38px;height:22px;border-radius:12px;background:rgba(120,135,170,.35);
- position:relative;cursor:pointer;transition:.18s;flex:none}
-.sw::after{content:"";position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;
- background:#fff;transition:.18s;box-shadow:0 1px 3px rgba(0,0,0,.4)}
-.sw[aria-pressed="true"]{background:linear-gradient(90deg,var(--accent),#7c6bff)}
-.sw[aria-pressed="true"]::after{left:18px}
+.scene-expander {
+ margin-top: 13px;
+ width: 100%;
+ appearance: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 10px 12px;
+ border-radius: 11px;
+ border: 1px solid var(--panel-brd);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: 13px;
+ font-weight: 500;
+ transition: 0.15s;
+}
+.scene-expander:hover {
+ background: rgba(255, 255, 255, 0.09);
+ border-color: var(--accent);
+}
+.scene-expander .gear-ico {
+ width: 15px;
+ height: 15px;
+ color: var(--accent);
+ flex: none;
+}
+.scene-expander .se-label {
+ flex: 1;
+ text-align: left;
+}
+.scene-expander .chev {
+ width: 13px;
+ height: 13px;
+ color: var(--muted);
+ transition: transform 0.2s;
+}
+.scene-expander[aria-pressed='true'] {
+ border-color: var(--accent);
+ background: rgba(90, 209, 255, 0.08);
+}
+.scene-expander[aria-pressed='true'] .chev {
+ transform: rotate(180deg);
+ color: var(--accent);
+}
+.scene {
+ margin-top: 12px;
+ border-top: 1px solid var(--line);
+ padding-top: 6px;
+ max-height: 46vh;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+.scene::-webkit-scrollbar {
+ width: 7px;
+}
+.scene::-webkit-scrollbar-thumb {
+ background: rgba(150, 170, 220, 0.22);
+ border-radius: 4px;
+}
+.scene[hidden] {
+ display: none;
+}
+.sec-h {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin: 14px 0 6px;
+ opacity: 0.8;
+}
+.sec-h:first-child {
+ margin-top: 6px;
+}
+.sc-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 5px 0;
+}
+.sc-row .sc-lbl {
+ font-size: 12.5px;
+ color: var(--ink);
+}
+.sc-slider {
+ padding: 2px 0 9px;
+}
+.sc-slider .sc-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
+}
+.sc-slider .sc-lbl {
+ font-size: 12.5px;
+ color: var(--muted);
+}
+.sc-slider .sc-val {
+ font-family: var(--mono);
+ font-size: 11.5px;
+ color: var(--accent);
+}
+.sc-seg {
+ display: flex;
+ gap: 4px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ padding: 3px;
+}
+.sc-seg button {
+ flex: 1;
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-family: var(--sans);
+ font-size: 11.5px;
+ padding: 5px 8px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: 0.13s;
+ white-space: nowrap;
+}
+.sc-seg button.on {
+ background: rgba(90, 209, 255, 0.16);
+ color: var(--ink);
+}
+.sw {
+ appearance: none;
+ border: 0;
+ width: 38px;
+ height: 22px;
+ border-radius: 12px;
+ background: rgba(120, 135, 170, 0.35);
+ position: relative;
+ cursor: pointer;
+ transition: 0.18s;
+ flex: none;
+}
+.sw::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #fff;
+ transition: 0.18s;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
+}
+.sw[aria-pressed='true'] {
+ background: linear-gradient(90deg, var(--accent), #7c6bff);
+}
+.sw[aria-pressed='true']::after {
+ left: 18px;
+}
/* live ticker (top-right) */
-.ticker{position:fixed;top:26px;right:30px;z-index:5;width:286px;
- display:flex;flex-direction:column;gap:7px;pointer-events:none}
-.tk-line{display:flex;align-items:center;gap:9px;padding:8px 12px;border-radius:11px;
- background:rgba(14,18,32,.5);border:1px solid rgba(150,170,220,.12);
- backdrop-filter:blur(10px) saturate(1.1);-webkit-backdrop-filter:blur(10px) saturate(1.1);
- box-shadow:0 8px 24px rgba(0,0,0,.32);
- font-family:var(--mono);font-size:11.5px;color:var(--ink);
- animation:tkin .5s cubic-bezier(.2,.75,.2,1) both;overflow:hidden}
-.tk-line .tk-dot{width:8px;height:8px;border-radius:50%;flex:none;box-shadow:0 0 9px currentColor}
-.tk-line .tk-txt{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:.01em}
-.tk-line .tk-city{color:var(--muted)}
-@keyframes tkin{from{opacity:0;transform:translateY(-9px) scale(.985)}to{opacity:1;transform:none}}
+.ticker {
+ position: fixed;
+ top: 26px;
+ right: 30px;
+ z-index: 5;
+ width: 286px;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ pointer-events: none;
+}
+.tk-line {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 8px 12px;
+ border-radius: 11px;
+ background: rgba(14, 18, 32, 0.5);
+ border: 1px solid rgba(150, 170, 220, 0.12);
+ backdrop-filter: blur(10px) saturate(1.1);
+ -webkit-backdrop-filter: blur(10px) saturate(1.1);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
+ font-family: var(--mono);
+ font-size: 11.5px;
+ color: var(--ink);
+ animation: tkin 0.5s cubic-bezier(0.2, 0.75, 0.2, 1) both;
+ overflow: hidden;
+}
+.tk-line .tk-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: none;
+ box-shadow: 0 0 9px currentColor;
+}
+.tk-line .tk-txt {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ letter-spacing: 0.01em;
+}
+.tk-line .tk-city {
+ color: var(--muted);
+}
+.tk-line.tk-surge {
+ border-color: rgba(90, 209, 255, 0.4);
+ background: rgba(20, 34, 52, 0.6);
+ color: #dff4ff;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+@keyframes tkin {
+ from {
+ opacity: 0;
+ transform: translateY(-9px) scale(0.985);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
/* FPS meter */
-.fps-meter{position:fixed;top:26px;left:50%;transform:translateX(-50%);z-index:7;
- display:flex;align-items:baseline;gap:6px;padding:6px 12px;border-radius:10px;
- background:rgba(14,18,32,.6);border:1px solid var(--panel-brd);
- backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
- font-family:var(--mono);color:var(--ink);pointer-events:none;
- box-shadow:0 8px 24px rgba(0,0,0,.32)}
-.fps-meter .fps-num{font-size:17px;font-weight:600;font-variant-numeric:tabular-nums;color:#5cffb0}
-.fps-meter .fps-unit{font-size:10px;color:var(--muted)}
-.fps-meter .fps-ms{font-size:11px;color:var(--muted);margin-left:4px}
-.fps-meter .fps-tag{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);
- margin-left:8px;padding-left:8px;border-left:1px solid var(--line)}
-.fps-meter.warn .fps-num{color:#ffd166}
-.fps-meter.bad .fps-num{color:#ff8a8a}
+.fps-meter {
+ position: fixed;
+ top: 26px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 7;
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: 10px;
+ background: rgba(14, 18, 32, 0.6);
+ border: 1px solid var(--panel-brd);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ font-family: var(--mono);
+ color: var(--ink);
+ pointer-events: none;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
+}
+.fps-meter .fps-num {
+ font-size: 17px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ color: #5cffb0;
+}
+.fps-meter .fps-unit {
+ font-size: 10px;
+ color: var(--muted);
+}
+.fps-meter .fps-ms {
+ font-size: 11px;
+ color: var(--muted);
+ margin-left: 4px;
+}
+.fps-meter .fps-tag {
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin-left: 8px;
+ padding-left: 8px;
+ border-left: 1px solid var(--line);
+}
+.fps-meter.warn .fps-num {
+ color: #ffd166;
+}
+.fps-meter.bad .fps-num {
+ color: #ff8a8a;
+}
/* loading */
-#loading{position:fixed;inset:0;display:grid;place-items:center;z-index:20;
- background:var(--bg);color:var(--muted);font-family:var(--mono);font-size:13px}
-.spinner{width:30px;height:30px;border:2px solid rgba(255,255,255,.15);border-top-color:var(--accent);
- border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 14px}
-@keyframes spin{to{transform:rotate(360deg)}}
-.load-err{color:#ff8a8a;text-align:center;line-height:1.6}
+#loading {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ z-index: 20;
+ background: var(--bg);
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 13px;
+}
+.spinner {
+ width: 30px;
+ height: 30px;
+ border: 2px solid rgba(255, 255, 255, 0.15);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 14px;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.load-err {
+ color: #ff8a8a;
+ text-align: center;
+ line-height: 1.6;
+}
-@media (max-width:560px){
- .panel{left:12px;right:12px;bottom:12px;width:auto}
- .brand{top:14px;left:16px}
- .ticker{display:none}
- .fps-meter{top:auto;bottom:12px;left:12px;transform:none}
+@media (max-width: 560px) {
+ .panel {
+ left: 12px;
+ right: 12px;
+ bottom: 12px;
+ width: auto;
+ }
+ .brand {
+ top: 14px;
+ left: 16px;
+ }
+ .ticker {
+ display: none;
+ }
+ .fps-meter {
+ top: auto;
+ bottom: 12px;
+ left: 12px;
+ transform: none;
+ }
}
diff --git a/shared/ui.js b/shared/ui.js
index 4a329e3..d16838b 100644
--- a/shared/ui.js
+++ b/shared/ui.js
@@ -5,126 +5,150 @@
* and notify the engine when a change needs a structural rebuild. Both
* renderers reuse this verbatim so their controls behave identically.
*/
-import { SCENE_SCHEMA, formatValue } from "./scene-schema.js";
-import { saveScene, STORAGE } from "./config.js";
+import { SCENE_SCHEMA, formatValue } from './scene-schema.js';
+import { saveScene, STORAGE } from './config.js';
-const STRUCTURAL_KEYS = new Set(["density"]); // changes that require a rebuild
+const STRUCTURAL_KEYS = new Set(['density']); // changes that require a rebuild
// ---- "Scene & effects" gear panel (demo only) --------------------------
// Rendered straight from the schema, so adding a setting there adds a control here.
export function buildScenePanel({ host, toggle, scene, onChange }) {
const fields = SCENE_SCHEMA.sections.flatMap((s) => s.fields);
const byKey = {};
- fields.forEach((f) => { byKey[f.key] = f; });
+ fields.forEach((f) => {
+ byKey[f.key] = f;
+ });
- host.innerHTML = SCENE_SCHEMA.sections.map(sectionHTML).join("");
+ host.innerHTML = SCENE_SCHEMA.sections.map(sectionHTML).join('');
// hydrate controls from current scene
fields.forEach((f) => {
- if (f.type === "range") {
+ if (f.type === 'range') {
host.querySelector(`[data-k="${f.key}"]`).value = scene[f.key];
host.querySelector(`[data-val="${f.key}"]`).textContent = formatValue(f, scene[f.key]);
- } else if (f.type === "toggle") {
- host.querySelector(`[data-tog="${f.key}"]`).setAttribute("aria-pressed", scene[f.key] ? "true" : "false");
- } else if (f.type === "select") {
+ } else if (f.type === 'toggle') {
+ host
+ .querySelector(`[data-tog="${f.key}"]`)
+ .setAttribute('aria-pressed', scene[f.key] ? 'true' : 'false');
+ } else if (f.type === 'select') {
host.querySelectorAll(`[data-seg="${f.key}"]`).forEach((bn) => {
- bn.classList.toggle("on", bn.getAttribute("data-v") === scene[f.key]);
+ bn.classList.toggle('on', bn.getAttribute('data-v') === scene[f.key]);
});
}
});
- const commit = (k) => { saveScene(scene); onChange(k, STRUCTURAL_KEYS.has(k)); };
+ const commit = (k) => {
+ saveScene(scene);
+ onChange(k, STRUCTURAL_KEYS.has(k));
+ };
- host.addEventListener("input", (e) => {
- const k = e.target.getAttribute("data-k");
+ host.addEventListener('input', (e) => {
+ const k = e.target.getAttribute('data-k');
if (!k) return;
scene[k] = +e.target.value;
host.querySelector(`[data-val="${k}"]`).textContent = formatValue(byKey[k], scene[k]);
commit(k);
});
- host.addEventListener("click", (e) => {
- const tog = e.target.closest("[data-tog]");
+ host.addEventListener('click', (e) => {
+ const tog = e.target.closest('[data-tog]');
if (tog) {
- const k = tog.getAttribute("data-tog");
+ const k = tog.getAttribute('data-tog');
scene[k] = !scene[k];
- tog.setAttribute("aria-pressed", scene[k] ? "true" : "false");
+ tog.setAttribute('aria-pressed', scene[k] ? 'true' : 'false');
commit(k);
return;
}
- const seg = e.target.closest("[data-seg]");
+ const seg = e.target.closest('[data-seg]');
if (seg) {
- const k = seg.getAttribute("data-seg"), v = seg.getAttribute("data-v");
+ const k = seg.getAttribute('data-seg'),
+ v = seg.getAttribute('data-v');
scene[k] = v;
- host.querySelectorAll(`[data-seg="${k}"]`).forEach((bn) => bn.classList.toggle("on", bn === seg));
+ host
+ .querySelectorAll(`[data-seg="${k}"]`)
+ .forEach((bn) => bn.classList.toggle('on', bn === seg));
commit(k);
}
});
// open/close + persistence
function setOpen(open) {
- host.toggleAttribute("hidden", !open);
- toggle.setAttribute("aria-pressed", open ? "true" : "false");
- try { localStorage.setItem(STORAGE.sceneOpen, open ? "1" : "0"); } catch (e) { /* ignore */ }
+ host.toggleAttribute('hidden', !open);
+ toggle.setAttribute('aria-pressed', open ? 'true' : 'false');
+ try {
+ localStorage.setItem(STORAGE.sceneOpen, open ? '1' : '0');
+ } catch (e) {
+ /* ignore */
+ }
}
- toggle.addEventListener("click", () => setOpen(host.hasAttribute("hidden")));
+ toggle.addEventListener('click', () => setOpen(host.hasAttribute('hidden')));
let wasOpen = false;
- try { wasOpen = localStorage.getItem(STORAGE.sceneOpen) === "1"; } catch (e) { /* ignore */ }
+ try {
+ wasOpen = localStorage.getItem(STORAGE.sceneOpen) === '1';
+ } catch (e) {
+ /* ignore */
+ }
if (wasOpen) setOpen(true);
}
function sectionHTML(section) {
- return `${section.title}
` + section.fields.map(fieldHTML).join("");
+ return `${section.title}
` + section.fields.map(fieldHTML).join('');
}
function fieldHTML(f) {
- if (f.type === "range") {
- return ``
+ );
}
- if (f.type === "toggle") {
- return `
${f.label}` +
- `
`;
+ if (f.type === 'toggle') {
+ return (
+ `
${f.label}` +
+ `
`
+ );
}
- if (f.type === "select") {
- const b = f.options.map((o) => `
`).join("");
+ if (f.type === 'select') {
+ const b = f.options
+ .map((o) => `
`)
+ .join('');
return `
`;
}
- return "";
+ return '';
}
// ---- activity list (swatch / name / count / toggle) --------------------
export function buildActivityControls({ list, types, state }) {
types.forEach((t) => {
- const row = document.createElement("div");
- row.className = "act-row";
+ const row = document.createElement('div');
+ row.className = 'act-row';
row.innerHTML =
`
` +
+ `
` +
`
${t.label}` +
`
0` +
`
`;
+ `
`;
list.appendChild(row);
});
- list.addEventListener("input", (e) => {
- const id = e.target.getAttribute("data-color");
+ list.addEventListener('input', (e) => {
+ const id = e.target.getAttribute('data-color');
if (!id) return;
state.types[id].color = e.target.value;
- e.target.closest(".swatch").style.setProperty("--c", e.target.value);
+ e.target.closest('.swatch').style.setProperty('--c', e.target.value);
});
- list.addEventListener("click", (e) => {
- const btn = e.target.closest("[data-toggle]");
+ list.addEventListener('click', (e) => {
+ const btn = e.target.closest('[data-toggle]');
if (!btn) return;
- const id = btn.getAttribute("data-toggle");
+ const id = btn.getAttribute('data-toggle');
const on = !state.types[id].enabled;
state.types[id].enabled = on;
- btn.setAttribute("aria-pressed", on ? "true" : "false");
- btn.closest(".act-row").classList.toggle("off", !on);
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
+ btn.closest('.act-row').classList.toggle('off', !on);
});
- const totalEl = document.getElementById("total-count");
+ const totalEl = document.getElementById('total-count');
return {
// refresh the per-type and grand-total counters
bump(id) {
@@ -141,46 +165,49 @@ export function buildActivityControls({ list, types, state }) {
// ---- base controls: rate / rotation / pause / fireworks ----------------
export function buildBaseControls({ sim, types }) {
- const rate = document.getElementById("rate");
- const rateVal = document.getElementById("rate-val");
+ const rate = document.getElementById('rate');
+ const rateVal = document.getElementById('rate-val');
rate.value = sim.rate;
- rateVal.textContent = sim.rate.toFixed(1) + "/s";
- rate.addEventListener("input", () => {
+ rateVal.textContent = sim.rate.toFixed(1) + '/s';
+ rate.addEventListener('input', () => {
sim.rate = +rate.value;
- rateVal.textContent = sim.rate.toFixed(1) + "/s";
+ rateVal.textContent = sim.rate.toFixed(1) + '/s';
});
- const rot = document.getElementById("rot");
- const rotVal = document.getElementById("rot-val");
+ const rot = document.getElementById('rot');
+ const rotVal = document.getElementById('rot-val');
rot.value = sim.rotSpeed;
- rotVal.textContent = sim.rotSpeed === 0 ? "off" : sim.rotSpeed + "°/s";
- rot.addEventListener("input", () => {
+ rotVal.textContent = sim.rotSpeed === 0 ? 'off' : sim.rotSpeed + '°/s';
+ rot.addEventListener('input', () => {
sim.rotSpeed = +rot.value;
- rotVal.textContent = sim.rotSpeed === 0 ? "off" : sim.rotSpeed + "°/s";
+ rotVal.textContent = sim.rotSpeed === 0 ? 'off' : sim.rotSpeed + '°/s';
});
- const pause = document.getElementById("pause");
- pause.addEventListener("click", () => {
+ const pause = document.getElementById('pause');
+ pause.addEventListener('click', () => {
sim.paused = !sim.paused;
- pause.classList.toggle("paused", sim.paused);
- pause.querySelector(".pp-label").textContent = sim.paused ? "Play" : "Pause";
+ pause.classList.toggle('paused', sim.paused);
+ pause.querySelector('.pp-label').textContent = sim.paused ? 'Play' : 'Pause';
});
- const fwSel = document.getElementById("fw-trigger");
+ const fwSel = document.getElementById('fw-trigger');
types.forEach((t) => {
- const o = document.createElement("option");
- o.value = t.id; o.textContent = t.label;
+ const o = document.createElement('option');
+ o.value = t.id;
+ o.textContent = t.label;
if (t.id === sim.fwTrigger) o.selected = true;
fwSel.appendChild(o);
});
fwSel.value = sim.fwTrigger;
- fwSel.addEventListener("change", () => { sim.fwTrigger = fwSel.value; });
+ fwSel.addEventListener('change', () => {
+ sim.fwTrigger = fwSel.value;
+ });
- const fwTog = document.getElementById("fw-toggle");
- fwTog.addEventListener("click", () => {
+ const fwTog = document.getElementById('fw-toggle');
+ fwTog.addEventListener('click', () => {
sim.fireworks = !sim.fireworks;
- fwTog.setAttribute("aria-pressed", sim.fireworks ? "true" : "false");
- fwTog.closest(".fw-row").classList.toggle("off", !sim.fireworks);
+ fwTog.setAttribute('aria-pressed', sim.fireworks ? 'true' : 'false');
+ fwTog.closest('.fw-row').classList.toggle('off', !sim.fireworks);
});
}
@@ -192,8 +219,8 @@ export function createTicker(el, verbs) {
const now = performance.now();
if (now - lastTick < 220) return; // throttle so high rates don't thrash the DOM
lastTick = now;
- const line = document.createElement("div");
- line.className = "tk-line";
+ const line = document.createElement('div');
+ line.className = 'tk-line';
line.innerHTML =
`
` +
`
${verbs[type.id] || type.label}` +
@@ -201,5 +228,15 @@ export function createTicker(el, verbs) {
el.insertBefore(line, el.firstChild);
while (el.children.length > 7) el.removeChild(el.lastChild);
},
+ // surge callout — a highlighted line that bypasses the throttle
+ special(text) {
+ const line = document.createElement('div');
+ line.className = 'tk-line tk-surge';
+ line.innerHTML =
+ `` +
+ `${text}`;
+ el.insertBefore(line, el.firstChild);
+ while (el.children.length > 7) el.removeChild(el.lastChild);
+ },
};
}