fix(hero): starfield uniforme com grade estratificada + tags mobile sem quebra interna
This commit is contained in:
1 parent
957c58b69a
commit
52960313f0
4 files changed
+119
-203
No files matched your search
@@ -8,7 +8,7 @@ import { CV_URL } from "@/lib/site-navigation";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { ParticleBuildText } from "./cinematic/ParticleBuildText";
|
||||
import { HeroStarfield } from "./cinematic/HeroStarfield";
|
||||
import { TelemetryStrip } from "./cinematic/TelemetryStrip";
|
||||
|
||||
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
||||
@@ -318,8 +318,8 @@ function SideVisual({
|
||||
if (variant.scene === "command") {
|
||||
return (
|
||||
<div className="mb-sp-6 space-y-sp-4">
|
||||
{/* Partículas decorativas atrás da cena: o h1 real permanece o conteúdo acessível */}
|
||||
<ParticleBuildText text={variant.headline} />
|
||||
{/* Starfield decorativo: grade uniforme com jitter cobrindo o card inteiro */}
|
||||
<HeroStarfield />
|
||||
{/* autoRotate off em reduced-motion: globo vira âncora estática */}
|
||||
<ControlRoomGlobe autoRotate={!shouldReduceMotion} />
|
||||
<TelemetryStrip reducedMotion={shouldReduceMotion} />
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function Projects({
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-sp-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-sp-6">
|
||||
<div className="max-w-[560px]">
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<DrawLine delay={0.05} />
|
||||
@@ -81,7 +81,12 @@ export default function Projects({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<MagneticButton as="span" className="ds-tag" strength={0.15}>
|
||||
<MagneticButton
|
||||
as="span"
|
||||
className="ds-tag"
|
||||
strength={0.15}
|
||||
wrapperClassName="shrink-0 max-sm:ml-auto"
|
||||
>
|
||||
{project.status}
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
+107
-195
@@ -1,28 +1,26 @@
|
||||
"use client";
|
||||
"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;
|
||||
// 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;
|
||||
sx: number; // origem da assemblei (espalhada) no primeiro load
|
||||
sy: number;
|
||||
tx: number;
|
||||
tx: number; // âncora na grade com jitter
|
||||
ty: number;
|
||||
ox: number;
|
||||
ox: number; // deslocamento acumulado do repel do ponteiro
|
||||
oy: number;
|
||||
delay: number;
|
||||
fillStyle: string;
|
||||
baseAlpha: number;
|
||||
ambient: boolean;
|
||||
size: number;
|
||||
phase: number;
|
||||
speed: number;
|
||||
@@ -31,10 +29,6 @@ type Particle = {
|
||||
birth: number;
|
||||
};
|
||||
|
||||
type TrackingCapableContext = CanvasRenderingContext2D & {
|
||||
letterSpacing?: string;
|
||||
};
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
const FALLBACK_SLASH: Rgb = [204, 255, 0];
|
||||
@@ -61,27 +55,7 @@ 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 }) {
|
||||
export function HeroStarfield() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
@@ -118,19 +92,47 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
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);
|
||||
// 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 fontsReady =
|
||||
typeof document.fonts === "undefined"
|
||||
? Promise.resolve()
|
||||
: document.fonts.ready;
|
||||
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;
|
||||
@@ -142,120 +144,33 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
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(
|
||||
const count = Math.round(
|
||||
Math.min(
|
||||
MAX_PARTICLES,
|
||||
Math.max(MIN_PARTICLES, (width * height) / 180),
|
||||
MAX_STARS,
|
||||
Math.max(MIN_STARS, (width * height) / STAR_AREA_DIVISOR),
|
||||
),
|
||||
);
|
||||
|
||||
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)),
|
||||
// células quadradas-ish para a proporção do card
|
||||
grid.cols = Math.max(
|
||||
1,
|
||||
Math.round(Math.sqrt(count * (canvas.width / canvas.height))),
|
||||
);
|
||||
const ambient: Particle[] = Array.from({ length: ambientCount }, () => {
|
||||
const tx = Math.random() * canvas.width;
|
||||
const ty = Math.random() * canvas.height;
|
||||
return makeParticle(tx, ty, true);
|
||||
});
|
||||
grid.rows = Math.max(1, Math.ceil(count / grid.cols));
|
||||
grid.cellW = canvas.width / grid.cols;
|
||||
grid.cellH = canvas.height / grid.rows;
|
||||
|
||||
particles = [...textParticles, ...ambient];
|
||||
// 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 = () => {
|
||||
@@ -280,12 +195,17 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
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(elapsed * particle.speed + particle.phase) *
|
||||
(Math.sin(t + particle.phase) +
|
||||
0.5 * Math.sin(t * 0.43 + particle.phase * 1.7)) *
|
||||
particle.amp *
|
||||
progress;
|
||||
const driftY =
|
||||
Math.cos(elapsed * particle.speed * 0.9 + particle.phase) *
|
||||
(Math.cos(t * 0.9 + particle.phase) +
|
||||
0.5 * Math.cos(t * 0.51 + particle.phase * 2.3)) *
|
||||
particle.amp *
|
||||
progress;
|
||||
|
||||
@@ -302,27 +222,27 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
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);
|
||||
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,
|
||||
@@ -371,8 +291,8 @@ 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
|
||||
// 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) {
|
||||
@@ -414,12 +334,9 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
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());
|
||||
});
|
||||
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
|
||||
hasAssembled = true;
|
||||
sampleUntilBuilt(() => drawStaticFrame());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -429,8 +346,8 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
};
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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 =
|
||||
@@ -440,7 +357,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
current.left < window.innerWidth;
|
||||
};
|
||||
syncIntersecting();
|
||||
syncLoop();
|
||||
sampleUntilBuilt(() => syncLoop());
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||
intersecting = entries.some((entry) => entry.isIntersecting);
|
||||
@@ -458,11 +375,6 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
fontsReady.then(() => {
|
||||
if (cancelled) return;
|
||||
sampleUntilBuilt(() => syncLoop());
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(rafId);
|
||||
@@ -478,7 +390,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [text]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
+2
-3
@@ -337,9 +337,8 @@ textarea {
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
/* pílula inteira numa linha: a quebra acontece entre tags (.ds-tags wrap), nunca dentro dela */
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
|
||||
Reference in new issue
Block a user