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
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 */}