feat(C205): linha de três séries e marca maior no rodapé do gerador de gráficos (Closes #1212)

This commit is contained in:
Francisco Solla 2026-09-19 12:45:08 -03:00
parent dbd9ecdfd4
commit ca19b2c86f
12 changed files with 1471 additions and 152 deletions

View file

@ -14,13 +14,14 @@ comunicação transformar o gráfico no material final (legenda, carrossel,
contexto). É a **base visual confiável**, não a peça acabada.
O template visual é o artefato aprovado
`docs/plans/graficos-dados-instagram-ui-design.html` (revisão C203 — marca e
paleta oficiais do kit 1313); o renderer
`docs/plans/graficos-dados-instagram-ui-design.html` (base C203 aprovada — marca
e paleta oficiais do kit 1313 —, com as revisões C205 da linha de três séries e
C206 do rodapé/marca aguardando gate humano); o renderer
(`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe.
**Nunca invente estrutura visual** — o dono é o `designer`. O rodapé usa o
ativo oficial `public/campaign-kit/jorge-solla-positivo.png` (nunca recriar
lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa),
`#184e92` (sinal de marca), com amarelo/verde só dentro dos ativos.
lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
`#184e92` (sinal de marca, nunca dado), com amarelo/verde só dentro dos ativos.
## Quando usar
@ -48,10 +49,15 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa),
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;
- **duas ou três séries no mesmo período → `line` multi-série**: tabela com 2
ou 3 colunas de medida e a primeira coluna temporal; o par
`--good=`/`--bad=` marca 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. A **extensão de três séries** (C205) usa o
trio `--good=`/`--neutral=`/`--neutral-dark=` informado junto — melhor =
vermelho/círculo/"↑ amplia", neutro = cinza/losango/"↗ cresce", neutro
escuro = tinta/triângulo/"↘ diminui" —, só no feed e sem projeção nem
cruzamento;
- **duas medidas por categoria → `delta`**: barra de variação com a base
(0 → inicial) num tom e a extensão (inicial → final) noutro, os dois valores
numa gutter de duas colunas e o vermelho fora do plot (sem valência);
@ -82,6 +88,15 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa),
--type=line --size=feed \
--good="Estadual" --bad="Municipal" --projected=2026 --crossing=2024
```
Três séries no feed (extensão C205, os três tons entram juntos):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> \
--headline="Hospital estadual puxa a ampliação de leitos de internação" \
--subtitle="Vitória da Conquista (BA) · leitos existentes · 2015–2026" \
--source="Ministério da Saúde — CNES · leitos de internação por esfera jurídica" \
--type=line --size=feed \
--good="Hospital Estadual" --neutral="Rede municipal" --neutral-dark="Rede privada"
```
Barra de variação (base + extensão, tabela de rótulo + inicial + final):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --type=delta --size=feed \
@ -139,13 +154,31 @@ Na linha de 2 séries, `series` substitui `rows` e `projectedLabel` marca o
}
```
Na extensão de três séries (C205), o mesmo `series` carrega o trio de tons:
```json
{
"chartType": "line",
"size": "feed",
"headline": "Hospital estadual puxa a ampliação de leitos de internação",
"source": "Ministério da Saúde — CNES · leitos de internação por esfera jurídica",
"series": [
{ "name": "Hospital Estadual", "tone": "good", "rows": [{ "label": "2015", "value": 235 }] },
{ "name": "Rede municipal", "tone": "neutral", "rows": [{ "label": "2015", "value": 93 }] },
{ "name": "Rede privada", "tone": "neutral-dark", "rows": [{ "label": "2015", "value": 832 }] }
]
}
```
`chartType` ∈ `bar | column | line | anchor | delta`; `highlight` é o rótulo que
recebe o único vermelho `#e4102f` (default: o maior valor); `unit` anexa o
sufixo aos rótulos de valor (ex.: `"%"`). O `kicker` (spec) segue a **relação**
dos dados, não o layout — colunas verticais de categorias leem "Comparação",
poucos períodos leem "Poucos períodos". Em `series`, `tone` ∈
`good | bad` (os dois tons ou nenhum) e as duas séries compartilham os mesmos
rótulos temporais; `rows` e `highlight` não entram nessa variante.
`good | bad` (os dois tons ou nenhum) ou `good | neutral | neutral-dark` (o trio
da extensão C205, só no feed, sem `projectedLabel` nem `crossingLabel`); as
séries compartilham os mesmos rótulos temporais; `rows` e `highlight` não entram
nessas variantes.
`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
@ -179,44 +212,50 @@ 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 (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. A **barra de variação** (`delta`) também
- **Até 7 pontos** por gráfico (barra, coluna e linha simples); a **linha
multi-série** (2 ou 3) 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. A **barra de variação** (`delta`) também
comporta **até 7 categorias**, e no story ela **não corta** (um top-N apagaria
a manchete "quatro dos sete").
- **Barra de variação (`delta`)** parte do zero com `0 ≤ inicial ≤ final`;
retração (final < inicial) é recusada com a linha de 2 séries no lugar;
retração (final < inicial) é recusada com a linha multi-série no lugar;
`--highlight`/valência não entram (o par de tons é fixo e o vermelho fica fora
do plot).
- **Comparação positiva dupla (`--dual-positive`)** exige `column`, exatamente 2
categorias e `feed`; recusa `--highlight` e `--no-highlight`; os tons seguem a
ordem de entrada (nunca o maior) e ambos leem como resultado bom — cor nunca
carrega sozinha (cada coluna tem rótulo e valor).
- **Valência só na linha de 2 séries**, em par (`good`/`bad`) e sempre por
palavra + seta + forma do marcador + cor — **cor nunca carrega sozinha**:
bom = vermelho `#e4102f`, círculo, "↑ amplia" (o vermelho é a cor do
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.
- **Valência só na linha multi-série** e sempre por palavra + seta + forma do
marcador + cor — **cor nunca carrega sozinha**. No par de 2 séries
(`good`/`bad`): bom = vermelho `#e4102f`, 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**). Na extensão de 3 séries (C205, só no
feed): o trio `good`/`neutral`/`neutral-dark` entra junto — melhor = vermelho,
círculo, "↑ amplia"; neutro = cinza `#78716c`, losango, "↗ cresce"; neutro
escuro = tinta `#1c1917`, triângulo, "↘ diminui" (direções factuais, nunca
"recua"/perda) — e o azul `#184e92` não entra em dado nesta variante.
- **Projeção** (`--projected=`) só no último período das duas séries (o trio de
três séries não certifica projeção): ú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ó no par de 2 séries) 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.
- **Dado faltando/ambíguo → pergunta, nunca completa.** O builder sai com
`{ needsQuestion: true, issues: [...] }` e não gera imagem.
- **Sem fonte, sem peça:** `--source` é obrigatório.
- **Marca oficial (C203):** o rodapé usa o ativo
`public/campaign-kit/jorge-solla-positivo.png` como data URI (paleta oficial:
destaque/valência boa `#e4102f`, sinal de marca `#184e92`; amarelo/verde só
dentro dos ativos). Sem o ativo, o builder falha — nunca recria o lockup
tipográfico.
- **Marca oficial (C203/C206):** o rodapé usa o ativo
`public/campaign-kit/jorge-solla-positivo.png` como data URI numa moldura na
razão do próprio ativo (feed 251×144, quadrado 192×110, story 279×160) —
paleta oficial: destaque/valência melhor `#e4102f`, sinal de marca `#184e92`
(nunca dado); amarelo/verde só dentro dos ativos. Sem o ativo, o builder
falha — nunca recria o lockup tipográfico.
- Legibilidade: headline ≥48px e rótulos ≥30px no canvas 1080; contraste ≥4.5:1
no texto e ≥3:1 nas marcas; **cor nunca é a única pista** (o destaque tem
valor e posição).
@ -230,11 +269,12 @@ 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. 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.
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. Na linha
multi-série o teto é 12 por série.
- **Séries desalinhadas / tom ímpar (par ou trio incompletos) / projeção ou
cruzamento na linha de três séries / três séries fora do feed / 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`
@ -245,8 +285,10 @@ O builder **recusa** em vez de desenhar algo enganoso:
## Referências
- 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; revisão
C203 — marca e paleta oficiais do kit 1313).
a variação certificada de linha de duas séries, com valência bom/ruim; a
extensão C205 de três séries observadas e a revisão C206 do rodapé/marca,
ambas pendentes de gate humano; base C203 — marca e paleta oficiais do kit
1313).
- Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl:
`docs/plans/graficos-dados-instagram-impl.md`.
- Marca oficial: `docs/plans/graficos-dados-marca-kit-1313.md` (impl:

View file

@ -0,0 +1 @@
**C205 — Gráficos do Instagram: linha de três séries e marca oficial maior no rodapé (2026-09-19):** a skill `graficos-dados` (C191/C203) ganha a variante de **três séries observadas** no feed e um rodapé com a marca ampliada. (1) **Linha de 3 séries (revisão C205):** o contrato aceita 2 ou 3 séries temporais alinhadas (`MIN_SERIES`/`MAX_SERIES`; alinhamento e rótulos checados em todas as séries) e o trio de tons `good`/`neutral`/`neutral-dark` entra junto (ou nenhum), com projeção, cruzamento e tamanho ≠ feed recusados; no renderer, melhor = vermelho/círculo/"↑ amplia", neutro = cinza `#78716c`/losango/"↗ cresce" e neutro escuro = tinta `#1c1917`/triângulo/"↘ diminui" (o azul `#184e92` sai dos dados), com gutter de três linhas (nome / "valor · ±N%" / palavra), leaders em x=570–594 e texto em x=600. (2) **Rodapé (revisão C206):** a moldura passa a abraçar a razão do ativo oficial (feed 251×144, quadrado 192×110, story 279×160; rodapé mínimo 171/128/190 px, fonte 25 px nas cenas densas) — a marca deixa de boiar numa caixa 286×90 com ~129 px vazios. (3) **Superfície:** CLI com `--good/--neutral/--neutral-dark` (par `--good/--bad` intacto; `--medium` aposentado com mensagem acionável), `SKILL.md`/command e o hi-fi (`docs/plans/graficos-dados-instagram-ui-design.html`, C205/C206 aprovadas no gate de 2026-09-19) atualizados no mesmo PR. Peça de validação: leitos de internação do SUS em Vitória da Conquista (CNES, 2015–jul/2026) — hospital estadual 235→369 (+57%), rede municipal 93→113 (+22%) e rede privada 832→510 (−39%). Verificação: unit 4.096 verdes (specs do gerador atualizados), lint/format/knip/typecheck limpos; sem migration.

View file

@ -201,6 +201,7 @@
grid-template-columns: 1fr auto;
align-items: end;
gap: 30px;
min-height: 171px;
}
.source-line {
margin: 0;
@ -212,25 +213,35 @@
color: var(--ink);
font-weight: 750;
}
/* Ativo oficial do kit (jorge-solla-positivo.png): exibido inteiro, sem recorte e sem
distorção. O arquivo é o lockup do nome; nunca recriar o lockup com tipografia. */
/* C206 · ativo oficial 1037×595 (≈1,743:1). A moldura abraça a razão do PNG para eliminar
o letterbox criado pela antiga caixa 286×90. A transparência interna do próprio ativo é
preservada: sem crop, sem scale desproporcional e sem lockup tipográfico recriado. */
.brand-logo-frame {
width: 286px;
height: 90px;
flex: 0 0 286px;
display: flex;
align-items: center;
justify-content: flex-end;
width: 251px;
height: 144px;
flex: 0 0 251px;
}
.brand-logo-frame img {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Porte por canvas do template exportado. Feed é o default acima. */
.square-canvas .chart-footer {
min-height: 128px;
padding-top: 16px;
}
.square-canvas .source-line {
font-size: 25px;
}
.square-canvas .brand-logo-frame {
width: 192px;
height: 110px;
flex-basis: 192px;
}
.mini-canvas {
position: relative;
width: 100%;
@ -285,19 +296,14 @@
line-height: 1.2;
}
.mini-logo-frame {
width: 70px;
height: 23px;
flex: 0 0 70px;
display: flex;
align-items: center;
justify-content: flex-end;
width: 63px;
height: 36px;
flex: 0 0 63px;
}
.mini-logo-frame img {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
width: 100%;
height: 100%;
object-fit: contain;
}
.mini-bars {
@ -448,6 +454,60 @@
font-size: 28px;
font-weight: 750;
}
/* ------------------------------------------------------------------ */
/* CENA 02D · C205 — revisão da extensão para três séries observadas. */
/* C204 é superada: bom + neutro + neutro escuro; a 02B não muda. */
/* ------------------------------------------------------------------ */
.feed-canvas.triple-series .chart-headline {
max-width: 900px;
font-size: 56px;
}
.feed-canvas.triple-series .chart-subtitle {
max-width: 900px;
margin-top: 18px;
font-size: 27px;
}
.feed-canvas.triple-series .source-line {
max-width: 570px;
font-size: 25px;
}
.triple-line-plot {
flex: none;
width: 880px;
height: 650px;
margin-top: 16px;
}
.triple-line-plot svg {
display: block;
width: 880px;
height: 650px;
}
.triple-axis-label {
fill: #78716c;
font-size: 30px;
font-weight: 650;
}
.triple-end-name {
fill: var(--ink);
font-size: 30px;
font-weight: 700;
}
.triple-end-metric {
fill: var(--ink);
font-size: 34px;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
.triple-end-valence {
font-size: 30px;
font-weight: 850;
}
.triple-end-leader {
fill: none;
stroke-width: 3px;
stroke-linecap: square;
stroke-linejoin: round;
}
.rule-code {
display: inline-flex;
padding: 2px 6px;
@ -781,6 +841,7 @@
align-items: end;
justify-content: space-between;
gap: 28px;
min-height: 190px;
}
.story-source {
max-width: 570px;
@ -788,6 +849,11 @@
font-size: 27px;
line-height: 1.3;
}
.story-canvas .brand-logo-frame {
width: 279px;
height: 160px;
flex-basis: 279px;
}
.state-piece {
position: relative;
@ -874,19 +940,14 @@
font-size: 11px;
}
.state-logo-frame {
width: 116px;
height: 36px;
flex: 0 0 116px;
display: flex;
align-items: center;
justify-content: flex-end;
width: 87px;
height: 50px;
flex: 0 0 87px;
}
.state-logo-frame img {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
width: 100%;
height: 100%;
object-fit: contain;
}
@ -937,21 +998,23 @@
<div
class="mb-6 rounded-2xl border border-[#184e92]/30 bg-[#184e92]/[0.05] p-5 sm:p-6"
role="note"
aria-label="Design aprovado no gate em 2026-09-19"
aria-label="Base C203 aprovada e revisões C205 e C206 aprovadas no gate de 2026-09-19"
>
<div class="flex flex-wrap items-start gap-x-5 gap-y-3">
<span
class="inline-flex items-center rounded-md bg-[#184e92] px-3 py-1 text-sm font-black tracking-[0.14em] text-white"
>APROVADO</span
>BASE C203 APROVADA · C205/C206 APROVADAS</span
>
<div class="max-w-3xl">
<p class="text-sm font-extrabold text-[#184e92]">
Aprovado no gate em 2026-09-19 — revisão C203 (marca e paleta oficiais do kit 1313).
Base C203 aprovada em 2026-09-19 · revisões C205 e C206 aprovadas no gate em
2026-09-19.
</p>
<p class="mt-1 text-sm leading-6 text-stone-700">
O humano validou a substituição do lockup tipográfico pelo ativo oficial e o
mapeamento de cor fixado aqui; este artefato é o registro do aceite visual e a fonte
de verdade do port.
O aceite C203 permanece intacto. A C205 revisa no lugar a CENA 02D — linha temporal de
três séries — e supera a C204, que nunca foi certificada. A C206 revisa somente o
rodapé compartilhado para ampliar a marca oficial. As duas são a fonte de verdade do
port desde o gate humano de 2026-09-19.
</p>
</div>
</div>
@ -960,9 +1023,9 @@
<header class="max-w-4xl">
<div class="flex flex-wrap items-center gap-2">
<span class="scene-label border-blue-200 bg-blue-50 text-[#184e92]"
>Revisão C203 · marca oficial do kit 1313</span
>Revisões C205 + C206 · aprovadas no gate</span
>
<span class="scene-label">Aprovado no gate · 2026-09-19</span>
<span class="scene-label">Base C203 aprovada · C205/C206 aprovadas</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">
@ -1025,6 +1088,91 @@
</div>
</section>
<section
class="mt-4 rounded-2xl border border-blue-200 bg-blue-50/70 p-5 sm:p-6"
aria-labelledby="revision-c205-title"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="max-w-3xl">
<p class="text-xs font-extrabold tracking-[0.08em] text-[#184e92]">
REVISÃO VISÍVEL · C205 · APROVADA NO GATE 2026-09-19
</p>
<h2 id="revision-c205-title" class="mt-1 text-lg font-bold tracking-tight">
Três séries observadas: semântica neutra e gutter com mais respiro
</h2>
<p class="mt-2 text-sm leading-6 text-stone-700">
A <strong>CENA 02D</strong> troca bom/médio/neutro por
<strong>bom/neutro/neutro escuro</strong>, usando apenas neutros oficiais além do
vermelho do melhor resultado. A gutter passa de quatro para três linhas por bloco, com
valor + variação na mesma linha e faixas mais arejadas. O último ponto segue
<strong>jul/2026 observado</strong>, sem qualquer artefato de projeção. A CENA 02B
preserva integralmente o par bom/ruim aprovado.
</p>
</div>
<span class="scene-label border-blue-200 bg-blue-50 text-[#184e92]"
>APROVADA · GATE 2026-09-19</span
>
</div>
</section>
<section
class="mt-4 rounded-2xl border border-blue-200 bg-blue-50/70 p-5 sm:p-6"
aria-labelledby="revision-c206-title"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="max-w-3xl">
<p class="text-xs font-extrabold tracking-[0.08em] text-[#184e92]">
REVISÃO VISÍVEL · C206 · APROVADA NO GATE 2026-09-19
</p>
<h2 id="revision-c206-title" class="mt-1 text-lg font-bold tracking-tight">
Marca oficial maior, sem caixa vazia artificial no rodapé
</h2>
<p class="mt-2 text-sm leading-6 text-stone-700">
O ativo oficial <strong>1037×595</strong> permanece inteiro. A antiga moldura
<strong>286×90</strong> era larga demais para a razão do PNG e fazia a imagem ocupar
só cerca de 157×90 px. A C206 troca a moldura por caixas na mesma razão do ativo:
<strong>251×144 no feed</strong>, <strong>192×110 no quadrado</strong> e
<strong>279×160 no story</strong>. O PNG passa a ocupar 100% dessas caixas com
<span class="rule-code">object-fit: contain</span>; só a transparência nativa do
arquivo permanece.
</p>
</div>
<span class="scene-label border-blue-200 bg-blue-50 text-[#184e92]"
>APROVADA · GATE 2026-09-19</span
>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="rounded-xl border border-amber-200 bg-white p-4">
<p class="text-sm font-bold">Feed · 1080×1350</p>
<p class="mt-1 text-sm leading-5 text-stone-600">
<span class="rule-code">brand-logo-frame 251×144</span>; rodapé mínimo 171 px,
padding-top 25 px, gap 30 px. Fonte/nota 27 px; nas linhas densas 02B/02D, 25 px.
</p>
</div>
<div class="rounded-xl border border-amber-200 bg-white p-4">
<p class="text-sm font-bold">Quadrado · 1080×1080</p>
<p class="mt-1 text-sm leading-5 text-stone-600">
<span class="rule-code">brand-logo-frame 192×110</span>; rodapé mínimo 128 px,
padding-top 16 px, gap 30 px. Fonte/nota 25 px. O porte menor protege a altura útil do
canvas mais curto sem voltar à marca miúda da C203.
</p>
</div>
<div class="rounded-xl border border-amber-200 bg-white p-4">
<p class="text-sm font-bold">Story · 1080×1920</p>
<p class="mt-1 text-sm leading-5 text-stone-600">
<span class="rule-code">brand-logo-frame 279×160</span>; rodapé mínimo 190 px,
padding-top 28 px, gap 28 px. Fonte/nota 27 px; tudo termina acima da safe zone.
</p>
</div>
</div>
<p class="mt-4 border-t border-amber-200 pt-4 text-sm leading-6 text-stone-700">
<strong>Compatibilidade:</strong> o crescimento é absorvido pelo espaço flexível antes do
rodapé; não se altera headline, subtítulo, plot, escala, eixo ou gutter das CENAS 01, 02,
02B e 02D. O rodapé segue off-white, com filete <strong>#e7e5e4</strong> e texto
<strong>#57534e</strong>; o contraste e as margens laterais de 84 px permanecem.
</p>
</section>
<section class="scene-card mt-9 p-4 sm:p-6 lg:p-8" aria-labelledby="main-scene-title">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
@ -1226,7 +1374,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">
Cinco variações, um mesmo template
Cinco variações-base + duas extensões, um mesmo template
</h2>
</div>
<p class="max-w-xl text-sm leading-5 text-stone-600">
@ -2129,11 +2277,448 @@
</div>
</section>
<!-- ================================================================== -->
<!-- CENA 02D · C205 — REVISÃO DA EXTENSÃO, NÃO DA CENA 02B. -->
<!-- Três séries; todos os 36 pontos são observados. Gate humano pendente. -->
<!-- ================================================================== -->
<section
class="scene-card mt-10 border-2 border-blue-200 p-4 sm:p-6 lg:p-8"
aria-labelledby="triple-line-title"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-bold text-brand-dark">
CENA 02D · REVISÃO C205 · C204 SUPERADA · APROVADA NO GATE 2026-09-19
</p>
<h2 id="triple-line-title" class="mt-1 text-xl font-bold tracking-tight">
Feed 4:5 · linha temporal de três séries observadas
</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 × 650</span>
<span class="measure-tag">36 marcas · sem projeção</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 triple-series"
aria-label="Hospital estadual puxa a ampliação de leitos de internação em Vitória da Conquista entre 2015 e julho de 2026; todos os pontos são observados"
>
<div class="feed-inner">
<div class="top-rule"></div>
<p class="chart-context">Comparação no tempo</p>
<h3 class="chart-headline">
Hospital estadual puxa a ampliação de leitos de internação
</h3>
<p class="chart-subtitle">
Vitória da Conquista (BA) · leitos existentes · 2015–2026
</p>
<div class="triple-line-plot tabular">
<svg
viewBox="0 0 880 650"
role="img"
aria-label="Hospital Estadual: 235 leitos em 2015 e 369 em julho de 2026, aumento de 57 por cento. Rede municipal: 93 e 113, aumento de 22 por cento. Rede privada, soma dos leitos empresariais e sem fins lucrativos: 832 e 510, diminuição de 39 por cento. Todos os doze pontos de cada série são observados."
preserveAspectRatio="xMidYMid meet"
>
<line
x1="52"
y1="56"
x2="560"
y2="56"
stroke="#a8a29e"
stroke-opacity="0.32"
stroke-width="2"
/>
<line
x1="52"
y1="300"
x2="560"
y2="300"
stroke="#a8a29e"
stroke-opacity="0.32"
stroke-width="2"
/>
<line x1="52" y1="544" x2="560" y2="544" stroke="#a8a29e" stroke-width="2" />
<text x="42" y="538" text-anchor="end" class="triple-axis-label">0</text>
<polyline
class="triple-series triple-series-good"
points="52,429 98,429 144,419 191,419 237,419 283,419 329,419 375,419 421,364 468,364 514,364 560,364"
fill="none"
stroke="#e4102f"
stroke-width="7"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
class="triple-series triple-series-neutral"
points="52,499 98,499 144,493 191,499 237,499 283,499 329,499 375,499 421,489 468,489 514,489 560,489"
fill="none"
stroke="#78716c"
stroke-width="7"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
class="triple-series triple-series-neutral-dark"
points="52,138 98,191 144,199 191,230 237,290 283,302 329,320 375,305 421,296 468,282 514,300 560,295"
fill="none"
stroke="#1c1917"
stroke-width="7"
stroke-linecap="round"
stroke-linejoin="round"
/>
<g class="triple-markers-good" fill="#e4102f">
<circle cx="52" cy="429" r="8" />
<circle cx="98" cy="429" r="8" />
<circle cx="144" cy="419" r="8" />
<circle cx="191" cy="419" r="8" />
<circle cx="237" cy="419" r="8" />
<circle cx="283" cy="419" r="8" />
<circle cx="329" cy="419" r="8" />
<circle cx="375" cy="419" r="8" />
<circle cx="421" cy="364" r="8" />
<circle cx="468" cy="364" r="8" />
<circle cx="514" cy="364" r="8" />
<circle cx="560" cy="364" r="8" />
</g>
<g class="triple-markers-neutral" fill="#78716c">
<rect x="45" y="492" width="14" height="14" transform="rotate(45 52 499)" />
<rect x="91" y="492" width="14" height="14" transform="rotate(45 98 499)" />
<rect
x="137"
y="486"
width="14"
height="14"
transform="rotate(45 144 493)"
/>
<rect
x="184"
y="492"
width="14"
height="14"
transform="rotate(45 191 499)"
/>
<rect
x="230"
y="492"
width="14"
height="14"
transform="rotate(45 237 499)"
/>
<rect
x="276"
y="492"
width="14"
height="14"
transform="rotate(45 283 499)"
/>
<rect
x="322"
y="492"
width="14"
height="14"
transform="rotate(45 329 499)"
/>
<rect
x="368"
y="492"
width="14"
height="14"
transform="rotate(45 375 499)"
/>
<rect
x="414"
y="482"
width="14"
height="14"
transform="rotate(45 421 489)"
/>
<rect
x="461"
y="482"
width="14"
height="14"
transform="rotate(45 468 489)"
/>
<rect
x="507"
y="482"
width="14"
height="14"
transform="rotate(45 514 489)"
/>
<rect
x="553"
y="482"
width="14"
height="14"
transform="rotate(45 560 489)"
/>
</g>
<g class="triple-markers-neutral-dark" fill="#1c1917">
<path d="M42 130 L62 130 L52 148 Z" />
<path d="M88 183 L108 183 L98 201 Z" />
<path d="M134 191 L154 191 L144 209 Z" />
<path d="M181 222 L201 222 L191 240 Z" />
<path d="M227 282 L247 282 L237 300 Z" />
<path d="M273 294 L293 294 L283 312 Z" />
<path d="M319 312 L339 312 L329 330 Z" />
<path d="M365 297 L385 297 L375 315 Z" />
<path d="M411 288 L431 288 L421 306 Z" />
<path d="M458 274 L478 274 L468 292 Z" />
<path d="M504 292 L524 292 L514 310 Z" />
<path d="M550 287 L570 287 L560 305 Z" />
</g>
<polyline
class="triple-end-leader"
points="570,295 580,295 590,108 594,108"
stroke="#1c1917"
/>
<text x="600" y="62" class="triple-end-name">Rede privada</text>
<text x="600" y="108" class="triple-end-metric">510 · −39%</text>
<text x="600" y="150" class="triple-end-valence" fill="#1c1917">
↘ diminui
</text>
<polyline
class="triple-end-leader"
points="570,364 580,364 590,304 594,304"
stroke="#e4102f"
/>
<text x="600" y="258" class="triple-end-name">Hospital Estadual</text>
<text x="600" y="304" class="triple-end-metric">369 · +57%</text>
<text x="600" y="346" class="triple-end-valence" fill="#e4102f">
↑ amplia
</text>
<polyline
class="triple-end-leader"
points="570,489 580,489 590,500 594,500"
stroke="#78716c"
/>
<text x="600" y="454" class="triple-end-name">Rede municipal</text>
<text x="600" y="500" class="triple-end-metric">113 · +22%</text>
<text x="600" y="542" class="triple-end-valence" fill="#78716c">
↗ cresce
</text>
<g class="triple-axis-label" text-anchor="middle">
<text x="52" y="636">2015</text>
<text x="144" y="636">2017</text>
<text x="237" y="636">2019</text>
<text x="329" y="636">2021</text>
<text x="421" y="636">2023</text>
<text x="560" y="636">2026</text>
</g>
</svg>
</div>
<footer class="chart-footer">
<p class="source-line">
<strong>Fonte:</strong> Ministério da Saúde — CNES · leitos de internação por
esfera jurídica<br />Nota: Último ponto: jul/2026. Rede privada = leitos
empresariais + sem fins lucrativos.
</p>
<div class="brand-logo-frame">
<img
src="../../public/campaign-kit/jorge-solla-positivo.png"
alt="Jorge Solla — Deputado Federal"
/>
</div>
</footer>
</div>
</article>
</div>
</div>
<aside class="grid gap-4">
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">BOM + DOIS NEUTROS · QUATRO PISTAS</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
Melhor = vermelho + círculo + ↑ + “amplia”. Neutro = cinza + losango + ↗ + “cresce”.
Neutro escuro = tinta + triângulo para baixo + ↘ + “diminui”. Cor, forma, seta e
palavra trabalham juntas; o azul deixa completamente os dados.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">DIREÇÃO FACTUAL, SEM JUÍZO INVENTADO</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
O municipal sobe pouco e recebe <strong>“↗ cresce”</strong>; a rede privada cai e
recebe <strong>“↘ diminui”</strong>. São descrições do movimento, não “bom/ruim”. O
par valorativo da CENA 02B continua usando “↑ amplia” e “↓ recua”.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">
PAR CLARO × ESCURO · SÓ NEUTROS OFICIAIS
</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
Municipal usa <span class="rule-code">#78716c</span>; privada usa
<span class="rule-code">#1c1917</span>. O salto de cinza de eixo para tinta separa
as linhas sem criar cor nova; losango/triângulo, ↗/↘ e as palavras preservam a
leitura quando a diferença tonal não estiver disponível.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">2026 É OBSERVADO</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
O eixo mostra “2026”; o subtítulo fecha 2015–2026 e a nota especifica jul/2026. Não
há faixa, tracejado, marcador vazado nem “projeção”: inventar qualquer um desses
sinais mudaria a natureza do dado.
</p>
</div>
</aside>
</div>
<div class="mt-7 border-t border-stone-200 pt-6" aria-labelledby="triple-rules-title">
<p class="text-xs font-bold text-brand-dark">REGRAS DA LINHA DE 3 SÉRIES · C205</p>
<h3 id="triple-rules-title" class="mt-1 text-lg font-bold tracking-tight">
Contrato visual da extensão 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">Extensão, entrada e teto</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Esta variante <strong>estende</strong> a CENA 02B; não a substitui. Exatamente 3
séries, mesmos períodos e mesma ordem. Teto aprovado:
<strong>12 pontos por série no feed</strong> (36 marcas). É o limite exibido nesta
cena: linha 7 px + passo mínimo 46 px deixam ≥26 px livres entre marcadores de 20
px. Acima de 12, período desalinhado ou valor ausente → perguntar; nunca apertar,
interpolar ou omitir. Esta extensão não certifica quadrado nem story.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Mapa de tons · sem depender de cor</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Bom/melhor =
<span class="rule-code">#e4102f + círculo r=8 + ↑ “amplia”</span>. Neutro =
<span class="rule-code">#78716c + losango 14×14 rotado 45° + ↗ “cresce”</span>.
Neutro escuro =
<span class="rule-code">#1c1917 + triângulo ▼ 20×18 + ↘ “diminui”</span>. Marcadores
são preenchidos porque os 36 pontos são observados. Azul
<span class="rule-code">#184e92</span> fica só na marca/kicker/régua, nunca em dado.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Convivência com a CENA 02B</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
O contrato de 2 séries não muda: bom = vermelho/círculo/“↑ amplia”; ruim =
<span class="rule-code">#78716c + quadrado + ↓ “recua”</span>; comparação sem tons
também permanece. Na variante de 3 séries, losango + ↗ “cresce” distinguem o neutro
municipal, e triângulo + ↘ “diminui” distinguem o neutro escuro privado. Não
converter direção em juízo nem reaproveitar “recua” fora do tom ruim da CENA 02B.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Plot e escala · feed 1080×1350</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Canvas padding <span class="rule-code">76 / 84 / 58</span>; headline 56 px;
subtítulo 27 px; plot margin-top 16. SVG
<span class="rule-code">viewBox 0 0 880 650</span>. Dados: x=52…560, y=56…544; topo
y=56; baseline y=544; grid y=56/300/544; domínio compartilhado 0…1.000. Gutter de
texto <span class="rule-code">x=600…880</span> (280 px), começando 40 px após o
último ponto. Linhas 7 px. O corredor x=560…600 pertence só aos leaders; texto nunca
invade o plot.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Gutter, leading e colisão</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Cada bloco cai de quatro para <strong>três linhas</strong>: nome; valor + variação;
seta + palavra. Fontes/line-box: nome <span class="rule-code">30/36 px</span>;
métrica <span class="rule-code">34/40 px</span>; valência
<span class="rule-code">30/36 px</span>. Baselines: Privada
<span class="rule-code">62/108/150</span>, Estadual
<span class="rule-code">258/304/346</span>, Municipal
<span class="rule-code">454/500/542</span>. O pitch é 196 px; descontados os
line-boxes, sobram 70+ px entre blocos. Ordenar pelo y do ponto final e alocar,
nessa ordem, as três lanes fixas; nunca aproximar blocos para seguir o ponto.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Leaders e rótulos finais</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Texto começa em <span class="rule-code">x=600</span>. Leader 3 px sai de x=570 no y
do ponto, passa pelo trilho x=580, dobra em x=590 até o baseline da métrica e
termina em x=594; sobra um respiro de 6 px antes do texto. Alvos:
<span class="rule-code">y=108/304/500</span>. Como finais e blocos têm a mesma
ordem, leaders não se cruzam. Nome em uma linha; se exceder 280 px, pedir forma
curta — nunca reduzir abaixo de 30 px, quebrar ou abreviar sozinho. A métrica usa
tabular-nums no formato literal “valor · ±N%”.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Eixo X · 12 pontos observados</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Pontos em x=
<span class="rule-code">52/98/144/191/237/283/329/375/421/468/514/560</span>
(passo ≈46 px). Fonte 30 px. Para 9–12 pontos, manter a regra da 02B: alternar desde
o primeiro; se o último não cair na alternância, remover o penúltimo exibido e fixar
o último. Aqui: <strong>2015, 2017, 2019, 2021, 2023, 2026</strong>. “2026” continua
simples no eixo; “último ponto: jul/2026” fica na nota. Nunca anexar “projeção”.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Variação observada e ausência de projeção</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
A variação compara 2015 ao último ponto observado, jul/2026: Estadual
<strong>235→369 · +57%</strong>; Municipal <strong>93→113 · +22%</strong>; Privada
<strong>832→510 · −39%</strong>. A gutter funde valor e variação e remove a palavra
“observado”; a nota carrega a precisão mensal. Proibidos nesta cena:
<span class="rule-code">projection-band</span>, tracejado, marcador vazado e label
“projeção”.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Copy e fonte · literais desta cena</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Manchete: “Hospital estadual puxa a ampliação de leitos de internação”. Subtítulo:
“Vitória da Conquista (BA) · leitos existentes · 2015–2026”. Fonte: “Ministério da
Saúde — CNES · leitos de internação por esfera jurídica”. Nota: “Último ponto:
jul/2026. Rede privada = leitos empresariais + sem fins lucrativos.”
</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 da extensão</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Chassi: <span class="rule-code">triple-line-plot</span>,
<span class="rule-code">triple-axis-label</span>. Séries/marcadores:
<span class="rule-code">triple-series-good / triple-markers-good</span>,
<span class="rule-code">triple-series-neutral / triple-markers-neutral</span> e
<span class="rule-code"
>triple-series-neutral-dark / triple-markers-neutral-dark</span
>. Saída: <span class="rule-code">triple-end-leader</span>,
<span class="rule-code">triple-end-name</span>,
<span class="rule-code">triple-end-metric</span> e
<span class="rule-code">triple-end-valence</span>. Reusar sem alteração no corpo:
<span class="rule-code">top-rule</span>,
<span class="rule-code">chart-context</span>,
<span class="rule-code">chart-headline</span>,
<span class="rule-code">chart-subtitle</span>,
<span class="rule-code">chart-footer</span>. No rodapé, aplicar a C206:
<span class="rule-code">source-line ≥25 px</span> e
<span class="rule-code">brand-logo-frame 251×144</span> no feed.
</p>
</div>
</div>
</div>
</section>
<!-- ================================================================== -->
<!-- CENA 02C · PROPOSTA DEGRADED — variante "delta" (barra de -->
<!-- variação por categoria). NÃO CERTIFICADA. Escrita no tier -->
<!-- degradado; exige sign-off humano antes do porte. As cenas -->
<!-- certificadas acima e abaixo permanecem intactas. -->
<!-- degradado; exige sign-off humano antes do porte. A base C203 e -->
<!-- todas as demais cenas permanecem intactas. -->
<!-- ================================================================== -->
<section
class="scene-card mt-10 border-2 border-dashed border-solla/60 p-4 sm:p-6 lg:p-8"
@ -2258,16 +2843,14 @@
<strong>Fonte:</strong> Ministério da Saúde — CNES<br />Nota: sem variação:
EMAD, EMAP, ENASF-AB e ECR.
</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 class="brand-logo-frame">
<img
src="../../public/campaign-kit/jorge-solla-positivo.png"
alt="Jorge Solla — Deputado Federal"
/>
</div>
</footer>
</div>
<span class="asset-flag">NEEDS ASSET · LOGO OFICIAL</span>
</article>
</div>
</div>
@ -2419,10 +3002,14 @@
<strong>Fonte:</strong> Ministério da Saúde — CNES · Nota: sem variação: EMAD,
EMAP, ENASF-AB e ECR.
</div>
<div class="brand-name">JORGE SOLLA</div>
<div class="brand-logo-frame">
<img
src="../../public/campaign-kit/jorge-solla-positivo.png"
alt="Jorge Solla — Deputado Federal"
/>
</div>
</footer>
</div>
<span class="asset-flag" style="bottom: 270px">NEEDS ASSET · LOGO OFICIAL</span>
</article>
</div>
</div>
@ -2529,8 +3116,7 @@
<span class="rule-code">chart-subtitle</span>,
<span class="rule-code">chart-footer</span>,
<span class="rule-code">source-line</span>,
<span class="rule-code">brand-lockup</span> e
<span class="rule-code">asset-flag</span>.
<span class="rule-code">brand-logo-frame</span> e o PNG oficial inteiro.
</p>
</div>
</div>
@ -2787,7 +3373,9 @@
<p class="text-sm font-bold">Densidade</p>
<p class="mt-1 text-sm leading-5 text-stone-600">
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.
até 12 pontos alinhados por série. Revisão C205 aprovada: linha de 3 séries no feed,
até 12 pontos alinhados por série (36 marcas). Story mantém 5 apenas para
barra/coluna; C205 não certifica quadrado nem story.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
@ -2798,6 +3386,15 @@
vermelhas quando as duas categorias forem explicitamente positivas.
</p>
</div>
<div class="rounded-xl border border-amber-300 bg-amber-50/70 p-4">
<p class="text-sm font-bold">Rodapé e marca · C206 aprovada</p>
<p class="mt-1 text-sm leading-5 text-stone-600">
Fonte/nota: 27 px no feed/story; 25 px no quadrado e nas cenas densas 02B/02D. Marca
oficial inteira: 251×144 (feed), 192×110 (quadrado), 279×160 (story). Rodapé mínimo:
171/128/190 px, respectivamente. Sem recorte, distorção, recoloração ou lockup
recriado.
</p>
</div>
</div>
</div>
</section>
@ -2807,9 +3404,10 @@
>
<span
>base C203 <strong>aprovada</strong> · extensão C191
<strong>certificada pelo designer e aprovada pelo humano</strong> · 2026-09-19</span
<strong>certificada pelo designer e aprovada pelo humano</strong> · C205 (supera a C204) +
C206 <strong>aprovadas no gate de 2026-09-19</strong></span
>
<span>Kit 1313 oficial · valência redundante · NEEDS ASSET resolvido</span>
<span>Kit 1313 oficial · valência redundante · marca ampliada · NEEDS ASSET resolvido</span>
</footer>
</main>
</body>

View file

@ -26,6 +26,7 @@ import {
MAX_POINTS,
MAX_POINTS_LINE,
MAX_POINTS_STORY,
MAX_SERIES,
RELATION_LABEL,
SIZES,
classifyRelation,
@ -59,25 +60,49 @@ 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`.
* `--good`/`--bad` name the series that carry the pair of the approved
* two-series variant; `--good`/`--neutral`/`--neutral-dark` name the triad of
* the three-series extension (C205). The vocabularies never mix: the tone flags
* enter together (the whole pair or the whole triad) or not at all, and
* `validateSpec` re-checks the contract.
*/
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 triad = series.length === MAX_SERIES
const allowed = triad ? ['good', 'neutral', 'neutral-dark'] : ['good', 'bad']
const informed = ['good', 'bad', 'medium', 'neutral', 'neutral-dark'].filter(
(tone) => flags[tone],
)
if (informed.length === 0) return series
if (informed.includes('medium')) {
die(
'o tom --medium saiu na revisão C205: use --neutral (neutro) e --neutral-dark (neutro escuro).',
)
}
for (const tone of informed) {
if (!allowed.includes(tone)) {
die(
triad
? `--${tone} é do par de duas séries: a linha de três séries usa --good, --neutral e --neutral-dark.`
: `--${tone} é do trio de três séries: a linha de duas séries usa --good e --bad.`,
)
}
}
if (informed.length !== allowed.length) {
die(`informe os tons juntos (${allowed.map((tone) => `--${tone}`).join(', ')}) ou nenhum.`)
}
const names = informed.map((tone) => String(flags[tone]))
if (new Set(names).size !== names.length) {
die(`os tons apontam para a mesma série: ${names.join(', ')}.`)
}
const known = series.map((serie) => serie.name)
for (const name of [good, bad].filter(Boolean)) {
for (const name of names) {
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 tone = allowed.find((candidate) => String(flags[candidate]) === serie.name)
return tone ? { ...serie, tone } : serie
})
}

View file

@ -24,12 +24,20 @@ 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.
* Two-series and three-series time comparison (approved variants): an annual
* series carries more points than a ranking, per series. One tone per series
* encodes the valence — the pair of C191 (good/bad) or the triad of the C205
* extension (good/neutral/neutral-dark, feed only, no projection and no
* crossing).
*/
export const MAX_SERIES = 2
export const MIN_SERIES = 2
export const MAX_SERIES = 3
export const MAX_POINTS_LINE = 12
const SERIES_TONES = ['good', 'bad']
const PAIR_TONES = ['good', 'bad']
const TRIAD_TONES = ['good', 'neutral', 'neutral-dark']
/** Allowed tone vocabulary of the informed series count (never a cross-vocabulary). */
const tonesFor = (count) => (count === MAX_SERIES ? TRIAD_TONES : PAIR_TONES)
/** Output canvases: feed 4:5 (default), square, stories/reels 9:16 with safe bands. */
export const SIZES = {
@ -157,7 +165,7 @@ const cleanMatrix = (matrix) =>
* turns into a series in silence.
*/
const looksLikeSeriesHeader = (header) =>
header.length >= MAX_SERIES + 1 &&
header.length >= MIN_SERIES + 1 &&
header.every((cell) => cell !== '' && parseNumber(cell) === null)
const detectSeries = (cleaned) => {
@ -413,23 +421,35 @@ export const classifyRelation = (rows, forcedType = null) => {
}
/**
* 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.
* Guardrails of the multi-series time line (approved variants): aligned
* periods, at most `MAX_POINTS_LINE` per series, honest zero base, the tone
* pair (2 series) or triad (3 series, C204 — feed only, no projection, no
* crossing) informed together 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).')
throw new Error('a comparação de 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.')
throw new Error('a linha multi-série marca a valência pelos tons — não use --highlight.')
}
if (series.length !== MAX_SERIES) {
if (series.length < MIN_SERIES || series.length > MAX_SERIES) {
throw new Error(
`a linha multi-série desenha ${MAX_SERIES} séries (${series.length} recebidas).`,
`a linha multi-série desenha ${MIN_SERIES} ou ${MAX_SERIES} séries (${series.length} recebidas).`,
)
}
if (series.length === MAX_SERIES) {
if (spec.projectedLabel) {
throw new Error('a linha de três séries não certifica projeção — remova --projected.')
}
if (spec.crossingLabel) {
throw new Error('a linha de três séries não certifica cruzamento — remova --crossing.')
}
if (spec.size && spec.size !== 'feed') {
throw new Error('a linha de três séries só está certificada no feed (1080×1350).')
}
}
const names = new Set()
for (const serie of series) {
if (typeof serie.name !== 'string' || serie.name.trim() === '')
@ -454,27 +474,38 @@ const validateSeries = (spec) => {
}
}
}
const [first, second] = series
const aligned =
first.rows.length === second.rows.length &&
first.rows.every((row, index) => row.label === second.rows[index].label)
const [first] = series
const aligned = series.every(
(serie) =>
serie.rows.length === first.rows.length &&
serie.rows.every((row, index) => row.label === first.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.')
throw new Error('a linha multi-série exige períodos no eixo (ex.: anos) — sem categorias.')
}
const allowedTones = tonesFor(series.length)
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.')
throw new Error(
allowedTones === TRIAD_TONES
? 'informe os três tons (melhor, neutro e neutro escuro) ou de nenhum.'
: '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 (!allowedTones.includes(tone)) {
throw new Error(`tom inválido: ${JSON.stringify(tone)} (use ${allowedTones.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 (informed.length === series.length && new Set(tones).size !== series.length) {
throw new Error(
allowedTones === TRIAD_TONES
? 'os três tons precisam ser distintos (melhor, neutro e neutro escuro).'
: '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

View file

@ -245,9 +245,10 @@ const niceMax = (value) => {
/**
* 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").
* (design: "2017, 2019, 2021, 2023 e 2026"). Shared by the two- and the
* three-series lines.
*/
const dualAxisIndices = (count) => {
const axisLabelIndices = (count) => {
if (count < 9) return [...Array(count).keys()]
const shown = new Set()
for (let index = 0; index < count; index += 2) shown.add(index)
@ -458,7 +459,7 @@ export const dualLineChart = ({
})()
: ''
const axis = dualAxisIndices(count)
const axis = axisLabelIndices(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>`,
@ -538,3 +539,158 @@ export const lineChart = ({
.join('')}
</svg>`
}
/**
* Geometry of the approved three-series time line (C205 extension, feed only).
* The data box is narrower than the two-series one (x=52…560) so the wider end
* gutter (x=600…880, 280px) holds three three-line blocks; x=560…600 belongs to
* the leaders only. Lanes: name / metric / valence with 30–40px line boxes and
* a 196px pitch (design: 62/108/150 · 258/304/346 · 454/500/542).
*/
const TRIPLE_GEOMETRY = {
feed: {
width: 880,
height: 650,
left: 52,
right: 560,
top: 56,
baseline: 544,
stroke: 7,
dotRadius: 8,
diamondSize: 14,
triangleWidth: 20,
triangleTop: 8,
triangleApex: 10,
axisSize: 30,
axisY: 636,
zeroX: 42,
zeroY: 538,
nameSize: 30,
metricSize: 34,
valenceSize: 30,
textX: 600,
leader: { start: 570, rail: 580, bend: 590, end: 594 },
laneOffsets: [62, 108, 150],
lanePitch: 196,
},
}
/**
* Three-series time line (C205 extension of the two-series variant): three
* observed series on a shared zero-based scale and the triad of redundant tones
* (melhor = red circle ↑ "amplia"; neutro = gray diamond ↗ "cresce"; neutro
* escuro = ink triangle ↘ "diminui") with a three-line end gutter. Every point
* is observed — no band, no dash, no hollow marker, no "projeção".
*
* `series` items are `{ name, tone: 'good'|'neutral'|'neutral-dark', rows }`;
* the caller owns the palette and the valence words (never color alone).
*/
export const tripleLineChart = ({ series, size = 'feed', colors, valence, format }) => {
const cfg = TRIPLE_GEOMETRY[size] ?? TRIPLE_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 lastIndex = count - 1
const marker = (tone, x, y) => {
if (tone === 'neutral') {
const half = cfg.diamondSize / 2
return `<rect x="${x - half}" y="${y - half}" width="${cfg.diamondSize}" height="${cfg.diamondSize}" transform="rotate(45 ${x} ${y})" />`
}
if (tone === 'neutral-dark') {
const half = cfg.triangleWidth / 2
return `<path d="M${x - half} ${y - cfg.triangleTop} L${x + half} ${y - cfg.triangleTop} L${x} ${y + cfg.triangleApex} Z" />`
}
return `<circle cx="${x}" cy="${y}" r="${cfg.dotRadius}" />`
}
const gridLines = `<line class="triple-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="triple-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="triple-zero-line" x1="${cfg.left}" y1="${cfg.baseline}" x2="${cfg.right}" y2="${cfg.baseline}" stroke="${colors.grid}" stroke-width="2" />
<text x="${cfg.zeroX}" y="${cfg.zeroY}" text-anchor="end" class="triple-axis-label" font-size="${cfg.axisSize}">0</text>`
const lines = series
.map((serie) => {
const color = colors[serie.tone] ?? colors.neutral
const points = serie.rows.map((row, index) => ({ x: xAt(index), y: yAt(row.value) }))
return `<polyline class="triple-series triple-series-${serie.tone}" points="${points.map((point) => `${point.x},${point.y}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${cfg.stroke}" stroke-linecap="round" stroke-linejoin="round" />
<g class="triple-markers-${serie.tone}" fill="${color}">${points.map((point) => marker(serie.tone, point.x, point.y)).join('')}</g>`
})
.join('')
const changeOf = (serie) => {
const firstValue = serie.rows[0].value
const observed = serie.rows[lastIndex].value
if (!(firstValue > 0) || !Number.isFinite(observed)) return null
const percent = Math.round(((observed - firstValue) / firstValue) * 100)
const sign = percent > 0 ? '+' : percent < 0 ? '−' : ''
return { percent, percentLabel: `${sign}${Math.abs(percent)}%` }
}
const blocks = series
.map((serie) => ({
serie,
color: colors[serie.tone] ?? colors.neutral,
change: changeOf(serie),
point: { y: yAt(serie.rows[lastIndex].value) },
}))
.sort((a, b) => a.point.y - b.point.y)
.map((block, lane) => ({
...block,
baselines: cfg.laneOffsets.map((offset) => offset + lane * cfg.lanePitch),
}))
const endBlocks = blocks
.map((block) => {
const { baselines, point } = block
const metric = `${format(block.serie.rows[lastIndex])}${
block.change ? ` · ${block.change.percentLabel}` : ''
}`
const text = (className, size, fill, value, index) =>
`<text x="${cfg.textX}" y="${baselines[index]}" class="${className}" font-size="${size}"${fill ? ` fill="${fill}"` : ''}>${htmlEscape(value)}</text>`
return [
`<polyline class="triple-end-leader" points="${cfg.leader.start},${point.y} ${cfg.leader.rail},${point.y} ${cfg.leader.bend},${baselines[1]} ${cfg.leader.end},${baselines[1]}" fill="none" stroke="${block.color}" stroke-width="3" />`,
text('triple-end-name', cfg.nameSize, null, block.serie.name, 0),
text('triple-end-metric', cfg.metricSize, null, metric, 1),
text(
'triple-end-valence',
cfg.valenceSize,
block.color,
valence[block.serie.tone] ?? '',
2,
),
].join('')
})
.join('')
const axis = axisLabelIndices(count)
.map(
(index) =>
`<text x="${xAt(index)}" y="${cfg.axisY}" text-anchor="middle" class="triple-axis-label" font-size="${cfg.axisSize}">${htmlEscape(series[0].rows[index].label)}</text>`,
)
.join('')
const ariaLabel = `${series
.map((serie) => {
const change = changeOf(serie)
const direction = !change
? ''
: change.percent > 0
? 'aumento'
: change.percent < 0
? 'diminuição'
: 'sem variação'
const trend = change ? `, ${direction} de ${Math.abs(change.percent)} por cento` : ''
return `${serie.name}: de ${format(serie.rows[0])} em ${serie.rows[0].label} para ${format(serie.rows[lastIndex])} em ${serie.rows[lastIndex].label}${trend}`
})
.join('. ')}. Todos os pontos de cada série são observados.`
return `<svg class="chart triple-chart" viewBox="0 0 ${cfg.width} ${cfg.height}" role="img" aria-label="${htmlEscape(ariaLabel)}" preserveAspectRatio="xMidYMid meet">
${gridLines}
${lines}
${endBlocks}
${axis}
</svg>`
}

View file

@ -10,7 +10,12 @@
*/
import { RELATION_LABEL, SERIES_RELATION_LABEL, SIZES } from './chartData.mjs'
import { dualLineChart, lineChart, proportionalPercent } from './chartPrimitives.mjs'
import {
dualLineChart,
lineChart,
proportionalPercent,
tripleLineChart,
} from './chartPrimitives.mjs'
import { htmlEscape } from './reportText.mjs'
/** Official kit 1313 palette, mapped by role (C203). */
@ -180,9 +185,37 @@ const dualBody = (spec) => {
})}</div>`
}
/**
* Three-series time line (approved C205 extension of the two-series variant,
* feed only): melhor = red + circle + "↑ amplia"; neutro = gray + diamond +
* "↗ cresce"; neutro escuro = ink + triangle + "↘ diminui" (factual direction,
* never the bad valence "↓ recua"). Every tone travels by word + arrow + shape
* + color, and the brand blue leaves the data entirely.
*/
const TRIPLE_COLORS = {
good: SOLLA_PALETTE.highlight,
neutral: SOLLA_PALETTE.axis,
'neutral-dark': SOLLA_PALETTE.ink,
grid: SOLLA_PALETTE.barStrong,
ink: SOLLA_PALETTE.ink,
}
const TRIPLE_VALENCE = { good: '↑ amplia', neutral: '↗ cresce', 'neutral-dark': '↘ diminui' }
const tripleBody = (spec) =>
`<div class="triple-line-plot">${tripleLineChart({
series: spec.series,
size: 'feed',
colors: TRIPLE_COLORS,
valence: TRIPLE_VALENCE,
format: (row) => formatValue(row.value),
})}</div>`
const plotBody = (spec) => {
if (spec.chartType === 'delta') return deltaBody(spec)
if (Array.isArray(spec.series) && spec.series.length > 0) return dualBody(spec)
const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0
if (seriesCount === 3) return tripleBody(spec)
if (seriesCount > 0) return dualBody(spec)
if (spec.chartType === 'anchor') return anchorBody(spec)
if (spec.chartType === 'column') return columnBody(spec)
if (spec.chartType === 'line') {
@ -316,22 +349,28 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
.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; }
.triple-line-plot { flex: none; }
.triple-line-plot svg { display: block; width: 880px; height: auto; }
.triple-grid-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-opacity: 0.32; stroke-width: 2; }
.triple-zero-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; }
.triple-axis-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 650; }
.triple-end-name { fill: ${SOLLA_PALETTE.ink}; font-weight: 700; }
.triple-end-metric { fill: ${SOLLA_PALETTE.ink}; font-weight: 850; font-variant-numeric: tabular-nums; }
.triple-end-valence { font-weight: 850; }
.triple-end-leader { fill: none; stroke-width: 3; stroke-linecap: square; stroke-linejoin: round; }
.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; }
.footer {
margin-top: auto; padding-top: 25px; border-top: 2px solid ${SOLLA_PALETTE.hairline};
display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 30px;
min-height: 171px;
}
.source { margin: 0; color: ${SOLLA_PALETTE.label}; font-size: 27px; line-height: 1.32; }
.source strong { color: ${SOLLA_PALETTE.ink}; font-weight: 750; }
.brand-logo-frame {
width: 286px; height: 90px; flex: 0 0 286px;
display: flex; align-items: center; justify-content: flex-end;
}
.brand-logo-frame { width: 251px; height: 144px; flex: 0 0 251px; }
.brand-logo-frame img {
display: block; max-width: 100%; max-height: 100%; width: auto; height: auto;
object-fit: contain;
display: block; width: 100%; height: 100%; object-fit: contain;
}`
/** Per-size vertical rhythm of the template (feed 4:5, square, stories 9:16). */
@ -384,19 +423,30 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
const canvas = SIZES[sizeKey]
const layout = layoutFor(sizeKey)
const dual = Array.isArray(spec.series) && spec.series.length > 0
const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0
const dual = seriesCount === 2
const triple = seriesCount === 3
const dualPositive = Boolean(spec.dualPositive)
const dualPositiveStyles =
!dualPositive || sizeKey !== 'feed'
? ''
: '.headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; }'
const dualStyles = !dual
? ''
: sizeKey === 'feed'
? '.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 dualStyles =
!dual && !triple
? ''
: triple
? '.headline { font-size: 56px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 27px; } .source { max-width: 570px; font-size: 25px; } .plot { margin-top: 16px; }'
: 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 footerStyles =
sizeKey === 'square'
? '.footer { min-height: 128px; padding-top: 16px; } .source { font-size: 25px; } .brand-logo-frame { width: 192px; height: 110px; flex-basis: 192px; }'
: sizeKey === 'story'
? '.footer { min-height: 190px; padding-top: 28px; gap: 28px; } .brand-logo-frame { width: 279px; height: 160px; flex-basis: 279px; }'
: ''
const delta = spec.chartType === 'delta'
const deltaStyles = !delta
? ''
@ -406,7 +456,8 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
? '.inner { padding: 56px 84px 48px; } .plot { margin-top: 0; } .headline { font-size: 54px; } .subtitle { font-size: 28px; } .delta-grade { margin-top: 24px; --delta-bar-h: 42px; --delta-gap: 12px; --delta-v1-w: 84px; --delta-v2-w: 104px; } .delta-value { font-size: 30px; }'
: '.plot { margin-top: 0; } .headline { font-size: 64px; } .delta-grade { margin-top: 40px; --delta-gap: 22px; --delta-bar-h: 52px; }'
const kicker =
spec.kicker ?? (dual ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico'))
spec.kicker ??
(dual || triple ? 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>
@ -422,13 +473,14 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
.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; }
${footerStyles}
${dualStyles}
${deltaStyles}
${dualPositiveStyles}
</style>
</head>
<body>
<article class="canvas${dual ? ' dual-series' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<article class="canvas${dual ? ' dual-series' : ''}${triple ? ' triple-series' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<div class="inner">
<div class="top-rule"></div>
<p class="context">${htmlEscape(kicker)}</p>

View file

@ -368,6 +368,110 @@ describe('main — spec assembly, replay and the per-size log', () => {
})
})
it('assembles the three-series spec with the informed triad (C205)', async () => {
const input = await writeInput(
dir,
'Ano,Estadual,Municipal,Privada\n2015,235,93,832\n2016,235,93,724\n2017,256,105,707\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const outPath = join(dir, 'triple.png')
await main({
argv: [
`--in=${input}`,
'--type=line',
'--headline=Redes públicas ampliam leitos; a privada recua',
'--source=Ministério da Saúde — CNES',
'--good=Estadual',
'--neutral=Municipal',
'--neutral-dark=Privada',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(screenshot).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ width: 1080, height: 1350, outPath }),
)
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(
dir,
'data/graficos-instagram/redes-publicas-ampliam-leitos-a-privada-recua.chart-spec.json',
),
'utf8',
),
)
expect(
spec.series.map((serie: { name: string; tone: string }) => [serie.name, serie.tone]),
).toEqual([
['Estadual', 'good'],
['Municipal', 'neutral'],
['Privada', 'neutral-dark'],
])
expect(stdout.join('')).toContain('tipo=line')
expect(stdout.join('')).toContain('3 séries × 3')
})
it('refuses an incomplete triad and a 2-series tone on the three-series line', async () => {
const input = await writeInput(dir, 'Ano,Estadual,Municipal,Privada\n2015,1,2,3\n2016,2,2,2\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${input}`, '--headline=X', '--source=Y', '--good=Estadual'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/juntos/)
await expect(
main({
argv: [`--in=${input}`, '--headline=X', '--source=Y', '--good=Estadual', '--bad=Municipal'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/par de duas séries/)
expect(launchBrowser).not.toHaveBeenCalled()
})
it('retires --medium and keeps the vocabularies apart', async () => {
const tripleInput = await writeInput(
dir,
'Ano,Estadual,Municipal,Privada\n2015,1,2,3\n2016,2,2,2\n',
)
const pairInput = await writeInput(dir, 'Ano,Estadual,Municipal\n2017,1,2\n2018,2,1\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${tripleInput}`, '--headline=X', '--source=Y', '--medium=Estadual'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/saiu na revisão C205/)
await expect(
main({
argv: [`--in=${pairInput}`, '--headline=X', '--source=Y', '--neutral=Estadual'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/trio de três séries/)
expect(launchBrowser).not.toHaveBeenCalled()
})
it('replays a --spec without reparsing the input and closes the browser', async () => {
const specPath = join(dir, 'replay.chart-spec.json')
await writeFile(

View file

@ -6,6 +6,7 @@ import {
MAX_POINTS_LINE,
MAX_POINTS_STORY,
MAX_SERIES,
MIN_SERIES,
RELATION_LABEL,
SIZES,
classifyRelation,
@ -288,6 +289,16 @@ describe('parseInput — two aligned measures become series, not a ranking', ()
expect(dataset.issues).toEqual([])
})
it('reads a time table with three measures as three series (C204)', () => {
const dataset = parseInput({
text: 'Ano,Estadual,Municipal,Privada\n2015,235,93,832\n2016,235,93,724\n',
format: 'csv',
})
expect(dataset.series).toHaveLength(3)
expect(dataset.series?.[2]).toMatchObject({ name: 'Privada' })
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()
@ -372,10 +383,12 @@ describe('validateSpec — the two-series time line', () => {
expect(() => validateSpec(seriesSpec({ chartType: 'bar' }))).toThrow(/linha/)
})
it(`refuses more than ${MAX_SERIES} series`, () => {
it(`refuses fewer than ${MIN_SERIES} and more than ${MAX_SERIES} series`, () => {
const [first, second] = seriesSpec().series
const third = { ...first, name: 'Terceira' }
expect(() => validateSpec(seriesSpec({ series: [first] }))).toThrow(/séries/)
expect(() =>
validateSpec(seriesSpec({ series: [first, second, { ...first, name: 'Total' }] })),
validateSpec(seriesSpec({ series: [first, second, third, { ...third, name: 'Quarta' }] })),
).toThrow(/séries/)
})
@ -466,6 +479,87 @@ describe('validateSpec — the two-series time line', () => {
})
})
describe('validateSpec — the three-series time line (C205 extension)', () => {
const annual = (values: number[]) =>
values.map((value, index) => ({ label: `${2015 + index}`, value }))
const tripleSpec = (overrides = {}) => ({
chartType: 'line',
size: 'feed',
headline: 'Hospital estadual puxa a ampliação de leitos de internação',
series: [
{
name: 'Hospital Estadual',
tone: 'good',
rows: annual([235, 235, 256, 256, 256, 256, 256, 256, 369, 369, 369, 369]),
},
{
name: 'Rede municipal',
tone: 'neutral',
rows: annual([93, 93, 105, 93, 93, 93, 93, 93, 113, 113, 113, 113]),
},
{
name: 'Rede privada',
tone: 'neutral-dark',
rows: annual([832, 724, 707, 644, 521, 496, 459, 489, 508, 536, 500, 510]),
},
],
...overrides,
})
it('accepts three aligned temporal series with the informed triad', () => {
expect(validateSpec(tripleSpec())).toBeTruthy()
})
it('accepts the twelve points per series of the certified extension', () => {
expect(tripleSpec().series[0].rows).toHaveLength(MAX_POINTS_LINE)
})
it('refuses a partial or repeated triad', () => {
const [first, second, third] = tripleSpec().series
expect(() =>
validateSpec(tripleSpec({ series: [{ ...first, tone: undefined }, second, third] })),
).toThrow(/três tons/)
expect(() =>
validateSpec(tripleSpec({ series: [{ ...first, tone: 'neutral' }, second, third] })),
).toThrow(/distintos/)
expect(() =>
validateSpec(tripleSpec({ series: [first, second, { ...third, tone: 'bad' }] })),
).toThrow(/tom inválido/)
expect(() =>
validateSpec(tripleSpec({ series: [first, second, { ...third, tone: 'medium' }] })),
).toThrow(/tom inválido/)
})
it('refuses projection and crossing on the three-series extension', () => {
expect(() => validateSpec(tripleSpec({ projectedLabel: '2026' }))).toThrow(/projeção/)
expect(() => validateSpec(tripleSpec({ crossingLabel: '2026' }))).toThrow(/cruzamento/)
})
it('certifies the three-series line on the feed canvas only', () => {
expect(validateSpec(tripleSpec({ size: 'feed' }))).toBeTruthy()
expect(() => validateSpec(tripleSpec({ size: 'square' }))).toThrow(/feed/)
expect(() => validateSpec(tripleSpec({ size: 'story' }))).toThrow(/feed/)
})
it('keeps the alignment, temporal and ceiling guardrails of the pair', () => {
const [first, second, third] = tripleSpec().series
const drifted = {
...third,
rows: third.rows.map((row, index) => (index === 0 ? { ...row, label: '2014' } : row)),
}
expect(() => validateSpec(tripleSpec({ series: [first, second, drifted] }))).toThrow(
/mesmos períodos/,
)
const rows = Array.from({ length: MAX_POINTS_LINE + 1 }, (_v, index) => ({
label: `${2000 + index}`,
value: index + 1,
}))
expect(() => validateSpec(tripleSpec({ series: [{ ...first, rows }, second, third] }))).toThrow(
/resuma/,
)
})
})
describe('validateSpec — the variation bar (delta)', () => {
const deltaSpec = (overrides = {}) => ({
chartType: 'delta',

View file

@ -8,6 +8,7 @@ import {
lineChart,
proportionalPercent,
stackedColumnChart,
tripleLineChart,
valueList,
} from '../../scripts/lib/chartPrimitives.mjs'
@ -294,3 +295,111 @@ describe('dualLineChart — the approved two-series time line', () => {
expect(evil).toContain('&lt;b&gt;x&lt;/b&gt;')
})
})
describe('tripleLineChart — the approved three-series extension (C205, feed)', () => {
const annual = (values: number[]) =>
values.map((value, index) => ({ label: `${2015 + index}`, value }))
const series = [
{
name: 'Hospital Estadual',
tone: 'good',
rows: annual([235, 235, 256, 256, 256, 256, 256, 256, 369, 369, 369, 369]),
},
{
name: 'Rede municipal',
tone: 'neutral',
rows: annual([93, 93, 105, 93, 93, 93, 93, 93, 113, 113, 113, 113]),
},
{
name: 'Rede privada',
tone: 'neutral-dark',
rows: annual([832, 724, 707, 644, 521, 496, 459, 489, 508, 536, 500, 510]),
},
]
const colors = {
good: '#e4102f',
neutral: '#78716c',
'neutral-dark': '#1c1917',
grid: '#a8a29e',
ink: '#1c1917',
}
const valence = { good: '↑ amplia', neutral: '↗ cresce', 'neutral-dark': '↘ diminui' }
const format = (row: { value: number }) =>
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value)
const svg = tripleLineChart({ series, colors, valence, format })
it('draws the triad with color, word and one marker shape per tone', () => {
expect(svg).toContain('class="triple-series triple-series-good"')
expect(svg).toContain('stroke="#e4102f"')
expect(svg).toContain('class="triple-series triple-series-neutral"')
expect(svg).toContain('stroke="#78716c"')
expect(svg).toContain('class="triple-series triple-series-neutral-dark"')
expect(svg).toContain('stroke="#1c1917"')
expect(svg).toContain('<g class="triple-markers-good" fill="#e4102f"><circle')
expect(svg).toContain('<g class="triple-markers-neutral" fill="#78716c"><rect')
expect(svg).toContain('transform="rotate(45 52 499)"')
expect(svg).toContain('<g class="triple-markers-neutral-dark" fill="#1c1917"><path')
expect(svg).toContain('>↑ amplia<')
expect(svg).toContain('>↗ cresce<')
expect(svg).toContain('>↘ diminui<')
expect(svg).not.toContain('#184e92')
})
it('stacks the three-line end blocks by final point, metric and valence', () => {
expect(svg).toContain('>Rede privada<')
expect(svg).toContain('>510 · −39%<')
expect(svg).toContain('>Hospital Estadual<')
expect(svg).toContain('>369 · +57%<')
expect(svg).toContain('>Rede municipal<')
expect(svg).toContain('>113 · +22%<')
expect(svg.indexOf('>Rede privada<')).toBeLessThan(svg.indexOf('>Hospital Estadual<'))
expect(svg.indexOf('>Hospital Estadual<')).toBeLessThan(svg.indexOf('>Rede municipal<'))
})
it('keeps the wider data box, the shared zero base and the leader band', () => {
expect(svg).toContain('viewBox="0 0 880 650"')
expect(svg).toContain('x2="560"')
expect(svg).toContain('y1="544"')
expect(svg).toContain('>0<')
expect(svg.match(/class="triple-grid-line"/g)).toHaveLength(2)
expect(svg).toContain('points="570,295 580,295 590,108 594,108"')
expect(svg).toContain('class="triple-end-leader"')
expect(svg).toContain('x="600"')
})
it('draws every point observed — no projection artifact', () => {
expect(svg).not.toContain('dasharray')
expect(svg).not.toContain('projeção')
expect(svg).not.toContain('fill-opacity')
expect((svg.match(/class="triple-series/g) ?? []).length).toBe(3)
})
it('alternates the twelve annual labels, always keeping the first and the last', () => {
for (const label of ['2015', '2017', '2019', '2021', '2023', '2026']) {
expect(svg).toContain(`>${label}<`)
}
for (const label of ['2016', '2018', '2020', '2022', '2025']) {
expect(svg).not.toContain(`>${label}<`)
}
})
it('carries the observed series in the aria label', () => {
expect(svg).toContain(
'Hospital Estadual: de 235 em 2015 para 369 em 2026, aumento de 57 por cento',
)
expect(svg).toContain(
'Rede privada: de 832 em 2015 para 510 em 2026, diminuição de 39 por cento',
)
expect(svg).toContain('Todos os pontos de cada série são observados')
})
it('escapes untrusted series names', () => {
const evil = tripleLineChart({
series: [{ ...series[0], name: '<b>x</b>' }, series[1], series[2]],
colors,
valence,
format,
})
expect(evil).toContain('&lt;b&gt;x&lt;/b&gt;')
})
})

View file

@ -49,6 +49,21 @@ describe('skill /graficos-dados documents the flow', () => {
expect(skill).toContain('#184e92')
expect(skill).not.toContain('#c51414')
})
it('pins the three-series extension and its triad (C205)', () => {
expect(skill).toMatch(/três séries/)
expect(skill).toContain('--neutral=')
expect(skill).toContain('--neutral-dark=')
expect(skill).toContain('↗ cresce')
expect(skill).toContain('↘ diminui')
expect(skill).toMatch(/só no feed/)
})
it('pins the larger brand mark of the shared footer (C206)', () => {
expect(skill).toContain('251×144')
expect(skill).toContain('192×110')
expect(skill).toContain('279×160')
})
})
describe('command /graficos-dados couples to the skill', () => {

View file

@ -338,3 +338,95 @@ describe('renderChartHtml — the variation bar (delta)', () => {
expect((story.match(/class="delta-row/g) ?? []).length).toBe(deltaRows.length)
})
})
// C205 approved extension: the three-series observed line runs on the feed
// canvas only, with the triad good/neutral/neutral-dark (red circle "↑ amplia",
// gray diamond "↗ cresce", ink triangle "↘ diminui") and no projection.
const tripleAnnual = (values: number[]) =>
values.map((value, index) => ({ label: `${2015 + index}`, value }))
const tripleSpec = (overrides = {}) => ({
chartType: 'line',
size: 'feed',
headline: 'Hospital estadual puxa a ampliação de leitos de internação',
subtitle: 'Vitória da Conquista (BA) · leitos existentes · 2015–2026',
source: 'Ministério da Saúde — CNES · leitos de internação por esfera jurídica',
note: 'Último ponto: jul/2026. Rede privada = leitos empresariais + sem fins lucrativos.',
series: [
{
name: 'Hospital Estadual',
tone: 'good',
rows: tripleAnnual([235, 235, 256, 256, 256, 256, 256, 256, 369, 369, 369, 369]),
},
{
name: 'Rede municipal',
tone: 'neutral',
rows: tripleAnnual([93, 93, 105, 93, 93, 93, 93, 93, 113, 113, 113, 113]),
},
{
name: 'Rede privada',
tone: 'neutral-dark',
rows: tripleAnnual([832, 724, 707, 644, 521, 496, 459, 489, 508, 536, 500, 510]),
},
],
...overrides,
})
describe('renderChartHtml — the three-series time line (C205)', () => {
it('renders the triple plot with the fixed kicker and the three valence words', () => {
const html = render(tripleSpec())
expect(html).toContain('triple-line-plot')
expect(html).toContain('canvas triple-series')
expect(html).toContain('Comparação no tempo')
expect(html).toContain('↑ amplia')
expect(html).toContain('↗ cresce')
expect(html).toContain('↘ diminui')
expect(html).not.toContain('↓ recua')
const svg = html.slice(html.indexOf('<svg'), html.indexOf('</svg>'))
expect(svg).not.toContain('#184e92')
expect(html).toContain('369 · +57%')
expect(html).toContain('510 · −39%')
})
it('uses the compact feed rhythm of the extension', () => {
const html = render(tripleSpec())
expect(html).toContain('font-size: 56px')
expect(html).toContain('.plot { margin-top: 16px; }')
expect(html).toContain('max-width: 570px')
})
it('keeps every projection artifact out of the observed series', () => {
const html = render(tripleSpec())
const svg = html.slice(html.indexOf('<svg'), html.indexOf('</svg>'))
expect(svg).not.toContain('dasharray')
expect(svg).not.toContain('projeção')
expect(svg).not.toContain('fill-opacity')
})
it('carries the source, the monthly note and the official mark inside the image', () => {
const html = render(tripleSpec())
expect(html).toContain('Ministério da Saúde — CNES')
expect(html).toContain('jul/2026')
expect(html).toContain('alt="Jorge Solla — Deputado Federal"')
expect(html).toContain('data:image/png;base64,')
})
})
describe('renderChartHtml — the shared footer (C206 brand mark)', () => {
it('hugs the official asset ratio on the feed canvas', () => {
const html = render(spec())
expect(html).toContain('min-height: 171px')
expect(html).toContain('.brand-logo-frame { width: 251px; height: 144px; flex: 0 0 251px; }')
expect(html).toContain('width: 100%; height: 100%; object-fit: contain;')
})
it('adapts the footer and the mark to square and to Stories', () => {
const square = render(spec({ size: 'square' }))
expect(square).toContain('min-height: 128px')
expect(square).toContain('width: 192px; height: 110px; flex-basis: 192px')
expect(square).toContain('.source { font-size: 25px; }')
const story = render(spec({ size: 'story' }))
expect(story).toContain('min-height: 190px')
expect(story).toContain('gap: 28px')
expect(story).toContain('width: 279px; height: 160px; flex-basis: 279px')
})
})