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;
}
}
}
})();