← Biblioteca componentes/dados/regua-liquidez/ficha.md CódigoTela cheia
dados/regua-liquidez

Régua de liquidez com objetivos arrastáveis

🟢 estavel complexidade alta prefixo liq- grafico area svg linha-do-tempo arrasto ferramenta liquidez horizonte objetivos interativo

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

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

markup.html · 160 linhas · 10,1 KB

<!-- ==========================================================================
     RÉGUA DE LIQUIDEZ — objetivos arrastáveis numa linha do tempo de 30 anos
     Prefixo: liq-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Usa as primitivas .wrap .sec-pad .sec-head .eyebrow .sec-title .sec-lead
     .btn .btn-ghost do core.

     Três camadas alinhadas pelo mesmo eixo, todas filhas de .liq-plot:
       .liq-track   as cinco fichas, posicionadas por --liq-ano (0 a 30)
       .liq-rail    a régua: linha, marcas de 5 em 5 anos e rótulos
       .liq-graf    a área em SVG, viewBox 0 0 300 100 (x = ano × 10)

     A posição de cada ficha vive no HTML, em duas variáveis:
       style="--liq-ano:5;--liq-fila:0"
     --liq-ano  é o ano, de 0 a 30 — o eixo x
     --liq-fila é a faixa vertical, de 0 para cima — só muda quando duas
                fichas ficam perto demais e o script as desempilha

     O valor de cada objetivo vive em data-liq-valor, em MILHARES de reais.
     É de lá que saem as três leituras de horizonte — e é lá que o consumidor
     troca os números ilustrativos, não no script (o estado sem JavaScript
     precisa do mesmo dado, e duas cópias divergiriam no primeiro ajuste).

     SEM JAVASCRIPT a peça fica inteira: as cinco fichas nas posições do HTML,
     a área já desenhada, as três leituras corretas. Só não arrasta — e a
     frase que promete o arrasto nasce `hidden`, revelada pelo script.
     ========================================================================== -->

<section class="liq-sec sec-dark sec-pad" id="liquidez">
  <div class="wrap">

    <div class="sec-head liq-head">
      <span class="eyebrow" data-rv>Liquidez</span>
      <h2 class="sec-title ttl">Cada objetivo tem um <em>prazo</em></h2>
      <p class="sec-lead liq-lead" data-rv data-d="1">A carteira se organiza a partir de quando o dinheiro é necessário. Não a partir do que rende mais.</p>
    </div>

    <div class="liq-quadro" data-rv data-d="2" data-liq>

      <p class="liq-ajuda">Cada ficha marca quando o dinheiro é necessário.<span class="liq-ajuda-js" hidden> Arraste uma ficha pela régua — ou dê foco nela e use as setas.</span></p>

      <!-- CONTEÚDO ILUSTRATIVO — objetivos, anos e valores são exemplo -->
      <div class="liq-scroll">
        <div class="liq-plot">

          <div class="liq-track">

            <div class="liq-ficha" style="--liq-ano:0;--liq-fila:0"
                 data-liq-valor="240" data-liq-rotulo="Reserva de emergência"
                 aria-label="Reserva de emergência, R$ 240 mil. As setas movem o ano na régua.">
              <span class="liq-ficha-topo">
                <svg class="liq-icone" viewBox="0 0 18 18" aria-hidden="true" focusable="false"><path d="M9 2.2 3.6 4.2v4.1c0 3.2 2.2 5.6 5.4 7.5 3.2-1.9 5.4-4.3 5.4-7.5V4.2Z"/></svg>
                <span class="liq-ficha-ano" aria-live="polite" aria-atomic="true">hoje</span>
              </span>
              <span class="liq-ficha-rot">Reserva de emergência</span>
              <span class="liq-ficha-val">R$ 240 mil</span>
            </div>

            <div class="liq-ficha" style="--liq-ano:5;--liq-fila:0"
                 data-liq-valor="900" data-liq-rotulo="Troca de imóvel"
                 aria-label="Troca de imóvel, R$ 900 mil. As setas movem o ano na régua.">
              <span class="liq-ficha-topo">
                <svg class="liq-icone" viewBox="0 0 18 18" aria-hidden="true" focusable="false"><path d="M2.8 8.2 9 3l6.2 5.2M4.6 7v8h8.8V7"/></svg>
                <span class="liq-ficha-ano" aria-live="polite" aria-atomic="true">em 5 anos</span>
              </span>
              <span class="liq-ficha-rot">Troca de imóvel</span>
              <span class="liq-ficha-val">R$ 900 mil</span>
            </div>

            <div class="liq-ficha" style="--liq-ano:12;--liq-fila:0"
                 data-liq-valor="600" data-liq-rotulo="Faculdade dos filhos"
                 aria-label="Faculdade dos filhos, R$ 600 mil. As setas movem o ano na régua.">
              <span class="liq-ficha-topo">
                <svg class="liq-icone" viewBox="0 0 18 18" aria-hidden="true" focusable="false"><path d="M1.8 6.6 9 3.3l7.2 3.3L9 9.9ZM4.8 8.1v3.6c0 1.2 1.9 2.1 4.2 2.1s4.2-.9 4.2-2.1V8.1M15.6 7.4v3.3"/></svg>
                <span class="liq-ficha-ano" aria-live="polite" aria-atomic="true">em 12 anos</span>
              </span>
              <span class="liq-ficha-rot">Faculdade dos filhos</span>
              <span class="liq-ficha-val">R$ 600 mil</span>
            </div>

            <div class="liq-ficha" style="--liq-ano:18;--liq-fila:0"
                 data-liq-valor="1100" data-liq-rotulo="Venda da empresa"
                 aria-label="Venda da empresa, R$ 1,1 milhão. As setas movem o ano na régua.">
              <span class="liq-ficha-topo">
                <svg class="liq-icone" viewBox="0 0 18 18" aria-hidden="true" focusable="false"><path d="M4.2 15V3.8h9.6V15M2.4 15h13.2M6.7 6.6h1.4M9.9 6.6h1.4M6.7 9.4h1.4M9.9 9.4h1.4"/></svg>
                <span class="liq-ficha-ano" aria-live="polite" aria-atomic="true">em 18 anos</span>
              </span>
              <span class="liq-ficha-rot">Venda da empresa</span>
              <span class="liq-ficha-val">R$ 1,1 mi</span>
            </div>

            <div class="liq-ficha" style="--liq-ano:27;--liq-fila:0"
                 data-liq-valor="1800" data-liq-rotulo="Renda vitalícia"
                 aria-label="Renda vitalícia, R$ 1,8 milhão. As setas movem o ano na régua.">
              <span class="liq-ficha-topo">
                <svg class="liq-icone" viewBox="0 0 18 18" aria-hidden="true" focusable="false"><path d="M2.4 12.6h13.2M4.6 12.6a4.4 4.4 0 0 1 8.8 0"/></svg>
                <span class="liq-ficha-ano" aria-live="polite" aria-atomic="true">em 27 anos</span>
              </span>
              <span class="liq-ficha-rot">Renda vitalícia</span>
              <span class="liq-ficha-val">R$ 1,8 mi</span>
            </div>

          </div>

          <div class="liq-rail" aria-hidden="true">
            <span class="liq-marca" style="--liq-ano:0"><span class="liq-marca-txt">hoje</span></span>
            <span class="liq-marca" style="--liq-ano:5"><span class="liq-marca-txt">5</span></span>
            <span class="liq-marca" style="--liq-ano:10"><span class="liq-marca-txt">10</span></span>
            <span class="liq-marca" style="--liq-ano:15"><span class="liq-marca-txt">15</span></span>
            <span class="liq-marca" style="--liq-ano:20"><span class="liq-marca-txt">20</span></span>
            <span class="liq-marca" style="--liq-ano:25"><span class="liq-marca-txt">25</span></span>
            <span class="liq-marca" style="--liq-ano:30"><span class="liq-marca-txt">30 anos</span></span>
          </div>

          <!-- Decorativo: o dado desta seção está nas fichas e nas leituras.
               preserveAspectRatio="none" estica o eixo x até a largura da
               coluna; por isso os traços levam vector-effect. -->
          <svg class="liq-graf" viewBox="0 0 300 100" preserveAspectRatio="none" aria-hidden="true" focusable="false">
            <path class="liq-grade" d="M50 0V100M100 0V100M150 0V100M200 0V100M250 0V100" vector-effect="non-scaling-stroke"/>
            <path class="liq-area" d="M0 65.1L5 75L10 93.4L15 100L20 100L25 99.7L30 89.3L35 67.6L40 42.4L45 22.4L50 14.9L55 39L60 84L65 100L70 100L75 100L80 98.6L85 95.1L90 90L95 83.8L100 77.3L105 71.2L110 66.3L115 63L120 61.9L125 71.7L130 89.1L135 91.8L140 85.9L145 79.2L150 71.9L155 64.6L160 57.8L165 52L170 47.5L175 44.7L180 43.8L185 59.7L190 89.4L195 100L200 100L205 100L210 100L215 98.4L220 93.8L225 86.5L230 77L235 65.9L240 54L245 42.1L250 31L255 21.5L260 14.2L265 9.6L270 8L275 34L280 82.7L285 100L290 100L295 100L300 100L0 100Z"/>
            <path class="liq-linha" pathLength="1" vector-effect="non-scaling-stroke" d="M0 65.1L5 75L10 93.4L15 100L20 100L25 99.7L30 89.3L35 67.6L40 42.4L45 22.4L50 14.9L55 39L60 84L65 100L70 100L75 100L80 98.6L85 95.1L90 90L95 83.8L100 77.3L105 71.2L110 66.3L115 63L120 61.9L125 71.7L130 89.1L135 91.8L140 85.9L145 79.2L150 71.9L155 64.6L160 57.8L165 52L170 47.5L175 44.7L180 43.8L185 59.7L190 89.4L195 100L200 100L205 100L210 100L215 98.4L220 93.8L225 86.5L230 77L235 65.9L240 54L245 42.1L250 31L255 21.5L260 14.2L265 9.6L270 8L275 34L280 82.7L285 100L290 100L295 100L300 100"/>
          </svg>

          <p class="liq-eixo-nota">Exigência de liquidez ao longo do tempo — forma relativa, sem escala de valor.</p>

        </div>
      </div>

      <div class="liq-saida">
        <div class="liq-saida-col">
          <h3 class="liq-saida-tit">Faixa por horizonte</h3>
          <!-- CONTEÚDO ILUSTRATIVO — percentuais calculados sobre os valores acima -->
          <ul class="liq-leituras">
            <li class="liq-leitura" style="--liq-frac:.05">
              <span class="liq-leitura-num"><span class="liq-leitura-pct">5</span>%</span>
              <span class="liq-leitura-rot">nos próximos 12 meses</span>
              <span class="liq-leitura-barra"></span>
            </li>
            <li class="liq-leitura" style="--liq-frac:.19">
              <span class="liq-leitura-num"><span class="liq-leitura-pct">19</span>%</span>
              <span class="liq-leitura-rot">no médio prazo, de 1 a 5 anos</span>
              <span class="liq-leitura-barra"></span>
            </li>
            <li class="liq-leitura" style="--liq-frac:.76">
              <span class="liq-leitura-num"><span class="liq-leitura-pct">76</span>%</span>
              <span class="liq-leitura-rot">de longo prazo, acima de 5 anos</span>
              <span class="liq-leitura-barra"></span>
            </li>
          </ul>
        </div>

        <div class="liq-saida-col">
          <p class="nota liq-nota">Simulação ilustrativa, para orientar a conversa. As três faixas são a divisão aritmética dos objetivos que você mesmo posicionou na régua — não são recomendação de investimento nem promessa de rentabilidade. Cenários passados não garantem resultado futuro.</p>
          <a class="btn btn-ghost liq-btn" href="#contato">Levar esta régua a uma conversa</a>
        </div>
      </div>

    </div>
  </div>
</section>

estilo.css · 255 linhas · 9,9 KB

/* ==========================================================================
   RÉGUA DE LIQUIDEZ — objetivos arrastáveis numa linha do tempo de 30 anos
   Prefixo: liq-
   Tokens usados: --accent --bg --display --ease --fg --fg-2 --fg-muted
                  --radius-media --rule --rule-2
   Primitivas usadas: .wrap .sec-pad .sec-head .eyebrow .sec-title .sec-lead
                      .btn .btn-ghost (core/base.css)

   O eixo x é um só para as três camadas, e é por isso que elas são irmãs
   dentro de .liq-plot: a ficha, a marca da régua e o pico da área caem na
   mesma coluna de pixels porque todas leem a mesma largura de conteúdo.

   O recuo lateral de .liq-plot é METADE da ficha. Sem ele, a ficha do ano 0
   (centrada em left:0 com translateX(-50%)) sairia meia caixa para fora.
   ========================================================================== */

.liq-quadro{
  /* ---- Parafusos públicos ---- */
  --liq-ficha-w:122px;    /* largura da ficha */
  --liq-ficha-h:104px;    /* altura FIXA da ficha — é o que alinha as filas */
  --liq-fila-h:116px;     /* passo vertical entre duas filas (altura + folga) */
  --liq-haste:24px;       /* px entre a ficha da fila 0 e a régua */
  --liq-graf-h:132px;     /* altura da área */
  --liq-min:920px;        /* largura mínima do gráfico antes de rolar de lado */
  --liq-anos:30;          /* fim da régua, em anos */

  margin-top:clamp(38px,5vh,62px);
  border:1px solid var(--rule);
  padding:clamp(20px,2.2vw,28px);
}

.liq-ajuda{font-size:13.5px;line-height:1.5;color:var(--fg-muted)}

/* ---- o eixo ----
   .liq-scroll rola de lado em tela estreita (ver a nota do mobile no fim).
   O padding vertical existe para o anel de foco do core (2px a 3px de
   distância) não ser cortado pelo overflow do scroller. */
.liq-scroll{
  margin-top:22px;
  overflow-x:auto;overflow-y:hidden;
  overscroll-behavior-x:contain;
  padding-block:10px;
}
.liq-plot{
  min-width:var(--liq-min);
  padding-inline:calc(var(--liq-ficha-w) / 2);
}

/* ---- as fichas ----
   min-height cresce uma fila de cada vez, escrita pelo script em --liq-filas.
   O padrão 1 no var() é o que mantém a régua com a altura certa sem
   JavaScript, onde ninguém empilha nada. */
.liq-track{
  position:relative;
  min-height:calc(
    var(--liq-ficha-h) + var(--liq-haste)
    + (var(--liq-filas,1) - 1) * var(--liq-fila-h)
  );
  transition:min-height .3s var(--ease);
}

.liq-ficha{
  position:absolute;
  left:calc(var(--liq-ano) / var(--liq-anos) * 100%);
  bottom:calc(var(--liq-haste) + var(--liq-fila,0) * var(--liq-fila-h));
  /* A fila 0 é a mais próxima da régua e tem de cobrir a haste das filas de
     cima, que a atravessam para chegar à linha. Por isso o z-index DESCE
     conforme a fila sobe. */
  z-index:calc(9 - var(--liq-fila,0));
  width:var(--liq-ficha-w);height:var(--liq-ficha-h);
  transform:translateX(-50%);
  display:flex;flex-direction:column;
  padding:10px 11px;
  border:1px solid var(--rule-2);
  border-radius:var(--radius-media);
  background:color-mix(in srgb,var(--fg) 6%,var(--bg));
  /* pan-y deixa a página rolar no toque vertical: o componente só toma para
     si o gesto horizontal. */
  touch-action:pan-y;
  user-select:none;-webkit-user-select:none;
  transition:left .24s var(--ease),bottom .3s var(--ease),
             border-color .3s var(--ease),background-color .3s var(--ease);
}
.liq-ficha:hover{border-color:color-mix(in srgb,var(--accent) 55%,transparent)}

/* A haste desce da ficha até a régua. Fica atrás da própria ficha
   (z-index:-1 dentro do contexto que o transform da ficha cria) e atrás das
   fichas das filas de baixo, pela regra de z-index acima. */
.liq-ficha::after{
  content:"";position:absolute;left:50%;top:100%;
  width:1px;height:calc(var(--liq-haste) + var(--liq-fila,0) * var(--liq-fila-h));
  background:var(--rule-2);z-index:-1;
}
.liq-ficha.is-dragging{
  transition:none;z-index:20;
  border-color:var(--accent);
  background:color-mix(in srgb,var(--fg) 10%,var(--bg));
}
.liq-ficha.is-dragging::after{background:var(--accent)}
/* Só promete o gesto quando o script está de pé: sem ele a ficha não arrasta
   e uma mão aberta de cursor mentiria. */
.liq-quadro.is-live .liq-ficha{cursor:grab}
.liq-quadro.is-live .liq-ficha.is-dragging{cursor:grabbing}

@media(prefers-reduced-motion:reduce){
  /* Reposicionar sem transição: o arrasto continua (é gesto pedido), mas a
     ficha e a régua deixam de deslizar sozinhas depois do encaixe. */
  .liq-ficha,.liq-track{transition:none}
}

.liq-ficha-topo{display:flex;align-items:center;justify-content:space-between;gap:8px;line-height:1.2}
.liq-icone{
  width:16px;height:16px;flex:0 0 16px;
  fill:none;stroke:var(--accent);stroke-width:1.2;
  stroke-linecap:round;stroke-linejoin:round;
}
.liq-ficha-ano{
  font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;
  color:var(--fg-muted);white-space:nowrap;
}
.liq-ficha-rot{margin-top:8px;font-size:12px;line-height:1.28;color:var(--fg)}
.liq-ficha-val{
  margin-top:auto;
  font-family:var(--display);font-weight:700;font-size:14px;line-height:1.25;
  letter-spacing:-.01em;color:var(--fg-2);
}

/* ---- a régua ---- */
.liq-rail{position:relative;height:30px;border-top:1px solid var(--rule-2)}
.liq-marca{
  position:absolute;top:0;
  left:calc(var(--liq-ano) / var(--liq-anos) * 100%);
  transform:translateX(-50%);
}
.liq-marca::before{
  content:"";position:absolute;left:50%;top:0;
  width:1px;height:7px;background:var(--rule-2);
}
.liq-marca-txt{
  display:block;margin-top:12px;
  font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;
  color:var(--fg-muted);white-space:nowrap;
}

/* ---- a área ----
   O SVG é decorativo (aria-hidden): o dado está nas fichas e nas leituras.
   A máscara é o degradê que o gradiente de preenchimento daria — e sem ela
   seria preciso um <linearGradient> com id, que o contrato não quer. */
.liq-graf{
  display:block;width:100%;height:var(--liq-graf-h);margin-top:16px;
  mask-image:linear-gradient(to top,#000 40%,rgba(0,0,0,.55) 100%);
  -webkit-mask-image:linear-gradient(to top,#000 40%,rgba(0,0,0,.55) 100%);
}
.liq-grade{fill:none;stroke:var(--rule);stroke-width:1}
.liq-area{fill:color-mix(in srgb,var(--accent) 20%,transparent);stroke:none}
/* pathLength="1" normaliza o traço: o dash não depende do comprimento real
   do caminho, que muda a cada arrasto. */
.liq-linha{
  fill:none;stroke:var(--accent);stroke-width:1.5;
  stroke-linejoin:round;
  stroke-dasharray:1;stroke-dashoffset:0;
  transition:stroke-dashoffset 1.5s var(--ease);
}
/* A entrada é DELEGADA ao data-rv do quadro — o componente não escreve
   observer próprio. Escondido só enquanto houver um data-rv que ainda não
   entrou: quem tirar o data-rv do .liq-quadro fica com o traço pronto pela
   regra acima, em vez de uma linha que nunca aparece. */
[data-rv] .liq-linha{stroke-dashoffset:1}
[data-rv].in .liq-linha{stroke-dashoffset:0}
@media(prefers-reduced-motion:reduce){
  /* A forma nasce pronta: sem o traço se desenhando na entrada. */
  .liq-linha{stroke-dashoffset:0;transition:none}
}

.liq-eixo-nota{
  margin-top:12px;
  font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;
  color:var(--fg-muted);
}

/* ---- as leituras ---- */
.liq-saida{
  display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);
  gap:clamp(24px,3vw,52px);
  margin-top:clamp(26px,3vh,38px);
  padding-top:clamp(22px,2.6vh,30px);
  border-top:1px solid var(--rule);
}
.liq-saida-tit{
  font-family:var(--sans);font-weight:400;
  font-size:11px;letter-spacing:.18em;text-transform:uppercase;
  color:var(--fg-muted);
}
.liq-leituras{display:grid;gap:15px;margin-top:18px}
.liq-leitura{
  display:grid;grid-template-columns:auto minmax(0,1fr);
  column-gap:14px;align-items:center;
}
.liq-leitura-num{
  grid-column:1;grid-row:1 / 3;
  font-family:var(--display);font-weight:700;
  font-size:27px;letter-spacing:-.03em;line-height:1;
  color:var(--fg);min-width:3.4ch;text-align:right;
}
.liq-leitura-rot{grid-column:2;grid-row:1;font-size:13.5px;line-height:1.3;color:var(--fg-2)}
.liq-leitura-barra{
  grid-column:2;grid-row:2;
  position:relative;height:2px;margin-top:9px;background:var(--rule);
}
.liq-leitura-barra::after{
  content:"";position:absolute;inset:0 auto 0 0;
  width:calc(var(--liq-frac,0) * 100%);
  background:var(--accent);
  transition:width .3s var(--ease);
}
@media(prefers-reduced-motion:reduce){
  /* A barra acompanha o arrasto sem deslizar: o valor aparece direto. */
  .liq-leitura-barra::after{transition:none}
}

/* Tipografia pela primitiva .nota do core; 48ch porque a ressalva mora na
   coluna estreita das leituras, ao lado da régua. */
.liq-nota{--nota-max:48ch}
.liq-btn{margin-top:22px}

/* ==========================================================================
   TELAS ESTREITAS
   A régua ROLA DE LADO, não comprime. Comprimir 30 anos nos 320px úteis de
   um celular poria as cinco fichas a menos de 55px umas das outras — todas
   colidiriam, todas subiriam de fila, e a régua viraria uma pilha vertical
   de 592px onde a posição no tempo, que é a única coisa que a peça ensina,
   deixaria de ser legível. Rolando, cada ano continua valendo os mesmos
   pixels que no desktop, e o gesto horizontal já é o gesto da peça.
   Medido: a rolagem interna começa por volta de 1010px de janela.
   ========================================================================== */

/* Faixa de notebook: a ficha encolhe para a régua caber sem rolar de lado.
   Sem isto o piso de 920px do gráfico começaria a rolar já em 1130px de
   janela — 60px de rolagem interna, o suficiente para parecer defeito. */
@media(max-width:1180px){
  .liq-quadro{--liq-ficha-w:110px;--liq-min:840px}
}

@media(max-width:860px){
  .liq-saida{grid-template-columns:minmax(0,1fr);gap:26px}
  .liq-quadro{padding:18px 14px}
  .liq-nota{--nota-max:none}
}

@media(max-width:560px){
  .liq-quadro{--liq-graf-h:112px}
  .liq-leitura-num{font-size:23px}
  .liq-leitura-rot{font-size:13px}
  .liq-btn{width:100%;justify-content:center}
}

script.js · 342 linhas · 13,2 KB

/* ==========================================================================
   RÉGUA DE LIQUIDEZ — arrasto, teclado, empilhamento e redesenho da área

   O que o script faz, em ordem de importância:
     1. move a ficha no eixo do tempo (ponteiro ou teclado) e a encaixa no
        ano inteiro mais próximo;
     2. redesenha o `d` dos dois caminhos da área a cada mudança;
     3. recalcula as três leituras de horizonte;
     4. desempilha em filas as fichas que ficariam sobrepostas.

   Sem ele a peça já está de pé: a posição das fichas, o `d` da área e os três
   percentuais vêm escritos no markup, calculados por esta mesma conta.

   ---- A FORMA DA ÁREA, e por que ela não é o valor puro -------------------
   A altura não é o valor do objetivo: é o valor DIVIDIDO PELO TEMPO que
   existe para prepará-lo. R$ 900 mil daqui a cinco anos exige mais liquidez
   agora do que R$ 1,8 milhão daqui a vinte e sete — e é exatamente isso que
   a peça precisa ensinar.

     janela de subida(ano) = min(subidaBase + subidaFator × ano, subidaTeto)
     janela de descida     = descida
     altura(objetivo)      = valor / (0,5 × (subida + descida))

   Cada objetivo vira um sino de cosseno levantado sobre essas duas janelas,
   e a área é a soma dos cinco. O eixo y é normalizado pelo próprio máximo:
   é FORMA, não escala — não há número no eixo, e a peça não afirma nenhum.

   ---- COMPLIANCE ---------------------------------------------------------
   Nada aqui projeta rentabilidade. As três leituras são divisão simples dos
   valores que a pessoa posicionou, por faixa de prazo. Nenhum dado sai do
   navegador, nada vai para localStorage, nada é coletado.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-liq]');
  if (!raizes.length) return;

  /* ---- Tabela de ajuste ---------------------------------------------------
     Os VALORES de cada objetivo não moram aqui: moram no markup, em
     data-liq-valor, porque sem JavaScript a régua já precisa mostrar as
     leituras certas — duas cópias do mesmo número divergiriam no primeiro
     ajuste. Aqui fica a matemática da curva e o corte dos horizontes. */
  var AJUSTE = {
    subidaBase:  1.2,   /* anos de rampa antes de um objetivo no ano 0 */
    subidaFator: 0.28,  /* anos de rampa somados por ano de horizonte */
    subidaTeto:  6,     /* teto da rampa, em anos */
    descida:     1.4,   /* anos de queda depois do objetivo */
    curto:       1,     /* até N anos = "próximos 12 meses" */
    medio:       5,     /* até N anos = "médio prazo" */
    passo:       0.5,   /* amostragem da área, em anos (61 pontos em 30 anos) */
    topo:        92,    /* altura do pico, em unidades do viewBox de 100 */
    folga:       8,     /* px mínimos entre duas fichas da mesma fila */
    limiar:      6,     /* px de ponteiro que separam clique de arrasto */
    encaixe:     200    /* ms da animação de encaixe no ano inteiro */
  };

  var ANOS = 30;        /* fim da régua — o mesmo --liq-anos do CSS */

  function reduzido() {
    if (window.LIB && typeof window.LIB.reduce === 'boolean') return window.LIB.reduce;
    return matchMedia('(prefers-reduced-motion: reduce)').matches &&
           location.search.indexOf('motion=on') < 0;
  }

  function limitar(v, a, b) { return v < a ? a : (v > b ? b : v); }

  function subida(ano) {
    var s = AJUSTE.subidaBase + AJUSTE.subidaFator * ano;
    return s > AJUSTE.subidaTeto ? AJUSTE.subidaTeto : s;
  }
  function altura(o) { return o.valor / (0.5 * (subida(o.ano) + AJUSTE.descida)); }
  function nucleo(t, ano) {
    var u = t <= ano ? (ano - t) / subida(ano) : (t - ano) / AJUSTE.descida;
    if (u >= 1) return 0;
    return 0.5 * (1 + Math.cos(Math.PI * u));
  }

  /* Monta os dois `d`: o traço de cima e a área fechada no rodapé. */
  function caminhos(itens) {
    var pts = [], t, i, v, max = 0;
    for (t = 0; t <= ANOS + 1e-9; t += AJUSTE.passo) {
      v = 0;
      for (i = 0; i < itens.length; i++) v += altura(itens[i]) * nucleo(t, itens[i].ano);
      if (v > max) max = v;
      pts.push([t * 10, v]);
    }
    if (max <= 0) max = 1;
    var d = '';
    for (i = 0; i < pts.length; i++) {
      d += (i ? 'L' : 'M') + (Math.round(pts[i][0] * 10) / 10) + ' ' +
           (Math.round((100 - pts[i][1] / max * AJUSTE.topo) * 10) / 10);
    }
    var fim = 'L300 100';
    return { linha: d, area: d + (d.slice(-fim.length) === fim ? '' : fim) + 'L0 100Z' };
  }

  /* Três faixas de horizonte, em percentual do total posicionado.
     O arredondamento é por MAIOR RESTO: três arredondamentos independentes
     fechariam em 99 ou 101, e a soma é justamente o que dá credibilidade à
     leitura. Medido: 5,17 / 19,40 / 75,43 sai 5 / 19 / 76. */
  function leituras(itens) {
    var tot = 0, faixa = [0, 0, 0], i, ano;
    for (i = 0; i < itens.length; i++) tot += itens[i].valor;
    if (tot <= 0) return [0, 0, 0];
    for (i = 0; i < itens.length; i++) {
      ano = Math.round(itens[i].ano);
      faixa[ano <= AJUSTE.curto ? 0 : (ano <= AJUSTE.medio ? 1 : 2)] += itens[i].valor;
    }
    var brutos = [], pisos = [], soma = 0, p;
    for (i = 0; i < 3; i++) {
      p = faixa[i] / tot * 100;
      brutos.push(p);
      pisos.push(Math.floor(p));
      soma += Math.floor(p);
    }
    var ordem = [0, 1, 2].sort(function (x, y) {
      return (brutos[y] - pisos[y]) - (brutos[x] - pisos[x]);
    });
    var falta = 100 - soma;
    for (i = 0; i < falta; i++) pisos[ordem[i % 3]] += 1;
    return pisos;
  }

  function textoAno(ano) {
    var a = Math.round(ano);
    if (a <= 0) return 'hoje';
    return a === 1 ? 'em 1 ano' : 'em ' + a + ' anos';
  }

  function iniciar(raiz) {
    var track = raiz.querySelector('.liq-track');
    var area  = raiz.querySelector('.liq-area');
    var traco = raiz.querySelector('.liq-linha');
    var lis   = raiz.querySelectorAll('.liq-leitura');
    var nos   = raiz.querySelectorAll('.liq-ficha');
    if (!track || !area || !traco || lis.length !== 3 || nos.length < 2) return;

    var reduz = reduzido();
    var itens = [], i, el;

    for (i = 0; i < nos.length; i++) {
      el = nos[i];
      itens.push({
        el: el,
        anoEl: el.querySelector('.liq-ficha-ano'),
        valor: parseFloat(el.getAttribute('data-liq-valor')) || 0,
        ano: parseFloat(el.style.getPropertyValue('--liq-ano')) || 0,
        fila: 0
      });
      el.setAttribute('tabindex', '0');
    }

    /* A frase que promete o gesto só aparece agora: sem script ela mentiria. */
    var ajuda = raiz.querySelector('.liq-ajuda-js');
    if (ajuda) ajuda.hidden = false;
    raiz.classList.add('is-live');

    function daFicha(el) {
      for (var j = 0; j < itens.length; j++) if (itens[j].el === el) return itens[j];
      return null;
    }

    function escrever(it) {
      it.el.style.setProperty('--liq-ano', it.ano);
      if (!it.anoEl) return;
      /* Só toca no texto quando o ano inteiro muda: o elemento é aria-live, e
         reescrevê-lo a cada quadro do arrasto inundaria o leitor de tela. */
      var t = textoAno(it.ano);
      if (it.anoEl.textContent !== t) it.anoEl.textContent = t;
    }

    function pintar() {
      var c = caminhos(itens);
      area.setAttribute('d', c.area);
      traco.setAttribute('d', c.linha);
      var p = leituras(itens), k, num;
      for (k = 0; k < 3; k++) {
        num = lis[k].querySelector('.liq-leitura-pct');
        if (num) num.textContent = p[k];
        lis[k].style.setProperty('--liq-frac', (p[k] / 100).toFixed(3));
      }
    }

    /* Desempilhamento guloso, da esquerda para a direita: cada ficha desce
       para a fila mais baixa em que ninguém a alcança. Duas fichas no mesmo
       ano têm distância zero, logo caem sempre em filas diferentes. */
    function empilhar() {
      var largura = track.clientWidth || 1;
      var passo = (itens[0].el.offsetWidth || 122) + AJUSTE.folga;
      var ordem = itens.slice().sort(function (a, b) { return a.ano - b.ano; });
      var filas = [], maior = 1, j, k, f, x, livre;

      for (j = 0; j < ordem.length; j++) {
        x = ordem[j].ano / ANOS * largura;
        f = 0;
        while (true) {
          if (!filas[f]) filas[f] = [];
          livre = true;
          for (k = 0; k < filas[f].length; k++) {
            if (Math.abs(x - filas[f][k]) < passo) { livre = false; break; }
          }
          if (livre) break;
          f++;
        }
        filas[f].push(x);
        ordem[j].fila = f;
        if (f + 1 > maior) maior = f + 1;
      }
      for (j = 0; j < itens.length; j++) {
        itens[j].el.style.setProperty('--liq-fila', itens[j].fila);
      }
      track.style.setProperty('--liq-filas', maior);
    }

    /* ---- o único rAF da instância ----
       Ele dorme sempre que não há encaixe em curso: arrasto e teclado pedem
       um quadro, o quadro faz o trabalho e devolve o laço ao repouso. */
    var pedido = 0, ultimo = 0, encaixe = null, refila = false;

    function pedir() { if (!pedido) pedido = requestAnimationFrame(passo); }

    function passo(agora) {
      pedido = 0;
      var dt = ultimo ? Math.min(agora - ultimo, 50) : 16;
      ultimo = agora;
      var j, u, continua = false;

      if (encaixe) {
        encaixe.t += dt;
        u = encaixe.t / AJUSTE.encaixe;
        if (u >= 1) { encaixe.it.ano = encaixe.para; encaixe = null; refila = true; }
        else {
          encaixe.it.ano = encaixe.de + (encaixe.para - encaixe.de) * (1 - Math.pow(1 - u, 3));
          continua = true;
        }
      }

      for (j = 0; j < itens.length; j++) escrever(itens[j]);
      pintar();
      if (refila && !continua) { refila = false; empilhar(); }

      if (continua) pedir();
      else ultimo = 0;
    }

    function mandarPara(it, destino) {
      destino = limitar(Math.round(destino), 0, ANOS);
      /* Movimento reduzido: nada desliza sozinho — a ficha aparece no ano. */
      if (reduz || Math.abs(destino - it.ano) < 0.02) {
        it.ano = destino; refila = true; pedir(); return;
      }
      encaixe = { it: it, de: it.ano, para: destino, t: 0 };
      pedir();
    }

    /* ---- ponteiro ----
       A captura é pedida já no pointerdown, e não depois do limiar: sem ela
       um movimento rápido que sai da caixa de 122px perde os eventos no meio
       do gesto. O limiar de 6px decide só se aquilo virou arrasto. */
    var arrasto = null;

    function aoDescer(e) {
      if (e.button > 0) return;
      var it = daFicha(e.currentTarget);
      if (!it) return;
      if (encaixe && encaixe.it === it) encaixe = null;
      arrasto = { it: it, id: e.pointerId, x0: e.clientX, ano0: it.ano, ativo: false };
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
    }

    function aoMover(e) {
      if (!arrasto || e.pointerId !== arrasto.id) return;
      var dx = e.clientX - arrasto.x0;
      if (!arrasto.ativo) {
        if (Math.abs(dx) < AJUSTE.limiar) return;
        arrasto.ativo = true;
        arrasto.it.el.classList.add('is-dragging');
      }
      arrasto.it.ano = limitar(arrasto.ano0 + dx / (track.clientWidth || 1) * ANOS, 0, ANOS);
      pedir();
    }

    function aoSubir(e) {
      if (!arrasto || e.pointerId !== arrasto.id) return;
      var it = arrasto.it, houve = arrasto.ativo;
      try { it.el.releasePointerCapture(e.pointerId); } catch (err) {}
      arrasto = null;
      it.el.classList.remove('is-dragging');
      if (houve) mandarPara(it, it.ano);
    }

    /* pointercancel = o navegador levou o gesto (rolagem vertical no toque).
       Devolver a ficha ao ano de partida é mais honesto do que deixá-la onde
       o dedo passou de raspão. */
    function aoCancelar(e) {
      if (!arrasto || e.pointerId !== arrasto.id) return;
      var it = arrasto.it, volta = arrasto.ano0, houve = arrasto.ativo;
      arrasto = null;
      it.el.classList.remove('is-dragging');
      if (!houve) return;
      it.ano = volta;
      refila = true;
      pedir();
    }

    function aoTeclar(e) {
      var it = daFicha(e.currentTarget);
      if (!it) return;
      var base = Math.round(it.ano), destino = null, k = e.key;
      if (k === 'ArrowLeft' || k === 'ArrowDown' || k === 'Left' || k === 'Down') destino = base - 1;
      else if (k === 'ArrowRight' || k === 'ArrowUp' || k === 'Right' || k === 'Up') destino = base + 1;
      else if (k === 'PageDown') destino = base - 5;
      else if (k === 'PageUp') destino = base + 5;
      else if (k === 'Home') destino = 0;
      else if (k === 'End') destino = ANOS;
      if (destino === null) return;
      /* Sem isto a seta rola a página de lado em vez de mover a ficha. */
      e.preventDefault();
      mandarPara(it, destino);
    }

    function semArrastoNativo(e) { e.preventDefault(); }

    for (i = 0; i < itens.length; i++) {
      el = itens[i].el;
      el.addEventListener('pointerdown', aoDescer);
      el.addEventListener('pointermove', aoMover);
      el.addEventListener('pointerup', aoSubir);
      el.addEventListener('pointercancel', aoCancelar);
      el.addEventListener('keydown', aoTeclar);
      el.addEventListener('dragstart', semArrastoNativo);
    }

    /* A largura da régua muda com a janela, e a colisão é medida em px. */
    addEventListener('resize', function () { refila = true; pedir(); }, { passive: true });

    empilhar();
    pintar();
  }

  for (var r = 0; r < raizes.length; r++) iniciar(raizes[r]);
})();

Uma linha do tempo de trinta anos com cinco objetivos de vida em cima dela. A pessoa arrasta cada ficha para o ano em que precisa do dinheiro, e três coisas respondem na hora: a área embaixo redesenha a exigência de liquidez, as três faixas de horizonte recalculam, e a régua abre uma fila nova quando duas fichas ficariam sobrepostas.

É a peça que explica por que uma carteira não pode ser toda de longo prazo — e explica sem uma palavra técnica, porque quem move as fichas é o visitante. O argumento não é dito: é encenado. Arraste a renda vitalícia de vinte e sete anos para três e o pico salta; o mesmo dinheiro, exigido antes, pesa mais.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
.eyebrow Uma palavra. "Liquidez", "Horizonte", "Prazos".
h2.sec-title 4 a 6 palavras, uma em <em>. O <em> deve cair na palavra-conceito ("prazo"), não num adjetivo.
.sec-lead Duas frases curtas, em oposição. A segunda nega o senso comum ("não a partir do que rende mais").
.liq-ajuda Uma frase verdadeira sem JavaScript, e o convite ao gesto dentro do <span class="liq-ajuda-js" hidden>. Nunca prometa arrasto na parte fixa.
.liq-ficha-rot 2 a 3 palavras, duas linhas no card de 122px. Um rótulo de uma linha só desalinha a leitura vertical entre as fichas.
.liq-ficha-val R$ 240 mil / R$ 1,8 mi. Números redondos: a peça é ilustrativa, e centavos fingem precisão.
.liq-ficha-ano Escrito pelo script — hoje, em 1 ano, em N anos. Não edite à mão além do estado inicial.
.liq-leitura-rot O horizonte por extenso, em minúscula: "nos próximos 12 meses". Nunca "curto prazo" sozinho — o número de meses é o que a pessoa entende.
.liq-nota A ressalva. Fica ao lado das leituras, não no rodapé.

Cinco fichas é o número, e não é estético. Quatro deixam a régua vazia entre os marcos; seis põem duas em colisão já no estado inicial, e a peça abre com uma fila empilhada, que é o estado de exceção.

Especificação dos assets

Nenhum. Os cinco ícones são <path> de 18×18 escritos no markup, em stroke:var(--accent) com 1,2px — escudo, casa, capelo, prédio e horizonte. Trocar um ícone é trocar um d; mantenha o mesmo viewBox e nada de fill.


Compliance

O que o consumidor tem de trocar antes de publicar:

  1. Os cinco data-liq-valor e os .liq-ficha-val. São ilustrativos e estão marcados com <!-- CONTEÚDO ILUSTRATIVO -->. Os dois têm de andar juntos: o atributo alimenta a conta, o texto alimenta o olho.
  2. Os cortes de horizonte, em AJUSTE.curto e AJUSTE.medio no topo do script.js, e os rótulos dos três <li> junto. Se a casa chama médio prazo de 2 a 7 anos, mude os dois.
  3. A .liq-nota, com a área de compliance do cliente. O texto que vai aqui diz três coisas e não pode perder nenhuma: é simulação, não é recomendação, cenário passado não garante futuro.

O que a peça não faz, de propósito: não projeta rentabilidade, não diz que carteira montar, não escreve percentual de alocação por classe de ativo. As três leituras são faixa por horizonte — a divisão aritmética dos valores que a própria pessoa posicionou, e nada mais. O eixo vertical da área não tem número nem unidade: é forma, e a legenda embaixo diz isso em voz alta.

Nenhum dado sai do navegador. Não há campo de nome, e-mail ou telefone, não há localStorage, e o que a pessoa posiciona morre no recarregamento.


Como usar

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

Ganchos

Gancho O quê
[data-liq] A raiz da instância. O script nunca busca fora dela — pode repetir na página.
data-liq-valor Valor do objetivo, em milhares de reais. É a fonte das três leituras.
--liq-ano Posição no eixo, 0 a 30. Escrita no HTML, reescrita pelo arrasto.
--liq-fila / --liq-filas Faixa vertical da ficha e quantas estão em uso. O script escreve as duas; sem ele valem 0 e 1.
--liq-frac Fração da barra de cada leitura, 0 a 1.
--liq-ficha-w/-h, --liq-fila-h, --liq-haste, --liq-graf-h, --liq-min, --liq-anos Os parafusos de geometria, na raiz do componente.
.is-live / .is-dragging O script está de pé; a ficha está sob o dedo.

Variações


Gotchas


Histórico

Projeto Onde Data Diferenças
autoral construído do zero na biblioteca 2026-09-03 —

Origem. Autoral, sem referência única e sem uma linha copiada. O padrão — objetivos de vida colocados numa linha do tempo arrastável, com uma leitura que se recalcula embaixo — aparece em planejador de banco privado, em ferramenta de goals-based planning de gestora e em simulador de previdência. Nenhum deles serviu de base: a forma veio da família, o arquivo é daqui.

Decisões da tradução para o contrato. A área virou <path> com o d montado por concatenação de string, 61 pontos de 0,5 em 0,5 ano, sem biblioteca de gráfico e sem canvas. A paleta saiu de color-mix sobre --accent, e o degradê de preenchimento virou mask-image — um <linearGradient> exigiria um id, que o contrato não quer. O arrasto é pointer events com setPointerCapture pedida já no pointerdown e limiar de 6px, em vez de biblioteca de drag. O equivalente completo de teclado (setas, Home, End, PageUp/PageDown) foi acrescentado — nenhum dos exemplos da família tinha, e arrasto sozinho não tem equivalente acessível. O eixo vertical perdeu qualquer número, e a leitura passou a ser faixa por horizonte apresentada como consequência aritmética, nunca como recomendação de carteira.