/** Contas puras da vitrine (sem DOM), separadas para serem testadas uma a uma. */ export interface CardCandidate { id: string; visible: boolean; /** Distância do centro do card ao centro da tela, em pixels. */ distance: number; } /** * Qual card toca: o que está sob o mouse (se visível); senão o visível mais perto do centro da tela. * Empate fica com o primeiro da lista (ordem do DOM: esquerda antes de direita). */ export function pickActiveCard( candidates: CardCandidate[], hoveredId: string | null, ): string | null { const visible = candidates.filter((candidate) => candidate.visible); if (hoveredId && visible.some((candidate) => candidate.id === hoveredId)) return hoveredId; let best: CardCandidate | null = null; for (const candidate of visible) { if (!best || candidate.distance < best.distance - 0.5) best = candidate; } return best?.id ?? null; } export interface ReelPosition { /** Projeto em cena. */ index: number; /** Quanto do trecho do projeto já passou (0 a 1). */ local: number; } /** Em que projeto e em que ponto dele a rolagem está, dado o avanço total `t` (0 a 1) e o peso de cada trecho. */ export function locateInReel(weights: number[], t: number): ReelPosition { const total = weights.reduce((sum, weight) => sum + weight, 0); if (weights.length === 0 || total <= 0) return { index: 0, local: 0 }; const position = Math.min(1, Math.max(0, t)) * total; let start = 0; for (let index = 0; index < weights.length; index += 1) { const end = start + weights[index]; if (position < end || index === weights.length - 1) { const local = (position - start) / weights[index]; return { index, local: Math.min(1, Math.max(0, local)) }; } start = end; } return { index: weights.length - 1, local: 1 }; } /** Início do trecho de cada projeto, como fração (0 a 1) do avanço total. */ export function reelStarts(weights: number[]): number[] { const total = weights.reduce((sum, weight) => sum + weight, 0); let acc = 0; return weights.map((weight) => { const start = total > 0 ? acc / total : 0; acc += weight; return start; }); } /** Fração do trecho de cada projeto em que o vídeo fica parado, no começo e no fim. */ export const REEL_HOLD = 0.1; /** Avanço total (0 a 1) em que o projeto `index` está em `local` (0 a 1) do trecho dele. */ export function reelOffset(weights: number[], index: number, local: number) { const total = weights.reduce((sum, weight) => sum + weight, 0); if (total <= 0 || index < 0 || index >= weights.length) return 0; return reelStarts(weights)[index] + (local * weights[index]) / total; } /** * O trecho de cada projeto tem uma pausa no começo e outra no fim: o vídeo só anda no meio. * Isso dá tempo de ler o título antes de o site começar a rolar e de descansar antes da troca. */ export function videoProgress(local: number, hold = REEL_HOLD): number { const span = 1 - hold * 2; return Math.min(1, Math.max(0, (local - hold) / span)); } /** Aproxima `current` de `target` de forma suave, independente da taxa de quadros (`tau` em segundos). */ export function easeToward( current: number, target: number, deltaSeconds: number, tau = 0.07, ): number { const factor = 1 - Math.exp(-Math.max(0, deltaSeconds) / tau); return current + (target - current) * factor; }