From 4ac68de29c0bacad220f3498b87d32997768a3b0 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 19 Sep 2026 01:02:37 -0300 Subject: [PATCH] =?UTF-8?q?feat(C191):=20barra=20de=20varia=C3=A7=C3=A3o?= =?UTF-8?q?=20(delta)=20no=20gr=C3=A1fico=20de=20Instagram=20=E2=80=94=20b?= =?UTF-8?q?ase=20e=20extens=C3=A3o=20por=20categoria,=20sem=20val=C3=AAnci?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .agents/skills/graficos-dados/SKILL.md | 39 +- docs/changelog/2026-09-19-c191-delta.md | 1 + .../graficos-dados-instagram-ui-design.html | 531 ++++++++++++++++++ scripts/build-chart-from-data.mjs | 12 +- scripts/lib/chartData.mjs | 142 ++++- scripts/lib/graficosInstagramRender.mjs | 119 ++++ tests/unit/buildChartFromData.unit.spec.ts | 39 ++ tests/unit/chartData.unit.spec.ts | 111 +++- .../unit/graficosInstagramRender.unit.spec.ts | 70 +++ 9 files changed, 1043 insertions(+), 21 deletions(-) create mode 100644 docs/changelog/2026-09-19-c191-delta.md diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md index 8bac2093..1ccf28c9 100644 --- a/.agents/skills/graficos-dados/SKILL.md +++ b/.agents/skills/graficos-dados/SKILL.md @@ -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=` 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= --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/--.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 diff --git a/docs/changelog/2026-09-19-c191-delta.md b/docs/changelog/2026-09-19-c191-delta.md new file mode 100644 index 00000000..1fd0ce15 --- /dev/null +++ b/docs/changelog/2026-09-19-c191-delta.md @@ -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"). diff --git a/docs/plans/graficos-dados-instagram-ui-design.html b/docs/plans/graficos-dados-instagram-ui-design.html index 11ddd6a3..4caf2cb4 100644 --- a/docs/plans/graficos-dados-instagram-ui-design.html +++ b/docs/plans/graficos-dados-instagram-ui-design.html @@ -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 @@ + + + + + + +
+
+ DEGRADED · SIGN-OFF HUMANO 2026-09-19 + Proposta criada no tier degradado (quota do designer frontier indisponível) e + aprovada por sign-off humano 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. +
+ +
+
+

+ CENA 02C · SEXTA VARIAÇÃO · SIGN-OFF HUMANO 2026-09-19 +

+

+ Feed 4:5 · barra de variação por categoria (2020 → jul/2026) +

+
+
+ 1080 × 1350 + chartType proposto: delta +
+
+ +
+
+
+
+
+
+

Variação no período

+

+ Quatro dos sete tipos de equipe de saúde seguem sem ampliação desde 2020 +

+

+ Vitória da Conquista — número de equipes por tipo (2020 → jul/2026) +

+ + + +
+

+ Fonte: Ministério da Saúde — CNES
Nota: sem variação: + EMAD, EMAP, ENASF-AB e ECR. +

+
+ +
+
JORGE SOLLA
+
MANDATO DEPUTADO FEDERAL
+
+
+
+
+ NEEDS ASSET · LOGO OFICIAL +
+
+
+ + +
+ +
+

+ ADAPTAÇÃO STORIES · MESMA REGRA, SEM CORTAR CATEGORIA +

+

+ Stories 9:16 · as sete categorias, sem top-N +

+

+ O ranking certificado reduz para 5 pontos no story. Esta variante + não reduz: 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. +

+ +
+
+ 250 px · evitar conteúdo essencial + 250 px · evitar conteúdo essencial +
+
+

Variação no período

+

+ Quatro dos sete tipos de equipe de saúde seguem sem ampliação desde 2020 +

+

+ Vitória da Conquista — número de equipes por tipo (2020 → jul/2026) +

+ +
+
+ Fonte: Ministério da Saúde — CNES · Nota: sem variação: EMAD, + EMAP, ENASF-AB e ECR. +
+
JORGE SOLLA
+
+
+ NEEDS ASSET · LOGO OFICIAL +
+
+
+ +
+

+ REGRAS DA VARIANTE DE VARIAÇÃO (PROPOSTA DEGRADED) +

+

+ Contrato visual proposto para o porte classe-a-class — pendente de sign-off +

+
+
+

Entrada, teto e ordem

+

+ N categorias com initial e + final, + 0 ≤ initial ≤ final. Máximo 7. + 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, + perguntar. +

+
+
+

Tons e regra do vermelho

+

+ Par fixo: base = #a8a29e (0 → inicial), extensão = + #d6d3d1 (inicial → final). O vermelho + #c51414 não entra no plot; + `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. +

+
+
+

Valores e zero

+

+ Gutter à direita com duas colunas alinhadas (“2020” e “jul/2026”), tabular-nums. + Inicial em #57534e, final em + #1c1917. Zero = borda esquerda de 2 px + #78716c 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. +

+
+
+

Feed · 1080×1350

+

+ Padding 76/84/58; headline 60 px; 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). +

+
+
+

Quadrado · 1080×1080

+

+ 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. +

+
+
+

Story · 1080×1920

+

+ 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 + 7 categorias (não reduz para 5); tudo entre y=250 e y=1670. Nota + com as sem-variação obrigatória. +

+
+
+

Fail-closed (proposto)

+

+ final < initial (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. initial ausente → + perguntar. final ≤ 0 → recusar. Mais de 7 categorias + → perguntar/agrupar. Sem source → recusar. Nada de + interpolar, completar ou abreviar sozinho. +

+
+
+

Mapa de classes proposto

+

+ Container: delta-grade; cabeçalho + delta-head + + delta-period; linha + delta-row; rótulo + delta-label (+ + <b> para a sigla); trilho + delta-track; base + delta-base; extensão + delta-ext (ausente quando inicial = final); valores + delta-value initial / + delta-value final. Modificador de linha sem extensão: + is-flat (estado/aria; sem mudança visual — a ausência + do delta-ext é a pista). Reaproveitados: + top-rule, + chart-context, + chart-headline, + chart-subtitle, + chart-footer, + source-line, + brand-lockup e + asset-flag. +

+
+
+
+
+
diff --git a/scripts/build-chart-from-data.mjs b/scripts/build-chart-from-data.mjs index 07267066..0d0b5113 100644 --- a/scripts/build-chart-from-data.mjs +++ b/scripts/build-chart-from-data.mjs @@ -122,13 +122,14 @@ export const main = async ({ } } else { if (!flags.in) die('informe --in= com os dados (ou --spec= 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 } + : {}), + }), } } diff --git a/scripts/lib/chartData.mjs b/scripts/lib/chartData.mjs index 1077a1a4..49bf5e1f 100644 --- a/scripts/lib/chartData.mjs +++ b/scripts/lib/chartData.mjs @@ -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) diff --git a/scripts/lib/graficosInstagramRender.mjs b/scripts/lib/graficosInstagramRender.mjs index 60f63f17..4a639a53 100644 --- a/scripts/lib/graficosInstagramRender.mjs +++ b/scripts/lib/graficosInstagramRender.mjs @@ -76,6 +76,63 @@ const columnBody = (spec) => {
` } +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 `${htmlEscape(label.slice(0, separator).trim())} · ${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 `` +} + 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} diff --git a/tests/unit/buildChartFromData.unit.spec.ts b/tests/unit/buildChartFromData.unit.spec.ts index ecf3f271..3ed17f9a 100644 --- a/tests/unit/buildChartFromData.unit.spec.ts +++ b/tests/unit/buildChartFromData.unit.spec.ts @@ -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( diff --git a/tests/unit/chartData.unit.spec.ts b/tests/unit/chartData.unit.spec.ts index 773de957..a7e70486 100644 --- a/tests/unit/chartData.unit.spec.ts +++ b/tests/unit/chartData.unit.spec.ts @@ -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 }) diff --git a/tests/unit/graficosInstagramRender.unit.spec.ts b/tests/unit/graficosInstagramRender.unit.spec.ts index 3863a887..cf979d1e 100644 --- a/tests/unit/graficosInstagramRender.unit.spec.ts +++ b/tests/unit/graficosInstagramRender.unit.spec.ts @@ -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('
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('ESF · 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) + }) +})