mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
feat(C191): linha de duas séries no gráfico de Instagram — valência bom/ruim, projeção e cruzamento
Skill, núcleo (chartData), desenho (dualLineChart/renderer), design CENA 02B certificado e testes.
This commit is contained in:
parent
a8a5ed324f
commit
6883aff9f3
10 changed files with 1787 additions and 55 deletions
|
|
@ -37,12 +37,17 @@ O template visual é o artefato aprovado
|
|||
```bash
|
||||
node scripts/build-chart-from-data.mjs --in=<arquivo> --inspect
|
||||
```
|
||||
Devolve `{ rows, format, issues }` — a matriz parseada e os problemas.
|
||||
Devolve `{ rows, series, format, issues }` — a matriz parseada (tabela com
|
||||
2+ colunas de medida vira `series`) e os problemas.
|
||||
3. **Decida o tipo e a copy.** A relação dos dados manda:
|
||||
- **ranking / comparação → `bar`** (barras horizontais, ordenadas pelo valor,
|
||||
rótulo direto e valor na ponta);
|
||||
- **poucos períodos (≤4) → `column`** (base zero comum);
|
||||
- **série de tempo contínua → `line`** (ponto final destacado);
|
||||
- **duas séries no mesmo período → `line` multi-série**: tabela com 2 colunas
|
||||
de medida e a primeira coluna temporal; `--good=`/`--bad=` marcam a valência
|
||||
bom/ruim (par ou nenhum), `--projected=` marca o último período projetado e
|
||||
`--crossing=<período>` confirma a ultrapassagem que ganha anotação;
|
||||
- **uma única medida → `anchor`** (número grande + frase que explica).
|
||||
Ambíguo (mais de um tipo plausível) → **pergunte**; não escolha no escuro.
|
||||
Escreva a **manchete = takeaway** (não "Gráfico de X"), subtítulo opcional e a
|
||||
|
|
@ -55,12 +60,22 @@ O template visual é o artefato aprovado
|
|||
--source="TSE 2022" --type=bar --size=feed \
|
||||
--highlight="Ilhéus"
|
||||
```
|
||||
Duas séries no tempo (valência bom/ruim + projeção no último ponto):
|
||||
```bash
|
||||
node scripts/build-chart-from-data.mjs --in=<arquivo> \
|
||||
--headline="Internações crescem no hospital estadual e recuam no municipal" \
|
||||
--source="Ministério da Saúde — SIH/SUS" \
|
||||
--note="2026 é projeção pela média de janeiro a junho × 2." \
|
||||
--type=line --size=feed \
|
||||
--good="Estadual" --bad="Municipal" --projected=2026 --crossing=2024
|
||||
```
|
||||
Saída default: `docs/research/graficos-instagram/<slug>-<data>-<size>.png`
|
||||
(gitignored) + o `chart-spec.json` em `data/graficos-instagram/`. `--size`:
|
||||
`feed` (1080×1350, default), `square` (1080×1080), `story` (1080×1920, com
|
||||
faixas seguras de 250px no topo e na base). `--out=` sobrescreve o caminho.
|
||||
5. **Confira e ajuste.** A pessoa confere o PNG; ajuste tipo/rótulo/manchete/
|
||||
tamanho/`--highlight` e regenere. **Replay** sem reparse: `--spec=<chart-spec.json>`.
|
||||
tamanho/`--highlight` (ou `--good`/`--bad`/`--projected`/`--crossing`) e
|
||||
regenere. **Replay** sem reparse: `--spec=<chart-spec.json>`.
|
||||
6. **Entregue o gráfico.** A comunicação finaliza a peça. O dado não persiste.
|
||||
|
||||
## Contrato do `chart-spec.json`
|
||||
|
|
@ -78,8 +93,32 @@ O template visual é o artefato aprovado
|
|||
}
|
||||
```
|
||||
|
||||
Na linha de 2 séries, `series` substitui `rows` e `projectedLabel` marca o
|
||||
último período projetado:
|
||||
|
||||
```json
|
||||
{
|
||||
"chartType": "line",
|
||||
"size": "feed",
|
||||
"headline": "Internações crescem no hospital estadual e recuam no municipal",
|
||||
"source": "Ministério da Saúde — SIH/SUS",
|
||||
"projectedLabel": "2026",
|
||||
"crossingLabel": "2024",
|
||||
"series": [
|
||||
{ "name": "Estadual", "tone": "good", "rows": [{ "label": "2017", "value": 9807 }] },
|
||||
{ "name": "Municipal", "tone": "bad", "rows": [{ "label": "2017", "value": 24002 }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`chartType` ∈ `bar | column | line | anchor`; `highlight` é o rótulo que recebe o
|
||||
único vermelho `#c51414` (default: o maior valor).
|
||||
único vermelho `#c51414` (default: o maior valor). Em `series`, `tone` ∈
|
||||
`good | bad` (os dois tons ou nenhum) e as duas séries compartilham os mesmos
|
||||
rótulos temporais; `rows` e `highlight` não entram nessa variante.
|
||||
`projectedLabel` marca a projeção no último período e `crossingLabel` só é aceito
|
||||
quando a série boa de fato ultrapassa a ruim naquele período (confirmação
|
||||
textual, nunca inferência automática). Os rótulos finais trazem a variação
|
||||
observada (primeiro → último ponto observado, fora a projeção).
|
||||
|
||||
## Guardrails (fail-closed)
|
||||
|
||||
|
|
@ -87,9 +126,23 @@ O builder **recusa** em vez de desenhar algo enganoso:
|
|||
|
||||
- **Barras e colunas partem do zero**; valor negativo é recusado; sem 3D/eixo
|
||||
truncado.
|
||||
- **Até 7 pontos** por gráfico; em **stories**, o ranking comporta **até 5**
|
||||
pontos (a adaptação vertical reduz um ponto, conforme o design aprovado).
|
||||
Acima disso, resuma as categorias e explique.
|
||||
- **Até 7 pontos** por gráfico (barra, coluna e linha simples); a **linha de 2
|
||||
séries** comporta **até 12 pontos por série**, sempre com os mesmos rótulos
|
||||
temporais alinhados. Em **stories**, o ranking comporta **até 5** pontos (a
|
||||
adaptação vertical reduz um ponto, conforme o design aprovado). Acima disso,
|
||||
resuma as categorias e explique.
|
||||
- **Valência só na linha de 2 séries**, em par (`good`/`bad`) e sempre por
|
||||
palavra + seta + forma do marcador + cor — **cor nunca carrega sozinha**:
|
||||
bom = vermelho `#c51414`, círculo, "↑ amplia" (o vermelho é a cor do
|
||||
mandato/PT e nunca marca a perda); ruim = cinza `#78716c`, quadrado,
|
||||
"↓ recua". Sem tons, a comparação é neutra: sem setas, sem palavras de
|
||||
valência e **sem vermelho no plot**.
|
||||
- **Projeção** (`--projected=`) só no último período das duas séries: último
|
||||
segmento tracejado, marcador final vazado e nota do método; sem projeção não
|
||||
há faixa, tracejado nem ressalva automática.
|
||||
- **Cruzamento** (`--crossing=<período>`) só com confirmação textual e quando a
|
||||
série boa realmente ultrapassa a ruim naquele período; a anotação nunca é
|
||||
inferida sozinha.
|
||||
- **Número-âncora:** a manchete é o takeaway (não repete o número) e o valor
|
||||
aparece uma vez, grande, seguido da frase que explica.
|
||||
- **Pizza não é gerada no v1** — pedido `pie` é recusado com sugestão de barras.
|
||||
|
|
@ -109,7 +162,11 @@ O builder **recusa** em vez de desenhar algo enganoso:
|
|||
|
||||
- **"dado ambíguo ou faltando"** → leia `issues` no JSON de saída: linha sem
|
||||
rótulo, valor não numérico, rótulo repetido. Pergunte à pessoa; não invente.
|
||||
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa.
|
||||
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. Na linha de 2
|
||||
séries o teto é 12 por série.
|
||||
- **Séries desalinhadas / tom ímpar / projeção fora do último ponto /
|
||||
cruzamento não confirmado** → o builder pede a correção com mensagem
|
||||
acionável; nunca interpola, completa nem infere ultrapassagem.
|
||||
- **"pizza não é gerada"** → ofereça barras horizontais.
|
||||
- **Número pt-BR:** `1.234,56` é lido como 1234,56; `1,5` como 1,5.
|
||||
**Separador único é sempre decimal:** `1.234` é lido como 1,234 e `1.500`
|
||||
|
|
@ -119,7 +176,8 @@ O builder **recusa** em vez de desenhar algo enganoso:
|
|||
|
||||
## Referências
|
||||
|
||||
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html`.
|
||||
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (inclui
|
||||
a variação certificada de linha de duas séries, com valência bom/ruim).
|
||||
- Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl:
|
||||
`docs/plans/graficos-dados-instagram-impl.md`.
|
||||
- Irmãos (mesmo padrão skill + command + builder local): C186/C187.
|
||||
|
|
|
|||
1
docs/changelog/2026-09-18-c191-duas-series.md
Normal file
1
docs/changelog/2026-09-18-c191-duas-series.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C191 — Linha de duas séries no gráfico de Instagram (comparação no tempo) (2026-09-18):** a comunicação agora compara **duas séries temporais no mesmo canvas** com valência explícita e honesta, mantendo os guardrails do C191. (1) **Skill** `.agents/skills/graficos-dados/SKILL.md`: tabela temporal com 2 colunas de medida vira multi-série (`--good=`/`--bad=`/`--projected=`/`--crossing=`), teto de **12 pontos por série** (barra/coluna seguem em 7; stories 5) e a regra de valência: o vermelho `#c51414` é a cor do mandato e marca o **caminho bom** — o ruim fica neutro (`#78716c`, quadrado, "↓ recua"), nunca a cor do PT na perda; palavra + seta + forma do marcador + cor (cor nunca sozinha). (2) **Núcleo puro** `scripts/lib/chartData.mjs`: detecção de série por cabeçalho, rótulos temporais alinhados, tons em par (`good`/`bad`), projeção só no último período, cruzamento só com confirmação textual e ultrapassagem real dos valores — tudo fail-closed (nada inferido). (3) **Dono do desenho** `scripts/lib/chartPrimitives.mjs`: `dualLineChart` (escala compartilhada no zero, teto legível, gutter de rótulos diretos com variação observada, projeção tracejada com marcador vazado, anotação de cruzamento ring+guia+caixa) e `graficosInstagramRender.mjs` com kicker "Comparação no tempo" e geometria por canvas (`880×620` feed, `880×440` quadrado, `880×700` story). (4) **Design** `docs/plans/graficos-dados-instagram-ui-design.html`: CENA 02B **certificada** pelo `designer` após reprovação do dono (vermelho marcava a perda; vazio morto no feed) e duas rodadas de correção (copy da caixa de cruzamento fora dos limites; gutter de rótulos espremido). Testes `chartData`/`chartPrimitives`/`graficosInstagramRender` atualizados; `pnpm test:unit` 3.854 verdes; prettier/typecheck/lint/knip limpos. Estreia: internações hospitalares de Vitória da Conquista 2017–2026 (Estadual × Municipal, projeção 2026, cruzamento 2024).
|
||||
|
|
@ -366,6 +366,106 @@
|
|||
font-weight: 750;
|
||||
}
|
||||
|
||||
.mini-dual-line {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.mini-dual-label {
|
||||
fill: var(--ink);
|
||||
font-size: 7px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.feed-canvas.dual-series .chart-headline {
|
||||
max-width: 900px;
|
||||
font-size: 60px;
|
||||
}
|
||||
.feed-canvas.dual-series .chart-subtitle {
|
||||
margin-top: 18px;
|
||||
font-size: 28px;
|
||||
}
|
||||
.feed-canvas.dual-series .source-line {
|
||||
font-size: 25px;
|
||||
}
|
||||
.dual-line-plot {
|
||||
flex: none;
|
||||
width: 880px;
|
||||
height: 620px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.dual-line-plot svg {
|
||||
display: block;
|
||||
width: 880px;
|
||||
height: 620px;
|
||||
}
|
||||
.dual-axis-label {
|
||||
fill: #78716c;
|
||||
font-size: 30px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.dual-projection-label {
|
||||
fill: #78716c;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dual-end-name {
|
||||
fill: var(--ink);
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dual-end-value {
|
||||
fill: var(--ink);
|
||||
font-size: 34px;
|
||||
font-weight: 850;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dual-end-valence {
|
||||
font-size: 30px;
|
||||
font-weight: 850;
|
||||
}
|
||||
.dual-end-change {
|
||||
fill: #57534e;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dual-crossing-guide {
|
||||
stroke: var(--solla);
|
||||
stroke-width: 2px;
|
||||
stroke-dasharray: 8 8;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.dual-crossing-ring {
|
||||
fill: var(--paper);
|
||||
stroke: var(--solla);
|
||||
stroke-width: 4px;
|
||||
}
|
||||
.dual-crossing-box {
|
||||
fill: var(--paper);
|
||||
stroke: #a8a29e;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
.dual-crossing-year {
|
||||
fill: var(--solla);
|
||||
font-size: 30px;
|
||||
font-weight: 850;
|
||||
}
|
||||
.dual-crossing-copy {
|
||||
fill: var(--ink);
|
||||
font-size: 28px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.rule-code {
|
||||
display: inline-flex;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #d6d3d1;
|
||||
border-radius: 5px;
|
||||
color: #44403c;
|
||||
background: #fafaf9;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.story-stage {
|
||||
width: 351px;
|
||||
height: 624px;
|
||||
|
|
@ -626,16 +726,16 @@
|
|||
<main class="mx-auto max-w-[1240px] px-4 py-8 sm:px-7 lg:py-12">
|
||||
<header class="max-w-4xl">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="scene-label">Gate visual · template v1</span>
|
||||
<span class="scene-label">Design certificado · C191</span>
|
||||
<span class="scene-label">Instagram feed + Stories</span>
|
||||
</div>
|
||||
<h1 class="mt-5 max-w-3xl text-3xl font-extrabold tracking-[-0.035em] text-ink sm:text-4xl">
|
||||
Gráficos claros, sóbrios e prontos para a comunicação
|
||||
</h1>
|
||||
<p class="mt-4 max-w-3xl text-[15px] leading-6 text-stone-600">
|
||||
A superfície desenhada é a imagem exportada — não uma tela de app. Os dados abaixo são
|
||||
deliberadamente genéricos; as peças fixam hierarquia, grid, tratamento de marca e limites
|
||||
do gerador.
|
||||
A superfície desenhada é a imagem exportada — não uma tela de app. Os exemplos-base são
|
||||
deliberadamente genéricos; a variante de duas séries usa o caso fornecido de Vitória da
|
||||
Conquista. As peças fixam hierarquia, grid, tratamento de marca e limites do gerador.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
|
@ -767,7 +867,7 @@
|
|||
<div>
|
||||
<p class="text-xs font-bold text-brand-dark">CENA 02 · SISTEMA</p>
|
||||
<h2 id="variations-title" class="mt-1 text-xl font-bold tracking-tight">
|
||||
Quatro relações, um mesmo template
|
||||
Cinco variações, um mesmo template
|
||||
</h2>
|
||||
</div>
|
||||
<p class="max-w-xl text-sm leading-5 text-stone-600">
|
||||
|
|
@ -903,6 +1003,558 @@
|
|||
Um valor grande + frase que explica.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="scene-card p-3">
|
||||
<article class="mini-canvas">
|
||||
<div class="mini-rule"></div>
|
||||
<div class="mini-kicker">Comparação no tempo</div>
|
||||
<h3 class="mini-title">Estadual cresce e supera o municipal</h3>
|
||||
<p class="mini-sub">Internações em Vitória da Conquista, 2017–2026.</p>
|
||||
<div class="mini-plot">
|
||||
<svg
|
||||
class="mini-dual-line"
|
||||
viewBox="0 0 220 130"
|
||||
role="img"
|
||||
aria-label="Duas séries de internações: hospital estadual cresce e supera o municipal em 2024; 2026 é projeção"
|
||||
>
|
||||
<line x1="8" y1="111" x2="151" y2="111" stroke="#a8a29e" stroke-width="1" />
|
||||
<line x1="8" y1="61" x2="151" y2="61" stroke="#e7e5e4" stroke-width="1" />
|
||||
<polyline
|
||||
points="10,72 26,68 42,61 58,66 74,55 90,47 106,49 122,28 138,23"
|
||||
fill="none"
|
||||
stroke="#c51414"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="138"
|
||||
y1="23"
|
||||
x2="151"
|
||||
y2="16"
|
||||
stroke="#c51414"
|
||||
stroke-width="3.5"
|
||||
stroke-dasharray="6 4"
|
||||
/>
|
||||
<polyline
|
||||
points="10,13 26,11 42,20 58,29 74,23 90,28 106,31 122,32 138,38"
|
||||
fill="none"
|
||||
stroke="#78716c"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="138"
|
||||
y1="38"
|
||||
x2="151"
|
||||
y2="50"
|
||||
stroke="#78716c"
|
||||
stroke-width="3"
|
||||
stroke-dasharray="6 4"
|
||||
/>
|
||||
<g fill="#c51414">
|
||||
<circle cx="10" cy="72" r="2.5" />
|
||||
<circle cx="26" cy="68" r="2.5" />
|
||||
<circle cx="42" cy="61" r="2.5" />
|
||||
<circle cx="58" cy="66" r="2.5" />
|
||||
<circle cx="74" cy="55" r="2.5" />
|
||||
<circle cx="90" cy="47" r="2.5" />
|
||||
<circle cx="106" cy="49" r="2.5" />
|
||||
<circle cx="122" cy="28" r="2.5" />
|
||||
<circle cx="138" cy="23" r="2.5" />
|
||||
</g>
|
||||
<g fill="#78716c">
|
||||
<rect x="7.5" y="10.5" width="5" height="5" />
|
||||
<rect x="23.5" y="8.5" width="5" height="5" />
|
||||
<rect x="39.5" y="17.5" width="5" height="5" />
|
||||
<rect x="55.5" y="26.5" width="5" height="5" />
|
||||
<rect x="71.5" y="20.5" width="5" height="5" />
|
||||
<rect x="87.5" y="25.5" width="5" height="5" />
|
||||
<rect x="103.5" y="28.5" width="5" height="5" />
|
||||
<rect x="119.5" y="29.5" width="5" height="5" />
|
||||
<rect x="135.5" y="35.5" width="5" height="5" />
|
||||
</g>
|
||||
<circle
|
||||
cx="151"
|
||||
cy="16"
|
||||
r="4"
|
||||
fill="#faf9f7"
|
||||
stroke="#c51414"
|
||||
stroke-width="2.5"
|
||||
/>
|
||||
<rect
|
||||
x="147"
|
||||
y="46"
|
||||
width="8"
|
||||
height="8"
|
||||
fill="#faf9f7"
|
||||
stroke="#78716c"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<text x="159" y="8" class="mini-dual-label">Estadual</text>
|
||||
<text x="159" y="17" class="mini-dual-label" style="fill: #c51414">
|
||||
23.294 · ↑ amplia
|
||||
</text>
|
||||
<text x="159" y="42" class="mini-dual-label">Municipal</text>
|
||||
<text x="159" y="51" class="mini-dual-label" style="fill: #78716c">
|
||||
15.948 · ↓ recua
|
||||
</text>
|
||||
<text
|
||||
x="151"
|
||||
y="126"
|
||||
text-anchor="middle"
|
||||
fill="#57534e"
|
||||
font-size="6.5"
|
||||
font-weight="700"
|
||||
>
|
||||
2026 · projeção
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<footer class="mini-footer">
|
||||
<span>Fonte: SIH/SUS</span><span class="mini-mark">JORGE SOLLA</span>
|
||||
</footer>
|
||||
</article>
|
||||
<p class="mt-3 text-sm font-bold">Linha · duas séries</p>
|
||||
<p class="mt-1 text-xs leading-4 text-stone-600">
|
||||
Tendências, valência redundante e projeção explícita.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene-card mt-10 p-4 sm:p-6 lg:p-8" aria-labelledby="dual-line-title">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-bold text-brand-dark">CENA 02B · VARIAÇÃO CERTIFICADA</p>
|
||||
<h2 id="dual-line-title" class="mt-1 text-xl font-bold tracking-tight">
|
||||
Feed 4:5 · linha temporal de duas séries
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="measure-tag"><span class="measure-dot"></span>1080 × 1350</span>
|
||||
<span class="measure-tag">Plot SVG · viewBox 880 × 620</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid items-start gap-7 lg:grid-cols-[594px_1fr]">
|
||||
<div class="overflow-x-auto pb-2">
|
||||
<div class="feed-stage">
|
||||
<article
|
||||
class="feed-canvas dual-series"
|
||||
aria-label="Internações crescem no hospital estadual e recuam no municipal em Vitória da Conquista, de 2017 a 2026; o ponto de 2026 é projeção"
|
||||
>
|
||||
<div class="feed-inner">
|
||||
<div class="top-rule"></div>
|
||||
<p class="chart-context">Comparação no tempo</p>
|
||||
<h3 class="chart-headline">
|
||||
Internações crescem no hospital estadual e recuam no municipal
|
||||
</h3>
|
||||
<p class="chart-subtitle">
|
||||
AIH aprovadas por ano · Vitória da Conquista (BA) · 2017–2026
|
||||
</p>
|
||||
|
||||
<div class="dual-line-plot tabular">
|
||||
<svg
|
||||
viewBox="0 0 880 620"
|
||||
role="img"
|
||||
aria-label="Hospital Estadual: de 9.807 internações em 2017 para projeção de 23.294 em 2026, alta observada de 121 por cento até 2025. Hospital Municipal: de 24.002 para projeção de 15.948, queda observada de 23 por cento até 2025. O estadual ultrapassa o municipal em 2024."
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<rect
|
||||
class="dual-projection-band"
|
||||
x="570"
|
||||
y="0"
|
||||
width="63"
|
||||
height="544"
|
||||
fill="#a8a29e"
|
||||
fill-opacity="0.16"
|
||||
/>
|
||||
<line
|
||||
class="dual-grid-line"
|
||||
x1="52"
|
||||
y1="56"
|
||||
x2="600"
|
||||
y2="56"
|
||||
stroke="#a8a29e"
|
||||
stroke-opacity="0.32"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
class="dual-grid-line"
|
||||
x1="52"
|
||||
y1="300"
|
||||
x2="600"
|
||||
y2="300"
|
||||
stroke="#a8a29e"
|
||||
stroke-opacity="0.32"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
class="dual-zero-line"
|
||||
x1="52"
|
||||
y1="544"
|
||||
x2="600"
|
||||
y2="544"
|
||||
stroke="#a8a29e"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<text x="42" y="538" text-anchor="end" class="dual-axis-label">0</text>
|
||||
<text x="624" y="45" text-anchor="end" class="dual-projection-label">
|
||||
projeção
|
||||
</text>
|
||||
|
||||
<line class="dual-crossing-guide" x1="478" y1="160" x2="478" y2="398" />
|
||||
|
||||
<polyline
|
||||
class="dual-series dual-series-good"
|
||||
points="52,352 113,336 174,314 235,331 296,288 356,256 417,261 478,144 539,122"
|
||||
fill="none"
|
||||
stroke="#c51414"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
class="dual-series-projection dual-series-good"
|
||||
x1="539"
|
||||
y1="122"
|
||||
x2="600"
|
||||
y2="89"
|
||||
stroke="#c51414"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="18 12"
|
||||
/>
|
||||
<polyline
|
||||
class="dual-series dual-series-bad"
|
||||
points="52,75 113,67 174,109 235,148 296,123 356,142 417,154 478,159 539,181"
|
||||
fill="none"
|
||||
stroke="#78716c"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
class="dual-series-projection dual-series-bad"
|
||||
x1="539"
|
||||
y1="181"
|
||||
x2="600"
|
||||
y2="233"
|
||||
stroke="#78716c"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="18 12"
|
||||
/>
|
||||
|
||||
<g class="dual-markers-good" fill="#c51414">
|
||||
<circle cx="52" cy="352" r="8" />
|
||||
<circle cx="113" cy="336" r="8" />
|
||||
<circle cx="174" cy="314" r="8" />
|
||||
<circle cx="235" cy="331" r="8" />
|
||||
<circle cx="296" cy="288" r="8" />
|
||||
<circle cx="356" cy="256" r="8" />
|
||||
<circle cx="417" cy="261" r="8" />
|
||||
<circle cx="478" cy="144" r="8" />
|
||||
<circle cx="539" cy="122" r="8" />
|
||||
</g>
|
||||
<g class="dual-markers-bad" fill="#78716c">
|
||||
<rect x="44" y="67" width="16" height="16" />
|
||||
<rect x="105" y="59" width="16" height="16" />
|
||||
<rect x="166" y="101" width="16" height="16" />
|
||||
<rect x="227" y="140" width="16" height="16" />
|
||||
<rect x="288" y="115" width="16" height="16" />
|
||||
<rect x="348" y="134" width="16" height="16" />
|
||||
<rect x="409" y="146" width="16" height="16" />
|
||||
<rect x="470" y="151" width="16" height="16" />
|
||||
<rect x="531" y="173" width="16" height="16" />
|
||||
</g>
|
||||
<circle
|
||||
class="dual-marker-projected dual-marker-projected-good"
|
||||
cx="600"
|
||||
cy="89"
|
||||
r="11"
|
||||
fill="#faf9f7"
|
||||
stroke="#c51414"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<rect
|
||||
class="dual-marker-projected dual-marker-projected-bad"
|
||||
x="590"
|
||||
y="223"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="#faf9f7"
|
||||
stroke="#78716c"
|
||||
stroke-width="5"
|
||||
/>
|
||||
|
||||
<circle class="dual-crossing-ring" cx="478" cy="144" r="14" />
|
||||
<circle cx="478" cy="144" r="7" fill="#c51414" />
|
||||
<rect
|
||||
class="dual-crossing-box"
|
||||
x="351"
|
||||
y="398"
|
||||
width="254"
|
||||
height="118"
|
||||
rx="8"
|
||||
/>
|
||||
<text x="478" y="431" text-anchor="middle" class="dual-crossing-year">
|
||||
2024
|
||||
</text>
|
||||
<text x="478" y="465" text-anchor="middle" class="dual-crossing-copy">
|
||||
Estadual
|
||||
</text>
|
||||
<text x="478" y="497" text-anchor="middle" class="dual-crossing-copy">
|
||||
ultrapassa
|
||||
</text>
|
||||
|
||||
<line
|
||||
class="dual-end-leader"
|
||||
x1="615"
|
||||
y1="89"
|
||||
x2="635"
|
||||
y2="89"
|
||||
stroke="#c51414"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<text x="642" y="54" class="dual-end-name">Estadual</text>
|
||||
<text x="642" y="96" class="dual-end-value">23.294</text>
|
||||
<text x="642" y="136" class="dual-end-valence" fill="#c51414">↑ amplia</text>
|
||||
<text x="642" y="176" class="dual-end-change">+121% até 2025</text>
|
||||
<line
|
||||
class="dual-end-leader"
|
||||
x1="615"
|
||||
y1="233"
|
||||
x2="635"
|
||||
y2="233"
|
||||
stroke="#78716c"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<text x="642" y="248" class="dual-end-name">Municipal</text>
|
||||
<text x="642" y="290" class="dual-end-value">15.948</text>
|
||||
<text x="642" y="330" class="dual-end-valence" fill="#78716c">↓ recua</text>
|
||||
<text x="642" y="370" class="dual-end-change">−23% até 2025</text>
|
||||
|
||||
<g class="dual-axis-label" text-anchor="middle">
|
||||
<text x="52" y="604">2017</text>
|
||||
<text x="174" y="604">2019</text>
|
||||
<text x="296" y="604">2021</text>
|
||||
<text x="417" y="604">2023</text>
|
||||
<text x="600" y="604">2026</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<footer class="chart-footer">
|
||||
<p class="source-line">
|
||||
<strong>Fonte:</strong> Ministério da Saúde — SIH/SUS (AIH aprovadas por
|
||||
ano)<br />
|
||||
Nota: 2026 é projeção pela média de janeiro a junho × 2.
|
||||
</p>
|
||||
<div class="brand-lockup" aria-label="Marca provisória do mandato">
|
||||
<span class="brand-signal"></span>
|
||||
<div>
|
||||
<div class="brand-name">JORGE SOLLA</div>
|
||||
<div class="brand-office">MANDATO DEPUTADO FEDERAL</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<span class="asset-flag">NEEDS ASSET · LOGO OFICIAL</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="grid gap-4">
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">VALÊNCIA SEM DEPENDER DA COR</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
Estadual (bom) usa o vermelho do mandato, círculo e “↑ amplia”. Municipal (ruim)
|
||||
recua em cinza de eixo, quadrado e “↓ recua”: a cor do PT nunca carrega o caminho
|
||||
ruim. Nome, valor, seta, palavra e forma mantêm a leitura redundante.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">PROJEÇÃO VISÍVEL, SEM ALARME</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
Só o segmento 2025→2026 é tracejado. Os dois marcadores finais são vazados, a faixa
|
||||
é neutra e “projeção” aparece sobre 2026; o cálculo permanece na nota da fonte.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">O VAZIO VIROU EXPLICAÇÃO</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
O plot cresce de 430 para 620 px e ocupa a coluna útil até perto do rodapé. A área
|
||||
abaixo das linhas não é cortada: mantém o zero real e recebe a marcação de 2024,
|
||||
quando o estadual ultrapassa, em vez de permanecer como vazio morto.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">SEM TONS · COMPARAÇÃO NEUTRA</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
Quando bom/ruim não vier informado, a série A fica em tinta com círculos e a B em
|
||||
cinza de eixo com quadrados. Os rótulos trazem apenas nome + valor: sem setas, sem
|
||||
“amplia/recua”, variação ou cruzamento valorativo e sem vermelho dentro do plot; o
|
||||
vermelho permanece só na régua e na marca do template.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 border-t border-stone-200 pt-6" aria-labelledby="dual-rules-title">
|
||||
<p class="text-xs font-bold text-brand-dark">REGRAS DA LINHA DE 2 SÉRIES</p>
|
||||
<h3 id="dual-rules-title" class="mt-1 text-lg font-bold tracking-tight">
|
||||
Contrato visual para o porte classe-a-classe
|
||||
</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Entrada e teto</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Exatamente 2 séries e os mesmos rótulos temporais, na mesma ordem. Teto:
|
||||
<strong>12 pontos por série</strong> no feed, quadrado e story (24 marcas no total).
|
||||
Barra/coluna continuam em 7 pontos no feed/quadrado e 5 no story. Contagem ou tempo
|
||||
desalinhado → perguntar; nunca interpolar, completar, repetir nem remover ponto. O
|
||||
domínio y é compartilhado e parte do zero real.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Bom × ruim · tons presentes</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Tons entram sempre em par. Bom =
|
||||
<span class="rule-code">#c51414 + círculo + ↑ “amplia”</span>. Ruim =
|
||||
<span class="rule-code">#78716c + quadrado + ↓ “recua”</span>. No caso: Estadual é
|
||||
bom; Municipal é ruim. Vermelho é marca do mandato/PT e aponta o caminho desejável;
|
||||
nunca significa perda. O ruim permanece neutro. Palavra, seta, forma e cor tornam a
|
||||
leitura redundante, não decorativa.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Sem tons · comparação neutra</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Se nenhum tom vier informado, não inferir juízo: série A =
|
||||
<span class="rule-code">#1c1917 + círculo</span>; série B =
|
||||
<span class="rule-code">#78716c + quadrado</span>. Rótulos finais têm nome + valor e
|
||||
podem trazer a variação observada com sinal matemático, sem palavra valorativa. Sem
|
||||
setas ou “amplia/recua”; nenhum vermelho no plot — ele fica apenas na régua superior
|
||||
e na marca do template.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Kicker, rótulos e colisão</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Kicker fixo: <strong>“Comparação no tempo”</strong>; não há legenda separada. Cada
|
||||
linha termina em bloco nome + valor + glifo/palavra + variação observada, tudo ≥30
|
||||
px. O gutter começa 42 px após o ponto e tem 238 px. Cada bloco usa 4 linhas: nome,
|
||||
valor, valência e “±N% até AAAA” em uma linha. Leading interno ≥38 px; entre blocos,
|
||||
≥40 px livres. Leaders de 3 px voltam ao ponto. Nunca esconder série. Nome quebra em
|
||||
até 2 linhas; se não couber, pedir forma curta, nunca abreviar sozinho.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Projeção</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Só quando o último período for projetado: faixa neutra
|
||||
<span class="rule-code">#a8a29e a 16%</span> do meio do penúltimo→último passo até
|
||||
33 px depois do ponto final; apenas esse segmento usa tracejado (feed/story
|
||||
<span class="rule-code">18 12</span>; quadrado
|
||||
<span class="rule-code">15 11</span>). Os dois marcadores finais preservam a forma e
|
||||
ficam vazados, com stroke 5 px. O rótulo projetado tem de ser o último ponto das
|
||||
duas séries. “projeção” fica no topo direito da faixa, não junto ao eixo nem
|
||||
misturada aos rótulos finais; a nota traz o método. Sem projeção: remover faixa,
|
||||
tracejado, palavra e ressalva automática.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Eixo X · 10 pontos anuais</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Fonte 30 px, centralizada. Para 9–12 pontos, mostrar rótulos alternados, sempre o
|
||||
primeiro e o último; omitir o penúltimo se colidir com o último. Neste caso, exibir
|
||||
2017, 2019, 2021, 2023 e 2026 — o rótulo final substitui 2025. A palavra “projeção”
|
||||
fica acima do período, nunca espremida no eixo.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Variação e cruzamento</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Se o último ponto é projeção, a variação compara o primeiro valor ao último
|
||||
<em>observado</em>, nunca à projeção: aqui, <strong>+121% até 2025</strong> no
|
||||
estadual e <strong>−23% até 2025</strong> no municipal. O cruzamento confirmado
|
||||
recebe <span class="rule-code">dual-crossing-*</span>: anel no ponto estadual de
|
||||
2024, guia tracejada até uma caixa de 3 linhas: “2024” / “Estadual” / “ultrapassa”.
|
||||
A copy permanece em 28 px; nunca comprimir em uma linha nem deixá-la tocar a borda.
|
||||
Sem confirmação textual do cruzamento, não gerar a anotação automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Feed · 1080×1350</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Canvas padding 76/84/58 px; headline 60 px; plot
|
||||
<span class="rule-code">880×620</span>, margin-top 28. Plot L52, R280, T56, B76;
|
||||
dados x=52…600; baseline y=544; grid em y=56/300/544. Os 10 pontos ficam em
|
||||
x=52/113/174/235/296/356/417/478/539/600; a largura útil cai de 598 para 548 px, com
|
||||
passo anual de ~61 px, e o eixo alternado usa 52/174/296/417/600 (≥121 px entre
|
||||
rótulos). Linhas 8 px; círculos r=8; quadrados 16×16; finais r=11 / 20×20; dash 18
|
||||
12. Gutter x=642…880; nomes/valência/variação 30 px, valores 36 px. Baselines
|
||||
finais: Estadual 54/96/136/176; Municipal 248/290/330/370. Cruzamento: guia x=478,
|
||||
y=160…398; caixa x=351, y=398, 254×118, rx=8; textos em y=431/465/497.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Quadrado · 1080×1080</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Canvas padding 56/84/48 px; headline 54 px; encurtar subtítulo antes de reduzir
|
||||
texto. Plot <span class="rule-code">880×440</span>, margin-top 20, L52, R280, T40,
|
||||
B70; dados x=52…600; baseline y=370; grid em y=40/205/370. Linhas 7 px; círculos
|
||||
r=7; quadrados 14×14; finais r=10 / 18×18; dash 15 11. Gutter x=642…880; textos 30
|
||||
px, valores 32 px. Baselines finais: Estadual 32/70/108/146; Municipal
|
||||
186/224/262/300. Cruzamento: guia x=478, y=115…252; caixa x=351, y=252, 254×108,
|
||||
rx=8; textos em y=282/311/340.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Story · 1080×1920</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Faixas seguras de 250 px no topo e na base; conteúdo padding 306/84/310 px; headline
|
||||
64 px. Plot <span class="rule-code">880×700</span>, margin-top 28, L52, R280, T64,
|
||||
B78; dados x=52…600; baseline y=622; grid em y=64/343/622. Linhas 8 px; círculos
|
||||
r=8; quadrados 16×16; finais r=11 / 20×20; dash 18 12. Gutter x=642…880; nomes,
|
||||
valência e variação 32 px, valores 36 px. Baselines finais: Estadual 68/112/154/196;
|
||||
Municipal 280/324/366/408. Plot, caixa, rótulos e “projeção” ficam integralmente
|
||||
entre y=250 e y=1670 do canvas. Cruzamento: guia x=478, y=181…478; caixa x=351,
|
||||
y=478, 254×118, rx=8; textos em y=511/545/577.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-stone-200 bg-white p-4 md:col-span-2 xl:col-span-3"
|
||||
>
|
||||
<p class="text-sm font-bold">Mapa de classes do SVG</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Chassi: <span class="rule-code">dual-line-plot</span>,
|
||||
<span class="rule-code">dual-axis-label</span>,
|
||||
<span class="rule-code">dual-projection-band</span> e
|
||||
<span class="rule-code">dual-projection-label</span>. Com tons:
|
||||
<span class="rule-code">dual-series-good / dual-markers-good</span> e
|
||||
<span class="rule-code">dual-series-bad / dual-markers-bad</span>. Sem tons, trocar
|
||||
apenas os modificadores por
|
||||
<span class="rule-code">dual-series-a / dual-markers-a</span> e
|
||||
<span class="rule-code">dual-series-b / dual-markers-b</span>. Projeção acrescenta
|
||||
<span class="rule-code">dual-series-projection</span> e
|
||||
<span class="rule-code">dual-marker-projected-*</span>. Saída direta:
|
||||
<span class="rule-code">dual-end-leader</span>,
|
||||
<span class="rule-code">dual-end-name</span>,
|
||||
<span class="rule-code">dual-end-value</span> e, só com tons,
|
||||
<span class="rule-code">dual-end-valence</span>; variação observada em
|
||||
<span class="rule-code">dual-end-change</span>. Cruzamento, quando confirmado:
|
||||
<span class="rule-code">dual-crossing-guide</span>,
|
||||
<span class="rule-code">dual-crossing-ring</span>,
|
||||
<span class="rule-code">dual-crossing-box</span>,
|
||||
<span class="rule-code">dual-crossing-year</span> e
|
||||
<span class="rule-code">dual-crossing-copy</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -1143,7 +1795,8 @@
|
|||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-sm font-bold">Densidade</p>
|
||||
<p class="mt-1 text-sm leading-5 text-stone-600">
|
||||
Até 7 pontos por gráfico. Acima disso: resumir com orientação explícita.
|
||||
Barra, coluna e linha simples: até 7 pontos. Exceção aprovada: linha de 2 séries,
|
||||
até 12 pontos alinhados por série; story mantém 5 apenas para barra/coluna.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
|
|
@ -1159,8 +1812,10 @@
|
|||
<footer
|
||||
class="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-stone-300 py-5 text-xs text-stone-600"
|
||||
>
|
||||
<span>Design hi-fi · graficos-dados-instagram · dados e fontes fictícios</span>
|
||||
<span>Paleta Solla · Inter · sem gradiente, 3D ou chrome de dashboard</span>
|
||||
<span
|
||||
>Design hi-fi certificado · graficos-dados-instagram · cinco variações integradas</span
|
||||
>
|
||||
<span>Paleta Solla · valência redundante · sem gradiente, 3D ou chrome de dashboard</span>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url'
|
|||
import { launchPdfBrowser, screenshotHtmlPng } from './lib/buildPdf.mjs'
|
||||
import {
|
||||
MAX_POINTS,
|
||||
MAX_POINTS_LINE,
|
||||
MAX_POINTS_STORY,
|
||||
SIZES,
|
||||
classifyRelation,
|
||||
|
|
@ -54,6 +55,29 @@ const slugify = (value) =>
|
|||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
/**
|
||||
* `--good`/`--bad` name the series that carry each tone of the approved
|
||||
* two-series variant; the pair itself is validated by `validateSpec`.
|
||||
*/
|
||||
const applyTones = (series, flags, die) => {
|
||||
const good = flags.good ? String(flags.good) : null
|
||||
const bad = flags.bad ? String(flags.bad) : null
|
||||
if (good && bad && good === bad) {
|
||||
die(`--good e --bad apontam para a mesma série: ${JSON.stringify(good)}.`)
|
||||
}
|
||||
const known = series.map((serie) => serie.name)
|
||||
for (const name of [good, bad].filter(Boolean)) {
|
||||
if (!known.includes(name)) {
|
||||
die(`série ${JSON.stringify(name)} não existe nos dados (nomes: ${known.join(', ')}).`)
|
||||
}
|
||||
}
|
||||
return series.map((serie) => {
|
||||
if (good && serie.name === good) return { ...serie, tone: 'good' }
|
||||
if (bad && serie.name === bad) return { ...serie, tone: 'bad' }
|
||||
return serie
|
||||
})
|
||||
}
|
||||
|
||||
const readInput = async (flags) => {
|
||||
const path = flags.in === '-' || flags.in === true ? null : String(flags.in ?? '')
|
||||
if (!path) {
|
||||
|
|
@ -111,14 +135,27 @@ export const main = async ({
|
|||
return
|
||||
}
|
||||
|
||||
if (dataset.rows.length === 0 || dataset.issues.length > 0) {
|
||||
const hasSeries = Array.isArray(dataset.series) && dataset.series.length > 0
|
||||
if ((!hasSeries && dataset.rows.length === 0) || dataset.issues.length > 0) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ needsQuestion: true, issues: dataset.issues, rows: dataset.rows }, null, 2)}\n`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
needsQuestion: true,
|
||||
issues: dataset.issues,
|
||||
rows: dataset.rows,
|
||||
series: dataset.series ?? [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
die('dado ambíguo ou faltando — pergunte à pessoa antes de gerar (nada é completado).')
|
||||
}
|
||||
|
||||
const chartType = classifyRelation(dataset.rows, flags.type ? String(flags.type) : null)
|
||||
const forcedType = flags.type ? String(flags.type) : null
|
||||
const chartType = hasSeries
|
||||
? (forcedType ?? 'line')
|
||||
: classifyRelation(dataset.rows, forcedType)
|
||||
spec = {
|
||||
chartType,
|
||||
size: flags.size ? String(flags.size) : 'feed',
|
||||
|
|
@ -126,8 +163,13 @@ export const main = async ({
|
|||
subtitle: flags.subtitle ? String(flags.subtitle) : '',
|
||||
source: flags.source ? String(flags.source) : '',
|
||||
note: flags.note ? String(flags.note) : '',
|
||||
highlight: flags.highlight ? String(flags.highlight) : null,
|
||||
rows: dataset.rows,
|
||||
...(hasSeries
|
||||
? {
|
||||
...(flags.projected ? { projectedLabel: String(flags.projected) } : {}),
|
||||
...(flags.crossing ? { crossingLabel: String(flags.crossing) } : {}),
|
||||
series: applyTones(dataset.series, flags, die),
|
||||
}
|
||||
: { highlight: flags.highlight ? String(flags.highlight) : null, rows: dataset.rows }),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,10 +189,14 @@ export const main = async ({
|
|||
const html = renderChartHtml(spec)
|
||||
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
|
||||
const canvas = SIZES[sizeKey]
|
||||
const pointCap =
|
||||
sizeKey === 'story' && (spec.chartType === 'bar' || spec.chartType === 'column')
|
||||
const pointCap = spec.series?.length
|
||||
? MAX_POINTS_LINE
|
||||
: sizeKey === 'story' && (spec.chartType === 'bar' || spec.chartType === 'column')
|
||||
? MAX_POINTS_STORY
|
||||
: MAX_POINTS
|
||||
const pointCount = spec.series?.length
|
||||
? `${spec.series.length} séries × ${spec.series[0].rows.length}`
|
||||
: `${spec.rows.length} ponto(s)`
|
||||
const browser = await launchBrowser()
|
||||
try {
|
||||
await mkdir(dirname(outPath), { recursive: true })
|
||||
|
|
@ -163,7 +209,7 @@ export const main = async ({
|
|||
outPath,
|
||||
})
|
||||
console.log(
|
||||
`[${LABEL}] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${spec.rows.length} ponto(s) ≤ ${pointCap}`,
|
||||
`[${LABEL}] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${pointCount} ≤ ${pointCap}`,
|
||||
)
|
||||
} finally {
|
||||
await browser.close()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ export const MAX_POINTS = 7
|
|||
*/
|
||||
export const MAX_POINTS_STORY = 5
|
||||
|
||||
/**
|
||||
* Two-series time comparison (approved variant): an annual series carries more
|
||||
* points than a ranking, per series. One tone per series encodes good/bad.
|
||||
*/
|
||||
export const MAX_SERIES = 2
|
||||
export const MAX_POINTS_LINE = 12
|
||||
const SERIES_TONES = ['good', 'bad']
|
||||
|
||||
/** Output canvases: feed 4:5 (default), square, stories/reels 9:16 with safe bands. */
|
||||
export const SIZES = {
|
||||
feed: { width: 1080, height: 1350, label: '1080×1350', safeTop: 0, safeBottom: 0 },
|
||||
|
|
@ -38,6 +46,9 @@ export const RELATION_LABEL = {
|
|||
anchor: 'Número-âncora',
|
||||
}
|
||||
|
||||
/** Kicker of the two-series time line (approved C191 variant). */
|
||||
export const SERIES_RELATION_LABEL = 'Comparação no tempo'
|
||||
|
||||
const TEMPORAL_RE =
|
||||
/^(\d{4}|\d{2}|[A-Za-zÀ-ÿ]{3,9}[./-]?\d{2,4}|\d{1,2}[./-]\d{2,4}|[TQ]\d|P\d+|\d{4}[./-]\d{1,2})$/u
|
||||
|
||||
|
|
@ -138,11 +149,51 @@ const cleanMatrix = (matrix) =>
|
|||
.map((row) => row.map((cell) => String(cell ?? '').trim()))
|
||||
.filter((row) => row.some(Boolean))
|
||||
|
||||
/**
|
||||
* A table whose header names two measures over a shared first column is a time
|
||||
* comparison: `series` instead of a single ranking. Only the header (words,
|
||||
* never numbers) may open that door — a data row with a missing value never
|
||||
* turns into a series in silence.
|
||||
*/
|
||||
const looksLikeSeriesHeader = (header) =>
|
||||
header.length >= MAX_SERIES + 1 &&
|
||||
header.every((cell) => cell !== '' && parseNumber(cell) === null)
|
||||
|
||||
const detectSeries = (cleaned) => {
|
||||
const header = cleaned[0]
|
||||
if (cleaned.length < 2 || !looksLikeSeriesHeader(header)) return null
|
||||
const issues = []
|
||||
const series = header.slice(1).map((name) => ({ name, rows: [] }))
|
||||
const seen = new Set()
|
||||
for (const row of cleaned.slice(1)) {
|
||||
const label = row[0]
|
||||
if (!label) {
|
||||
issues.push(`linha sem rótulo: ${JSON.stringify(row.join(' | '))}`)
|
||||
continue
|
||||
}
|
||||
if (seen.has(label)) issues.push(`rótulo repetido: "${label}"`)
|
||||
seen.add(label)
|
||||
series.forEach((serie, index) => {
|
||||
const raw = row[index + 1]
|
||||
const value = parseNumber(raw)
|
||||
if (value === null) {
|
||||
issues.push(`valor não numérico em "${serie.name} · ${label}": ${JSON.stringify(raw)}`)
|
||||
return
|
||||
}
|
||||
serie.rows.push({ label, value })
|
||||
})
|
||||
}
|
||||
return { series, issues }
|
||||
}
|
||||
|
||||
const matrixToDataset = (matrix, format) => {
|
||||
const cleaned = cleanMatrix(matrix)
|
||||
const issues = []
|
||||
if (cleaned.length === 0) return { rows: [], format, issues: ['nenhum dado encontrado'] }
|
||||
|
||||
const detected = detectSeries(cleaned)
|
||||
if (detected) return { rows: [], series: detected.series, format, issues: detected.issues }
|
||||
|
||||
let start = 0
|
||||
if (
|
||||
cleaned.length > 1 &&
|
||||
|
|
@ -284,12 +335,108 @@ export const classifyRelation = (rows, forcedType = null) => {
|
|||
return 'bar'
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardrails of the two-series time line (approved variant): aligned periods,
|
||||
* at most `MAX_POINTS_LINE` per series, honest zero base, good/bad tones paired
|
||||
* and the projection only on the last period.
|
||||
*/
|
||||
const validateSeries = (spec) => {
|
||||
const { series } = spec
|
||||
if (spec.chartType !== 'line') {
|
||||
throw new Error('a comparação de duas séries exige o tipo linha (série de tempo).')
|
||||
}
|
||||
if (spec.highlight) {
|
||||
throw new Error('a linha de duas séries marca bom/ruim pelos tons — não use --highlight.')
|
||||
}
|
||||
if (series.length !== MAX_SERIES) {
|
||||
throw new Error(
|
||||
`a linha multi-série desenha ${MAX_SERIES} séries (${series.length} recebidas).`,
|
||||
)
|
||||
}
|
||||
const names = new Set()
|
||||
for (const serie of series) {
|
||||
if (typeof serie.name !== 'string' || serie.name.trim() === '')
|
||||
throw new Error('série sem nome.')
|
||||
if (names.has(serie.name)) throw new Error(`série repetida: "${serie.name}".`)
|
||||
names.add(serie.name)
|
||||
if (!Array.isArray(serie.rows) || serie.rows.length < 2) {
|
||||
throw new Error(`série "${serie.name}" precisa de pelo menos 2 pontos.`)
|
||||
}
|
||||
if (serie.rows.length > MAX_POINTS_LINE) {
|
||||
throw new Error(
|
||||
`${serie.rows.length} pontos na série "${serie.name}" (> ${MAX_POINTS_LINE}): resuma os períodos antes de gerar o gráfico.`,
|
||||
)
|
||||
}
|
||||
for (const row of serie.rows) {
|
||||
if (!row.label) throw new Error(`ponto de dado sem rótulo na série "${serie.name}".`)
|
||||
if (!Number.isFinite(row.value)) {
|
||||
throw new Error(`valor ausente/inválido em "${serie.name} · ${row.label}".`)
|
||||
}
|
||||
if (row.value < 0) {
|
||||
throw new Error(`valor negativo em "${serie.name} · ${row.label}" — a linha parte do zero.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const [first, second] = series
|
||||
const aligned =
|
||||
first.rows.length === second.rows.length &&
|
||||
first.rows.every((row, index) => row.label === second.rows[index].label)
|
||||
if (!aligned)
|
||||
throw new Error('as séries precisam compartilhar os mesmos períodos, na mesma ordem.')
|
||||
if (!first.rows.every((row) => isTemporalLabel(row.label))) {
|
||||
throw new Error('a linha de duas séries exige períodos no eixo (ex.: anos) — sem categorias.')
|
||||
}
|
||||
const tones = series.map((serie) => serie.tone ?? null)
|
||||
const informed = tones.filter(Boolean)
|
||||
if (informed.length > 0 && informed.length !== series.length) {
|
||||
throw new Error('informe o tom das duas séries (bom e ruim) ou de nenhuma.')
|
||||
}
|
||||
for (const tone of informed) {
|
||||
if (!SERIES_TONES.includes(tone)) {
|
||||
throw new Error(`tom inválido: ${JSON.stringify(tone)} (use ${SERIES_TONES.join(', ')}).`)
|
||||
}
|
||||
}
|
||||
if (informed.length === series.length && tones[0] === tones[1]) {
|
||||
throw new Error('as duas séries não podem compartilhar o mesmo tom (bom × ruim).')
|
||||
}
|
||||
if (spec.projectedLabel) {
|
||||
const last = first.rows[first.rows.length - 1].label
|
||||
if (last !== spec.projectedLabel) {
|
||||
throw new Error(
|
||||
`a projeção só é marcada no último período (recebido "${spec.projectedLabel}"; último é "${last}").`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (spec.crossingLabel) {
|
||||
const good = series.find((serie) => serie.tone === 'good')
|
||||
const bad = series.find((serie) => serie.tone === 'bad')
|
||||
if (!good || !bad) {
|
||||
throw new Error('a anotação de cruzamento exige os tons bom e ruim — informe --good e --bad.')
|
||||
}
|
||||
const labels = first.rows.map((row) => row.label)
|
||||
const index = labels.indexOf(spec.crossingLabel)
|
||||
if (index < 1) {
|
||||
throw new Error(
|
||||
`cruzamento "${spec.crossingLabel}" precisa ser um período depois do primeiro — confira o rótulo.`,
|
||||
)
|
||||
}
|
||||
const overtakes =
|
||||
good.rows[index].value > bad.rows[index].value &&
|
||||
good.rows[index - 1].value <= bad.rows[index - 1].value
|
||||
if (!overtakes) {
|
||||
throw new Error(
|
||||
`não há ultrapassagem confirmada em "${spec.crossingLabel}" — confira os valores antes de anotar.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed guardrails of the approved template. Throws with the actionable
|
||||
* reason instead of drawing a misleading chart.
|
||||
*/
|
||||
export const validateSpec = (spec) => {
|
||||
const { chartType, rows, headline } = spec
|
||||
const { chartType, rows, series, headline } = spec
|
||||
if (spec.size && !SIZES[spec.size]) {
|
||||
throw new Error(
|
||||
`tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`,
|
||||
|
|
@ -303,32 +450,37 @@ export const validateSpec = (spec) => {
|
|||
}
|
||||
throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`)
|
||||
}
|
||||
if (!Array.isArray(rows) || rows.length === 0)
|
||||
throw new Error('sem pontos de dado para desenhar.')
|
||||
if (rows.length > MAX_POINTS) {
|
||||
throw new Error(
|
||||
`${rows.length} pontos (> ${MAX_POINTS}): resuma as categorias antes de gerar o gráfico.`,
|
||||
)
|
||||
}
|
||||
if (
|
||||
spec.size === 'story' &&
|
||||
(chartType === 'bar' || chartType === 'column') &&
|
||||
rows.length > MAX_POINTS_STORY
|
||||
) {
|
||||
throw new Error(
|
||||
`stories comporta até ${MAX_POINTS_STORY} pontos no ranking (${rows.length} recebidos): resuma para gerar a versão vertical.`,
|
||||
)
|
||||
}
|
||||
if (chartType === 'anchor' && rows.length !== 1) {
|
||||
throw new Error('número-âncora exige exatamente um valor.')
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (!row.label && chartType !== 'anchor') throw new Error('ponto de dado sem rótulo.')
|
||||
if (!Number.isFinite(row.value)) throw new Error(`valor ausente/inválido em "${row.label}".`)
|
||||
if (row.value < 0) throw new Error(`valor negativo em "${row.label}" — barras partem do zero.`)
|
||||
}
|
||||
if (spec.highlight && !rows.some((row) => row.label === spec.highlight)) {
|
||||
throw new Error(`destaque "${spec.highlight}" não existe nos dados — confira o rótulo.`)
|
||||
if (Array.isArray(series) && series.length > 0) {
|
||||
validateSeries(spec)
|
||||
} else {
|
||||
if (!Array.isArray(rows) || rows.length === 0)
|
||||
throw new Error('sem pontos de dado para desenhar.')
|
||||
if (rows.length > MAX_POINTS) {
|
||||
throw new Error(
|
||||
`${rows.length} pontos (> ${MAX_POINTS}): resuma as categorias antes de gerar o gráfico.`,
|
||||
)
|
||||
}
|
||||
if (
|
||||
spec.size === 'story' &&
|
||||
(chartType === 'bar' || chartType === 'column') &&
|
||||
rows.length > MAX_POINTS_STORY
|
||||
) {
|
||||
throw new Error(
|
||||
`stories comporta até ${MAX_POINTS_STORY} pontos no ranking (${rows.length} recebidos): resuma para gerar a versão vertical.`,
|
||||
)
|
||||
}
|
||||
if (chartType === 'anchor' && rows.length !== 1) {
|
||||
throw new Error('número-âncora exige exatamente um valor.')
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (!row.label && chartType !== 'anchor') throw new Error('ponto de dado sem rótulo.')
|
||||
if (!Number.isFinite(row.value)) throw new Error(`valor ausente/inválido em "${row.label}".`)
|
||||
if (row.value < 0)
|
||||
throw new Error(`valor negativo em "${row.label}" — barras partem do zero.`)
|
||||
}
|
||||
if (spec.highlight && !rows.some((row) => row.label === spec.highlight)) {
|
||||
throw new Error(`destaque "${spec.highlight}" não existe nos dados — confira o rótulo.`)
|
||||
}
|
||||
}
|
||||
if (typeof headline !== 'string' || headline.trim() === '') {
|
||||
throw new Error('título-manchete ausente — a manchete é obrigatória.')
|
||||
|
|
|
|||
|
|
@ -136,6 +136,361 @@ export const stackedColumnChart = ({
|
|||
</svg>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry of the approved two-series time line (C191 variant), per export
|
||||
* size. The data box is the same on every canvas; only the vertical rhythm and
|
||||
* the marker sizes change (design: "Feed / Quadrado / Story" rules).
|
||||
*/
|
||||
const DUAL_GEOMETRY = {
|
||||
feed: {
|
||||
width: 880,
|
||||
height: 620,
|
||||
left: 52,
|
||||
right: 600,
|
||||
top: 56,
|
||||
baseline: 544,
|
||||
stroke: 8,
|
||||
dotRadius: 8,
|
||||
squareSize: 16,
|
||||
projectedDotRadius: 11,
|
||||
projectedSquareSize: 20,
|
||||
dash: '18 12',
|
||||
nameSize: 30,
|
||||
valueSize: 36,
|
||||
valenceSize: 30,
|
||||
changeSize: 30,
|
||||
axisSize: 30,
|
||||
blockOffsets: [-35, 7, 47, 87],
|
||||
blockGap: 72,
|
||||
crossing: {
|
||||
boxWidth: 254,
|
||||
boxHeight: 118,
|
||||
boxY: 398,
|
||||
yearOffset: 33,
|
||||
copyOffset: 67,
|
||||
copyOffset2: 99,
|
||||
},
|
||||
},
|
||||
square: {
|
||||
width: 880,
|
||||
height: 440,
|
||||
left: 52,
|
||||
right: 600,
|
||||
top: 40,
|
||||
baseline: 370,
|
||||
stroke: 7,
|
||||
dotRadius: 7,
|
||||
squareSize: 14,
|
||||
projectedDotRadius: 10,
|
||||
projectedSquareSize: 18,
|
||||
dash: '15 11',
|
||||
nameSize: 30,
|
||||
valueSize: 32,
|
||||
valenceSize: 30,
|
||||
changeSize: 30,
|
||||
axisSize: 30,
|
||||
blockOffsets: [-31, 7, 45, 83],
|
||||
blockGap: 40,
|
||||
crossing: {
|
||||
boxWidth: 254,
|
||||
boxHeight: 108,
|
||||
boxY: 252,
|
||||
yearOffset: 30,
|
||||
copyOffset: 59,
|
||||
copyOffset2: 88,
|
||||
},
|
||||
},
|
||||
story: {
|
||||
width: 880,
|
||||
height: 700,
|
||||
left: 52,
|
||||
right: 600,
|
||||
top: 64,
|
||||
baseline: 622,
|
||||
stroke: 8,
|
||||
dotRadius: 8,
|
||||
squareSize: 16,
|
||||
projectedDotRadius: 11,
|
||||
projectedSquareSize: 20,
|
||||
dash: '18 12',
|
||||
nameSize: 32,
|
||||
valueSize: 36,
|
||||
valenceSize: 32,
|
||||
changeSize: 32,
|
||||
axisSize: 30,
|
||||
blockOffsets: [-34, 10, 52, 94],
|
||||
blockGap: 84,
|
||||
crossing: {
|
||||
boxWidth: 254,
|
||||
boxHeight: 118,
|
||||
boxY: 478,
|
||||
yearOffset: 33,
|
||||
copyOffset: 67,
|
||||
copyOffset2: 99,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Rounds the shared maximum up to the next legible step (never down). */
|
||||
const niceMax = (value) => {
|
||||
if (!(value > 0)) return 1
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value))
|
||||
for (const step of [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) {
|
||||
const candidate = step * magnitude
|
||||
if (candidate >= value) return candidate
|
||||
}
|
||||
return 10 * magnitude
|
||||
}
|
||||
|
||||
/**
|
||||
* 9–12 annual points alternate their axis labels, always keeping the first and
|
||||
* the last; the penultimate is dropped when it would collide with the last
|
||||
* (design: "2017, 2019, 2021, 2023 e 2026").
|
||||
*/
|
||||
const dualAxisIndices = (count) => {
|
||||
if (count < 9) return [...Array(count).keys()]
|
||||
const shown = new Set()
|
||||
for (let index = 0; index < count; index += 2) shown.add(index)
|
||||
const last = count - 1
|
||||
if (!shown.has(last)) {
|
||||
shown.delete(last - 1)
|
||||
shown.add(last)
|
||||
}
|
||||
return [...shown].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/** Keeps the crossing box inside the data box while staying centered on the point. */
|
||||
const crossingCenter = (x, cfg) =>
|
||||
Math.min(Math.max(x, cfg.left + cfg.crossing.boxWidth / 2), cfg.right - cfg.crossing.boxWidth / 2)
|
||||
|
||||
/**
|
||||
* Two-series time line (Instagram, C191 approved variant): shared zero-based
|
||||
* scale, direct end labels and valence carried by word + arrow + shape + color.
|
||||
* `series` items are `{ name, tone?: 'good'|'bad', rows: [{ label, value }] }`;
|
||||
* tones come in pairs, or not at all (neutral comparison, no red in the plot).
|
||||
*/
|
||||
export const dualLineChart = ({
|
||||
series,
|
||||
size = 'feed',
|
||||
projected = false,
|
||||
crossingLabel = /** @type {string | null} */ (null),
|
||||
colors,
|
||||
valence,
|
||||
format,
|
||||
}) => {
|
||||
const cfg = DUAL_GEOMETRY[size] ?? DUAL_GEOMETRY.feed
|
||||
const count = series[0].rows.length
|
||||
const max = niceMax(Math.max(...series.flatMap((serie) => serie.rows.map((row) => row.value))))
|
||||
const step = count > 1 ? (cfg.right - cfg.left) / (count - 1) : 0
|
||||
const xAt = (index) => Math.round(cfg.left + index * step)
|
||||
const yAt = (value) => cfg.baseline - Math.round((value / max) * (cfg.baseline - cfg.top))
|
||||
const pointsOf = (serie) => serie.rows.map((row, index) => ({ x: xAt(index), y: yAt(row.value) }))
|
||||
const styles = series.map((serie, index) => {
|
||||
if (serie.tone === 'good') {
|
||||
return { color: colors.good, shape: 'circle', valence: valence.good, tone: 'good' }
|
||||
}
|
||||
if (serie.tone === 'bad') {
|
||||
return { color: colors.bad, shape: 'square', valence: valence.bad, tone: 'bad' }
|
||||
}
|
||||
return index === 0
|
||||
? { color: colors.neutralA, shape: 'circle', valence: null, tone: 'a' }
|
||||
: { color: colors.neutralB, shape: 'square', valence: null, tone: 'b' }
|
||||
})
|
||||
|
||||
const marker = ({ x, y, shape, size: markerSize, fill, className = '' }) =>
|
||||
shape === 'square'
|
||||
? `<rect x="${x - markerSize / 2}" y="${y - markerSize / 2}" width="${markerSize}" height="${markerSize}" fill="${fill}"${className ? ` class="${className}"` : ''} />`
|
||||
: `<circle cx="${x}" cy="${y}" r="${markerSize / 2}" fill="${fill}"${className ? ` class="${className}"` : ''} />`
|
||||
|
||||
const band = projected
|
||||
? (() => {
|
||||
const before = xAt(count - 2)
|
||||
const last = xAt(count - 1)
|
||||
const start = Math.round((before + last) / 2)
|
||||
const end = last + 33
|
||||
return `<rect class="dual-projection-band" x="${start}" y="0" width="${end - start}" height="${cfg.baseline}" fill="${colors.grid}" fill-opacity="0.16" />`
|
||||
})()
|
||||
: ''
|
||||
|
||||
const gridLines = `<line class="dual-grid-line" x1="${cfg.left}" y1="${cfg.top}" x2="${cfg.right}" y2="${cfg.top}" stroke="${colors.grid}" stroke-opacity="0.32" stroke-width="2" />
|
||||
<line class="dual-grid-line" x1="${cfg.left}" y1="${Math.round((cfg.top + cfg.baseline) / 2)}" x2="${cfg.right}" y2="${Math.round((cfg.top + cfg.baseline) / 2)}" stroke="${colors.grid}" stroke-opacity="0.32" stroke-width="2" />
|
||||
<line class="dual-zero-line" x1="${cfg.left}" y1="${cfg.baseline}" x2="${cfg.right}" y2="${cfg.baseline}" stroke="${colors.grid}" stroke-width="2" />
|
||||
<text x="${cfg.left - 10}" y="${cfg.baseline - 5}" text-anchor="end" class="dual-axis-label" font-size="${cfg.axisSize}">0</text>`
|
||||
|
||||
const projectionLabel = projected
|
||||
? `<text x="${cfg.right + 24}" y="${cfg.top - 11}" text-anchor="end" class="dual-projection-label" font-size="${cfg.axisSize}">projeção</text>`
|
||||
: ''
|
||||
|
||||
const lines = series
|
||||
.map((serie, index) => {
|
||||
const points = pointsOf(serie)
|
||||
const style = styles[index]
|
||||
const solid = projected ? points.slice(0, -1) : points
|
||||
const polyline = `<polyline class="dual-series dual-series-${style.tone}" points="${solid.map((point) => `${point.x},${point.y}`).join(' ')}" fill="none" stroke="${style.color}" stroke-width="${cfg.stroke}" stroke-linecap="round" stroke-linejoin="round" />`
|
||||
const dashed = projected
|
||||
? `<line class="dual-series-projection dual-series-${style.tone}" x1="${points[count - 2].x}" y1="${points[count - 2].y}" x2="${points[count - 1].x}" y2="${points[count - 1].y}" stroke="${style.color}" stroke-width="${cfg.stroke}" stroke-linecap="round" stroke-dasharray="${cfg.dash}" />`
|
||||
: ''
|
||||
const regular = projected ? points.slice(0, -1) : points
|
||||
const markers = `<g class="dual-markers-${style.tone}" fill="${style.color}">${regular
|
||||
.map((point) =>
|
||||
marker({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
shape: style.shape,
|
||||
size: style.shape === 'square' ? cfg.squareSize : cfg.dotRadius * 2,
|
||||
fill: style.color,
|
||||
}),
|
||||
)
|
||||
.join('')}</g>`
|
||||
return `${polyline}${dashed}${markers}`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const projectedMarkers = projected
|
||||
? series
|
||||
.map((serie, index) => {
|
||||
const point = pointsOf(serie)[count - 1]
|
||||
const style = styles[index]
|
||||
return marker({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
shape: style.shape,
|
||||
size: style.shape === 'square' ? cfg.projectedSquareSize : cfg.projectedDotRadius * 2,
|
||||
fill: colors.paper,
|
||||
className: `dual-marker-projected dual-marker-projected-${style.tone}`,
|
||||
}).replace('/>', ` stroke="${style.color}" stroke-width="5" />`)
|
||||
})
|
||||
.join('')
|
||||
: ''
|
||||
|
||||
const observedIndex = projected ? count - 2 : count - 1
|
||||
const changeOf = (serie) => {
|
||||
const firstValue = serie.rows[0].value
|
||||
const observed = serie.rows[observedIndex]?.value
|
||||
if (!(firstValue > 0) || !Number.isFinite(observed)) return null
|
||||
const percent = Math.round(((observed - firstValue) / firstValue) * 100)
|
||||
const sign = percent > 0 ? '+' : percent < 0 ? '−' : ''
|
||||
const until = `até ${serie.rows[observedIndex].label}`
|
||||
return {
|
||||
percent: `${sign}${Math.abs(percent)}%`,
|
||||
until,
|
||||
label: `${sign}${Math.abs(percent)}% ${until}`,
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = series
|
||||
.map((serie, index) => {
|
||||
const point = pointsOf(serie)[count - 1]
|
||||
const offsets = styles[index].valence ? cfg.blockOffsets : cfg.blockOffsets.slice(0, 3)
|
||||
return {
|
||||
name: serie.name,
|
||||
style: styles[index],
|
||||
point,
|
||||
baselines: offsets.map((offset) => point.y + offset),
|
||||
value: format(serie.rows[count - 1]),
|
||||
change: changeOf(serie),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.point.y - b.point.y)
|
||||
let previousBottom = null
|
||||
for (const block of blocks) {
|
||||
if (previousBottom !== null) {
|
||||
const minName = previousBottom + cfg.blockGap
|
||||
const delta = minName - block.baselines[0]
|
||||
if (delta > 0) block.baselines = block.baselines.map((baseline) => baseline + delta)
|
||||
}
|
||||
previousBottom = block.baselines[block.baselines.length - 1]
|
||||
}
|
||||
const clampOverflow = blocks[blocks.length - 1]?.baselines.at(-1) - (cfg.height - 12)
|
||||
const endBlocks = blocks
|
||||
.map((block) => {
|
||||
const baselineOffset = clampOverflow > 0 ? -clampOverflow : 0
|
||||
const text = (className, size, fill, value, index) =>
|
||||
`<text x="${cfg.right + 42}" y="${block.baselines[index] + baselineOffset}" class="${className}" font-size="${size}"${fill ? ` fill="${fill}"` : ''}>${htmlEscape(value)}</text>`
|
||||
const parts = [
|
||||
`<line class="dual-end-leader" x1="${cfg.right + 15}" y1="${block.point.y}" x2="${cfg.right + 29}" y2="${block.point.y}" stroke="${block.style.color}" stroke-width="3" />`,
|
||||
text('dual-end-name', cfg.nameSize, colors.ink, block.name, 0),
|
||||
text('dual-end-value', cfg.valueSize, colors.ink, block.value, 1),
|
||||
]
|
||||
if (block.style.valence) {
|
||||
parts.push(
|
||||
text('dual-end-valence', cfg.valenceSize, block.style.color, block.style.valence, 2),
|
||||
)
|
||||
}
|
||||
if (block.change) {
|
||||
parts.push(
|
||||
text(
|
||||
'dual-end-change',
|
||||
cfg.changeSize,
|
||||
colors.change,
|
||||
block.change.label,
|
||||
block.style.valence ? 3 : 2,
|
||||
),
|
||||
)
|
||||
}
|
||||
return parts.join('')
|
||||
})
|
||||
.join('')
|
||||
|
||||
const goodSeries = series.find((serie) => serie.tone === 'good')
|
||||
const crossingIndex = crossingLabel
|
||||
? series[0].rows.findIndex((row) => row.label === crossingLabel)
|
||||
: -1
|
||||
const showCrossing = Boolean(goodSeries) && crossingIndex >= 1
|
||||
const crossingGuide = showCrossing
|
||||
? (() => {
|
||||
const point = pointsOf(goodSeries)[crossingIndex]
|
||||
const center = crossingCenter(point.x, cfg)
|
||||
return `<line class="dual-crossing-guide" x1="${center}" y1="${point.y + 16}" x2="${center}" y2="${cfg.crossing.boxY}" stroke="${colors.good}" stroke-width="2" stroke-dasharray="8 8" opacity="0.45" />`
|
||||
})()
|
||||
: ''
|
||||
const crossingAnnotation = showCrossing
|
||||
? (() => {
|
||||
const point = pointsOf(goodSeries)[crossingIndex]
|
||||
const box = cfg.crossing
|
||||
const center = crossingCenter(point.x, cfg)
|
||||
return `<circle class="dual-crossing-ring" cx="${point.x}" cy="${point.y}" r="14" fill="${colors.paper}" stroke="${colors.good}" stroke-width="4" />
|
||||
<circle cx="${point.x}" cy="${point.y}" r="7" fill="${colors.good}" />
|
||||
<rect class="dual-crossing-box" x="${Math.round(center - box.boxWidth / 2)}" y="${box.boxY}" width="${box.boxWidth}" height="${box.boxHeight}" rx="8" fill="${colors.paper}" stroke="${colors.grid}" stroke-width="2" />
|
||||
<text x="${center}" y="${box.boxY + box.yearOffset}" text-anchor="middle" class="dual-crossing-year" font-size="30" font-weight="850" fill="${colors.good}">${htmlEscape(crossingLabel)}</text>
|
||||
<text x="${center}" y="${box.boxY + box.copyOffset}" text-anchor="middle" class="dual-crossing-copy" font-size="28" font-weight="750" fill="${colors.ink}">${htmlEscape(goodSeries.name)}</text>
|
||||
<text x="${center}" y="${box.boxY + box.copyOffset2}" text-anchor="middle" class="dual-crossing-copy" font-size="28" font-weight="750" fill="${colors.ink}">ultrapassa</text>`
|
||||
})()
|
||||
: ''
|
||||
|
||||
const axis = dualAxisIndices(count)
|
||||
.map(
|
||||
(index) =>
|
||||
`<text x="${xAt(index)}" y="${cfg.height - 14}" text-anchor="middle" class="dual-axis-label" font-size="${cfg.axisSize}">${htmlEscape(series[0].rows[index].label)}</text>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
const ariaLabel = `${series
|
||||
.map((serie) => {
|
||||
const change = changeOf(serie)
|
||||
const destination = projected
|
||||
? ` para projeção de ${format(serie.rows[count - 1])} em ${serie.rows[count - 1].label}`
|
||||
: ` para ${format(serie.rows[count - 1])} em ${serie.rows[count - 1].label}`
|
||||
const trend = change
|
||||
? `, variação observada de ${change.percent} até ${serie.rows[observedIndex].label}`
|
||||
: ''
|
||||
return `${serie.name}: de ${format(serie.rows[0])} em ${serie.rows[0].label}${destination}${trend}`
|
||||
})
|
||||
.join('. ')}${showCrossing ? `. ${goodSeries.name} ultrapassa em ${crossingLabel}` : ''}`
|
||||
|
||||
return `<svg class="chart dual-chart" viewBox="0 0 ${cfg.width} ${cfg.height}" role="img" aria-label="${htmlEscape(ariaLabel)}" preserveAspectRatio="xMidYMid meet">
|
||||
${band}
|
||||
${gridLines}
|
||||
${projectionLabel}
|
||||
${crossingGuide}
|
||||
${lines}
|
||||
${projectedMarkers}
|
||||
${crossingAnnotation}
|
||||
${endBlocks}
|
||||
${axis}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series line (Instagram): every point a hollow marker, the last one the
|
||||
* single red highlight with its value. Honest scale — the baseline is zero.
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
* only sets the viewport and clips the canvas.
|
||||
*/
|
||||
|
||||
import { RELATION_LABEL, SIZES } from './chartData.mjs'
|
||||
import { lineChart, proportionalPercent } from './chartPrimitives.mjs'
|
||||
import { RELATION_LABEL, SERIES_RELATION_LABEL, SIZES } from './chartData.mjs'
|
||||
import { dualLineChart, lineChart, proportionalPercent } from './chartPrimitives.mjs'
|
||||
import { htmlEscape } from './reportText.mjs'
|
||||
|
||||
/** Solla palette (verbatim from the intention). */
|
||||
|
|
@ -85,7 +85,39 @@ const anchorBody = (spec) => {
|
|||
</div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-series time line (approved C191 variant): the valence travels by word +
|
||||
* arrow + marker shape + color, never by color alone. Red belongs to the
|
||||
* mandate (the good path); the falling series stays neutral.
|
||||
*/
|
||||
const DUAL_COLORS = {
|
||||
good: SOLLA_PALETTE.highlight,
|
||||
bad: SOLLA_PALETTE.axis,
|
||||
neutralA: SOLLA_PALETTE.ink,
|
||||
neutralB: SOLLA_PALETTE.axis,
|
||||
grid: SOLLA_PALETTE.barStrong,
|
||||
paper: SOLLA_PALETTE.paper,
|
||||
ink: SOLLA_PALETTE.ink,
|
||||
change: SOLLA_PALETTE.label,
|
||||
}
|
||||
|
||||
const DUAL_VALENCE = { good: '↑ amplia', bad: '↓ recua' }
|
||||
|
||||
const dualBody = (spec) => {
|
||||
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
|
||||
return `<div class="dual-line-plot">${dualLineChart({
|
||||
series: spec.series,
|
||||
size: sizeKey,
|
||||
projected: Boolean(spec.projectedLabel),
|
||||
crossingLabel: spec.crossingLabel ?? null,
|
||||
colors: DUAL_COLORS,
|
||||
valence: DUAL_VALENCE,
|
||||
format: (row) => formatValue(row.value),
|
||||
})}</div>`
|
||||
}
|
||||
|
||||
const plotBody = (spec) => {
|
||||
if (Array.isArray(spec.series) && spec.series.length > 0) return dualBody(spec)
|
||||
if (spec.chartType === 'anchor') return anchorBody(spec)
|
||||
if (spec.chartType === 'column') return columnBody(spec)
|
||||
if (spec.chartType === 'line') {
|
||||
|
|
@ -152,6 +184,22 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
|
|||
.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; }
|
||||
.line-plot { height: 100%; display: flex; align-items: center; }
|
||||
.line-plot svg { width: 100%; height: auto; }
|
||||
.dual-line-plot { flex: none; }
|
||||
.dual-line-plot svg { display: block; width: 880px; height: auto; }
|
||||
.dual-projection-band { fill: ${SOLLA_PALETTE.barStrong}; fill-opacity: 0.16; }
|
||||
.dual-grid-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-opacity: 0.32; stroke-width: 2; }
|
||||
.dual-zero-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; }
|
||||
.dual-axis-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 650; }
|
||||
.dual-projection-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 700; }
|
||||
.dual-end-name { fill: ${SOLLA_PALETTE.ink}; font-weight: 700; }
|
||||
.dual-end-value { fill: ${SOLLA_PALETTE.ink}; font-weight: 850; font-variant-numeric: tabular-nums; }
|
||||
.dual-end-valence { font-weight: 850; }
|
||||
.dual-end-change { fill: ${SOLLA_PALETTE.label}; font-weight: 700; }
|
||||
.dual-crossing-guide { stroke: ${SOLLA_PALETTE.highlight}; stroke-width: 2; stroke-dasharray: 8 8; opacity: 0.45; }
|
||||
.dual-crossing-ring { fill: ${SOLLA_PALETTE.paper}; stroke: ${SOLLA_PALETTE.highlight}; stroke-width: 4; }
|
||||
.dual-crossing-box { fill: ${SOLLA_PALETTE.paper}; stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; }
|
||||
.dual-crossing-year { fill: ${SOLLA_PALETTE.highlight}; font-weight: 850; }
|
||||
.dual-crossing-copy { fill: ${SOLLA_PALETTE.ink}; font-weight: 750; }
|
||||
.anchor { padding-top: 20px; }
|
||||
.anchor-number { color: ${SOLLA_PALETTE.highlight}; font-size: 300px; line-height: 0.86; letter-spacing: -0.065em; font-weight: 900; font-variant-numeric: tabular-nums; }
|
||||
.anchor-copy { max-width: 760px; margin: 40px 0 0; font-size: 52px; line-height: 1.12; font-weight: 750; }
|
||||
|
|
@ -208,7 +256,15 @@ export const renderChartHtml = (spec) => {
|
|||
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
|
||||
const canvas = SIZES[sizeKey]
|
||||
const layout = layoutFor(sizeKey)
|
||||
const kicker = RELATION_LABEL[spec.chartType] ?? 'Gráfico'
|
||||
const dual = Array.isArray(spec.series) && spec.series.length > 0
|
||||
const dualStyles = !dual
|
||||
? ''
|
||||
: sizeKey === 'feed'
|
||||
? '.headline { font-size: 60px; max-width: 900px; } .subtitle { font-size: 28px; } .source { font-size: 25px; } .plot { margin-top: 28px; }'
|
||||
: sizeKey === 'square'
|
||||
? '.inner { padding: 56px 84px 48px; } .headline { font-size: 54px; } .plot { margin-top: 20px; }'
|
||||
: '.headline { font-size: 64px; } .plot { margin-top: 28px; }'
|
||||
const kicker = dual ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico')
|
||||
const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label
|
||||
const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle
|
||||
return `<!doctype html>
|
||||
|
|
@ -224,10 +280,11 @@ export const renderChartHtml = (spec) => {
|
|||
.headline { font-size: ${layout.headline}px; max-width: ${canvas.width - 200}px; }
|
||||
.subtitle { margin-top: ${Math.round(layout.gapAfterContext * 1.28)}px; font-size: ${layout.subtitle}px; max-width: ${canvas.width - 260}px; }
|
||||
.plot { margin-top: ${layout.plotGap}px; }
|
||||
${dualStyles}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="canvas" role="img" aria-label="${htmlEscape(spec.headline)}">
|
||||
<article class="canvas${dual ? ' dual-series' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
|
||||
<div class="inner">
|
||||
<div class="top-rule"></div>
|
||||
<p class="context">${htmlEscape(kicker)}</p>
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
CHART_COLORS,
|
||||
barChart,
|
||||
columnChart,
|
||||
dualLineChart,
|
||||
lineChart,
|
||||
proportionalPercent,
|
||||
stackedColumnChart,
|
||||
|
|
@ -140,3 +141,156 @@ describe('lineChart — series with one highlighted end', () => {
|
|||
expect(svg).toContain('fill="#c51414"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dualLineChart — the approved two-series time line', () => {
|
||||
const annual = (values: number[]) =>
|
||||
values.map((value, index) => ({ label: `${2017 + index}`, value }))
|
||||
const series = [
|
||||
{
|
||||
name: 'Estadual',
|
||||
tone: 'good',
|
||||
rows: annual([9807, 10636, 11807, 10929, 13119, 14767, 14501, 20495, 21637, 23294]),
|
||||
},
|
||||
{
|
||||
name: 'Municipal',
|
||||
tone: 'bad',
|
||||
rows: annual([24002, 24438, 22269, 20305, 21562, 20608, 19983, 19726, 18588, 15948]),
|
||||
},
|
||||
]
|
||||
const colors = {
|
||||
good: '#c51414',
|
||||
bad: '#78716c',
|
||||
neutralA: '#1c1917',
|
||||
neutralB: '#78716c',
|
||||
grid: '#a8a29e',
|
||||
paper: '#faf9f7',
|
||||
ink: '#1c1917',
|
||||
change: '#57534e',
|
||||
}
|
||||
const valence = { good: '↑ amplia', bad: '↓ recua' }
|
||||
const format = (row: { value: number }) =>
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value)
|
||||
const svg = dualLineChart({
|
||||
series,
|
||||
size: 'feed',
|
||||
projected: true,
|
||||
crossingLabel: '2024',
|
||||
colors,
|
||||
valence,
|
||||
format,
|
||||
})
|
||||
|
||||
it('draws the good path in the mandate red and the bad one neutral', () => {
|
||||
expect(svg).toContain('class="dual-series dual-series-good"')
|
||||
expect(svg).toContain('stroke="#c51414"')
|
||||
expect(svg).toContain('class="dual-series dual-series-bad"')
|
||||
expect(svg).toContain('stroke="#78716c"')
|
||||
expect(svg).toContain('<g class="dual-markers-good" fill="#c51414"><circle')
|
||||
expect(svg).toContain('<g class="dual-markers-bad" fill="#78716c"><rect')
|
||||
})
|
||||
|
||||
it('labels each line with name, value, valence word and observed change', () => {
|
||||
expect(svg).toContain('>Estadual<')
|
||||
expect(svg).toContain('>23.294<')
|
||||
expect(svg).toContain('>↑ amplia<')
|
||||
expect(svg).toContain('>+121% até 2025<')
|
||||
expect(svg).toContain('>Municipal<')
|
||||
expect(svg).toContain('>15.948<')
|
||||
expect(svg).toContain('>↓ recua<')
|
||||
expect(svg).toContain('>−23% até 2025<')
|
||||
})
|
||||
|
||||
it('marks only the last segment as a projection', () => {
|
||||
expect(svg.match(/stroke-dasharray="18 12"/g)).toHaveLength(2)
|
||||
expect(svg).toContain('class="dual-projection-band"')
|
||||
expect(svg).toContain('>projeção<')
|
||||
expect(svg).toContain('class="dual-marker-projected dual-marker-projected-good"')
|
||||
expect(svg).toContain('class="dual-marker-projected dual-marker-projected-bad"')
|
||||
})
|
||||
|
||||
it('annotates the confirmed crossing with ring, guide and box', () => {
|
||||
expect(svg).toContain('class="dual-crossing-guide"')
|
||||
expect(svg).toContain('class="dual-crossing-ring"')
|
||||
expect(svg).toContain('class="dual-crossing-box"')
|
||||
expect(svg).toContain('class="dual-crossing-year"')
|
||||
expect(svg).toContain('>2024<')
|
||||
expect(svg).toContain('class="dual-crossing-copy"')
|
||||
expect(svg).toContain('>ultrapassa<')
|
||||
})
|
||||
|
||||
it('keeps the crossing out when it was not confirmed', () => {
|
||||
const withoutCrossing = dualLineChart({
|
||||
series,
|
||||
size: 'feed',
|
||||
projected: true,
|
||||
colors,
|
||||
valence,
|
||||
format,
|
||||
})
|
||||
expect(withoutCrossing).not.toContain('dual-crossing')
|
||||
expect(withoutCrossing).not.toContain('ultrapassa')
|
||||
})
|
||||
|
||||
it('keeps the honest zero base and the shared legible ceiling', () => {
|
||||
expect(svg).toContain('viewBox="0 0 880 620"')
|
||||
expect(svg).toContain('y1="544"')
|
||||
expect(svg).toContain('>0<')
|
||||
expect(svg.match(/class="dual-grid-line"/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('alternates the ten annual axis labels, always keeping first and last', () => {
|
||||
for (const label of ['2017', '2019', '2021', '2023', '2026']) {
|
||||
expect(svg).toContain(`>${label}<`)
|
||||
}
|
||||
for (const label of ['2018', '2020', '2022', '2025']) {
|
||||
expect(svg).not.toContain(`>${label}<`)
|
||||
}
|
||||
})
|
||||
|
||||
it('adapts the geometry to square and to Stories', () => {
|
||||
const square = dualLineChart({
|
||||
series,
|
||||
size: 'square',
|
||||
projected: true,
|
||||
colors,
|
||||
valence,
|
||||
format,
|
||||
})
|
||||
expect(square).toContain('viewBox="0 0 880 440"')
|
||||
expect(square).toContain('stroke-dasharray="15 11"')
|
||||
expect(square).toContain('font-size="32"')
|
||||
const story = dualLineChart({ series, size: 'story', projected: true, colors, valence, format })
|
||||
expect(story).toContain('viewBox="0 0 880 700"')
|
||||
expect(story).toContain('stroke-dasharray="18 12"')
|
||||
expect(story).toContain('font-size="36"')
|
||||
})
|
||||
|
||||
it('compares neutrally without valence or red when no tone is informed', () => {
|
||||
const neutral = dualLineChart({
|
||||
series: series.map(({ name, rows }) => ({ name, rows })),
|
||||
size: 'feed',
|
||||
projected: false,
|
||||
colors,
|
||||
valence,
|
||||
format,
|
||||
})
|
||||
expect(neutral).toContain('class="dual-series dual-series-a"')
|
||||
expect(neutral).toContain('class="dual-series dual-series-b"')
|
||||
expect(neutral).not.toContain('#c51414')
|
||||
expect(neutral).not.toContain('amplia')
|
||||
expect(neutral).not.toContain('recua')
|
||||
expect(neutral).not.toContain('dual-projection-band')
|
||||
})
|
||||
|
||||
it('escapes untrusted series names', () => {
|
||||
const evil = dualLineChart({
|
||||
series: [{ ...series[0], name: '<b>x</b>' }, series[1]],
|
||||
size: 'feed',
|
||||
projected: false,
|
||||
colors,
|
||||
valence,
|
||||
format,
|
||||
})
|
||||
expect(evil).toContain('<b>x</b>')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -106,3 +106,87 @@ describe('SOLLA_PALETTE — the verbatim brand colors', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
// C191 approved variant: the two-series time line carries valence by word +
|
||||
// arrow + marker shape + color, on a shared zero-based scale.
|
||||
const annual = (values: number[]) =>
|
||||
values.map((value, index) => ({ label: `${2017 + index}`, value }))
|
||||
const seriesSpec = (overrides = {}) => ({
|
||||
chartType: 'line',
|
||||
size: 'feed',
|
||||
headline: 'Internações crescem no hospital estadual e recuam no municipal',
|
||||
subtitle: 'AIH aprovadas por ano · Vitória da Conquista (BA) · 2017–2026',
|
||||
source: 'Ministério da Saúde — SIH/SUS',
|
||||
note: '2026 é projeção pela média de janeiro a junho × 2.',
|
||||
projectedLabel: '2026',
|
||||
crossingLabel: '2024',
|
||||
series: [
|
||||
{
|
||||
name: 'Estadual',
|
||||
tone: 'good',
|
||||
rows: annual([9807, 10636, 11807, 10929, 13119, 14767, 14501, 20495, 21637, 23294]),
|
||||
},
|
||||
{
|
||||
name: 'Municipal',
|
||||
tone: 'bad',
|
||||
rows: annual([24002, 24438, 22269, 20305, 21562, 20608, 19983, 19726, 18588, 15948]),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('renderChartHtml — the two-series time line', () => {
|
||||
it('renders the dual plot with the fixed kicker, changes and valence words', () => {
|
||||
const html = renderChartHtml(seriesSpec())
|
||||
expect(html).toContain('dual-line-plot')
|
||||
expect(html).toContain('Comparação no tempo')
|
||||
expect(html).toContain('↑ amplia')
|
||||
expect(html).toContain('↓ recua')
|
||||
expect(html).toContain('+121%')
|
||||
expect(html).toContain('−23%')
|
||||
expect(html).toContain('23.294')
|
||||
})
|
||||
|
||||
it('annotates the confirmed crossing without inferring it by itself', () => {
|
||||
expect(renderChartHtml(seriesSpec())).toContain('ultrapassa')
|
||||
expect(renderChartHtml(seriesSpec({ crossingLabel: null }))).not.toContain('ultrapassa')
|
||||
})
|
||||
|
||||
it('uses the compact feed rhythm of the variant', () => {
|
||||
const html = renderChartHtml(seriesSpec())
|
||||
expect(html).toContain('font-size: 60px')
|
||||
expect(html).toContain('margin-top: 28px')
|
||||
})
|
||||
|
||||
it('adapts the plot and the rhythm to square and to Stories', () => {
|
||||
const square = renderChartHtml(seriesSpec({ size: 'square' }))
|
||||
expect(square).toContain('viewBox="0 0 880 440"')
|
||||
expect(square).toContain('font-size: 54px')
|
||||
const story = renderChartHtml(seriesSpec({ size: 'story' }))
|
||||
expect(story).toContain('viewBox="0 0 880 700"')
|
||||
expect(story).toContain('font-size: 64px')
|
||||
})
|
||||
|
||||
it('keeps red out of the plot when the comparison is neutral', () => {
|
||||
const { series } = seriesSpec()
|
||||
const html = renderChartHtml(
|
||||
seriesSpec({
|
||||
crossingLabel: null,
|
||||
series: series.map((serie: { name: string; rows: unknown[] }) => ({
|
||||
name: serie.name,
|
||||
rows: serie.rows,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
const svg = html.slice(html.indexOf('<svg'), html.indexOf('</svg>'))
|
||||
expect(svg).not.toContain('#c51414')
|
||||
expect(svg).not.toContain('amplia')
|
||||
})
|
||||
|
||||
it('carries the projection note and the source inside the image', () => {
|
||||
const html = renderChartHtml(seriesSpec())
|
||||
expect(html).toContain('2026 é projeção pela média de janeiro a junho × 2.')
|
||||
expect(html).toContain('Ministério da Saúde — SIH/SUS')
|
||||
expect(html).toContain('JORGE SOLLA')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue