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:
Francisco Solla 2026-09-19 12:27:27 -03:00
parent 0d6518c3d1
commit 384cfe98e8
9 changed files with 649 additions and 28 deletions

View file

@ -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

View 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).

View file

@ -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>

View file

@ -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 }

View file

@ -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(

View file

@ -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>

View file

@ -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(

View file

@ -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', () => {

View file

@ -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', () => {