// heroes.jsx — três tratamentos de hero (Comissa) const { useState: _useState, useEffect: _useEffect, useRef: _useRef } = React; // ───────────────────────────────────────────────────────────── // Tilt3D — segue o cursor com perspectiva 3D suave // ───────────────────────────────────────────────────────────── function Tilt3D({ children, maxTiltX = 5, maxTiltY = 7, lift = 12, scale = 1.005, anchor, glare = true }) { const wrap = _useRef(null); const [t, set] = _useState({ rx: 0, ry: 0, mx: 50, my: 50, hover: false }); _useEffect(() => { const target = anchor && anchor.current ? anchor.current : wrap.current; if (!target) return; let raf = 0; const onMove = (e) => { const r = target.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width; const y = (e.clientY - r.top) / r.height; const cx = Math.max(0, Math.min(1, x)); const cy = Math.max(0, Math.min(1, y)); cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { set({ rx: (0.5 - cy) * 2 * maxTiltX, ry: (cx - 0.5) * 2 * maxTiltY, mx: cx * 100, my: cy * 100, hover: true, }); }); }; const onLeave = () => { cancelAnimationFrame(raf); set({ rx: 0, ry: 0, mx: 50, my: 50, hover: false }); }; target.addEventListener('mousemove', onMove); target.addEventListener('mouseleave', onLeave); return () => { cancelAnimationFrame(raf); target.removeEventListener('mousemove', onMove); target.removeEventListener('mouseleave', onLeave); }; }, [maxTiltX, maxTiltY, anchor]); return (
{children} {glare && (
)}
); } // ───────────────────────────────────────────────────────────── // PingsDeVenda — as vendas caindo em cima do globo // ───────────────────────────────────────────────────────────── // O globo pinga um ponto no mapa; aqui a mesma venda aparece escrita, nas // bordas da dobra, logo acima da moldura do app. As duas coisas contam a // mesma história em dois níveis: o mapa diz "está acontecendo em todo lugar", // o cartão diz quanto entrou e de onde. const PING_VENDAS = [ { p: 'Honda Civic 2024', v: 'R$ 3.567', c: 'São Paulo' }, { p: 'Curso Trading Pro', v: 'R$ 891', c: 'Lisboa' }, { p: 'Apto 2Q Pinheiros', v: 'R$ 14.800', c: 'Curitiba' }, { p: 'Rolex Submariner', v: 'R$ 2.682', c: 'Toronto' }, { p: 'Consórcio imóvel', v: 'R$ 1.240', c: 'Recife' }, { p: 'Plano anual SaaS', v: 'R$ 420', c: '東京' }, { p: 'Mentoria 12 meses', v: 'R$ 4.980', c: 'Ciudad de México' }, { p: 'Seguro auto', v: 'R$ 760', c: 'Porto Alegre' }, ]; function PingsDeVenda({ startDelayMs = 2400, intervalMs = 2300, variante = 'hero' }) { const [cards, setCards] = _useState([]); const noEscuro = variante === 'escuro'; _useEffect(() => { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; let n = 0, intervalo = 0; const timers = new Set(); const nasce = () => { const venda = PING_VENDAS[n % PING_VENDAS.length]; const chave = 'p' + n; const lado = n % 2 === 0 ? 'esq' : 'dir'; // Os três topos ficam TODOS acima da moldura do app, que começa em 596. // Antes eram 486/524/562: o terceiro tem 44px de altura, terminava em // 606 e entrava embaixo do painel — o cartão aparecia cortado ao meio // pela borda da moldura, e em telas de ~1100px, onde o painel é mais // largo que o texto, os dois lados batiam. Mexeu na altura do painel? // Reconferir estes números. const topo = noEscuro ? [null, null, null][n % 3] : [446, 492, 538][n % 3]; const base = noEscuro ? [26, 74, 122][n % 3] : null; n++; setCards((c) => [...c, { chave, venda, lado, topo, base }]); const t = setTimeout(() => { timers.delete(t); setCards((c) => c.filter((x) => x.chave !== chave)); }, 4300); timers.add(t); }; const t0 = setTimeout(() => { nasce(); intervalo = setInterval(nasce, intervalMs); }, startDelayMs); return () => { clearTimeout(t0); clearInterval(intervalo); timers.forEach(clearTimeout); }; }, [startDelayMs, intervalMs]); return (
{cards.map((c) => (
+{c.venda.v}
{c.venda.p} · {c.venda.c}
))}
); } // ───────────────────────────────────────────────────────────── // PainelRoteiro — o cursor operando o app dentro da moldura do hero // ───────────────────────────────────────────────────────────── // A captura do painel é bonita e morta. Aqui alguém usa o produto na frente // do visitante: abre Minhas vendas, clica em Nova venda, os campos chegam // prontos do link, registra — e o dado sai voando pro Comissa Empresa, que // avisa que recebeu. É a promessa do site acontecendo em 10 segundos, sem // ninguém precisar ler nada. // // As posições saem de offsetLeft/offsetTop, não de getBoundingClientRect: a // moldura do hero é inclinada em 3D, e rect devolve a caixa JÁ transformada — // o cursor apontava sempre alguns pixels fora do alvo. offset* dá a // coordenada de layout, que é o mesmo sistema em que este overlay vive. function posRelativa(el, raiz) { let x = 0, y = 0, n = el; while (n && n !== raiz) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent; } return { x, y }; } const ROTEIRO_VENDA = { produto: 'Honda Civic 2024', bruto: 'R$ 118.900', taxa: '3%', com: 'R$ 3.567' }; function PainelRoteiro({ anchorRef }) { const [fase, setFase] = _useState(0); const [cur, setCur] = _useState({ x: 320, y: 60, vis: false, click: false }); const btnRef = _useRef(null); const capaRef = _useRef(null); _useEffect(() => { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setFase(6); return; } let parado = false; const timers = new Set(); const depois = (ms, fn) => { const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); }; const irPara = (seletor) => { const raiz = capaRef.current && capaRef.current.offsetParent; const alvo = seletor === 'form' ? btnRef.current : (anchorRef.current && anchorRef.current.querySelector(`[data-cdspot="${seletor}"]`)); if (!alvo || !raiz) return null; const p = posRelativa(alvo, raiz); setCur((c) => ({ ...c, vis: true, x: p.x + Math.min(alvo.offsetWidth * 0.5, 64), y: p.y + alvo.offsetHeight / 2 })); return alvo; }; const clicar = (el) => { setCur((c) => ({ ...c, click: true })); if (el && el.classList) { el.classList.remove('cd-poked'); void el.offsetWidth; el.classList.add('cd-poked'); depois(950, () => el.classList.remove('cd-poked')); } depois(420, () => { if (!parado) setCur((c) => ({ ...c, click: false })); }); }; const ciclo = () => { if (parado) return; setFase(1); const a = irPara('sidebar-vendas'); depois(950, () => { if (!parado) clicar(a); }); depois(1700, () => { if (parado) return; setFase(2); const b = irPara('btn-nova-venda'); depois(900, () => clicar(b)); }); depois(3000, () => { if (!parado) setFase(3); }); depois(4400, () => { if (parado) return; const c = irPara('form'); depois(700, () => clicar(c)); }); depois(5300, () => { if (!parado) setFase(4); }); depois(5900, () => { if (!parado) setFase(5); }); depois(7100, () => { if (!parado) setFase(6); }); depois(10200, () => { if (!parado) setFase(7); }); depois(11200, () => { if (parado) return; setFase(0); setCur((c) => ({ ...c, vis: false })); depois(900, ciclo); }); }; depois(2200, ciclo); return () => { parado = true; timers.forEach(clearTimeout); }; }, [anchorRef]); const formAberto = fase >= 3 && fase <= 5; const registrado = fase >= 4; const voando = fase >= 5; const chegou = fase >= 6; const saindo = fase === 7; const linhas = [ ['Produto', ROTEIRO_VENDA.produto], ['Valor', ROTEIRO_VENDA.bruto], ['Comissão', `${ROTEIRO_VENDA.taxa} · ${ROTEIRO_VENDA.com}`], ]; return (
{/* formulário que o clique em "Nova venda" abre */}
Nova venda do link
{linhas.map(([k, v], i) => (
{k} {v}
))}
{registrado ? 'Venda registrada ✓' : 'Registrar venda'}
{/* o sinal: rastro que desenha do formulário até o outro app. Tudo aqui vive DENTRO da moldura de 980px: o hero recorta na largura real da janela, então qualquer coisa ancorada em `right` negativo desaparecia pela borda em telas menores que ~1130px — e o que sumia era justo o fim da história, o outro app avisando que recebeu. */} {/* a pastinha levando o dado */}
{ROTEIRO_VENDA.produto} · {ROTEIRO_VENDA.com}
{/* o outro app avisando que recebeu */}
{chegou && }
Comissa Empresa
Arthur vendeu {ROTEIRO_VENDA.produto}
{ROTEIRO_VENDA.bruto} · a pagar {ROTEIRO_VENDA.com}
{/* o cursor */}
{cur.click && ( )} Você
); } // ───────────────────────────────────────────────────────────── // HeroA — Spotlight centralizado // ───────────────────────────────────────────────────────────── function HeroA({ width = 1280, height = 960 }) { const heroRef = _useRef(null); const painelRef = _useRef(null); // O painel tem 980px fixos e o hero recorta na largura real da janela. Sem // isto, em qualquer tela menor que ~1130px o lado direito da moldura — onde // o roteiro termina, com o Comissa Empresa avisando que recebeu — ficava // fora da tela. Encolher a moldura inteira mantém a cena completa. const larguraPainel = Math.max(660, Math.min(980, width - 72)); return (
{/* O globo volta pra trás do painel — e é ele que dá escala à cena: cada venda que entra pinga na cidade de onde veio. Fica ATRÁS da moldura de propósito: metade dele some por baixo do painel, o que costura as duas camadas numa composição só em vez de deixar globo e captura flutuando lado a lado sem se referirem um ao outro. */}
{/* zIndex 3, acima do painel (2). */}
Novo · comissão calculada em tempo real

O painel
que mantém o vendedor
no controle.

Carro, curso, relógio, imóvel, etc. O que você vende vai pra um lugar só. Comissão, meta e cliente na ponta do dedo. Sem planilha. Sem confusão.

window.openModal('signup')}>Testar grátis window.iniciarTour && window.iniciarTour()}> Ver tour de 90s
14 dias grátis · Sem cartão de crédito · Cancela em um clique
{/* O painel reage ao mouse: Tilt3D ancorado no hero inteiro, então ele acompanha o cursor desde o momento em que o visitante passa por cima do texto — a tela responde antes de ele clicar em nada. */}
{/* A classe da cascata vive AQUI dentro, e não no pai: `intro-fade-right` anima `transform`, e o pai usa transform pra se centralizar (`translateX(-50%)`). Na mesma caixa, o último quadro da animação zerava a centralização e o painel saltava meia tela pra direita. */}
} />
); } // ───────────────────────────────────────────────────────────── // Stats animados do Hero B (vendas processadas / minutos / uptime) // ───────────────────────────────────────────────────────────── function HeroLiveStats() { const vendas = window.useDrift({ start: 4.3, min: 4.05, max: 4.62, stepMin: 0.02, stepMax: 0.08, upBias: 0.74, intervalMs: 2000, }); const minutos = window.useDrift({ start: 23, min: 22, max: 31, stepMin: 0.4, stepMax: 1.4, upBias: 0.68, intervalMs: 2400, }); const uptime = window.useDrift({ start: 99.99, min: 99.92, max: 99.999, stepMin: 0.003, stepMax: 0.014, upBias: 0.55, intervalMs: 2800, }); const stats = [ { target: vendas, ms: 1700, fmt: (n) => 'R$ ' + n.toFixed(1).replace('.', ',') + 'M', l: 'em vendas processadas no Comissa', }, { target: minutos, ms: 1800, fmt: (n) => Math.round(n) + ' min', l: 'economizados por vendedor / dia*', }, { target: uptime, ms: 2000, fmt: (n) => n.toFixed(2).replace('.', ',') + '%', l: 'uptime nos últimos 12 meses', }, ]; return ( // 44 e não 56: com 56 a linha terminava em 814px dentro de um hero que // corta em 840, e os rótulos de duas linhas ficavam sem a última.
{stats.map((s) => { const TN = window.TweenedNumber; return (
{s.l}
); })}
); } // ───────────────────────────────────────────────────────────── // Planet — globo focado no Brasil + marcadores verdes pulsantes // ───────────────────────────────────────────────────────────── // Contornos grosseiros dos continentes, em [lat, lon]. Não é cartografia — é // o bastante pra o olho reconhecer o Brasil, os Estados Unidos, a China e a // Austrália enquanto o globo gira. const CONTINENTES = [ // América do Sul [[12,-72],[11,-64],[7,-58],[5,-52],[0,-50],[-5,-35],[-9,-35],[-13,-38],[-18,-39],[-23,-42],[-27,-48],[-33,-53],[-38,-57],[-42,-63],[-50,-68],[-55,-68],[-53,-72],[-45,-74],[-38,-73],[-30,-71],[-23,-70],[-18,-70],[-14,-76],[-6,-81],[0,-80],[6,-77],[9,-75],[12,-72]], // América do Norte [[70,-160],[70,-140],[68,-135],[60,-140],[58,-133],[54,-130],[48,-125],[40,-124],[32,-117],[23,-110],[20,-105],[16,-95],[18,-88],[21,-87],[25,-80],[30,-81],[35,-76],[40,-74],[44,-67],[48,-60],[52,-56],[56,-62],[60,-65],[63,-78],[68,-85],[70,-100],[70,-125],[70,-160]], // Groenlândia [[83,-30],[80,-20],[75,-20],[70,-25],[65,-40],[70,-52],[76,-60],[80,-55],[83,-30]], // África [[35,-6],[37,10],[33,22],[31,32],[22,37],[12,43],[10,51],[2,42],[-5,39],[-15,40],[-25,33],[-34,26],[-34,19],[-28,16],[-18,12],[-8,13],[0,9],[5,0],[10,-15],[15,-17],[22,-17],[28,-13],[32,-9],[35,-6]], // Eurásia [[36,-9],[43,-9],[48,-5],[50,2],[58,5],[62,5],[70,20],[70,32],[68,45],[73,60],[76,90],[72,110],[70,130],[66,170],[60,160],[55,140],[50,140],[43,132],[38,126],[30,122],[22,110],[10,105],[15,95],[22,90],[20,72],[25,60],[28,50],[25,38],[30,33],[36,27],[40,26],[40,15],[44,13],[36,-9]], // Índia [[24,68],[20,72],[15,74],[8,77],[13,80],[20,86],[22,89],[26,80],[24,68]], // Austrália [[-11,131],[-12,137],[-17,141],[-11,143],[-19,147],[-25,153],[-32,153],[-38,148],[-38,141],[-35,136],[-32,129],[-34,116],[-28,114],[-22,114],[-18,122],[-14,127],[-11,131]], // Japão [[45,142],[42,141],[38,141],[35,140],[34,136],[35,133],[33,131],[31,130],[33,133],[36,137],[39,140],[42,143],[45,142]], ]; // Fronteiras de países, traço mais fino: é o que transforma "um continente" em // "o Brasil", "o Canadá", "a Rússia". const PAISES = [ // Brasil [[5,-60],[4,-52],[0,-50],[-2,-44],[-5,-37],[-9,-35],[-13,-38],[-18,-39],[-23,-42],[-27,-48],[-33,-53],[-30,-57],[-27,-55],[-24,-54],[-22,-57],[-20,-58],[-16,-58],[-11,-65],[-9,-69],[-4,-70],[0,-70],[2,-67],[5,-64],[5,-60]], // Estados Unidos [[49,-123],[49,-95],[48,-88],[45,-83],[43,-79],[45,-71],[45,-67],[41,-70],[39,-74],[35,-76],[31,-81],[25,-80],[29,-83],[30,-89],[29,-94],[26,-97],[29,-103],[32,-108],[32,-115],[35,-120],[40,-124],[46,-124],[49,-123]], // Canadá [[49,-123],[49,-95],[48,-88],[45,-83],[43,-79],[45,-71],[45,-67],[50,-64],[52,-56],[56,-62],[60,-65],[63,-78],[68,-85],[70,-100],[70,-125],[69,-140],[60,-139],[54,-130],[49,-123]], // México [[32,-115],[32,-108],[29,-103],[26,-97],[21,-97],[18,-94],[16,-95],[18,-88],[21,-87],[19,-90],[16,-92],[14,-92],[18,-102],[21,-105],[26,-109],[31,-114],[32,-115]], // Argentina [[-22,-64],[-22,-58],[-27,-55],[-30,-57],[-34,-58],[-38,-58],[-42,-63],[-46,-67],[-50,-68],[-55,-68],[-52,-72],[-45,-71],[-38,-70],[-32,-69],[-27,-66],[-22,-67],[-22,-64]], // Rússia [[70,30],[70,60],[76,90],[72,110],[70,130],[66,170],[60,163],[55,140],[50,140],[44,132],[43,131],[48,130],[50,120],[50,110],[50,90],[47,85],[50,80],[54,73],[52,60],[47,48],[44,40],[47,37],[52,32],[55,28],[60,28],[66,30],[70,30]], // França [[51,2],[49,5],[47,7],[46,7],[44,7],[43,3],[42,3],[43,-2],[46,-1],[48,-5],[49,-1],[51,2]], // Península Ibérica [[43,-9],[43,-2],[42,3],[39,0],[37,-2],[36,-6],[37,-9],[41,-9],[43,-9]], // China [[53,124],[48,134],[43,131],[39,124],[35,120],[30,122],[24,118],[21,110],[22,102],[28,98],[28,88],[35,78],[40,75],[45,83],[48,88],[50,110],[53,120],[53,124]], ]; // O mapa de vendas. Começou só com capitais brasileiras, mas o globo gira // inteiro agora — e um planeta que roda com pontos em um país só fica meia // volta apagado. Cada praaça aqui é um lugar de onde uma venda pinga. const BR_CITIES = [ { name: 'São Paulo', lat: -23.55, lon: -46.63 }, { name: 'Rio de Janeiro', lat: -22.91, lon: -43.20 }, { name: 'Brasília', lat: -15.78, lon: -47.93 }, { name: 'Belo Horizonte', lat: -19.92, lon: -43.94 }, { name: 'Salvador', lat: -12.97, lon: -38.50 }, { name: 'Curitiba', lat: -25.43, lon: -49.27 }, { name: 'Recife', lat: -8.06, lon: -34.87 }, { name: 'Manaus', lat: -3.10, lon: -60.02 }, { name: 'Porto Alegre', lat: -30.03, lon: -51.23 }, { name: 'Fortaleza', lat: -3.73, lon: -38.52 }, { name: 'Goiânia', lat: -16.68, lon: -49.27 }, { name: 'Belém', lat: -1.45, lon: -48.50 }, // resto do mundo { name: 'Buenos Aires', lat: -34.60, lon: -58.38 }, { name: 'Santiago', lat: -33.45, lon: -70.67 }, { name: 'Bogotá', lat: 4.71, lon: -74.07 }, { name: 'Ciudad de México',lat: 19.43, lon: -99.13 }, { name: 'Miami', lat: 25.76, lon: -80.19 }, { name: 'New York', lat: 40.71, lon: -74.01 }, { name: 'Toronto', lat: 43.65, lon: -79.38 }, { name: 'Vancouver', lat: 49.28, lon: -123.12 }, { name: 'Lisboa', lat: 38.72, lon: -9.14 }, { name: 'Madrid', lat: 40.42, lon: -3.70 }, { name: 'London', lat: 51.51, lon: -0.13 }, { name: 'Paris', lat: 48.86, lon: 2.35 }, { name: 'Berlin', lat: 52.52, lon: 13.40 }, { name: 'Moskva', lat: 55.76, lon: 37.62 }, { name: 'Dubai', lat: 25.20, lon: 55.27 }, { name: 'Lagos', lat: 6.52, lon: 3.38 }, { name: 'Johannesburg', lat: -26.20, lon: 28.05 }, { name: 'Mumbai', lat: 19.08, lon: 72.88 }, { name: 'Shanghai', lat: 31.23, lon: 121.47 }, { name: 'Seoul', lat: 37.57, lon: 126.98 }, { name: '東京', lat: 35.68, lon: 139.69 }, { name: 'Sydney', lat: -33.87, lon: 151.21 }, ]; function Planet({ radius = 240, spawnEveryMs = 1400 }) { const mountRef = _useRef(null); _useEffect(() => { if (!window.THREE) return; const THREE = window.THREE; const mount = mountRef.current; if (!mount) return; const w0 = mount.clientWidth || 600; const h0 = mount.clientHeight || 720; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setClearColor(0x000000, 0); renderer.setSize(w0, h0); mount.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(38, w0 / h0, 0.1, 2000); camera.position.z = radius * 3.2; const planet = new THREE.Group(); scene.add(planet); // superfície — Terra (base azul clara até a textura carregar) // Estas cores são as do tema, escritas em 0x porque o three.js não lê // custom properties. Se `--indigo` mudar, elas mudam junto — senão o globo // fica sendo o único pedaço da página com a paleta antiga. const earthGeom = new THREE.SphereGeometry(radius, 64, 64); const earthMat = new THREE.MeshBasicMaterial({ color: 0xC9DDFF, transparent: true, opacity: 0.17, }); const earth = new THREE.Mesh(earthGeom, earthMat); planet.add(earth); // atmosfera / glow do acento ao redor do planeta (fresnel simples) const atmoMat = new THREE.MeshBasicMaterial({ color: 0x38BDF8, transparent: true, opacity: 0.10, side: THREE.BackSide, blending: THREE.AdditiveBlending, depthWrite: false, }); const atmo = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.14, 48, 48), atmoMat); planet.add(atmo); const atmoMat2 = new THREE.MeshBasicMaterial({ color: 0x34D399, transparent: true, opacity: 0.06, side: THREE.BackSide, blending: THREE.AdditiveBlending, depthWrite: false, }); const atmo2 = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.28, 48, 48), atmoMat2); planet.add(atmo2); // grade de meridianos e paralelos desenhada como linhas de verdade. // O `wireframe` de uma esfera desenha os TRIÂNGULOS da malha (diagonais // inclusive), que de perto lia como tela de mosquiteiro. Circunferências // reais dão o globo técnico de planetário, que é o que a cena quer. const descartar = []; const gratMat = new THREE.LineBasicMaterial({ color: 0x60A5FA, transparent: true, opacity: 0.16, depthWrite: false, }); const grat = new THREE.Group(); const circulo = (fn) => { const pts = []; for (let a = 0; a <= 96; a++) pts.push(fn((a / 96) * Math.PI * 2)); const g = new THREE.BufferGeometry().setFromPoints(pts); descartar.push(g); grat.add(new THREE.Line(g, gratMat)); }; const R = radius * 1.004; for (let lat = -60; lat <= 60; lat += 30) { const rr = R * Math.cos(lat * Math.PI / 180); const yy = R * Math.sin(lat * Math.PI / 180); circulo((t) => new THREE.Vector3(Math.cos(t) * rr, yy, Math.sin(t) * rr)); } for (let lon = 0; lon < 180; lon += 30) { const cl = Math.cos(lon * Math.PI / 180), sl = Math.sin(lon * Math.PI / 180); circulo((t) => new THREE.Vector3(Math.cos(t) * R * cl, Math.sin(t) * R, Math.cos(t) * R * sl)); } // O equador vem mais forte: dá eixo ao globo e evita a sensação de bola // de linhas todas com o mesmo peso. const equadorMat = new THREE.LineBasicMaterial({ color: 0x2563EB, transparent: true, opacity: 0.38, depthWrite: false, }); { const pts = []; for (let a = 0; a <= 128; a++) { const t = (a / 128) * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(t) * R, 0, Math.sin(t) * R)); } const g = new THREE.BufferGeometry().setFromPoints(pts); descartar.push(g); grat.add(new THREE.Line(g, equadorMat)); } planet.add(grat); // Contornos dos continentes e das fronteiras de países. Não é cartografia: // são polígonos grosseiros desenhados como linha sobre a esfera, o bastante // pro olho reconhecer Brasil, Estados Unidos, Canadá, Rússia, França, China // e Austrália — o argumento é que a venda pinga de qualquer lugar do mundo, // e isso só se sustenta se o mundo estiver desenhado. const terraMat = new THREE.LineBasicMaterial({ color: 0x2563EB, transparent: true, opacity: 0.62, depthWrite: false, }); const paisMat = new THREE.LineBasicMaterial({ color: 0x60A5FA, transparent: true, opacity: 0.44, depthWrite: false, }); const tracaPoli = (poli, mat, alt) => { const pts = []; for (let k = 0; k < poli.length - 1; k++) { const [la1, lo1] = poli[k], [la2, lo2] = poli[k + 1]; for (let st = 0; st < 6; st++) { const f = st / 6; pts.push(latLonToVec3(la1 + (la2 - la1) * f, lo1 + (lo2 - lo1) * f, radius * alt)); } } const u = poli[poli.length - 1]; pts.push(latLonToVec3(u[0], u[1], radius * alt)); const g = new THREE.BufferGeometry().setFromPoints(pts); descartar.push(g); planet.add(new THREE.Line(g, mat)); }; CONTINENTES.forEach((poli) => tracaPoli(poli, terraMat, 1.006)); PAISES.forEach((poli) => tracaPoli(poli, paisMat, 1.009)); // Ponto dentro do polígono por lançamento de raio (lon, lat como x, y). const dentroDoPoli = (poli, la, lo) => { let dentro = false; for (let i = 0, j = poli.length - 1; i < poli.length; j = i++) { const yi = poli[i][0], xi = poli[i][1]; const yj = poli[j][0], xj = poli[j][1]; if (((yi > la) !== (yj > la)) && (lo < (xj - xi) * (la - yi) / (yj - yi) + xi)) dentro = !dentro; } return dentro; }; const luzPos = [], luzCor = []; const quente = new THREE.Color(0xFFD27A); const fria = new THREE.Color(0x93C5FD); const massas = CONTINENTES.concat(PAISES); massas.forEach((poli) => { let minLa = 90, maxLa = -90, minLo = 180, maxLo = -180; poli.forEach(([la, lo]) => { if (la < minLa) minLa = la; if (la > maxLa) maxLa = la; if (lo < minLo) minLo = lo; if (lo > maxLo) maxLo = lo; }); let achados = 0, tentativas = 0; const alvo = 260; while (achados < alvo && tentativas < alvo * 14) { tentativas++; const la = minLa + Math.random() * (maxLa - minLa); const lo = minLo + Math.random() * (maxLo - minLo); if (!dentroDoPoli(poli, la, lo)) continue; achados++; const v = latLonToVec3(la, lo, radius * 1.002); luzPos.push(v.x, v.y, v.z); const c = Math.random() < 0.22 ? fria : quente; const b = 0.45 + Math.random() * 0.55; luzCor.push(c.r * b, c.g * b, c.b * b); } }); const luzGeom = new THREE.BufferGeometry(); luzGeom.setAttribute('position', new THREE.Float32BufferAttribute(luzPos, 3)); luzGeom.setAttribute('color', new THREE.Float32BufferAttribute(luzCor, 3)); const luzMat = new THREE.PointsMaterial({ size: Math.max(1.1, radius * 0.008), vertexColors: true, transparent: true, opacity: 0.85, sizeAttenuation: true, blending: THREE.AdditiveBlending, depthWrite: false, }); const luzes = new THREE.Points(luzGeom, luzMat); planet.add(luzes); // anel orbital externo, inclinado e girando devagar. É o único elemento // com movimento próprio constante: dá vida ao globo nos segundos em que // nenhuma venda está pingando. const anelGeom = new THREE.RingGeometry(radius * 1.46, radius * 1.475, 160); const anelMat = new THREE.MeshBasicMaterial({ color: 0x38BDF8, transparent: true, opacity: 0.22, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false, }); const anel = new THREE.Mesh(anelGeom, anelMat); anel.rotation.x = Math.PI / 2.5; anel.rotation.y = 0.25; scene.add(anel); // A foto da Terra saiu de propósito. Ela era do threejs.org, pesava e // desenhava um planeta escuro em cima de um tema claro: o olho ia nele em // vez de ir no dashboard, que é o que o hero precisa vender. Sobra a esfera // de wireframe clara com os marcadores, que sugere "vendas no mundo" sem // roubar a cena. Bônus: uma requisição externa a menos no carregamento. // marcadores rodam JUNTO com o globo const markersGroup = new THREE.Group(); planet.add(markersGroup); // orientação inicial: Brasil de frente const brazilLatRad = -14 * Math.PI / 180; const brazilLonRad = -55 * Math.PI / 180; // texturas equirectangulares: lon 0 fica no z+ depois desta rotação const baseRotY = -brazilLonRad - Math.PI / 2; const baseRotX = -brazilLatRad * 0.6; planet.rotation.y = baseRotY; planet.rotation.x = baseRotX; function latLonToVec3(lat, lon, r) { const phi = (90 - lat) * Math.PI / 180; const theta = lon * Math.PI / 180; return new THREE.Vector3( r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), -r * Math.sin(phi) * Math.sin(theta), ); } const markers = []; // Cidades sempre acesas, fracas: o mapa existe antes da primeira venda // pingar. Sem elas o globo passa segundos completamente vazio e não lia // como mapa de vendas, só como bola listrada. const baseMat = new THREE.MeshBasicMaterial({ color: 0x2563EB, transparent: true, opacity: 0.6 }); const baseGeom = new THREE.SphereGeometry(2.6, 8, 8); BR_CITIES.forEach((c) => { const d = new THREE.Mesh(baseGeom, baseMat); d.position.copy(latLonToVec3(c.lat, c.lon, radius * 1.004)); markersGroup.add(d); }); const spawnMarker = () => { const c = BR_CITIES[Math.floor(Math.random() * BR_CITIES.length)]; const pos = latLonToVec3(c.lat, c.lon, radius * 1.005); const normal = pos.clone().normalize(); const dotMat = new THREE.MeshBasicMaterial({ color: 0x10B981, transparent: true, opacity: 1, }); const dot = new THREE.Mesh(new THREE.SphereGeometry(4.4, 12, 12), dotMat); dot.position.copy(pos); markersGroup.add(dot); const ringMat = new THREE.MeshBasicMaterial({ color: 0x10B981, transparent: true, opacity: 0.9, side: THREE.DoubleSide, depthWrite: false, }); const ring = new THREE.Mesh(new THREE.RingGeometry(4, 5.6, 32), ringMat); ring.position.copy(pos); ring.lookAt(pos.clone().add(normal)); markersGroup.add(ring); const beamMat = new THREE.LineBasicMaterial({ color: 0x10B981, transparent: true, opacity: 0.7, }); const beamGeom = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), normal.clone().multiplyScalar(20), ]); const beam = new THREE.Line(beamGeom, beamMat); beam.position.copy(pos); markersGroup.add(beam); markers.push({ dot, ring, beam, born: performance.now() }); }; // ── Arcos de venda: curvas brilhando cruzando o planeta ────── const arcs = []; const spawnArc = () => { const ai = Math.floor(Math.random() * BR_CITIES.length); let bi = Math.floor(Math.random() * BR_CITIES.length); if (bi === ai) bi = (bi + 1) % BR_CITIES.length; const a = BR_CITIES[ai], b = BR_CITIES[bi]; const pa = latLonToVec3(a.lat, a.lon, radius * 1.012); const pb = latLonToVec3(b.lat, b.lon, radius * 1.012); const mid = pa.clone().add(pb).multiplyScalar(0.5); const lift = 1.18 + (pa.distanceTo(pb) / (radius * 2)) * 0.6; mid.normalize().multiplyScalar(radius * lift); const curve = new THREE.QuadraticBezierCurve3(pa, mid, pb); const SEG = 48; const pts = curve.getPoints(SEG); const geom = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: Math.random() < 0.5 ? 0x60A5FA : 0x34D399, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, }); const line = new THREE.Line(geom, mat); geom.setDrawRange(0, 0); markersGroup.add(line); const headMat = new THREE.MeshBasicMaterial({ color: 0xE3EDFF, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, }); const head = new THREE.Mesh(new THREE.SphereGeometry(2.6, 10, 10), headMat); markersGroup.add(head); arcs.push({ line, geom, mat, head, headMat, curve, seg: SEG, born: performance.now() }); }; let raf = 0; let last = performance.now(); let elapsed = 0; let lastSpawn = 0; let lastArcSpawn = 0; const tick = (now) => { raf = requestAnimationFrame(tick); const dt = (now - last) / 1000; last = now; elapsed += dt; // Gira o mundo inteiro, devagar. A oscilação em torno do Brasil fazia // sentido quando só havia capitais brasileiras marcadas; com o mapa // inteiro pingando, metade das vendas acontecia nas costas do planeta. planet.rotation.y = baseRotY + elapsed * 0.075; planet.rotation.x = baseRotX + Math.sin(elapsed * 0.11) * 0.05; anel.rotation.z = elapsed * 0.06; if (now - lastSpawn > spawnEveryMs) { lastSpawn = now; spawnMarker(); } for (let i = markers.length - 1; i >= 0; i--) { const m = markers[i]; const age = (now - m.born) / 1000; const life = 3.4; if (age > life) { markersGroup.remove(m.dot); markersGroup.remove(m.ring); markersGroup.remove(m.beam); m.dot.geometry.dispose(); m.dot.material.dispose(); m.ring.geometry.dispose(); m.ring.material.dispose(); m.beam.geometry.dispose(); m.beam.material.dispose(); markers.splice(i, 1); continue; } const t = age / life; m.dot.scale.setScalar(1 + Math.sin(Math.min(1, age * 1.4) * Math.PI) * 0.5); m.dot.material.opacity = Math.max(0, 1 - t * 0.7); m.ring.scale.setScalar(1 + age * 3.2); m.ring.material.opacity = Math.max(0, 0.9 - t); m.beam.material.opacity = Math.max(0, 0.7 - t * 1.4); } // arcos de venda cruzando o planeta if (now - lastArcSpawn > spawnEveryMs * 1.15) { lastArcSpawn = now; spawnArc(); } for (let i = arcs.length - 1; i >= 0; i--) { const arc = arcs[i]; const age = (now - arc.born) / 1000; const alife = 2.8; if (age > alife) { markersGroup.remove(arc.line); markersGroup.remove(arc.head); arc.geom.dispose(); arc.mat.dispose(); arc.head.geometry.dispose(); arc.headMat.dispose(); arcs.splice(i, 1); continue; } const at = age / alife; const grow = Math.min(1, at / 0.55); // desenha o arco viajando arc.geom.setDrawRange(0, Math.max(2, Math.floor((arc.seg + 1) * grow))); const fadeIn = Math.min(1, at / 0.12); const fadeOut = at > 0.7 ? Math.max(0, (1 - at) / 0.3) : 1; arc.mat.opacity = 0.7 * fadeIn * fadeOut; const hp = arc.curve.getPoint(grow); arc.head.position.copy(hp); arc.headMat.opacity = (grow < 1 ? 1 : Math.max(0, (1 - at) / 0.3)); } renderer.render(scene, camera); }; raf = requestAnimationFrame(tick); const onResize = () => { const w = mount.clientWidth, h = mount.clientHeight; if (!w || !h) return; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); }; window.addEventListener('resize', onResize); // Pausa o loop quando não há nada pra ver: aba escondida OU globo fora da // viewport. Sem a segunda condição, o WebGL segue desenhando 60 vezes por // segundo o resto da página inteira abaixo do hero — a página fica pesada, // o compositor começa a rasterizar em qualidade baixa (aquele aspecto de // "240p" nas seções de baixo) e o renderizador chega a travar. let naTela = true; const parar = () => { if (raf) { cancelAnimationFrame(raf); raf = 0; } }; const seguir = () => { if (raf || document.hidden || !naTela) return; last = performance.now(); raf = requestAnimationFrame(tick); }; const onVis = () => { document.hidden ? parar() : seguir(); }; document.addEventListener('visibilitychange', onVis); const io = new IntersectionObserver(([e]) => { naTela = e.isIntersecting; naTela ? seguir() : parar(); }, { threshold: 0 }); io.observe(mount); return () => { if (raf) cancelAnimationFrame(raf); io.disconnect(); window.removeEventListener('resize', onResize); document.removeEventListener('visibilitychange', onVis); if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement); earthGeom.dispose(); earthMat.dispose(); atmo.geometry.dispose(); atmo.material.dispose(); atmo2.geometry.dispose(); atmo2.material.dispose(); descartar.forEach((g) => g.dispose()); gratMat.dispose(); equadorMat.dispose(); terraMat.dispose(); paisMat.dispose(); luzGeom.dispose(); luzMat.dispose(); anelGeom.dispose(); anelMat.dispose(); baseGeom.dispose(); baseMat.dispose(); markers.forEach(m => { m.dot.geometry.dispose(); m.dot.material.dispose(); m.ring.geometry.dispose(); m.ring.material.dispose(); m.beam.geometry.dispose(); m.beam.material.dispose(); }); arcs.forEach(arc => { arc.geom.dispose(); arc.mat.dispose(); arc.head.geometry.dispose(); arc.headMat.dispose(); }); renderer.dispose(); }; }, [radius, spawnEveryMs]); return (