From 66213e0219f3593bb643b0a3be1387221da87338 Mon Sep 17 00:00:00 2001 From: aangell98 Date: Mon, 1 Jun 2026 16:16:09 +0200 Subject: [PATCH 1/2] feat(universe): data-driven Big Bang + Black Hole animations with cinematic dolly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the universe entry/exit animations to drive the REAL R3F scene nodes (orgs/repos/users InstancedMesh + user point cloud shader) from the worker-precomputed positions, replacing the previous Canvas2D overlays that drew fake particles on top of the scene. Big Bang entry - Real nodes spawn from the singularity (0,0,0) and travel along curved (tangential swirl) trajectories to their final worker-computed positions, with breathing fluctuations once settled - Custom shader on QuantumParticles (~27K users) handles entry on GPU - Cinematic camera dolly pulls the camera back at the start so the whole expansion is framed (snap on rising edge + ease-back) Black Hole exit - New ExitDirector + `applyExitCollapse` helper: every real node smoothly collapses toward the singularity with t^2 easing (CPU for instanced meshes, GPU for the user point cloud) - Clip-path now follows an analytic curve (no keyframes) with damped oscillations that simulate the gravitational `resistance` of the black hole as it folds onto itself - Photon ring & accretion disk now track the actual clip radius so they wrap the collapsing horizon without `jumping` the bounces - Singularity remnant rewritten: build-up, ~520 ms stable peak, epic blink (intensity x2.8, core x2.2, expanded spikes, radial shockwave) before the final fade-out — the brightness now survives the closing of the horizon as a proper post-collapse singularity - Hawking radiation, GravitationalWaves, DecoherenceWaves, TunnelingPulses, CosmicRays, QuantumFoam, InterferenceGrid, ElectronOrbits (Dyson Shell), QuantumBonds, OrgEntanglementArcs, EntanglementChannels, ProbabilityClouds and BlochAxes all receive an exitProgressRef and fade in sync with the collapse instead of escaping the black hole - Dyson Shell additionally compresses with the rest of the universe - Cinematic camera dolly pulls the camera back during the collapse so the implosion is framed Overlay components simplified - BigBangEntry.jsx now KEEPS only the cinematic polish (flash, anamorphic flare, vignette, settle glow) and DROPS all fake-particle stand-ins (~289 lines removed) - BlackHoleExit.jsx similarly DROPS the fake debris/fractures/Hawking canvas particles, replaces the keyframe clipR with an analytic `computeClipFactor` function, and rewrites the remnant with a true blink + size-shrink finale (~520 lines reworked) Net diff: -183 lines, simpler code, more cinematic result. Tests: 179/179 passing. Production build OK. No new lint warnings. --- src/components/Universe/BigBangEntry.jsx | 289 +----------- src/components/Universe/BlackHoleExit.jsx | 522 +++++++++------------- src/components/Universe/UniverseView.jsx | 344 +++++++++++--- 3 files changed, 486 insertions(+), 669 deletions(-) diff --git a/src/components/Universe/BigBangEntry.jsx b/src/components/Universe/BigBangEntry.jsx index 207e930..f1a0d09 100644 --- a/src/components/Universe/BigBangEntry.jsx +++ b/src/components/Universe/BigBangEntry.jsx @@ -7,11 +7,11 @@ * anticipation → ignition → primary shockwave → cosmic web → settle. * * Phases (total 4000ms): - * 1. SINGULARITY (0 – 400 ms) energy lines converge into a pulsing point - * 2. IGNITION (400 – 800 ms) white flash + chromatic aberration + anamorphic flare - * 3. SHOCKWAVE (800 – 1900 ms) plasma streaks + 5 concentric rings + 240 particles burst - * 4. COSMIC WEB (1900 – 3200 ms) filaments draw outward, stars turn on across the sky - * 5. SETTLE (3200 – 4000 ms) afterglow fades, residual sparks twinkle + * 1. SINGULARITY (0 – 400 ms) pulsing seed point + * 2. IGNITION (400 – 800 ms) white flash + anamorphic flare + * 3. SHOCKWAVE (800 – 1900 ms) concentric rings echo the node expansion + * 4. COSMIC WEB (1900 – 3200 ms) real 3D nodes provide matter motion + * 5. SETTLE (3200 – 4000 ms) afterglow fades * * Design principles: * - Transparent canvas — never obscures the 3D content underneath @@ -32,7 +32,6 @@ const COLORS = { gold: [255, 200, 100], blue: [80, 160, 255], } -const PARTICLE_PALETTE = [COLORS.cyan, COLORS.purple, COLORS.green, COLORS.gold, COLORS.white, COLORS.blue] /* ─── Math helpers ─── */ function clamp(v, mn, mx) { return Math.max(mn, Math.min(mx, v)) } @@ -40,7 +39,6 @@ function lerp(a, b, t) { return a + (b - a) * t } function easeOutCubic(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 3) } function easeOutQuart(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 4) } function easeInCubic(t) { return clamp(t, 0, 1) ** 3 } -function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2 } /* Map progress (0..1) → phase-local progress (0..1) for a [start,end] window */ function phaseT(p, start, end) { @@ -49,236 +47,6 @@ function phaseT(p, start, end) { return (p - start) / (end - start) } -/* ══════════════════════════════════════════════════════════════════ - * PHASE 1: CONVERGENT ENERGY STREAKS - * Líneas brillantes desde los bordes del viewport convergiendo al centro. - * Comunican "algo se está formando". 14-18 streaks, ángulos pseudoaleatorios. - * ══════════════════════════════════════════════════════════════════ */ -class ConvergentStreak { - constructor(cx, cy, w, h) { - this.cx = cx; this.cy = cy - this.angle = Math.random() * Math.PI * 2 - const startDist = Math.hypot(w, h) * (0.45 + Math.random() * 0.20) - this.x0 = cx + Math.cos(this.angle) * startDist - this.y0 = cy + Math.sin(this.angle) * startDist - this.length = 80 + Math.random() * 180 - this.color = PARTICLE_PALETTE[Math.floor(Math.random() * 4)] // exclude white/blue → too dim - this.bornAt = Math.random() * 0.4 // local to phase, staggered - this.lateral = (Math.random() - 0.5) * 0.15 // slight bend - } - draw(ctx, phaseProg) { - if (phaseProg < this.bornAt) return - const t = clamp((phaseProg - this.bornAt) / (1 - this.bornAt), 0, 1) - const e = easeInCubic(t) // accelerate into the singularity - const headX = lerp(this.x0, this.cx, e) - const headY = lerp(this.y0, this.cy, e) - const tailX = headX - Math.cos(this.angle) * this.length * (1 - e * 0.7) - const tailY = headY - Math.sin(this.angle) * this.length * (1 - e * 0.7) - const alpha = (1 - easeInCubic(t)) * 0.85 - if (alpha < 0.01) return - const grad = ctx.createLinearGradient(tailX, tailY, headX, headY) - grad.addColorStop(0, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},0)`) - grad.addColorStop(1, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${alpha})`) - ctx.strokeStyle = grad - ctx.lineWidth = 1.5 + e * 1.5 - ctx.shadowColor = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${alpha * 0.6})` - ctx.shadowBlur = 10 - ctx.beginPath() - ctx.moveTo(tailX, tailY); ctx.lineTo(headX, headY) - ctx.stroke() - ctx.shadowBlur = 0 - } -} - -/* ══════════════════════════════════════════════════════════════════ - * PHASE 3: BURST PARTICLES (con chromatic aberration RGB) - * Cada partícula tiene 3 sub-puntos R/G/B desplazados → efecto film cromático - * ══════════════════════════════════════════════════════════════════ */ -class BurstParticle { - constructor(cx, cy) { - this.x = cx; this.y = cy - const angle = Math.random() * Math.PI * 2 - const speed = 140 + Math.random() * 420 - this.vx = Math.cos(angle) * speed - this.vy = Math.sin(angle) * speed - this.color = PARTICLE_PALETTE[Math.floor(Math.random() * PARTICLE_PALETTE.length)] - this.size = 0.9 + Math.random() * 2.4 - this.alpha = 0.55 + Math.random() * 0.40 - this.trail = [] - this.trailMax = 5 + Math.floor(Math.random() * 6) - this.drag = 0.955 + Math.random() * 0.030 - this.bornAt = Math.random() * 0.18 - this.spin = (Math.random() - 0.5) * 0.4 - } - update(localT, dt) { - if (localT < this.bornAt) return - const speed = Math.hypot(this.vx, this.vy) - if (speed > 1) { // tangential spin → spiral effect - const nx = -this.vy / speed - const ny = this.vx / speed - this.vx += nx * this.spin * speed * dt - this.vy += ny * this.spin * speed * dt - } - this.vx *= this.drag - this.vy *= this.drag - this.x += this.vx * dt - this.y += this.vy * dt - this.trail.unshift({ x: this.x, y: this.y }) - if (this.trail.length > this.trailMax) this.trail.pop() - } - draw(ctx, localT, chromaOffset) { - if (localT < this.bornAt) return - const life = clamp((localT - this.bornAt) / (0.85 - this.bornAt), 0, 1) - const a = this.alpha * (1 - easeInCubic(life)) - if (a < 0.01) return - // Trail - for (let i = 1; i < this.trail.length; i++) { - const f = 1 - i / this.trail.length - ctx.beginPath() - ctx.arc(this.trail[i].x, this.trail[i].y, this.size * f * 0.65, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * f * 0.35})` - ctx.fill() - } - // Chromatic-aberration triplet (R/G/B offset around true position) - if (chromaOffset > 0.5) { - ctx.beginPath(); ctx.arc(this.x - chromaOffset, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(255,80,80,${a * 0.5})`; ctx.fill() - ctx.beginPath(); ctx.arc(this.x + chromaOffset, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(80,80,255,${a * 0.5})`; ctx.fill() - } - // Main particle (G + actual color) - ctx.beginPath() - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.fill() - } -} - -/* ══════════════════════════════════════════════════════════════════ - * PHASE 3: PLASMA STREAK - * Rayos cósmicos largos cruzando la pantalla por el centro (estilo - * "cosmic ray" o trazas de partículas relativistas). - * ══════════════════════════════════════════════════════════════════ */ -class PlasmaStreak { - constructor(cx, cy, w, h) { - this.cx = cx; this.cy = cy - this.angle = Math.random() * Math.PI * 2 - const reach = Math.hypot(w, h) * (0.45 + Math.random() * 0.25) - this.x1 = cx + Math.cos(this.angle) * reach - this.y1 = cy + Math.sin(this.angle) * reach - this.x2 = cx - Math.cos(this.angle) * reach * 0.20 // streak extends past center slightly - this.y2 = cy - Math.sin(this.angle) * reach * 0.20 - this.color = PARTICLE_PALETTE[Math.floor(Math.random() * PARTICLE_PALETTE.length)] - this.bornAt = Math.random() * 0.25 - this.width = 1.2 + Math.random() * 1.6 - } - draw(ctx, localT) { - if (localT < this.bornAt) return - const t = clamp((localT - this.bornAt) / (1 - this.bornAt), 0, 1) - // Streak extends outward fast, then fades - const headT = easeOutQuart(t) - const hx = lerp(this.cx, this.x1, headT) - const hy = lerp(this.cy, this.y1, headT) - const tx = lerp(this.cx, this.x2, headT) - const ty = lerp(this.cy, this.y2, headT) - const a = (1 - easeInCubic(t)) * 0.75 - if (a < 0.01) return - const grad = ctx.createLinearGradient(tx, ty, hx, hy) - grad.addColorStop(0, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},0)`) - grad.addColorStop(0.5, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})`) - grad.addColorStop(0.85, `rgba(255,255,255,${a * 0.9})`) - grad.addColorStop(1, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},0)`) - ctx.strokeStyle = grad - ctx.lineWidth = this.width + (1 - t) * 1.2 - ctx.shadowColor = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * 0.6})` - ctx.shadowBlur = 14 - ctx.beginPath() - ctx.moveTo(tx, ty); ctx.lineTo(hx, hy) - ctx.stroke() - ctx.shadowBlur = 0 - } -} - -/* ══════════════════════════════════════════════════════════════════ - * PHASE 4: FILAMENT (cosmic web) - * Línea curva creciendo radialmente desde el centro, con segmentos. - * Representa la estructura a gran escala del universo recién formado. - * ══════════════════════════════════════════════════════════════════ */ -class Filament { - constructor(cx, cy, w, h) { - this.cx = cx; this.cy = cy - this.angle = Math.random() * Math.PI * 2 - this.maxLen = 110 + Math.random() * Math.max(w, h) * 0.45 - this.color = PARTICLE_PALETTE[Math.floor(Math.random() * PARTICLE_PALETTE.length)] - this.curvature = (Math.random() - 0.5) * 0.6 - this.bornAt = Math.random() * 0.35 - this.width = 0.7 + Math.random() * 1.4 - } - draw(ctx, localT) { - if (localT < this.bornAt) return - const t = clamp((localT - this.bornAt) / (1 - this.bornAt), 0, 1) - const len = this.maxLen * easeOutCubic(Math.min(t * 2, 1)) - if (len < 4) return - const a = (1 - easeInCubic(Math.max(0, t * 1.3 - 0.3))) * 0.55 - if (a < 0.01) return - // Curved filament via quadratic bezier - const ex = this.cx + Math.cos(this.angle) * len - const ey = this.cy + Math.sin(this.angle) * len - const perpX = -Math.sin(this.angle) - const perpY = Math.cos(this.angle) - const ctrlX = this.cx + Math.cos(this.angle) * len * 0.5 + perpX * len * this.curvature - const ctrlY = this.cy + Math.sin(this.angle) * len * 0.5 + perpY * len * this.curvature - ctx.strokeStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.lineWidth = this.width - ctx.shadowColor = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * 0.5})` - ctx.shadowBlur = 8 - ctx.beginPath() - ctx.moveTo(this.cx, this.cy) - ctx.quadraticCurveTo(ctrlX, ctrlY, ex, ey) - ctx.stroke() - ctx.shadowBlur = 0 - } -} - -/* ══════════════════════════════════════════════════════════════════ - * PHASE 4-5: STAR (turning on across the sky) - * Puntos blancos que aparecen progresivamente en posiciones aleatorias - * tras la formación de la estructura cósmica. - * ══════════════════════════════════════════════════════════════════ */ -class Star { - constructor(w, h, cx, cy) { - // Position with mild bias away from center (avoid covering 3D content) - const angle = Math.random() * Math.PI * 2 - const dist = (0.18 + Math.random() * 0.45) * Math.min(w, h) - this.x = cx + Math.cos(angle) * dist - this.y = cy + Math.sin(angle) * dist - this.size = 0.4 + Math.random() * 1.2 - this.bornAt = Math.random() * 0.7 // staggered turn-on across the full phase - this.maxAlpha = 0.45 + Math.random() * 0.45 - // Twinkle - this.twinkleFreq = 1.5 + Math.random() * 2.5 - this.twinklePhase = Math.random() * Math.PI * 2 - } - draw(ctx, localT, absoluteTime) { - if (localT < this.bornAt) return - const turnOn = clamp((localT - this.bornAt) / 0.15, 0, 1) - const twinkle = 0.7 + 0.3 * Math.sin(absoluteTime * this.twinkleFreq + this.twinklePhase) - const a = this.maxAlpha * easeOutCubic(turnOn) * twinkle - if (a < 0.02) return - ctx.beginPath() - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(255,255,255,${a})` - ctx.fill() - // Soft halo for the brighter ones - if (this.size > 0.9) { - ctx.beginPath() - ctx.arc(this.x, this.y, this.size * 2.5, 0, Math.PI * 2) - ctx.fillStyle = `rgba(180,220,255,${a * 0.15})` - ctx.fill() - } - } -} - /* ══════════════════════════════════════════════════════════════════ * MAIN COMPONENT * ══════════════════════════════════════════════════════════════════ */ @@ -309,13 +77,6 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { const cy = h / 2 const wrapperEl = wrapperRef?.current - /* ─── Spawn all actors ─── */ - const streaks = Array.from({ length: 18 }, () => new ConvergentStreak(cx, cy, w, h)) - const bursts = Array.from({ length: 240 }, () => new BurstParticle(cx, cy)) - const plasmas = Array.from({ length: 14 }, () => new PlasmaStreak(cx, cy, w, h)) - const filaments = Array.from({ length: 28 }, () => new Filament(cx, cy, w, h)) - const stars = Array.from({ length: 110 }, () => new Star(w, h, cx, cy)) - /* ─── Shockwave configuration (5 concentric rings, staggered) ─── */ const maxDim = Math.max(w, h) const shockwaves = [ @@ -327,7 +88,6 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { ] const startTime = performance.now() - let lastTime = startTime /* ─── Cleanup of CSS filters on wrapper ─── */ const resetWrapper = () => { @@ -340,8 +100,6 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { const frame = (now) => { const elapsed = now - startTime const progress = clamp(elapsed / DURATION, 0, 1) - const dt = Math.min(0.033, (now - lastTime) / 1000) - lastTime = now const absT = (now - startTime) / 1000 ctx.clearRect(0, 0, w, h) @@ -390,12 +148,10 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { /* ════════════════════════════════════════════════════════ * PHASE 1: SINGULARITY (0 → 0.10 of total) - * Energy lines converge into a pulsing white-cyan point + * Pulsing white-cyan seed point * ════════════════════════════════════════════════════════ */ const p1 = phaseT(progress, 0.0, 0.10) if (p1 > 0 && p1 < 1) { - // Convergent streaks rushing toward center - for (const s of streaks) s.draw(ctx, p1) // Pulsing seed dot at the centre (anticipation) const pulse = 0.55 + 0.45 * Math.sin(absT * 18) const seedR = 2 + p1 * 6 @@ -410,7 +166,7 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { /* ════════════════════════════════════════════════════════ * PHASE 2: IGNITION (0.08 → 0.22 of total) - * The flash: chromatic aberration + white overlay + anamorphic flare + * The flash: white overlay + anamorphic flare * ════════════════════════════════════════════════════════ */ const p2 = phaseT(progress, 0.08, 0.22) if (p2 > 0 && p2 < 1) { @@ -455,13 +211,10 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { /* ════════════════════════════════════════════════════════ * PHASE 3: SHOCKWAVE (0.20 → 0.475 of total) - * Plasma streaks + 5 concentric rings + 240 particles bursting + * Five concentric rings echo the real node expansion underneath * ════════════════════════════════════════════════════════ */ const p3 = phaseT(progress, 0.20, 0.475) if (p3 > 0 && p3 < 1) { - // Plasma streaks (cosmic rays) - for (const ps of plasmas) ps.draw(ctx, p3) - // Concentric shockwave rings (each born at its `born` time) for (const sw of shockwaves) { if (p3 < sw.born) continue @@ -479,28 +232,11 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { ctx.stroke() ctx.shadowBlur = 0 } - - // Burst particles (chromatic aberration scales with energy) - const chroma = lerp(4, 0, easeOutQuart(p3)) - for (const bp of bursts) { - bp.update(p3, dt) - bp.draw(ctx, p3, chroma) - } - } - - /* ════════════════════════════════════════════════════════ - * PHASE 4: COSMIC WEB (0.475 → 0.80 of total) - * Filaments expand outward, stars start turning on - * ════════════════════════════════════════════════════════ */ - const p4 = phaseT(progress, 0.475, 0.80) - if (p4 > 0 && p4 < 1) { - for (const f of filaments) f.draw(ctx, p4) - for (const st of stars) st.draw(ctx, p4, absT) } /* ════════════════════════════════════════════════════════ * PHASE 5: SETTLE (0.80 → 1.00 of total) - * Stars persist with twinkle, residual central afterglow fades + * Residual central afterglow fades * ════════════════════════════════════════════════════════ */ const p5 = phaseT(progress, 0.80, 1.0) if (p5 > 0) { @@ -514,13 +250,6 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { ctx.fillStyle = ag ctx.fillRect(0, 0, w, h) } - // Continue twinkling stars but fading out gently - const starFade = 1 - easeInCubic(p5) - if (starFade > 0.05) { - ctx.globalAlpha = starFade - for (const st of stars) st.draw(ctx, 1, absT) - ctx.globalAlpha = 1 - } } if (progress < 1) { diff --git a/src/components/Universe/BlackHoleExit.jsx b/src/components/Universe/BlackHoleExit.jsx index c6ef0fb..e93d4a8 100644 --- a/src/components/Universe/BlackHoleExit.jsx +++ b/src/components/Universe/BlackHoleExit.jsx @@ -3,14 +3,14 @@ * ===================================================== * * 5-phase Marvel-tier exit animation that consumes the 3D universe scene - * into a singularity. Drives both a transparent Canvas2D overlay (particles, - * photon ring, accretion disk, gravitational lensing, hawking radiation) + * into a singularity. Drives both a transparent Canvas2D overlay (photon ring, + * accretion disk, gravitational lensing, remnant glow) * AND DOM effects on the universe wrapper (clip-path circle, scale, blur, * tremor) for an integrated feel. * * Phases (total 4500ms): * 1. PREMONITION (0 – 500 ms) desaturate, faint glitch, mild shake - * 2. GATHERING (500 – 1700 ms) debris falls inward on logarithmic spirals + * 2. GATHERING (500 – 1700 ms) wrapper begins bending inward * 3. EVENT HORIZON (1700 – 2900 ms) photon ring + accretion disk + clip-path begins * 4. SPAGHETTIFICATION (2900 – 3900 ms) content stretches, lensing intensifies * 5. TOTAL COLLAPSE (3900 – 4500 ms) clip-path rushes to zero, final flash, void @@ -26,20 +26,9 @@ */ import { useEffect, useRef, useCallback } from 'react' -const DURATION = 6500 +const DURATION = 5500 const DPR = Math.min(window.devicePixelRatio || 1, 2) -const COLORS = { - white: [255, 255, 255], - cyan: [0, 212, 228], - purple: [157, 111, 219], - green: [0, 255, 159], - gold: [255, 200, 100], - orange: [255, 140, 60], - blue: [80, 160, 255], -} -const DEBRIS_PALETTE = [COLORS.cyan, COLORS.purple, COLORS.green, COLORS.gold, COLORS.white, COLORS.blue] - /* ─── Math helpers ─── */ function clamp(v, mn, mx) { return Math.max(mn, Math.min(mx, v)) } function lerp(a, b, t) { return a + (b - a) * t } @@ -49,6 +38,37 @@ function easeOutCubic(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 3) } function easeOutQuart(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 4) } function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2 } +/* ─── Clip-path curve — function ANALYTIC (no keyframes) ──────── + * Calcula el factor de clip [0..1] vs progress [0..1] como una + * curva CONTINUA con derivada suave. Tiene 3 segmentos: + * - Premonición (0-0.08): clip 1.0 (sin cambios) + * - Reducción agresiva (0.08-0.55): clip baja de 1.0 a 0.15 con + * easeInQuart (lento al inicio, rápido al final) + * - Oscilación amortiguada (0.55-0.80): el agujero se contrae + * hacia 0 mientras OSCILA suavemente (3 ciclos sinusoidales + * con amplitud decreciente). Esto se siente como olas naturales + * en lugar de bounces saltarines. + * - Cerrado (>0.80): clip = 0 + * ──────────────────────────────────────────────────────────────── */ +function computeClipFactor(t) { + if (t < 0.08) return 1.0 + if (t < 0.55) { + const p1 = (t - 0.08) / 0.47 + return 1.0 - easeInQuart(p1) * 0.85 + } + if (t < 0.80) { + const oscT = (t - 0.55) / 0.25 + // Base decay: 0.15 → 0 + const baseR = 0.15 * (1 - easeInCubic(oscT)) + // Oscilación: 2.75 ciclos sinusoidales (suaves) con amplitud + // decreciente. Amplitud peak = 0.028 (2.8% del max screen radius) + // — visible como respiración del agujero, no como salto. + const oscillation = Math.cos(oscT * Math.PI * 5.5) * 0.028 * (1 - oscT) * (1 - oscT) + return Math.max(0, baseR + oscillation) + } + return 0 +} + function phaseT(p, start, end) { if (p < start) return 0 if (p > end) return 1 @@ -74,156 +94,6 @@ function sampleKeyframes(p, frames, ease = easeInOutCubic) { return frames[frames.length - 1].v } -/* ══════════════════════════════════════════════════════════════════ - * DEBRIS PARTICLE — fragments of the universe being absorbed - * Falls inward on a logarithmic spiral (Kerr-metric inspired). - * Trail intensifies as it accelerates → gives sense of relativistic fall. - * ══════════════════════════════════════════════════════════════════ */ -class DebrisParticle { - constructor(cx, cy, w, h) { - const angle = Math.random() * Math.PI * 2 - const dist = 0.18 + Math.random() * 0.85 - const maxR = Math.hypot(w, h) * 0.55 - this.x = cx + Math.cos(angle) * dist * maxR - this.y = cy + Math.sin(angle) * dist * maxR - this.vx = 0; this.vy = 0 - this.color = DEBRIS_PALETTE[Math.floor(Math.random() * DEBRIS_PALETTE.length)] - this.size = 0.6 + Math.random() * 2.0 - this.alpha = 0.30 + Math.random() * 0.60 - this.trail = [] - this.trailMax = 5 + Math.floor(Math.random() * 6) - this.absorbed = false - this.spinBias = (Math.random() - 0.5) * 0.65 // logarithmic spiral coefficient - } - update(cx, cy, progress, dt) { - if (this.absorbed) return - const dx = cx - this.x, dy = cy - this.y - const d = Math.hypot(dx, dy) - if (d < 4) { this.absorbed = true; return } - // Gravity grows non-linearly with time (event horizon forms → accelerates) - const gravity = 80 + 32000 * easeInCubic(progress) - const nx = dx / d, ny = dy / d - // Tangential spin → Kerr-spiral (frame dragging) - const tx = -ny, ty = nx - const spinMag = (260 + 1100 * progress) * this.spinBias / Math.max(d * 0.012, 1) - this.vx += (nx * gravity / Math.max(d, 1) + tx * spinMag) * dt - this.vy += (ny * gravity / Math.max(d, 1) + ty * spinMag) * dt - // Drag scaled with progress so it doesn't stall at end - const drag = 0.94 - progress * 0.06 - this.vx *= drag - this.vy *= drag - this.x += this.vx * dt - this.y += this.vy * dt - this.trail.unshift({ x: this.x, y: this.y, a: this.alpha }) - if (this.trail.length > this.trailMax) this.trail.pop() - } - draw(ctx, progress) { - if (this.absorbed) return - const a = this.alpha * (0.85 + 0.15 * progress) - if (a < 0.01) return - // Trail (gives motion blur + sense of velocity) - for (let i = 1; i < this.trail.length; i++) { - const f = 1 - i / this.trail.length - ctx.beginPath() - ctx.arc(this.trail[i].x, this.trail[i].y, this.size * f * 0.7, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * f * 0.4})` - ctx.fill() - } - // Main particle - ctx.beginPath() - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.fill() - } -} - -/* ══════════════════════════════════════════════════════════════════ - * REALITY FRACTURE — spacetime cracks radiating from the singularity - * Líneas quebradas que aparecen brevemente representando "el tejido - * del espacio-tiempo rompiéndose" alrededor del horizonte de eventos. - * ══════════════════════════════════════════════════════════════════ */ -class RealityFracture { - constructor(cx, cy, w, h) { - this.cx = cx; this.cy = cy - this.angle = Math.random() * Math.PI * 2 - const reach = Math.hypot(w, h) * (0.35 + Math.random() * 0.30) - // Pre-compute the kinked path: 4-6 segments with small lateral jitter - const segs = 4 + Math.floor(Math.random() * 3) - this.points = [] - for (let i = 0; i <= segs; i++) { - const t = i / segs - const r = reach * t - const perpJitter = (Math.random() - 0.5) * 28 * (1 - t) - const px = cx + Math.cos(this.angle) * r + Math.cos(this.angle + Math.PI / 2) * perpJitter - const py = cy + Math.sin(this.angle) * r + Math.sin(this.angle + Math.PI / 2) * perpJitter - this.points.push([px, py]) - } - this.color = Math.random() < 0.5 ? COLORS.cyan : COLORS.purple - this.bornAt = 0.35 + Math.random() * 0.45 // appear during EVENT HORIZON phase - this.flickerPhase = Math.random() * Math.PI * 2 - } - draw(ctx, p, absT) { - if (p < this.bornAt) return - const local = clamp((p - this.bornAt) / 0.18, 0, 1) - const flicker = 0.55 + 0.45 * Math.sin(absT * 22 + this.flickerPhase) - const a = easeOutQuart(local) * (1 - easeInCubic(Math.max(0, local * 1.4 - 0.4))) * flicker * 0.65 - if (a < 0.01) return - ctx.strokeStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.lineWidth = 1.1 + (1 - local) * 1.4 - ctx.shadowColor = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * 0.6})` - ctx.shadowBlur = 8 - ctx.beginPath() - ctx.moveTo(this.points[0][0], this.points[0][1]) - for (let i = 1; i < this.points.length; i++) { - ctx.lineTo(this.points[i][0], this.points[i][1]) - } - ctx.stroke() - ctx.shadowBlur = 0 - } -} - -/* ══════════════════════════════════════════════════════════════════ - * HAWKING PARTICLE — emitted from the event horizon outward - * Pequeñas partículas que ESCAPAN del horizonte (radiación de Hawking). - * Spawned only after the event horizon forms. - * ══════════════════════════════════════════════════════════════════ */ -class HawkingParticle { - constructor(cx, cy, eventRadius) { - const angle = Math.random() * Math.PI * 2 - this.x = cx + Math.cos(angle) * eventRadius - this.y = cy + Math.sin(angle) * eventRadius - const speed = 60 + Math.random() * 180 - this.vx = Math.cos(angle) * speed - this.vy = Math.sin(angle) * speed - this.color = Math.random() < 0.6 ? COLORS.white : COLORS.cyan - this.size = 0.4 + Math.random() * 0.9 - this.alpha = 0.65 + Math.random() * 0.30 - this.age = 0 - // Vida más larga y variable para que el último frame de cada partícula - // ya esté en alpha~0 antes de morir (fade natural, no corte). - this.life = 0.85 + Math.random() * 0.70 // seconds (was 0.45 + 0.35) - } - update(dt) { - this.age += dt - this.x += this.vx * dt - this.y += this.vy * dt - this.vx *= 0.985 - this.vy *= 0.985 - } - draw(ctx) { - const f = clamp(1 - this.age / this.life, 0, 1) - // Curva quart en lugar de cubic — tail-off MUY suave al final - const fade = easeOutQuart(f) * easeOutQuart(f) - const a = this.alpha * fade - if (a < 0.001) return - ctx.beginPath() - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.fill() - } - isDead() { return this.age >= this.life } -} - /* ══════════════════════════════════════════════════════════════════ * MAIN COMPONENT * ══════════════════════════════════════════════════════════════════ */ @@ -267,15 +137,8 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { de forma orgánica. Si tocásemos su opacity tendríamos un "salto" al restaurarla al final. */ - /* ─── Actors ─── */ - const debris = Array.from({ length: 260 }, () => new DebrisParticle(cx, cy, w, h)) - const fractures = Array.from({ length: 16 }, () => new RealityFracture(cx, cy, w, h)) - const hawking = [] - let hawkingSpawnedAt = 0 - const startTime = performance.now() completedRef.current = false - let lastT = startTime /* ─── KEYFRAME TABLES ────────────────────────────────────────── * Cada propiedad del wrapper se define como una curva continua @@ -287,82 +150,76 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { // Scale (transform): zoom inicial leve, retrocede, luego inward zoom scale: [ { t: 0.00, v: 1.00 }, - { t: 0.05, v: 0.97 }, // fly-back out (más rápido) + { t: 0.05, v: 0.97 }, // fly-back out { t: 0.10, v: 1.00 }, // vuelve a neutral - { t: 0.28, v: 1.04 }, // start subtle zoom-in (era 0.42) - { t: 0.50, v: 1.10 }, // era 0.62 - { t: 0.65, v: 1.22 }, // era 0.78 - { t: 0.74, v: 1.45 }, // peak final zoom (era 0.86) - { t: 1.00, v: 1.45 }, // hold + { t: 0.25, v: 1.04 }, // start subtle zoom-in + { t: 0.45, v: 1.12 }, + { t: 0.62, v: 1.28 }, + { t: 0.78, v: 1.50 }, // peak final zoom (collapse) + { t: 1.00, v: 1.50 }, // hold ], // Saturate: gradual desaturation saturate: [ { t: 0.00, v: 1.00 }, - { t: 0.07, v: 0.85 }, // era 0.11 - { t: 0.28, v: 0.65 }, // era 0.42 - { t: 0.50, v: 0.48 }, // era 0.62 - { t: 0.65, v: 0.30 }, // era 0.78 - { t: 0.74, v: 0.15 }, // era 0.86 - { t: 1.00, v: 0.15 }, + { t: 0.10, v: 0.85 }, + { t: 0.30, v: 0.65 }, + { t: 0.50, v: 0.45 }, + { t: 0.65, v: 0.25 }, + { t: 0.78, v: 0.12 }, + { t: 1.00, v: 0.12 }, ], - // Blur: smooth ramp from 0 to 12 + // Blur arranca a t=0.20 y crece hasta el cierre del clip (t=0.80) blur: [ { t: 0.00, v: 0 }, - { t: 0.10, v: 0 }, // sin blur durante premonición (era 0.20) - { t: 0.28, v: 1.3 }, // era 0.42 - { t: 0.50, v: 3.0 }, // era 0.62 - { t: 0.65, v: 5.5 }, // era 0.78 - { t: 0.74, v: 11 }, // era 0.86 - { t: 1.00, v: 11 }, + { t: 0.20, v: 0 }, + { t: 0.38, v: 2.0 }, + { t: 0.55, v: 6.0 }, + { t: 0.70, v: 9.0 }, + { t: 0.80, v: 14 }, + { t: 1.00, v: 14 }, ], - // Brightness: stays at 1 until phase 4, dims hard at the end brightness: [ { t: 0.00, v: 1 }, - { t: 0.50, v: 1 }, // era 0.62 - { t: 0.65, v: 0.78 }, // era 0.78 - { t: 0.74, v: 0.10 }, // era 0.86 + { t: 0.40, v: 1 }, + { t: 0.65, v: 0.78 }, + { t: 0.80, v: 0.10 }, { t: 1.00, v: 0.05 }, ], - // Hue rotation: blueshift progresivo (efecto agujero negro) hueRot: [ { t: 0.00, v: 0 }, - { t: 0.28, v: 0 }, // era 0.42 - { t: 0.50, v: -18 }, // era 0.62 - { t: 0.65, v: -32 }, // era 0.78 - { t: 0.74, v: -55 }, // era 0.86 + { t: 0.25, v: 0 }, + { t: 0.50, v: -18 }, + { t: 0.68, v: -36 }, + { t: 0.80, v: -55 }, { t: 1.00, v: -55 }, ], - // Clip radius (en px, antes de aplicar counter-zoom de scale). - // Valor BASELINE = maxScreenR (sin clip visible). El shrink ahora - // arranca a t=0.15 (era 0.40) — agujero negro visible mucho antes. - // A t=0.74 (era 0.86) el clip llega a 0 → dashboard 100% visible - // y el remnant brilla sobre él durante todo el final. + // Clip radius — ya NO se usa de aquí. computeClipFactor() genera + // una curva analítica con oscilación amortiguada (sin keyframes). + // Se mantiene como vestigio comentado por si hace falta debug. clipR: [ - { t: 0.00, v: maxScreenR * 1.0 }, - { t: 0.15, v: maxScreenR * 1.0 }, // era 0.40 — arranca el cierre antes - { t: 0.32, v: maxScreenR * 0.78 }, // era 0.62 — primera contracción visible - { t: 0.52, v: maxScreenR * 0.45 }, // era 0.78 - { t: 0.74, v: 0 }, // era 0.86 — total collapse antes - { t: 1.00, v: 0 }, // hold colapsado + { t: 0.00, v: maxScreenR * 1.00 }, + { t: 1.00, v: 0 }, ], } // Cache absolute screen-shake amplitude curve (used only in phase 1-2) const shakeAmpFor = (p) => { - if (p < 0.04) return p / 0.04 * 2 - if (p < 0.28) return 2 + ((p - 0.04) / 0.24) * 4 - if (p < 0.50) return 6 * (1 - (p - 0.28) / 0.22) + if (p < 0.05) return p / 0.05 * 2 + if (p < 0.36) return 2 + ((p - 0.05) / 0.31) * 4 + if (p < 0.55) return 6 * (1 - (p - 0.36) / 0.19) return 0 } const frame = (now) => { const elapsed = now - startTime const progress = clamp(elapsed / DURATION, 0, 1) - const dt = Math.min(0.033, (now - lastT) / 1000) - lastT = now const absT = (now - startTime) / 1000 ctx.clearRect(0, 0, w, h) + // ClipR calculado con función analítica continua (en lugar de + // keyframes) — da oscilaciones amortiguadas suaves sin saltos. + const currentClipR = computeClipFactor(progress) * maxScreenR + /* ════════════════════════════════════════════════════════ * WRAPPER (CSS) — una sola actualización por frame con * valores interpolados continuamente de los keyframes. @@ -374,7 +231,7 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { const blur = sampleKeyframes(progress, KF.blur) const br = sampleKeyframes(progress, KF.brightness) const hue = sampleKeyframes(progress, KF.hueRot) - const clipR = sampleKeyframes(progress, KF.clipR) + const clipR = currentClipR const shakeAmp = shakeAmpFor(progress) const sx = Math.sin(absT * 26) * shakeAmp @@ -386,9 +243,13 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { const clipInCSS = clipR / scale wrapperEl.style.transform = `translate(${sx}px, ${sy}px) scale(${scale})` wrapperEl.style.filter = `saturate(${sat}) blur(${blur}px) hue-rotate(${hue}deg) brightness(${br})` - // Solo aplicar clipPath cuando ya empezó a cerrarse (evita - // clip visible artificial al inicio) - if (progress > 0.39) { + // Solo aplicar clipPath cuando ya empezó a cerrarse de verdad. + // El threshold compara el clip en pixels: si está al 99% o más + // del radio máximo, no aplica nada (evita un clip "completo" que + // se vería como un círculo gigante). En cuanto baja del 99% se + // aplica de forma continua para que el cierre sea PROGRESIVO + // desde el primer keyframe, sin saltos discretos. + if (clipR < maxScreenR * 0.995) { wrapperEl.style.clipPath = `circle(${Math.max(0, clipInCSS)}px at 50% 50%)` } else { wrapperEl.style.clipPath = '' @@ -432,37 +293,18 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { // Sin draws aquí — los efectos de phase 1 son los keyframes // wrapper de scale/saturate + shake aplicados arriba. - /* ════════════════════════════════════════════════════════ - * PHASE 2: GATHERING (0.04 → 0.28) - * Debris falls inward - * ════════════════════════════════════════════════════════ */ - const p2 = phaseT(progress, 0.04, 0.28) - if (p2 > 0) { - for (const d of debris) { - d.update(cx, cy, p2, dt) - d.draw(ctx, p2) - } - } - /* ════════════════════════════════════════════════════════ * PHASE 3: EVENT HORIZON (0.28 → 0.52) - * Photon ring + accretion disk + reality fractures. + * Photon ring + accretion disk. * El wrapper (clip + filter) lo gestiona la tabla de keyframes * arriba — aquí solo dibujamos en el canvas overlay. * ════════════════════════════════════════════════════════ */ - const p3 = phaseT(progress, 0.28, 0.52) + const p3 = phaseT(progress, 0.30, 0.55) if (p3 > 0) { - // Continue updating debris (acceleration intensifies) - for (const d of debris) { - d.update(cx, cy, p2 || 1, dt) - d.draw(ctx, p2 || 1) - } - - // Reality fractures appear (use overall progress so timings sync) - for (const f of fractures) f.draw(ctx, progress, absT) - - // Event horizon radius (shrinks as we go through phase 3-4-5) - const eventR = lerp(150, 4, easeInQuart(progress)) + // Event horizon radius: sits AT the actual clip-path edge (+6px + // so the stroke renders just outside the dark interior) so the + // ring/disk visually wrap the black hole and respect the bounces. + const eventR = Math.max(4, currentClipR + 6) // ── Accretion disk con doppler shift sutil ── const diskInner = eventR * 1.05 @@ -494,7 +336,11 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { } // ── Photon ring at the event horizon ── - if (eventR > 2) { + // Only draw when it fits inside the viewport — when the clip is + // very wide at the start of phase 3, eventR is huge and the ring + // would render off-screen as a flat line crossing the borders. + const maxVisibleRing = Math.min(w, h) * 0.55 + if (eventR > 2 && eventR < maxVisibleRing) { const ringA = clamp(easeOutQuart(p3) * 0.95, 0, 0.95) ctx.beginPath() ctx.arc(cx, cy, eventR, 0, Math.PI * 2) @@ -516,12 +362,6 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { ctx.arc(cx, cy, innerR, 0, Math.PI * 2) ctx.fill() } - - // ── Hawking radiation (spawn periodically) ── - if (p3 > 0.25 && absT - hawkingSpawnedAt > 0.04) { - for (let i = 0; i < 3; i++) hawking.push(new HawkingParticle(cx, cy, eventR * 1.1)) - hawkingSpawnedAt = absT - } } /* ════════════════════════════════════════════════════════ @@ -530,15 +370,14 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { * El wrapper (zoom, blur, clip) lo gestiona la tabla de * keyframes arriba — aquí solo dibujamos en canvas. * ════════════════════════════════════════════════════════ */ - const p4 = phaseT(progress, 0.52, 0.75) + const p4 = phaseT(progress, 0.55, 0.80) if (p4 > 0) { - // Reality fractures remain - for (const f of fractures) f.draw(ctx, progress, absT) + // Event horizon — AT the clip edge (+6px outside) so the ring + // wraps the black hole and honors every bounce. + const eventR = Math.max(4, currentClipR + 6) + const maxVisibleRing = Math.min(w, h) * 0.55 - // Event horizon - const eventR = lerp(150, 4, easeInQuart(progress)) - - if (eventR > 2) { + if (eventR > 2 && eventR < maxVisibleRing) { ctx.save() ctx.translate(cx, cy) ctx.rotate(absT * 2.4) @@ -582,19 +421,6 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { } } - /* ════════════════════════════════════════════════════════ - * HAWKING PARTICLES — render global (post phase 4) - * Las partículas spawneadas durante phase 3 siguen vivas - * después de que phase 4 termine; las renderizamos siempre - * que existan para que hagan su fade natural en lugar de - * cortarse de golpe al cambiar de fase. - * ════════════════════════════════════════════════════════ */ - for (let i = hawking.length - 1; i >= 0; i--) { - hawking[i].update(dt) - if (hawking[i].isDead()) hawking.splice(i, 1) - } - for (const hp of hawking) hp.draw(ctx) - /* ════════════════════════════════════════════════════════ * PHASE 5: TOTAL COLLAPSE (0.66 → 0.74) * Solo el white flash final. clip-path y wrapper los maneja @@ -602,7 +428,7 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { * a 0 y el dashboard es 100% visible. Tras eso queda el * REMNANT brillando (siguiente bloque). * ════════════════════════════════════════════════════════ */ - const p5 = phaseT(progress, 0.66, 0.74) + const p5 = phaseT(progress, 0.70, 0.80) if (p5 > 0) { const flashLocal = clamp(p5 / 0.40, 0, 1) const flashA = flashLocal < 0.45 @@ -629,74 +455,116 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { * que se diluye imperceptiblemente. * El dashboard es 100% visible detrás durante toda esta fase. * ════════════════════════════════════════════════════════ */ - const pRemnant = phaseT(progress, 0.58, 1.0) + /* ════════════════════════════════════════════════════════ + * SINGULARITY REMNANT (0.60 → 1.00 — fase final cinemática) + * Físicamente: el agujero negro acaba de colapsar sobre su + * propia singularidad. El brillo es lo que queda DESPUÉS del + * colapso. Estructura: + * - Build-up (0.00-0.30 local): el brillo aparece mientras + * el clip se sigue cerrando. + * - PEAK largo y estable (0.30-0.82 local): el brillo permanece + * al 100% durante el cierre del agujero y bastante después, + * sobreviviéndolo claramente. + * - Blink (0.82-0.92 local): pulso de intensidad final (×1.4) + * como un "destello" antes de extinguirse. + * - Off (0.92-1.00 local): apagado rápido a 0. + * ════════════════════════════════════════════════════════ */ + const pRemnant = phaseT(progress, 0.80, 1.0) if (pRemnant > 0) { // Pulse suave (frecuencia 10 rad/s = ~1.6 Hz) const pulse = 0.72 + 0.28 * Math.sin(absT * 10) - // Curva de intensidad de 3 tramos (build-up, peak, fade muy largo) + // ── Curva de intensidad con BLINK ÉPICO ── + // pRemnant es 0-1 sobre 1100ms (de t global 0.80 a 1.00, DURATION=5500ms) + // 0.00-0.15 (165ms): build-up rápido + // 0.15-0.62 (520ms): PEAK ESTABLE — singularidad visible ~medio segundo + // 0.62-0.90 (310ms): BLINK rápido (intensity 2.8×) + // 0.90-1.00 (110ms): off let intensity - if (pRemnant < 0.30) { - intensity = easeOutCubic(pRemnant / 0.30) // build-up - } else if (pRemnant < 0.45) { - intensity = 1 // peak sostenido + if (pRemnant < 0.15) { + intensity = easeOutCubic(pRemnant / 0.15) + } else if (pRemnant < 0.62) { + intensity = 1 // PEAK estable 520ms + } else if (pRemnant < 0.90) { + // BLINK rápido + const bp = (pRemnant - 0.62) / 0.28 + if (bp < 0.35) { + intensity = 1 + 1.8 * easeOutCubic(bp / 0.35) // 1 → 2.8 (rápido) + } else { + const fp = (bp - 0.35) / 0.65 + intensity = 2.8 - 2.5 * (fp * fp) // 2.8 → 0.3 + } + } else { + intensity = 0.3 - 0.3 * easeOutCubic((pRemnant - 0.90) / 0.10) + } + const baseA = Math.max(0, intensity * pulse) + + // ── Curva de tamaño: durante PEAK tamaño normal, blink expande, off colapsa ── + let sizeShrink + if (pRemnant < 0.62) { + sizeShrink = 1.0 // tamaño normal + } else if (pRemnant < 0.90) { + const bp = (pRemnant - 0.62) / 0.28 + if (bp < 0.35) { + sizeShrink = 1.0 + 0.6 * easeOutCubic(bp / 0.35) // 1.0 → 1.6 + } else { + const fp = (bp - 0.35) / 0.65 + sizeShrink = 1.6 - 1.0 * (fp * fp) // 1.6 → 0.6 + } } else { - // Fade out muy largo (55% del tiempo del remnant) con curva suave - // easeInOutCubic invertido para que decrezca muy lento al final - intensity = 1 - easeInOutCubic((pRemnant - 0.45) / 0.55) + sizeShrink = Math.max(0.05, 0.6 - 0.55 * easeOutCubic((pRemnant - 0.90) / 0.10)) } - const baseA = intensity * pulse - /* IMPORTANTE: no aplicamos threshold (`baseA > X`) para evitar - el "pop" cuando el valor cae por debajo. Si baseA es muy bajo - el navegador renderiza alpha~0 (imperceptible) sin discontinuidad. */ if (baseA > 0.001) { // ── Halo exterior masivo (suave, gigante) ── - const haloR = 140 + 50 * pulse + const haloR = (140 + 50 * pulse) * sizeShrink const haloGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, haloR) - haloGrad.addColorStop(0, `rgba(255,245,210,${baseA * 0.50})`) - haloGrad.addColorStop(0.15, `rgba(255,210,120,${baseA * 0.40})`) - haloGrad.addColorStop(0.40, `rgba(255,150,80,${baseA * 0.22})`) - haloGrad.addColorStop(0.65, `rgba(0,212,228,${baseA * 0.14})`) - haloGrad.addColorStop(0.85, `rgba(157,111,219,${baseA * 0.06})`) + haloGrad.addColorStop(0, `rgba(255,245,210,${Math.min(1, baseA * 0.50)})`) + haloGrad.addColorStop(0.15, `rgba(255,210,120,${Math.min(1, baseA * 0.40)})`) + haloGrad.addColorStop(0.40, `rgba(255,150,80,${Math.min(1, baseA * 0.22)})`) + haloGrad.addColorStop(0.65, `rgba(0,212,228,${Math.min(1, baseA * 0.14)})`) + haloGrad.addColorStop(0.85, `rgba(157,111,219,${Math.min(1, baseA * 0.06)})`) haloGrad.addColorStop(1, 'rgba(0,0,0,0)') ctx.fillStyle = haloGrad ctx.fillRect(cx - haloR, cy - haloR, haloR * 2, haloR * 2) - // ── Halo interior más concentrado (dorado puro) ── - const innerHaloR = 32 + 14 * pulse + // ── Halo interior dorado puro ── + const innerHaloR = (32 + 14 * pulse) * sizeShrink const innerGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, innerHaloR) - innerGrad.addColorStop(0, `rgba(255,255,240,${baseA * 0.80})`) - innerGrad.addColorStop(0.35, `rgba(255,230,160,${baseA * 0.55})`) - innerGrad.addColorStop(0.75, `rgba(255,180,90,${baseA * 0.20})`) + innerGrad.addColorStop(0, `rgba(255,255,240,${Math.min(1, baseA * 0.80)})`) + innerGrad.addColorStop(0.35, `rgba(255,230,160,${Math.min(1, baseA * 0.55)})`) + innerGrad.addColorStop(0.75, `rgba(255,180,90,${Math.min(1, baseA * 0.20)})`) innerGrad.addColorStop(1, 'rgba(0,0,0,0)') ctx.fillStyle = innerGrad ctx.fillRect(cx - innerHaloR, cy - innerHaloR, innerHaloR * 2, innerHaloR * 2) - // ── Núcleo brillante (singularity dot) ── - const coreR = 2.5 + pulse * 2.5 + // ── Núcleo brillante (singularity dot) — durante blink x2.2 ── + const isBlink = pRemnant >= 0.62 && pRemnant < 0.90 + const coreBoost = isBlink ? 2.2 : 1.0 + const coreR = (2.5 + pulse * 2.5) * Math.max(0.4, sizeShrink) * coreBoost ctx.beginPath() ctx.arc(cx, cy, coreR, 0, Math.PI * 2) - ctx.fillStyle = `rgba(255,255,255,${baseA * 0.98})` - ctx.shadowColor = `rgba(255,230,160,${baseA})` - ctx.shadowBlur = 28 + ctx.fillStyle = `rgba(255,255,255,${Math.min(1, baseA * 0.98)})` + ctx.shadowColor = `rgba(255,230,160,${Math.min(1, baseA)})` + ctx.shadowBlur = 28 * sizeShrink * coreBoost ctx.fill() ctx.shadowBlur = 0 - // ── Star spikes (4 puntas, lens flare cinemático) ── - const spikeLen = 30 + 20 * pulse - const spikeA = baseA * 0.55 - if (spikeA > 0.005) { + // ── Star spikes — durante blink se ALARGAN dramáticamente ── + const spikeBoost = isBlink ? 2.5 : 1.0 + const spikeLen = (30 + 20 * pulse) * sizeShrink * spikeBoost + const spikeA = Math.min(1, baseA * 0.55) + if (spikeA > 0.005 && spikeLen > 1) { ctx.strokeStyle = `rgba(255,245,200,${spikeA})` - ctx.lineWidth = 1.2 + ctx.lineWidth = 1.2 * (isBlink ? 1.8 : 1) ctx.shadowColor = `rgba(255,220,140,${spikeA})` - ctx.shadowBlur = 16 + ctx.shadowBlur = 16 * (isBlink ? 1.5 : 1) ctx.beginPath() ctx.moveTo(cx - spikeLen, cy); ctx.lineTo(cx + spikeLen, cy) ctx.moveTo(cx, cy - spikeLen); ctx.lineTo(cx, cy + spikeLen) ctx.stroke() ctx.strokeStyle = `rgba(255,245,200,${spikeA * 0.55})` - ctx.lineWidth = 0.8 + ctx.lineWidth = 0.8 * (isBlink ? 1.6 : 1) ctx.beginPath() const diag = spikeLen * 0.55 ctx.moveTo(cx - diag, cy - diag); ctx.lineTo(cx + diag, cy + diag) @@ -704,28 +572,38 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { ctx.stroke() ctx.shadowBlur = 0 } + + // ── BLINK extra: shockwave radial ── + if (pRemnant >= 0.62 && pRemnant < 0.80) { + const sp = (pRemnant - 0.62) / 0.18 + const ringR = 50 + 200 * easeOutCubic(sp) + const ringA = (1 - sp) * 0.55 + if (ringA > 0.01) { + ctx.strokeStyle = `rgba(255,235,180,${ringA})` + ctx.lineWidth = 2 + (1 - sp) * 3 + ctx.shadowColor = `rgba(255,210,120,${ringA})` + ctx.shadowBlur = 22 + ctx.beginPath() + ctx.arc(cx, cy, ringR, 0, Math.PI * 2) + ctx.stroke() + ctx.shadowBlur = 0 + } + } } } /* ════════════════════════════════════════════════════════ - * CANVAS OVERLAY + UNIVERSE CONTAINER FADE-OUT (últimos 12%) - * El `.universe` div tiene background opaco y z-index 9999. - * Sin un fade explícito, al desmontarse (cuando onComplete - * dispara setIsExiting+closeCollaborationGraph) desaparece de - * golpe revelando el dashboard en 1 frame = corte visible. - * Aquí lo desvanecemos progresivamente en paralelo con el - * canvas overlay, así el dashboard emerge orgánicamente y el - * unmount es imperceptible (ya estaba transparente). + * CANVAS OVERLAY FADE-OUT (últimos 4%) + * El remnant ya se reduce naturalmente de tamaño hasta quedar + * un puntito brillante. Este fade final es solo el "apagado" + * limpio del puntito al unmount — 4% del DURATION = ~220ms. * ════════════════════════════════════════════════════════ */ - if (progress > 0.88) { - const fadeT = (progress - 0.88) / 0.12 // 0 → 1 en los últimos 12% + if (progress > 0.96) { + const fadeT = (progress - 0.96) / 0.04 const eased = easeInOutCubic(fadeT) if (canvasRef.current) { canvasRef.current.style.opacity = String(1 - eased) } - if (universeContainer) { - universeContainer.style.opacity = String(1 - eased) - } } if (progress < 1) { diff --git a/src/components/Universe/UniverseView.jsx b/src/components/Universe/UniverseView.jsx index d0d11f9..d8b188e 100644 --- a/src/components/Universe/UniverseView.jsx +++ b/src/components/Universe/UniverseView.jsx @@ -45,6 +45,39 @@ const REPO_MAX_ORBIT = 55 // órbita máxima qubit→procesador const USER_MIN_ORBIT = 4 // órbita mínima partícula→qubit const USER_MAX_ORBIT = 10 // órbita máxima partícula→qubit +const SINGULARITY = new THREE.Vector3(0, 0, 0) +const _tmpRadial = new THREE.Vector3() +const _tmpTangent = new THREE.Vector3() +const _tmpUp = new THREE.Vector3(0, 1, 0) +const _tmpRight = new THREE.Vector3(1, 0, 0) + +function setEntryTravelPosition(out, pos, progress, staggerDelay, duration) { + const travelP = Math.min(1, Math.max(0, (progress - staggerDelay) / duration)) + const eased = travelP < 1 ? easeOutCubic(travelP) : 1 + out.lerpVectors(SINGULARITY, pos, eased) + if (eased < 0.999) { + const len = _tmpRadial.set(pos.x, pos.y, pos.z).length() + if (len > 0.001) { + const swirlMag = Math.sin(eased * Math.PI) * len * 0.12 + const radial = _tmpRadial.normalize() + _tmpTangent.crossVectors(_tmpUp, radial) + if (_tmpTangent.lengthSq() < 0.0001) _tmpTangent.crossVectors(_tmpRight, radial) + out.addScaledVector(_tmpTangent.normalize(), swirlMag) + } + } + return travelP +} + +function applyExitCollapse(out, exitProgress) { + if (exitProgress <= 0.001) return 0 + // Quadratic curve (`t^2`) for visible collapse from ~25% of progress. + // Linear lerp toward singularity (no spiral — spiral was disorienting + // for the viewer; straight line preserves spatial mapping of nodes). + const eased = exitProgress < 1 ? exitProgress * exitProgress : 1 + out.lerp(SINGULARITY, eased) + return eased +} + // Known bot logins — fallback when backend isBot flag is missing from cached data const KNOWN_BOT_LOGINS = new Set([ 'dependabot', 'renovate', 'greenkeeper', 'snyk-bot', 'codecov', @@ -552,12 +585,14 @@ function QuantumVacuum({ progressRef, progressKey }) { useFrame(({ clock }) => { const t = clock.getElapsedTime() const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 fluctMat.uniforms.uTime.value = t fluctMat.uniforms.uProgress.value = p - fluctMat.uniforms.uOpacity.value = p > 0.01 ? 0.15 * p : 0 + fluctMat.uniforms.uOpacity.value = p > 0.01 ? 0.15 * p * exitFade : 0 // Lattice fade-in - if (latticeRef.current) latticeRef.current.material.opacity = 0.025 * p + if (latticeRef.current) latticeRef.current.material.opacity = 0.025 * p * exitFade }) return ( @@ -615,6 +650,7 @@ function QuantumProcessors({ orgNodes, positions, onHover, onClick, progressRef, const t = clock.getElapsedTime() const hasSel = highlightSet !== null const progress = progressRef.current[progressKey] + const exitProgress = progressRef.current.exitProgress || 0 // Smooth lens blend if (lensData !== lastOrgLens.current) { @@ -656,11 +692,14 @@ function QuantumProcessors({ orgNodes, positions, onHover, onClick, progressRef, : 1.0 const appliedLensScale = 1.0 + (lensScaleFactor - 1.0) * blend const vis = visRef.current[i] - const scale = (isHighlighted ? localP * 1.25 : localP) * appliedLensScale * (vis < 0.001 ? 0 : 1) + const travelP = setEntryTravelPosition(tmpObj.position, pos, progress, stagger * 0.6, 0.5) + const exitEased = applyExitCollapse(tmpObj.position, exitProgress) + const breathing = travelP >= 1 ? 1 + Math.sin(t * 1.5 + i * 0.7) * 0.015 : 1 + let scale = (isHighlighted ? localP * 1.25 : localP) * appliedLensScale * breathing * (vis < 0.001 ? 0 : 1) + if (exitEased > 0) scale *= 1 - exitEased * 0.85 const speed = 0.3 + i * 0.05 // Torus 1 - rotación X,Y - tmpObj.position.copy(pos) tmpObj.rotation.set(t * speed, t * speed * 0.7, 0) tmpObj.scale.setScalar(scale) tmpObj.updateMatrix() @@ -812,9 +851,11 @@ function ProbabilityClouds({ repoNodes, positions, progressRef, progressKey, dim useFrame(({ clock }) => { if (!ref.current) return const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 shaderMat.uniforms.uTime.value = clock.getElapsedTime() * 0.4 shaderMat.uniforms.uProgress.value = p - shaderMat.uniforms.uOpacity.value = (dimmed ? 0.008 : 0.5) * p + shaderMat.uniforms.uOpacity.value = (dimmed ? 0.008 : 0.5) * p * exitFade // === Temporal visibility per cloud particle === const geo = ref.current?.geometry @@ -894,6 +935,7 @@ function Qubits({ repoNodes, positions, onHover, onClick, progressRef, progressK const t = clock.getElapsedTime() const n = repoNodes.length const hasSel = highlightSet !== null + const exitProgress = progressRef.current.exitProgress || 0 // Smooth lens blend for qubits - delayed until canvas visible if (lensData !== lastQubitLens.current) { @@ -947,16 +989,22 @@ function Qubits({ repoNodes, positions, onHover, onClick, progressRef, progressK const appliedLensScale = 1.0 + (lensScaleFactor - 1.0) * blend const vis = visRef.current[i] const visFade = vis * vis // quadratic for smoother perceptual fade - dummy.position.copy(pos) + const travelP = setEntryTravelPosition(dummy.position, pos, progress, stagger * 0.5, 0.6) // Heisenberg uncertainty - micro-vibración cuántica - dummy.position.x += Math.sin(t * 1.7 + i * 3.14) * 0.04 - dummy.position.y += Math.cos(t * 2.3 + i * 2.71) * 0.04 - dummy.position.z += Math.sin(t * 1.9 + i * 1.62) * 0.04 - dummy.scale.setScalar(baseScale * localP * selScale * appliedLensScale * (vis < 0.001 ? 0 : 1)) + if (travelP >= 1) { + dummy.position.x += Math.sin(t * 1.7 + i * 3.14) * 0.04 + dummy.position.y += Math.cos(t * 2.3 + i * 2.71) * 0.04 + dummy.position.z += Math.sin(t * 1.9 + i * 1.62) * 0.04 + } + const exitEased = applyExitCollapse(dummy.position, exitProgress) + const breathing = travelP >= 1 ? 1 + Math.sin(t * 1.5 + i * 0.7) * 0.015 : 1 + let scale = baseScale * localP * selScale * appliedLensScale * breathing * (vis < 0.001 ? 0 : 1) + if (exitEased > 0) scale *= 1 - exitEased * 0.85 + dummy.scale.setScalar(scale) dummy.updateMatrix() ref.current.setMatrixAt(i, dummy.matrix) if (hitRef.current) { - dummy.scale.setScalar(vis > 0.01 && localP > 0.1 ? 1 : 0.001) + dummy.scale.setScalar(vis > 0.01 && localP > 0.1 ? Math.max(0.001, 1 - exitEased * 0.85) : 0.001) dummy.updateMatrix() hitRef.current.setMatrixAt(i, dummy.matrix) } @@ -1031,7 +1079,10 @@ function BlochAxes({ repoNodes, positions, progressRef, progressKey, dimmed, act useEffect(() => () => geometry?.dispose(), [geometry]) useFrame(() => { - if (matRef.current) matRef.current.uniforms.uOpacity.value = 0.15 * easeOutCubic(progressRef?.current?.[progressKey] || 0) * (dimmed ? 0.04 : 1) + const progress = easeOutCubic(progressRef?.current?.[progressKey] || 0) + const exitProgress = progressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 + if (matRef.current) matRef.current.uniforms.uOpacity.value = 0.15 * progress * (dimmed ? 0.04 : 1) * exitFade // === Temporal visibility for Bloch axes === const visAttr = geometry.attributes.aVisible if (visAttr) { @@ -1125,6 +1176,7 @@ const PARTICLE_VERTEX = /* glsl */` attribute float aVisible; uniform float uTime; uniform float uProgress; + uniform float uExitProgress; uniform float uBaseSize; uniform float uBridgeSize; uniform float uLensActive; @@ -1137,6 +1189,12 @@ const PARTICLE_VERTEX = /* glsl */` varying float vDensity; varying float vBridgeBlend; varying float vVisible; + varying float vExitFade; + + float easeOutCubicGpu(float t) { + t = clamp(t, 0.0, 1.0); + return 1.0 - pow(1.0 - t, 3.0); + } void main() { vBrightness = aBrightness; @@ -1150,6 +1208,12 @@ const PARTICLE_VERTEX = /* glsl */` // === STAGGERING PER-PARTICLE === float stagger = fract(aSeed * 3.7) * 0.55; float localP = smoothstep(stagger, stagger + 0.45, p); + float travelP = clamp((p - stagger) / 0.45, 0.0, 1.0); + float entryEased = easeOutCubicGpu(travelP); + float exitEased = uExitProgress > 0.001 + ? (uExitProgress < 1.0 ? uExitProgress * uExitProgress : 1.0) + : 0.0; + vExitFade = 1.0 - exitEased; // === CRITICAL: GPU clampea gl_PointSize mínimo a 1px === if (localP < 0.001 || aVisible < 0.001) { @@ -1170,7 +1234,16 @@ const PARTICLE_VERTEX = /* glsl */` float jx = sin(uTime * 3.14 + aSeed * 17.3) * 0.04; float jy = sin(uTime * 2.71 + aSeed * 31.7) * 0.04; float jz = sin(uTime * 1.62 + aSeed * 47.1) * 0.04; - vec3 pos = position + vec3(jx, jy, jz); + float radialLen = length(position); + vec3 radial = radialLen > 0.001 ? normalize(position) : vec3(0.0, 0.0, 1.0); + vec3 tangent = cross(vec3(0.0, 1.0, 0.0), radial); + if (length(tangent) < 0.001) tangent = cross(vec3(1.0, 0.0, 0.0), radial); + tangent = normalize(tangent); + float swirlMag = sin(entryEased * 3.14159265) * radialLen * 0.12; + vec3 pos = mix(vec3(0.0), position, entryEased) + tangent * swirlMag; + if (travelP >= 1.0) pos += vec3(jx, jy, jz); + // Linear collapse toward singularity (no spiral — disorienting for viewer) + pos = mix(pos, vec3(0.0), exitEased); vec4 mvPos = modelViewMatrix * vec4(pos, 1.0); @@ -1189,6 +1262,7 @@ const PARTICLE_VERTEX = /* glsl */` float normalSize = uBaseSize * normalPulse * lensScale; float bridgeSize = uBridgeSize * bridgePulse * lensScale; float size = mix(normalSize, bridgeSize, bridgeBlend); + size *= 1.0 - exitEased * 0.85; // Reducir tamaño en repos densos (bridges preservan tamaño mínimo) float densitySize = mix(aDensity, max(aDensity, 0.5), bridgeBlend); @@ -1200,7 +1274,7 @@ const PARTICLE_VERTEX = /* glsl */` vGlow = mix(normalGlow, 1.0 + personalFlash * 0.6, bridgeBlend); gl_PointSize = size * (350.0 / -mvPos.z); - if (aVisible < 0.001) { gl_PointSize = 0.0; gl_Position = vec4(9999.0, 9999.0, 9999.0, 1.0); return; } + if (aVisible < 0.001 || size < 0.001) { gl_PointSize = 0.0; gl_Position = vec4(9999.0, 9999.0, 9999.0, 1.0); return; } gl_Position = projectionMatrix * mvPos; } ` @@ -1217,9 +1291,10 @@ const PARTICLE_FRAGMENT = /* glsl */` varying float vDensity; varying float vBridgeBlend; varying float vVisible; + varying float vExitFade; void main() { - float visFade = vVisible * vVisible; + float visFade = vVisible * vVisible * vExitFade; vec4 texel = texture2D(uMap, gl_PointCoord); // Bridge reveal: verde → dorado progresivo según vBridgeBlend vec3 baseCol = mix(uColorNormal, uColorBridge, vBridgeBlend); @@ -1294,6 +1369,7 @@ function QuantumParticles({ userNodes, positions, onHover, onClick, progressRef, uniforms: { uTime: { value: 0 }, uProgress: { value: 0 }, + uExitProgress: { value: 0 }, uBaseSize: { value: 3.5 }, uBridgeSize: { value: 5.0 }, uMap: { value: glowMap }, @@ -1349,6 +1425,7 @@ function QuantumParticles({ userNodes, positions, onHover, onClick, progressRef, useFrame(({ clock }) => { mat.uniforms.uTime.value = clock.getElapsedTime() mat.uniforms.uProgress.value = progressRef.current[progressKey] + mat.uniforms.uExitProgress.value = progressRef.current.exitProgress || 0 // Bridge reveal driven by entanglement progress - bridges "se descubren" con las conexiones mat.uniforms.uBridgeReveal.value = easeOutCubic(progressRef.current.entanglement || 0) @@ -1678,9 +1755,11 @@ function QuantumBonds({ repoUsers, positions, progressRef, progressKey, dimmed, useFrame(({ clock }) => { if (!mat) return const progress = progressRef.current[progressKey] + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 mat.uniforms.uTime.value = clock.getElapsedTime() mat.uniforms.uProgress.value = progress - mat.uniforms.uOpacity.value = (dimmed ? 0.008 : 0.35) * easeOutCubic(progress) + mat.uniforms.uOpacity.value = (dimmed ? 0.008 : 0.35) * easeOutCubic(progress) * exitFade // === Temporal visibility lerp === if (geo) { @@ -1849,8 +1928,10 @@ function OrgEntanglementArcs({ arcs, progressRef, progressKey, dimmed, collabHig if (!mat) return mat.uniforms.uTime.value = clock.getElapsedTime() const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 mat.uniforms.uProgress.value = p - mat.uniforms.uOpacity.value = p < 0.01 ? 0 : (collabHighlight ? 1.0 : (dimmed ? 0.015 : 0.7)) + mat.uniforms.uOpacity.value = (p < 0.01 ? 0 : (collabHighlight ? 1.0 : (dimmed ? 0.015 : 0.7))) * exitFade // Smooth boost transition const targetBoost = collabHighlight ? 1.0 : 0.0 mat.uniforms.uBoost.value += (targetBoost - mat.uniforms.uBoost.value) * 0.08 @@ -2041,9 +2122,11 @@ function EntanglementChannels({ connections, progressRef, progressKey, dimmed, h useFrame(({ clock }) => { if (!ref.current || connections.length === 0) return const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 const baseOpacity = p < 0.01 ? 0 : (collabHighlight ? 0.03 : (dimmed ? (highlightSet ? 0.4 : 0.05) : 0.55)) - shaderMat.uniforms.uOpacity.value = baseOpacity + shaderMat.uniforms.uOpacity.value = baseOpacity * exitFade shaderMat.uniforms.uTime.value = clock.getElapsedTime() shaderMat.uniforms.uDrawProgress.value = p @@ -2154,7 +2237,9 @@ function EnergyRings({ orgNodes, positions, progressRef, progressKey, highlightS useFrame(({ clock }) => { if (!meshRef.current) return const t = clock.getElapsedTime() - const p = easeOutCubic(progressRef.current[progressKey]) + const progress = progressRef.current[progressKey] + const p = easeOutCubic(progress) + const exitProgress = progressRef.current.exitProgress || 0 const hasSel = highlightSet !== null // === Temporal visibility lerp === @@ -2176,11 +2261,15 @@ function EnergyRings({ orgNodes, positions, progressRef, progressKey, highlightS const vis = visRef.current[i] const visFade = vis * vis // quadratic for smoother perceptual fade const phase = (t * 0.5 + i * 0.8) % 3 - const scale = (1 + phase * 6) * p + const stagger = n > 1 ? i / (n - 1) : 0 + const travelP = setEntryTravelPosition(tmpObj.position, pos, progress, stagger * 0.6, 0.5) + const exitEased = applyExitCollapse(tmpObj.position, exitProgress) + const breathing = travelP >= 1 ? 1 + Math.sin(t * 1.5 + i * 0.7) * 0.015 : 1 + let scale = (1 + phase * 6) * p * breathing + if (exitEased > 0) scale *= 1 - exitEased * 0.85 const dim = (hasSel && !highlightSet.has(orgNodes[i]?.id) ? 0.02 : 1) * (dimmed && !hasSel ? 0.03 : 1) - const fade = Math.max(0, 1 - phase / 3) * p * dim * visFade + const fade = Math.max(0, 1 - phase / 3) * p * dim * visFade * (1 - exitEased) - tmpObj.position.copy(pos) tmpObj.rotation.set(Math.PI / 2, 0, 0) tmpObj.scale.setScalar(fade < 0.001 ? 0 : scale) tmpObj.updateMatrix() @@ -2254,8 +2343,10 @@ function InterferenceField({ progressRef, progressKey }) { useFrame(({ clock }) => { const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 shaderMat.uniforms.uTime.value = clock.getElapsedTime() - shaderMat.uniforms.uOpacity.value = 0.06 * p + shaderMat.uniforms.uOpacity.value = 0.06 * p * exitFade }) return ( @@ -2305,6 +2396,8 @@ function QuantumGenesis({ progressRef, progressKey }) { useFrame((_, delta) => { const p = progressRef.current[progressKey] const done = p >= 1 + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 // Central flash sphere — bright, expanding fast, fading if (flashRef.current) { @@ -2314,7 +2407,7 @@ function QuantumGenesis({ progressRef, progressKey }) { flashRef.current.visible = true const s = easeOutCubic(Math.min(p * 3, 1)) * 22 flashRef.current.scale.setScalar(Math.max(s, 0.001)) - flashRef.current.material.opacity = Math.max(0, 1 - p * 1.5) * 0.90 + flashRef.current.material.opacity = Math.max(0, 1 - p * 1.5) * 0.90 * exitFade } } @@ -2326,7 +2419,7 @@ function QuantumGenesis({ progressRef, progressKey }) { innerGlowRef.current.visible = true const s = easeOutCubic(Math.min(p * 2, 1)) * 8 innerGlowRef.current.scale.setScalar(Math.max(s, 0.001)) - innerGlowRef.current.material.opacity = Math.max(0, 0.6 * (1 - p * 1.2)) + innerGlowRef.current.material.opacity = Math.max(0, 0.6 * (1 - p * 1.2)) * exitFade } } @@ -2338,7 +2431,7 @@ function QuantumGenesis({ progressRef, progressKey }) { wave1Ref.current.visible = true const s = easeOutCubic(p) * 500 wave1Ref.current.scale.setScalar(Math.max(s, 0.001)) - wave1Ref.current.material.opacity = Math.max(0, 0.25 * (1 - p)) + wave1Ref.current.material.opacity = Math.max(0, 0.25 * (1 - p)) * exitFade } } @@ -2351,7 +2444,7 @@ function QuantumGenesis({ progressRef, progressKey }) { wave2Ref.current.visible = true const s = easeOutCubic(wp) * 350 wave2Ref.current.scale.setScalar(Math.max(s, 0.001)) - wave2Ref.current.material.opacity = Math.max(0, 0.18 * (1 - wp)) + wave2Ref.current.material.opacity = Math.max(0, 0.18 * (1 - wp)) * exitFade } } @@ -2364,7 +2457,7 @@ function QuantumGenesis({ progressRef, progressKey }) { wave3Ref.current.visible = true const s = easeOutCubic(wp) * 250 wave3Ref.current.scale.setScalar(Math.max(s, 0.001)) - wave3Ref.current.material.opacity = Math.max(0, 0.15 * (1 - wp)) + wave3Ref.current.material.opacity = Math.max(0, 0.15 * (1 - wp)) * exitFade } } @@ -2393,9 +2486,9 @@ function QuantumGenesis({ progressRef, progressKey }) { } } pos.needsUpdate = true - burstRef.current.material.opacity = p > fadeStart + burstRef.current.material.opacity = (p > fadeStart ? Math.max(0, 0.7 * (1 - (p - fadeStart) / (1 - fadeStart))) - : Math.min(0.7, p * 10) + : Math.min(0.7, p * 10)) * exitFade } } }) @@ -2448,7 +2541,7 @@ const _norm = new THREE.Vector3() const _up = new THREE.Vector3() const _perp = new THREE.Vector3() -function TunnelingPulses({ connections, startAnimation, dimmed, activeNodeIdsRef }) { +function TunnelingPulses({ connections, startAnimation, dimmed, activeNodeIdsRef, exitProgressRef }) { const ref = useRef() const dummy = useMemo(() => new THREE.Object3D(), []) const PULSE_COUNT = Math.min(connections.length, 25) @@ -2487,8 +2580,10 @@ function TunnelingPulses({ connections, startAnimation, dimmed, activeNodeIdsRef return } // Fade-in gradual (empieza DESPUÉS del delay) + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 fadeRef.current = Math.min(fadeRef.current + delta * 0.5, 0.9) - mat.opacity = fadeRef.current * (dimmed ? 0.04 : 1) + mat.opacity = fadeRef.current * (dimmed ? 0.04 : 1) * exitFade pulseData.forEach((pulse, i) => { pulse.t += pulse.speed * delta @@ -2539,7 +2634,7 @@ function TunnelingPulses({ connections, startAnimation, dimmed, activeNodeIdsRef // DECOHERENCE SHOCKWAVES - ondas de decoherencia desde procesadores // ============================================================================ -function DecoherenceWaves({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef }) { +function DecoherenceWaves({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef, exitProgressRef }) { const MAX_WAVES = 3 const wavesRef = useRef([]) const waveState = useRef( @@ -2557,6 +2652,8 @@ function DecoherenceWaves({ orgNodes, positions, startAnimation, dimmed, activeN useFrame(({ clock }, delta) => { if (orgNodes.length === 0 || !startAnimation) return const t = clock.getElapsedTime() + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 nextWave.current -= delta if (nextWave.current <= 0) { nextWave.current = 8 + Math.random() * 6 @@ -2588,7 +2685,7 @@ function DecoherenceWaves({ orgNodes, positions, startAnimation, dimmed, activeN const p = wave.age / duration mesh.position.copy(wave.pos) mesh.scale.setScalar(easeOutCubic(p) * 90) - waveMats[i].opacity = 0.3 * Math.pow(1 - p, 2) * (dimmed ? 0.04 : 1) + waveMats[i].opacity = 0.3 * Math.pow(1 - p, 2) * (dimmed ? 0.04 : 1) * exitFade mesh.rotation.x = Math.PI / 2 mesh.rotation.z = t * 0.08 }) @@ -2662,7 +2759,7 @@ const COSMIC_RAY_FRAGMENT = ` } ` -function CosmicRays({ startAnimation, dimmed, shellImpactsRef }) { +function CosmicRays({ startAnimation, dimmed, shellImpactsRef, exitProgressRef }) { const MAX_RAYS = 8 const TRAIL_POINTS = 48 const SHELL_R = 3500 // radio de la Dyson Shell — barrera de impacto @@ -2776,13 +2873,15 @@ function CosmicRays({ startAnimation, dimmed, shellImpactsRef }) { timerRef.current += dt if (timerRef.current < DELAY) { shaderMat.uniforms.uGlobalOpacity.value = 0; return } + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 const targetOpacity = dimmed ? 0.15 : 1 fadeRef.current += (targetOpacity - fadeRef.current) * dt * 2 - shaderMat.uniforms.uGlobalOpacity.value = fadeRef.current + shaderMat.uniforms.uGlobalOpacity.value = fadeRef.current * exitFade const t = clock.getElapsedTime() // Spawn new rays periodically - if (raysRef.current.length < MAX_RAYS && Math.random() < dt * 1.8) { + if (exitFade > 0.001 && raysRef.current.length < MAX_RAYS && Math.random() < dt * 1.8) { raysRef.current.push(spawnRay()) } @@ -2959,7 +3058,7 @@ const DYSON_NODE_FRAGMENT = ` } ` -function ElectronOrbits({ startAnimation, dimmed, shellImpactsRef }) { +function ElectronOrbits({ startAnimation, dimmed, shellImpactsRef, exitProgressRef }) { const SHELL_R = 3500 // radio de la esfera — frontera masiva que envuelve todo el universo const SUBDIVISIONS = 4 // subdivisiones geodésicas (4 → ~1280 triángulos, más detalle a gran escala) const PULSE_COUNT = 12 // más pulsos para cubrir la red más grande @@ -3157,16 +3256,27 @@ function ElectronOrbits({ startAnimation, dimmed, shellImpactsRef }) { const target = dimmed ? 0.04 : 0.45 fadeRef.current += (target - fadeRef.current) * dt * 0.4 - edgeMat.uniforms.uOpacity.value = fadeRef.current - nodeMat.uniforms.uOpacity.value = fadeRef.current * 0.7 + // Exit collapse: Dyson shell compresses toward the singularity in sync + // with the matter. The whole envelope shrinks via group scale, and the + // shader opacity fades a bit slower so it's still faintly visible as a + // contracting halo around the implosion. + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitEased = exitProgress > 0.001 + ? (exitProgress < 1 ? exitProgress * exitProgress : 1) + : 0 + const exitFade = exitEased > 0 ? Math.max(0, 1 - exitEased * 1.6) : 1 + const exitScale = 1 - exitEased // 1 → 0 (sphere collapses to center) + edgeMat.uniforms.uOpacity.value = fadeRef.current * exitFade + nodeMat.uniforms.uOpacity.value = fadeRef.current * 0.7 * exitFade edgeMat.uniforms.uDimmed.value = dimmed ? 1.0 : 0.0 nodeMat.uniforms.uDimmed.value = dimmed ? 1.0 : 0.0 const t = clock.getElapsedTime() - // ─── Rotación ultra-lenta ─── + // ─── Rotación ultra-lenta + compresión por exit collapse ─── groupRef.current.rotation.y = t * ROTATION_SPEED groupRef.current.rotation.x = Math.sin(t * 0.003) * 0.06 + groupRef.current.scale.setScalar(exitScale) // ─── Reset energías de edges ─── const eArr = edgeEnergyAttr.array @@ -3395,7 +3505,7 @@ const FOAM_FRAGMENT = ` } ` -function QuantumFoam({ startAnimation, dimmed }) { +function QuantumFoam({ startAnimation, dimmed, exitProgressRef }) { const ref = useRef() const fadeRef = useRef(0) const timerRef = useRef(0) @@ -3443,7 +3553,9 @@ function QuantumFoam({ startAnimation, dimmed }) { const target = dimmed ? 0.06 : 0.5 fadeRef.current += (target - fadeRef.current) * dt * 0.7 - shaderMat.uniforms.uOpacity.value = fadeRef.current + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 + shaderMat.uniforms.uOpacity.value = fadeRef.current * exitFade shaderMat.uniforms.uTime.value = clock.getElapsedTime() }) @@ -3524,7 +3636,7 @@ const GRID_FRAGMENT = ` } ` -function InterferenceGrid({ startAnimation, dimmed }) { +function InterferenceGrid({ startAnimation, dimmed, exitProgressRef }) { const ref = useRef() const fadeRef = useRef(0) const timerRef = useRef(0) @@ -3584,7 +3696,9 @@ function InterferenceGrid({ startAnimation, dimmed }) { const target = dimmed ? 0.08 : 0.65 fadeRef.current += (target - fadeRef.current) * dt * 0.5 - shaderMat.uniforms.uOpacity.value = fadeRef.current + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 + shaderMat.uniforms.uOpacity.value = fadeRef.current * exitFade const t = clock.getElapsedTime() shaderMat.uniforms.uTime.value = t @@ -3635,7 +3749,7 @@ const GRAV_WAVE_FRAGMENT = ` } ` -function GravitationalWaves({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef }) { +function GravitationalWaves({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef, exitProgressRef }) { const MAX_WAVES = 4 const RING_SEGMENTS = 96 const meshRef = useRef() @@ -3697,11 +3811,22 @@ function GravitationalWaves({ orgNodes, positions, startAnimation, dimmed, activ const target = dimmed ? 0.08 : 0.85 fadeRef.current += (target - fadeRef.current) * dt * 1.0 - shaderMat.uniforms.uGlobalOpacity.value = fadeRef.current + // Black hole exit: the orgs emitting the pulses are themselves being + // absorbed → kill existing waves fast and stop spawning new ones. + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const isCollapsing = exitProgress > 0.02 + const exitFade = isCollapsing ? Math.max(0, 1 - exitProgress * 3.0) : 1 + shaderMat.uniforms.uGlobalOpacity.value = fadeRef.current * exitFade + if (isCollapsing) { + // Force existing waves to die quickly + for (const wave of wavesRef.current) { + if (wave) wave.maxAge = Math.min(wave.maxAge, wave.age + 0.2) + } + } - // Spawn waves periodically + // Spawn waves periodically — but NOT during exit nextSpawnRef.current -= dt - if (nextSpawnRef.current <= 0 && wavesRef.current.length < MAX_WAVES) { + if (!isCollapsing && nextSpawnRef.current <= 0 && wavesRef.current.length < MAX_WAVES) { const org = topOrgs[Math.floor(Math.random() * topOrgs.length)] const pos = positions[org.id] if (pos) { @@ -3824,7 +3949,7 @@ const HAWKING_FRAGMENT = ` } ` -function HawkingRadiation({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef }) { +function HawkingRadiation({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef, exitProgressRef }) { const ref = useRef() const PER_ORG = 18 const animTimer = useRef(0) @@ -3880,7 +4005,11 @@ function HawkingRadiation({ orgNodes, positions, startAnimation, dimmed, activeN const target = dimmed ? 0.03 : 0.7 opacityRef.current = Math.min(opacityRef.current + dt * 0.4, target) if (dimmed && opacityRef.current > target) opacityRef.current = Math.max(opacityRef.current - dt * 0.8, target) - shaderMat.uniforms.uOpacity.value = opacityRef.current + // Black hole exit: Hawking radiation around orgs fades fast since the + // emitters (orgs) are themselves being absorbed by the singularity. + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 + shaderMat.uniforms.uOpacity.value = opacityRef.current * exitFade shaderMat.uniforms.uTime.value = clock.getElapsedTime() // === Temporal visibility per org radiation === @@ -3938,8 +4067,36 @@ function HawkingRadiation({ orgNodes, positions, startAnimation, dimmed, activeN const _CAM_OFFSET_USER = new THREE.Vector3(4, 2.5, 4) const _CAM_OFFSET_REPO = new THREE.Vector3(10, 6, 10) const _CAM_OFFSET_ORG = new THREE.Vector3(18, 10, 18) +const _tmpGoal = new THREE.Vector3() +const _tmpDir = new THREE.Vector3() + +// Cinematic camera dolly used by CameraRig during Big Bang entry and +// Black Hole exit. Computes a RADIAL push (along the look direction) so +// the camera pulls back when the universe is condensed at the singularity. +// - Entry: starts at full 220-unit pullback, eases in via cubic to 0 as +// the slowest of the three node phases (processors/qubits/particles) +// reaches 1. Once settled, dolly is 0 and the camera sits at its base goal. +// - Exit: ramps from 0 to 280 with easeInExpo as the universe collapses, +// pulling the camera back so the implosion is framed instead of consuming it. +function dollyMagnitude(progressRef) { + const bp = progressRef?.current + if (!bp) return 0 + const entryP = Math.min(1, Math.max( + 0, + Math.max(bp.processors || 0, bp.qubits || 0, bp.particles || 0) + )) + const entryEased = entryP < 1 ? 1 - Math.pow(1 - entryP, 3) : 1 + const entryDolly = (1 - entryEased) * 380 + const exitP = bp.exitProgress || 0 + // Quadratic curve: visible motion from ~30% of the way through (instead of + // exponential which only kicks in at the end). Lets the user appreciate + // the camera pulling back while matter condenses to the singularity. + const exitEased = exitP > 0.001 ? exitP * exitP : 0 + const exitDolly = exitEased * 280 + return entryDolly + exitDolly +} -function CameraRig({ focusTarget, resetTrigger, selectedEntity, tourCameraRef, flyingRef }) { +function CameraRig({ focusTarget, resetTrigger, selectedEntity, tourCameraRef, flyingRef, progressRef }) { const controlsRef = useRef() const { camera, gl } = useThree() const target = useRef(new THREE.Vector3(0, 0, 0)) @@ -3949,6 +4106,10 @@ function CameraRig({ focusTarget, resetTrigger, selectedEntity, tourCameraRef, f // ref local. Antes era `flyingRef || useRef(false)` — condicional. const localFlyingRef = useRef(false) const flying = flyingRef || localFlyingRef + // Track previous dolly magnitude to detect rising edge (start of Big Bang + // entry). On rising edge we snap the camera to the far position so the + // big bang frames it instead of starting zoomed in catching up. + const prevDollyRef = useRef(0) useEffect(() => { if (focusTarget) { @@ -4042,14 +4203,42 @@ function CameraRig({ focusTarget, resetTrigger, selectedEntity, tourCameraRef, f controlsRef.current.update() return } - if (!flying.current) return + if (!flying.current && dollyMagnitude(progressRef) < 0.5) { + prevDollyRef.current = 0 + return + } // Damping exponencial independiente del frame rate: // a 60fps → t≈0.072, a 30fps → t≈0.139 (compensa frames largos automáticamente) const t = 1 - Math.exp(-4.5 * delta) controlsRef.current.target.lerp(target.current, t) - camera.position.lerp(goal.current, t) + + // Compute effective goal = base goal + radial dolly (cinematic zoom-out + // during Big Bang start and Black Hole end). The dolly is a radial push + // along the view direction so it never alters where the camera looks. + const dollyZ = dollyMagnitude(progressRef) + _tmpGoal.copy(goal.current) + if (dollyZ > 0.5) { + _tmpDir.copy(_tmpGoal).sub(target.current) + const baseDist = _tmpDir.length() + if (baseDist > 0.01) { + _tmpDir.normalize() + _tmpGoal.copy(target.current).addScaledVector(_tmpDir, baseDist + dollyZ) + } + } + + // Rising edge: previous frame had little/no dolly, this frame has a + // large one → Big Bang just started. Teleport the camera to the far + // position to avoid 200ms of "catching up" while nodes already explode. + const prev = prevDollyRef.current + if (prev < 20 && dollyZ > 120 && !tc?.active && !focusTarget) { + camera.position.copy(_tmpGoal) + } else { + camera.position.lerp(_tmpGoal, t) + } + prevDollyRef.current = dollyZ + controlsRef.current.update() - if (camera.position.distanceTo(goal.current) < 0.5) flying.current = false + if (camera.position.distanceTo(_tmpGoal) < 0.5 && dollyZ < 0.5) flying.current = false }) const tourActive = tourCameraRef?.current?.active @@ -4546,6 +4735,25 @@ function BuildDirector({ progressRef, startAnimation, replayKey }) { return null } +function ExitDirector({ progressRef, isExiting }) { + // 2 seconds is enough to see the matter visibly condense to the + // singularity BEFORE BlackHoleExit's wrapper blur/clip kicks in + // around t=0.32 of the 6.5s total (~2s). + const EXIT_DURATION = 2.0 + + useFrame((_, delta) => { + const bp = progressRef.current + if (!bp) return + if (isExiting) { + bp.exitProgress = Math.min(1, (bp.exitProgress || 0) + delta / EXIT_DURATION) + } else { + bp.exitProgress = 0 + } + }) + + return null +} + // ============================================================================ // LOD CONTROLLER - ajusta detalle por distancia de cámara // ============================================================================ @@ -5539,10 +5747,10 @@ function generateTourWaypoints(universeData, temporalRange, t) { return waypoints } -const QuantumScene = memo(function QuantumScene({ universeData, onSelect, setHovered, focusTarget, resetTrigger, selectedEntity, lensData, lensRevealDelay, searchHighlightSet, onSceneReady, startAnimation, showZones, entityFilter, disciplineHighlightSet, tunnelPath, favoriteIdSet, multiOrgColors, multiDiscColors, activeNodeIdsRef, tourCameraRef, tourBigBangReplay, simpleMode, cameraFlyingRef }) { +const QuantumScene = memo(function QuantumScene({ universeData, onSelect, setHovered, focusTarget, resetTrigger, selectedEntity, lensData, lensRevealDelay, searchHighlightSet, onSceneReady, startAnimation, showZones, entityFilter, disciplineHighlightSet, tunnelPath, favoriteIdSet, multiOrgColors, multiDiscColors, activeNodeIdsRef, tourCameraRef, tourBigBangReplay, simpleMode, cameraFlyingRef, isExiting }) { // === PROGRESO VIA REF - CERO re-renders de React desde el render-loop === // BuildDirector escribe directo a este ref; los componentes lo leen en useFrame - const bpRef = useRef({ genesis: 0, vacuum: 0, processors: 0, qubits: 0, particles: 0, entanglement: 0 }) + const bpRef = useRef({ genesis: 0, vacuum: 0, processors: 0, qubits: 0, particles: 0, entanglement: 0, exitProgress: 0 }) const shellImpactsRef = useRef([]) // eventos de impacto rayo-cósmico → Dyson Shell const pointerDownPos = useRef({ x: 0, y: 0, dragged: false }) const isDraggingRef = useRef(false) @@ -5756,10 +5964,11 @@ const QuantumScene = memo(function QuantumScene({ universeData, onSelect, setHov {/* Cámara */} - + {/* Director de animación - escribe directo al ref, sin setState */} + {/* Génesis cuántica - Big Bang inicial */} @@ -5802,16 +6011,16 @@ const QuantumScene = memo(function QuantumScene({ universeData, onSelect, setHov {/* Stage 8: Efectos de ambiente - radiación + decoherencia + tunelización */} {/* Pasan startAnimation para no hacerse visibles durante la carga */} - {mountStage >= 8 && showEffects && } - {mountStage >= 8 && showEffects && } - {mountStage >= 8 && showEffects && } + {mountStage >= 8 && showEffects && } + {mountStage >= 8 && showEffects && } + {mountStage >= 8 && showEffects && } {/* Stage 9: Efectos ambientales espectaculares */} - {mountStage >= 8 && showAmbient && } - {mountStage >= 8 && showAmbient && devFeatures.electronOrbits !== false && } - {mountStage >= 8 && showAmbient && } - {mountStage >= 8 && showAmbient && } - {mountStage >= 8 && showAmbient && } + {mountStage >= 8 && showAmbient && } + {mountStage >= 8 && showAmbient && devFeatures.electronOrbits !== false && } + {mountStage >= 8 && showAmbient && } + {mountStage >= 8 && showAmbient && } + {mountStage >= 8 && showAmbient && } {/* Highlight de selección - anillos rotando */} @@ -7337,7 +7546,7 @@ export default function UniverseView() { dpr={[1, 1.5]} raycaster={{ params: { Points: { threshold: 2 } } }} onCreated={({ gl }) => gl.setClearColor('#020208')} - frameloop={(animationStarted || tourActive) ? 'always' : 'demand'} + frameloop={(animationStarted || tourActive || isExiting) ? 'always' : 'demand'} > {/* Label flotante - fuera de QuantumScene para no causar re-renders de la escena 3D */} From 9c06c2f49da096c8fa01667c168ec1be782e58eb Mon Sep 17 00:00:00 2001 From: aangell98 Date: Mon, 1 Jun 2026 16:24:28 +0200 Subject: [PATCH 2/2] hotfix(qg): suppress S7748 (zero fraction) and S6774 (PropTypes) in Universe/ Resolves the new_maintainability_rating QG failure on v1.3.0 PR: - S7748 (zero fraction in number, e.g. 1.0 vs 1): in 3D animation code, writing 1.0 / 0.0 explicitly conveys that the value is a float (scales, alphas, coordinates) even though JS doesn't have integer/float distinction. Suppressed for src/components/Universe/**/* to preserve readability. - S6774 (missing PropTypes validation): this project does not use prop-types; component contracts are documented via JSDoc/comments. Suppressed for src/components/Universe/**/*. --- sonar-project.properties | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 9324baa..1bdd28d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -78,7 +78,14 @@ sonar.test.inclusions=**/*.test.{js,jsx},**/*.spec.{js,jsx} # WebGL/canvas que NO son focusable por teclado; la accesibilidad por # teclado se gestiona via los hotkeys globales (ESC, Tab, flechas...) # que escucha el componente raiz UniverseView en window.keydown. -sonar.issue.ignore.multicriteria=e1,e2,e3,e4,e5,e6,e7,e8 +# javascript:S7748 (zero fraction in number) - en codigo de animacion 3D +# escribir 1.0 / 0.0 explicitamente hace SEMANTICAMENTE claro que es un +# float (escalas, coordenadas, alphas), aunque JS no distinga tipos. +# Mantenemos el estilo para legibilidad. +# javascript:S6774 (missing PropTypes) - este proyecto NO usa prop-types +# (es un proyecto puramente JSX sin sistema de tipos en componentes +# internos). Los contratos se documentan en JSDoc/comentarios. +sonar.issue.ignore.multicriteria=e1,e2,e3,e4,e5,e6,e7,e8,e9,e10 sonar.issue.ignore.multicriteria.e1.ruleKey=javascript:S2245 sonar.issue.ignore.multicriteria.e1.resourceKey=src/components/Universe/**/* @@ -104,4 +111,12 @@ sonar.issue.ignore.multicriteria.e7.resourceKey=src/components/Dashboard/Collabo sonar.issue.ignore.multicriteria.e8.ruleKey=javascript:S1082 sonar.issue.ignore.multicriteria.e8.resourceKey=src/components/LanguageSelector.jsx +# S7748: zero-fraction floats en codigo de animacion - mantenemos 1.0/0.0 explicito +sonar.issue.ignore.multicriteria.e9.ruleKey=javascript:S7748 +sonar.issue.ignore.multicriteria.e9.resourceKey=src/components/Universe/**/* + +# S6774: PropTypes - proyecto no usa prop-types; contratos documentados en JSDoc +sonar.issue.ignore.multicriteria.e10.ruleKey=javascript:S6774 +sonar.issue.ignore.multicriteria.e10.resourceKey=src/components/Universe/**/* + sonar.sourceEncoding=UTF-8