From e2510bfc585ba4dfddc158214c12dce49bd3aaf4 Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:22:26 +0100 Subject: [PATCH 1/9] perf: drop trig from hot loops, cache geo rebuilds Precompute sin/cos of lat and lng at build time so per-frame projection of land dots, corona spikes and aurora samples is pure multiplies via the angle-sum identities. Tier-sort land dots with tierEnd ranges so each relief tier draws as one contiguous run. Cache the sun's cos/sin(lon) and rebuild night/core GeoJSON only when the sun moves, not per frame. Legacy geometry fields retained so the SVG build keeps working. --- shared/geo.js | 53 ++++++++++++++++++++++++------ shared/geometry.js | 80 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 103 insertions(+), 30 deletions(-) diff --git a/shared/geo.js b/shared/geo.js index 14a7dac..7f910b0 100644 --- a/shared/geo.js +++ b/shared/geo.js @@ -8,12 +8,19 @@ * 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"; export class Projection { #lastSun = -1e9; + #graticule = null; + #nightShape = null; + #coreShape = null; constructor() { this.d3proj = d3.geoOrthographic().clipAngle(90).precision(0.4); @@ -23,7 +30,7 @@ export class Projection { 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 } @@ -58,29 +65,57 @@ 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..d35df89 100644 --- a/shared/geometry.js +++ b/shared/geometry.js @@ -3,6 +3,15 @@ * 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"; @@ -30,43 +39,63 @@ export function quadPoint(p0, cp, p1, u) { } // ---- 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); + 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, }; } @@ -124,19 +153,28 @@ export function auroraSpecs(scene) { { lat: -bl - 2.5, amp: 5.5, phase: 3.9, col: sch[1], width: 3.5, op0: 0.30, 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; + 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 dlon = lngR - lon0, cd = Math.cos(dlon); + 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); + 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); } From e6492d4c585c4194585877e54c27c70ccb3ee6ff Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:22:27 +0100 Subject: [PATCH 2/9] feat: adaptive quality, cinematic arrival, fling inertia in BaseEngine Track an EMA of raw frame time and nudge a dpr multiplier down (to 0.55 min) on sustained slow frames, back up with cooldown hysteresis; CSS size never changes. Add the intro ramp (0->1 over ~3.2s) with introPhase(a,b) for layers to read eased sub-windows, plus fling inertia so a flick glides and decays back into auto-rotation (works while paused). --- shared/engine.js | 197 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 177 insertions(+), 20 deletions(-) diff --git a/shared/engine.js b/shared/engine.js index f4e8813..995599e 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). @@ -22,13 +41,32 @@ 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,15 +80,27 @@ 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.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; + // 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 = []; } @@ -78,13 +128,25 @@ export class BaseEngine { 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.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(); @@ -96,31 +158,70 @@ export class BaseEngine { 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); }); } #frame(now) { - const dt = Math.min(0.05, (now - this.#last) / 1000); + 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]); + } 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,19 +232,75 @@ 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 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 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); From 14426811502252569ad0a2c6b4c592645fef63bd Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:22:27 +0100 Subject: [PATCH 3/9] feat: calmer pacing and defaults Slower, longer-lived meteors and slower beam draw with longer dissolve. Calmer defaults: rotation 4 deg/s, activity 2.4/s, meteor frequency 40%. --- shared/config.js | 4 ++-- shared/sim.js | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/shared/config.js b/shared/config.js index b7af6e8..74f8829 100644 --- a/shared/config.js +++ b/shared/config.js @@ -16,8 +16,8 @@ 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 + 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 }; diff --git a/shared/sim.js b/shared/sim.js index b4b5574..7a8ee17 100644 --- a/shared/sim.js +++ b/shared/sim.js @@ -7,17 +7,22 @@ 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; } } @@ -35,11 +40,11 @@ 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, + t: 0, ttl: 1.1 + Math.random() * 0.8, }; } // Advance a meteor; returns false once it should be removed. From 78ffdf0534c08aee5255b8e794e65a666ac16975 Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:22:27 +0100 Subject: [PATCH 4/9] feat: scene schema for cinema and new visual toggles Add the cinema schema section (intro, parallax, moon, heartbeat, comet, constellations, sunGlint, surges) and the new visual toggles (sunGlare, parallaxStars, nebula) so platform configs can drive them. --- shared/scene-schema.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/shared/scene-schema.js b/shared/scene-schema.js index daa6282..5d3361b 100644 --- a/shared/scene-schema.js +++ b/shared/scene-schema.js @@ -37,6 +37,7 @@ export const SCENE_SCHEMA = { { 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 }, ], }, { @@ -45,6 +46,7 @@ export const SCENE_SCHEMA = { { 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 }, ], }, { @@ -65,10 +67,23 @@ export const SCENE_SCHEMA = { { key: "orbits", type: "toggle", label: "Orbital rings", 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.5, min: 0, max: 1, step: 0.05, display: "pct" }, + { 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 }, From 25c1dc902e129930c628602e2f649b75bc4038ab Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:22:28 +0100 Subject: [PATCH 5/9] feat: cinematic canvas layers and shared draw helpers New canvas/draw.js (composite ops, glow sprites, blit, path-from-segments) and canvas/cinema.js with six layers: orbiting moon, rare comet, shimmering constellations, ocean sun glint, HQ heartbeat wave, and city surges. --- canvas/cinema.js | 338 +++++++++++++++++++++++++++++++++++++++++++++++ canvas/draw.js | 58 ++++++++ 2 files changed, 396 insertions(+) create mode 100644 canvas/cinema.js create mode 100644 canvas/draw.js diff --git a/canvas/cinema.js b/canvas/cinema.js new file mode 100644 index 0000000..dde951a --- /dev/null +++ b/canvas/cinema.js @@ -0,0 +1,338 @@ +/* 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..007ddde --- /dev/null +++ b/canvas/draw.js @@ -0,0 +1,58 @@ +/* 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; +} From a161121d9211ab8b4a8d8520dc14aa278020d23a Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:22:28 +0100 Subject: [PATCH 6/9] feat: render upgraded visuals and wire cinematic layers (canvas) Deeper offset-lit ocean, wider atmospheric rim with crisp shell line, sun glare under the sphere, low-res offscreen terminator, parallax starfield, nebula haze, 4-layer beams, twin impact rings, 3-pass aurora and warmer city lights. Register the new cinematic layers and read the engine's intro/parallax state in main. --- canvas/layers.js | 442 +++++++++++++++++++++++++++++++++-------------- canvas/main.js | 2 + 2 files changed, 310 insertions(+), 134 deletions(-) diff --git a/canvas/layers.js b/canvas/layers.js index 139ac7b..64f944d 100644 --- a/canvas/layers.js +++ b/canvas/layers.js @@ -10,11 +10,19 @@ * * 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 { 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 { + moonLayer, cometLayer, constellationsLayer, sunGlintLayer, heartbeatLayer, surgeLayer, +} from "./cinema.js"; import { ORBIT_DEFS, computeOrbit, arcControl, quadSplit, quadPoint, buildLandDots, buildSpikes, pickNodes, auroraSpecs, auroraSegments, @@ -24,120 +32,234 @@ import { fireworkBurst, fireworkBarrage, stepFirework, fireworkAlpha, } from "../shared/sim.js"; -const ADD = (ctx) => (ctx.globalCompositeOperation = "lighter"); -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; -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.80, 0.40, [[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.40, 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)"); + 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.40, "rgba(96,200,255,0.10)"); + grad.addColorStop(0.50, "rgba(112,205,255,0.50)"); + grad.addColorStop(0.62, "rgba(132,145,255,0.30)"); + grad.addColorStop(0.80, "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.16, 0, TAU); g.fill(); + 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); 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(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); }, 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"); + 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.20)"); - h.addColorStop(0.45, "rgba(120,180,255,0.04)"); + 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 { @@ -145,7 +267,7 @@ export function sphereLayer() { 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; }, }; @@ -171,8 +293,12 @@ export function orbitsBackLayer() { 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; }, }; } @@ -182,13 +308,16 @@ export function orbitsFrontLayer() { 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(); @@ -211,14 +340,15 @@ export function spikesLayer() { 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]); @@ -227,7 +357,7 @@ export function spikesLayer() { 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.strokeStyle = rgba("#9fc6ff", Math.min(1, scene.coronaIntensity * 1.6) * e.introPhase(0.55, 0.9)); ctx.stroke(); NORMAL(ctx); }, @@ -246,7 +376,7 @@ export function graticuleLayer() { 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.lineWidth = 0.6; ctx.strokeStyle = "rgba(140,160,210,0.30)"; ctx.stroke(); }, }; } @@ -268,34 +398,40 @@ export function landLayer() { draw(e) { const { ctx, CX, CY, R, scene, proj } = e; const { lon0, sinLat0, cosLat0 } = proj; + 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; - // pass A: project every front dot once; tag night-side city dots + 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; + 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 = 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 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; + const end = d.tierEnd[t], 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); + 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,27 +439,40 @@ 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, + 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 { @@ -331,15 +480,15 @@ export function cityLightsLayer() { 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,7 +499,7 @@ 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 { @@ -358,16 +507,18 @@ export function auroraLayer() { 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; + 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); }, @@ -392,7 +543,7 @@ 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); + 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(); } @@ -441,9 +592,11 @@ export function beamsLayer() { 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,14 +606,21 @@ 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); + 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 }); @@ -474,24 +634,32 @@ 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 })); }, 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.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.10, 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(); } @@ -554,7 +722,7 @@ export function meteorsLayer() { return { 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++; } @@ -572,7 +740,7 @@ export function meteorsLayer() { 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(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(); @@ -595,15 +763,18 @@ export function hqLayer() { 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.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); + 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 @@ -614,6 +785,7 @@ export function hqLayer() { 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.globalAlpha = 1; }, }; } @@ -621,9 +793,11 @@ 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..02c0e29 100644 --- a/canvas/main.js +++ b/canvas/main.js @@ -43,6 +43,7 @@ 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)); +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) { @@ -60,6 +61,7 @@ if (demo) { if (structural) engine.rebuildFor(key); engine.applyScene(); if (key === "starDrift" || key === "starTwinkle") applyStarfield(); + if (key === "intro" && scene.intro) engine.replayIntro(); // toggle on → replay the arrival }, }); } From cb76497b77db4124a8c2d280c882d41f4ca67633 Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:22:28 +0100 Subject: [PATCH 7/9] feat: control-panel toggles and styles for new scene options Surface the new cinema and visual toggles in the scene panel with matching styles. --- shared/ui.css | 9 ++++++--- shared/ui.js | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/shared/ui.css b/shared/ui.css index a290739..22b27f5 100644 --- a/shared/ui.css +++ b/shared/ui.css @@ -1,7 +1,7 @@ /* games.directory globe — shared chrome (backend-agnostic). * Renderer-specific styling lives in canvas/canvas.css and svg/scene.css. */ :root{ - --bg:#05060c; + --bg:#04050b; --ink:#eaf0ff; --muted:#8a93b2; --line:rgba(140,160,210,.14); @@ -15,8 +15,9 @@ 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%), + radial-gradient(1200px 800px at 70% -10%, rgba(40,70,130,.20), transparent 60%), + radial-gradient(900px 700px at 15% 110%, rgba(70,40,120,.16), transparent 55%), + radial-gradient(140% 100% at 50% 120%, rgba(8,12,26,.6), transparent 70%), var(--bg); color:var(--ink); font-family:var(--sans); @@ -177,6 +178,8 @@ input[type=range]::-moz-range-thumb{width:15px;height:15px;border-radius:50%;bac .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)} +.tk-line.tk-surge{border-color:rgba(90,209,255,.4);background:rgba(20,34,52,.6); + color:#dff4ff;font-weight:600;letter-spacing:.02em} @keyframes tkin{from{opacity:0;transform:translateY(-9px) scale(.985)}to{opacity:1;transform:none}} /* FPS meter */ diff --git a/shared/ui.js b/shared/ui.js index 4a329e3..19ded77 100644 --- a/shared/ui.js +++ b/shared/ui.js @@ -201,5 +201,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); + }, }; } From 401fdb435e90b8afd6930b1371e1a9f8619cf806 Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:25:00 +0100 Subject: [PATCH 8/9] docs: changelog for canvas perf + cinematic upgrade --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e8f43..a6d9ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 From 8c1f54fb323c76a69443896644c6f3f8dbd9cc81 Mon Sep 17 00:00:00 2001 From: pacMakaveli Date: Fri, 12 Jun 2026 13:28:01 +0100 Subject: [PATCH 9/9] style: apply prettier to upgraded files --- CHANGELOG.md | 2 + canvas/cinema.js | 327 +++++++++++---- canvas/draw.js | 30 +- canvas/layers.js | 729 +++++++++++++++++++++++---------- canvas/main.js | 78 ++-- shared/config.js | 41 +- shared/engine.js | 185 ++++++--- shared/geo.js | 23 +- shared/geometry.js | 194 ++++++--- shared/scene-schema.js | 244 ++++++++--- shared/sim.js | 58 ++- shared/ui.css | 910 ++++++++++++++++++++++++++++++++--------- shared/ui.js | 175 ++++---- 13 files changed, 2182 insertions(+), 814 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d9ccb..8de05f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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. @@ -16,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. diff --git a/canvas/cinema.js b/canvas/cinema.js index dde951a..a555933 100644 --- a/canvas/cinema.js +++ b/canvas/cinema.js @@ -6,9 +6,9 @@ * 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"; +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 @@ -19,44 +19,63 @@ const MOON_PERIOD = 95000; // ms per orbit export function moonLayer() { let sprite = null; function build() { - const S = 128, r = 56; - sprite = document.createElement("canvas"); + const S = 128, + r = 56; + sprite = document.createElement('canvas'); sprite.width = sprite.height = S; - const g = sprite.getContext("2d"); + const g = sprite.getContext('2d'); g.save(); - g.beginPath(); g.arc(S / 2, S / 2, r, 0, TAU); g.clip(); + 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); + 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], + [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(); + 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.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(); + 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, + 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 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 @@ -77,14 +96,26 @@ export function moonLayer() { // 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; + let active = false, + t0 = 0, + dur = 0, + p0, + cp, + p1, + nextAt = 0; return { - name: "comet", z: -2.4, + name: 'comet', + z: -2.4, simulate(e) { - if (!e.scene.comet) { active = false; return; } + 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; + 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)]; @@ -110,15 +141,23 @@ export function cometLayer() { // 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"; + 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(); + 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 = env; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(head[0], head[1], 1.8, 0, TAU); + ctx.fill(); ctx.globalAlpha = 1; NORMAL(ctx); }, @@ -129,14 +168,63 @@ export function cometLayer() { // 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 + { + 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 ANCHORS = [[0.16, 0.2], [0.78, 0.64], [0.62, 0.12]]; const CON_CYCLE = 42000; export function constellationsLayer() { let placed = null; @@ -148,8 +236,11 @@ export function constellationsLayer() { })); } return { - name: "constellations", z: -2.6, - resize(e) { build(e); }, + name: 'constellations', + z: -2.6, + resize(e) { + build(e); + }, visible: (e) => e.scene.constellations, draw(e) { const { ctx, now } = e; @@ -160,15 +251,19 @@ export function constellationsLayer() { 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 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"; + 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]; + 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); @@ -179,8 +274,11 @@ export function constellationsLayer() { 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 = env * tw; + ctx.fillStyle = '#eaf2ff'; + ctx.beginPath(); + ctx.arc(x + ox, y + oy, 1.1, 0, TAU); + ctx.fill(); } ctx.globalAlpha = 1; NORMAL(ctx); @@ -195,17 +293,19 @@ export function sunGlintLayer() { let sprite = null; function build() { const S = 256; - sprite = document.createElement("canvas"); + sprite = document.createElement('canvas'); sprite.width = sprite.height = S; - const g = sprite.getContext("2d"); + 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); + 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, + name: 'sunGlint', + z: 45, visible: (e) => e.scene.sunGlint, draw(e) { const { ctx, CX, CY, R, now, proj } = e; @@ -215,10 +315,12 @@ export function sunGlintLayer() { 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; + 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.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); @@ -235,24 +337,41 @@ export function sunGlintLayer() { // ====================================================================== // 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_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); } +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; + let t0 = -1, + nextAt = 0, + hqTrig = null; return { - name: "heartbeat", z: 58, + 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) }; + 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; + t0 = e.now; + nextAt = e.now + HB_PERIOD; } if (t0 >= 0 && e.now - t0 > HB_DUR) t0 = -1; }, @@ -265,36 +384,61 @@ export function heartbeatLayer() { 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 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; + 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); + 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"; + 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 ct = HB_COS[i], + st = HB_SIN[i]; const qz = hz * cD + (uz * ct + vz * st) * sD; - if (qz <= 0.02) { pen = false; continue; } + 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); + 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); + 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); }, }; @@ -305,7 +449,8 @@ export function heartbeatLayer() { // ====================================================================== export function surgeLayer() { return { - name: "surgeMarker", z: 67, + name: 'surgeMarker', + z: 67, visible: (e) => !!e.surge, draw(e) { const { ctx, now } = e; @@ -319,19 +464,29 @@ export function surgeLayer() { 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.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); + 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.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.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.fillStyle = '#bfe9ff'; + ctx.fillText(s.city.name, x + 10, y - 8); ctx.globalAlpha = 1; }, }; diff --git a/canvas/draw.js b/canvas/draw.js index 007ddde..34e1e68 100644 --- a/canvas/draw.js +++ b/canvas/draw.js @@ -5,23 +5,26 @@ * no simulation. */ -export const ADD = (ctx) => (ctx.globalCompositeOperation = "lighter"); -export const NORMAL = (ctx) => (ctx.globalCompositeOperation = "source-over"); +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"); + const S = 128, + c = document.createElement('canvas'); c.width = c.height = S; - const g = c.getContext("2d"); + 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; + 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) { @@ -31,9 +34,10 @@ export function drawGlow(ctx, x, y, r, alpha) { } // 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"); + 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; @@ -42,7 +46,7 @@ export function makeSprite(e, paint) { export function blit(ctx, sprite, alpha, additive) { ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); - if (additive) ctx.globalCompositeOperation = "lighter"; + if (additive) ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = alpha; ctx.drawImage(sprite, 0, 0); ctx.restore(); diff --git a/canvas/layers.js b/canvas/layers.js index 64f944d..364cbc7 100644 --- a/canvas/layers.js +++ b/canvas/layers.js @@ -17,48 +17,87 @@ * * Depends on the global `d3` (graticule/terminator geo-paths). */ -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 { 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 { - moonLayer, cometLayer, constellationsLayer, sunGlintLayer, heartbeatLayer, surgeLayer, -} from "./cinema.js"; + moonLayer, + cometLayer, + constellationsLayer, + sunGlintLayer, + heartbeatLayer, + surgeLayer, +} from './cinema.js'; import { - ORBIT_DEFS, computeOrbit, arcControl, quadSplit, quadPoint, - buildLandDots, buildSpikes, pickNodes, auroraSpecs, auroraSegments, -} from "../shared/geometry.js"; + 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"; + BEAM, + pickBeam, + beamEnvelope, + spawnMeteorParams, + stepMeteor, + meteorOpacity, + fireworkBurst, + fireworkBarrage, + stepFirework, + fireworkAlpha, +} from '../shared/sim.js'; // ====================================================================== // Nebula — vast, slow-drifting hue washes behind everything (cached) // ====================================================================== export function nebulaLayer() { - let sprite = null, sw = 0, sh = 0; + 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"); + 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"); + const g = sprite.getContext('2d'); g.setTransform(Q, 0, 0, Q, 0, 0); - g.globalCompositeOperation = "lighter"; + 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); + 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.80, 0.40, [[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.40, 0.52, [[0, "rgba(58,176,212,0.05)"], [1, "rgba(0,0,0,0)"]]); + 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); }, + name: 'nebula', + z: -4, + resize(e) { + build(e); + }, visible: (e) => e.scene.nebula, draw(e) { const { ctx, now, W, H } = e; @@ -81,14 +120,24 @@ export function nebulaLayer() { // 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"]; + 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); + 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; + 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; @@ -96,21 +145,29 @@ export function starfieldLayer() { 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, + 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 { - name: "starfield", z: -3, - resize(e) { build(e); }, + 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 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 @@ -119,10 +176,12 @@ export function starfieldLayer() { 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; + 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; + 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]); } } @@ -130,22 +189,32 @@ export function starfieldLayer() { // 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; + 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.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.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); }, @@ -158,46 +227,62 @@ export function starfieldLayer() { 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, glare = 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.78, CX, CY, R * 1.22); - grad.addColorStop(0, "rgba(90,200,255,0)"); - grad.addColorStop(0.40, "rgba(96,200,255,0.10)"); - grad.addColorStop(0.50, "rgba(112,205,255,0.50)"); - grad.addColorStop(0.62, "rgba(132,145,255,0.30)"); - grad.addColorStop(0.80, "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(); + 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(); + 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.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(); + 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 = document.createElement('canvas'); glare.width = glare.height = GS; - const gg = glare.getContext("2d"); + 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); + 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, CX, CY, R, proj } = e; @@ -214,9 +299,15 @@ export function atmosphereLayer() { 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); + 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; @@ -240,31 +331,50 @@ export function sphereLayer() { const { CX, CY, R } = e; sprite = makeSprite(e, (g) => { 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"; + 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(); + 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(); + 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, e.introPhase(0.12, 0.5), false); @@ -277,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) => { @@ -289,14 +400,16 @@ 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; 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)"; + 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; }, @@ -304,7 +417,8 @@ export function orbitsBackLayer() { } export function orbitsFrontLayer() { return { - name: "orbitsFront", z: 80, + name: 'orbitsFront', + z: 80, visible: (e) => e.scene.orbits, draw(e) { const { ctx } = e; @@ -312,15 +426,19 @@ export function orbitsFrontLayer() { if (ia <= 0) return; ADD(ctx); ctx.globalAlpha = ia; - ctx.lineWidth = 0.85; ctx.strokeStyle = "rgba(188,214,255,0.44)"; + 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) * 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); @@ -334,13 +452,17 @@ 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 cosLon0 = Math.cos(lon0), + sinLon0 = Math.sin(lon0); const tt = now * 0.0016; ADD(ctx); ctx.beginPath(); @@ -356,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) * e.introPhase(0.55, 0.9)); + 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); }, @@ -370,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.30)"; ctx.stroke(); + ctx.beginPath(); + path(proj.graticule()); + ctx.lineWidth = 0.6; + ctx.strokeStyle = 'rgba(140,160,210,0.30)'; + ctx.stroke(); }, }; } @@ -385,49 +515,83 @@ 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 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; + 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 cd = cLng[i] * cosLon0 + sLng[i] * sinLon0; // cos(lng − lon0) + 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) + 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 && 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++; } + 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: 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 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 end = d.tierEnd[t], sz = sizes[t], half = sz / 2; - ctx.fillStyle = rgba("#bfe0ff", Math.min(1, alphas[t])); + 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); } @@ -443,18 +607,23 @@ export function landLayer() { // upscale gives a naturally soft twilight gradient (and costs ~1/20th the fill) // ====================================================================== export function nightLayer() { - let off = null, g = null, 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 = 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"); + g = off.getContext('2d'); path = d3.geoPath(e.proj.d3proj, g); } return { - name: "night", z: 55, - resize(e) { setup(e); }, + name: 'night', + z: 55, + resize(e) { + setup(e); + }, visible: (e) => e.scene.dayNight && e.scene.darkness > 0, draw(e) { const { ctx, proj, scene, W, H } = e; @@ -463,9 +632,13 @@ export function nightLayer() { 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.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(); + g.beginPath(); + path(proj.coreShape()); + g.fill(); ctx.drawImage(off, 0, 0, W, H); }, }; @@ -476,11 +649,15 @@ export function nightLayer() { // ====================================================================== 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 * e.introPhase(0.5, 0.85), sz = 2.9, 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.0014)) * cb), Math.min(1.4, (0.55 + 0.45 * Math.sin(now * 0.0014 + 2.1)) * cb), @@ -488,7 +665,7 @@ export function cityLightsLayer() { ]; ADD(ctx); for (let g = 0; g < 3; g++) { - ctx.fillStyle = rgba("#ffd28a", 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); } @@ -503,22 +680,31 @@ export function cityLightsLayer() { // ====================================================================== 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 * e.introPhase(0.5, 0.85); + const t = now * 0.0011, + k = scene.auroraIntensity * e.introPhase(0.5, 0.85); if (k <= 0) return; - ctx.lineCap = "round"; ctx.lineJoin = "round"; + 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); // 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); + 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); }, @@ -531,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; @@ -544,8 +733,11 @@ export function nodesLayer() { 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.5); - ctx.globalAlpha = tw; ctx.fillStyle = "#eaf4ff"; - ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, TAU); ctx.fill(); + ctx.globalAlpha = tw; + ctx.fillStyle = '#eaf4ff'; + ctx.beginPath(); + ctx.arc(p[0], p[1], r, 0, TAU); + ctx.fill(); } ctx.globalAlpha = 1; NORMAL(ctx); @@ -559,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; @@ -585,18 +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); // 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); + 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) { @@ -606,25 +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 = 3.8; ctx.stroke(trail); + ctx.strokeStyle = tint(b.color); + ctx.lineWidth = 3.8; + ctx.stroke(trail); } if (t < 1) { 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(); + 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.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.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); @@ -640,28 +869,43 @@ 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) / TTL >= 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) / TTL, 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.17, 0, TAU); ctx.stroke(); - const a2 = clamp(a * 1.4 - 0.4, 0, 1), ee2 = 1 - Math.pow(1 - a2, 2); + 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.10, 0, TAU); ctx.stroke(); + 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 - 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); @@ -680,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); @@ -693,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); } }, @@ -704,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); @@ -718,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 && 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.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(); + 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); @@ -759,7 +1026,8 @@ 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; @@ -769,22 +1037,35 @@ export function hqLayer() { for (const off of [0, 1300]) { const p = ((now + off) % 2600) / 2600; 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.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 * ia); NORMAL(ctx); + 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(); + 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; }, }; @@ -793,11 +1074,29 @@ export function hqLayer() { // ---------------------------------------------------------------------- export function registerDefaultLayers(engine) { [ - nebulaLayer(), starfieldLayer(), constellationsLayer(), cometLayer(), - meteorsLayer(), moonLayer(), atmosphereLayer(), - orbitsBackLayer(), sphereLayer(), spikesLayer(), graticuleLayer(), - sunGlintLayer(), landLayer(), nightLayer(), cityLightsLayer(), - heartbeatLayer(), auroraLayer(), nodesLayer(), surgeLayer(), - 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 02c0e29..6df6f3b 100644 --- a/canvas/main.js +++ b/canvas/main.js @@ -10,82 +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)); -engine.on("surge", ({ city }) => ticker.special(`${city.name} is lighting up right now`)); +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 === "intro" && scene.intro) engine.replayIntro(); // toggle on → replay the arrival + 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 74f8829..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: 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 + 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 995599e..099df5f 100644 --- a/shared/engine.js +++ b/shared/engine.js @@ -37,22 +37,22 @@ * 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 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 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 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 +const SURGE_LEN_MS = 6500; // how long a surge lasts export class BaseEngine { #handlers = {}; @@ -83,12 +83,20 @@ export class BaseEngine { 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; @@ -105,8 +113,13 @@ export class BaseEngine { } // ---- 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) { @@ -115,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); @@ -125,8 +144,12 @@ 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 @@ -138,13 +161,17 @@ export class BaseEngine { if (p >= 1) return 1; return p * p * (3 - 2 * p); // smoothstep } - replayIntro() { this.#introT0 = performance.now(); } + 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.quality; this.resizeBackend(); @@ -154,30 +181,37 @@ 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()); - document.addEventListener("visibilitychange", () => { this.#last = performance.now(); }); + 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 raw = now - this.#last; const dt = Math.min(0.05, raw / 1000); - this.#last = now; this.now = now; this.dt = dt; this.frameCount++; + 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); + this.intro = this.scene.intro === false ? 1 : Math.min(1, (now - this.#introT0) / INTRO_MS); const sim = this.sim; if (!this.#drag.active) { @@ -194,7 +228,8 @@ export class BaseEngine { } // pointer parallax eases toward its target (or back to rest) - const lk = this.look, ease = Math.min(1, dt * 2.5); + 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; @@ -207,7 +242,11 @@ export class BaseEngine { 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++; } + 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) @@ -219,8 +258,10 @@ export class BaseEngine { // 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; + 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); @@ -243,16 +284,17 @@ export class BaseEngine { 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 }); + this.emit('surge', { city: c }); break; } } - this.#nextSurge = now + SURGE_EVERY_MS[0] + Math.random() * (SURGE_EVERY_MS[1] - SURGE_EVERY_MS[0]); + 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 + 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) { @@ -261,58 +303,79 @@ export class BaseEngine { 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.#cooldown = COOLDOWN_FRAMES * 2; // step up more cautiously this.resize(); } } // ---- pointer parallax ------------------------------------------------ #bindPointer() { - window.addEventListener("mousemove", (e) => { + 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; }); + 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; 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(); + 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; + 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)); - 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 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"); + 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)); }; - 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 7f910b0..732cf0e 100644 --- a/shared/geo.js +++ b/shared/geo.js @@ -14,7 +14,7 @@ * * Depends on the global `d3` (geo module). */ -import { DEG } from "./util.js"; +import { DEG } from './util.js'; export class Projection { #lastSun = -1e9; @@ -24,11 +24,15 @@ export class Projection { 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, sinLon: 0, cosLon: 1 }; @@ -36,7 +40,9 @@ export class Projection { } 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]); } @@ -86,7 +92,8 @@ export class Projection { // 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); + 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, @@ -115,7 +122,7 @@ export class Projection { this.sun.cosLat = Math.cos(declDeg * DEG); this.antisolar = [lonDeg + 180, -declDeg]; - this.#nightShape = null; // rebuild lazily with the new centre + this.#nightShape = null; // rebuild lazily with the new centre this.#coreShape = null; } } diff --git a/shared/geometry.js b/shared/geometry.js index d35df89..c93e2f0 100644 --- a/shared/geometry.js +++ b/shared/geometry.js @@ -13,13 +13,16 @@ * 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]; @@ -27,15 +30,19 @@ 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 ------------------------------------------------------- @@ -48,14 +55,18 @@ export function buildLandDots(feature, 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, lngR = l * DEG; + const latR = lat * DEG, + lngR = l * DEG; const r = Math.random(); recs.push({ - sin: Math.sin(latR), cos: Math.cos(latR), lng: lngR, - sinLng: Math.sin(lngR), cosLng: Math.cos(lngR), + 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), + tier: r < 0.46 ? 0 : r < 0.83 ? 1 : 2, pt: [l, lat], }); } @@ -63,16 +74,28 @@ export function buildLandDots(feature, step) { 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), + 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.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; @@ -82,20 +105,36 @@ export function buildLandDots(feature, step) { // ---- corona spikes ----------------------------------------------------- export function buildSpikes(step = 7) { - const sinA = [], cosA = [], lngA = [], sinLngA = [], cosLngA = [], 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, 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); + 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: 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, + 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, }; } @@ -106,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; @@ -115,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 }; @@ -147,37 +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); +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); + 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 cosLon0 = Math.cos(lon0), + sinLon0 = Math.sin(lon0); const t = now * 0.0006 * speed; - const segs = []; let seg = null; + 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 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 = AUR_SIN[i] * cosLon0 - AUR_COS[i] * sinLon0; // sin(lng − lon0) + 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 5d3361b..54e6223 100644 --- a/shared/scene-schema.js +++ b/shared/scene-schema.js @@ -25,69 +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" }, - { key: "sunGlare", type: "toggle", label: "Sun glare on limb", default: true }, + 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" }, - { key: "sunGlint", type: "toggle", label: "Ocean sun glint", 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', + }, + { 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: "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: '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 }, + 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 }, ], }, ], @@ -109,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 } } @@ -130,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 7a8ee17..1580207 100644 --- a/shared/sim.js +++ b/shared/sim.js @@ -4,7 +4,7 @@ * 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 ------------------------------------------------------------- // Calm-and-majestic pacing: a slow, graceful draw with a long dissolve. @@ -19,12 +19,20 @@ export function pickBeam(e) { if (!enabled.length) return null; const type = weightedPick(enabled, (t) => e.state.types[t.id].weight || 1); let city = null; - if (e.surge && e.surge.until > e.now && Math.random() < 0.7 && e.proj.visible(e.surge.city.lnglat)) { + 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; } @@ -42,16 +50,23 @@ export function spawnMeteorParams(W, H) { const ang = (Math.random() * 0.5 + 0.62) * Math.PI; // ~112°–203°: down & left 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: 1.1 + Math.random() * 0.8, + 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) { @@ -64,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 }; @@ -82,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 22b27f5..fa0f1ad 100644 --- a/shared/ui.css +++ b/shared/ui.css @@ -1,213 +1,755 @@ /* games.directory globe — shared chrome (backend-agnostic). * Renderer-specific styling lives in canvas/canvas.css and svg/scene.css. */ -:root{ - --bg:#04050b; - --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,.20), transparent 60%), - radial-gradient(900px 700px at 15% 110%, rgba(70,40,120,.16), transparent 55%), - radial-gradient(140% 100% at 50% 120%, rgba(8,12,26,.6), transparent 70%), - 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)} -.tk-line.tk-surge{border-color:rgba(90,209,255,.4);background:rgba(20,34,52,.6); - color:#dff4ff;font-weight:600;letter-spacing:.02em} -@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 19ded77..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}` + @@ -203,8 +230,8 @@ export function createTicker(el, verbs) { }, // surge callout — a highlighted line that bypasses the throttle special(text) { - const line = document.createElement("div"); - line.className = "tk-line tk-surge"; + const line = document.createElement('div'); + line.className = 'tk-line tk-surge'; line.innerHTML = `` + `${text}`;