Files
portifolio-evandrodevbr/app/components/cinematic/HeroStarfield.tsx
T
Evandro Fonseca Junior dfcadc71fa
CI / verify (push) Failing after 5m29s
wip: foto da pasta principal antes de trocar de branch
Estado do disco em 14/10/2026, sem filtrar. Inclui as copias .sync-conflict-* do Syncthing de 07/10 e os 15 arquivos que o Syncthing apagou; todo esse conteudo ja existia em outros commits. So havia fora do Git: AGENTS.md, CLAUDE.md e a alteracao em data/project-health.json.
2026-10-14 00:42:49 -03:00

405 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef } from "react";
const MAX_DPR = 1.5;
const ASSEMBLE_MS = 2000;
const REPEL_RADIUS_PX = 80;
// Densidade: ~1 star por STAR_AREA_DIVISOR px² CSS, entre piso e teto absolutos
const STAR_AREA_DIVISOR = 700;
const MIN_STARS = 300;
const MAX_STARS = 850;
const OFFSCREEN_POINTER = -100000;
type Particle = {
sx: number; // origem da assemblei (espalhada) no primeiro load
sy: number;
tx: number; // âncora na grade com jitter
ty: number;
ox: number; // deslocamento acumulado do repel do ponteiro
oy: number;
delay: number;
fillStyle: string;
baseAlpha: number;
size: number;
phase: number;
speed: number;
amp: number;
ttl: number;
birth: number;
};
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;
}
export function HeroStarfield() {
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;
// Grade estratificada: uma star por célula com jitter interno → cobertura
// uniforme do card, sem os aglomerados/vazios do random puro
const grid = {
cols: 1,
rows: 1,
cellW: canvas.width || 1,
cellH: canvas.height || 1,
};
const jitteredCellPosition = (cell: number): [number, number] => {
const cx = cell % grid.cols;
const cy = Math.floor(cell / grid.cols);
return [
(cx + Math.random()) * grid.cellW,
(cy + Math.random()) * grid.cellH,
];
};
const makeParticle = (tx: number, ty: number): Particle => {
const useSlash = Math.random() < 0.45;
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: 0.5 + Math.random() * 0.45,
size: (Math.random() < 0.72 ? 2 : 3) * dpr,
phase: Math.random() * Math.PI * 2,
// deriva lenta: período ~2–5s com amplitude 2,5–5,5px CSS → "levemente"
speed: 0.0011 + Math.random() * 0.0016,
amp: (2.5 + Math.random() * 3) * dpr,
ttl: 4000 + Math.random() * 6000,
// birth negativo: estagia os ciclos de vida para o spawn/death já rodar disperso no load
birth: -Math.random() * 10000,
};
};
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 count = Math.round(
Math.min(
MAX_STARS,
Math.max(MIN_STARS, (width * height) / STAR_AREA_DIVISOR),
),
);
// células quadradas-ish para a proporção do card
grid.cols = Math.max(
1,
Math.round(Math.sqrt(count * (canvas.width / canvas.height))),
);
grid.rows = Math.max(1, Math.ceil(count / grid.cols));
grid.cellW = canvas.width / grid.cols;
grid.cellH = canvas.height / grid.rows;
// ordem embaralhada das células: cada star ocupa uma célula distinta no spawn
const cells = Array.from({ length: grid.cols * grid.rows }, (_, i) => i);
for (let i = cells.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = cells[i];
cells[i] = cells[j];
cells[j] = tmp;
}
particles = cells.slice(0, count).map((cell) => {
const [tx, ty] = jitteredCellPosition(cell);
return makeParticle(tx, ty);
});
};
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;
// wander Lissajous: harmônico principal + secundário em razão
// não-inteira → trajetória contínua sem repetição óbvia
const t = elapsed * particle.speed;
const driftX =
(Math.sin(t + particle.phase) +
0.5 * Math.sin(t * 0.43 + particle.phase * 1.7)) *
particle.amp *
progress;
const driftY =
(Math.cos(t * 0.9 + particle.phase) +
0.5 * Math.cos(t * 0.51 + particle.phase * 2.3)) *
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 = 0.6 + 0.4 * Math.sin(elapsed * 0.003 + particle.phase);
let life = elapsed - particle.birth;
if (life > particle.ttl) {
// renasce em outra célula da grade → uniforme também entre ciclos
const [tx, ty] = jitteredCellPosition(
Math.floor(Math.random() * grid.cols * grid.rows),
);
particle.tx = tx;
particle.ty = ty;
particle.sx = tx;
particle.sy = ty;
particle.phase = Math.random() * Math.PI * 2;
particle.baseAlpha = 0.5 + Math.random() * 0.45;
particle.size = (Math.random() < 0.72 ? 2 : 3) * dpr;
particle.ttl = 4000 + Math.random() * 6000;
particle.birth = elapsed;
life = 0;
}
// fade-in de 800ms ao nascer, fade-out nos últimos 800ms de vida
const 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 de 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
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 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();
sampleUntilBuilt(() => 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);
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);
};
}, []);
return (
<div
ref={containerRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
);
}