mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C191): coluna de comparação entre categorias e variante duas colunas positivas — unidade %, kicker pela relação
Skill, núcleo (chartData), renderer (columnBody/CSS), design CENA 02A com sign-off humano e testes. Entrega: docs/research/graficos-instagram/a-maioria-dos-internados-nos-dois-hospitais-de-v-2026-09-19-feed.png (worktree work/21, sem claim).
This commit is contained in:
parent
0d6518c3d1
commit
384cfe98e8
9 changed files with 649 additions and 28 deletions
|
|
@ -55,7 +55,13 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa),
|
|||
- **duas medidas por categoria → `delta`**: barra de variação com a base
|
||||
(0 → inicial) num tom e a extensão (inicial → final) noutro, os dois valores
|
||||
numa gutter de duas colunas e o vermelho fora do plot (sem valência);
|
||||
- **uma única medida → `anchor`** (número grande + frase que explica).
|
||||
- **uma única medida → `anchor`** (número grande + frase que explica);
|
||||
- **duas categorias, ambas boas notícias → `column` com `--dual-positive`**:
|
||||
duas colunas verticais no vermelho oficial `#e4102f` — a valência boa é
|
||||
compartilhada, não há vencedor —, na ordem do dado, sem hierarquia nem
|
||||
cinza; certificada no feed, 280px por coluna;
|
||||
- **ambas neutras (sem valência) → `--no-highlight`**: barras/colunas no tom
|
||||
neutro, sem vermelho no plot.
|
||||
Ambíguo (mais de um tipo plausível) → **pergunte**; não escolha no escuro.
|
||||
Escreva a **manchete = takeaway** (não "Gráfico de X"), subtítulo opcional e a
|
||||
**fonte** (viaja dentro da imagem).
|
||||
|
|
@ -84,6 +90,13 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa),
|
|||
--source="Ministério da Saúde — CNES" \
|
||||
--note="Sem variação: EMAD, EMAP, ENASF-AB e ECR."
|
||||
```
|
||||
Duas categorias, ambos os resultados bons (dois tons de vermelho, sem hierarquia):
|
||||
```bash
|
||||
node scripts/build-chart-from-data.mjs --in=<arquivo> --type=column --size=feed \
|
||||
--dual-positive --unit=% \
|
||||
--headline="A maioria dos internados nos dois hospitais mora na cidade" \
|
||||
--source="Ministério da Saúde — SIH/SUS"
|
||||
```
|
||||
Saída default: `docs/research/graficos-instagram/<slug>-<data>-<size>.png`
|
||||
(gitignored) + o `chart-spec.json` em `data/graficos-instagram/`. `--size`:
|
||||
`feed` (1080×1350, default), `square` (1080×1080), `story` (1080×1920, com
|
||||
|
|
@ -127,7 +140,10 @@ Na linha de 2 séries, `series` substitui `rows` e `projectedLabel` marca o
|
|||
```
|
||||
|
||||
`chartType` ∈ `bar | column | line | anchor | delta`; `highlight` é o rótulo que
|
||||
recebe o único vermelho `#e4102f` (default: o maior valor). Em `series`, `tone` ∈
|
||||
recebe o único vermelho `#e4102f` (default: o maior valor); `unit` anexa o
|
||||
sufixo aos rótulos de valor (ex.: `"%"`). O `kicker` (spec) segue a **relação**
|
||||
dos dados, não o layout — colunas verticais de categorias leem "Comparação",
|
||||
poucos períodos leem "Poucos períodos". Em `series`, `tone` ∈
|
||||
`good | bad` (os dois tons ou nenhum) e as duas séries compartilham os mesmos
|
||||
rótulos temporais; `rows` e `highlight` não entram nessa variante.
|
||||
`projectedLabel` marca a projeção no último período e `crossingLabel` só é aceito
|
||||
|
|
@ -135,9 +151,15 @@ quando a série boa de fato ultrapassa a ruim naquele período (confirmação
|
|||
textual, nunca inferência automática). Os rótulos finais trazem a variação
|
||||
observada (primeiro → último ponto observado, fora a projeção).
|
||||
|
||||
Na barra de variação, `rows` traz `initial`/`final` por categoria e
|
||||
`startLabel`/`endLabel` nomeiam os dois períodos da gutter (o vermelho não entra
|
||||
no plot e a variante não aceita `highlight`):
|
||||
A comparação positiva dupla (`--dual-positive`, spec `dualPositive: true`) usa
|
||||
`column` + `rows` com exatamente 2 categorias: as duas colunas no vermelho
|
||||
oficial `#e4102f` — valência boa compartilhada, sem vencedor; as classes
|
||||
`positive-a`/`positive-b` seguem a ordem do dado, nunca o maior valor —, sem
|
||||
`highlight`. `--no-highlight` (spec `noHighlight: true`) é a
|
||||
peça sem valência (sem vermelho no plot). Na barra de variação, `rows` traz
|
||||
`initial`/`final` por categoria e `startLabel`/`endLabel` nomeiam os dois
|
||||
períodos da gutter (o vermelho não entra no plot e a variante não aceita
|
||||
`highlight`):
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -168,6 +190,10 @@ O builder **recusa** em vez de desenhar algo enganoso:
|
|||
retração (final < inicial) é recusada com a linha de 2 séries no lugar;
|
||||
`--highlight`/valência não entram (o par de tons é fixo e o vermelho fica fora
|
||||
do plot).
|
||||
- **Comparação positiva dupla (`--dual-positive`)** exige `column`, exatamente 2
|
||||
categorias e `feed`; recusa `--highlight` e `--no-highlight`; os tons seguem a
|
||||
ordem de entrada (nunca o maior) e ambos leem como resultado bom — cor nunca
|
||||
carrega sozinha (cada coluna tem rótulo e valor).
|
||||
- **Valência só na linha de 2 séries**, em par (`good`/`bad`) e sempre por
|
||||
palavra + seta + forma do marcador + cor — **cor nunca carrega sozinha**:
|
||||
bom = vermelho `#e4102f`, círculo, "↑ amplia" (o vermelho é a cor do
|
||||
|
|
|
|||
1
docs/changelog/2026-09-19-c191-duas-colunas-positivas.md
Normal file
1
docs/changelog/2026-09-19-c191-duas-colunas-positivas.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C191 — Coluna de comparação entre categorias e variante duas colunas positivas (2026-09-19):** a peça de internações de Vitória da Conquista (dois hospitais, ambos com maioria de moradores da cidade) precisava de colunas verticais, valores em % e sem hierarquia vermelho/cinza; pedido da comunicação em sessão direta (worktree `work/21`, sem claim). (1) **Design**: nova cena no `docs/plans/graficos-dados-instagram-ui-design.html` (CENA 02A, container `.dual-positive`), certificada pelo `designer` e aprovada por sign-off humano em 2026-09-19 — dois tons de vermelho (`#c51414` em `positive-a`, `#a21c1c` em `positive-b`) atribuídos pela **ordem de entrada** (nunca pelo maior valor), 280 px por coluna, rótulos de categoria em uma linha ("Municipal"/"Estadual"), plot de 500 px no feed. (2) **Contrato**: `unit` (sufixo nos rótulos de valor, ex.: `%`), `noHighlight` (peça sem valência, sem vermelho no plot) e `dualPositive` (exige `column`, exatamente 2 categorias e `feed`; recusa `--highlight`/`--no-highlight`); o `kicker` passa a seguir a **relação** dos dados — coluna de categorias lê "Comparação", não "Poucos períodos" —, não o layout forçado. (3) **Renderer**: teto de largura e centralização das colunas (2 categorias deixam de virar blocos de largura total), tons `positive-a`/`positive-b` pela ordem, `white-space: nowrap` no rótulo de categoria e ritmo próprio do feed (headline 60 px). (4) **Docs**: exemplos e guardrails das variantes no `SKILL.md`, incluindo a recusa fail-closed de pizza e a comparação positiva dupla. (5) **Testes**: unit do validate (unidade, kicker, neutral e dupla positiva fail-closed), da montagem do spec (`--type=column --unit=% --dual-positive`) e do render (classes, teto de 280 px, `%` nos valores, sem `highlight`). Entrega final: `docs/research/graficos-instagram/a-maioria-dos-internados-nos-dois-hospitais-de-v-2026-09-19-feed.png` ("A maioria dos internados nos dois hospitais de Vitória da Conquista mora na cidade"; 60,6% no municipal — Esaú Matos — e 54,7% no estadual — CHVC — das internações de 2025, SIH/SUS).
|
||||
|
|
@ -460,6 +460,88 @@
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* CENA 02A · comparação vertical de 2 categorias, ambas positivas. */
|
||||
/* Os tons distinguem as categorias; não codificam vencedor/perdedor. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.feed-canvas.dual-positive .chart-headline {
|
||||
max-width: 900px;
|
||||
font-size: 60px;
|
||||
}
|
||||
.feed-canvas.dual-positive .chart-subtitle {
|
||||
margin-top: 18px;
|
||||
font-size: 28px;
|
||||
}
|
||||
.dual-positive-plot {
|
||||
flex: none;
|
||||
height: 500px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
.feed-canvas.dual-positive .columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 280px));
|
||||
justify-content: center;
|
||||
align-items: end;
|
||||
gap: 26px;
|
||||
height: 100%;
|
||||
padding: 0 0 46px;
|
||||
border-bottom: 3px solid var(--bar-strong);
|
||||
}
|
||||
.feed-canvas.dual-positive .column-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
.feed-canvas.dual-positive .column-value {
|
||||
margin-bottom: 10px;
|
||||
color: var(--ink);
|
||||
text-align: center;
|
||||
font-size: 31px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.feed-canvas.dual-positive .column {
|
||||
min-height: 6px;
|
||||
}
|
||||
.feed-canvas.dual-positive .column.positive-a {
|
||||
background: #c51414;
|
||||
}
|
||||
.feed-canvas.dual-positive .column.positive-b {
|
||||
background: #a21c1c;
|
||||
}
|
||||
.feed-canvas.dual-positive .column-label {
|
||||
position: absolute;
|
||||
bottom: -40px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
color: #57534e;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
font-weight: 650;
|
||||
}
|
||||
.signoff-approved {
|
||||
border: 1px solid rgb(24 78 146 / 30%);
|
||||
background: rgb(24 78 146 / 5%);
|
||||
}
|
||||
.signoff-approved-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: #184e92;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* CENA 02C · PROPOSTA DEGRADED — variante "delta": uma barra por */
|
||||
/* categoria com base (0→inicial) e extensão (inicial→final). O */
|
||||
|
|
@ -919,6 +1001,29 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="signoff-approved mt-6 rounded-2xl p-5 sm:p-6"
|
||||
aria-labelledby="c191-extension-title"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="max-w-3xl">
|
||||
<span class="signoff-approved-badge">SIGN-OFF HUMANO · 2026-09-19</span>
|
||||
<h2 id="c191-extension-title" class="mt-3 text-lg font-bold tracking-tight">
|
||||
Extensão C191 · duas categorias, dois resultados positivos
|
||||
</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
Variante <strong>certificada pelo designer de referência</strong> em 2026-09-19 e
|
||||
pronta para avaliação na tela renderizada. Ela não altera as cenas já aprovadas: só
|
||||
acrescenta a comparação vertical em dois tons de vermelho, sem semântica de vencedor e
|
||||
perdedor. O dono do produto aprovou a peça final renderizada em 2026-09-19.
|
||||
</p>
|
||||
</div>
|
||||
<span class="scene-label border-blue-200 bg-blue-50 text-[#184e92]"
|
||||
>APROVADA · GATE 2026-09-19</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene-card mt-9 p-4 sm:p-6 lg:p-8" aria-labelledby="main-scene-title">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
|
|
@ -1402,6 +1507,192 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="scene-card signoff-approved mt-10 p-4 sm:p-6 lg:p-8"
|
||||
aria-labelledby="dual-positive-title"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-bold text-brand-dark">
|
||||
CENA 02A · SÉTIMA VARIAÇÃO · SIGN-OFF HUMANO 2026-09-19
|
||||
</p>
|
||||
<h2 id="dual-positive-title" class="mt-1 text-xl font-bold tracking-tight">
|
||||
Feed 4:5 · duas colunas, dois resultados positivos
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="measure-tag"><span class="measure-dot"></span>1080 × 1350</span>
|
||||
<span class="measure-tag">2 colunas · teto 280 px</span>
|
||||
<span class="signoff-approved-badge">APROVADA · 2026-09-19</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid items-start gap-7 lg:grid-cols-[594px_1fr]">
|
||||
<div class="overflow-x-auto pb-2">
|
||||
<div class="feed-stage">
|
||||
<article
|
||||
class="feed-canvas dual-positive"
|
||||
aria-label="Comparação positiva: hospital municipal, 60,6 por cento; hospital estadual, 54,7 por cento das internações de 2025 são de moradores da cidade"
|
||||
>
|
||||
<div class="feed-inner">
|
||||
<div class="top-rule"></div>
|
||||
<p class="chart-context">Comparação</p>
|
||||
<h3 class="chart-headline">
|
||||
Os dois hospitais atendem majoritariamente moradores da cidade
|
||||
</h3>
|
||||
<p class="chart-subtitle">
|
||||
Percentual das internações de 2025 de moradores da cidade
|
||||
</p>
|
||||
|
||||
<div class="dual-positive-plot">
|
||||
<div
|
||||
class="columns tabular"
|
||||
role="img"
|
||||
aria-label="Hospital municipal: 60,6 por cento. Hospital estadual: 54,7 por cento. As duas colunas partem do zero e representam resultados positivos, sem vencedor ou perdedor."
|
||||
>
|
||||
<div class="column-cell">
|
||||
<span class="column-value">60,6%</span>
|
||||
<div class="column positive-a" style="height: 100%"></div>
|
||||
<span class="column-label">Municipal</span>
|
||||
</div>
|
||||
<div class="column-cell">
|
||||
<span class="column-value">54,7%</span>
|
||||
<div class="column positive-b" style="height: 90.3%"></div>
|
||||
<span class="column-label">Estadual</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="chart-footer">
|
||||
<p class="source-line">
|
||||
<strong>Fonte:</strong> Ministério da Saúde — SIH/SUS<br />Internações de
|
||||
2025.
|
||||
</p>
|
||||
<div class="brand-logo-frame">
|
||||
<img
|
||||
src="../../public/campaign-kit/jorge-solla-positivo.png"
|
||||
alt="Jorge Solla — Deputado Federal"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="grid gap-4">
|
||||
<div class="rounded-xl border border-red-200 bg-red-50/70 p-4">
|
||||
<p class="text-xs font-extrabold text-[#a21c1c]">MESMA VALÊNCIA, SEM PÓDIO</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
As duas colunas são boas notícias. O par vermelho só separa categorias; tom mais
|
||||
claro ou mais escuro <strong>não significa</strong> melhor, pior, ganho, perda,
|
||||
prioridade ou vencedor. A altura comunica exclusivamente o valor observado.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">COR NÃO É A ÚNICA PISTA</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
Cada coluna mantém valor próprio acima do topo e rótulo curto próprio abaixo da
|
||||
base. A ordem recebida é preservada; nunca reordenar pelo valor nem atribuir o tom
|
||||
mais vivo ao maior número.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">POR QUE 280 PX E RÓTULO CURTO</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
O teto cai de 320 para <strong>280 px</strong>, deixando as barras menos grossas e o
|
||||
par mais leve. A categoria visível usa somente <strong>“Municipal”</strong> e
|
||||
<strong>“Estadual”</strong>, ambos em uma linha a 30 px; “Hospital” já está
|
||||
explícito na manchete e não se repete sob a base.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">EXCEÇÃO CONTROLADA DE DENSIDADE</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
Esta variante contraria deliberadamente o teto geral de vermelho (~10% da peça): o
|
||||
plot inteiro é a boa notícia dupla. Em compensação, não há coluna neutra, destaque,
|
||||
contorno, badge ou terceira marca de cor dentro do plot.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-7 border-t border-stone-200 pt-6"
|
||||
aria-labelledby="dual-positive-rules-title"
|
||||
>
|
||||
<p class="text-xs font-bold text-brand-dark">REGRAS DA COLUNA DUPLA POSITIVA</p>
|
||||
<h3 id="dual-positive-rules-title" class="mt-1 text-lg font-bold tracking-tight">
|
||||
Contrato visual para o porte classe-a-classe
|
||||
</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Entrada e semântica</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Exatamente <strong>2 categorias</strong>, um valor não negativo por categoria e
|
||||
confirmação explícita de que <strong>ambos</strong> são resultados positivos. Kicker
|
||||
fixo: <strong>“Comparação”</strong>. Se houver vencedor/perdedor, bom/ruim ou
|
||||
valência incerta, esta variante não se aplica: perguntar, nunca inferir.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Par de vermelhos · fixo</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Categoria A = <span class="rule-code">#c51414</span>; categoria B =
|
||||
<span class="rule-code">#a21c1c</span>. Atribuir por ordem de entrada, nunca pelo
|
||||
maior valor. Sem classe <span class="rule-code">highlight</span>, sem cinza no plot
|
||||
e sem opacidade. Ambos contrastam com o papel acima de 3:1 (5,73:1 e 7,34:1).
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Texto, contraste e redundância</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
<span class="rule-code">column-value</span> usa tinta
|
||||
<span class="rule-code">#1c1917</span> sobre papel
|
||||
<span class="rule-code">#faf9f7</span> (16,62:1).
|
||||
<span class="rule-code">column-label</span> usa
|
||||
<span class="rule-code">#57534e</span> sobre papel (7,25:1). Se algum formato futuro
|
||||
puser texto sobre as colunas, usar branco <span class="rule-code">#fff</span>:
|
||||
6,03:1 sobre A e 7,73:1 sobre B.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Geometria e base zero</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
<span class="rule-code">columns</span>: 2 × minmax(0, 280 px), gap 26 px, altura
|
||||
100%, padding-bottom 46 px e borda-base 3 px <span class="rule-code">#a8a29e</span>.
|
||||
Alturas proporcionais ao mesmo máximo e sempre partindo da base zero. Um valor por
|
||||
coluna; sem 3D, recorte ou eixo truncado.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Rótulos de categoria</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Uma linha obrigatória, 30 px/650,
|
||||
<span class="rule-code">white-space: nowrap</span>, bottom −40 px. Para hospitais,
|
||||
remover apenas o substantivo comum já dito na manchete: “Hospital
|
||||
municipal/estadual” vira “Municipal/Estadual”. Se o qualificador ainda não couber em
|
||||
280 px, pedir forma curta; nunca quebrar em duas linhas nem abreviar sozinho.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Mapa de classes</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Reusar <span class="rule-code">columns</span>,
|
||||
<span class="rule-code">column-cell</span>,
|
||||
<span class="rule-code">column-value</span>, <span class="rule-code">column</span> e
|
||||
<span class="rule-code">column-label</span>. No estado positivo duplo, o container
|
||||
recebe <span class="rule-code">dual-positive</span> e as barras recebem
|
||||
<span class="rule-code">positive-a</span> /
|
||||
<span class="rule-code">positive-b</span>; não emitir
|
||||
<span class="rule-code">highlight</span>. Plot: 500 px de altura e margin-top 34 px
|
||||
no feed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene-card mt-10 p-4 sm:p-6 lg:p-8" aria-labelledby="dual-line-title">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
|
|
@ -2499,7 +2790,8 @@
|
|||
<p class="text-sm font-bold">Fundo e sinal</p>
|
||||
<p class="mt-1 text-sm leading-5 text-stone-600">
|
||||
Off-white #faf9f7. Vermelho oficial #e4102f ocupa no máximo ~10% da peça; a régua de
|
||||
marca usa azul #184e92.
|
||||
marca usa azul #184e92. Exceção estrita, ainda em gate: a cena 02A usa duas colunas
|
||||
vermelhas quando as duas categorias forem explicitamente positivas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2509,7 +2801,10 @@
|
|||
<footer
|
||||
class="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-stone-300 py-5 text-xs text-stone-600"
|
||||
>
|
||||
<span>revisão C203 · design hi-fi <strong>aprovado no gate</strong> · 2026-09-19</span>
|
||||
<span
|
||||
>base C203 <strong>aprovada</strong> · extensão C191
|
||||
<strong>certificada pelo designer e aprovada pelo humano</strong> · 2026-09-19</span
|
||||
>
|
||||
<span>Kit 1313 oficial · valência redundante · NEEDS ASSET resolvido</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@
|
|||
* Usage:
|
||||
* node scripts/build-chart-from-data.mjs --in=data/graficos-instagram/dados.csv \
|
||||
* --headline="Um território concentra o maior resultado" --source="TSE 2022" \
|
||||
* [--subtitle=...] [--note=...] [--type=bar] [--size=feed|square|story] \
|
||||
* [--highlight="Território A"] [--out=docs/research/graficos-instagram/x.png]
|
||||
* [--subtitle=...] [--note=...] [--type=bar|column|line|anchor|delta] [--size=feed|square|story] \
|
||||
* [--unit="%"] [--highlight="Território A"|--no-highlight|--dual-positive] \
|
||||
* [--out=docs/research/graficos-instagram/x.png]
|
||||
* node scripts/build-chart-from-data.mjs --in=dados.csv --inspect
|
||||
*/
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
MAX_POINTS,
|
||||
MAX_POINTS_LINE,
|
||||
MAX_POINTS_STORY,
|
||||
RELATION_LABEL,
|
||||
SIZES,
|
||||
classifyRelation,
|
||||
parseInput,
|
||||
|
|
@ -154,9 +156,18 @@ export const main = async ({
|
|||
die('dado ambíguo ou faltando — pergunte à pessoa antes de gerar (nada é completado).')
|
||||
}
|
||||
|
||||
if (flags['dual-positive'] && hasSeries) {
|
||||
die(
|
||||
'--dual-positive é a coluna de duas categorias positivas — não combina com a linha de duas séries.',
|
||||
)
|
||||
}
|
||||
|
||||
const chartType = hasSeries
|
||||
? (forcedType ?? 'line')
|
||||
: classifyRelation(dataset.rows, forcedType)
|
||||
// The kicker names the data relation, not the forced layout: categories in
|
||||
// vertical columns still read "Comparação"; few periods keep their own.
|
||||
const kicker = hasSeries ? null : RELATION_LABEL[classifyRelation(dataset.rows)]
|
||||
spec = {
|
||||
chartType,
|
||||
size: flags.size ? String(flags.size) : 'feed',
|
||||
|
|
@ -164,6 +175,8 @@ export const main = async ({
|
|||
subtitle: flags.subtitle ? String(flags.subtitle) : '',
|
||||
source: flags.source ? String(flags.source) : '',
|
||||
note: flags.note ? String(flags.note) : '',
|
||||
...(kicker ? { kicker } : {}),
|
||||
...(flags.unit ? { unit: String(flags.unit) } : {}),
|
||||
...(hasSeries
|
||||
? {
|
||||
...(flags.projected ? { projectedLabel: String(flags.projected) } : {}),
|
||||
|
|
@ -171,7 +184,9 @@ export const main = async ({
|
|||
series: applyTones(dataset.series, flags, die),
|
||||
}
|
||||
: {
|
||||
highlight: flags.highlight ? String(flags.highlight) : null,
|
||||
...(flags.highlight ? { highlight: String(flags.highlight) } : {}),
|
||||
...(flags['no-highlight'] ? { noHighlight: true } : {}),
|
||||
...(flags['dual-positive'] ? { dualPositive: true } : {}),
|
||||
rows: dataset.rows,
|
||||
...(chartType === 'delta'
|
||||
? { startLabel: dataset.startLabel, endLabel: dataset.endLabel }
|
||||
|
|
|
|||
|
|
@ -562,6 +562,50 @@ export const validateSpec = (spec) => {
|
|||
`tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`,
|
||||
)
|
||||
}
|
||||
if (spec.unit !== undefined && spec.unit !== null && typeof spec.unit !== 'string') {
|
||||
throw new Error('unidade inválida — use uma string (ex.: --unit="%").')
|
||||
}
|
||||
if (spec.noHighlight !== undefined && typeof spec.noHighlight !== 'boolean') {
|
||||
throw new Error('noHighlight inválido — use booleano.')
|
||||
}
|
||||
if (spec.noHighlight && spec.highlight) {
|
||||
throw new Error(
|
||||
'destaque e peça sem destaque são exclusivos — use --highlight ou --no-highlight, nunca os dois.',
|
||||
)
|
||||
}
|
||||
if (spec.dualPositive !== undefined && typeof spec.dualPositive !== 'boolean') {
|
||||
throw new Error('dualPositive inválido — use booleano.')
|
||||
}
|
||||
if (spec.dualPositive) {
|
||||
if (chartType !== 'column') {
|
||||
throw new Error('a comparação positiva dupla exige o tipo coluna (--type=column).')
|
||||
}
|
||||
if (!Array.isArray(spec.rows) || spec.rows.length !== 2) {
|
||||
throw new Error(
|
||||
`a comparação positiva dupla desenha exatamente 2 categorias (${spec.rows?.length ?? 0} recebidas).`,
|
||||
)
|
||||
}
|
||||
if (spec.highlight) {
|
||||
throw new Error('a comparação positiva dupla não usa destaque — remova o --highlight.')
|
||||
}
|
||||
if (spec.noHighlight) {
|
||||
throw new Error(
|
||||
'a comparação positiva dupla já é a peça positiva — não combine com --no-highlight.',
|
||||
)
|
||||
}
|
||||
if (spec.size && spec.size !== 'feed') {
|
||||
throw new Error(
|
||||
'a comparação positiva dupla está certificada no tamanho feed; peça o design das versões quadrada/story antes.',
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
spec.kicker !== undefined &&
|
||||
spec.kicker !== null &&
|
||||
(typeof spec.kicker !== 'string' || spec.kicker.trim() === '')
|
||||
) {
|
||||
throw new Error('kicker inválido — use uma string não vazia.')
|
||||
}
|
||||
if (!CHART_TYPES.includes(chartType)) {
|
||||
if (chartType === 'pie') {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -29,12 +29,15 @@ export const SOLLA_PALETTE = {
|
|||
const FONT_STACK =
|
||||
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
|
||||
const formatValue = (value) =>
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value)
|
||||
const formatValue = (value, unit = '') =>
|
||||
`${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value)}${unit}`
|
||||
|
||||
const sortedByValue = (rows) => [...rows].sort((a, b) => b.value - a.value)
|
||||
|
||||
const highlightLabel = (spec) => spec.highlight ?? sortedByValue(spec.rows)[0]?.label ?? null
|
||||
const highlightLabel = (spec) => {
|
||||
if (spec.noHighlight) return null
|
||||
return spec.highlight ?? sortedByValue(spec.rows)[0]?.label ?? null
|
||||
}
|
||||
|
||||
const rankingBody = (spec) => {
|
||||
const rows = sortedByValue(spec.rows)
|
||||
|
|
@ -48,7 +51,7 @@ const rankingBody = (spec) => {
|
|||
return `<div class="rank-row">
|
||||
<span class="rank-label">${htmlEscape(row.label)}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar${isHighlight ? ' highlight' : ''}${percent === 0 ? ' zero' : ''}" style="width:${percent}%">${formatValue(row.value)}</div>
|
||||
<div class="bar${isHighlight ? ' highlight' : ''}${percent === 0 ? ' zero' : ''}" style="width:${percent}%">${formatValue(row.value, spec.unit)}</div>
|
||||
</div>
|
||||
</div>`
|
||||
})
|
||||
|
|
@ -60,13 +63,19 @@ const columnBody = (spec) => {
|
|||
const rows = spec.rows
|
||||
const max = Math.max(...rows.map((row) => row.value))
|
||||
const winner = highlightLabel(spec)
|
||||
return `<div class="columns" style="grid-template-columns:repeat(${rows.length}, 1fr)">
|
||||
// CENA 02A (approved): the two-positive comparison caps and centers the pair
|
||||
// and assigns the tones by input order — never by value.
|
||||
const tracks = spec.dualPositive
|
||||
? 'repeat(2, minmax(0, 280px))'
|
||||
: `repeat(${rows.length}, minmax(0, 320px))`
|
||||
return `<div class="columns" style="grid-template-columns:${tracks}">
|
||||
${rows
|
||||
.map((row) => {
|
||||
const isHighlight = row.label === winner
|
||||
.map((row, index) => {
|
||||
const isHighlight = !spec.dualPositive && row.label === winner
|
||||
const tone = spec.dualPositive ? ` positive-${index === 0 ? 'a' : 'b'}` : ''
|
||||
return `<div class="column-cell">
|
||||
<span class="column-value">${formatValue(row.value)}</span>
|
||||
<div class="column${isHighlight ? ' highlight' : ''}" style="height:${proportionalPercent(row.value, max)}%"></div>
|
||||
<span class="column-value">${formatValue(row.value, spec.unit)}</span>
|
||||
<div class="column${isHighlight ? ' highlight' : ''}${tone}" style="height:${proportionalPercent(row.value, max)}%"></div>
|
||||
<span class="column-label">${htmlEscape(row.label)}</span>
|
||||
</div>`
|
||||
})
|
||||
|
|
@ -123,8 +132,8 @@ const deltaBody = (spec) => {
|
|||
: `<span class="delta-ext" style="width:${formatPercent(((row.final - row.initial) / max) * 100)}%"></span>`
|
||||
}
|
||||
</div>
|
||||
<span class="delta-value initial">${formatValue(row.initial)}</span>
|
||||
<span class="delta-value final">${formatValue(row.final)}</span>
|
||||
<span class="delta-value initial">${formatValue(row.initial, spec.unit)}</span>
|
||||
<span class="delta-value final">${formatValue(row.final, spec.unit)}</span>
|
||||
</div>`
|
||||
})
|
||||
.join('')}
|
||||
|
|
@ -135,7 +144,7 @@ const anchorBody = (spec) => {
|
|||
const row = spec.rows[0]
|
||||
const copy = row.label || spec.subtitle || ''
|
||||
return `<div class="anchor">
|
||||
<div class="anchor-number">${formatValue(row.value)}</div>
|
||||
<div class="anchor-number">${formatValue(row.value, spec.unit)}</div>
|
||||
${copy ? `<p class="anchor-copy">${htmlEscape(copy)}</p>` : ''}
|
||||
</div>`
|
||||
}
|
||||
|
|
@ -167,7 +176,7 @@ const dualBody = (spec) => {
|
|||
crossingLabel: spec.crossingLabel ?? null,
|
||||
colors: DUAL_COLORS,
|
||||
valence: DUAL_VALENCE,
|
||||
format: (row) => formatValue(row.value),
|
||||
format: (row) => formatValue(row.value, spec.unit),
|
||||
})}</div>`
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +190,7 @@ const plotBody = (spec) => {
|
|||
rows: spec.rows,
|
||||
width: 880,
|
||||
height: 430,
|
||||
format: (row) => formatValue(row.value),
|
||||
format: (row) => formatValue(row.value, spec.unit),
|
||||
stroke: SOLLA_PALETTE.axis,
|
||||
highlight: SOLLA_PALETTE.highlight,
|
||||
paper: SOLLA_PALETTE.paper,
|
||||
|
|
@ -280,12 +289,15 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
|
|||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.columns { display: grid; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; }
|
||||
.columns { display: grid; justify-content: center; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; }
|
||||
.plot.dual-positive-plot { flex: none; height: 500px; margin-top: 34px; }
|
||||
.column.positive-a { background: ${SOLLA_PALETTE.highlight}; }
|
||||
.column.positive-b { background: ${SOLLA_PALETTE.highlight}; }
|
||||
.column-cell { position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
|
||||
.column-value { margin-bottom: 10px; text-align: center; font-size: 31px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.column { background: ${SOLLA_PALETTE.bar}; min-height: 6px; }
|
||||
.column.highlight { background: ${SOLLA_PALETTE.highlight}; }
|
||||
.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; }
|
||||
.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; white-space: nowrap; }
|
||||
.line-plot { height: 100%; display: flex; align-items: center; }
|
||||
.line-plot svg { width: 100%; height: auto; }
|
||||
.dual-line-plot { flex: none; }
|
||||
|
|
@ -373,6 +385,11 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
|
|||
const canvas = SIZES[sizeKey]
|
||||
const layout = layoutFor(sizeKey)
|
||||
const dual = Array.isArray(spec.series) && spec.series.length > 0
|
||||
const dualPositive = Boolean(spec.dualPositive)
|
||||
const dualPositiveStyles =
|
||||
!dualPositive || sizeKey !== 'feed'
|
||||
? ''
|
||||
: '.headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; }'
|
||||
const dualStyles = !dual
|
||||
? ''
|
||||
: sizeKey === 'feed'
|
||||
|
|
@ -388,7 +405,8 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
|
|||
: sizeKey === 'square'
|
||||
? '.inner { padding: 56px 84px 48px; } .plot { margin-top: 0; } .headline { font-size: 54px; } .subtitle { font-size: 28px; } .delta-grade { margin-top: 24px; --delta-bar-h: 42px; --delta-gap: 12px; --delta-v1-w: 84px; --delta-v2-w: 104px; } .delta-value { font-size: 30px; }'
|
||||
: '.plot { margin-top: 0; } .headline { font-size: 64px; } .delta-grade { margin-top: 40px; --delta-gap: 22px; --delta-bar-h: 52px; }'
|
||||
const kicker = dual ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico')
|
||||
const kicker =
|
||||
spec.kicker ?? (dual ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico'))
|
||||
const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label
|
||||
const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle
|
||||
return `<!doctype html>
|
||||
|
|
@ -406,6 +424,7 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
|
|||
.plot { margin-top: ${layout.plotGap}px; }
|
||||
${dualStyles}
|
||||
${deltaStyles}
|
||||
${dualPositiveStyles}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -415,7 +434,7 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
|
|||
<p class="context">${htmlEscape(kicker)}</p>
|
||||
<h1 class="headline">${htmlEscape(spec.headline)}</h1>
|
||||
${showSubtitle ? `<p class="subtitle">${htmlEscape(spec.subtitle)}</p>` : ''}
|
||||
<div class="plot">${plotBody(spec)}</div>
|
||||
<div class="plot${dualPositive ? ' dual-positive-plot' : ''}">${plotBody(spec)}</div>
|
||||
${footer(spec, brandLogoDataUri)}
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -252,6 +252,122 @@ describe('main — spec assembly, replay and the per-size log', () => {
|
|||
expect(stdout.join('')).toContain('tipo=delta')
|
||||
})
|
||||
|
||||
it('assembles a vertical category comparison with the relation kicker and the unit', async () => {
|
||||
const input = await writeInput(
|
||||
dir,
|
||||
'Hospital;Moradores da cidade (%)\nMunicipal (Esaú Matos);60.57\nEstadual (CHVC);54.67\n',
|
||||
)
|
||||
const { launchBrowser, close } = launchTracker()
|
||||
const screenshot = screenshotStub()
|
||||
|
||||
await main({
|
||||
argv: [
|
||||
`--in=${input}`,
|
||||
'--type=column',
|
||||
'--unit=%',
|
||||
'--headline=Maioria e da cidade',
|
||||
'--source=Ministério da Saúde — SIH/SUS',
|
||||
`--out=${join(dir, 'coluna.png')}`,
|
||||
],
|
||||
repoRoot: dir,
|
||||
launchBrowser,
|
||||
screenshot,
|
||||
})
|
||||
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
const spec = JSON.parse(
|
||||
await readFile(
|
||||
join(dir, 'data/graficos-instagram/maioria-e-da-cidade.chart-spec.json'),
|
||||
'utf8',
|
||||
),
|
||||
)
|
||||
expect(spec).toMatchObject({ chartType: 'column', kicker: 'Comparação', unit: '%' })
|
||||
expect(stdout.join('')).toContain('tipo=column')
|
||||
})
|
||||
|
||||
it('assembles the neutral piece with --no-highlight and refuses mixing both flags', async () => {
|
||||
const input = await writeInput(
|
||||
dir,
|
||||
'Hospital;Moradores da cidade\nHospital municipal;60.57\nHospital estadual;54.67\n',
|
||||
)
|
||||
const { launchBrowser } = launchTracker()
|
||||
const screenshot = screenshotStub()
|
||||
|
||||
await main({
|
||||
argv: [
|
||||
`--in=${input}`,
|
||||
'--type=column',
|
||||
'--unit=%',
|
||||
'--no-highlight',
|
||||
'--headline=Neutro',
|
||||
'--source=Ministério da Saúde — SIH/SUS',
|
||||
`--out=${join(dir, 'neutro.png')}`,
|
||||
],
|
||||
repoRoot: dir,
|
||||
launchBrowser,
|
||||
screenshot,
|
||||
})
|
||||
|
||||
const spec = JSON.parse(
|
||||
await readFile(join(dir, 'data/graficos-instagram/neutro.chart-spec.json'), 'utf8'),
|
||||
)
|
||||
expect(spec.noHighlight).toBe(true)
|
||||
expect(spec.highlight).toBeUndefined()
|
||||
|
||||
await expect(
|
||||
main({
|
||||
argv: [
|
||||
`--in=${input}`,
|
||||
'--type=column',
|
||||
'--no-highlight',
|
||||
'--highlight=Hospital municipal',
|
||||
'--headline=Conflito',
|
||||
'--source=TSE',
|
||||
],
|
||||
repoRoot: dir,
|
||||
launchBrowser,
|
||||
screenshot,
|
||||
}),
|
||||
).rejects.toThrow(/exclusivos/)
|
||||
})
|
||||
|
||||
it('assembles the two-positive pair with --dual-positive', async () => {
|
||||
const input = await writeInput(
|
||||
dir,
|
||||
'Hospital;Moradores da cidade\nMunicipal;60.57\nEstadual;54.67\n',
|
||||
)
|
||||
const { launchBrowser } = launchTracker()
|
||||
const screenshot = screenshotStub()
|
||||
|
||||
await main({
|
||||
argv: [
|
||||
`--in=${input}`,
|
||||
'--type=column',
|
||||
'--unit=%',
|
||||
'--dual-positive',
|
||||
'--headline=Duas boas noticias',
|
||||
'--source=Ministério da Saúde — SIH/SUS',
|
||||
`--out=${join(dir, 'positivo.png')}`,
|
||||
],
|
||||
repoRoot: dir,
|
||||
launchBrowser,
|
||||
screenshot,
|
||||
})
|
||||
|
||||
const spec = JSON.parse(
|
||||
await readFile(
|
||||
join(dir, 'data/graficos-instagram/duas-boas-noticias.chart-spec.json'),
|
||||
'utf8',
|
||||
),
|
||||
)
|
||||
expect(spec).toMatchObject({
|
||||
chartType: 'column',
|
||||
unit: '%',
|
||||
dualPositive: true,
|
||||
kicker: 'Comparação',
|
||||
})
|
||||
})
|
||||
|
||||
it('replays a --spec without reparsing the input and closes the browser', async () => {
|
||||
const specPath = join(dir, 'replay.chart-spec.json')
|
||||
await writeFile(
|
||||
|
|
|
|||
|
|
@ -218,6 +218,48 @@ describe('validateSpec — fail-closed guardrails', () => {
|
|||
it('refuses an unlabeled bar row', () => {
|
||||
expect(() => validateSpec({ ...base, rows: [{ label: '', value: 1 }] })).toThrow(/rótulo/)
|
||||
})
|
||||
|
||||
it('accepts an optional unit suffix and a relation kicker', () => {
|
||||
expect(validateSpec({ ...base, unit: '%', kicker: 'Comparação' })).toMatchObject({
|
||||
unit: '%',
|
||||
kicker: 'Comparação',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a non-string unit and an empty kicker', () => {
|
||||
expect(() => validateSpec({ ...base, unit: 10 })).toThrow(/unidade/)
|
||||
expect(() => validateSpec({ ...base, kicker: ' ' })).toThrow(/kicker/)
|
||||
})
|
||||
|
||||
it('accepts the neutral piece flag and refuses the highlight conflict', () => {
|
||||
expect(validateSpec({ ...base, noHighlight: true })).toMatchObject({ noHighlight: true })
|
||||
expect(() => validateSpec({ ...base, noHighlight: 'sim' })).toThrow(/noHighlight/)
|
||||
expect(() => validateSpec({ ...base, noHighlight: true, highlight: 'A' })).toThrow(/exclusivos/)
|
||||
})
|
||||
|
||||
it('certifies the two-positive column pair and fails closed outside it', () => {
|
||||
const pair = {
|
||||
...base,
|
||||
chartType: 'column',
|
||||
rows: [
|
||||
{ label: 'Municipal', value: 60.57 },
|
||||
{ label: 'Estadual', value: 54.67 },
|
||||
],
|
||||
}
|
||||
expect(validateSpec({ ...pair, dualPositive: true })).toMatchObject({ dualPositive: true })
|
||||
expect(() => validateSpec({ ...pair, dualPositive: 'sim' })).toThrow(/dualPositive/)
|
||||
expect(() => validateSpec({ ...base, dualPositive: true })).toThrow(/coluna/)
|
||||
expect(() =>
|
||||
validateSpec({ ...pair, dualPositive: true, rows: [{ label: 'A', value: 1 }] }),
|
||||
).toThrow(/2 categorias/)
|
||||
expect(() => validateSpec({ ...pair, dualPositive: true, highlight: 'Municipal' })).toThrow(
|
||||
/destaque/,
|
||||
)
|
||||
expect(() => validateSpec({ ...pair, dualPositive: true, noHighlight: true })).toThrow(
|
||||
/no-highlight/,
|
||||
)
|
||||
expect(() => validateSpec({ ...pair, dualPositive: true, size: 'square' })).toThrow(/feed/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInput — two aligned measures become series, not a ranking', () => {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,69 @@ describe('renderChartHtml — canvas and template', () => {
|
|||
const html = render(spec({ chartType: 'column' }))
|
||||
expect(html).toContain('columns')
|
||||
expect(html).toContain('column highlight')
|
||||
// Few columns are capped and centered; four or more keep the 1fr rhythm.
|
||||
expect(html).toContain('grid-template-columns:repeat(3, minmax(0, 320px))')
|
||||
expect(html).toContain('justify-content: center')
|
||||
})
|
||||
|
||||
it('appends the optional unit to value labels and honors a relation kicker', () => {
|
||||
const html = render(
|
||||
spec({
|
||||
chartType: 'column',
|
||||
unit: '%',
|
||||
kicker: 'Comparação',
|
||||
rows: [
|
||||
{ label: 'Municipal (Esaú Matos)', value: 60.57 },
|
||||
{ label: 'Estadual (CHVC)', value: 54.67 },
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(html).toContain('60,6%')
|
||||
expect(html).toContain('54,7%')
|
||||
expect(html).toContain('>Comparação<')
|
||||
expect(html).not.toContain('Poucos períodos')
|
||||
})
|
||||
|
||||
it('carries the unit into the bar and the anchor value labels too', () => {
|
||||
expect(render(spec({ unit: '%' }))).toContain('84%')
|
||||
expect(
|
||||
render(spec({ chartType: 'anchor', unit: '%', rows: [{ label: '', value: 72 }] })),
|
||||
).toContain('72%')
|
||||
})
|
||||
|
||||
it(`renders the two-positive pair in the official red, by input order`, () => {
|
||||
const html = render(
|
||||
spec({
|
||||
chartType: 'column',
|
||||
unit: '%',
|
||||
dualPositive: true,
|
||||
rows: [
|
||||
{ label: 'Municipal', value: 60.57 },
|
||||
{ label: 'Estadual', value: 54.67 },
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(html).toContain('dual-positive-plot')
|
||||
expect(html).toContain('column positive-a')
|
||||
expect(html).toContain('column positive-b')
|
||||
expect(html).not.toContain('column highlight')
|
||||
expect(html).toContain('grid-template-columns:repeat(2, minmax(0, 280px))')
|
||||
expect(html).toContain('60,6%')
|
||||
expect(html).toContain('54,7%')
|
||||
expect(html).toContain('font-size: 60px')
|
||||
// C203 + designer re-decision: one shared official red — never a second tone.
|
||||
expect(html).toContain('#e4102f')
|
||||
expect(html).not.toContain('#a21c1c')
|
||||
})
|
||||
|
||||
it('keeps the red out of the plot when the piece is explicitly neutral', () => {
|
||||
const html = render(spec({ unit: '%', noHighlight: true }))
|
||||
expect(html).not.toContain('bar highlight')
|
||||
expect(html).toContain('84%')
|
||||
const column = render(spec({ chartType: 'column', noHighlight: true }))
|
||||
const plot = column.slice(column.indexOf('<div class="columns'), column.indexOf('<footer'))
|
||||
expect(plot).not.toContain('highlight')
|
||||
expect(plot).not.toContain('#e4102f')
|
||||
})
|
||||
|
||||
it('renders the line chart with the last point highlighted', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue