← Biblioteca componentes/dados/diagrama-hub/ficha.md CódigoTela cheia
dados/diagrama-hub

Diagrama hub com traços calculados sobre o DOM

🟢 estavel complexidade alta prefixo hub- diagrama svg arquitetura hub orbita runtime dados

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

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

markup.html · 78 linhas · 3,5 KB

<!-- ==========================================================================
     DIAGRAMA HUB — dois territórios ligados a um núcleo em órbita
     Prefixo: hub-
     Requer: core (tokens, base, motion) + estilo.css e script.js

     Os traços que ligam as pontas ao núcleo NÃO são coordenadas fixas: o
     script lê a posição real dos elementos no DOM e escreve o "d" dos
     paths. Por isso o diagrama acompanha qualquer largura, tradução ou
     mudança de conteúdo sem ninguém redesenhar SVG.

     Os traços nascem apagados (stroke-dashoffset) e são desenhados quando a
     seção entra na tela.
     ========================================================================== -->

<section class="sec-dark sec-pad" id="planejamento">
  <div class="wrap">
    <div class="sec-head">
      <span class="eyebrow" data-rv>Wealth planning</span>
      <h2 class="sec-title ttl">Sua <em>família</em> no centro da estratégia</h2>
      <p class="sec-lead" data-rv data-d="1">Conectamos alocação no Brasil e no exterior em uma arquitetura patrimonial única — pensada para o presente da sua empresa e o futuro da sua família.</p>
    </div>

    <div class="hub" data-hub>
      <svg class="hub-links" data-hub-links aria-hidden="true">
        <path class="hub-link" data-hub-link="left" d=""></path>
        <path class="hub-link" data-hub-link="right" d=""></path>
        <path class="hub-link hub-link-down" data-hub-link="down" d=""></path>
      </svg>

      <div class="hub-grid">
        <div class="hub-side" data-rv>
          <div class="hub-fig" data-hub-node="left">
            <img class="hub-ic" src="../../../showroom/demo/icon-territorio.svg" alt="" aria-hidden="true">
          </div>
          <div class="hub-cap"><b>Brasil</b><span>onshore</span></div>
          <ul class="hub-list">
            <li>Multicustódia, fundo exclusivo e carteira administrada</li>
            <li>Relatório de patrimônio consolidado</li>
            <li>Alocação orientada pelo passivo</li>
            <li>Holding familiar e estruturação tributária</li>
          </ul>
        </div>

        <div class="hub-core" data-hub-core data-rv data-d="1">
          <svg class="hub-orbit" viewBox="0 0 300 300" aria-hidden="true">
            <circle class="hub-ring" cx="150" cy="150" r="140"></circle>
            <g class="hub-spin">
              <circle class="hub-dot" cx="150" cy="10" r="4.5"></circle>
              <circle class="hub-dot is-sm" cx="150" cy="290" r="3"></circle>
            </g>
          </svg>
          <div class="hub-core-inner">
            <strong>Sua família<br>no centro</strong>
          </div>
        </div>

        <div class="hub-side is-right" data-rv data-d="2">
          <div class="hub-fig" data-hub-node="right">
            <img class="hub-ic" src="../../../showroom/demo/icon-globo.svg" alt="" aria-hidden="true">
          </div>
          <div class="hub-cap"><b>Exterior</b><span>offshore</span></div>
          <ul class="hub-list">
            <li>Conta de pessoa física no exterior</li>
            <li>Personal Investment Company</li>
            <li>Fundo exclusivo offshore</li>
            <li>Trust e seguro de vida internacional</li>
          </ul>
        </div>
      </div>

      <div class="hub-foot" data-hub-foot data-rv data-d="3">
        <span class="hub-foot-mark" aria-hidden="true"></span>
        <b>Terceira ramificação</b>
        <p>Soluções para sócios e empresas: gestão de caixa, sucessão empresarial, seguros, câmbio, consultoria estratégica e M&amp;A.</p>
      </div>
    </div>
  </div>
</section>

estilo.css · 128 linhas · 5,6 KB

/* ==========================================================================
   DIAGRAMA HUB — dois territórios ligados a um núcleo em órbita
   Prefixo: hub-
   Tokens usados: --fg --fg-2 --fg-muted --rule --rule-2 --accent --accent-2
                  --display --sans --ease
   ========================================================================== */


.hub-head .eyebrow{margin-inline:auto}
/* nowrap: o título é curto e centralizado; quebrar em duas linhas aqui
   desequilibra a composição simétrica do diagrama logo abaixo. */
.hub-head .sec-title{max-width:none;margin-inline:auto;white-space:nowrap}
.hub-head .sec-lead{max-width:63ch;margin-inline:auto}

.hub{position:relative;margin-top:clamp(50px,8vh,86px)}

/* ---- os traços ----
   O "d" de cada path é escrito pelo script, a partir da posição real dos
   elementos. pathLength="1" (também posto pelo script) normaliza o
   comprimento, então dasharray/dashoffset:1 vale para qualquer forma. */
.hub-links{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;z-index:0}
.hub-link{fill:none;stroke:var(--rule-2);stroke-width:1;stroke-dasharray:1;stroke-dashoffset:1}
.hub.is-drawn .hub-link{transition:stroke-dashoffset 1.4s var(--ease);stroke-dashoffset:0}
.hub.is-drawn .hub-link-down{transition-delay:.25s}

.hub-grid{
  display:grid;
  grid-template-columns:minmax(0,1fr) clamp(210px,24vw,290px) minmax(0,1fr);
  gap:clamp(20px,3vw,48px);align-items:center;position:relative;z-index:1;
}

/* ---- figuras ---- */
.hub-fig{width:clamp(78px,9vw,112px);margin-bottom:22px}
.is-right .hub-fig{margin-left:auto}
/* Os ícones chegam como arquivo, então entram por fade. O "desenho" por
   stroke-dashoffset só funcionaria com o SVG inline. */
.hub-ic{
  width:100%;height:auto;display:block;
  opacity:0;transform:translateY(8px);
  transition:opacity 1s var(--ease) .15s,transform 1s var(--ease) .15s;
}
.hub.is-drawn .hub-ic{opacity:1;transform:none}
@media(prefers-reduced-motion:reduce){.hub-ic{opacity:1;transform:none;transition:none}}

/* ---- núcleo ----
   Sem borda nem fundo próprio: o único círculo da composição é o anel
   externo. Um segundo círculo em volta do texto vira alvo, não centro. */
.hub-core{position:relative;aspect-ratio:1;display:grid;place-items:center}
.hub-orbit{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.hub-ring{fill:none;stroke:var(--rule-2);stroke-width:1}
.hub-spin{transform-origin:150px 150px;animation:hubOrbit 26s linear infinite}
.hub-dot{fill:var(--accent)}
.hub-dot.is-sm{fill:var(--accent-2)}
@keyframes hubOrbit{from{transform:rotate(0)}to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){.hub-spin{animation:none}}

.hub-core-inner{
  position:relative;z-index:2;text-align:center;padding:16px;
  width:62%;aspect-ratio:1;display:grid;place-content:center;
}
.hub-core-inner strong{
  display:block;font-family:var(--sans);font-style:italic;font-weight:300;
  font-size:clamp(15px,1.7vw,19px);line-height:1.3;color:var(--fg);
}

/* ---- listas laterais ---- */
.hub-side{min-width:0}
.hub-cap{display:flex;align-items:baseline;gap:10px;padding-bottom:14px;border-bottom:1px solid var(--rule)}
.hub-cap b{
  font-family:var(--display);font-weight:700;font-size:12.5px;
  letter-spacing:.1em;text-transform:uppercase;color:var(--accent);
}
.hub-cap span{font-size:11px;color:var(--fg-muted);letter-spacing:.06em}

.hub-list li{
  font-size:15px;line-height:1.6;color:var(--fg-2);
  padding:13px 0;border-bottom:1px solid var(--rule);
  display:flex;gap:11px;align-items:flex-start;
  transition:color .3s,border-color .3s;
}
.hub-list li:hover{color:var(--fg);border-bottom-color:var(--rule-2)}
.hub-list li::before{
  content:"";flex:0 0 auto;width:5px;height:5px;margin-top:8px;border-radius:50%;
  background:var(--accent-2);transition:background .3s,transform .3s;
}
.hub-list li:hover::before{background:var(--accent);transform:scale(1.3)}

/* A coluna da direita é espelhada: texto, marcador e legenda invertem. */
.hub-side.is-right{text-align:right}
.hub-side.is-right .hub-cap{justify-content:flex-end}
.hub-side.is-right .hub-list li{flex-direction:row-reverse}

/* ---- terceira ramificação ----
   Não é card avulso: pendura no mesmo traço que liga as pontas ao núcleo
   (o hub-link-down, calculado a partir da base do núcleo). */
.hub-foot{
  margin-top:clamp(46px,7vh,72px);padding-top:clamp(22px,3vh,32px);
  display:flex;flex-direction:column;align-items:center;text-align:center;
  position:relative;z-index:1;
}
.hub-foot-mark{
  position:absolute;top:0;left:50%;width:6px;height:6px;background:var(--accent-2);
  transform:translate(-50%,-50%) rotate(45deg);
}
.hub-foot b{
  font-family:var(--display);font-weight:700;font-size:12px;
  letter-spacing:.1em;text-transform:uppercase;color:var(--accent);white-space:nowrap;
}
.hub-foot p{font-size:15px;line-height:1.6;color:var(--fg-muted);max-width:50ch;margin-top:10px}

/* ---- mobile ----
   Uma coluna, tudo centralizado, sem traços: em pilha vertical eles ligariam
   coisas que já estão obviamente ligadas. */
@media(max-width:900px){
  .hub-grid{grid-template-columns:1fr;gap:38px}
  .hub-links{display:none}
  .hub-core{width:100%;max-width:224px;margin-inline:auto}
  .hub-core-inner{width:72%;padding:14px;place-items:center}
  .hub-fig,.is-right .hub-fig{margin-inline:auto}
  .hub-side,.hub-side.is-right{text-align:center}
  .hub-cap,.hub-side.is-right .hub-cap{justify-content:center}
  .hub-list li,.hub-side.is-right .hub-list li{display:block;text-align:center}
  .hub-list li::before{display:none}
  .hub-cap b{font-size:14px}
  .hub-foot b{font-size:14px}
}

@media(max-width:560px){.hub-head .sec-title{white-space:normal}}

script.js · 83 linhas · 3,4 KB

/* ==========================================================================
   DIAGRAMA HUB — traços calculados sobre a posição real do DOM

   A ideia toda: em vez de desenhar um SVG com coordenadas fixas, medimos
   onde os elementos realmente caíram e escrevemos o "d" dos paths. O
   diagrama passa a acompanhar qualquer largura, qualquer tradução e
   qualquer mudança de conteúdo sem ninguém abrir o Illustrator.
   ========================================================================== */

(function () {
  document.querySelectorAll('[data-hub]').forEach(function (hub) {
    var svg  = hub.querySelector('[data-hub-links]');
    var core = hub.querySelector('[data-hub-core]');
    if (!svg || !core) return;

    var linkL = hub.querySelector('[data-hub-link="left"]');
    var linkR = hub.querySelector('[data-hub-link="right"]');
    var linkD = hub.querySelector('[data-hub-link="down"]');
    var noL   = hub.querySelector('[data-hub-node="left"]');
    var noR   = hub.querySelector('[data-hub-node="right"]');
    var pe    = hub.querySelector('[data-hub-foot]');

    /* pathLength="1" normaliza o comprimento de qualquer traço, então o
       dasharray/dashoffset:1 do CSS vale para todos. Escrever o offset
       inline aqui NÃO funcionaria: estilo inline vence a regra .is-drawn e
       o traço nunca chegaria a zero. */
    function normalizar(el) { el.setAttribute('pathLength', '1'); }

    function ponto(el, lado) {
      var r = el.getBoundingClientRect();
      var h = hub.getBoundingClientRect();
      var x = lado === 'left' ? r.left : lado === 'right' ? r.right : r.left + r.width / 2;
      var y = lado === 'top'  ? r.top  : lado === 'bottom' ? r.bottom : r.top + r.height / 2;
      return { x: x - h.left, y: y - h.top };
    }

    function reta(a, b) { return 'M' + a.x + ',' + a.y + ' L' + b.x + ',' + b.y; }

    function medir() {
      if (innerWidth <= 900) return;            // no mobile não há traços

      var h = hub.getBoundingClientRect();
      svg.setAttribute('viewBox', '0 0 ' + h.width + ' ' + h.height);

      var esq = ponto(noL, 'right');
      var dir = ponto(noR, 'left');

      /* Linha reta e nivelada com o ícone, mas partindo da BORDA da div do
         núcleo (não do anel visível). A div é maior que o anel, então isso
         deixa a mesma folga entre traço e anel nos três links — inclusive
         no de baixo, que sai da base da mesma div. */
      var coreE = { x: ponto(core, 'left').x,  y: esq.y };
      var coreD = { x: ponto(core, 'right').x, y: dir.y };

      linkL.setAttribute('d', reta(esq, coreE));
      linkR.setAttribute('d', reta(dir, coreD));
      normalizar(linkL); normalizar(linkR);

      if (pe && linkD) {
        linkD.setAttribute('d', reta(ponto(core, 'bottom'), ponto(pe, 'top')));
        normalizar(linkD);
      }
    }

    hub.querySelectorAll('.hub-link').forEach(normalizar);
    medir();
    /* Fontes carregam depois do primeiro layout e mudam a altura das listas,
       o que move os nós. Medir de novo no load resolve. */
    addEventListener('load', medir);

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

    new IntersectionObserver(function (es, obs) {
      if (!es[0].isIntersecting) return;
      hub.classList.add('is-drawn');
      obs.disconnect();
    }, { threshold: .2 }).observe(hub);
  });
})();

Composição simétrica: dois territórios com listas de itens nas laterais, um núcleo circular no meio com um ponto em órbita, e uma terceira ramificação pendurada embaixo. Os traços que ligam tudo são SVG desenhados em runtime, a partir da posição real que os elementos ocuparam na tela — e entram sendo traçados quando a seção aparece.

Nenhuma coordenada é fixa. É o que faz o diagrama sobreviver a qualquer largura, tradução ou mudança de texto sem ninguém reabrir o Illustrator.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Título 1 linha curta, uma palavra em <em>. Ele é nowrap no desktop — se não couber em uma linha, encurte o texto, não mexa no CSS.
.hub-cap b 1 palavra: o território. "Brasil", "Exterior".
.hub-cap span 1 palavra técnica, minúscula: "onshore", "offshore".
.hub-list 4 itens de cada lado, mesma contagem. 5 a 9 palavras cada.
.hub-core-inner 3 a 5 palavras, com <br> manual. É a tese da seção: "Sua família no centro".
.hub-foot Rótulo curto em caixa alta e 1 frase de até 50ch.

Contagem igual dos dois lados é estrutural. O align-items:center do grid centraliza cada coluna; com listas de alturas muito diferentes, os traços saem em alturas que não conversam.


Como usar

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

Ganchos

Gancho Papel
[data-hub] a raiz, e o sistema de coordenadas dos traços
[data-hub-links] o SVG dos traços
[data-hub-core] o núcleo — origem dos três traços
[data-hub-node="left"] / "right" as figuras laterais, pontas dos traços
[data-hub-foot] a terceira ramificação
.hub.is-drawn dispara o traçado e a entrada dos ícones

Variações


Gotchas


Histórico

Projeto Onde Data Diferenças
Liberta Wealth index.html #planejamento + main.js 2026-08 Origem. Ids #hub, #hubLinks, #linkL/R/D, #hubCore, #figBR, #figEX — o script conhecia cada elemento pelo nome. Classes .cap, .o-ring, .o-dot, .o-spin, .hub-foot-dm fora do prefixo, .drawn como estado, --sage/--tan cruas.

Normalizações aplicadas na extração: ids → [data-hub-*], o que permite mais de um diagrama na página e desacopla o script dos nomes do conteúdo (figBR/figEX amarravam o código a "Brasil" e "Exterior"); .cap → .hub-cap, .o-* → .hub-ring/.hub-dot/.hub-spin, .hub-foot-dm → .hub-foot-mark; .drawn → .is-drawn; --sage/--tan → --accent/--accent-2.

Código morto removido: o CSS de origem ainda trazia .geo-line, .geo-wire, .geo-arc e .geo-pin — classes de uma versão anterior em que os mapas eram SVG inline "desenhados" por stroke-dashoffset. Na versão que rodou, os mapas viraram <img> e essas regras nunca mais foram usadas. Não vieram para a biblioteca.