mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
feat(C191): barra de variação (delta) no gráfico de Instagram — base e extensão por categoria, sem valência
Skill, núcleo (chartData), renderer (deltaBody/CSS), design CENA 02C com sign-off humano e testes. Entrega: docs/research/graficos-instagram/equipes-saude-vdc-2020-2026-feed.png (worktree work/20, sem claim).
This commit is contained in:
parent
cf5b353fd2
commit
4ac68de29c
9 changed files with 1043 additions and 21 deletions
|
|
@ -48,6 +48,9 @@ O template visual é o artefato aprovado
|
|||
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 medidas por categoria → `delta`**: barra de variação com a base
|
||||
(0 → inicial) num tom e a extensão (inicial → final) noutro, os dois valores
|
||||
numa gutter de duas colunas e o vermelho fora do plot (sem valência);
|
||||
- **uma única medida → `anchor`** (número grande + frase que explica).
|
||||
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
|
||||
|
|
@ -69,6 +72,14 @@ O template visual é o artefato aprovado
|
|||
--type=line --size=feed \
|
||||
--good="Estadual" --bad="Municipal" --projected=2026 --crossing=2024
|
||||
```
|
||||
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 \
|
||||
--headline="Quatro dos sete tipos de equipe de saúde seguem sem ampliação desde 2020" \
|
||||
--subtitle="Vitória da Conquista — número de equipes por tipo (2020 → jul/2026)" \
|
||||
--source="Ministério da Saúde — CNES" \
|
||||
--note="Sem variação: EMAD, EMAP, ENASF-AB e ECR."
|
||||
```
|
||||
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
|
||||
|
|
@ -111,8 +122,8 @@ Na linha de 2 séries, `series` substitui `rows` e `projectedLabel` marca o
|
|||
}
|
||||
```
|
||||
|
||||
`chartType` ∈ `bar | column | line | anchor`; `highlight` é o rótulo que recebe o
|
||||
único vermelho `#c51414` (default: o maior valor). Em `series`, `tone` ∈
|
||||
`chartType` ∈ `bar | column | line | anchor | delta`; `highlight` é o rótulo que
|
||||
recebe o ú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
|
||||
|
|
@ -120,6 +131,22 @@ quando a série boa de fato ultrapassa a ruim naquele período (confirmação
|
|||
textual, nunca inferência automática). Os rótulos finais trazem a variação
|
||||
observada (primeiro → último ponto observado, fora a projeção).
|
||||
|
||||
Na barra de variação, `rows` traz `initial`/`final` por categoria e
|
||||
`startLabel`/`endLabel` nomeiam os dois períodos da gutter (o vermelho não entra
|
||||
no plot e a variante não aceita `highlight`):
|
||||
|
||||
```json
|
||||
{
|
||||
"chartType": "delta",
|
||||
"size": "feed",
|
||||
"headline": "Quatro dos sete tipos seguem sem ampliação desde 2020",
|
||||
"source": "Ministério da Saúde — CNES",
|
||||
"startLabel": "2020",
|
||||
"endLabel": "jul/2026",
|
||||
"rows": [{ "label": "ESF · Saúde da Família", "initial": 58, "final": 63 }]
|
||||
}
|
||||
```
|
||||
|
||||
## Guardrails (fail-closed)
|
||||
|
||||
O builder **recusa** em vez de desenhar algo enganoso:
|
||||
|
|
@ -130,7 +157,13 @@ O builder **recusa** em vez de desenhar algo enganoso:
|
|||
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.
|
||||
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;
|
||||
`--highlight`/valência não entram (o par de tons é fixo e o vermelho fica fora
|
||||
do plot).
|
||||
- **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
|
||||
|
|
|
|||
1
docs/changelog/2026-09-19-c191-delta.md
Normal file
1
docs/changelog/2026-09-19-c191-delta.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C191 — Barra de variação (delta) no gráfico de Instagram (2026-09-19):** a peça por tipo de equipe de saúde precisava mostrar base e extensão (2020 → 2026) numa barra por categoria, e o template só tinha barra/coluna/linha/âncora/duas séries; pedido da comunicação em sessão direta (worktree `work/20`, sem claim). (1) **Design**: sexta variante no `docs/plans/graficos-dados-instagram-ui-design.html` (CENA 02C), aprovada por sign-off humano em 2026-09-19 com o tier degradado registrado (quota do designer frontier indisponível): barra zero-based com base 0→inicial em `#a8a29e` e extensão inicial→final em `#d6d3d1` (tick de 3 px `#78716c` no degrau), dois valores em gutter tabular ("2020" e "jul/2026") e sem valência — `#c51414` fica fora do plot; 7 categorias inclusive no story (sem top-N, que apagaria a manchete "quatro dos sete"). (2) **Contrato**: `chartType: 'delta'` com `rows: [{ label, initial, final }]` + `startLabel`/`endLabel`; tabela de 3 colunas lida por `--type=delta` (xlsx/csv/tsv/md/txt) com o cabeçalho nomeando os dois períodos; fail-closed para retração (sugere a linha de 2 séries), valor negativo, final ≤ 0, mais de 7 categorias, medida ausente e `--highlight` (o par de tons é fixo). (3) **Renderer**: `deltaBody` portado classe-a-classe do design (`.delta-grade`, `.delta-head`, `.delta-row`, `.delta-label`, `.delta-track`, `.delta-base`, `.delta-ext`, `.delta-value`, `.delta-period`), com os ritmos de feed/square/story e o kicker "Variação no período"; sigla em negrito antes do "·". (4) **Docs**: contrato do `chart-spec.json`, exemplo de CLI e guardrail da variante no `SKILL.md`. (5) **Testes**: unit do parse (tabela com dois períodos, medida ausente flagrada), do validate (retração, highlight, teto de 7, story sem corte, medidas inválidas) e do render (gutter, ordem por final, flat sem `delta-ext`, proporções 92,1%/7,9%, sem vermelho no plot, story com as 7). Entrega final: `docs/research/graficos-instagram/equipes-saude-vdc-2020-2026-feed.png` (7 tipos do CNES sem a EAP; "Quatro dos sete tipos de equipe de saúde seguem sem ampliação desde 2020").
|
||||
|
|
@ -460,6 +460,129 @@
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* CENA 02C · PROPOSTA DEGRADED — variante "delta": uma barra por */
|
||||
/* categoria com base (0→inicial) e extensão (inicial→final). O */
|
||||
/* vermelho NÃO entra no plot: a leitura é crítica e não há valência */
|
||||
/* de "bom". O par de tons é a única ênfase, repetida por linha. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.delta-grade {
|
||||
--delta-label-w: 292px;
|
||||
--delta-v1-w: 88px;
|
||||
--delta-v2-w: 110px;
|
||||
--delta-gap: 20px;
|
||||
--delta-bar-h: 53px;
|
||||
--delta-base: #a8a29e;
|
||||
--delta-ext: #d6d3d1;
|
||||
flex: none;
|
||||
display: grid;
|
||||
gap: var(--delta-gap);
|
||||
margin-top: 34px;
|
||||
}
|
||||
.delta-head,
|
||||
.delta-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--delta-label-w) 1fr var(--delta-v1-w) var(--delta-v2-w);
|
||||
align-items: center;
|
||||
gap: var(--delta-gap);
|
||||
}
|
||||
.delta-head {
|
||||
align-items: end;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.delta-label {
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
font-weight: 650;
|
||||
}
|
||||
.delta-label b {
|
||||
font-weight: 850;
|
||||
}
|
||||
.delta-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: var(--delta-bar-h);
|
||||
border-left: 2px solid #78716c; /* zero real, nunca truncado */
|
||||
}
|
||||
.delta-base {
|
||||
height: 100%;
|
||||
background: var(--delta-base);
|
||||
}
|
||||
.delta-ext {
|
||||
height: 100%;
|
||||
background: var(--delta-ext);
|
||||
border-left: 3px solid #78716c; /* marcador de degrau: forma, não cor */
|
||||
}
|
||||
.delta-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 31px;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.delta-value.initial {
|
||||
color: #57534e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.delta-value.final {
|
||||
color: #1c1917;
|
||||
font-weight: 850;
|
||||
}
|
||||
.delta-period {
|
||||
color: #57534e;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
font-weight: 750;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.feed-canvas.delta .chart-headline {
|
||||
max-width: 900px;
|
||||
font-size: 60px;
|
||||
}
|
||||
.feed-canvas.delta .chart-subtitle {
|
||||
margin-top: 18px;
|
||||
font-size: 28px;
|
||||
}
|
||||
.feed-canvas.delta .delta-grade {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.story-canvas.delta .delta-grade {
|
||||
--delta-gap: 22px;
|
||||
--delta-bar-h: 52px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
.story-canvas.delta .story-title {
|
||||
font-size: 64px;
|
||||
}
|
||||
.degraded-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 18px;
|
||||
padding: 12px 16px;
|
||||
border: 2px dashed var(--solla);
|
||||
border-radius: 10px;
|
||||
background: #fff5f5;
|
||||
color: #7f1d1d;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
}
|
||||
.degraded-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: 6px;
|
||||
background: var(--solla);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.story-stage {
|
||||
width: 351px;
|
||||
height: 624px;
|
||||
|
|
@ -1711,6 +1834,414 @@
|
|||
</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. -->
|
||||
<!-- ================================================================== -->
|
||||
<section
|
||||
class="scene-card mt-10 border-2 border-dashed border-solla/60 p-4 sm:p-6 lg:p-8"
|
||||
aria-labelledby="delta-title"
|
||||
>
|
||||
<div class="degraded-banner">
|
||||
<span class="degraded-badge">DEGRADED · SIGN-OFF HUMANO 2026-09-19</span>
|
||||
<span
|
||||
>Proposta criada no tier degradado (quota do designer frontier indisponível) e
|
||||
<strong>aprovada por sign-off humano</strong> em 2026-09-19 — visual como está, com o
|
||||
“quatro dos sete tipos” confirmado. Portada classe-a-classe no renderer e nos testes na
|
||||
mesma data; a certificação pelo designer frontier segue pendente.</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-bold text-brand-dark">
|
||||
CENA 02C · SEXTA VARIAÇÃO · SIGN-OFF HUMANO 2026-09-19
|
||||
</p>
|
||||
<h2 id="delta-title" class="mt-1 text-xl font-bold tracking-tight">
|
||||
Feed 4:5 · barra de variação por categoria (2020 → jul/2026)
|
||||
</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">chartType proposto: delta</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 delta"
|
||||
aria-label="Exemplo de barra de variação por categoria para feed, com dados de Vitória da Conquista"
|
||||
>
|
||||
<div class="feed-inner">
|
||||
<div class="top-rule"></div>
|
||||
<p class="chart-context">Variação no período</p>
|
||||
<h3 class="chart-headline">
|
||||
Quatro dos sete tipos de equipe de saúde seguem sem ampliação desde 2020
|
||||
</h3>
|
||||
<p class="chart-subtitle">
|
||||
Vitória da Conquista — número de equipes por tipo (2020 → jul/2026)
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="delta-grade tabular"
|
||||
role="img"
|
||||
aria-label="Barras por tipo de equipe de saúde. ESF: 58 equipes em 2020 e 63 em julho de 2026. ESB: 40 e 53. ENASF-AB: 5 e 5. EABP: 1 e 4. EMAD: 1 e 1. EMAP: 1 e 1. ECR: 1 e 1. Quatro dos sete tipos seguem sem ampliação; a escala parte do zero real."
|
||||
>
|
||||
<div class="delta-head">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span class="delta-period">2020</span>
|
||||
<span class="delta-period">jul/2026</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>ESF</b> · Saúde da Família</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 92.1%"></span>
|
||||
<span class="delta-ext" style="width: 7.9%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">58</span>
|
||||
<span class="delta-value final">63</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>ESB</b> · Saúde Bucal</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 63.5%"></span>
|
||||
<span class="delta-ext" style="width: 20.6%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">40</span>
|
||||
<span class="delta-value final">53</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>ENASF-AB</b> · Núcleo Ampliado</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 7.9%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">5</span>
|
||||
<span class="delta-value final">5</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>EABP</b> · Atenção Primária Prisional</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 1.6%"></span>
|
||||
<span class="delta-ext" style="width: 4.8%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">1</span>
|
||||
<span class="delta-value final">4</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>EMAD</b> · Atenção Domiciliar</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 1.6%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">1</span>
|
||||
<span class="delta-value final">1</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>EMAP</b> · Apoio Multidisciplinar</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 1.6%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">1</span>
|
||||
<span class="delta-value final">1</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>ECR</b> · Consultórios na Rua</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 1.6%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">1</span>
|
||||
<span class="delta-value final">1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="chart-footer">
|
||||
<p class="source-line">
|
||||
<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>
|
||||
</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">O QUE A BARRA DIZ · SEM VALÊNCIA</p>
|
||||
<p class="mt-2 text-sm leading-6 text-stone-700">
|
||||
Base (0 → 2020) em <span class="rule-code">#a8a29e</span>; extensão (2020 → 2026) em
|
||||
<span class="rule-code">#d6d3d1</span>, separados por um tick de 3 px em
|
||||
<span class="rule-code">#78716c</span> no ponto de degrau. Não há seta, palavra
|
||||
“amplia/recua”, bom/ruim nem valorização: a extensão maior não ganha cor de prêmio.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-dashed border-stone-400 bg-stone-50 p-4">
|
||||
<p class="text-xs font-extrabold text-stone-700">O VERMELHO FICA FORA DO PLOT</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
<span class="rule-code">#c51414</span> não entra em nenhum segmento. O único
|
||||
vermelho da peça continua sendo a régua superior e a marca do template. O “destaque
|
||||
único” vira a própria leitura: a ausência de extensão em quatro linhas, sustentada
|
||||
pela nota — não uma marca de cor.
|
||||
<strong>Reinterpretação sujeita a sign-off.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">SEM EXTENSÃO = SEM SEGMENTO</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Quando inicial = final, o <span class="rule-code">delta-ext</span> simplesmente não
|
||||
existe: a barra é um tom só e a gutter mostra dois números iguais (5 / 5, 1 / 1).
|
||||
Nada de extensão falsa e nada de <span class="rule-code">min-width</span> que infle
|
||||
o crescimento.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-4">
|
||||
<p class="text-xs font-bold text-brand-dark">POR QUE OS VALORES NA GUTTER</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
São dois valores por linha e a base pode ser minúscula (1 de 63). A gutter tabular à
|
||||
direita, com duas colunas alinhadas sob “2020” e “jul/2026”, mantém os dois números
|
||||
legíveis sem distorcer a barra; o ranking de valor único continua pondo o número na
|
||||
ponta.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-stone-200 pt-6">
|
||||
<p class="text-xs font-bold text-brand-dark">
|
||||
ADAPTAÇÃO STORIES · MESMA REGRA, SEM CORTAR CATEGORIA
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-bold tracking-tight">
|
||||
Stories 9:16 · as sete categorias, sem top-N
|
||||
</h3>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-5 text-stone-600">
|
||||
O ranking certificado reduz para 5 pontos no story. Esta variante
|
||||
<strong>não reduz</strong>: um top-N apagaria a manchete “quatro dos sete”. O story
|
||||
recomprime a barra e o gap, mantém os dois números na gutter e exige a nota das
|
||||
categorias sem variação.
|
||||
</p>
|
||||
|
||||
<div class="story-stage mt-6">
|
||||
<article
|
||||
class="story-canvas delta"
|
||||
aria-label="Adaptação da barra de variação para Stories, com faixas seguras de 250 pixels e as sete categorias"
|
||||
>
|
||||
<span class="safe-label top">250 px · evitar conteúdo essencial</span>
|
||||
<span class="safe-label bottom">250 px · evitar conteúdo essencial</span>
|
||||
<div class="story-content">
|
||||
<div class="top-rule"></div>
|
||||
<p class="chart-context">Variação no período</p>
|
||||
<h3 class="story-title">
|
||||
Quatro dos sete tipos de equipe de saúde seguem sem ampliação desde 2020
|
||||
</h3>
|
||||
<p class="story-sub">
|
||||
Vitória da Conquista — número de equipes por tipo (2020 → jul/2026)
|
||||
</p>
|
||||
<div
|
||||
class="delta-grade tabular"
|
||||
role="img"
|
||||
aria-label="Barras por tipo de equipe de saúde. ESF: 58 equipes em 2020 e 63 em julho de 2026. ESB: 40 e 53. ENASF-AB: 5 e 5. EABP: 1 e 4. EMAD: 1 e 1. EMAP: 1 e 1. ECR: 1 e 1. Quatro dos sete tipos seguem sem ampliação."
|
||||
>
|
||||
<div class="delta-head">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span class="delta-period">2020</span>
|
||||
<span class="delta-period">jul/2026</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>ESF</b> · Saúde da Família</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 92.1%"></span>
|
||||
<span class="delta-ext" style="width: 7.9%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">58</span>
|
||||
<span class="delta-value final">63</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>ESB</b> · Saúde Bucal</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 63.5%"></span>
|
||||
<span class="delta-ext" style="width: 20.6%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">40</span>
|
||||
<span class="delta-value final">53</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>ENASF-AB</b> · Núcleo Ampliado</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 7.9%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">5</span>
|
||||
<span class="delta-value final">5</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>EABP</b> · Atenção Primária Prisional</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 1.6%"></span>
|
||||
<span class="delta-ext" style="width: 4.8%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">1</span>
|
||||
<span class="delta-value final">4</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>EMAD</b> · Atenção Domiciliar</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 1.6%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">1</span>
|
||||
<span class="delta-value final">1</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>EMAP</b> · Apoio Multidisciplinar</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 1.6%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">1</span>
|
||||
<span class="delta-value final">1</span>
|
||||
</div>
|
||||
<div class="delta-row">
|
||||
<span class="delta-label"><b>ECR</b> · Consultórios na Rua</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width: 1.6%"></span>
|
||||
</div>
|
||||
<span class="delta-value initial">1</span>
|
||||
<span class="delta-value final">1</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="story-footer">
|
||||
<div class="story-source">
|
||||
<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>
|
||||
</footer>
|
||||
</div>
|
||||
<span class="asset-flag" style="bottom: 270px">NEEDS ASSET · LOGO OFICIAL</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 border-t border-stone-200 pt-6" aria-labelledby="delta-rules-title">
|
||||
<p class="text-xs font-bold text-brand-dark">
|
||||
REGRAS DA VARIANTE DE VARIAÇÃO (PROPOSTA DEGRADED)
|
||||
</p>
|
||||
<h3 id="delta-rules-title" class="mt-1 text-lg font-bold tracking-tight">
|
||||
Contrato visual proposto para o porte classe-a-class — pendente de sign-off
|
||||
</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-xl border border-dashed border-stone-400 bg-stone-50 p-4">
|
||||
<p class="text-sm font-bold">Entrada, teto e ordem</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
N categorias com <span class="rule-code">initial</span> e
|
||||
<span class="rule-code">final</span>,
|
||||
<span class="rule-code">0 ≤ initial ≤ final</span>. Máximo <strong>7</strong>.
|
||||
Ordem: final desc (regra do ranking; empate por initial, depois rótulo). Nada de
|
||||
top-N, “Outros” ou corte silencioso: acima de 7 ou desalinhado,
|
||||
<strong>perguntar</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-dashed border-stone-400 bg-stone-50 p-4">
|
||||
<p class="text-sm font-bold">Tons e regra do vermelho</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Par fixo: base = <span class="rule-code">#a8a29e</span> (0 → inicial), extensão =
|
||||
<span class="rule-code">#d6d3d1</span> (inicial → final). O vermelho
|
||||
<span class="rule-code">#c51414</span> <strong>não entra no plot</strong>;
|
||||
`highlight` não é aceito nesta variante. Sem `↑`/`↓`, sem “amplia/recua”, sem
|
||||
valência bom/ruim. O único vermelho é a régua superior e a marca.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-dashed border-stone-400 bg-stone-50 p-4">
|
||||
<p class="text-sm font-bold">Valores e zero</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Gutter à direita com duas colunas alinhadas (“2020” e “jul/2026”), tabular-nums.
|
||||
Inicial em <span class="rule-code">#57534e</span>, final em
|
||||
<span class="rule-code">#1c1917</span>. Zero = borda esquerda de 2 px
|
||||
<span class="rule-code">#78716c</span> do track; sem eixo truncado. A extensão é
|
||||
exatamente proporcional (sem min-width): o número carrega o que a barra não alcança.
|
||||
</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">
|
||||
Padding 76/84/58; headline <strong>60 px</strong>; subtítulo 28 px; plot margin-top
|
||||
30. Linha: label 292 / gap 20 / track / gutter 88 + 110; barra 53 px; gap 20;
|
||||
valores 31 px; cabeçalho 30 px. 7 categorias (2 linhas de rótulo, se preciso).
|
||||
</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">
|
||||
Padding 56/84/48; headline 54 px; subtítulo 28 px; plot margin-top 24. Barra 42 px;
|
||||
gap 12; label 292; gutter 84 + 104; valores 30 px. Mantém as 7 categorias: se as
|
||||
duas linhas de rótulo não couberem, encurtar descriptor/headline ou sugerir feed —
|
||||
nunca top-N.
|
||||
</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 de 250 px; conteúdo padding 306/84/310; headline 64 px; plot margin-top 40.
|
||||
Barra 52 px; gap 22; label 292; gutter 88 + 110; valores 31 px. Mantém as
|
||||
<strong>7</strong> categorias (não reduz para 5); tudo entre y=250 e y=1670. Nota
|
||||
com as sem-variação obrigatória.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-solla/60 bg-white p-4 md:col-span-2 xl:col-span-3"
|
||||
>
|
||||
<p class="text-sm font-bold">Fail-closed (proposto)</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
<span class="rule-code">final < initial</span> (retração) → recusar: uma barra de
|
||||
base zero não representa queda sem eixo negativo; sugerir a linha de 2 séries ou uma
|
||||
variante de retração a definir. <span class="rule-code">initial</span> ausente →
|
||||
perguntar. <span class="rule-code">final ≤ 0</span> → recusar. Mais de 7 categorias
|
||||
→ perguntar/agrupar. Sem <span class="rule-code">source</span> → recusar. Nada de
|
||||
interpolar, completar ou abreviar sozinho.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-stone-400 bg-stone-50 p-4 md:col-span-2 xl:col-span-3"
|
||||
>
|
||||
<p class="text-sm font-bold">Mapa de classes proposto</p>
|
||||
<p class="mt-2 text-sm leading-5 text-stone-600">
|
||||
Container: <span class="rule-code">delta-grade</span>; cabeçalho
|
||||
<span class="rule-code">delta-head</span> +
|
||||
<span class="rule-code">delta-period</span>; linha
|
||||
<span class="rule-code">delta-row</span>; rótulo
|
||||
<span class="rule-code">delta-label</span> (+
|
||||
<span class="rule-code"><b></span> para a sigla); trilho
|
||||
<span class="rule-code">delta-track</span>; base
|
||||
<span class="rule-code">delta-base</span>; extensão
|
||||
<span class="rule-code">delta-ext</span> (ausente quando inicial = final); valores
|
||||
<span class="rule-code">delta-value initial</span> /
|
||||
<span class="rule-code">delta-value final</span>. Modificador de linha sem extensão:
|
||||
<span class="rule-code">is-flat</span> (estado/aria; sem mudança visual — a ausência
|
||||
do <span class="rule-code">delta-ext</span> é a pista). Reaproveitados:
|
||||
<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>,
|
||||
<span class="rule-code">source-line</span>,
|
||||
<span class="rule-code">brand-lockup</span> e
|
||||
<span class="rule-code">asset-flag</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene-card mt-10 p-4 sm:p-6 lg:p-8" aria-labelledby="story-title">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -122,13 +122,14 @@ export const main = async ({
|
|||
}
|
||||
} else {
|
||||
if (!flags.in) die('informe --in=<arquivo|-> com os dados (ou --spec=<json> para replay).')
|
||||
const forcedType = flags.type ? String(flags.type) : null
|
||||
let input
|
||||
try {
|
||||
input = await readInput(flags)
|
||||
} catch (error) {
|
||||
die(`falha ao ler --in=${flags.in}: ${error?.message ?? error}`)
|
||||
}
|
||||
const dataset = parseInput(input)
|
||||
const dataset = parseInput({ ...input, delta: forcedType === 'delta' })
|
||||
|
||||
if (flags.inspect) {
|
||||
process.stdout.write(`${JSON.stringify(dataset, null, 2)}\n`)
|
||||
|
|
@ -152,7 +153,6 @@ export const main = async ({
|
|||
die('dado ambíguo ou faltando — pergunte à pessoa antes de gerar (nada é completado).')
|
||||
}
|
||||
|
||||
const forcedType = flags.type ? String(flags.type) : null
|
||||
const chartType = hasSeries
|
||||
? (forcedType ?? 'line')
|
||||
: classifyRelation(dataset.rows, forcedType)
|
||||
|
|
@ -169,7 +169,13 @@ export const main = async ({
|
|||
...(flags.crossing ? { crossingLabel: String(flags.crossing) } : {}),
|
||||
series: applyTones(dataset.series, flags, die),
|
||||
}
|
||||
: { highlight: flags.highlight ? String(flags.highlight) : null, rows: dataset.rows }),
|
||||
: {
|
||||
highlight: flags.highlight ? String(flags.highlight) : null,
|
||||
rows: dataset.rows,
|
||||
...(chartType === 'delta'
|
||||
? { startLabel: dataset.startLabel, endLabel: dataset.endLabel }
|
||||
: {}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
import { parse as parseCsv } from 'csv-parse/sync'
|
||||
import XLSX from 'xlsx'
|
||||
|
||||
export const CHART_TYPES = ['bar', 'column', 'line', 'anchor']
|
||||
export const CHART_TYPES = ['bar', 'column', 'line', 'anchor', 'delta']
|
||||
|
||||
/** Max data points per chart (honest density; above this the person must summarize). */
|
||||
export const MAX_POINTS = 7
|
||||
|
|
@ -44,6 +44,7 @@ export const RELATION_LABEL = {
|
|||
column: 'Poucos períodos',
|
||||
line: 'Série de tempo',
|
||||
anchor: 'Número-âncora',
|
||||
delta: 'Variação no período',
|
||||
}
|
||||
|
||||
/** Kicker of the two-series time line (approved C191 variant). */
|
||||
|
|
@ -227,6 +228,54 @@ const matrixToDataset = (matrix, format) => {
|
|||
return { rows, format, issues }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta table (approved C191 variant): the first row is the header naming the
|
||||
* two observed periods; every body row carries the label and the two measures
|
||||
* (initial and final) of one category. A missing/invalid measure is an explicit
|
||||
* issue — never a completed value.
|
||||
*/
|
||||
const matrixToDelta = (matrix, format) => {
|
||||
const cleaned = cleanMatrix(matrix)
|
||||
const empty = { rows: [], startLabel: '', endLabel: '', format }
|
||||
if (cleaned.length < 2) {
|
||||
return {
|
||||
...empty,
|
||||
issues: ['a barra de variação exige o cabeçalho com os dois períodos e ao menos uma linha.'],
|
||||
}
|
||||
}
|
||||
const [header, ...body] = cleaned
|
||||
if (header.length < 3) {
|
||||
return {
|
||||
...empty,
|
||||
issues: ['a barra de variação exige três colunas: rótulo, valor inicial e valor final.'],
|
||||
}
|
||||
}
|
||||
const issues = []
|
||||
const rows = []
|
||||
const seen = new Set()
|
||||
for (const row of body) {
|
||||
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)
|
||||
const initial = parseNumber(row[1] ?? '')
|
||||
const final = parseNumber(row[2] ?? '')
|
||||
if (initial === null) {
|
||||
issues.push(`valor inicial não numérico em "${label}": ${JSON.stringify(row[1] ?? '')}`)
|
||||
continue
|
||||
}
|
||||
if (final === null) {
|
||||
issues.push(`valor final não numérico em "${label}": ${JSON.stringify(row[2] ?? '')}`)
|
||||
continue
|
||||
}
|
||||
rows.push({ label, initial, final })
|
||||
}
|
||||
return { rows, startLabel: header[1] ?? '', endLabel: header[2] ?? '', format, issues }
|
||||
}
|
||||
|
||||
const sniffDelimiter = (line) => {
|
||||
let best = null
|
||||
let bestCount = 0
|
||||
|
|
@ -269,14 +318,27 @@ const splitPair = (line) => {
|
|||
return [line, '']
|
||||
}
|
||||
|
||||
const parseFreeText = (text) => {
|
||||
const parseFreeText = (text, { delta = false } = {}) => {
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
|
||||
if (lines.length === 0) return { rows: [], format: 'txt', issues: ['nenhum dado encontrado'] }
|
||||
if (isMarkdownTable(lines)) return matrixToDataset(parseMarkdownTable(lines), 'md')
|
||||
if (isMarkdownTable(lines)) {
|
||||
const matrix = parseMarkdownTable(lines)
|
||||
return delta ? matrixToDelta(matrix, 'md') : matrixToDataset(matrix, 'md')
|
||||
}
|
||||
const delimiter = sniffDelimiter(lines[0])
|
||||
if (delimiter) {
|
||||
const matrix = lines.map((line) => line.split(delimiter))
|
||||
return matrixToDataset(matrix, delimiter === '\t' ? 'tsv' : 'csv')
|
||||
const format = delimiter === '\t' ? 'tsv' : 'csv'
|
||||
return delta ? matrixToDelta(matrix, format) : matrixToDataset(matrix, format)
|
||||
}
|
||||
if (delta) {
|
||||
return {
|
||||
rows: [],
|
||||
startLabel: '',
|
||||
endLabel: '',
|
||||
format: 'txt',
|
||||
issues: ['a barra de variação exige uma tabela (rótulo + valor inicial + valor final).'],
|
||||
}
|
||||
}
|
||||
const matrix = lines.map(splitPair)
|
||||
return matrixToDataset(matrix, 'txt')
|
||||
|
|
@ -285,15 +347,17 @@ const parseFreeText = (text) => {
|
|||
/**
|
||||
* Parse the raw input into `{ rows, format, issues }`. `buffer` is required for
|
||||
* xlsx/xls; `text` for csv/md/txt. `format` overrides the extension inference.
|
||||
* `delta` reads the table through the variation-bar contract (label + initial +
|
||||
* final, header naming the two periods) instead of the single-measure path.
|
||||
*
|
||||
* @param {{ text?: string | null, buffer?: Buffer | null, format?: string | null }} [options]
|
||||
* @param {{ text?: string | null, buffer?: Buffer | null, format?: string | null, delta?: boolean }} [options]
|
||||
*/
|
||||
export const parseInput = ({ text = null, buffer = null, format = null } = {}) => {
|
||||
export const parseInput = ({ text = null, buffer = null, format = null, delta = false } = {}) => {
|
||||
if (format === 'xlsx' || format === 'xls') {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' })
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false })
|
||||
return matrixToDataset(matrix, 'xlsx')
|
||||
return delta ? matrixToDelta(matrix, 'xlsx') : matrixToDataset(matrix, 'xlsx')
|
||||
}
|
||||
const body = String(text ?? '')
|
||||
if (format === 'csv' || format === 'tsv') {
|
||||
|
|
@ -304,15 +368,16 @@ export const parseInput = ({ text = null, buffer = null, format = null } = {}) =
|
|||
skip_empty_lines: true,
|
||||
relax_column_count: true,
|
||||
})
|
||||
return matrixToDataset(matrix, format)
|
||||
return delta ? matrixToDelta(matrix, format) : matrixToDataset(matrix, format)
|
||||
}
|
||||
return parseFreeText(body)
|
||||
return parseFreeText(body, { delta })
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation of the data → chart type. One measure is an anchor; every-temporal
|
||||
* labels are a series (few periods as columns, many as a line); anything else
|
||||
* is a ranking/comparison in horizontal bars.
|
||||
* Relation of the data → chart type. One measure is an anchor; two measures per
|
||||
* category (initial/final) are the variation bar; every-temporal labels are a
|
||||
* series (few periods as columns, many as a line); anything else is a
|
||||
* ranking/comparison in horizontal bars.
|
||||
*
|
||||
* @param {{ label: string, value: number }[]} rows
|
||||
* @param {string | null} [forcedType]
|
||||
|
|
@ -328,6 +393,12 @@ export const classifyRelation = (rows, forcedType = null) => {
|
|||
}
|
||||
return forcedType
|
||||
}
|
||||
if (
|
||||
rows.length > 0 &&
|
||||
rows.every((row) => Number.isFinite(row.initial) && Number.isFinite(row.final))
|
||||
) {
|
||||
return 'delta'
|
||||
}
|
||||
if (rows.length === 1) return 'anchor'
|
||||
if (rows.length > 1 && rows.every((row) => isTemporalLabel(row.label))) {
|
||||
return rows.length <= 4 ? 'column' : 'line'
|
||||
|
|
@ -431,6 +502,49 @@ const validateSeries = (spec) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardrails of the approved variation bar (delta variant): zero-based, at most
|
||||
* `MAX_POINTS` categories, both measures present, no retraction (a zero-based
|
||||
* bar cannot carry a fall — the person gets the two-series line instead) and no
|
||||
* highlight (the two-tone pair is fixed; the piece has no valence).
|
||||
*/
|
||||
const validateDelta = (spec) => {
|
||||
const { rows, startLabel, endLabel } = spec
|
||||
if (spec.highlight) {
|
||||
throw new Error('a barra de variação não aceita destaque: o par de tons é fixo e sem valência.')
|
||||
}
|
||||
if (
|
||||
typeof startLabel !== 'string' ||
|
||||
startLabel.trim() === '' ||
|
||||
typeof endLabel !== 'string' ||
|
||||
endLabel.trim() === ''
|
||||
) {
|
||||
throw new Error('a barra de variação exige startLabel e endLabel (os dois períodos da gutter).')
|
||||
}
|
||||
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.`,
|
||||
)
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (!row.label) throw new Error('ponto de dado sem rótulo.')
|
||||
if (!Number.isFinite(row.initial))
|
||||
throw new Error(`valor inicial ausente/inválido em "${row.label}".`)
|
||||
if (!Number.isFinite(row.final))
|
||||
throw new Error(`valor final ausente/inválido em "${row.label}".`)
|
||||
if (row.initial < 0 || row.final < 0)
|
||||
throw new Error(`valor negativo em "${row.label}" — a barra parte do zero.`)
|
||||
if (row.final < row.initial) {
|
||||
throw new Error(
|
||||
`retração em "${row.label}" (${row.initial} → ${row.final}): a barra de base zero não representa queda — use a linha de duas séries.`,
|
||||
)
|
||||
}
|
||||
if (row.final <= 0) throw new Error(`valor final não positivo em "${row.label}".`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed guardrails of the approved template. Throws with the actionable
|
||||
* reason instead of drawing a misleading chart.
|
||||
|
|
@ -450,7 +564,9 @@ export const validateSpec = (spec) => {
|
|||
}
|
||||
throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`)
|
||||
}
|
||||
if (Array.isArray(series) && series.length > 0) {
|
||||
if (chartType === 'delta') {
|
||||
validateDelta(spec)
|
||||
} else if (Array.isArray(series) && series.length > 0) {
|
||||
validateSeries(spec)
|
||||
} else {
|
||||
if (!Array.isArray(rows) || rows.length === 0)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,63 @@ const columnBody = (spec) => {
|
|||
</div>`
|
||||
}
|
||||
|
||||
const formatPercent = (value) => {
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
|
||||
}
|
||||
|
||||
const deltaOrder = (rows) =>
|
||||
[...rows].sort(
|
||||
(a, b) => b.final - a.final || a.initial - b.initial || a.label.localeCompare(b.label, 'pt-BR'),
|
||||
)
|
||||
|
||||
/** The sigla before "·" gets the bold of the approved variant; plain labels stay plain. */
|
||||
const deltaLabelHtml = (label) => {
|
||||
const separator = label.indexOf('·')
|
||||
if (separator < 0) return htmlEscape(label)
|
||||
return `<b>${htmlEscape(label.slice(0, separator).trim())}</b> · ${htmlEscape(
|
||||
label.slice(separator + 1).trim(),
|
||||
)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Variation bar (approved C191 variant): one zero-based bar per category, the
|
||||
* 0 → initial segment in the base tone and the initial → final extension in the
|
||||
* lighter tone. A flat category has no extension segment at all, and the two
|
||||
* numbers in the gutter carry the reading — never the color alone.
|
||||
*/
|
||||
const deltaBody = (spec) => {
|
||||
const rows = deltaOrder(spec.rows)
|
||||
const max = Math.max(1, ...rows.map((row) => row.final))
|
||||
const aria = `Barras por tipo, de ${spec.startLabel} a ${spec.endLabel}: ${rows
|
||||
.map((row) => `${row.label} ${formatValue(row.initial)} → ${formatValue(row.final)}`)
|
||||
.join('; ')}.`
|
||||
return `<div class="delta-grade" role="img" aria-label="${htmlEscape(aria)}">
|
||||
<div class="delta-head">
|
||||
<span></span><span></span>
|
||||
<span class="delta-period">${htmlEscape(spec.startLabel)}</span>
|
||||
<span class="delta-period">${htmlEscape(spec.endLabel)}</span>
|
||||
</div>
|
||||
${rows
|
||||
.map((row) => {
|
||||
const flat = row.final === row.initial
|
||||
return `<div class="delta-row${flat ? ' is-flat' : ''}">
|
||||
<span class="delta-label">${deltaLabelHtml(row.label)}</span>
|
||||
<div class="delta-track">
|
||||
<span class="delta-base" style="width:${formatPercent((row.initial / max) * 100)}%"></span>${
|
||||
flat
|
||||
? ''
|
||||
: `<span class="delta-ext" style="width:${formatPercent(((row.final - row.initial) / max) * 100)}%"></span>`
|
||||
}
|
||||
</div>
|
||||
<span class="delta-value initial">${formatValue(row.initial)}</span>
|
||||
<span class="delta-value final">${formatValue(row.final)}</span>
|
||||
</div>`
|
||||
})
|
||||
.join('')}
|
||||
</div>`
|
||||
}
|
||||
|
||||
const anchorBody = (spec) => {
|
||||
const row = spec.rows[0]
|
||||
const copy = row.label || spec.subtitle || ''
|
||||
|
|
@ -117,6 +174,7 @@ const dualBody = (spec) => {
|
|||
}
|
||||
|
||||
const plotBody = (spec) => {
|
||||
if (spec.chartType === 'delta') return deltaBody(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)
|
||||
|
|
@ -176,6 +234,58 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
|
|||
}
|
||||
.bar.highlight { color: #fff; background: ${SOLLA_PALETTE.highlight}; }
|
||||
.bar.zero { min-width: 0; padding: 0 0 0 8px; justify-content: flex-start; }
|
||||
.delta-grade {
|
||||
--delta-label-w: 292px;
|
||||
--delta-v1-w: 88px;
|
||||
--delta-v2-w: 110px;
|
||||
--delta-gap: 20px;
|
||||
--delta-bar-h: 53px;
|
||||
--delta-base: ${SOLLA_PALETTE.barStrong};
|
||||
--delta-ext: ${SOLLA_PALETTE.bar};
|
||||
flex: none;
|
||||
display: grid;
|
||||
gap: var(--delta-gap);
|
||||
margin-top: 34px;
|
||||
}
|
||||
.delta-head,
|
||||
.delta-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--delta-label-w) 1fr var(--delta-v1-w) var(--delta-v2-w);
|
||||
align-items: center;
|
||||
gap: var(--delta-gap);
|
||||
}
|
||||
.delta-head { align-items: end; padding-bottom: 6px; }
|
||||
.delta-label { font-size: 30px; line-height: 1.1; font-weight: 650; }
|
||||
.delta-label b { font-weight: 850; }
|
||||
.delta-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: var(--delta-bar-h);
|
||||
border-left: 2px solid ${SOLLA_PALETTE.axis};
|
||||
}
|
||||
.delta-base { height: 100%; background: var(--delta-base); }
|
||||
.delta-ext {
|
||||
height: 100%;
|
||||
background: var(--delta-ext);
|
||||
border-left: 3px solid ${SOLLA_PALETTE.axis};
|
||||
}
|
||||
.delta-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 31px;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.delta-value.initial { color: ${SOLLA_PALETTE.label}; font-weight: 700; }
|
||||
.delta-value.final { color: ${SOLLA_PALETTE.ink}; font-weight: 850; }
|
||||
.delta-period {
|
||||
color: ${SOLLA_PALETTE.label};
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
font-weight: 750;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.columns { display: grid; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; }
|
||||
.column-cell { position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
|
||||
.column-value { margin-bottom: 10px; text-align: center; font-size: 31px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
|
|
@ -264,6 +374,14 @@ export const renderChartHtml = (spec) => {
|
|||
: sizeKey === 'square'
|
||||
? '.inner { padding: 56px 84px 48px; } .headline { font-size: 54px; } .plot { margin-top: 20px; }'
|
||||
: '.headline { font-size: 64px; } .plot { margin-top: 28px; }'
|
||||
const delta = spec.chartType === 'delta'
|
||||
const deltaStyles = !delta
|
||||
? ''
|
||||
: sizeKey === 'feed'
|
||||
? '.plot { margin-top: 0; } .headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; } .delta-grade { margin-top: 30px; }'
|
||||
: sizeKey === 'square'
|
||||
? '.inner { padding: 56px 84px 48px; } .plot { margin-top: 0; } .headline { font-size: 54px; } .subtitle { font-size: 28px; } .delta-grade { margin-top: 24px; --delta-bar-h: 42px; --delta-gap: 12px; --delta-v1-w: 84px; --delta-v2-w: 104px; } .delta-value { font-size: 30px; }'
|
||||
: '.plot { margin-top: 0; } .headline { font-size: 64px; } .delta-grade { margin-top: 40px; --delta-gap: 22px; --delta-bar-h: 52px; }'
|
||||
const kicker = dual ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico')
|
||||
const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label
|
||||
const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle
|
||||
|
|
@ -281,6 +399,7 @@ export const renderChartHtml = (spec) => {
|
|||
.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}
|
||||
${deltaStyles}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -186,6 +186,45 @@ describe('main — spec assembly, replay and the per-size log', () => {
|
|||
expect(log).toContain('tipo=bar')
|
||||
})
|
||||
|
||||
it('assembles the delta spec from a table with the two periods', async () => {
|
||||
const input = await writeInput(
|
||||
dir,
|
||||
'Tipo\t2020\t2026\nESF · Saúde da Família\t58\t63\nENASF-AB · Ampliado\t5\t5\n',
|
||||
)
|
||||
const { launchBrowser, close } = launchTracker()
|
||||
const screenshot = screenshotStub()
|
||||
const outPath = join(dir, 'delta.png')
|
||||
|
||||
await main({
|
||||
argv: [
|
||||
`--in=${input}`,
|
||||
'--type=delta',
|
||||
'--headline=Quatro dos sete seguem sem ampliação',
|
||||
'--source=Ministério da Saúde — CNES',
|
||||
`--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/quatro-dos-sete-seguem-sem-ampliacao.chart-spec.json'),
|
||||
'utf8',
|
||||
),
|
||||
)
|
||||
expect(spec).toMatchObject({ chartType: 'delta', startLabel: '2020', endLabel: '2026' })
|
||||
expect(spec.rows).toHaveLength(2)
|
||||
expect(stdout.join('')).toContain('tipo=delta')
|
||||
})
|
||||
|
||||
it('replays a --spec without reparsing the input and closes the browser', async () => {
|
||||
const specPath = join(dir, 'replay.chart-spec.json')
|
||||
await writeFile(
|
||||
|
|
|
|||
|
|
@ -261,6 +261,42 @@ describe('parseInput — two aligned measures become series, not a ranking', ()
|
|||
})
|
||||
})
|
||||
|
||||
describe('parseInput — the variation bar (delta)', () => {
|
||||
it('reads a table whose header names the two periods', () => {
|
||||
const dataset = parseInput({
|
||||
text: 'Tipo\t2020\tjul/2026\nESF · Saúde da Família\t58\t63\nENASF-AB · Ampliado\t5\t5\n',
|
||||
format: 'tsv',
|
||||
delta: true,
|
||||
})
|
||||
expect(dataset.startLabel).toBe('2020')
|
||||
expect(dataset.endLabel).toBe('jul/2026')
|
||||
expect(dataset.rows).toEqual([
|
||||
{ label: 'ESF · Saúde da Família', initial: 58, final: 63 },
|
||||
{ label: 'ENASF-AB · Ampliado', initial: 5, final: 5 },
|
||||
])
|
||||
expect(dataset.issues).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a missing measure instead of completing it', () => {
|
||||
const dataset = parseInput({
|
||||
text: 'Tipo\t2020\t2026\nECR · Consultórios na Rua\t-\t1\n',
|
||||
format: 'tsv',
|
||||
delta: true,
|
||||
})
|
||||
expect(dataset.rows).toEqual([])
|
||||
expect(dataset.issues.join(' ')).toContain('inicial')
|
||||
})
|
||||
|
||||
it('reads the delta table from the free-text path too', () => {
|
||||
const dataset = parseInput({
|
||||
text: 'Tipo\t2020\t2026\nESB · Saúde Bucal\t40\t53\n',
|
||||
format: 'txt',
|
||||
delta: true,
|
||||
})
|
||||
expect(dataset.rows).toEqual([{ label: 'ESB · Saúde Bucal', initial: 40, final: 53 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSpec — the two-series time line', () => {
|
||||
const seriesSpec = (overrides = {}) => ({
|
||||
chartType: 'line',
|
||||
|
|
@ -388,10 +424,81 @@ describe('validateSpec — the two-series time line', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('validateSpec — the variation bar (delta)', () => {
|
||||
const deltaSpec = (overrides = {}) => ({
|
||||
chartType: 'delta',
|
||||
headline: 'Quatro dos sete tipos seguem sem ampliação desde 2020',
|
||||
startLabel: '2020',
|
||||
endLabel: 'jul/2026',
|
||||
rows: [
|
||||
{ label: 'ESF · Saúde da Família', initial: 58, final: 63 },
|
||||
{ label: 'ESB · Saúde Bucal', initial: 40, final: 53 },
|
||||
{ label: 'ENASF-AB · Ampliado', initial: 5, final: 5 },
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('accepts a zero-based variation with the two periods named', () => {
|
||||
expect(validateSpec(deltaSpec())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('refuses a retraction and points to the two-series line', () => {
|
||||
expect(() =>
|
||||
validateSpec(deltaSpec({ rows: [{ label: 'ESF', initial: 63, final: 58 }] })),
|
||||
).toThrow(/linha de duas séries/)
|
||||
})
|
||||
|
||||
it('refuses --highlight (the two-tone pair is fixed and there is no valence)', () => {
|
||||
expect(() => validateSpec(deltaSpec({ highlight: 'ESB · Saúde Bucal' }))).toThrow(/destaque/)
|
||||
})
|
||||
|
||||
it('requires the two period labels of the gutter', () => {
|
||||
expect(() => validateSpec(deltaSpec({ startLabel: '' }))).toThrow(/startLabel/)
|
||||
expect(() => validateSpec(deltaSpec({ endLabel: undefined }))).toThrow(/endLabel/)
|
||||
})
|
||||
|
||||
it(`caps the categories at ${MAX_POINTS}`, () => {
|
||||
const rows = Array.from({ length: MAX_POINTS + 1 }, (_v, index) => ({
|
||||
label: `Tipo ${index}`,
|
||||
initial: 1,
|
||||
final: 2,
|
||||
}))
|
||||
expect(() => validateSpec(deltaSpec({ rows }))).toThrow(/resuma/)
|
||||
})
|
||||
|
||||
it('allows the seven categories on the Stories canvas (no ranking cut)', () => {
|
||||
const rows = Array.from({ length: MAX_POINTS }, (_v, index) => ({
|
||||
label: `Tipo ${index}`,
|
||||
initial: 1,
|
||||
final: 2,
|
||||
}))
|
||||
expect(validateSpec(deltaSpec({ size: 'story', rows }))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('refuses a missing or negative measure and a non-positive final', () => {
|
||||
expect(() => validateSpec(deltaSpec({ rows: [{ label: 'ESF', initial: 1 }] }))).toThrow(
|
||||
/final ausente/,
|
||||
)
|
||||
expect(() =>
|
||||
validateSpec(deltaSpec({ rows: [{ label: 'ESF', initial: -1, final: 2 }] })),
|
||||
).toThrow(/negativo/)
|
||||
expect(() =>
|
||||
validateSpec(deltaSpec({ rows: [{ label: 'ESF', initial: 0, final: 0 }] })),
|
||||
).toThrow(/não positivo/)
|
||||
})
|
||||
|
||||
it('is the relation of a two-measure table and still honours an explicit type', () => {
|
||||
const rows = [{ label: 'ESF', initial: 1, final: 2 }]
|
||||
expect(classifyRelation(rows)).toBe('delta')
|
||||
expect(classifyRelation(rows, 'bar')).toBe('bar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contracts', () => {
|
||||
it('declares the four v1 types, labels and sizes', () => {
|
||||
expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor'])
|
||||
it('declares the v1 types plus the delta variant, labels and sizes', () => {
|
||||
expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor', 'delta'])
|
||||
expect(RELATION_LABEL.line).toBe('Série de tempo')
|
||||
expect(RELATION_LABEL.delta).toBe('Variação no período')
|
||||
expect(SIZES.story).toMatchObject({ width: 1080, height: 1920, safeTop: 250, safeBottom: 250 })
|
||||
expect(SIZES.feed).toMatchObject({ width: 1080, height: 1350 })
|
||||
expect(SIZES.square).toMatchObject({ width: 1080, height: 1080 })
|
||||
|
|
|
|||
|
|
@ -190,3 +190,73 @@ describe('renderChartHtml — the two-series time line', () => {
|
|||
expect(html).toContain('JORGE SOLLA')
|
||||
})
|
||||
})
|
||||
|
||||
// C191 approved variant: the variation bar (delta) draws 0→initial in the base
|
||||
// tone and initial→final in the lighter tone, the two values in a tabular
|
||||
// gutter; a flat category has no extension segment and the red stays out of the
|
||||
// plot (the piece has no valence).
|
||||
const deltaRows = [
|
||||
{ label: 'ESF · Saúde da Família', initial: 58, final: 63 },
|
||||
{ label: 'ESB · Saúde Bucal', initial: 40, final: 53 },
|
||||
{ label: 'ENASF-AB · Ampliado', initial: 5, final: 5 },
|
||||
{ label: 'EABP · Atenção Primária Prisional', initial: 1, final: 4 },
|
||||
{ label: 'EMAD · Atenção Domiciliar', initial: 1, final: 1 },
|
||||
]
|
||||
const deltaSpec = (overrides = {}) => ({
|
||||
chartType: 'delta',
|
||||
size: 'feed',
|
||||
headline: 'Quatro dos sete tipos de equipe de saúde seguem sem ampliação desde 2020',
|
||||
subtitle: 'Vitória da Conquista — número de equipes por tipo (2020 → jul/2026)',
|
||||
source: 'Ministério da Saúde — CNES',
|
||||
note: 'Sem variação: EMAD, EMAP, ENASF-AB e ECR.',
|
||||
startLabel: '2020',
|
||||
endLabel: 'jul/2026',
|
||||
rows: deltaRows,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('renderChartHtml — the variation bar (delta)', () => {
|
||||
it('renders the fixed kicker, the periods and the two-value gutter', () => {
|
||||
const html = renderChartHtml(deltaSpec())
|
||||
expect(html).toContain('Variação no período')
|
||||
expect(html).toContain('delta-period">2020')
|
||||
expect(html).toContain('delta-period">jul/2026')
|
||||
expect(html).toContain('delta-value initial">58')
|
||||
expect(html).toContain('delta-value final">63')
|
||||
})
|
||||
|
||||
it('sorts by the final value and keeps the red out of the plot', () => {
|
||||
const html = renderChartHtml(deltaSpec())
|
||||
const body = html.slice(html.indexOf('<div class="delta-grade'))
|
||||
expect(body.indexOf('ESF · Saúde da Família')).toBeLessThan(body.indexOf('ESB · Saúde Bucal'))
|
||||
expect(body.indexOf('ESB · Saúde Bucal')).toBeLessThan(body.indexOf('ENASF-AB'))
|
||||
const plot = body.slice(0, body.indexOf('<footer'))
|
||||
expect(plot).not.toContain('#c51414')
|
||||
})
|
||||
|
||||
it('leaves a flat category without the extension segment', () => {
|
||||
const html = renderChartHtml(deltaSpec())
|
||||
const start = html.indexOf('class="delta-row is-flat"')
|
||||
const flat = html.slice(start, html.indexOf('class="delta-row"', start))
|
||||
expect(flat).not.toContain('delta-ext')
|
||||
expect(flat).toContain('delta-value initial">5')
|
||||
expect(flat).toContain('delta-value final">5')
|
||||
})
|
||||
|
||||
it('proportions base and extension against the biggest final value', () => {
|
||||
const html = renderChartHtml(deltaSpec())
|
||||
expect(html).toContain('width:92.1%')
|
||||
expect(html).toContain('width:7.9%')
|
||||
})
|
||||
|
||||
it('bolds the sigla before the middle dot', () => {
|
||||
expect(renderChartHtml(deltaSpec())).toContain('<b>ESF</b> · Saúde da Família')
|
||||
})
|
||||
|
||||
it('adapts the rhythm and keeps every category on the Stories canvas', () => {
|
||||
const story = renderChartHtml(deltaSpec({ size: 'story' }))
|
||||
expect(story).toContain('padding: 306px 84px 310px')
|
||||
expect(story).toMatch(/\.headline \{ font-size: 64px/)
|
||||
expect((story.match(/class="delta-row/g) ?? []).length).toBe(deltaRows.length)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue