← Biblioteca componentes/dados/globo-canvas/ficha.md CódigoTela cheia
dados/globo-canvas

Globo de pontos em canvas, girando pelo scroll

🟢 estavel complexidade alta prefixo globe- globo mapa canvas scroll internacional arcos dados

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/dados/globo-canvas/estilo.css">
<script src="componentes/dados/globo-canvas/script.js"></script>

markup.html · 28 linhas · 1,5 KB

<!-- ==========================================================================
     GLOBO EM CANVAS — esfera de pontos que gira pelo scroll
     Prefixo: globe-
     Requer: core (tokens, base, motion) + pontos.js, estilo.css e script.js
     A ordem dos scripts importa: pontos.js ANTES de script.js.

     O globo é fundo: fica atrás do conteúdo da seção, ancorado à direita.
     O [data-globe-anchor] é opcional — quando existe, a borda direita da
     esfera se alinha à borda direita daquele elemento (em geral o grid de
     cards que vem abaixo), o que amarra o globo à coluna em vez de à tela.
     ========================================================================== -->

<section class="globe-sec sec-dark sec-pad" data-globe>
  <div class="globe-media">
    <canvas class="globe-canvas" data-globe-canvas aria-hidden="true"></canvas>
  </div>
  <div class="grain"></div>

  <div class="wrap">
    <span class="eyebrow" data-rv>Nossa estrutura</span>
    <h2 class="sec-title ttl">Um ecossistema construído para atuação <em>global</em></h2>
    <p class="sec-lead" data-rv data-d="1">Consultoria, research independente e presença internacional reunidos para atender seu patrimônio onde ele estiver.</p>

    <div class="globe-slot" data-globe-anchor>
      <p class="globe-hint">Este bloco é o conteúdo da seção. Na origem é o <code>conteudo/grid-entidades</code>; a borda direita dele é o que ancora a esfera.</p>
    </div>
  </div>
</section>

estilo.css · 37 linhas · 1,6 KB

/* ==========================================================================
   GLOBO EM CANVAS — esfera de pontos que gira pelo scroll
   Prefixo: globe-
   Tokens usados: --fg-2 --rule --accent --ease
   O desenho em si é canvas: cor, raio e opacidade vivem no script, que lê
   --accent do elemento. Aqui só o posicionamento e a entrada.
   ========================================================================== */

.globe-sec{position:relative;overflow:hidden}
.globe-media{position:absolute;inset:0;z-index:0}
.globe-sec > .wrap{position:relative;z-index:2}

/* Entra por fade quando a seção aparece — o canvas já desenhou antes, mas
   surgir do nada é pior que surgir suave. */
.globe-canvas{position:absolute;top:0;right:0;opacity:0;transition:opacity 1.4s var(--ease)}
.globe-sec.is-drawn .globe-canvas{opacity:1}

/* Placeholder do conteúdo — some quando um componente real ocupar o slot. */
.globe-slot{margin-top:clamp(38px,6vh,60px);min-height:220px;border:1px dashed var(--rule);
  border-radius:12px;display:flex;align-items:center;padding:clamp(20px,3vw,32px)}
.globe-hint{font-size:14px;line-height:1.7;color:var(--fg-2);max-width:52ch}

/* No mobile o globo sai de trás do texto: vira um bloco próprio, de altura
   fixa, no fluxo normal, acima do título. Atrás do texto numa tela estreita
   ele só atrapalharia a leitura. */
@media(max-width:900px){
  .globe-media{
    position:relative;inset:auto;
    height:clamp(260px,75vw,364px);
    margin-bottom:clamp(16px,3vh,28px);
  }
  .globe-canvas{top:0;left:0;right:auto}
}

@media(prefers-reduced-motion:reduce){
  .globe-canvas{transition:none}
}

script.js · 269 linhas · 10,2 KB

/* ==========================================================================
   GLOBO EM CANVAS — esfera de pontos em projeção ortográfica
   Requer window.__GLOBE_PTS (pontos.js) carregado ANTES deste arquivo.

   Por que canvas e não SVG: os pontos chegam como lat/lng puros e são
   reprojetados a cada frame. É isso que permite girar — um SVG achatado
   ficaria parado.

   Sem giro automático: o ângulo segue o scroll. Ao entrar na dobra a face
   mostra as Américas; daí em diante descer gira num sentido e subir no
   outro, sempre relativo ao ponto de entrada — voltar ao topo da dobra
   re-sincroniza.
   ========================================================================== */

(function () {
  var raw = window.__GLOBE_PTS;
  if (!raw) return;

  /* Canvas não lê custom property. A cor é resolvida uma vez, a partir do
     --accent do próprio elemento, e virada em componentes rgb. */
  function corDoTema(el) {
    var v = getComputedStyle(el).getPropertyValue('--accent').trim();
    var m = v.match(/^#([0-9a-f]{3,8})$/i);
    if (m) {
      var h = m[1];
      if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
      return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
    }
    var nums = v.match(/(\d+(\.\d+)?)/g);
    return nums && nums.length >= 3 ? [+nums[0], +nums[1], +nums[2]] : [198, 206, 170];
  }

  document.querySelectorAll('[data-globe]').forEach(function (sec) {
    var cv = sec.querySelector('[data-globe-canvas]');
    var media = sec.querySelector('.globe-media');
    if (!cv || !media) return;

    var ctx = cv.getContext('2d');
    var TAU = Math.PI * 2, RAD = Math.PI / 180;
    var RGB = corDoTema(sec);
    function tinta(a) { return 'rgba(' + RGB[0] + ',' + RGB[1] + ',' + RGB[2] + ',' + a + ')'; }

    /* Pré-cálculo: sen/cos de lat e o lng de cada ponto. No frame sobra só
       a conta da rotação. */
    var n = raw.length / 2;
    var sLat = new Float32Array(n), cLat = new Float32Array(n), lng = new Float32Array(n);
    for (var i = 0; i < n; i++) {
      var la = (raw[i * 2] / 10) * RAD;
      sLat[i] = Math.sin(la); cLat[i] = Math.cos(la);
      lng[i] = (raw[i * 2 + 1] / 10) * RAD;
    }

    var LAT0 = 10 * RAD;                       // inclinação: Américas em foco
    var sLat0 = Math.sin(LAT0), cLat0 = Math.cos(LAT0);

    /* Rotas: pares {lat,lng} interpolados em 3D (slerp), para o arco seguir
       a curvatura real do globo em vez de uma reta em lat/lng.
       Troque esta lista pelas praças do projeto. */
    var ROTAS = [
      [{ lat: -23.5, lng: -46.6 }, { lat: 25.7, lng: -80.2 }],
      [{ lat: -23.5, lng: -46.6 }, { lat: 51.5, lng: -0.1 }],
      [{ lat: 40.7, lng: -74.0 }, { lat: 51.5, lng: -0.1 }]
    ];
    var PASSOS = 48;

    function vetor(p) {
      var la = p.lat * RAD, ln = p.lng * RAD, cl = Math.cos(la);
      return [cl * Math.cos(ln), cl * Math.sin(ln), Math.sin(la)];
    }

    var arcos = ROTAS.map(function (rota) {
      var va = vetor(rota[0]), vb = vetor(rota[1]);
      var dot = va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2];
      var om = Math.acos(Math.max(-1, Math.min(1, dot)));
      var sinOm = Math.sin(om) || 1e-6;
      var pts = [];
      for (var s = 0; s <= PASSOS; s++) {
        var t = s / PASSOS;
        var wa = Math.sin((1 - t) * om) / sinOm, wb = Math.sin(t * om) / sinOm;
        var x = wa * va[0] + wb * vb[0], y = wa * va[1] + wb * vb[1], z = wa * va[2] + wb * vb[2];
        pts.push({ sLat: z, cLat: Math.sqrt(x * x + y * y), lng: Math.atan2(y, x) });
      }
      return pts;
    });
    var revelado = ROTAS.map(function () { return 0; });

    var ancora = sec.querySelector('[data-globe-anchor]');
    var mqMobile = matchMedia('(max-width:900px)');
    var W = 0, H = 0, cx = 0, cy = 0, R = 0, dpr = 1;

    function dimensionar() {
      dpr = Math.min(devicePixelRatio || 1, 2);
      cv.width = W * dpr; cv.height = H * dpr;
      cv.style.width = W + 'px'; cv.style.height = H + 'px';
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }

    function medir() {
      /* No mobile o globo é um bloco próprio acima do texto: a composição
         é só centralizada nele, sem depender da coluna do conteúdo. */
      if (mqMobile.matches) {
        var rm = media.getBoundingClientRect();
        W = rm.width; H = rm.height;
        dimensionar();
        R = Math.min(W, H) * 0.42;
        cx = W / 2; cy = H / 2;
        return;
      }

      var r = sec.getBoundingClientRect();
      W = r.width; H = r.height;
      dimensionar();

      /* Ancorado no conteúdo, não na tela: a borda direita da esfera cai na
         mesma coluna do conteúdo. Sem âncora, cai na borda da seção. */
      var g = ancora ? ancora.getBoundingClientRect() : r;
      R = Math.min(g.width * 0.29, H * 0.24);
      cx = (g.right - r.left) - R;
      cy = R + Math.max(64, H * 0.09);          // folga para o menu fixo
    }

    /* Buckets de opacidade: um fill por bucket em vez de um por ponto.
       São ~3 mil pontos por frame — a diferença entre 7 fills e 3000 é a
       diferença entre rodar e travar. */
    var BUCKETS = 7;
    var trilhas = new Array(BUCKETS);

    /* O corte físico seria no horizonte (cosC = 0). Estendido um pouco além
       para a última leva de pontos entrar pequena e apagada, em vez de a
       borda "desligar" de uma vez. */
    var CORTE = -0.08;

    function projetar(sLaPt, cLaPt, lngPt, lng0) {
      var d = lngPt - lng0, cd = Math.cos(d);
      return {
        x: cx + R * cLaPt * Math.sin(d),
        y: cy - R * (cLat0 * sLaPt - sLat0 * cLaPt * cd),
        cosC: sLat0 * sLaPt + cLat0 * cLaPt * cd
      };
    }

    function desenhar(lng0) {
      ctx.clearRect(0, 0, W, H);
      if (R <= 0) return;

      // corpo da esfera: leve volume, mais claro no alto à esquerda
      var corpo = ctx.createRadialGradient(cx - R * .35, cy - R * .4, R * .1, cx, cy, R);
      corpo.addColorStop(0, 'rgba(255,255,255,.045)');
      corpo.addColorStop(1, 'rgba(255,255,255,.008)');
      ctx.fillStyle = corpo;
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, TAU); ctx.fill();

      // aro
      ctx.strokeStyle = tinta(.16);
      ctx.lineWidth = 1;
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, TAU); ctx.stroke();

      for (var b = 0; b < BUCKETS; b++) trilhas[b] = new Path2D();

      var rBase = Math.max(R * 0.0075, 0.9);
      var rBucket = new Array(BUCKETS);
      for (var bb = 0; bb < BUCKETS; bb++) rBucket[bb] = rBase * (0.4 + ((bb + 0.5) / BUCKETS) * 0.6);

      for (var i2 = 0; i2 < n; i2++) {
        var pr = projetar(sLat[i2], cLat[i2], lng[i2], lng0);
        if (pr.cosC <= CORTE) continue;
        var ajust = (pr.cosC - CORTE) / (1 - CORTE);
        var bi = (ajust * BUCKETS) | 0;
        if (bi > BUCKETS - 1) bi = BUCKETS - 1;
        if (bi < 0) bi = 0;
        var dr = rBucket[bi];
        trilhas[bi].moveTo(pr.x + dr, pr.y);
        trilhas[bi].arc(pr.x, pr.y, dr, 0, TAU);
      }

      for (var b2 = 0; b2 < BUCKETS; b2++) {
        var t = (b2 + 0.5) / BUCKETS;            // 0 = borda, 1 = centro
        ctx.fillStyle = tinta((0.02 + t * 0.68).toFixed(3));
        ctx.fill(trilhas[b2]);
      }

      for (var ai = 0; ai < arcos.length; ai++) desenharArco(lng0, arcos[ai], revelado[ai]);
    }

    function desenharArco(lng0, pts, t) {
      if (t <= 0) return;
      var ate = t * (pts.length - 1);
      var caminho = new Path2D();
      var tracando = false, ultimo = null;

      for (var k = 0; k <= ate; k++) {
        var pr = projetar(pts[k].sLat, pts[k].cLat, pts[k].lng, lng0);
        /* Corte um pouco antes do horizonte: sem isso o arco continua
           desenhado do outro lado da esfera, atravessando o globo. */
        if (pr.cosC <= 0.03) { tracando = false; continue; }
        if (!tracando) { caminho.moveTo(pr.x, pr.y); tracando = true; }
        else caminho.lineTo(pr.x, pr.y);
        ultimo = pr;
      }

      ctx.strokeStyle = tinta(.55);
      ctx.lineWidth = 1.3;
      ctx.stroke(caminho);

      // cometa na ponta enquanto ainda está desenhando
      if (ultimo && t < 0.98) {
        ctx.fillStyle = '#f2f0ec';
        ctx.beginPath(); ctx.arc(ultimo.x, ultimo.y, 2, 0, TAU); ctx.fill();
      }
    }

    /* ---- ângulo, scroll e entrada ---- */
    var FACE_AMERICAS = -78 * RAD;
    var PX_PARA_RAD = 0.0022;                  // ~1 volta a cada 2800px
    var angulo = FACE_AMERICAS, ancoraY = null, visto = false;

    medir();

    window.LIB.onScroll(function () {
      if (ancoraY === null) return;            // ainda não calibrado: não gira no escuro
      angulo = FACE_AMERICAS + (scrollY - ancoraY) * PX_PARA_RAD;
      desenhar(angulo);
    });

    var t;
    addEventListener('resize', function () {
      clearTimeout(t);
      t = setTimeout(function () { medir(); desenhar(angulo); }, 150);
    }, { passive: true });

    /* Os arcos desenham em sequência, não juntos: cada um começa ATRASO ms
       depois do anterior, para ler como "conexões acendendo uma de cada
       vez" em vez de uma malha inteira aparecendo de uma só. */
    var ARCO_MS = 1800, ATRASO_MS = 550;
    var totalMs = ATRASO_MS * (revelado.length - 1) + ARCO_MS;

    function revelar() {
      if (window.LIB.reduce) {
        for (var j = 0; j < revelado.length; j++) revelado[j] = 1;
        desenhar(angulo);
        return;
      }
      for (var j2 = 0; j2 < revelado.length; j2++) revelado[j2] = 0;
      var t0 = null;
      function passo(ts) {
        if (t0 === null) t0 = ts;
        var passado = ts - t0;
        for (var k = 0; k < revelado.length; k++) {
          var p = Math.min(Math.max((passado - k * ATRASO_MS) / ARCO_MS, 0), 1);
          revelado[k] = 1 - Math.pow(1 - p, 3);   // ease-out cúbica
        }
        desenhar(angulo);
        if (passado < totalMs) requestAnimationFrame(passo);
      }
      requestAnimationFrame(passo);
    }

    new IntersectionObserver(function (es) {
      if (!es[0].isIntersecting) return;
      if (!visto) { visto = true; sec.classList.add('is-drawn'); }
      /* Toda vez que a dobra (re)entra na tela a face reseta para as
         Américas — assim a leitura é sempre a mesma, independente de por
         onde a pessoa chegou. */
      ancoraY = scrollY;
      angulo = FACE_AMERICAS;
      revelar();
    }, { threshold: .12 }).observe(sec);
  });
})();

Esfera desenhada em canvas a partir de ~3 mil pontos de terra em lat/lng, em projeção ortográfica. Vive atrás do conteúdo da seção, ancorada à direita. Não gira sozinho: o ângulo segue a rolagem. Ao entrar na dobra a face mostra as Américas, e arcos entre praças acendem em sequência.


Quando usar

Quando não usar


Padrão editorial

O globo não tem texto. O que ele exige é coerência: as rotas em ROTAS precisam ser as praças reais da empresa, e o texto da seção precisa nomeá-las. Globo com arco para Londres numa seção que só fala do Brasil é ruído.


Como usar

<link rel="stylesheet" href="componentes/dados/globo-canvas/estilo.css">
<script src="componentes/dados/globo-canvas/pontos.js"></script>
<script src="componentes/dados/globo-canvas/script.js"></script>

A ordem importa: pontos.js antes de script.js. O primeiro só publica window.__GLOBE_PTS; o segundo sai em silêncio se não achar.

Trocar as praças

No script.js, a lista ROTAS é de pares {lat, lng}:

var ROTAS = [
  [{ lat: -23.5, lng: -46.6 }, { lat: 25.7, lng: -80.2 }],   // São Paulo → Miami
  [{ lat: -23.5, lng: -46.6 }, { lat: 51.5, lng: -0.1 }]     // São Paulo → Londres
];

Três arcos é o teto confortável; acima disso vira malha.

Ganchos

Gancho Papel
[data-globe] a seção
[data-globe-canvas] o canvas
[data-globe-anchor] opcional — a borda direita da esfera se alinha à borda direita deste elemento
.globe-sec.is-drawn entrada por fade

Pareamento

Na origem, o conteúdo por cima é o conteudo/grid-entidades, e o grid de cards é a âncora. Qualquer conteúdo serve — o [data-globe-anchor] é o que amarra a esfera à coluna em vez de à tela.


Gotchas


Histórico

Projeto Onde Data Diferenças
Liberta Wealth index.html #estrutura + initGlobe() em main.js 2026-08 Origem. Função initGlobe(structId, canvasId, opts) chamada por id, classes .struct/.struct-media/.geo-globe, estado .drawn, sage rgba(198,206,170,…) cravado em quatro lugares do canvas, e — o pior acoplamento — struct.querySelector('.ents') para achar a âncora: o globo dependia de o grid de entidades existir e se chamar assim.

Normalizações aplicadas na extração: prefixo globe-; ids → [data-globe] / [data-globe-canvas], o que permite mais de um globo na página; .drawn → .is-drawn; a cor passou a ser lida de --accent em vez de cravada, então o globo acompanha o tema; desacoplado do grid de entidades — a âncora virou [data-globe-anchor] opcional, com fallback para a própria seção; o listener de scroll com ticking próprio virou LIB.onScroll (que já faz o mesmo); reduce local → LIB.reduce; variáveis e funções renomeadas para português, seguindo o resto da biblioteca.