- 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
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
const DEFAULT_TEXT =
|
|
"Engenharia de software para sistemas que precisam operar de verdade.";
|
|
|
|
const MAX_DPR = 1.5;
|
|
const ASSEMBLE_MS = 2000;
|
|
const REPEL_RADIUS_PX = 80;
|
|
const MIN_PARTICLES = 1200;
|
|
const MAX_PARTICLES = 2500;
|
|
const OFFSCREEN_POINTER = -100000;
|
|
|
|
type Particle = {
|
|
sx: number;
|
|
sy: number;
|
|
tx: number;
|
|
ty: number;
|
|
ox: number;
|
|
oy: number;
|
|
delay: number;
|
|
fillStyle: string;
|
|
baseAlpha: number;
|
|
ambient: boolean;
|
|
size: number;
|
|
phase: number;
|
|
speed: number;
|
|
amp: number;
|
|
ttl: number;
|
|
birth: number;
|
|
};
|
|
|
|
type TrackingCapableContext = CanvasRenderingContext2D & {
|
|
letterSpacing?: string;
|
|
};
|
|
|
|
type Rgb = [number, number, number];
|
|
|
|
const FALLBACK_SLASH: Rgb = [204, 255, 0];
|
|
const FALLBACK_PAPER: Rgb = [244, 242, 237];
|
|
|
|
function hexToRgb(hex: string): Rgb | null {
|
|
const value = hex.trim().replace("#", "");
|
|
const full =
|
|
value.length === 3
|
|
? value
|
|
.split("")
|
|
.map((char) => char + char)
|
|
.join("")
|
|
: value;
|
|
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
|
|
return [
|
|
Number.parseInt(full.slice(0, 2), 16),
|
|
Number.parseInt(full.slice(2, 4), 16),
|
|
Number.parseInt(full.slice(4, 6), 16),
|
|
];
|
|
}
|
|
|
|
function easeOutQuint(progress: number) {
|
|
return 1 - (1 - progress) ** 5;
|
|
}
|
|
|
|
function wrapLines(
|
|
ctx: CanvasRenderingContext2D,
|
|
text: string,
|
|
maxWidth: number,
|
|
) {
|
|
const lines: string[] = [];
|
|
let current = "";
|
|
for (const word of text.split(" ")) {
|
|
const candidate = current ? `${current} ${word}` : word;
|
|
if (!current || ctx.measureText(candidate).width <= maxWidth) {
|
|
current = candidate;
|
|
} else {
|
|
lines.push(current);
|
|
current = word;
|
|
}
|
|
}
|
|
if (current) lines.push(current);
|
|
return lines;
|
|
}
|
|
|
|
export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!container || !canvas) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
const reducedMotion = window.matchMedia(
|
|
"(prefers-reduced-motion: reduce)",
|
|
).matches;
|
|
|
|
let rafId = 0;
|
|
let running = false;
|
|
let cancelled = false;
|
|
let resampleTimer = 0;
|
|
let sampleRetryTimer = 0;
|
|
let sampleAttempts = 0;
|
|
let particles: Particle[] = [];
|
|
let hasAssembled = false;
|
|
let elapsed = 0;
|
|
let lastTime = 0;
|
|
let dpr = 1;
|
|
let intersecting = true;
|
|
const pointer = { x: OFFSCREEN_POINTER, y: OFFSCREEN_POINTER };
|
|
let rect = container.getBoundingClientRect();
|
|
|
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
const slashRgb =
|
|
hexToRgb(rootStyles.getPropertyValue("--slash")) ?? FALLBACK_SLASH;
|
|
const paperRgb =
|
|
hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER;
|
|
|
|
// next/font gera nome de famÃlia com hash: resolver pela classe font-display real
|
|
const probe = document.createElement("span");
|
|
probe.className = "font-display";
|
|
probe.style.position = "absolute";
|
|
probe.style.opacity = "0";
|
|
document.body.appendChild(probe);
|
|
const fontFamily = getComputedStyle(probe).fontFamily || "sans-serif";
|
|
document.body.removeChild(probe);
|
|
|
|
const fontsReady =
|
|
typeof document.fonts === "undefined"
|
|
? Promise.resolve()
|
|
: document.fonts.ready;
|
|
|
|
const sample = () => {
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
if (width < 60 || height < 60) return;
|
|
|
|
dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
|
canvas.width = Math.round(width * dpr);
|
|
canvas.height = Math.round(height * dpr);
|
|
rect = container.getBoundingClientRect();
|
|
|
|
const offscreen = document.createElement("canvas");
|
|
offscreen.width = canvas.width;
|
|
offscreen.height = canvas.height;
|
|
const offCtx = offscreen.getContext("2d");
|
|
if (!offCtx) return;
|
|
// Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro
|
|
offCtx.scale(dpr, dpr);
|
|
|
|
const maxWidth = width * 0.92;
|
|
let fontSize = Math.min(height * 0.22, width / 7);
|
|
let lines: string[] = [];
|
|
while (fontSize >= 12) {
|
|
offCtx.font = `800 ${fontSize}px ${fontFamily}`;
|
|
const trackingCtx = offCtx as TrackingCapableContext;
|
|
if (trackingCtx.letterSpacing !== undefined) {
|
|
trackingCtx.letterSpacing = "-0.04em";
|
|
}
|
|
lines = wrapLines(offCtx, text, maxWidth);
|
|
if (lines.length * fontSize * 0.98 <= height * 0.8) break;
|
|
fontSize *= 0.94;
|
|
}
|
|
fontSize = Math.max(fontSize, 12);
|
|
|
|
const lineHeight = fontSize * 0.98;
|
|
const startY = (height - lines.length * lineHeight) / 2 + fontSize * 0.74;
|
|
offCtx.fillStyle = "#ffffff";
|
|
offCtx.textBaseline = "alphabetic";
|
|
lines.forEach((line, index) => {
|
|
const lineWidth = offCtx.measureText(line).width;
|
|
offCtx.fillText(
|
|
line,
|
|
(width - lineWidth) / 2,
|
|
startY + index * lineHeight,
|
|
);
|
|
});
|
|
|
|
const pixels = offCtx.getImageData(
|
|
0,
|
|
0,
|
|
offscreen.width,
|
|
offscreen.height,
|
|
).data;
|
|
const targets: Array<[number, number]> = [];
|
|
const stride = 3;
|
|
for (let py = 0; py < offscreen.height; py += stride) {
|
|
for (let px = 0; px < offscreen.width; px += stride) {
|
|
if (pixels[(py * offscreen.width + px) * 4 + 3] > 128) {
|
|
targets.push([px, py]);
|
|
}
|
|
}
|
|
}
|
|
// 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(
|
|
Math.min(
|
|
MAX_PARTICLES,
|
|
Math.max(MIN_PARTICLES, (width * height) / 180),
|
|
),
|
|
);
|
|
|
|
const textParticles = Array.from({ length: desired }, () => {
|
|
const target = targets[Math.floor(Math.random() * targets.length)];
|
|
return makeParticle(target[0], target[1], false);
|
|
});
|
|
|
|
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 = () => {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
const pointerX = (pointer.x - rect.left) * dpr;
|
|
const pointerY = (pointer.y - rect.top) * dpr;
|
|
const radius = REPEL_RADIUS_PX * dpr;
|
|
const radiusSq = radius * radius;
|
|
let allSettled = true;
|
|
|
|
for (const particle of particles) {
|
|
const progress = Math.min(
|
|
Math.max(
|
|
(elapsed - particle.delay * ASSEMBLE_MS) / (ASSEMBLE_MS * 0.75),
|
|
0,
|
|
),
|
|
1,
|
|
);
|
|
if (progress < 1) allSettled = false;
|
|
const eased = easeOutQuint(progress);
|
|
const baseX = particle.sx + (particle.tx - particle.sx) * eased;
|
|
const baseY = particle.sy + (particle.ty - particle.sy) * eased;
|
|
|
|
const driftX =
|
|
Math.sin(elapsed * particle.speed + particle.phase) *
|
|
particle.amp *
|
|
progress;
|
|
const driftY =
|
|
Math.cos(elapsed * particle.speed * 0.9 + particle.phase) *
|
|
particle.amp *
|
|
progress;
|
|
|
|
const dx = baseX + driftX - pointerX;
|
|
const dy = baseY + driftY - pointerY;
|
|
const distSq = dx * dx + dy * dy;
|
|
if (distSq < radiusSq) {
|
|
const dist = Math.sqrt(distSq) || 1;
|
|
const force = (1 - dist / radius) * 10 * dpr;
|
|
particle.ox += (dx / dist) * force;
|
|
particle.oy += (dy / dist) * force;
|
|
}
|
|
particle.ox *= 0.86;
|
|
particle.oy *= 0.86;
|
|
|
|
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(
|
|
baseX + driftX + particle.ox,
|
|
baseY + driftY + particle.oy,
|
|
particle.size,
|
|
particle.size,
|
|
);
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
if (allSettled && particles.length > 0) hasAssembled = true;
|
|
};
|
|
|
|
const drawStaticFrame = () => {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
for (const particle of particles) {
|
|
ctx.fillStyle = particle.fillStyle;
|
|
ctx.globalAlpha = particle.baseAlpha;
|
|
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
};
|
|
|
|
const tick = (now: number) => {
|
|
if (!running) return;
|
|
elapsed += Math.min(now - lastTime, 50);
|
|
lastTime = now;
|
|
render();
|
|
rafId = window.requestAnimationFrame(tick);
|
|
};
|
|
|
|
const syncLoop = () => {
|
|
const shouldRun = !reducedMotion && intersecting && !document.hidden;
|
|
if (shouldRun && !running) {
|
|
running = true;
|
|
lastTime = performance.now();
|
|
rafId = window.requestAnimationFrame(tick);
|
|
} else if (!shouldRun && running) {
|
|
running = false;
|
|
window.cancelAnimationFrame(rafId);
|
|
}
|
|
};
|
|
|
|
// 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 = () => {
|
|
window.clearTimeout(resampleTimer);
|
|
resampleTimer = window.setTimeout(() => {
|
|
sampleUntilBuilt(() => {
|
|
if (reducedMotion) drawStaticFrame();
|
|
});
|
|
}, 150);
|
|
};
|
|
|
|
const handlePointerMove = (event: PointerEvent) => {
|
|
pointer.x = event.clientX;
|
|
pointer.y = event.clientY;
|
|
};
|
|
const handlePointerLeave = () => {
|
|
pointer.x = OFFSCREEN_POINTER;
|
|
pointer.y = OFFSCREEN_POINTER;
|
|
};
|
|
const handleScroll = () => {
|
|
rect = container.getBoundingClientRect();
|
|
};
|
|
const handleVisibilityChange = () => {
|
|
syncLoop();
|
|
};
|
|
|
|
const resizeObserver = new ResizeObserver(scheduleResample);
|
|
resizeObserver.observe(container);
|
|
|
|
if (reducedMotion) {
|
|
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
|
|
fontsReady.then(() => {
|
|
if (cancelled) return;
|
|
hasAssembled = true;
|
|
sampleUntilBuilt(() => drawStaticFrame());
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(resampleTimer);
|
|
window.clearTimeout(sampleRetryTimer);
|
|
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) => {
|
|
intersecting = entries.some((entry) => entry.isIntersecting);
|
|
syncLoop();
|
|
});
|
|
intersectionObserver.observe(container);
|
|
|
|
window.addEventListener("pointermove", handlePointerMove, {
|
|
passive: true,
|
|
});
|
|
document.documentElement.addEventListener(
|
|
"pointerleave",
|
|
handlePointerLeave,
|
|
);
|
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
fontsReady.then(() => {
|
|
if (cancelled) return;
|
|
sampleUntilBuilt(() => syncLoop());
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.cancelAnimationFrame(rafId);
|
|
window.clearTimeout(resampleTimer);
|
|
window.clearTimeout(sampleRetryTimer);
|
|
resizeObserver.disconnect();
|
|
intersectionObserver.disconnect();
|
|
window.removeEventListener("pointermove", handlePointerMove);
|
|
document.documentElement.removeEventListener(
|
|
"pointerleave",
|
|
handlePointerLeave,
|
|
);
|
|
window.removeEventListener("scroll", handleScroll);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [text]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0"
|
|
>
|
|
<canvas ref={canvasRef} className="h-full w-full" />
|
|
</div>
|
|
);
|
|
}
|