feat(C191): linha de duas séries no gráfico de Instagram — valência bom/ruim, projeção e cruzamento

Skill, núcleo (chartData), desenho (dualLineChart/renderer), design CENA 02B certificado e testes.
This commit is contained in:
Francisco Solla 2026-09-18 22:50:20 -03:00
parent a8a5ed324f
commit 6883aff9f3
10 changed files with 1787 additions and 55 deletions

View file

@ -37,12 +37,17 @@ O template visual é o artefato aprovado
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --inspect
```
Devolve `{ rows, format, issues }` — a matriz parseada e os problemas.
Devolve `{ rows, series, format, issues }` — a matriz parseada (tabela com
2+ colunas de medida vira `series`) e os problemas.
3. **Decida o tipo e a copy.** A relação dos dados manda:
- **ranking / comparação → `bar`** (barras horizontais, ordenadas pelo valor,
rótulo direto e valor na ponta);
- **poucos períodos (≤4) → `column`** (base zero comum);
- **série de tempo contínua → `line`** (ponto final destacado);
- **duas séries no mesmo período → `line` multi-série**: tabela com 2 colunas
de medida e a primeira coluna temporal; `--good=`/`--bad=` marcam a valência
bom/ruim (par ou nenhum), `--projected=` marca o último período projetado e
`--crossing=<período>` confirma a ultrapassagem que ganha anotação;
- **uma única medida → `anchor`** (número grande + frase que explica).
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
@ -55,12 +60,22 @@ O template visual é o artefato aprovado
--source="TSE 2022" --type=bar --size=feed \
--highlight="Ilhéus"
```
Duas séries no tempo (valência bom/ruim + projeção no último ponto):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> \
--headline="Internações crescem no hospital estadual e recuam no municipal" \
--source="Ministério da Saúde — SIH/SUS" \
--note="2026 é projeção pela média de janeiro a junho × 2." \
--type=line --size=feed \
--good="Estadual" --bad="Municipal" --projected=2026 --crossing=2024
```
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
faixas seguras de 250px no topo e na base). `--out=` sobrescreve o caminho.
5. **Confira e ajuste.** A pessoa confere o PNG; ajuste tipo/rótulo/manchete/
tamanho/`--highlight` e regenere. **Replay** sem reparse: `--spec=<chart-spec.json>`.
tamanho/`--highlight` (ou `--good`/`--bad`/`--projected`/`--crossing`) e
regenere. **Replay** sem reparse: `--spec=<chart-spec.json>`.
6. **Entregue o gráfico.** A comunicação finaliza a peça. O dado não persiste.
## Contrato do `chart-spec.json`
@ -78,8 +93,32 @@ O template visual é o artefato aprovado
}
```
Na linha de 2 séries, `series` substitui `rows` e `projectedLabel` marca o
último período projetado:
```json
{
"chartType": "line",
"size": "feed",
"headline": "Internações crescem no hospital estadual e recuam no municipal",
"source": "Ministério da Saúde — SIH/SUS",
"projectedLabel": "2026",
"crossingLabel": "2024",
"series": [
{ "name": "Estadual", "tone": "good", "rows": [{ "label": "2017", "value": 9807 }] },
{ "name": "Municipal", "tone": "bad", "rows": [{ "label": "2017", "value": 24002 }] }
]
}
```
`chartType` ∈ `bar | column | line | anchor`; `highlight` é o rótulo que recebe o
único vermelho `#c51414` (default: o maior valor).
único vermelho `#c51414` (default: o maior valor). 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
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).
## Guardrails (fail-closed)
@ -87,9 +126,23 @@ O builder **recusa** em vez de desenhar algo enganoso:
- **Barras e colunas partem do zero**; valor negativo é recusado; sem 3D/eixo
truncado.
- **Até 7 pontos** por gráfico; em **stories**, o ranking comporta **até 5**
pontos (a adaptação vertical reduz um ponto, conforme o design aprovado).
Acima disso, resuma as categorias e explique.
- **Até 7 pontos** por gráfico (barra, coluna e linha simples); a **linha de 2
séries** comporta **até 12 pontos por série**, sempre com os mesmos rótulos
temporais alinhados. Em **stories**, o ranking comporta **até 5** pontos (a
adaptação vertical reduz um ponto, conforme o design aprovado). Acima disso,
resuma as categorias e explique.
- **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 `#c51414`, círculo, "↑ amplia" (o vermelho é a cor do
mandato/PT e nunca marca a perda); ruim = cinza `#78716c`, quadrado,
"↓ recua". Sem tons, a comparação é neutra: sem setas, sem palavras de
valência e **sem vermelho no plot**.
- **Projeção** (`--projected=`) só no último período das duas séries: último
segmento tracejado, marcador final vazado e nota do método; sem projeção não
há faixa, tracejado nem ressalva automática.
- **Cruzamento** (`--crossing=<período>`) só com confirmação textual e quando a
série boa realmente ultrapassa a ruim naquele período; a anotação nunca é
inferida sozinha.
- **Número-âncora:** a manchete é o takeaway (não repete o número) e o valor
aparece uma vez, grande, seguido da frase que explica.
- **Pizza não é gerada no v1** — pedido `pie` é recusado com sugestão de barras.
@ -109,7 +162,11 @@ O builder **recusa** em vez de desenhar algo enganoso:
- **"dado ambíguo ou faltando"** → leia `issues` no JSON de saída: linha sem
rótulo, valor não numérico, rótulo repetido. Pergunte à pessoa; não invente.
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa.
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. Na linha de 2
séries o teto é 12 por série.
- **Séries desalinhadas / tom ímpar / projeção fora do último ponto /
cruzamento não confirmado** → o builder pede a correção com mensagem
acionável; nunca interpola, completa nem infere ultrapassagem.
- **"pizza não é gerada"** → ofereça barras horizontais.
- **Número pt-BR:** `1.234,56` é lido como 1234,56; `1,5` como 1,5.
**Separador único é sempre decimal:** `1.234` é lido como 1,234 e `1.500`
@ -119,7 +176,8 @@ O builder **recusa** em vez de desenhar algo enganoso:
## Referências
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html`.
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (inclui
a variação certificada de linha de duas séries, com valência bom/ruim).
- Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl:
`docs/plans/graficos-dados-instagram-impl.md`.
- Irmãos (mesmo padrão skill + command + builder local): C186/C187.

View file

@ -0,0 +1 @@
**C191 — Linha de duas séries no gráfico de Instagram (comparação no tempo) (2026-09-18):** a comunicação agora compara **duas séries temporais no mesmo canvas** com valência explícita e honesta, mantendo os guardrails do C191. (1) **Skill** `.agents/skills/graficos-dados/SKILL.md`: tabela temporal com 2 colunas de medida vira multi-série (`--good=`/`--bad=`/`--projected=`/`--crossing=`), teto de **12 pontos por série** (barra/coluna seguem em 7; stories 5) e a regra de valência: o vermelho `#c51414` é a cor do mandato e marca o **caminho bom** — o ruim fica neutro (`#78716c`, quadrado, "↓ recua"), nunca a cor do PT na perda; palavra + seta + forma do marcador + cor (cor nunca sozinha). (2) **Núcleo puro** `scripts/lib/chartData.mjs`: detecção de série por cabeçalho, rótulos temporais alinhados, tons em par (`good`/`bad`), projeção só no último período, cruzamento só com confirmação textual e ultrapassagem real dos valores — tudo fail-closed (nada inferido). (3) **Dono do desenho** `scripts/lib/chartPrimitives.mjs`: `dualLineChart` (escala compartilhada no zero, teto legível, gutter de rótulos diretos com variação observada, projeção tracejada com marcador vazado, anotação de cruzamento ring+guia+caixa) e `graficosInstagramRender.mjs` com kicker "Comparação no tempo" e geometria por canvas (`880×620` feed, `880×440` quadrado, `880×700` story). (4) **Design** `docs/plans/graficos-dados-instagram-ui-design.html`: CENA 02B **certificada** pelo `designer` após reprovação do dono (vermelho marcava a perda; vazio morto no feed) e duas rodadas de correção (copy da caixa de cruzamento fora dos limites; gutter de rótulos espremido). Testes `chartData`/`chartPrimitives`/`graficosInstagramRender` atualizados; `pnpm test:unit` 3.854 verdes; prettier/typecheck/lint/knip limpos. Estreia: internações hospitalares de Vitória da Conquista 2017–2026 (Estadual × Municipal, projeção 2026, cruzamento 2024).

View file

@ -366,6 +366,106 @@
font-weight: 750;
}
.mini-dual-line {
width: 100%;
height: 100%;
}
.mini-dual-label {
fill: var(--ink);
font-size: 7px;
font-weight: 800;
}
.feed-canvas.dual-series .chart-headline {
max-width: 900px;
font-size: 60px;
}
.feed-canvas.dual-series .chart-subtitle {
margin-top: 18px;
font-size: 28px;
}
.feed-canvas.dual-series .source-line {
font-size: 25px;
}
.dual-line-plot {
flex: none;
width: 880px;
height: 620px;
margin-top: 28px;
}
.dual-line-plot svg {
display: block;
width: 880px;
height: 620px;
}
.dual-axis-label {
fill: #78716c;
font-size: 30px;
font-weight: 650;
}
.dual-projection-label {
fill: #78716c;
font-size: 30px;
font-weight: 700;
}
.dual-end-name {
fill: var(--ink);
font-size: 30px;
font-weight: 700;
}
.dual-end-value {
fill: var(--ink);
font-size: 34px;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
.dual-end-valence {
font-size: 30px;
font-weight: 850;
}
.dual-end-change {
fill: #57534e;
font-size: 30px;
font-weight: 700;
}
.dual-crossing-guide {
stroke: var(--solla);
stroke-width: 2px;
stroke-dasharray: 8 8;
opacity: 0.45;
}
.dual-crossing-ring {
fill: var(--paper);
stroke: var(--solla);
stroke-width: 4px;
}
.dual-crossing-box {
fill: var(--paper);
stroke: #a8a29e;
stroke-width: 2px;
}
.dual-crossing-year {
fill: var(--solla);
font-size: 30px;
font-weight: 850;
}
.dual-crossing-copy {
fill: var(--ink);
font-size: 28px;
font-weight: 750;
}
.rule-code {
display: inline-flex;
padding: 2px 6px;
border: 1px solid #d6d3d1;
border-radius: 5px;
color: #44403c;
background: #fafaf9;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.4;
}
.story-stage {
width: 351px;
height: 624px;
@ -626,16 +726,16 @@
<main class="mx-auto max-w-[1240px] px-4 py-8 sm:px-7 lg:py-12">
<header class="max-w-4xl">
<div class="flex flex-wrap items-center gap-2">
<span class="scene-label">Gate visual · template v1</span>
<span class="scene-label">Design certificado · C191</span>
<span class="scene-label">Instagram feed + Stories</span>
</div>
<h1 class="mt-5 max-w-3xl text-3xl font-extrabold tracking-[-0.035em] text-ink sm:text-4xl">
Gráficos claros, sóbrios e prontos para a comunicação
</h1>
<p class="mt-4 max-w-3xl text-[15px] leading-6 text-stone-600">
A superfície desenhada é a imagem exportada — não uma tela de app. Os dados abaixo são
deliberadamente genéricos; as peças fixam hierarquia, grid, tratamento de marca e limites
do gerador.
A superfície desenhada é a imagem exportada — não uma tela de app. Os exemplos-base são
deliberadamente genéricos; a variante de duas séries usa o caso fornecido de Vitória da
Conquista. As peças fixam hierarquia, grid, tratamento de marca e limites do gerador.
</p>
</header>
@ -767,7 +867,7 @@
<div>
<p class="text-xs font-bold text-brand-dark">CENA 02 · SISTEMA</p>
<h2 id="variations-title" class="mt-1 text-xl font-bold tracking-tight">
Quatro relações, um mesmo template
Cinco variações, um mesmo template
</h2>
</div>
<p class="max-w-xl text-sm leading-5 text-stone-600">
@ -903,6 +1003,558 @@
Um valor grande + frase que explica.
</p>
</div>
<div class="scene-card p-3">
<article class="mini-canvas">
<div class="mini-rule"></div>
<div class="mini-kicker">Comparação no tempo</div>
<h3 class="mini-title">Estadual cresce e supera o municipal</h3>
<p class="mini-sub">Internações em Vitória da Conquista, 2017–2026.</p>
<div class="mini-plot">
<svg
class="mini-dual-line"
viewBox="0 0 220 130"
role="img"
aria-label="Duas séries de internações: hospital estadual cresce e supera o municipal em 2024; 2026 é projeção"
>
<line x1="8" y1="111" x2="151" y2="111" stroke="#a8a29e" stroke-width="1" />
<line x1="8" y1="61" x2="151" y2="61" stroke="#e7e5e4" stroke-width="1" />
<polyline
points="10,72 26,68 42,61 58,66 74,55 90,47 106,49 122,28 138,23"
fill="none"
stroke="#c51414"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="138"
y1="23"
x2="151"
y2="16"
stroke="#c51414"
stroke-width="3.5"
stroke-dasharray="6 4"
/>
<polyline
points="10,13 26,11 42,20 58,29 74,23 90,28 106,31 122,32 138,38"
fill="none"
stroke="#78716c"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="138"
y1="38"
x2="151"
y2="50"
stroke="#78716c"
stroke-width="3"
stroke-dasharray="6 4"
/>
<g fill="#c51414">
<circle cx="10" cy="72" r="2.5" />
<circle cx="26" cy="68" r="2.5" />
<circle cx="42" cy="61" r="2.5" />
<circle cx="58" cy="66" r="2.5" />
<circle cx="74" cy="55" r="2.5" />
<circle cx="90" cy="47" r="2.5" />
<circle cx="106" cy="49" r="2.5" />
<circle cx="122" cy="28" r="2.5" />
<circle cx="138" cy="23" r="2.5" />
</g>
<g fill="#78716c">
<rect x="7.5" y="10.5" width="5" height="5" />
<rect x="23.5" y="8.5" width="5" height="5" />
<rect x="39.5" y="17.5" width="5" height="5" />
<rect x="55.5" y="26.5" width="5" height="5" />
<rect x="71.5" y="20.5" width="5" height="5" />
<rect x="87.5" y="25.5" width="5" height="5" />
<rect x="103.5" y="28.5" width="5" height="5" />
<rect x="119.5" y="29.5" width="5" height="5" />
<rect x="135.5" y="35.5" width="5" height="5" />
</g>
<circle
cx="151"
cy="16"
r="4"
fill="#faf9f7"
stroke="#c51414"
stroke-width="2.5"
/>
<rect
x="147"
y="46"
width="8"
height="8"
fill="#faf9f7"
stroke="#78716c"
stroke-width="2"
/>
<text x="159" y="8" class="mini-dual-label">Estadual</text>
<text x="159" y="17" class="mini-dual-label" style="fill: #c51414">
23.294 · ↑ amplia
</text>
<text x="159" y="42" class="mini-dual-label">Municipal</text>
<text x="159" y="51" class="mini-dual-label" style="fill: #78716c">
15.948 · ↓ recua
</text>
<text
x="151"
y="126"
text-anchor="middle"
fill="#57534e"
font-size="6.5"
font-weight="700"
>
2026 · projeção
</text>
</svg>
</div>
<footer class="mini-footer">
<span>Fonte: SIH/SUS</span><span class="mini-mark">JORGE SOLLA</span>
</footer>
</article>
<p class="mt-3 text-sm font-bold">Linha · duas séries</p>
<p class="mt-1 text-xs leading-4 text-stone-600">
Tendências, valência redundante e projeção explícita.
</p>
</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>
<p class="text-xs font-bold text-brand-dark">CENA 02B · VARIAÇÃO CERTIFICADA</p>
<h2 id="dual-line-title" class="mt-1 text-xl font-bold tracking-tight">
Feed 4:5 · linha temporal de duas séries
</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">Plot SVG · viewBox 880 × 620</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-series"
aria-label="Internações crescem no hospital estadual e recuam no municipal em Vitória da Conquista, de 2017 a 2026; o ponto de 2026 é projeção"
>
<div class="feed-inner">
<div class="top-rule"></div>
<p class="chart-context">Comparação no tempo</p>
<h3 class="chart-headline">
Internações crescem no hospital estadual e recuam no municipal
</h3>
<p class="chart-subtitle">
AIH aprovadas por ano · Vitória da Conquista (BA) · 2017–2026
</p>
<div class="dual-line-plot tabular">
<svg
viewBox="0 0 880 620"
role="img"
aria-label="Hospital Estadual: de 9.807 internações em 2017 para projeção de 23.294 em 2026, alta observada de 121 por cento até 2025. Hospital Municipal: de 24.002 para projeção de 15.948, queda observada de 23 por cento até 2025. O estadual ultrapassa o municipal em 2024."
preserveAspectRatio="xMidYMid meet"
>
<rect
class="dual-projection-band"
x="570"
y="0"
width="63"
height="544"
fill="#a8a29e"
fill-opacity="0.16"
/>
<line
class="dual-grid-line"
x1="52"
y1="56"
x2="600"
y2="56"
stroke="#a8a29e"
stroke-opacity="0.32"
stroke-width="2"
/>
<line
class="dual-grid-line"
x1="52"
y1="300"
x2="600"
y2="300"
stroke="#a8a29e"
stroke-opacity="0.32"
stroke-width="2"
/>
<line
class="dual-zero-line"
x1="52"
y1="544"
x2="600"
y2="544"
stroke="#a8a29e"
stroke-width="2"
/>
<text x="42" y="538" text-anchor="end" class="dual-axis-label">0</text>
<text x="624" y="45" text-anchor="end" class="dual-projection-label">
projeção
</text>
<line class="dual-crossing-guide" x1="478" y1="160" x2="478" y2="398" />
<polyline
class="dual-series dual-series-good"
points="52,352 113,336 174,314 235,331 296,288 356,256 417,261 478,144 539,122"
fill="none"
stroke="#c51414"
stroke-width="8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
class="dual-series-projection dual-series-good"
x1="539"
y1="122"
x2="600"
y2="89"
stroke="#c51414"
stroke-width="8"
stroke-linecap="round"
stroke-dasharray="18 12"
/>
<polyline
class="dual-series dual-series-bad"
points="52,75 113,67 174,109 235,148 296,123 356,142 417,154 478,159 539,181"
fill="none"
stroke="#78716c"
stroke-width="8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
class="dual-series-projection dual-series-bad"
x1="539"
y1="181"
x2="600"
y2="233"
stroke="#78716c"
stroke-width="8"
stroke-linecap="round"
stroke-dasharray="18 12"
/>
<g class="dual-markers-good" fill="#c51414">
<circle cx="52" cy="352" r="8" />
<circle cx="113" cy="336" r="8" />
<circle cx="174" cy="314" r="8" />
<circle cx="235" cy="331" r="8" />
<circle cx="296" cy="288" r="8" />
<circle cx="356" cy="256" r="8" />
<circle cx="417" cy="261" r="8" />
<circle cx="478" cy="144" r="8" />
<circle cx="539" cy="122" r="8" />
</g>
<g class="dual-markers-bad" fill="#78716c">
<rect x="44" y="67" width="16" height="16" />
<rect x="105" y="59" width="16" height="16" />
<rect x="166" y="101" width="16" height="16" />
<rect x="227" y="140" width="16" height="16" />
<rect x="288" y="115" width="16" height="16" />
<rect x="348" y="134" width="16" height="16" />
<rect x="409" y="146" width="16" height="16" />
<rect x="470" y="151" width="16" height="16" />
<rect x="531" y="173" width="16" height="16" />
</g>
<circle
class="dual-marker-projected dual-marker-projected-good"
cx="600"
cy="89"
r="11"
fill="#faf9f7"
stroke="#c51414"
stroke-width="5"
/>
<rect
class="dual-marker-projected dual-marker-projected-bad"
x="590"
y="223"
width="20"
height="20"
fill="#faf9f7"
stroke="#78716c"
stroke-width="5"
/>
<circle class="dual-crossing-ring" cx="478" cy="144" r="14" />
<circle cx="478" cy="144" r="7" fill="#c51414" />
<rect
class="dual-crossing-box"
x="351"
y="398"
width="254"
height="118"
rx="8"
/>
<text x="478" y="431" text-anchor="middle" class="dual-crossing-year">
2024
</text>
<text x="478" y="465" text-anchor="middle" class="dual-crossing-copy">
Estadual
</text>
<text x="478" y="497" text-anchor="middle" class="dual-crossing-copy">
ultrapassa
</text>
<line
class="dual-end-leader"
x1="615"
y1="89"
x2="635"
y2="89"
stroke="#c51414"
stroke-width="3"
/>
<text x="642" y="54" class="dual-end-name">Estadual</text>
<text x="642" y="96" class="dual-end-value">23.294</text>
<text x="642" y="136" class="dual-end-valence" fill="#c51414">↑ amplia</text>
<text x="642" y="176" class="dual-end-change">+121% até 2025</text>
<line
class="dual-end-leader"
x1="615"
y1="233"
x2="635"
y2="233"
stroke="#78716c"
stroke-width="3"
/>
<text x="642" y="248" class="dual-end-name">Municipal</text>
<text x="642" y="290" class="dual-end-value">15.948</text>
<text x="642" y="330" class="dual-end-valence" fill="#78716c">↓ recua</text>
<text x="642" y="370" class="dual-end-change">−23% até 2025</text>
<g class="dual-axis-label" text-anchor="middle">
<text x="52" y="604">2017</text>
<text x="174" y="604">2019</text>
<text x="296" y="604">2021</text>
<text x="417" y="604">2023</text>
<text x="600" y="604">2026</text>
</g>
</svg>
</div>
<footer class="chart-footer">
<p class="source-line">
<strong>Fonte:</strong> Ministério da Saúde — SIH/SUS (AIH aprovadas por
ano)<br />
Nota: 2026 é projeção pela média de janeiro a junho × 2.
</p>
<div class="brand-lockup" aria-label="Marca provisória do mandato">
<span class="brand-signal"></span>
<div>
<div class="brand-name">JORGE SOLLA</div>
<div class="brand-office">MANDATO DEPUTADO FEDERAL</div>
</div>
</div>
</footer>
</div>
<span class="asset-flag">NEEDS ASSET · LOGO OFICIAL</span>
</article>
</div>
</div>
<aside class="grid gap-4">
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">VALÊNCIA SEM DEPENDER DA COR</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
Estadual (bom) usa o vermelho do mandato, círculo e “↑ amplia”. Municipal (ruim)
recua em cinza de eixo, quadrado e “↓ recua”: a cor do PT nunca carrega o caminho
ruim. Nome, valor, seta, palavra e forma mantêm a leitura redundante.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">PROJEÇÃO VISÍVEL, SEM ALARME</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
Só o segmento 2025→2026 é tracejado. Os dois marcadores finais são vazados, a faixa
é neutra e “projeção” aparece sobre 2026; o cálculo permanece na nota da fonte.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">O VAZIO VIROU EXPLICAÇÃO</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
O plot cresce de 430 para 620 px e ocupa a coluna útil até perto do rodapé. A área
abaixo das linhas não é cortada: mantém o zero real e recebe a marcação de 2024,
quando o estadual ultrapassa, em vez de permanecer como vazio morto.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">SEM TONS · COMPARAÇÃO NEUTRA</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
Quando bom/ruim não vier informado, a série A fica em tinta com círculos e a B em
cinza de eixo com quadrados. Os rótulos trazem apenas nome + valor: sem setas, sem
“amplia/recua”, variação ou cruzamento valorativo e sem vermelho dentro do plot; o
vermelho permanece só na régua e na marca do template.
</p>
</div>
</aside>
</div>
<div class="mt-7 border-t border-stone-200 pt-6" aria-labelledby="dual-rules-title">
<p class="text-xs font-bold text-brand-dark">REGRAS DA LINHA DE 2 SÉRIES</p>
<h3 id="dual-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 teto</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Exatamente 2 séries e os mesmos rótulos temporais, na mesma ordem. Teto:
<strong>12 pontos por série</strong> no feed, quadrado e story (24 marcas no total).
Barra/coluna continuam em 7 pontos no feed/quadrado e 5 no story. Contagem ou tempo
desalinhado → perguntar; nunca interpolar, completar, repetir nem remover ponto. O
domínio y é compartilhado e parte do zero real.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Bom × ruim · tons presentes</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Tons entram sempre em par. Bom =
<span class="rule-code">#c51414 + círculo + ↑ “amplia”</span>. Ruim =
<span class="rule-code">#78716c + quadrado + ↓ “recua”</span>. No caso: Estadual é
bom; Municipal é ruim. Vermelho é marca do mandato/PT e aponta o caminho desejável;
nunca significa perda. O ruim permanece neutro. Palavra, seta, forma e cor tornam a
leitura redundante, não decorativa.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Sem tons · comparação neutra</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Se nenhum tom vier informado, não inferir juízo: série A =
<span class="rule-code">#1c1917 + círculo</span>; série B =
<span class="rule-code">#78716c + quadrado</span>. Rótulos finais têm nome + valor e
podem trazer a variação observada com sinal matemático, sem palavra valorativa. Sem
setas ou “amplia/recua”; nenhum vermelho no plot — ele fica apenas na régua superior
e na marca do template.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Kicker, rótulos e colisão</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Kicker fixo: <strong>“Comparação no tempo”</strong>; não há legenda separada. Cada
linha termina em bloco nome + valor + glifo/palavra + variação observada, tudo ≥30
px. O gutter começa 42 px após o ponto e tem 238 px. Cada bloco usa 4 linhas: nome,
valor, valência e “±N% até AAAA” em uma linha. Leading interno ≥38 px; entre blocos,
≥40 px livres. Leaders de 3 px voltam ao ponto. Nunca esconder série. Nome quebra em
até 2 linhas; se não couber, pedir forma curta, nunca abreviar sozinho.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Projeção</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Só quando o último período for projetado: faixa neutra
<span class="rule-code">#a8a29e a 16%</span> do meio do penúltimo→último passo até
33 px depois do ponto final; apenas esse segmento usa tracejado (feed/story
<span class="rule-code">18 12</span>; quadrado
<span class="rule-code">15 11</span>). Os dois marcadores finais preservam a forma e
ficam vazados, com stroke 5 px. O rótulo projetado tem de ser o último ponto das
duas séries. “projeção” fica no topo direito da faixa, não junto ao eixo nem
misturada aos rótulos finais; a nota traz o método. Sem projeção: remover faixa,
tracejado, palavra e ressalva automática.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Eixo X · 10 pontos anuais</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Fonte 30 px, centralizada. Para 9–12 pontos, mostrar rótulos alternados, sempre o
primeiro e o último; omitir o penúltimo se colidir com o último. Neste caso, exibir
2017, 2019, 2021, 2023 e 2026 — o rótulo final substitui 2025. A palavra “projeção”
fica acima do período, nunca espremida no eixo.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Variação e cruzamento</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Se o último ponto é projeção, a variação compara o primeiro valor ao último
<em>observado</em>, nunca à projeção: aqui, <strong>+121% até 2025</strong> no
estadual e <strong>−23% até 2025</strong> no municipal. O cruzamento confirmado
recebe <span class="rule-code">dual-crossing-*</span>: anel no ponto estadual de
2024, guia tracejada até uma caixa de 3 linhas: “2024” / “Estadual” / “ultrapassa”.
A copy permanece em 28 px; nunca comprimir em uma linha nem deixá-la tocar a borda.
Sem confirmação textual do cruzamento, não gerar a anotação automaticamente.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Feed · 1080×1350</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Canvas padding 76/84/58 px; headline 60 px; plot
<span class="rule-code">880×620</span>, margin-top 28. Plot L52, R280, T56, B76;
dados x=52…600; baseline y=544; grid em y=56/300/544. Os 10 pontos ficam em
x=52/113/174/235/296/356/417/478/539/600; a largura útil cai de 598 para 548 px, com
passo anual de ~61 px, e o eixo alternado usa 52/174/296/417/600 (≥121 px entre
rótulos). Linhas 8 px; círculos r=8; quadrados 16×16; finais r=11 / 20×20; dash 18
12. Gutter x=642…880; nomes/valência/variação 30 px, valores 36 px. Baselines
finais: Estadual 54/96/136/176; Municipal 248/290/330/370. Cruzamento: guia x=478,
y=160…398; caixa x=351, y=398, 254×118, rx=8; textos em y=431/465/497.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Quadrado · 1080×1080</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Canvas padding 56/84/48 px; headline 54 px; encurtar subtítulo antes de reduzir
texto. Plot <span class="rule-code">880×440</span>, margin-top 20, L52, R280, T40,
B70; dados x=52…600; baseline y=370; grid em y=40/205/370. Linhas 7 px; círculos
r=7; quadrados 14×14; finais r=10 / 18×18; dash 15 11. Gutter x=642…880; textos 30
px, valores 32 px. Baselines finais: Estadual 32/70/108/146; Municipal
186/224/262/300. Cruzamento: guia x=478, y=115…252; caixa x=351, y=252, 254×108,
rx=8; textos em y=282/311/340.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Story · 1080×1920</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Faixas seguras de 250 px no topo e na base; conteúdo padding 306/84/310 px; headline
64 px. Plot <span class="rule-code">880×700</span>, margin-top 28, L52, R280, T64,
B78; dados x=52…600; baseline y=622; grid em y=64/343/622. Linhas 8 px; círculos
r=8; quadrados 16×16; finais r=11 / 20×20; dash 18 12. Gutter x=642…880; nomes,
valência e variação 32 px, valores 36 px. Baselines finais: Estadual 68/112/154/196;
Municipal 280/324/366/408. Plot, caixa, rótulos e “projeção” ficam integralmente
entre y=250 e y=1670 do canvas. Cruzamento: guia x=478, y=181…478; caixa x=351,
y=478, 254×118, rx=8; textos em y=511/545/577.
</p>
</div>
<div
class="rounded-xl border border-stone-200 bg-white p-4 md:col-span-2 xl:col-span-3"
>
<p class="text-sm font-bold">Mapa de classes do SVG</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Chassi: <span class="rule-code">dual-line-plot</span>,
<span class="rule-code">dual-axis-label</span>,
<span class="rule-code">dual-projection-band</span> e
<span class="rule-code">dual-projection-label</span>. Com tons:
<span class="rule-code">dual-series-good / dual-markers-good</span> e
<span class="rule-code">dual-series-bad / dual-markers-bad</span>. Sem tons, trocar
apenas os modificadores por
<span class="rule-code">dual-series-a / dual-markers-a</span> e
<span class="rule-code">dual-series-b / dual-markers-b</span>. Projeção acrescenta
<span class="rule-code">dual-series-projection</span> e
<span class="rule-code">dual-marker-projected-*</span>. Saída direta:
<span class="rule-code">dual-end-leader</span>,
<span class="rule-code">dual-end-name</span>,
<span class="rule-code">dual-end-value</span> e, só com tons,
<span class="rule-code">dual-end-valence</span>; variação observada em
<span class="rule-code">dual-end-change</span>. Cruzamento, quando confirmado:
<span class="rule-code">dual-crossing-guide</span>,
<span class="rule-code">dual-crossing-ring</span>,
<span class="rule-code">dual-crossing-box</span>,
<span class="rule-code">dual-crossing-year</span> e
<span class="rule-code">dual-crossing-copy</span>.
</p>
</div>
</div>
</div>
</section>
@ -1143,7 +1795,8 @@
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Densidade</p>
<p class="mt-1 text-sm leading-5 text-stone-600">
Até 7 pontos por gráfico. Acima disso: resumir com orientação explícita.
Barra, coluna e linha simples: até 7 pontos. Exceção aprovada: linha de 2 séries,
até 12 pontos alinhados por série; story mantém 5 apenas para barra/coluna.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
@ -1159,8 +1812,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>Design hi-fi · graficos-dados-instagram · dados e fontes fictícios</span>
<span>Paleta Solla · Inter · sem gradiente, 3D ou chrome de dashboard</span>
<span
>Design hi-fi certificado · graficos-dados-instagram · cinco variações integradas</span
>
<span>Paleta Solla · valência redundante · sem gradiente, 3D ou chrome de dashboard</span>
</footer>
</main>
</body>

View file

@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url'
import { launchPdfBrowser, screenshotHtmlPng } from './lib/buildPdf.mjs'
import {
MAX_POINTS,
MAX_POINTS_LINE,
MAX_POINTS_STORY,
SIZES,
classifyRelation,
@ -54,6 +55,29 @@ const slugify = (value) =>
const today = () => new Date().toISOString().slice(0, 10)
/**
* `--good`/`--bad` name the series that carry each tone of the approved
* two-series variant; the pair itself is validated by `validateSpec`.
*/
const applyTones = (series, flags, die) => {
const good = flags.good ? String(flags.good) : null
const bad = flags.bad ? String(flags.bad) : null
if (good && bad && good === bad) {
die(`--good e --bad apontam para a mesma série: ${JSON.stringify(good)}.`)
}
const known = series.map((serie) => serie.name)
for (const name of [good, bad].filter(Boolean)) {
if (!known.includes(name)) {
die(`série ${JSON.stringify(name)} não existe nos dados (nomes: ${known.join(', ')}).`)
}
}
return series.map((serie) => {
if (good && serie.name === good) return { ...serie, tone: 'good' }
if (bad && serie.name === bad) return { ...serie, tone: 'bad' }
return serie
})
}
const readInput = async (flags) => {
const path = flags.in === '-' || flags.in === true ? null : String(flags.in ?? '')
if (!path) {
@ -111,14 +135,27 @@ export const main = async ({
return
}
if (dataset.rows.length === 0 || dataset.issues.length > 0) {
const hasSeries = Array.isArray(dataset.series) && dataset.series.length > 0
if ((!hasSeries && dataset.rows.length === 0) || dataset.issues.length > 0) {
process.stdout.write(
`${JSON.stringify({ needsQuestion: true, issues: dataset.issues, rows: dataset.rows }, null, 2)}\n`,
`${JSON.stringify(
{
needsQuestion: true,
issues: dataset.issues,
rows: dataset.rows,
series: dataset.series ?? [],
},
null,
2,
)}\n`,
)
die('dado ambíguo ou faltando — pergunte à pessoa antes de gerar (nada é completado).')
}
const chartType = classifyRelation(dataset.rows, flags.type ? String(flags.type) : null)
const forcedType = flags.type ? String(flags.type) : null
const chartType = hasSeries
? (forcedType ?? 'line')
: classifyRelation(dataset.rows, forcedType)
spec = {
chartType,
size: flags.size ? String(flags.size) : 'feed',
@ -126,8 +163,13 @@ export const main = async ({
subtitle: flags.subtitle ? String(flags.subtitle) : '',
source: flags.source ? String(flags.source) : '',
note: flags.note ? String(flags.note) : '',
highlight: flags.highlight ? String(flags.highlight) : null,
rows: dataset.rows,
...(hasSeries
? {
...(flags.projected ? { projectedLabel: String(flags.projected) } : {}),
...(flags.crossing ? { crossingLabel: String(flags.crossing) } : {}),
series: applyTones(dataset.series, flags, die),
}
: { highlight: flags.highlight ? String(flags.highlight) : null, rows: dataset.rows }),
}
}
@ -147,10 +189,14 @@ export const main = async ({
const html = renderChartHtml(spec)
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
const canvas = SIZES[sizeKey]
const pointCap =
sizeKey === 'story' && (spec.chartType === 'bar' || spec.chartType === 'column')
const pointCap = spec.series?.length
? MAX_POINTS_LINE
: sizeKey === 'story' && (spec.chartType === 'bar' || spec.chartType === 'column')
? MAX_POINTS_STORY
: MAX_POINTS
const pointCount = spec.series?.length
? `${spec.series.length} séries × ${spec.series[0].rows.length}`
: `${spec.rows.length} ponto(s)`
const browser = await launchBrowser()
try {
await mkdir(dirname(outPath), { recursive: true })
@ -163,7 +209,7 @@ export const main = async ({
outPath,
})
console.log(
`[${LABEL}] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${spec.rows.length} ponto(s) ≤ ${pointCap}`,
`[${LABEL}] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${pointCount} ≤ ${pointCap}`,
)
} finally {
await browser.close()

View file

@ -23,6 +23,14 @@ export const MAX_POINTS = 7
*/
export const MAX_POINTS_STORY = 5
/**
* Two-series time comparison (approved variant): an annual series carries more
* points than a ranking, per series. One tone per series encodes good/bad.
*/
export const MAX_SERIES = 2
export const MAX_POINTS_LINE = 12
const SERIES_TONES = ['good', 'bad']
/** Output canvases: feed 4:5 (default), square, stories/reels 9:16 with safe bands. */
export const SIZES = {
feed: { width: 1080, height: 1350, label: '1080×1350', safeTop: 0, safeBottom: 0 },
@ -38,6 +46,9 @@ export const RELATION_LABEL = {
anchor: 'Número-âncora',
}
/** Kicker of the two-series time line (approved C191 variant). */
export const SERIES_RELATION_LABEL = 'Comparação no tempo'
const TEMPORAL_RE =
/^(\d{4}|\d{2}|[A-Za-zÀ-ÿ]{3,9}[./-]?\d{2,4}|\d{1,2}[./-]\d{2,4}|[TQ]\d|P\d+|\d{4}[./-]\d{1,2})$/u
@ -138,11 +149,51 @@ const cleanMatrix = (matrix) =>
.map((row) => row.map((cell) => String(cell ?? '').trim()))
.filter((row) => row.some(Boolean))
/**
* A table whose header names two measures over a shared first column is a time
* comparison: `series` instead of a single ranking. Only the header (words,
* never numbers) may open that door — a data row with a missing value never
* turns into a series in silence.
*/
const looksLikeSeriesHeader = (header) =>
header.length >= MAX_SERIES + 1 &&
header.every((cell) => cell !== '' && parseNumber(cell) === null)
const detectSeries = (cleaned) => {
const header = cleaned[0]
if (cleaned.length < 2 || !looksLikeSeriesHeader(header)) return null
const issues = []
const series = header.slice(1).map((name) => ({ name, rows: [] }))
const seen = new Set()
for (const row of cleaned.slice(1)) {
const label = row[0]
if (!label) {
issues.push(`linha sem rótulo: ${JSON.stringify(row.join(' | '))}`)
continue
}
if (seen.has(label)) issues.push(`rótulo repetido: "${label}"`)
seen.add(label)
series.forEach((serie, index) => {
const raw = row[index + 1]
const value = parseNumber(raw)
if (value === null) {
issues.push(`valor não numérico em "${serie.name} · ${label}": ${JSON.stringify(raw)}`)
return
}
serie.rows.push({ label, value })
})
}
return { series, issues }
}
const matrixToDataset = (matrix, format) => {
const cleaned = cleanMatrix(matrix)
const issues = []
if (cleaned.length === 0) return { rows: [], format, issues: ['nenhum dado encontrado'] }
const detected = detectSeries(cleaned)
if (detected) return { rows: [], series: detected.series, format, issues: detected.issues }
let start = 0
if (
cleaned.length > 1 &&
@ -284,12 +335,108 @@ export const classifyRelation = (rows, forcedType = null) => {
return 'bar'
}
/**
* Guardrails of the two-series time line (approved variant): aligned periods,
* at most `MAX_POINTS_LINE` per series, honest zero base, good/bad tones paired
* and the projection only on the last period.
*/
const validateSeries = (spec) => {
const { series } = spec
if (spec.chartType !== 'line') {
throw new Error('a comparação de duas séries exige o tipo linha (série de tempo).')
}
if (spec.highlight) {
throw new Error('a linha de duas séries marca bom/ruim pelos tons — não use --highlight.')
}
if (series.length !== MAX_SERIES) {
throw new Error(
`a linha multi-série desenha ${MAX_SERIES} séries (${series.length} recebidas).`,
)
}
const names = new Set()
for (const serie of series) {
if (typeof serie.name !== 'string' || serie.name.trim() === '')
throw new Error('série sem nome.')
if (names.has(serie.name)) throw new Error(`série repetida: "${serie.name}".`)
names.add(serie.name)
if (!Array.isArray(serie.rows) || serie.rows.length < 2) {
throw new Error(`série "${serie.name}" precisa de pelo menos 2 pontos.`)
}
if (serie.rows.length > MAX_POINTS_LINE) {
throw new Error(
`${serie.rows.length} pontos na série "${serie.name}" (> ${MAX_POINTS_LINE}): resuma os períodos antes de gerar o gráfico.`,
)
}
for (const row of serie.rows) {
if (!row.label) throw new Error(`ponto de dado sem rótulo na série "${serie.name}".`)
if (!Number.isFinite(row.value)) {
throw new Error(`valor ausente/inválido em "${serie.name} · ${row.label}".`)
}
if (row.value < 0) {
throw new Error(`valor negativo em "${serie.name} · ${row.label}" — a linha parte do zero.`)
}
}
}
const [first, second] = series
const aligned =
first.rows.length === second.rows.length &&
first.rows.every((row, index) => row.label === second.rows[index].label)
if (!aligned)
throw new Error('as séries precisam compartilhar os mesmos períodos, na mesma ordem.')
if (!first.rows.every((row) => isTemporalLabel(row.label))) {
throw new Error('a linha de duas séries exige períodos no eixo (ex.: anos) — sem categorias.')
}
const tones = series.map((serie) => serie.tone ?? null)
const informed = tones.filter(Boolean)
if (informed.length > 0 && informed.length !== series.length) {
throw new Error('informe o tom das duas séries (bom e ruim) ou de nenhuma.')
}
for (const tone of informed) {
if (!SERIES_TONES.includes(tone)) {
throw new Error(`tom inválido: ${JSON.stringify(tone)} (use ${SERIES_TONES.join(', ')}).`)
}
}
if (informed.length === series.length && tones[0] === tones[1]) {
throw new Error('as duas séries não podem compartilhar o mesmo tom (bom × ruim).')
}
if (spec.projectedLabel) {
const last = first.rows[first.rows.length - 1].label
if (last !== spec.projectedLabel) {
throw new Error(
`a projeção só é marcada no último período (recebido "${spec.projectedLabel}"; último é "${last}").`,
)
}
}
if (spec.crossingLabel) {
const good = series.find((serie) => serie.tone === 'good')
const bad = series.find((serie) => serie.tone === 'bad')
if (!good || !bad) {
throw new Error('a anotação de cruzamento exige os tons bom e ruim — informe --good e --bad.')
}
const labels = first.rows.map((row) => row.label)
const index = labels.indexOf(spec.crossingLabel)
if (index < 1) {
throw new Error(
`cruzamento "${spec.crossingLabel}" precisa ser um período depois do primeiro — confira o rótulo.`,
)
}
const overtakes =
good.rows[index].value > bad.rows[index].value &&
good.rows[index - 1].value <= bad.rows[index - 1].value
if (!overtakes) {
throw new Error(
`não há ultrapassagem confirmada em "${spec.crossingLabel}" — confira os valores antes de anotar.`,
)
}
}
}
/**
* Fail-closed guardrails of the approved template. Throws with the actionable
* reason instead of drawing a misleading chart.
*/
export const validateSpec = (spec) => {
const { chartType, rows, headline } = spec
const { chartType, rows, series, headline } = spec
if (spec.size && !SIZES[spec.size]) {
throw new Error(
`tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`,
@ -303,32 +450,37 @@ export const validateSpec = (spec) => {
}
throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`)
}
if (!Array.isArray(rows) || rows.length === 0)
throw new Error('sem pontos de dado para desenhar.')
if (rows.length > MAX_POINTS) {
throw new Error(
`${rows.length} pontos (> ${MAX_POINTS}): resuma as categorias antes de gerar o gráfico.`,
)
}
if (
spec.size === 'story' &&
(chartType === 'bar' || chartType === 'column') &&
rows.length > MAX_POINTS_STORY
) {
throw new Error(
`stories comporta até ${MAX_POINTS_STORY} pontos no ranking (${rows.length} recebidos): resuma para gerar a versão vertical.`,
)
}
if (chartType === 'anchor' && rows.length !== 1) {
throw new Error('número-âncora exige exatamente um valor.')
}
for (const row of rows) {
if (!row.label && chartType !== 'anchor') throw new Error('ponto de dado sem rótulo.')
if (!Number.isFinite(row.value)) throw new Error(`valor ausente/inválido em "${row.label}".`)
if (row.value < 0) throw new Error(`valor negativo em "${row.label}" — barras partem do zero.`)
}
if (spec.highlight && !rows.some((row) => row.label === spec.highlight)) {
throw new Error(`destaque "${spec.highlight}" não existe nos dados — confira o rótulo.`)
if (Array.isArray(series) && series.length > 0) {
validateSeries(spec)
} else {
if (!Array.isArray(rows) || rows.length === 0)
throw new Error('sem pontos de dado para desenhar.')
if (rows.length > MAX_POINTS) {
throw new Error(
`${rows.length} pontos (> ${MAX_POINTS}): resuma as categorias antes de gerar o gráfico.`,
)
}
if (
spec.size === 'story' &&
(chartType === 'bar' || chartType === 'column') &&
rows.length > MAX_POINTS_STORY
) {
throw new Error(
`stories comporta até ${MAX_POINTS_STORY} pontos no ranking (${rows.length} recebidos): resuma para gerar a versão vertical.`,
)
}
if (chartType === 'anchor' && rows.length !== 1) {
throw new Error('número-âncora exige exatamente um valor.')
}
for (const row of rows) {
if (!row.label && chartType !== 'anchor') throw new Error('ponto de dado sem rótulo.')
if (!Number.isFinite(row.value)) throw new Error(`valor ausente/inválido em "${row.label}".`)
if (row.value < 0)
throw new Error(`valor negativo em "${row.label}" — barras partem do zero.`)
}
if (spec.highlight && !rows.some((row) => row.label === spec.highlight)) {
throw new Error(`destaque "${spec.highlight}" não existe nos dados — confira o rótulo.`)
}
}
if (typeof headline !== 'string' || headline.trim() === '') {
throw new Error('título-manchete ausente — a manchete é obrigatória.')

View file

@ -136,6 +136,361 @@ export const stackedColumnChart = ({
</svg>`
}
/**
* Geometry of the approved two-series time line (C191 variant), per export
* size. The data box is the same on every canvas; only the vertical rhythm and
* the marker sizes change (design: "Feed / Quadrado / Story" rules).
*/
const DUAL_GEOMETRY = {
feed: {
width: 880,
height: 620,
left: 52,
right: 600,
top: 56,
baseline: 544,
stroke: 8,
dotRadius: 8,
squareSize: 16,
projectedDotRadius: 11,
projectedSquareSize: 20,
dash: '18 12',
nameSize: 30,
valueSize: 36,
valenceSize: 30,
changeSize: 30,
axisSize: 30,
blockOffsets: [-35, 7, 47, 87],
blockGap: 72,
crossing: {
boxWidth: 254,
boxHeight: 118,
boxY: 398,
yearOffset: 33,
copyOffset: 67,
copyOffset2: 99,
},
},
square: {
width: 880,
height: 440,
left: 52,
right: 600,
top: 40,
baseline: 370,
stroke: 7,
dotRadius: 7,
squareSize: 14,
projectedDotRadius: 10,
projectedSquareSize: 18,
dash: '15 11',
nameSize: 30,
valueSize: 32,
valenceSize: 30,
changeSize: 30,
axisSize: 30,
blockOffsets: [-31, 7, 45, 83],
blockGap: 40,
crossing: {
boxWidth: 254,
boxHeight: 108,
boxY: 252,
yearOffset: 30,
copyOffset: 59,
copyOffset2: 88,
},
},
story: {
width: 880,
height: 700,
left: 52,
right: 600,
top: 64,
baseline: 622,
stroke: 8,
dotRadius: 8,
squareSize: 16,
projectedDotRadius: 11,
projectedSquareSize: 20,
dash: '18 12',
nameSize: 32,
valueSize: 36,
valenceSize: 32,
changeSize: 32,
axisSize: 30,
blockOffsets: [-34, 10, 52, 94],
blockGap: 84,
crossing: {
boxWidth: 254,
boxHeight: 118,
boxY: 478,
yearOffset: 33,
copyOffset: 67,
copyOffset2: 99,
},
},
}
/** Rounds the shared maximum up to the next legible step (never down). */
const niceMax = (value) => {
if (!(value > 0)) return 1
const magnitude = 10 ** Math.floor(Math.log10(value))
for (const step of [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) {
const candidate = step * magnitude
if (candidate >= value) return candidate
}
return 10 * magnitude
}
/**
* 9–12 annual points alternate their axis labels, always keeping the first and
* the last; the penultimate is dropped when it would collide with the last
* (design: "2017, 2019, 2021, 2023 e 2026").
*/
const dualAxisIndices = (count) => {
if (count < 9) return [...Array(count).keys()]
const shown = new Set()
for (let index = 0; index < count; index += 2) shown.add(index)
const last = count - 1
if (!shown.has(last)) {
shown.delete(last - 1)
shown.add(last)
}
return [...shown].sort((a, b) => a - b)
}
/** Keeps the crossing box inside the data box while staying centered on the point. */
const crossingCenter = (x, cfg) =>
Math.min(Math.max(x, cfg.left + cfg.crossing.boxWidth / 2), cfg.right - cfg.crossing.boxWidth / 2)
/**
* Two-series time line (Instagram, C191 approved variant): shared zero-based
* scale, direct end labels and valence carried by word + arrow + shape + color.
* `series` items are `{ name, tone?: 'good'|'bad', rows: [{ label, value }] }`;
* tones come in pairs, or not at all (neutral comparison, no red in the plot).
*/
export const dualLineChart = ({
series,
size = 'feed',
projected = false,
crossingLabel = /** @type {string | null} */ (null),
colors,
valence,
format,
}) => {
const cfg = DUAL_GEOMETRY[size] ?? DUAL_GEOMETRY.feed
const count = series[0].rows.length
const max = niceMax(Math.max(...series.flatMap((serie) => serie.rows.map((row) => row.value))))
const step = count > 1 ? (cfg.right - cfg.left) / (count - 1) : 0
const xAt = (index) => Math.round(cfg.left + index * step)
const yAt = (value) => cfg.baseline - Math.round((value / max) * (cfg.baseline - cfg.top))
const pointsOf = (serie) => serie.rows.map((row, index) => ({ x: xAt(index), y: yAt(row.value) }))
const styles = series.map((serie, index) => {
if (serie.tone === 'good') {
return { color: colors.good, shape: 'circle', valence: valence.good, tone: 'good' }
}
if (serie.tone === 'bad') {
return { color: colors.bad, shape: 'square', valence: valence.bad, tone: 'bad' }
}
return index === 0
? { color: colors.neutralA, shape: 'circle', valence: null, tone: 'a' }
: { color: colors.neutralB, shape: 'square', valence: null, tone: 'b' }
})
const marker = ({ x, y, shape, size: markerSize, fill, className = '' }) =>
shape === 'square'
? `<rect x="${x - markerSize / 2}" y="${y - markerSize / 2}" width="${markerSize}" height="${markerSize}" fill="${fill}"${className ? ` class="${className}"` : ''} />`
: `<circle cx="${x}" cy="${y}" r="${markerSize / 2}" fill="${fill}"${className ? ` class="${className}"` : ''} />`
const band = projected
? (() => {
const before = xAt(count - 2)
const last = xAt(count - 1)
const start = Math.round((before + last) / 2)
const end = last + 33
return `<rect class="dual-projection-band" x="${start}" y="0" width="${end - start}" height="${cfg.baseline}" fill="${colors.grid}" fill-opacity="0.16" />`
})()
: ''
const gridLines = `<line class="dual-grid-line" x1="${cfg.left}" y1="${cfg.top}" x2="${cfg.right}" y2="${cfg.top}" stroke="${colors.grid}" stroke-opacity="0.32" stroke-width="2" />
<line class="dual-grid-line" x1="${cfg.left}" y1="${Math.round((cfg.top + cfg.baseline) / 2)}" x2="${cfg.right}" y2="${Math.round((cfg.top + cfg.baseline) / 2)}" stroke="${colors.grid}" stroke-opacity="0.32" stroke-width="2" />
<line class="dual-zero-line" x1="${cfg.left}" y1="${cfg.baseline}" x2="${cfg.right}" y2="${cfg.baseline}" stroke="${colors.grid}" stroke-width="2" />
<text x="${cfg.left - 10}" y="${cfg.baseline - 5}" text-anchor="end" class="dual-axis-label" font-size="${cfg.axisSize}">0</text>`
const projectionLabel = projected
? `<text x="${cfg.right + 24}" y="${cfg.top - 11}" text-anchor="end" class="dual-projection-label" font-size="${cfg.axisSize}">projeção</text>`
: ''
const lines = series
.map((serie, index) => {
const points = pointsOf(serie)
const style = styles[index]
const solid = projected ? points.slice(0, -1) : points
const polyline = `<polyline class="dual-series dual-series-${style.tone}" points="${solid.map((point) => `${point.x},${point.y}`).join(' ')}" fill="none" stroke="${style.color}" stroke-width="${cfg.stroke}" stroke-linecap="round" stroke-linejoin="round" />`
const dashed = projected
? `<line class="dual-series-projection dual-series-${style.tone}" x1="${points[count - 2].x}" y1="${points[count - 2].y}" x2="${points[count - 1].x}" y2="${points[count - 1].y}" stroke="${style.color}" stroke-width="${cfg.stroke}" stroke-linecap="round" stroke-dasharray="${cfg.dash}" />`
: ''
const regular = projected ? points.slice(0, -1) : points
const markers = `<g class="dual-markers-${style.tone}" fill="${style.color}">${regular
.map((point) =>
marker({
x: point.x,
y: point.y,
shape: style.shape,
size: style.shape === 'square' ? cfg.squareSize : cfg.dotRadius * 2,
fill: style.color,
}),
)
.join('')}</g>`
return `${polyline}${dashed}${markers}`
})
.join('')
const projectedMarkers = projected
? series
.map((serie, index) => {
const point = pointsOf(serie)[count - 1]
const style = styles[index]
return marker({
x: point.x,
y: point.y,
shape: style.shape,
size: style.shape === 'square' ? cfg.projectedSquareSize : cfg.projectedDotRadius * 2,
fill: colors.paper,
className: `dual-marker-projected dual-marker-projected-${style.tone}`,
}).replace('/>', ` stroke="${style.color}" stroke-width="5" />`)
})
.join('')
: ''
const observedIndex = projected ? count - 2 : count - 1
const changeOf = (serie) => {
const firstValue = serie.rows[0].value
const observed = serie.rows[observedIndex]?.value
if (!(firstValue > 0) || !Number.isFinite(observed)) return null
const percent = Math.round(((observed - firstValue) / firstValue) * 100)
const sign = percent > 0 ? '+' : percent < 0 ? '−' : ''
const until = `até ${serie.rows[observedIndex].label}`
return {
percent: `${sign}${Math.abs(percent)}%`,
until,
label: `${sign}${Math.abs(percent)}% ${until}`,
}
}
const blocks = series
.map((serie, index) => {
const point = pointsOf(serie)[count - 1]
const offsets = styles[index].valence ? cfg.blockOffsets : cfg.blockOffsets.slice(0, 3)
return {
name: serie.name,
style: styles[index],
point,
baselines: offsets.map((offset) => point.y + offset),
value: format(serie.rows[count - 1]),
change: changeOf(serie),
}
})
.sort((a, b) => a.point.y - b.point.y)
let previousBottom = null
for (const block of blocks) {
if (previousBottom !== null) {
const minName = previousBottom + cfg.blockGap
const delta = minName - block.baselines[0]
if (delta > 0) block.baselines = block.baselines.map((baseline) => baseline + delta)
}
previousBottom = block.baselines[block.baselines.length - 1]
}
const clampOverflow = blocks[blocks.length - 1]?.baselines.at(-1) - (cfg.height - 12)
const endBlocks = blocks
.map((block) => {
const baselineOffset = clampOverflow > 0 ? -clampOverflow : 0
const text = (className, size, fill, value, index) =>
`<text x="${cfg.right + 42}" y="${block.baselines[index] + baselineOffset}" class="${className}" font-size="${size}"${fill ? ` fill="${fill}"` : ''}>${htmlEscape(value)}</text>`
const parts = [
`<line class="dual-end-leader" x1="${cfg.right + 15}" y1="${block.point.y}" x2="${cfg.right + 29}" y2="${block.point.y}" stroke="${block.style.color}" stroke-width="3" />`,
text('dual-end-name', cfg.nameSize, colors.ink, block.name, 0),
text('dual-end-value', cfg.valueSize, colors.ink, block.value, 1),
]
if (block.style.valence) {
parts.push(
text('dual-end-valence', cfg.valenceSize, block.style.color, block.style.valence, 2),
)
}
if (block.change) {
parts.push(
text(
'dual-end-change',
cfg.changeSize,
colors.change,
block.change.label,
block.style.valence ? 3 : 2,
),
)
}
return parts.join('')
})
.join('')
const goodSeries = series.find((serie) => serie.tone === 'good')
const crossingIndex = crossingLabel
? series[0].rows.findIndex((row) => row.label === crossingLabel)
: -1
const showCrossing = Boolean(goodSeries) && crossingIndex >= 1
const crossingGuide = showCrossing
? (() => {
const point = pointsOf(goodSeries)[crossingIndex]
const center = crossingCenter(point.x, cfg)
return `<line class="dual-crossing-guide" x1="${center}" y1="${point.y + 16}" x2="${center}" y2="${cfg.crossing.boxY}" stroke="${colors.good}" stroke-width="2" stroke-dasharray="8 8" opacity="0.45" />`
})()
: ''
const crossingAnnotation = showCrossing
? (() => {
const point = pointsOf(goodSeries)[crossingIndex]
const box = cfg.crossing
const center = crossingCenter(point.x, cfg)
return `<circle class="dual-crossing-ring" cx="${point.x}" cy="${point.y}" r="14" fill="${colors.paper}" stroke="${colors.good}" stroke-width="4" />
<circle cx="${point.x}" cy="${point.y}" r="7" fill="${colors.good}" />
<rect class="dual-crossing-box" x="${Math.round(center - box.boxWidth / 2)}" y="${box.boxY}" width="${box.boxWidth}" height="${box.boxHeight}" rx="8" fill="${colors.paper}" stroke="${colors.grid}" stroke-width="2" />
<text x="${center}" y="${box.boxY + box.yearOffset}" text-anchor="middle" class="dual-crossing-year" font-size="30" font-weight="850" fill="${colors.good}">${htmlEscape(crossingLabel)}</text>
<text x="${center}" y="${box.boxY + box.copyOffset}" text-anchor="middle" class="dual-crossing-copy" font-size="28" font-weight="750" fill="${colors.ink}">${htmlEscape(goodSeries.name)}</text>
<text x="${center}" y="${box.boxY + box.copyOffset2}" text-anchor="middle" class="dual-crossing-copy" font-size="28" font-weight="750" fill="${colors.ink}">ultrapassa</text>`
})()
: ''
const axis = dualAxisIndices(count)
.map(
(index) =>
`<text x="${xAt(index)}" y="${cfg.height - 14}" text-anchor="middle" class="dual-axis-label" font-size="${cfg.axisSize}">${htmlEscape(series[0].rows[index].label)}</text>`,
)
.join('')
const ariaLabel = `${series
.map((serie) => {
const change = changeOf(serie)
const destination = projected
? ` para projeção de ${format(serie.rows[count - 1])} em ${serie.rows[count - 1].label}`
: ` para ${format(serie.rows[count - 1])} em ${serie.rows[count - 1].label}`
const trend = change
? `, variação observada de ${change.percent} até ${serie.rows[observedIndex].label}`
: ''
return `${serie.name}: de ${format(serie.rows[0])} em ${serie.rows[0].label}${destination}${trend}`
})
.join('. ')}${showCrossing ? `. ${goodSeries.name} ultrapassa em ${crossingLabel}` : ''}`
return `<svg class="chart dual-chart" viewBox="0 0 ${cfg.width} ${cfg.height}" role="img" aria-label="${htmlEscape(ariaLabel)}" preserveAspectRatio="xMidYMid meet">
${band}
${gridLines}
${projectionLabel}
${crossingGuide}
${lines}
${projectedMarkers}
${crossingAnnotation}
${endBlocks}
${axis}
</svg>`
}
/**
* Time-series line (Instagram): every point a hollow marker, the last one the
* single red highlight with its value. Honest scale — the baseline is zero.

View file

@ -9,8 +9,8 @@
* only sets the viewport and clips the canvas.
*/
import { RELATION_LABEL, SIZES } from './chartData.mjs'
import { lineChart, proportionalPercent } from './chartPrimitives.mjs'
import { RELATION_LABEL, SERIES_RELATION_LABEL, SIZES } from './chartData.mjs'
import { dualLineChart, lineChart, proportionalPercent } from './chartPrimitives.mjs'
import { htmlEscape } from './reportText.mjs'
/** Solla palette (verbatim from the intention). */
@ -85,7 +85,39 @@ const anchorBody = (spec) => {
</div>`
}
/**
* Two-series time line (approved C191 variant): the valence travels by word +
* arrow + marker shape + color, never by color alone. Red belongs to the
* mandate (the good path); the falling series stays neutral.
*/
const DUAL_COLORS = {
good: SOLLA_PALETTE.highlight,
bad: SOLLA_PALETTE.axis,
neutralA: SOLLA_PALETTE.ink,
neutralB: SOLLA_PALETTE.axis,
grid: SOLLA_PALETTE.barStrong,
paper: SOLLA_PALETTE.paper,
ink: SOLLA_PALETTE.ink,
change: SOLLA_PALETTE.label,
}
const DUAL_VALENCE = { good: '↑ amplia', bad: '↓ recua' }
const dualBody = (spec) => {
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
return `<div class="dual-line-plot">${dualLineChart({
series: spec.series,
size: sizeKey,
projected: Boolean(spec.projectedLabel),
crossingLabel: spec.crossingLabel ?? null,
colors: DUAL_COLORS,
valence: DUAL_VALENCE,
format: (row) => formatValue(row.value),
})}</div>`
}
const plotBody = (spec) => {
if (Array.isArray(spec.series) && spec.series.length > 0) return dualBody(spec)
if (spec.chartType === 'anchor') return anchorBody(spec)
if (spec.chartType === 'column') return columnBody(spec)
if (spec.chartType === 'line') {
@ -152,6 +184,22 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; }
.line-plot { height: 100%; display: flex; align-items: center; }
.line-plot svg { width: 100%; height: auto; }
.dual-line-plot { flex: none; }
.dual-line-plot svg { display: block; width: 880px; height: auto; }
.dual-projection-band { fill: ${SOLLA_PALETTE.barStrong}; fill-opacity: 0.16; }
.dual-grid-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-opacity: 0.32; stroke-width: 2; }
.dual-zero-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; }
.dual-axis-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 650; }
.dual-projection-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 700; }
.dual-end-name { fill: ${SOLLA_PALETTE.ink}; font-weight: 700; }
.dual-end-value { fill: ${SOLLA_PALETTE.ink}; font-weight: 850; font-variant-numeric: tabular-nums; }
.dual-end-valence { font-weight: 850; }
.dual-end-change { fill: ${SOLLA_PALETTE.label}; font-weight: 700; }
.dual-crossing-guide { stroke: ${SOLLA_PALETTE.highlight}; stroke-width: 2; stroke-dasharray: 8 8; opacity: 0.45; }
.dual-crossing-ring { fill: ${SOLLA_PALETTE.paper}; stroke: ${SOLLA_PALETTE.highlight}; stroke-width: 4; }
.dual-crossing-box { fill: ${SOLLA_PALETTE.paper}; stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; }
.dual-crossing-year { fill: ${SOLLA_PALETTE.highlight}; font-weight: 850; }
.dual-crossing-copy { fill: ${SOLLA_PALETTE.ink}; font-weight: 750; }
.anchor { padding-top: 20px; }
.anchor-number { color: ${SOLLA_PALETTE.highlight}; font-size: 300px; line-height: 0.86; letter-spacing: -0.065em; font-weight: 900; font-variant-numeric: tabular-nums; }
.anchor-copy { max-width: 760px; margin: 40px 0 0; font-size: 52px; line-height: 1.12; font-weight: 750; }
@ -208,7 +256,15 @@ export const renderChartHtml = (spec) => {
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
const canvas = SIZES[sizeKey]
const layout = layoutFor(sizeKey)
const kicker = RELATION_LABEL[spec.chartType] ?? 'Gráfico'
const dual = Array.isArray(spec.series) && spec.series.length > 0
const dualStyles = !dual
? ''
: sizeKey === 'feed'
? '.headline { font-size: 60px; max-width: 900px; } .subtitle { font-size: 28px; } .source { font-size: 25px; } .plot { margin-top: 28px; }'
: sizeKey === 'square'
? '.inner { padding: 56px 84px 48px; } .headline { font-size: 54px; } .plot { margin-top: 20px; }'
: '.headline { font-size: 64px; } .plot { margin-top: 28px; }'
const 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>
@ -224,10 +280,11 @@ export const renderChartHtml = (spec) => {
.headline { font-size: ${layout.headline}px; max-width: ${canvas.width - 200}px; }
.subtitle { margin-top: ${Math.round(layout.gapAfterContext * 1.28)}px; font-size: ${layout.subtitle}px; max-width: ${canvas.width - 260}px; }
.plot { margin-top: ${layout.plotGap}px; }
${dualStyles}
</style>
</head>
<body>
<article class="canvas" role="img" aria-label="${htmlEscape(spec.headline)}">
<article class="canvas${dual ? ' dual-series' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<div class="inner">
<div class="top-rule"></div>
<p class="context">${htmlEscape(kicker)}</p>

View file

@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'
import {
CHART_TYPES,
MAX_POINTS,
MAX_POINTS_LINE,
MAX_POINTS_STORY,
MAX_SERIES,
RELATION_LABEL,
SIZES,
classifyRelation,
@ -218,6 +220,174 @@ describe('validateSpec — fail-closed guardrails', () => {
})
})
describe('parseInput — two aligned measures become series, not a ranking', () => {
it('reads a time table with two measures as two series', () => {
const dataset = parseInput({
text: 'Ano,Estadual,Municipal\n2017,9807,24002\n2018,10636,24438\n',
format: 'csv',
})
expect(dataset.rows).toEqual([])
expect(dataset.series).toEqual([
{
name: 'Estadual',
rows: [
{ label: '2017', value: 9807 },
{ label: '2018', value: 10636 },
],
},
{
name: 'Municipal',
rows: [
{ label: '2017', value: 24002 },
{ label: '2018', value: 24438 },
],
},
])
expect(dataset.issues).toEqual([])
})
it('keeps a two-column table on the single-series path', () => {
const dataset = parseInput({ text: 'cidade,votos\nIlhéus,84\nItabuna,68\n', format: 'csv' })
expect(dataset.series).toBeUndefined()
expect(dataset.rows).toHaveLength(2)
})
it('flags a non-numeric cell of a series table instead of dropping the column', () => {
const dataset = parseInput({
text: 'Ano,Estadual,Municipal\n2017,9807,???\n2018,10636,24438\n',
format: 'csv',
})
expect(dataset.issues.join(' ')).toContain('Municipal · 2017')
})
})
describe('validateSpec — the two-series time line', () => {
const seriesSpec = (overrides = {}) => ({
chartType: 'line',
headline: 'Internações crescem no hospital estadual e recuam no municipal',
series: [
{
name: 'Estadual',
tone: 'good',
rows: [
{ label: '2017', value: 9807 },
{ label: '2026', value: 23294 },
],
},
{
name: 'Municipal',
tone: 'bad',
rows: [
{ label: '2017', value: 24002 },
{ label: '2026', value: 15948 },
],
},
],
...overrides,
})
it('accepts aligned temporal series with paired tones', () => {
expect(validateSpec(seriesSpec())).toBeTruthy()
})
it('refuses a multi-series spec that is not a line', () => {
expect(() => validateSpec(seriesSpec({ chartType: 'bar' }))).toThrow(/linha/)
})
it(`refuses more than ${MAX_SERIES} series`, () => {
const [first, second] = seriesSpec().series
expect(() =>
validateSpec(seriesSpec({ series: [first, second, { ...first, name: 'Total' }] })),
).toThrow(/séries/)
})
it('refuses misaligned periods between the series', () => {
const [first, second] = seriesSpec().series
const drifted = {
...second,
rows: [
{ label: '2018', value: 1 },
{ label: '2026', value: 2 },
],
}
expect(() => validateSpec(seriesSpec({ series: [first, drifted] }))).toThrow(/mesmos períodos/)
})
it('refuses categorical labels on the time axis', () => {
const categorical = (name: string, tone: string) => ({
name,
tone,
rows: [
{ label: 'Ilhéus', value: 1 },
{ label: 'Itabuna', value: 2 },
],
})
expect(() =>
validateSpec(
seriesSpec({ series: [categorical('Estadual', 'good'), categorical('Municipal', 'bad')] }),
),
).toThrow(/períodos/)
})
it('refuses a one-sided, repeated or unknown tone', () => {
const [first, second] = seriesSpec().series
expect(() =>
validateSpec(seriesSpec({ series: [{ ...first, tone: undefined }, second] })),
).toThrow(/tom/)
expect(() => validateSpec(seriesSpec({ series: [{ ...first, tone: 'bad' }, second] }))).toThrow(
/mesmo tom/,
)
expect(() =>
validateSpec(seriesSpec({ series: [{ ...first, tone: 'ótimo' }, second] })),
).toThrow(/tom inválido/)
})
it(`caps each series at ${MAX_POINTS_LINE} points`, () => {
const [first, second] = seriesSpec().series
const rows = Array.from({ length: MAX_POINTS_LINE + 1 }, (_v, index) => ({
label: `${2000 + index}`,
value: index + 1,
}))
expect(() => validateSpec(seriesSpec({ series: [{ ...first, rows }, second] }))).toThrow(
/resuma/,
)
expect(MAX_POINTS_LINE).toBeGreaterThan(MAX_POINTS)
})
it('marks the projection only on the last period', () => {
expect(validateSpec(seriesSpec({ projectedLabel: '2026' }))).toBeTruthy()
expect(() => validateSpec(seriesSpec({ projectedLabel: '2017' }))).toThrow(/último período/)
})
it('refuses --highlight on a two-series line (the tones carry the meaning)', () => {
expect(() => validateSpec(seriesSpec({ highlight: 'Estadual' }))).toThrow(/highlight/)
})
it('accepts a confirmed crossing and refuses an unconfirmed one', () => {
expect(validateSpec(seriesSpec({ crossingLabel: '2026' }))).toBeTruthy()
expect(() => validateSpec(seriesSpec({ crossingLabel: '2017' }))).toThrow(/primeiro/)
const [first, second] = seriesSpec().series
const alreadyAhead = {
...second,
rows: [
{ label: '2017', value: 1 },
{ label: '2026', value: 2 },
],
}
expect(() =>
validateSpec(seriesSpec({ crossingLabel: '2026', series: [first, alreadyAhead] })),
).toThrow(/ultrapassagem/)
})
it('refuses a crossing annotation without the tone pair', () => {
const { series } = seriesSpec()
const neutral = series.map(({ name, rows }) => ({ name, rows }))
expect(() => validateSpec(seriesSpec({ crossingLabel: '2026', series: neutral }))).toThrow(
/tons/,
)
})
})
describe('contracts', () => {
it('declares the four v1 types, labels and sizes', () => {
expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor'])

View file

@ -4,6 +4,7 @@ import {
CHART_COLORS,
barChart,
columnChart,
dualLineChart,
lineChart,
proportionalPercent,
stackedColumnChart,
@ -140,3 +141,156 @@ describe('lineChart — series with one highlighted end', () => {
expect(svg).toContain('fill="#c51414"')
})
})
describe('dualLineChart — the approved two-series time line', () => {
const annual = (values: number[]) =>
values.map((value, index) => ({ label: `${2017 + index}`, value }))
const series = [
{
name: 'Estadual',
tone: 'good',
rows: annual([9807, 10636, 11807, 10929, 13119, 14767, 14501, 20495, 21637, 23294]),
},
{
name: 'Municipal',
tone: 'bad',
rows: annual([24002, 24438, 22269, 20305, 21562, 20608, 19983, 19726, 18588, 15948]),
},
]
const colors = {
good: '#c51414',
bad: '#78716c',
neutralA: '#1c1917',
neutralB: '#78716c',
grid: '#a8a29e',
paper: '#faf9f7',
ink: '#1c1917',
change: '#57534e',
}
const valence = { good: '↑ amplia', bad: '↓ recua' }
const format = (row: { value: number }) =>
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value)
const svg = dualLineChart({
series,
size: 'feed',
projected: true,
crossingLabel: '2024',
colors,
valence,
format,
})
it('draws the good path in the mandate red and the bad one neutral', () => {
expect(svg).toContain('class="dual-series dual-series-good"')
expect(svg).toContain('stroke="#c51414"')
expect(svg).toContain('class="dual-series dual-series-bad"')
expect(svg).toContain('stroke="#78716c"')
expect(svg).toContain('<g class="dual-markers-good" fill="#c51414"><circle')
expect(svg).toContain('<g class="dual-markers-bad" fill="#78716c"><rect')
})
it('labels each line with name, value, valence word and observed change', () => {
expect(svg).toContain('>Estadual<')
expect(svg).toContain('>23.294<')
expect(svg).toContain('>↑ amplia<')
expect(svg).toContain('>+121% até 2025<')
expect(svg).toContain('>Municipal<')
expect(svg).toContain('>15.948<')
expect(svg).toContain('>↓ recua<')
expect(svg).toContain('>−23% até 2025<')
})
it('marks only the last segment as a projection', () => {
expect(svg.match(/stroke-dasharray="18 12"/g)).toHaveLength(2)
expect(svg).toContain('class="dual-projection-band"')
expect(svg).toContain('>projeção<')
expect(svg).toContain('class="dual-marker-projected dual-marker-projected-good"')
expect(svg).toContain('class="dual-marker-projected dual-marker-projected-bad"')
})
it('annotates the confirmed crossing with ring, guide and box', () => {
expect(svg).toContain('class="dual-crossing-guide"')
expect(svg).toContain('class="dual-crossing-ring"')
expect(svg).toContain('class="dual-crossing-box"')
expect(svg).toContain('class="dual-crossing-year"')
expect(svg).toContain('>2024<')
expect(svg).toContain('class="dual-crossing-copy"')
expect(svg).toContain('>ultrapassa<')
})
it('keeps the crossing out when it was not confirmed', () => {
const withoutCrossing = dualLineChart({
series,
size: 'feed',
projected: true,
colors,
valence,
format,
})
expect(withoutCrossing).not.toContain('dual-crossing')
expect(withoutCrossing).not.toContain('ultrapassa')
})
it('keeps the honest zero base and the shared legible ceiling', () => {
expect(svg).toContain('viewBox="0 0 880 620"')
expect(svg).toContain('y1="544"')
expect(svg).toContain('>0<')
expect(svg.match(/class="dual-grid-line"/g)).toHaveLength(2)
})
it('alternates the ten annual axis labels, always keeping first and last', () => {
for (const label of ['2017', '2019', '2021', '2023', '2026']) {
expect(svg).toContain(`>${label}<`)
}
for (const label of ['2018', '2020', '2022', '2025']) {
expect(svg).not.toContain(`>${label}<`)
}
})
it('adapts the geometry to square and to Stories', () => {
const square = dualLineChart({
series,
size: 'square',
projected: true,
colors,
valence,
format,
})
expect(square).toContain('viewBox="0 0 880 440"')
expect(square).toContain('stroke-dasharray="15 11"')
expect(square).toContain('font-size="32"')
const story = dualLineChart({ series, size: 'story', projected: true, colors, valence, format })
expect(story).toContain('viewBox="0 0 880 700"')
expect(story).toContain('stroke-dasharray="18 12"')
expect(story).toContain('font-size="36"')
})
it('compares neutrally without valence or red when no tone is informed', () => {
const neutral = dualLineChart({
series: series.map(({ name, rows }) => ({ name, rows })),
size: 'feed',
projected: false,
colors,
valence,
format,
})
expect(neutral).toContain('class="dual-series dual-series-a"')
expect(neutral).toContain('class="dual-series dual-series-b"')
expect(neutral).not.toContain('#c51414')
expect(neutral).not.toContain('amplia')
expect(neutral).not.toContain('recua')
expect(neutral).not.toContain('dual-projection-band')
})
it('escapes untrusted series names', () => {
const evil = dualLineChart({
series: [{ ...series[0], name: '<b>x</b>' }, series[1]],
size: 'feed',
projected: false,
colors,
valence,
format,
})
expect(evil).toContain('&lt;b&gt;x&lt;/b&gt;')
})
})

View file

@ -106,3 +106,87 @@ describe('SOLLA_PALETTE — the verbatim brand colors', () => {
})
})
})
// C191 approved variant: the two-series time line carries valence by word +
// arrow + marker shape + color, on a shared zero-based scale.
const annual = (values: number[]) =>
values.map((value, index) => ({ label: `${2017 + index}`, value }))
const seriesSpec = (overrides = {}) => ({
chartType: 'line',
size: 'feed',
headline: 'Internações crescem no hospital estadual e recuam no municipal',
subtitle: 'AIH aprovadas por ano · Vitória da Conquista (BA) · 2017–2026',
source: 'Ministério da Saúde — SIH/SUS',
note: '2026 é projeção pela média de janeiro a junho × 2.',
projectedLabel: '2026',
crossingLabel: '2024',
series: [
{
name: 'Estadual',
tone: 'good',
rows: annual([9807, 10636, 11807, 10929, 13119, 14767, 14501, 20495, 21637, 23294]),
},
{
name: 'Municipal',
tone: 'bad',
rows: annual([24002, 24438, 22269, 20305, 21562, 20608, 19983, 19726, 18588, 15948]),
},
],
...overrides,
})
describe('renderChartHtml — the two-series time line', () => {
it('renders the dual plot with the fixed kicker, changes and valence words', () => {
const html = renderChartHtml(seriesSpec())
expect(html).toContain('dual-line-plot')
expect(html).toContain('Comparação no tempo')
expect(html).toContain('↑ amplia')
expect(html).toContain('↓ recua')
expect(html).toContain('+121%')
expect(html).toContain('−23%')
expect(html).toContain('23.294')
})
it('annotates the confirmed crossing without inferring it by itself', () => {
expect(renderChartHtml(seriesSpec())).toContain('ultrapassa')
expect(renderChartHtml(seriesSpec({ crossingLabel: null }))).not.toContain('ultrapassa')
})
it('uses the compact feed rhythm of the variant', () => {
const html = renderChartHtml(seriesSpec())
expect(html).toContain('font-size: 60px')
expect(html).toContain('margin-top: 28px')
})
it('adapts the plot and the rhythm to square and to Stories', () => {
const square = renderChartHtml(seriesSpec({ size: 'square' }))
expect(square).toContain('viewBox="0 0 880 440"')
expect(square).toContain('font-size: 54px')
const story = renderChartHtml(seriesSpec({ size: 'story' }))
expect(story).toContain('viewBox="0 0 880 700"')
expect(story).toContain('font-size: 64px')
})
it('keeps red out of the plot when the comparison is neutral', () => {
const { series } = seriesSpec()
const html = renderChartHtml(
seriesSpec({
crossingLabel: null,
series: series.map((serie: { name: string; rows: unknown[] }) => ({
name: serie.name,
rows: serie.rows,
})),
}),
)
const svg = html.slice(html.indexOf('<svg'), html.indexOf('</svg>'))
expect(svg).not.toContain('#c51414')
expect(svg).not.toContain('amplia')
})
it('carries the projection note and the source inside the image', () => {
const html = renderChartHtml(seriesSpec())
expect(html).toContain('2026 é projeção pela média de janeiro a junho × 2.')
expect(html).toContain('Ministério da Saúde — SIH/SUS')
expect(html).toContain('JORGE SOLLA')
})
})