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 `
${f.label}` + + if (f.type === 'range') { + return ( + `
${f.label}` + `
` + - `
`; + `
` + ); } - 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 `
${f.label}
${b}
`; } - 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); + }, }; }