From 6883aff9f3571fbe33d89991d7ead12bfa9eb919 Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Fri, 18 Sep 2026 22:50:20 -0300
Subject: [PATCH] =?UTF-8?q?feat(C191):=20linha=20de=20duas=20s=C3=A9ries?=
=?UTF-8?q?=20no=20gr=C3=A1fico=20de=20Instagram=20=E2=80=94=20val=C3=AAnc?=
=?UTF-8?q?ia=20bom/ruim,=20proje=C3=A7=C3=A3o=20e=20cruzamento?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Skill, núcleo (chartData), desenho (dualLineChart/renderer), design CENA 02B certificado e testes.
---
.agents/skills/graficos-dados/SKILL.md | 74 +-
docs/changelog/2026-09-18-c191-duas-series.md | 1 +
.../graficos-dados-instagram-ui-design.html | 671 +++++++++++++++++-
scripts/build-chart-from-data.mjs | 62 +-
scripts/lib/chartData.mjs | 206 +++++-
scripts/lib/chartPrimitives.mjs | 355 +++++++++
scripts/lib/graficosInstagramRender.mjs | 65 +-
tests/unit/chartData.unit.spec.ts | 170 +++++
tests/unit/chartPrimitives.unit.spec.ts | 154 ++++
.../unit/graficosInstagramRender.unit.spec.ts | 84 +++
10 files changed, 1787 insertions(+), 55 deletions(-)
create mode 100644 docs/changelog/2026-09-18-c191-duas-series.md
diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md
index 78af7498..8bac2093 100644
--- a/.agents/skills/graficos-dados/SKILL.md
+++ b/.agents/skills/graficos-dados/SKILL.md
@@ -37,12 +37,17 @@ O template visual é o artefato aprovado
```bash
node scripts/build-chart-from-data.mjs --in= --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=` 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= \
+ --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/--.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=`.
+ tamanho/`--highlight` (ou `--good`/`--bad`/`--projected`/`--crossing`) e
+ regenere. **Replay** sem reparse: `--spec=`.
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=`) 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.
diff --git a/docs/changelog/2026-09-18-c191-duas-series.md b/docs/changelog/2026-09-18-c191-duas-series.md
new file mode 100644
index 00000000..fe1f5b99
--- /dev/null
+++ b/docs/changelog/2026-09-18-c191-duas-series.md
@@ -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).
diff --git a/docs/plans/graficos-dados-instagram-ui-design.html b/docs/plans/graficos-dados-instagram-ui-design.html
index 087dbb10..93564a6b 100644
--- a/docs/plans/graficos-dados-instagram-ui-design.html
+++ b/docs/plans/graficos-dados-instagram-ui-design.html
@@ -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 @@
Gráficos claros, sóbrios e prontos para a comunicação
- 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.
@@ -767,7 +867,7 @@
CENA 02 · SISTEMA
- Quatro relações, um mesmo template
+ Cinco variações, um mesmo template
@@ -903,6 +1003,558 @@
Um valor grande + frase que explica.
+
+
+
+
+
Comparação no tempo
+
Estadual cresce e supera o municipal
+
Internações em Vitória da Conquista, 2017–2026.
+
+
+
+
+
+
Linha · duas séries
+
+ Tendências, valência redundante e projeção explícita.
+
+
+
+
+
+
+
+
+
CENA 02B · VARIAÇÃO CERTIFICADA
+
+ Feed 4:5 · linha temporal de duas séries
+
+
+
+ 1080 × 1350
+ Plot SVG · viewBox 880 × 620
+
+
+
+
+
+
+
+
+
+
Comparação no tempo
+
+ Internações crescem no hospital estadual e recuam no municipal
+
+
+ AIH aprovadas por ano · Vitória da Conquista (BA) · 2017–2026
+
+
+
+
+
+
+
+
+ NEEDS ASSET · LOGO OFICIAL
+
+
+
+
+
+
+
+
+
REGRAS DA LINHA DE 2 SÉRIES
+
+ Contrato visual para o porte classe-a-classe
+
+
+
+
Entrada e teto
+
+ Exatamente 2 séries e os mesmos rótulos temporais, na mesma ordem. Teto:
+ 12 pontos por série 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.
+
+
+
+
Bom × ruim · tons presentes
+
+ Tons entram sempre em par. Bom =
+ #c51414 + círculo + ↑ “amplia”. Ruim =
+ #78716c + quadrado + ↓ “recua”. 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.
+
+
+
+
Sem tons · comparação neutra
+
+ Se nenhum tom vier informado, não inferir juízo: série A =
+ #1c1917 + círculo; série B =
+ #78716c + quadrado. 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.
+
+
+
+
Kicker, rótulos e colisão
+
+ Kicker fixo: “Comparação no tempo”; 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.
+
+
+
+
Projeção
+
+ Só quando o último período for projetado: faixa neutra
+ #a8a29e a 16% do meio do penúltimo→último passo até
+ 33 px depois do ponto final; apenas esse segmento usa tracejado (feed/story
+ 18 12; quadrado
+ 15 11). 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.
+
+
+
+
Eixo X · 10 pontos anuais
+
+ 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.
+
+
+
+
Variação e cruzamento
+
+ Se o último ponto é projeção, a variação compara o primeiro valor ao último
+ observado, nunca à projeção: aqui, +121% até 2025 no
+ estadual e −23% até 2025 no municipal. O cruzamento confirmado
+ recebe dual-crossing-*: 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.
+
+
+
+
Feed · 1080×1350
+
+ Canvas padding 76/84/58 px; headline 60 px; plot
+ 880×620, 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.
+
+ Faixas seguras de 250 px no topo e na base; conteúdo padding 306/84/310 px; headline
+ 64 px. Plot 880×700, 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.
+
+
+
+
Mapa de classes do SVG
+
+ Chassi: dual-line-plot,
+ dual-axis-label,
+ dual-projection-band e
+ dual-projection-label. Com tons:
+ dual-series-good / dual-markers-good e
+ dual-series-bad / dual-markers-bad. Sem tons, trocar
+ apenas os modificadores por
+ dual-series-a / dual-markers-a e
+ dual-series-b / dual-markers-b. Projeção acrescenta
+ dual-series-projection e
+ dual-marker-projected-*. Saída direta:
+ dual-end-leader,
+ dual-end-name,
+ dual-end-value e, só com tons,
+ dual-end-valence; variação observada em
+ dual-end-change. Cruzamento, quando confirmado:
+ dual-crossing-guide,
+ dual-crossing-ring,
+ dual-crossing-box,
+ dual-crossing-year e
+ dual-crossing-copy.
+
+
+
@@ -1143,7 +1795,8 @@
Densidade
- 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.
@@ -1159,8 +1812,10 @@
-
+
${htmlEscape(kicker)}
diff --git a/tests/unit/chartData.unit.spec.ts b/tests/unit/chartData.unit.spec.ts
index 3b557428..773de957 100644
--- a/tests/unit/chartData.unit.spec.ts
+++ b/tests/unit/chartData.unit.spec.ts
@@ -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'])
diff --git a/tests/unit/chartPrimitives.unit.spec.ts b/tests/unit/chartPrimitives.unit.spec.ts
index a157390a..41ee3eb1 100644
--- a/tests/unit/chartPrimitives.unit.spec.ts
+++ b/tests/unit/chartPrimitives.unit.spec.ts
@@ -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(' {
+ 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: 'x' }, series[1]],
+ size: 'feed',
+ projected: false,
+ colors,
+ valence,
+ format,
+ })
+ expect(evil).toContain('<b>x</b>')
+ })
+})
diff --git a/tests/unit/graficosInstagramRender.unit.spec.ts b/tests/unit/graficosInstagramRender.unit.spec.ts
index aa885364..3863a887 100644
--- a/tests/unit/graficosInstagramRender.unit.spec.ts
+++ b/tests/unit/graficosInstagramRender.unit.spec.ts
@@ -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('