fix: starfield visibility on load and WebGL fail-soft guard
- ParticleBuildText: self-scheduling rAF loop (previous one-shot tick left the canvas blank until scroll), deterministic sampleUntilBuilt warm-up, ambient star layer spread across the whole card with twinkle, random drift and a birth/death lifecycle (4-10s TTL with 800ms fades and respawn) - ControlRoomGlobe: fail-soft WebGL guard (webgl2/webgl check + try/catch around createGlobe) so an unavailable or poisoned context renders the fallback instead of crashing the hero tree
This commit is contained in:
1 parent
b791b7de9e
commit
957c58b69a
2 files changed
+177
-58
No files matched your search
@@ -125,26 +125,50 @@ export function ControlRoomGlobe({
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const globe = createGlobe(canvas, {
|
// Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon
|
||||||
...config,
|
// lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto.
|
||||||
width: width * 2,
|
let globe: ReturnType<typeof createGlobe> | null = null;
|
||||||
height: width * 2,
|
try {
|
||||||
onRender: (state) => {
|
const supportsWebGL =
|
||||||
if (!isVisibleRef.current || document.hidden) return;
|
canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||||
if (autoRotate && !pointerInteracting.current) {
|
if (supportsWebGL) {
|
||||||
phiRef.current += rotationSpeed;
|
globe = createGlobe(canvas, {
|
||||||
thetaPhaseRef.current += rotationSpeed * 0.9;
|
...config,
|
||||||
}
|
width: width * 2,
|
||||||
state.phi = phiRef.current + rs.get();
|
height: width * 2,
|
||||||
// Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical
|
onRender: (state) => {
|
||||||
state.theta =
|
if (!isVisibleRef.current || document.hidden) return;
|
||||||
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
|
if (autoRotate && !pointerInteracting.current) {
|
||||||
Math.sin(thetaPhaseRef.current) * 0.35 +
|
phiRef.current += rotationSpeed;
|
||||||
thetaDragRef.current;
|
thetaPhaseRef.current += rotationSpeed * 0.9;
|
||||||
state.width = width * 2;
|
}
|
||||||
state.height = width * 2;
|
state.phi = phiRef.current + rs.get();
|
||||||
},
|
// Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical
|
||||||
});
|
state.theta =
|
||||||
|
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
|
||||||
|
Math.sin(thetaPhaseRef.current) * 0.35 +
|
||||||
|
thetaDragRef.current;
|
||||||
|
state.width = width * 2;
|
||||||
|
state.height = width * 2;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
globe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globe) {
|
||||||
|
// Sem globo: fallback visível (gradiente do card permanece), página viva.
|
||||||
|
canvas.style.opacity = "1";
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
document.removeEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
handleVisibilityChange,
|
||||||
|
);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (canvasRef.current) {
|
if (canvasRef.current) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
@@ -20,11 +20,15 @@ type Particle = {
|
|||||||
ox: number;
|
ox: number;
|
||||||
oy: number;
|
oy: number;
|
||||||
delay: number;
|
delay: number;
|
||||||
color: string;
|
fillStyle: string;
|
||||||
|
baseAlpha: number;
|
||||||
|
ambient: boolean;
|
||||||
size: number;
|
size: number;
|
||||||
phase: number;
|
phase: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
amp: number;
|
amp: number;
|
||||||
|
ttl: number;
|
||||||
|
birth: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TrackingCapableContext = CanvasRenderingContext2D & {
|
type TrackingCapableContext = CanvasRenderingContext2D & {
|
||||||
@@ -97,6 +101,8 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
let running = false;
|
let running = false;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let resampleTimer = 0;
|
let resampleTimer = 0;
|
||||||
|
let sampleRetryTimer = 0;
|
||||||
|
let sampleAttempts = 0;
|
||||||
let particles: Particle[] = [];
|
let particles: Particle[] = [];
|
||||||
let hasAssembled = false;
|
let hasAssembled = false;
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
@@ -112,7 +118,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
const paperRgb =
|
const paperRgb =
|
||||||
hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER;
|
hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER;
|
||||||
|
|
||||||
// next/font gera nome de família com hash: resolver pela classe font-display real
|
// next/font gera nome de famÃlia com hash: resolver pela classe font-display real
|
||||||
const probe = document.createElement("span");
|
const probe = document.createElement("span");
|
||||||
probe.className = "font-display";
|
probe.className = "font-display";
|
||||||
probe.style.position = "absolute";
|
probe.style.position = "absolute";
|
||||||
@@ -141,7 +147,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
offscreen.height = canvas.height;
|
offscreen.height = canvas.height;
|
||||||
const offCtx = offscreen.getContext("2d");
|
const offCtx = offscreen.getContext("2d");
|
||||||
if (!offCtx) return;
|
if (!offCtx) return;
|
||||||
// Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro
|
// Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro
|
||||||
offCtx.scale(dpr, dpr);
|
offCtx.scale(dpr, dpr);
|
||||||
|
|
||||||
const maxWidth = width * 0.92;
|
const maxWidth = width * 0.92;
|
||||||
@@ -187,7 +193,46 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targets.length === 0) return;
|
// Ambient stars cover the whole card so the background reads as a living
|
||||||
|
// starfield even where the globe covers the headline text shape
|
||||||
|
const makeParticle = (
|
||||||
|
tx: number,
|
||||||
|
ty: number,
|
||||||
|
ambient: boolean,
|
||||||
|
): Particle => {
|
||||||
|
const useSlash = Math.random() < (ambient ? 0.45 : 0.72);
|
||||||
|
const rgb = useSlash ? slashRgb : paperRgb;
|
||||||
|
return {
|
||||||
|
tx,
|
||||||
|
ty,
|
||||||
|
sx: hasAssembled ? tx : (Math.random() * 1.3 - 0.15) * canvas.width,
|
||||||
|
sy: hasAssembled ? ty : (Math.random() * 1.3 - 0.15) * canvas.height,
|
||||||
|
ox: 0,
|
||||||
|
oy: 0,
|
||||||
|
delay: Math.random() * 0.35,
|
||||||
|
fillStyle: `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`,
|
||||||
|
baseAlpha: ambient
|
||||||
|
? 0.6 + Math.random() * 0.4
|
||||||
|
: useSlash
|
||||||
|
? 0.45 + Math.random() * 0.3
|
||||||
|
: 0.4 + Math.random() * 0.2,
|
||||||
|
ambient,
|
||||||
|
size:
|
||||||
|
(ambient
|
||||||
|
? Math.random() < 0.7
|
||||||
|
? 2
|
||||||
|
: 3
|
||||||
|
: Math.random() < 0.85
|
||||||
|
? 1
|
||||||
|
: 2) * dpr,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
speed: 0.0005 + Math.random() * 0.0007,
|
||||||
|
amp: (ambient ? 2 + Math.random() * 4 : 0.6 + Math.random()) * dpr,
|
||||||
|
ttl: 4000 + Math.random() * 6000,
|
||||||
|
// birth negativo: estagia os ciclos de vida para o spawn/death ja rodar disperso no load
|
||||||
|
birth: -Math.random() * 10000,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const desired = Math.round(
|
const desired = Math.round(
|
||||||
Math.min(
|
Math.min(
|
||||||
@@ -196,32 +241,21 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
particles = Array.from({ length: desired }, () => {
|
const textParticles = Array.from({ length: desired }, () => {
|
||||||
const target = targets[Math.floor(Math.random() * targets.length)];
|
const target = targets[Math.floor(Math.random() * targets.length)];
|
||||||
const useSlash = Math.random() < 0.72;
|
return makeParticle(target[0], target[1], false);
|
||||||
const rgb = useSlash ? slashRgb : paperRgb;
|
|
||||||
const alpha = useSlash
|
|
||||||
? 0.28 + Math.random() * 0.22
|
|
||||||
: 0.25 + Math.random() * 0.15;
|
|
||||||
return {
|
|
||||||
tx: target[0],
|
|
||||||
ty: target[1],
|
|
||||||
sx: hasAssembled
|
|
||||||
? target[0]
|
|
||||||
: (Math.random() * 1.3 - 0.15) * canvas.width,
|
|
||||||
sy: hasAssembled
|
|
||||||
? target[1]
|
|
||||||
: (Math.random() * 1.3 - 0.15) * canvas.height,
|
|
||||||
ox: 0,
|
|
||||||
oy: 0,
|
|
||||||
delay: Math.random() * 0.35,
|
|
||||||
color: `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha.toFixed(2)})`,
|
|
||||||
size: (Math.random() < 0.85 ? 1 : 2) * dpr,
|
|
||||||
phase: Math.random() * Math.PI * 2,
|
|
||||||
speed: 0.0005 + Math.random() * 0.0007,
|
|
||||||
amp: (0.6 + Math.random()) * dpr,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ambientCount = Math.round(
|
||||||
|
Math.min(520, Math.max(200, (width * height) / 2500)),
|
||||||
|
);
|
||||||
|
const ambient: Particle[] = Array.from({ length: ambientCount }, () => {
|
||||||
|
const tx = Math.random() * canvas.width;
|
||||||
|
const ty = Math.random() * canvas.height;
|
||||||
|
return makeParticle(tx, ty, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
particles = [...textParticles, ...ambient];
|
||||||
};
|
};
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
@@ -267,7 +301,33 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
particle.ox *= 0.86;
|
particle.ox *= 0.86;
|
||||||
particle.oy *= 0.86;
|
particle.oy *= 0.86;
|
||||||
|
|
||||||
ctx.fillStyle = particle.color;
|
const fadeIn = Math.min(elapsed / 1200, 1);
|
||||||
|
const twinkle = particle.ambient
|
||||||
|
? 0.55 + 0.45 * Math.sin(elapsed * 0.004 + particle.phase)
|
||||||
|
: 0.82 + 0.18 * Math.sin(elapsed * particle.speed + particle.phase);
|
||||||
|
let lifeFade = 1;
|
||||||
|
if (particle.ambient) {
|
||||||
|
let life = elapsed - particle.birth;
|
||||||
|
if (life > particle.ttl) {
|
||||||
|
particle.tx = Math.random() * canvas.width;
|
||||||
|
particle.ty = Math.random() * canvas.height;
|
||||||
|
particle.sx = particle.tx;
|
||||||
|
particle.sy = particle.ty;
|
||||||
|
particle.phase = Math.random() * Math.PI * 2;
|
||||||
|
particle.baseAlpha = 0.6 + Math.random() * 0.4;
|
||||||
|
particle.size = (Math.random() < 0.7 ? 2 : 3) * dpr;
|
||||||
|
particle.ttl = 4000 + Math.random() * 6000;
|
||||||
|
particle.birth = elapsed;
|
||||||
|
life = 0;
|
||||||
|
}
|
||||||
|
// fade-in de 800ms ao nascer, fade-out nos ultimos 800ms de vida
|
||||||
|
lifeFade = Math.min(life / 800, (particle.ttl - life) / 800, 1);
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = Math.min(
|
||||||
|
1,
|
||||||
|
particle.baseAlpha * fadeIn * twinkle * lifeFade,
|
||||||
|
);
|
||||||
|
ctx.fillStyle = particle.fillStyle;
|
||||||
ctx.fillRect(
|
ctx.fillRect(
|
||||||
baseX + driftX + particle.ox,
|
baseX + driftX + particle.ox,
|
||||||
baseY + driftY + particle.oy,
|
baseY + driftY + particle.oy,
|
||||||
@@ -276,21 +336,27 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
if (allSettled && particles.length > 0) hasAssembled = true;
|
if (allSettled && particles.length > 0) hasAssembled = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawStaticFrame = () => {
|
const drawStaticFrame = () => {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
for (const particle of particles) {
|
for (const particle of particles) {
|
||||||
ctx.fillStyle = particle.color;
|
ctx.fillStyle = particle.fillStyle;
|
||||||
|
ctx.globalAlpha = particle.baseAlpha;
|
||||||
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
|
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
|
||||||
}
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
|
if (!running) return;
|
||||||
elapsed += Math.min(now - lastTime, 50);
|
elapsed += Math.min(now - lastTime, 50);
|
||||||
lastTime = now;
|
lastTime = now;
|
||||||
render();
|
render();
|
||||||
|
rafId = window.requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncLoop = () => {
|
const syncLoop = () => {
|
||||||
@@ -305,11 +371,27 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Warm-up determinÃstico: corrida entre fontes/layout pode deixar o
|
||||||
|
// container com 0px no primeiro sample; re-tenta até existirem partÃculas
|
||||||
|
const sampleUntilBuilt = (onBuilt?: () => void) => {
|
||||||
|
sample();
|
||||||
|
if (particles.length > 0 || cancelled) {
|
||||||
|
onBuilt?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sampleAttempts++ > 50) return;
|
||||||
|
sampleRetryTimer = window.setTimeout(
|
||||||
|
() => sampleUntilBuilt(onBuilt),
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleResample = () => {
|
const scheduleResample = () => {
|
||||||
window.clearTimeout(resampleTimer);
|
window.clearTimeout(resampleTimer);
|
||||||
resampleTimer = window.setTimeout(() => {
|
resampleTimer = window.setTimeout(() => {
|
||||||
sample();
|
sampleUntilBuilt(() => {
|
||||||
if (reducedMotion) drawStaticFrame();
|
if (reducedMotion) drawStaticFrame();
|
||||||
|
});
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -332,21 +414,34 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
resizeObserver.observe(container);
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
|
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
|
||||||
fontsReady.then(() => {
|
fontsReady.then(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
hasAssembled = true;
|
hasAssembled = true;
|
||||||
sample();
|
sampleUntilBuilt(() => drawStaticFrame());
|
||||||
drawStaticFrame();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(resampleTimer);
|
window.clearTimeout(resampleTimer);
|
||||||
|
window.clearTimeout(sampleRetryTimer);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start determinÃstico: intersecting derivado de geometry no setup (o callback do IO
|
||||||
|
// pode chegar tarde/fora de ordem com fontsReady e deixar o loop rAF morto na carga)
|
||||||
|
const syncIntersecting = () => {
|
||||||
|
const current = container.getBoundingClientRect();
|
||||||
|
intersecting =
|
||||||
|
current.bottom > 0 &&
|
||||||
|
current.right > 0 &&
|
||||||
|
current.top < window.innerHeight &&
|
||||||
|
current.left < window.innerWidth;
|
||||||
|
};
|
||||||
|
syncIntersecting();
|
||||||
|
syncLoop();
|
||||||
|
|
||||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||||
intersecting = entries.some((entry) => entry.isIntersecting);
|
intersecting = entries.some((entry) => entry.isIntersecting);
|
||||||
syncLoop();
|
syncLoop();
|
||||||
@@ -365,14 +460,14 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|||||||
|
|
||||||
fontsReady.then(() => {
|
fontsReady.then(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
sample();
|
sampleUntilBuilt(() => syncLoop());
|
||||||
syncLoop();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.cancelAnimationFrame(rafId);
|
window.cancelAnimationFrame(rafId);
|
||||||
window.clearTimeout(resampleTimer);
|
window.clearTimeout(resampleTimer);
|
||||||
|
window.clearTimeout(sampleRetryTimer);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
intersectionObserver.disconnect();
|
intersectionObserver.disconnect();
|
||||||
window.removeEventListener("pointermove", handlePointerMove);
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
|||||||
Reference in new issue
Block a user