← Biblioteca componentes/conversao/calculadora-projecao/ficha.md CódigoTela cheia
conversao/calculadora-projecao

Calculadora de projeção

🟢 estavel complexidade alta prefixo proj- simulador calculadora grafico-area svg range projecao juro-composto ferramenta compliance sem-lib

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

<link rel="stylesheet" href="componentes/conversao/calculadora-projecao/estilo.css">
<script src="componentes/conversao/calculadora-projecao/script.js"></script>

markup.html · 164 linhas · 9,8 KB

<!-- ==========================================================================
     CALCULADORA DE PROJEÇÃO — três controles, uma faixa de cenários
     Prefixo: proj-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Usa a primitiva .glass do core no card do gráfico. Sem lib.

     O QUE ESTÁ ESCRITO AQUI É O ESTADO INICIAL COMPLETO, e isso é decisão:
     o `d` dos dois caminhos, o valor no fim da linha, os rótulos do eixo, as
     três leituras e o `--proj-fill` de cada faixa são exatamente o que o
     script produz para value="2500000", value="15000", value="15" e cenário
     "equilibrado". Sem JavaScript, a peça fica de pé: as faixas continuam
     sendo faixas (arrastam, respondem à seta), o gráfico mostra este estado e
     as leituras batem com ele.

     ISSO É UM COMPROMISSO A MANTER: mudou um `value`, um `min`, um `max` ou a
     tabela de cenários do script.js? O SVG e as leituras daqui têm de ser
     regerados junto, senão a página sem JS passa a mentir. A ficha traz a
     receita de uma linha para reimprimir os dois `d` a partir do próprio
     componente rodando.

     Os `id` existem só para o `for` dos rótulos. O script não usa nenhum: ele
     procura `data-proj-*` a partir da raiz `[data-proj]`. Repetiu a peça na
     mesma página? Sufixe os `id` e o `name` dos rádios; o resto continua.
     ========================================================================== -->

<section class="sec-deep sec-pad" id="simulador">
  <div class="wrap">
    <div class="sec-head proj-head">
      <span class="eyebrow" data-rv>Simulador</span>
      <h2 class="sec-title ttl">Quanto tempo o seu <em>plano</em> precisa</h2>
      <p class="sec-lead" data-rv data-d="1">Mova os três controles e veja a faixa de cenários se abrir. É um exercício para orientar a conversa — o plano de verdade nasce de um diagnóstico da sua carteira.</p>
    </div>

    <div class="proj-grid" data-proj data-rv data-d="2">

      <!-- ---------------------------------------------------- controles -->
      <div class="proj-panel">

        <div class="proj-ctrl">
          <div class="proj-ctrl-head">
            <label class="proj-name" for="proj-inicial">Patrimônio inicial</label>
            <output class="proj-val" for="proj-inicial" data-proj-val="inicial" aria-live="polite">R$ 2,5 mi</output>
          </div>
          <input class="proj-range" type="range" id="proj-inicial" data-proj-range="inicial"
                 min="500000" max="30000000" step="100000" value="2500000"
                 aria-valuetext="R$ 2,5 mi" style="--proj-fill:6.8%">
          <div class="proj-scale"><span>R$ 500 mil</span><span>R$ 30 mi</span></div>
        </div>

        <div class="proj-ctrl">
          <div class="proj-ctrl-head">
            <label class="proj-name" for="proj-aporte">Aporte mensal</label>
            <output class="proj-val" for="proj-aporte" data-proj-val="aporte" aria-live="polite">R$ 15 mil</output>
          </div>
          <input class="proj-range" type="range" id="proj-aporte" data-proj-range="aporte"
                 min="0" max="200000" step="5000" value="15000"
                 aria-valuetext="R$ 15 mil" style="--proj-fill:7.5%">
          <div class="proj-scale"><span>R$ 0</span><span>R$ 200 mil</span></div>
        </div>

        <div class="proj-ctrl">
          <div class="proj-ctrl-head">
            <label class="proj-name" for="proj-anos">Horizonte</label>
            <output class="proj-val" for="proj-anos" data-proj-val="anos" aria-live="polite">15 anos</output>
          </div>
          <input class="proj-range" type="range" id="proj-anos" data-proj-range="anos"
                 min="1" max="30" step="1" value="15"
                 aria-valuetext="15 anos" style="--proj-fill:48.3%">
          <div class="proj-scale"><span>1 ano</span><span>30 anos</span></div>
        </div>

        <!-- Escolha única em rádio de verdade: as setas do teclado navegam
             entre as três opções de graça, sem uma linha de JS. O cenário não
             muda promessa de retorno nenhuma — muda a LARGURA da faixa que o
             gráfico desenha. -->
        <fieldset class="proj-scen">
          <legend class="proj-scen-legend">Cenário simulado</legend>
          <div class="proj-chips">
            <input class="proj-chip-in" type="radio" name="proj-cenario" id="proj-cen-1" data-proj-scenario="conservador">
            <label class="proj-chip" for="proj-cen-1">Conservador</label>
            <input class="proj-chip-in" type="radio" name="proj-cenario" id="proj-cen-2" data-proj-scenario="equilibrado" checked>
            <label class="proj-chip" for="proj-cen-2">Equilibrado</label>
            <input class="proj-chip-in" type="radio" name="proj-cenario" id="proj-cen-3" data-proj-scenario="arrojado">
            <label class="proj-chip" for="proj-cen-3">Arrojado</label>
          </div>
        </fieldset>

        <!-- CONTEÚDO ILUSTRATIVO. Os três valores abaixo são o resultado da
             posição inicial dos controles com os parâmetros de exemplo do
             script.js. Não são dado apurado, não são premissa de mercado e
             não descrevem carteira nenhuma. -->
        <div class="proj-readout">
          <div class="proj-read">
            <p class="proj-read-k">Total aportado no período</p>
            <p class="proj-read-v" data-proj-out="aportado">R$ 5,2 mi</p>
          </div>
          <div class="proj-read">
            <p class="proj-read-k">Faixa simulada ao fim do período</p>
            <p class="proj-read-v"><span data-proj-out="min">R$ 8,2 mi</span><span class="proj-read-sep">a</span><span data-proj-out="max">R$ 14,6 mi</span></p>
          </div>
          <div class="proj-read">
            <p class="proj-read-k">Horizonte</p>
            <p class="proj-read-v" data-proj-out="anos">15 anos</p>
          </div>
        </div>

      </div>

      <!-- ------------------------------------------------------ gráfico -->
      <!-- CONTEÚDO ILUSTRATIVO. O desenho abaixo é o estado inicial e sai das
           taxas de exemplo da tabela CENARIOS do script.js. Trocar aquelas
           taxas por premissas próprias (e datadas) é obrigatório antes de
           publicar — veja a seção Compliance da ficha. -->
      <div class="glass proj-chart">
        <!-- aria-hidden porque o dado verdadeiro está nas leituras em texto,
             ao lado: quem usa leitor de tela recebe os números, não a palavra
             "gráfico". O SVG aqui é a tradução visual, não a fonte. -->
        <svg class="proj-svg" viewBox="0 0 640 360" data-proj-svg aria-hidden="true" focusable="false">
          <text class="proj-ymax" x="4" y="17" data-proj-ymax>R$ 15 mi</text>
          <path class="proj-guide" d="M4 26H606"/>
          <path class="proj-guide" d="M4 173H606"/>

          <path class="proj-band" data-proj-band d="M4 271L41.6 263.4L79.3 255.3L116.9 246.4L154.5 236.7L192.1 226.3L229.8 215L267.4 202.7L305 189.4L342.6 175L380.3 159.4L417.9 142.4L455.5 124L493.1 104.1L530.8 82.5L568.4 59.1L606 33.7L606 159.8L568.4 168.8L530.8 177.5L493.1 185.9L455.5 194L417.9 201.8L380.3 209.3L342.6 216.6L305 223.5L267.4 230.3L229.8 236.7L192.1 243L154.5 249L116.9 254.8L79.3 260.4L41.6 265.8L4 271Z"/>
          <path class="proj-line" data-proj-line d="M4 271L41.6 264.6L79.3 257.9L116.9 250.7L154.5 243.1L192.1 235L229.8 226.4L267.4 217.3L305 207.7L342.6 197.5L380.3 186.6L417.9 175.1L455.5 162.9L493.1 149.9L530.8 136.2L568.4 121.6L606 106.2"/>

          <path class="proj-axis" d="M4 320H606"/>
          <circle class="proj-dot" data-proj-dot cx="606" cy="106.2" r="4"/>
          <text class="proj-endcap" x="598" y="78.2" text-anchor="end" data-proj-endcap>cenário intermediário</text>
          <text class="proj-end" x="598" y="96.2" text-anchor="end" data-proj-end>R$ 10,9 mi</text>

          <text class="proj-tick" x="4" y="342" text-anchor="start" data-proj-tick>0</text>
          <text class="proj-tick" x="204.7" y="342" text-anchor="middle" data-proj-tick>5</text>
          <text class="proj-tick" x="405.3" y="342" text-anchor="middle" data-proj-tick>10</text>
          <text class="proj-tick" x="606" y="342" text-anchor="end" data-proj-tick>15 anos</text>
        </svg>

        <div class="proj-key">
          <span class="proj-key-item"><span class="proj-key-swatch"></span>Intervalo entre dois cenários simulados</span>
          <span class="proj-key-item"><span class="proj-key-swatch is-line"></span>Ponto médio do intervalo</span>
        </div>

        <!-- A ressalva mora AQUI, colada no resultado, e não no rodapé da
             página. Quem lê o número tem de ler a ressalva no mesmo movimento
             de olho. Não mova este bloco. -->
        <p class="nota proj-nota">Simulação ilustrativa, para orientar a conversa. Não é recomendação de investimento nem promessa de rentabilidade. Os parâmetros são de exemplo e não foram apurados; cenários passados não garantem resultado futuro.</p>
      </div>

      <!-- O botão é irmão do painel e do gráfico, não filho do painel, e a
           ordem do DOM é painel > gráfico > botão. No desktop o CSS o coloca
           de volta na coluna da esquerda, debaixo das leituras; no mobile,
           onde as colunas empilham, ele cai depois do gráfico — que é onde ele
           tem de estar. Dentro do painel, o mobile jogaria o gráfico para
           DEPOIS do botão e a peça terminaria num desenho órfão. -->
      <div class="proj-cta">
        <a href="#diagnostico" class="btn btn-primary">
          Falar com um assessor
          <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 7H12M8 3L12 7L8 11"/></svg>
        </a>
        <span class="pill">Sem custo</span>
      </div>

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

estilo.css · 339 linhas · 13,1 KB

/* ==========================================================================
   CALCULADORA DE PROJEÇÃO — prefixo proj-
   Duas colunas: à esquerda três controles de faixa, os chips de cenário e as
   leituras em número; à direita um gráfico de área em SVG dentro de um card
   de vidro, com a ressalva de compliance colada embaixo dele.

   Tokens usados: --accent --fg --fg-2 --fg-muted --rule --rule-2 --display
   --sans --ease --radius-pill --radius-media
   Primitivas do core (usadas, não recopiadas): .wrap .sec-pad .sec-head
   .eyebrow h2.sec-title .sec-lead .glass .btn/.btn-primary .pill

   O SVG é estilizado AQUI, não por atributo de apresentação: `fill` e
   `stroke` escritos no markup seriam cor crua e não leriam token. A regra do
   contrato vale dentro do SVG como vale fora.
   ========================================================================== */

.proj-head{--head-max:700px}

/* --------------------------------------------------------------- o grid */
/* A coluna do gráfico é a maior: é onde o olho vai. Abaixo de 940px as duas
   empilham (ver o fim do arquivo) — o gráfico primeiro seria bonito e errado,
   porque o gráfico só significa alguma coisa depois de a pessoa entender que
   pode mexer. Controles em cima, sempre. */
.proj-grid{
  --proj-thumb:18px;                    /* diâmetro do botão da faixa */
  --proj-tick-size:14px;                /* corpo dos rótulos dentro do SVG */
  --proj-end-size:19px;                 /* corpo do valor no fim da linha */
  --proj-band-op:16%;                   /* opacidade da faixa sobre --accent */
  margin-top:clamp(38px,6vh,62px);
  display:grid;
  grid-template-columns:minmax(0,.92fr) minmax(0,1.08fr);
  gap:clamp(28px,4vw,60px);
  align-items:start;
}

/* Três filhos, e a ordem do DOM é painel > gráfico > botão — a ordem certa
   para o mobile, onde tudo empilha. No desktop o botão volta para debaixo do
   painel por colocação explícita, e o gráfico ocupa a coluna da direita
   inteira. Sem isto o botão seria filho do painel e o mobile terminaria a
   peça num gráfico órfão, depois da chamada. */
.proj-panel{grid-column:1;grid-row:1}
.proj-chart{grid-column:2;grid-row:1 / span 2}
.proj-cta{grid-column:1;grid-row:2}

/* ---------------------------------------------------------- os controles */
.proj-ctrl{
  padding-block:clamp(14px,2vh,20px);
  border-top:1px solid var(--rule);
}
.proj-ctrl:first-child{border-top:0;padding-top:0}

.proj-ctrl-head{
  display:flex;align-items:baseline;justify-content:space-between;
  gap:16px;margin-bottom:10px;
}

.proj-name{
  font-family:var(--sans);
  font-size:11.5px;line-height:1.4;letter-spacing:.14em;text-transform:uppercase;
  color:var(--fg-muted);
}

/* O valor ao vivo. Tabular porque ele muda a cada quadro do arrasto: sem
   algarismo de avanço fixo o número treme lateralmente e puxa o rótulo
   junto. Mesma razão do contador da régua de números. */
.proj-val{
  font-family:var(--display);font-weight:700;
  font-size:clamp(19px,2vw,23px);line-height:1;letter-spacing:-.02em;
  color:var(--fg);
  font-variant-numeric:tabular-nums;
  white-space:nowrap;
}

.proj-scale{
  display:flex;justify-content:space-between;gap:12px;
  margin-top:6px;
  font-family:var(--sans);font-size:11px;letter-spacing:.06em;
  color:var(--fg-muted);
}

/* ------------------------------------------------------------- a faixa */
/* Um <input type="range"> de verdade, repintado. Nada de div com arrasto:
   o range já traz teclado (setas, Home/End, PageUp/PageDown), papel de
   slider, valor mínimo, máximo e atual — e nada disso se reescreve bem.
   O trecho preenchido é gradiente, porque o WebKit não tem pseudo-elemento
   de progresso; --proj-fill vem do script (e do style inline do markup,
   para o estado sem JS não nascer torto). */
.proj-range{
  --proj-fill:50%;
  -webkit-appearance:none;appearance:none;
  display:block;width:100%;height:calc(var(--proj-thumb) + 8px);
  margin:0;padding:0;background:transparent;cursor:pointer;
  border-radius:var(--radius-pill);   /* só para o anel de foco acompanhar */
}

/* WebKit e Gecko NÃO podem dividir a mesma regra: um seletor com pseudo
   desconhecido invalida o bloco inteiro para o outro motor. Daí a repetição
   deliberada abaixo — não é descuido de copiar e colar. */
.proj-range::-webkit-slider-runnable-track{
  height:3px;border-radius:var(--radius-pill);
  background:linear-gradient(to right,
    var(--accent) 0 var(--proj-fill),
    var(--rule-2) var(--proj-fill) 100%);
}
.proj-range::-moz-range-track{
  height:3px;border-radius:var(--radius-pill);
  background:linear-gradient(to right,
    var(--accent) 0 var(--proj-fill),
    var(--rule-2) var(--proj-fill) 100%);
}

.proj-range::-webkit-slider-thumb{
  -webkit-appearance:none;appearance:none;
  width:var(--proj-thumb);height:var(--proj-thumb);
  border:0;border-radius:50%;background:var(--accent);
  margin-top:calc((3px - var(--proj-thumb)) / 2);
  transition:box-shadow .3s var(--ease);
}
.proj-range::-moz-range-thumb{
  width:var(--proj-thumb);height:var(--proj-thumb);
  border:0;border-radius:50%;background:var(--accent);
  transition:box-shadow .3s var(--ease);
}
/* Halo no arrasto: cresce o alvo visual sem mexer no layout. */
.proj-range:hover::-webkit-slider-thumb,
.proj-range:active::-webkit-slider-thumb{
  box-shadow:0 0 0 7px color-mix(in srgb,var(--accent) 20%,transparent);
}
.proj-range:hover::-moz-range-thumb,
.proj-range:active::-moz-range-thumb{
  box-shadow:0 0 0 7px color-mix(in srgb,var(--accent) 20%,transparent);
}
@media(prefers-reduced-motion:reduce){
  .proj-range::-webkit-slider-thumb{transition:none}
  .proj-range::-moz-range-thumb{transition:none}
}

/* -------------------------------------------------------- os cenários */
.proj-scen{
  margin-top:clamp(20px,3vh,28px);
  padding:0;border:0;
}
.proj-scen-legend{
  padding:0;
  font-family:var(--sans);
  font-size:11.5px;line-height:1.4;letter-spacing:.14em;text-transform:uppercase;
  color:var(--fg-muted);
}
.proj-chips{
  position:relative;               /* âncora do radio escondido */
  display:flex;flex-wrap:wrap;gap:8px;
  margin-top:12px;
}
/* O radio some da tela mas continua no fluxo de foco e de teclado: é ele que
   dá as setas entre opções do mesmo `name` de graça. Substituir por <div
   role="radio"> custaria trinta linhas de JS para reproduzir o que o
   navegador entrega pronto. */
.proj-chip-in{
  position:absolute;width:1px;height:1px;margin:0;padding:0;border:0;
  clip-path:inset(50%);overflow:hidden;white-space:nowrap;
}
.proj-chip{
  display:inline-block;cursor:pointer;
  font-family:var(--sans);font-size:12px;letter-spacing:.08em;
  color:var(--fg-2);
  border:1px solid var(--rule-2);border-radius:var(--radius-pill);
  padding:9px 18px;
  transition:color .3s var(--ease),border-color .3s var(--ease),background .3s var(--ease);
}
.proj-chip:hover{color:var(--fg);border-color:var(--accent)}
.proj-chip-in:checked + .proj-chip{
  color:var(--accent);
  border-color:var(--accent);
  background:color-mix(in srgb,var(--accent) 10%,transparent);
}
/* O anel de foco vive no rótulo, porque o input não tem caixa visível. */
.proj-chip-in:focus-visible + .proj-chip{
  outline:2px solid var(--accent);outline-offset:3px;
}
@media(prefers-reduced-motion:reduce){
  .proj-chip{transition:none}
}

/* --------------------------------------------------------- as leituras */
.proj-readout{
  margin-top:clamp(24px,3.4vh,34px);
  padding-top:clamp(20px,2.8vh,26px);
  border-top:1px solid var(--rule);
  display:grid;gap:clamp(14px,2vh,20px);
}
.proj-read{display:flex;align-items:baseline;justify-content:space-between;gap:18px}
.proj-read-k{
  font-family:var(--sans);font-size:12.5px;line-height:1.45;
  color:var(--fg-muted);max-width:26ch;
}
.proj-read-v{
  font-family:var(--display);font-weight:700;
  font-size:clamp(17px,1.8vw,21px);line-height:1.1;letter-spacing:-.02em;
  color:var(--fg);
  font-variant-numeric:tabular-nums;
  white-space:nowrap;text-align:right;
}
/* O "a" que separa os dois extremos da faixa. Fino de propósito: os dois
   números são o dado, o conectivo não. */
.proj-read-sep{
  font-family:var(--sans);font-weight:400;font-size:12.5px;
  letter-spacing:.06em;color:var(--fg-muted);
  padding-inline:6px;
}

/* Sem margem própria: quem separa o botão das leituras é o `gap` da grade —
   no desktop porque ele é a segunda linha, no mobile porque ele é o terceiro
   item da pilha. Somar margem aqui dobrava o vão (medido: 88px onde o ritmo
   da dobra pedia 58). */
.proj-cta{display:flex;flex-wrap:wrap;align-items:center;gap:16px}

/* ---------------------------------------------------------- o gráfico */
.proj-chart{
  padding:clamp(16px,2.2vw,26px);
  border-radius:var(--radius-media);
}
.proj-svg{display:block;width:100%;height:auto}

.proj-guide{stroke:var(--rule);stroke-width:1;fill:none}
.proj-axis{stroke:var(--rule-2);stroke-width:1;fill:none}

/* A FAIXA. Ela é o assunto desta peça, não enfeite: uma linha só diria "o
   dinheiro chega aqui"; a área diz "o resultado mora nesta região, e a região
   é larga". Não a estreite para ficar bonita — estreitar a faixa é estreitar
   a ressalva. */
.proj-band{
  fill:color-mix(in srgb,var(--accent) var(--proj-band-op),transparent);
  stroke:none;
  transition:opacity .9s var(--ease),filter .9s var(--ease);
}
.proj-line{
  fill:none;stroke:var(--accent);stroke-width:2;
  stroke-linecap:round;stroke-linejoin:round;
  transition:stroke-dashoffset 1.5s var(--ease),filter 1.5s var(--ease);
}
.proj-dot{fill:var(--accent);stroke:none}

/* Estado de partida da entrada. Só o script o aplica, e só quando há
   movimento: sem JS o desenho nasce inteiro, que é o estado honesto. */
.is-enter .proj-band{opacity:0;filter:blur(10px)}
.is-enter .proj-line{filter:blur(6px)}
.is-enter .proj-dot,
.is-enter .proj-end,
.is-enter .proj-endcap{opacity:0}
.proj-dot,.proj-end,.proj-endcap{transition:opacity .8s var(--ease) .5s}
@media(prefers-reduced-motion:reduce){
  .proj-band,.proj-line,.proj-dot,.proj-end,.proj-endcap{transition:none}
}

/* Texto dentro do SVG: corpo em unidades do viewBox, então ele encolhe junto
   com o desenho. No mobile a caixa fica com metade da largura e o rótulo
   cairia para ~7px — por isso as variáveis sobem lá embaixo, na media query,
   em vez de um valor fixo aqui. */
.proj-tick,.proj-ymax{
  font-family:var(--sans);font-size:var(--proj-tick-size);
  letter-spacing:.08em;fill:var(--fg-muted);
}
.proj-endcap{
  font-family:var(--sans);font-size:var(--proj-tick-size);
  letter-spacing:.06em;fill:var(--fg-muted);
}
.proj-end{
  font-family:var(--display);font-weight:700;
  font-size:var(--proj-end-size);letter-spacing:-.02em;
  fill:var(--fg);
}

/* --------------------------------------------------------- pé do gráfico */
.proj-key{
  display:flex;flex-wrap:wrap;gap:8px 20px;
  margin-top:clamp(14px,2vh,20px);
}
.proj-key-item{
  display:inline-flex;align-items:center;gap:9px;
  font-family:var(--sans);font-size:12px;line-height:1.5;
  color:var(--fg-2);
}
.proj-key-swatch{
  width:22px;height:10px;flex:none;
  border-radius:2px;
  background:color-mix(in srgb,var(--accent) 26%,transparent);
}
.proj-key-swatch.is-line{
  height:2px;border-radius:0;
  background:var(--accent);
}

/* A RESSALVA. Fica aqui, colada no gráfico, e não no rodapé da página: quem
   lê o resultado tem de ler a ressalva no mesmo movimento de olho. Mover
   este bloco para o fim da página descumpre o combinado. */
/* A tipografia vem da primitiva .nota do core; aqui fica só a POSIÇÃO — a
   margem, o filete que amarra a ressalva ao gráfico, e a medida: 62ch porque
   a ressalva mora na coluna do resultado, não na largura da seção. */
.proj-nota{
  margin-top:clamp(14px,2vh,18px);
  padding-top:clamp(12px,1.8vh,16px);
  border-top:1px solid var(--rule);
  --nota-max:62ch;
}

/* ------------------------------------------------------------ variações */
/* Gráfico sem o card de vidro: só o desenho sobre o fundo da seção. Use em
   dobra clara, onde o vidro não tem o que refratar. */
.proj-chart--nu{
  padding:0;
  border:0;background:none;
  backdrop-filter:none;-webkit-backdrop-filter:none;
}
/* Faixa mais presente, para dobra em que o gráfico é a peça principal. */
.proj-grid--faixa-forte{--proj-band-op:26%}

/* ---------------------------------------------------------------- mobile */
@media(max-width:940px){
  /* O rótulo dentro do SVG é medido em unidade de viewBox: com a caixa na
     metade da largura, 14 unidades viram ~7px na tela. */
  .proj-grid{
    grid-template-columns:1fr;gap:clamp(26px,5vh,40px);
    --proj-tick-size:17px;--proj-end-size:23px;
  }
  /* Coluna única: a colocação explícita sai da frente e o fluxo do DOM
     (painel, gráfico, botão) volta a mandar. */
  .proj-panel,.proj-chart,.proj-cta{grid-column:1;grid-row:auto}
}

@media(max-width:560px){
  .proj-grid{--proj-tick-size:20px;--proj-end-size:26px;--proj-thumb:22px}
  .proj-read{flex-direction:column;align-items:flex-start;gap:4px}
  .proj-read-v{text-align:left}
  .proj-read-k{max-width:none}
  /* Botão de largura cheia, pílula não: esticada, ela deixa de ler como
     etiqueta e passa a ler como segundo botão. */
  .proj-cta{flex-direction:column;align-items:stretch}
  .proj-cta .pill{align-self:flex-start}
}

script.js · 480 linhas · 20,5 KB

/* ==========================================================================
   CALCULADORA DE PROJEÇÃO — a conta, o traçado e a contagem.

   Três controles de faixa e um chip de cenário entram; um caminho SVG e três
   leituras em texto saem. Sem lib: a matemática é juro composto mensal, o
   traçado é uma string `d` montada à mão e a contagem é requestAnimationFrame
   por tempo.

   O que o arquivo NÃO faz, de propósito:
   - não grava nada (nem localStorage, nem cookie, nem requisição). Os valores
     vivem em memória e morrem no recarregamento. São dados financeiros de
     quem está do outro lado, e a captura é trabalho do conversao/cta-formulario-lead;
   - não conhece nenhuma classe do estilo.css. Tudo que ele procura é
     `data-proj-*`, partindo da raiz `[data-proj]` — o componente pode repetir
     na página (contrato §7.6);
   - não calcula dentro do listener. O `input` só marca o que mudou e acorda
     um rAF; a conta e a escrita acontecem lá dentro, uma vez por quadro.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-proj]');
  if (!raizes.length) return;                              /* guard silencioso */

  /* ======================================================================
     <!-- CONTEÚDO ILUSTRATIVO -->
     PARÂMETROS DOS CENÁRIOS — as duas taxas anuais que desenham as bordas
     da faixa. São números de exemplo, escolhidos para dimensionar o gráfico.
     NÃO são premissa de mercado, não são recomendação e não foram apuradas.

     ANTES DE PUBLICAR, o consumidor TEM de trocar esta tabela por premissas
     próprias, DATADAS (a data entra visível na peça, junto da ressalva), e
     passar a peça inteira pela área de compliance do cliente. A largura da
     faixa é o que impede a leitura de "promessa": mantenha piso e teto
     distantes. Faixa estreita vira linha, e linha vira promessa.
     ====================================================================== */
  var CENARIOS = {
    conservador: { piso: .03, teto: .06 },
    equilibrado: { piso: .04, teto: .09 },
    arrojado:    { piso: .05, teto: .13 }
  };
  var CENARIO_PADRAO = 'equilibrado';

  /* ---- geometria do desenho, em unidades do viewBox ("0 0 640 360") ----
     Mudou aqui? Mude o viewBox e o SVG escrito no markup.html junto: as
     guias, o eixo, o y dos rótulos e os dois `d` saem todos daqui.
     A caixa é mais alta que larga na proporção 1,78 — juro composto numa
     caixa 2,1 desenha uma curva quase reta, e o assunto da peça é justamente
     a curvatura. */
  var PX0 = 4, PX1 = 606;      /* faixa útil no eixo x */
  var PY0 = 26, PY1 = 320;     /* topo do gráfico e linha de base */

  var DUR    = 520;   /* ms — o número indo do valor exibido ao novo valor */
  var DT_MAX = 50;    /* ms — teto do dt, para aba que volta do fundo não saltar */
  var DESENHO = 1500; /* ms — a linha se desenhando na entrada */
  var LIMIAR  = .28;  /* fração do gráfico visível que dispara a entrada */

  /* Degraus do teto do eixo vertical, por década. Ver tetoRedondo(). */
  var ESCADA = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];

  /* A flag é do core (contrato §7.5). O fallback existe porque a promessa de
     acessibilidade não pode depender da ordem dos <script>. */
  var reduzir = (window.LIB && typeof window.LIB.reduce === 'boolean')
    ? window.LIB.reduce
    : (matchMedia('(prefers-reduced-motion: reduce)').matches &&
       !/[?&]motion=on/.test(location.search));

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

  /* ====================================================================== */
  /* FORMATAÇÃO                                                             */
  /* ====================================================================== */

  /* "R$ 1,9 bi", "R$ 2,5 mi", "R$ 500 mil", "R$ 0". Uma casa decimal, e
     nenhuma acima de 100 — "R$ 118,4 mi" não cabe no rótulo do fim da linha
     sem atravessar a área do gráfico.

     O degrau "bi" não é hipótese remota: 30 milhões de patrimônio, 200 mil de
     aporte e 30 anos no cenário arrojado dão 1,9 bilhão, e sem esta faixa a
     tela mostraria "R$ 1919 mi". Os extremos dos controles têm de caber na
     formatação, senão eles não deviam ser os extremos.

     Cada degrau checa o arredondamento contra o degrau de cima: 999.700
     vira "R$ 1 mi", nunca "R$ 1000 mil". */
  function moeda(v) {
    if (!(v > 0)) return 'R$ 0';
    if (v >= 1e9)  return grande(v / 1e9, ' bi');
    if (v >= 1e6)  {
      var mi = v / 1e6;
      if (Math.round(mi) >= 1000) return 'R$ 1 bi';
      return grande(mi, ' mi');
    }
    if (v >= 1000) {
      var mil = Math.round(v / 1000);
      if (mil >= 1000) return 'R$ 1 mi';
      return 'R$ ' + mil + ' mil';
    }
    return 'R$ ' + Math.round(v);
  }

  function grande(m, sufixo) {
    var t = m.toFixed(m >= 100 ? 0 : 1);
    if (t.length > 2 && t.slice(-2) === '.0') t = t.slice(0, -2);   /* "30,0" -> "30" */
    return 'R$ ' + t.replace('.', ',') + sufixo;
  }

  function anosTexto(n) {
    n = Math.round(n);
    return n + (n === 1 ? ' ano' : ' anos');
  }

  /* ====================================================================== */
  /* MATEMÁTICA                                                             */
  /* ====================================================================== */

  /* Valor futuro com aporte mensal, capitalização mensal.
     `taxaAno` é taxa anual; a mensal equivalente é (1+a)^(1/12)-1 — e não
     a/12, que superestima. `meses` pode ser fracionário: o traçado amostra
     em passos que nem sempre caem em mês fechado. */
  function futuro(p, aporte, meses, taxaAno) {
    var i = Math.pow(1 + taxaAno, 1 / 12) - 1;
    var f = Math.pow(1 + i, meses);
    if (i === 0) return p + aporte * meses;
    return p * f + aporte * (f - 1) / i;
  }

  /* Série de `passos+1` pontos, do mês 0 ao fim do horizonte. */
  function serie(p, aporte, anos, taxaAno, passos) {
    var pts = [];
    for (var k = 0; k <= passos; k++) {
      pts.push(futuro(p, aporte, anos * 12 * k / passos, taxaAno));
    }
    return pts;
  }

  /* Teto "redondo" do eixo vertical. Sem ele o topo do gráfico gruda no valor
     máximo e a faixa nunca parece crescer — ela só se estica para preencher a
     caixa, e mover um controle vira mudança de escala em vez de mudança de
     tamanho. Com o teto redondo o desenho cresce de verdade dentro da caixa
     até o degrau seguinte, e o rótulo do topo torna o degrau legível.

     A escada é fina de propósito (11 degraus por década, não os 4 clássicos
     1-2-5-10): com a escada grossa o valor máximo caía em 73% da altura no
     estado inicial e sobrava um terço de caixa vazia. Com esta, o máximo fica
     sempre entre 83% e 100% do teto. A tabela em si vive lá em cima, junto das
     outras constantes: `var` sobe, valor não, e daqui ela chegaria `undefined`
     no laço que instancia os componentes. */
  function tetoRedondo(v) {
    if (!(v > 0)) return 1;
    var exp = Math.floor(Math.log(v) / Math.LN10);
    var base = Math.pow(10, exp);
    var razao = v / base;
    for (var i = 0; i < ESCADA.length; i++) {
      if (razao <= ESCADA[i] + 1e-9) return ESCADA[i] * base;
    }
    return 10 * base;
  }

  /* ====================================================================== */
  /* TRAÇADO                                                                */
  /* ====================================================================== */

  function px(k, passos) { return arred(PX0 + (PX1 - PX0) * k / passos); }
  function py(v, teto)   { return arred(PY1 - (PY1 - PY0) * (v / teto)); }
  /* Uma casa decimal: o `d` de 31 pontos cai de ~620 para ~380 caracteres e
     a diferença é invisível numa unidade de viewBox (~0,9px na tela). */
  function arred(n) { return Math.round(n * 10) / 10; }

  function caminhoLinha(pts, teto) {
    var d = '', i;
    for (i = 0; i < pts.length; i++) {
      d += (i ? 'L' : 'M') + px(i, pts.length - 1) + ' ' + py(pts[i], teto);
    }
    return d;
  }

  /* A faixa: borda de cima da esquerda para a direita, borda de baixo de
     volta, e fecha. Um polígono só — não dois caminhos empilhados, que
     deixariam costura visível na diagonal. */
  function caminhoFaixa(alto, baixo, teto) {
    var n = alto.length - 1, d = '', i;
    for (i = 0; i <= n; i++) d += (i ? 'L' : 'M') + px(i, n) + ' ' + py(alto[i], teto);
    for (i = n; i >= 0; i--) d += 'L' + px(i, n) + ' ' + py(baixo[i], teto);
    return d + 'Z';
  }

  /* ====================================================================== */
  /* UMA INSTÂNCIA                                                          */
  /* ====================================================================== */

  function iniciar(raiz) {
    var faixas = {
      inicial: raiz.querySelector('[data-proj-range="inicial"]'),
      aporte:  raiz.querySelector('[data-proj-range="aporte"]'),
      anos:    raiz.querySelector('[data-proj-range="anos"]')
    };
    if (!faixas.inicial || !faixas.aporte || !faixas.anos) return;

    var svg   = raiz.querySelector('[data-proj-svg]');
    var faixa = raiz.querySelector('[data-proj-band]');
    var linha = raiz.querySelector('[data-proj-line]');
    var ponto = raiz.querySelector('[data-proj-dot]');
    var fim   = raiz.querySelector('[data-proj-end]');
    var capFim = raiz.querySelector('[data-proj-endcap]');
    var topo  = raiz.querySelector('[data-proj-ymax]');
    var marcas = raiz.querySelectorAll('[data-proj-tick]');
    if (!svg || !faixa || !linha) return;

    var cenarios = raiz.querySelectorAll('[data-proj-scenario]');

    /* ---- as leituras que contam --------------------------------------- */
    /* Cada uma guarda o valor exibido (`atual`) e o alvo. O rAF caminha de um
       para o outro; sob movimento reduzido `atual` recebe o alvo direto. */
    var leituras = [];
    function registrar(chave, formato) {
      var el = raiz.querySelector('[data-proj-out="' + chave + '"]');
      if (!el) return;
      leituras.push({
        el: el, fmt: formato, escrito: null, virgem: true,
        atual: 0,          /* o que está na tela */
        de: 0, para: 0,    /* extremos do trajeto em curso */
        t: DUR             /* tempo decorrido do trajeto; DUR = parado */
      });
    }
    registrar('aportado', moeda);
    registrar('min', moeda);
    registrar('max', moeda);
    registrar('anos', anosTexto);

    var laco = 0, ultimo = 0, redesenhar = true;
    var cenario = CENARIO_PADRAO;
    for (var c = 0; c < cenarios.length; c++) {
      if (cenarios[c].checked) cenario = cenarios[c].getAttribute('data-proj-scenario');
    }

    /* ---- entrada: a linha se desenha ---------------------------------- */
    /* Sem JS a linha nasce inteira (o CSS não tem dasharray). Quem arma o
       traço é este bloco, e só quando há movimento: assim o estado honesto
       do markup nunca depende de uma classe que o script removeria. */
    var armado = false, olho = null, cronometro = 0;

    function armarEntrada() {
      var L = Math.ceil(linha.getTotalLength()) + 2;
      linha.style.strokeDasharray = L;
      linha.style.strokeDashoffset = L;
      svg.classList.add('is-enter');
      armado = true;
      olho = new IntersectionObserver(function (entradas) {
        for (var j = 0; j < entradas.length; j++) {
          if (entradas[j].isIntersecting) encerrarEntrada(true);
        }
      }, { threshold: LIMIAR });
      olho.observe(svg);
    }

    /* A entrada acaba de dois jeitos: o gráfico apareceu (aí ela roda) ou a
       pessoa mexeu num controle antes disso (aí ela é pulada). O segundo caso
       não é teórico: no mobile as colunas empilham, os controles ficam acima
       do gráfico e dá para arrastar uma faixa com o gráfico ainda fora da
       tela. Sem esta saída, o desenho continuaria escondido respondendo a um
       ajuste que ninguém vê. */
    function encerrarEntrada(animando) {
      if (!armado) return;
      armado = false;
      if (olho) { olho.disconnect(); olho = null; }
      svg.classList.remove('is-enter');
      if (!animando) { soltarTraco(); return; }
      linha.style.strokeDashoffset = 0;
      /* Terminado o traço, o tracejado sai: dali em diante o caminho muda de
         comprimento a cada ajuste, e um dasharray fixo passaria a cortar a
         ponta da linha nova. */
      cronometro = setTimeout(soltarTraco, DESENHO + 120);
    }

    function soltarTraco() {
      if (cronometro) { clearTimeout(cronometro); cronometro = 0; }
      linha.style.strokeDasharray = '';
      linha.style.strokeDashoffset = '';
    }

    /* ---- escuta -------------------------------------------------------- */
    /* `input`, não `change`: `change` num range só dispara quando a pessoa
       solta o ponteiro, e a peça inteira existe para responder DURANTE o
       arrasto. O listener não calcula nada — marca e acorda. */
    function pedirQuadro() {
      encerrarEntrada(false);   /* mexeu: a entrada não tem mais o que fazer */
      soltarTraco();            /* e o tracejado não pode cortar o caminho novo */
      redesenhar = true;
      acordar();
    }

    faixas.inicial.addEventListener('input', pedirQuadro);
    faixas.aporte.addEventListener('input', pedirQuadro);
    faixas.anos.addEventListener('input', pedirQuadro);
    for (var s = 0; s < cenarios.length; s++) {
      (function (el) {
        el.addEventListener('change', function () {
          cenario = el.getAttribute('data-proj-scenario');
          pedirQuadro();
        });
      })(cenarios[s]);
    }

    /* Primeira passada: sincroniza o preenchimento das faixas e os alvos com
       o que está escrito no markup. Como os alvos nascem iguais ao que já
       está na tela, nada conta no carregamento — a contagem é reação a um
       ajuste da pessoa, não um efeito de entrada. */
    calcular(true);
    desenhar();

    /* A entrada arma DEPOIS do primeiro desenho, e a ordem importa: o
       comprimento do traço vem de getTotalLength(), que muda com o caminho.
       Armar antes leria o comprimento do `d` escrito no markup — igual hoje,
       mas refém de os dois nunca divergirem. */
    if (!reduzir && typeof IntersectionObserver === 'function' && linha.getTotalLength) {
      armarEntrada();
    }

    /* ------------------------------------------------------------- o laço */
    function acordar() {
      if (laco) return;
      ultimo = 0;
      laco = requestAnimationFrame(passo);
    }

    function passo(agora) {
      var dt = ultimo ? Math.min(agora - ultimo, DT_MAX) : 16;
      ultimo = agora;
      laco = 0;

      if (redesenhar) { redesenhar = false; calcular(false); desenhar(); }

      /* O trajeto anda por TEMPO ACUMULADO, não por quadro: a 30fps e a
         120fps o número leva os mesmos 520ms. O dt entra com teto, senão uma
         aba que volta do segundo plano recebe um dt de vários segundos e o
         número salta o trajeto inteiro num quadro só.

         Cada ajuste REMARCA o trajeto a partir de onde o número está. Durante
         o arrasto isso acontece a cada quadro, e o efeito é um número que
         persegue o controle com atraso curto; solto o controle, o último
         trajeto corre inteiro e termina em 520ms — o fim é sempre datado, ao
         contrário de um amortecimento exponencial, que se aproxima para
         sempre sem chegar. */
      var vivo = false;
      for (var i = 0; i < leituras.length; i++) {
        var n = leituras[i];
        if (n.t < DUR) {
          n.t = Math.min(n.t + dt, DUR);
          var t = n.t / DUR;
          n.atual = n.de + (n.para - n.de) * (1 - Math.pow(1 - t, 3));   /* easeOutCubic */
          if (n.t < DUR) vivo = true;
          escrever(n);
        }
      }
      if (vivo) laco = requestAnimationFrame(passo);
    }

    function escrever(n) {
      var t = n.fmt(n.atual);
      if (t === n.escrito) return;         /* não toca o DOM à toa */
      n.escrito = t;
      n.el.textContent = t;
    }

    /* --------------------------------------------------------- a conta */
    var estado = null;

    function calcular(primeira) {
      var p     = parseFloat(faixas.inicial.value) || 0;
      var ap    = parseFloat(faixas.aporte.value) || 0;
      var anos  = parseInt(faixas.anos.value, 10) || 1;
      var taxas = CENARIOS[cenario] || CENARIOS[CENARIO_PADRAO];

      /* Um ponto por ano, com piso de 16: em 1 ano dois pontos dariam uma
         reta, e a curva do juro composto — que é o assunto — sumiria.
         No teto são 31 pontos por borda, 93 números por quadro. */
      var passos = Math.max(anos, 16);
      var alto   = serie(p, ap, anos, taxas.teto, passos);
      var baixo  = serie(p, ap, anos, taxas.piso, passos);
      var meio   = serie(p, ap, anos, (taxas.piso + taxas.teto) / 2, passos);

      estado = {
        p: p, ap: ap, anos: anos, passos: passos,
        alto: alto, baixo: baixo, meio: meio,
        teto: tetoRedondo(alto[passos]),
        aportado: p + ap * anos * 12
      };

      /* Preenchimento da trilha: WebKit não tem pseudo de progresso, então o
         trecho colorido é um gradiente lido de --proj-fill. */
      preencher(faixas.inicial);
      preencher(faixas.aporte);
      preencher(faixas.anos);

      rotular(faixas.inicial, moeda);
      rotular(faixas.aporte, moeda);
      rotular(faixas.anos, anosTexto);

      alvo('aportado', estado.aportado, primeira);
      alvo('min', baixo[passos], primeira);
      alvo('max', alto[passos], primeira);
      alvo('anos', anos, primeira);
    }

    function alvo(chave, v, primeira) {
      for (var i = 0; i < leituras.length; i++) {
        var n = leituras[i];
        if (n.el.getAttribute('data-proj-out') !== chave) continue;

        /* Na primeira passada (e sob movimento reduzido) o número aparece no
           valor final, sem trajeto. `escrito` recebe o texto que veio do
           markup para que a primeira escrita não toque o DOM se ele já
           estiver correto — que é o caso enquanto markup e script não
           divergirem. */
        if (primeira || reduzir) {
          n.de = n.para = n.atual = v;
          n.t = DUR;
          if (n.virgem) { n.escrito = n.el.textContent; n.virgem = false; }
          escrever(n);
          return;
        }
        if (v === n.para) return;          /* alvo repetido: nada a remarcar */
        n.de = n.atual;
        n.para = v;
        n.t = 0;
        return;
      }
    }

    function preencher(range) {
      var min = parseFloat(range.min) || 0;
      var max = parseFloat(range.max) || 1;
      var v = parseFloat(range.value) || 0;
      var pc = max === min ? 0 : (v - min) / (max - min) * 100;
      range.style.setProperty('--proj-fill', (Math.round(pc * 10) / 10) + '%');
    }

    /* O valor ao lado do controle e o aria-valuetext andam juntos: o primeiro
       é o que se lê, o segundo é o que se ouve. Sem o valuetext o leitor de
       tela anuncia "2500000" ao mover a seta. */
    function rotular(range, fmt) {
      var t = fmt(parseFloat(range.value) || 0);
      var saida = raiz.querySelector('[data-proj-val="' + range.getAttribute('data-proj-range') + '"]');
      if (saida && saida.textContent !== t) saida.textContent = t;
      range.setAttribute('aria-valuetext', t);
    }

    /* ------------------------------------------------------------ o desenho */
    function desenhar() {
      if (!estado) return;
      var e = estado, n = e.passos;

      faixa.setAttribute('d', caminhoFaixa(e.alto, e.baixo, e.teto));
      linha.setAttribute('d', caminhoLinha(e.meio, e.teto));

      var yFim = py(e.meio[n], e.teto);
      if (ponto) { ponto.setAttribute('cx', PX1); ponto.setAttribute('cy', yFim); }

      /* O rótulo do fim é preso à ponta da linha, mas não sai da caixa: em
         horizonte curto a ponta encosta na base e o texto vazaria para baixo
         do eixo. */
      var yTexto = Math.max(PY0 + 30, Math.min(yFim, PY1 - 8));
      if (fim) {
        fim.setAttribute('y', yTexto - 10);
        fim.textContent = moeda(e.meio[n]);
      }
      if (capFim) capFim.setAttribute('y', yTexto - 28);
      if (topo) topo.textContent = moeda(e.teto);

      for (var i = 0; i < marcas.length && i < 4; i++) {
        var ano = Math.round(e.anos * i / 3);
        var texto = i === 3 ? anosTexto(ano) : String(ano);
        /* Horizonte curto faz duas marcas caírem no mesmo ano; a repetida sai
           em branco em vez de aparecer duplicada. */
        if (i > 0 && ano === Math.round(e.anos * (i - 1) / 3) && i < 3) texto = '';
        if (i === 3 && ano === Math.round(e.anos * 2 / 3)) marcas[2].textContent = '';
        marcas[i].textContent = texto;
      }
    }
  }
})();

Duas colunas. À esquerda, três faixas — patrimônio inicial, aporte mensal, horizonte — mais um chip de cenário e três leituras em número. À direita, um gráfico de área em SVG que se redesenha a cada ajuste: uma faixa entre dois cenários simulados, com a linha do ponto médio por dentro. Sem lib: a conta é juro composto mensal, o caminho é uma string d montada à mão e a contagem é requestAnimationFrame por tempo.

O problema editorial é o de sempre numa página de assessoria: quem chega quer saber "e no meu caso?" e a página só sabe falar de si. A ferramenta devolve a pergunta em forma de desenho e termina entregando a pessoa a uma conversa, não a um cadastro. A decisão que define a peça é a faixa. Uma linha única diria "seu dinheiro vai chegar aqui" — afirmação que a peça não pode fazer, nem tecnicamente nem legalmente. A faixa diz "o resultado mora nesta região, e a região é larga", e o chip de cenário não muda promessa nenhuma: muda a largura da região.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho (.eyebrow) 1 palavra. "Simulador", "Exercício". Nunca "Calculadora de rentabilidade".
Título (.sec-title) 1 linha, uma palavra em <em>. Pergunta ou constatação, sem número: "Quanto tempo o seu plano precisa".
Apoio (.sec-lead) 2 linhas. Diz o que fazer ("mova os três controles") e o que a peça não é ("um exercício para orientar a conversa"). A segunda metade não é opcional.
Rótulo de faixa (.proj-name) 2 a 3 palavras, substantivo. "Patrimônio inicial", "Aporte mensal", "Horizonte". Minúsculas no HTML — a caixa alta é do CSS.
Extremos (.proj-scale) Os valores de min e max escritos por extenso, na mesma formatação do <output>. Se divergirem, a faixa mente sobre onde ela começa.
Chip de cenário (.proj-chip) 1 palavra, sem adjetivo de venda. "Conservador · Equilibrado · Arrojado" é o vocabulário do setor e não promete nada. Três é o número: dois lê binário, quatro não cabe em uma linha no mobile.
Chave da leitura (.proj-read-k) Frase curta e literal. "Faixa simulada ao fim do período", nunca "Seu patrimônio em 15 anos".
Ressalva (.proj-nota) Obrigatória, ao lado do resultado. Texto do §5 do contrato de conteúdo, mais a data das premissas.
Botão (.btn-primary) 1, com verbo de conversa: "Falar com um assessor". Nunca "Ver meu resultado" — o resultado já está na tela.

Especificação dos assets

Nenhum. Sem imagem, sem ícone de terceiro, sem fonte extra: o desenho é <path> e <text> num <svg> inline, com as cores saindo de --accent, --fg e --rule. O único "asset" da peça é a tabela de parâmetros no topo do script.js — e ela é conteúdo do cliente, não da biblioteca.


Compliance

Esta é a seção mais importante da ficha. Uma ferramenta que devolve número num site de assessoria pisa em terreno regulado.

O que o consumidor TEM de trocar antes de publicar:

  1. A tabela CENARIOS do script.js. As taxas de exemplo (3–6%, 4–9%, 5–13% ao ano) são números escolhidos para dimensionar o gráfico. Substitua por premissas próprias, datadas, e ponha a data visível junto da ressalva.
  2. O texto do .proj-nota, e a peça inteira, pela área de compliance do cliente. O modelo entregue atende ao piso; a redação final é dele — e a revisão inclui os rótulos, não só a ressalva.

O que a peça já garante e não deve ser desfeito:


Como usar

<link rel="stylesheet" href="core/tokens.css">
<link rel="stylesheet" href="core/temas/<projeto>.css">
<link rel="stylesheet" href="core/base.css">
<link rel="stylesheet" href="core/motion.css">
<link rel="stylesheet" href="componentes/conversao/calculadora-projecao/estilo.css">
<script src="core/motion.js"></script>
<script src="componentes/conversao/calculadora-projecao/script.js"></script>

Ganchos

Gancho Onde O quê
data-proj no .proj-grid a raiz. O script aceita várias na mesma página e nunca busca no document
data-proj-range nos três <input type="range"> inicial, aporte, anos. min, max, step e value moram no markup
data-proj-val no <output> ao lado o valor formatado, em aria-live="polite"
data-proj-out nas leituras aportado, min, max, anos — os números que contam
data-proj-scenario nos três rádios tem de casar com uma chave de CENARIOS, no script
data-proj-band / -line / -dot / -end / -endcap / -ymax / -tick dentro do SVG o que o script reescreve a cada quadro
--proj-fill (na faixa) e --proj-thumb --proj-tick-size --proj-end-size --proj-band-op (no .proj-grid) markup e CSS o preenchimento da trilha, inline no markup para o estado sem JS não nascer torto, mais os quatro parafusos públicos

Variações

Variação Como
Sem card de vidro .proj-chart--nu — para dobra clara, onde o vidro não tem o que refratar
Faixa mais presente .proj-grid--faixa-forte (--proj-band-op:26%)
Sem chip de cenário apague o <fieldset class="proj-scen">: o script cai no CENARIO_PADRAO
Sem recálculo não carregue o script.js — é o mesmo estado de quem abre sem JS

Gotchas


Histórico

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

Origem. Não há referência única e não há uma linha copiada. O padrão — três controles contínuos, um gráfico que responde e um botão no fim — aparece em três famílias de site: o simulador de aposentadoria de gestora de patrimônio, a calculadora de aporte de corretora digital e o simulador de carteira de assessoria independente. Delas vêm a forma (controles à esquerda, desenho à direita), a proporção e o ritmo da resposta.

Decisões de tradução para o contrato:

Como o padrão costuma vir Aqui Por quê
Chart.js, D3, Recharts <svg> inline com d montado por string Contrato §1: zero lib. São 40 linhas de traçado, e o SVG lê token, escala e é inspecionável
<div> com arrasto por pointer events <input type="range"> nativo, repintado Setas, Home/End, PageUp/PageDown, papel de slider e valor mínimo/máximo vêm de graça. Medido: 5 paradas de Tab percorrem a peça inteira, todas com anel de foco visível
Gráfico como única saída SVG aria-hidden + três leituras em texto §3 do brief de gráficos: o SVG é a tradução, o texto é o dado
0 no HTML, preenchido por JS, e resultado em localStorage estado inicial completo no markup, nada gravado Sem JS a peça fica de pé; e dado financeiro de terceiro não se guarda
Uma linha de projeção faixa entre dois cenários, com o ponto médio por dentro Compliance. Verificado: a faixa contém a linha nos 31 pontos, em todos os cenários
Contagem por amortecimento exponencial trajeto de duração fixa, remarcado a cada ajuste Exponencial se aproxima para sempre: o número levava 2,8 s para assentar. Com trajeto datado, 520 ms — medido, valor final na tela aos 561 ms

Verificado: desktop 1440×900 e mobile 390×844 com overflowX: ok e console limpo; fluxo inteiro por ponteiro e só por teclado; --reduce sem contagem, sem traçado e com a faixa nascendo opaca; e a página sem o script.js mostrando o mesmo desenho do markup. Status externo: nunca rodou em produção — espere ajuste no primeiro uso real, provavelmente nos extremos dos controles (que dependem do público do cliente) e na redação da ressalva, que é decisão da área de compliance dele.