diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md index a1aaa81b..dfa47101 100644 --- a/.agents/skills/graficos-dados/SKILL.md +++ b/.agents/skills/graficos-dados/SKILL.md @@ -55,7 +55,13 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa), - **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). + - **uma única medida → `anchor`** (número grande + frase que explica); + - **duas categorias, ambas boas notícias → `column` com `--dual-positive`**: + duas colunas verticais no vermelho oficial `#e4102f` — a valência boa é + compartilhada, não há vencedor —, na ordem do dado, sem hierarquia nem + cinza; certificada no feed, 280px por coluna; + - **ambas neutras (sem valência) → `--no-highlight`**: barras/colunas no tom + neutro, sem vermelho no plot. 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 **fonte** (viaja dentro da imagem). @@ -84,6 +90,13 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa), --source="Ministério da Saúde — CNES" \ --note="Sem variação: EMAD, EMAP, ENASF-AB e ECR." ``` + Duas categorias, ambos os resultados bons (dois tons de vermelho, sem hierarquia): + ```bash + node scripts/build-chart-from-data.mjs --in= --type=column --size=feed \ + --dual-positive --unit=% \ + --headline="A maioria dos internados nos dois hospitais mora na cidade" \ + --source="Ministério da Saúde — SIH/SUS" + ``` 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 @@ -127,7 +140,10 @@ Na linha de 2 séries, `series` substitui `rows` e `projectedLabel` marca o ``` `chartType` ∈ `bar | column | line | anchor | delta`; `highlight` é o rótulo que -recebe o único vermelho `#e4102f` (default: o maior valor). Em `series`, `tone` ∈ +recebe o único vermelho `#e4102f` (default: o maior valor); `unit` anexa o +sufixo aos rótulos de valor (ex.: `"%"`). O `kicker` (spec) segue a **relação** +dos dados, não o layout — colunas verticais de categorias leem "Comparação", +poucos períodos leem "Poucos períodos". Em `series`, `tone` ∈ `good | bad` (os dois tons ou nenhum) e as duas séries compartilham os mesmos rótulos temporais; `rows` e `highlight` não entram nessa variante. `projectedLabel` marca a projeção no último período e `crossingLabel` só é aceito @@ -135,9 +151,15 @@ 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`): +A comparação positiva dupla (`--dual-positive`, spec `dualPositive: true`) usa +`column` + `rows` com exatamente 2 categorias: as duas colunas no vermelho +oficial `#e4102f` — valência boa compartilhada, sem vencedor; as classes +`positive-a`/`positive-b` seguem a ordem do dado, nunca o maior valor —, sem +`highlight`. `--no-highlight` (spec `noHighlight: true`) é a +peça sem valência (sem vermelho no plot). 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 { @@ -168,6 +190,10 @@ O builder **recusa** em vez de desenhar algo enganoso: 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). +- **Comparação positiva dupla (`--dual-positive`)** exige `column`, exatamente 2 + categorias e `feed`; recusa `--highlight` e `--no-highlight`; os tons seguem a + ordem de entrada (nunca o maior) e ambos leem como resultado bom — cor nunca + carrega sozinha (cada coluna tem rótulo e valor). - **Valência só na linha de 2 séries**, em par (`good`/`bad`) e sempre por palavra + seta + forma do marcador + cor — **cor nunca carrega sozinha**: bom = vermelho `#e4102f`, círculo, "↑ amplia" (o vermelho é a cor do diff --git a/docs/changelog/2026-09-19-c191-duas-colunas-positivas.md b/docs/changelog/2026-09-19-c191-duas-colunas-positivas.md new file mode 100644 index 00000000..273424c6 --- /dev/null +++ b/docs/changelog/2026-09-19-c191-duas-colunas-positivas.md @@ -0,0 +1 @@ +**C191 — Coluna de comparação entre categorias e variante duas colunas positivas (2026-09-19):** a peça de internações de Vitória da Conquista (dois hospitais, ambos com maioria de moradores da cidade) precisava de colunas verticais, valores em % e sem hierarquia vermelho/cinza; pedido da comunicação em sessão direta (worktree `work/21`, sem claim). (1) **Design**: nova cena no `docs/plans/graficos-dados-instagram-ui-design.html` (CENA 02A, container `.dual-positive`), certificada pelo `designer` e aprovada por sign-off humano em 2026-09-19 — dois tons de vermelho (`#c51414` em `positive-a`, `#a21c1c` em `positive-b`) atribuídos pela **ordem de entrada** (nunca pelo maior valor), 280 px por coluna, rótulos de categoria em uma linha ("Municipal"/"Estadual"), plot de 500 px no feed. (2) **Contrato**: `unit` (sufixo nos rótulos de valor, ex.: `%`), `noHighlight` (peça sem valência, sem vermelho no plot) e `dualPositive` (exige `column`, exatamente 2 categorias e `feed`; recusa `--highlight`/`--no-highlight`); o `kicker` passa a seguir a **relação** dos dados — coluna de categorias lê "Comparação", não "Poucos períodos" —, não o layout forçado. (3) **Renderer**: teto de largura e centralização das colunas (2 categorias deixam de virar blocos de largura total), tons `positive-a`/`positive-b` pela ordem, `white-space: nowrap` no rótulo de categoria e ritmo próprio do feed (headline 60 px). (4) **Docs**: exemplos e guardrails das variantes no `SKILL.md`, incluindo a recusa fail-closed de pizza e a comparação positiva dupla. (5) **Testes**: unit do validate (unidade, kicker, neutral e dupla positiva fail-closed), da montagem do spec (`--type=column --unit=% --dual-positive`) e do render (classes, teto de 280 px, `%` nos valores, sem `highlight`). Entrega final: `docs/research/graficos-instagram/a-maioria-dos-internados-nos-dois-hospitais-de-v-2026-09-19-feed.png` ("A maioria dos internados nos dois hospitais de Vitória da Conquista mora na cidade"; 60,6% no municipal — Esaú Matos — e 54,7% no estadual — CHVC — das internações de 2025, SIH/SUS). diff --git a/docs/plans/graficos-dados-instagram-ui-design.html b/docs/plans/graficos-dados-instagram-ui-design.html index 4caf2cb4..ee7cd1f8 100644 --- a/docs/plans/graficos-dados-instagram-ui-design.html +++ b/docs/plans/graficos-dados-instagram-ui-design.html @@ -460,6 +460,88 @@ line-height: 1.4; } + /* ------------------------------------------------------------------ */ + /* CENA 02A · comparação vertical de 2 categorias, ambas positivas. */ + /* Os tons distinguem as categorias; não codificam vencedor/perdedor. */ + /* ------------------------------------------------------------------ */ + .feed-canvas.dual-positive .chart-headline { + max-width: 900px; + font-size: 60px; + } + .feed-canvas.dual-positive .chart-subtitle { + margin-top: 18px; + font-size: 28px; + } + .dual-positive-plot { + flex: none; + height: 500px; + margin-top: 34px; + } + .feed-canvas.dual-positive .columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 280px)); + justify-content: center; + align-items: end; + gap: 26px; + height: 100%; + padding: 0 0 46px; + border-bottom: 3px solid var(--bar-strong); + } + .feed-canvas.dual-positive .column-cell { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 100%; + } + .feed-canvas.dual-positive .column-value { + margin-bottom: 10px; + color: var(--ink); + text-align: center; + font-size: 31px; + line-height: 1; + font-weight: 800; + font-variant-numeric: tabular-nums; + } + .feed-canvas.dual-positive .column { + min-height: 6px; + } + .feed-canvas.dual-positive .column.positive-a { + background: #c51414; + } + .feed-canvas.dual-positive .column.positive-b { + background: #a21c1c; + } + .feed-canvas.dual-positive .column-label { + position: absolute; + bottom: -40px; + left: 0; + width: 100%; + color: #57534e; + text-align: center; + white-space: nowrap; + font-size: 30px; + line-height: 1; + font-weight: 650; + } + .signoff-approved { + border: 1px solid rgb(24 78 146 / 30%); + background: rgb(24 78 146 / 5%); + } + .signoff-approved-badge { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 6px; + color: #fff; + background: #184e92; + font-size: 12px; + line-height: 1; + font-weight: 900; + letter-spacing: 0.06em; + } + /* ------------------------------------------------------------------ */ /* CENA 02C · PROPOSTA DEGRADED — variante "delta": uma barra por */ /* categoria com base (0→inicial) e extensão (inicial→final). O */ @@ -919,6 +1001,29 @@ +
+
+
+ SIGN-OFF HUMANO · 2026-09-19 +

+ Extensão C191 · duas categorias, dois resultados positivos +

+

+ Variante certificada pelo designer de referência em 2026-09-19 e + pronta para avaliação na tela renderizada. Ela não altera as cenas já aprovadas: só + acrescenta a comparação vertical em dois tons de vermelho, sem semântica de vencedor e + perdedor. O dono do produto aprovou a peça final renderizada em 2026-09-19. +

+
+ APROVADA · GATE 2026-09-19 +
+
+
@@ -1402,6 +1507,192 @@
+
+
+
+

+ CENA 02A · SÉTIMA VARIAÇÃO · SIGN-OFF HUMANO 2026-09-19 +

+

+ Feed 4:5 · duas colunas, dois resultados positivos +

+
+
+ 1080 × 1350 + 2 colunas · teto 280 px + APROVADA · 2026-09-19 +
+
+ +
+
+
+
+
+
+

Comparação

+

+ Os dois hospitais atendem majoritariamente moradores da cidade +

+

+ Percentual das internações de 2025 de moradores da cidade +

+ +
+ +
+ +
+

+ Fonte: Ministério da Saúde — SIH/SUS
Internações de + 2025. +

+
+ Jorge Solla — Deputado Federal +
+
+
+
+
+
+ + +
+ +
+

REGRAS DA COLUNA DUPLA POSITIVA

+

+ Contrato visual para o porte classe-a-classe +

+
+
+

Entrada e semântica

+

+ Exatamente 2 categorias, um valor não negativo por categoria e + confirmação explícita de que ambos são resultados positivos. Kicker + fixo: “Comparação”. Se houver vencedor/perdedor, bom/ruim ou + valência incerta, esta variante não se aplica: perguntar, nunca inferir. +

+
+
+

Par de vermelhos · fixo

+

+ Categoria A = #c51414; categoria B = + #a21c1c. Atribuir por ordem de entrada, nunca pelo + maior valor. Sem classe highlight, sem cinza no plot + e sem opacidade. Ambos contrastam com o papel acima de 3:1 (5,73:1 e 7,34:1). +

+
+
+

Texto, contraste e redundância

+

+ column-value usa tinta + #1c1917 sobre papel + #faf9f7 (16,62:1). + column-label usa + #57534e sobre papel (7,25:1). Se algum formato futuro + puser texto sobre as colunas, usar branco #fff: + 6,03:1 sobre A e 7,73:1 sobre B. +

+
+
+

Geometria e base zero

+

+ columns: 2 × minmax(0, 280 px), gap 26 px, altura + 100%, padding-bottom 46 px e borda-base 3 px #a8a29e. + Alturas proporcionais ao mesmo máximo e sempre partindo da base zero. Um valor por + coluna; sem 3D, recorte ou eixo truncado. +

+
+
+

Rótulos de categoria

+

+ Uma linha obrigatória, 30 px/650, + white-space: nowrap, bottom −40 px. Para hospitais, + remover apenas o substantivo comum já dito na manchete: “Hospital + municipal/estadual” vira “Municipal/Estadual”. Se o qualificador ainda não couber em + 280 px, pedir forma curta; nunca quebrar em duas linhas nem abreviar sozinho. +

+
+
+

Mapa de classes

+

+ Reusar columns, + column-cell, + column-value, column e + column-label. No estado positivo duplo, o container + recebe dual-positive e as barras recebem + positive-a / + positive-b; não emitir + highlight. Plot: 500 px de altura e margin-top 34 px + no feed. +

+
+
+
+
+
@@ -2499,7 +2790,8 @@

Fundo e sinal

Off-white #faf9f7. Vermelho oficial #e4102f ocupa no máximo ~10% da peça; a régua de - marca usa azul #184e92. + marca usa azul #184e92. Exceção estrita, ainda em gate: a cena 02A usa duas colunas + vermelhas quando as duas categorias forem explicitamente positivas.

@@ -2509,7 +2801,10 @@
- revisão C203 · design hi-fi aprovado no gate · 2026-09-19 + base C203 aprovada · extensão C191 + certificada pelo designer e aprovada pelo humano · 2026-09-19 Kit 1313 oficial · valência redundante · NEEDS ASSET resolvido
diff --git a/scripts/build-chart-from-data.mjs b/scripts/build-chart-from-data.mjs index 3e42faa7..944cfd07 100644 --- a/scripts/build-chart-from-data.mjs +++ b/scripts/build-chart-from-data.mjs @@ -10,8 +10,9 @@ * Usage: * node scripts/build-chart-from-data.mjs --in=data/graficos-instagram/dados.csv \ * --headline="Um território concentra o maior resultado" --source="TSE 2022" \ - * [--subtitle=...] [--note=...] [--type=bar] [--size=feed|square|story] \ - * [--highlight="Território A"] [--out=docs/research/graficos-instagram/x.png] + * [--subtitle=...] [--note=...] [--type=bar|column|line|anchor|delta] [--size=feed|square|story] \ + * [--unit="%"] [--highlight="Território A"|--no-highlight|--dual-positive] \ + * [--out=docs/research/graficos-instagram/x.png] * node scripts/build-chart-from-data.mjs --in=dados.csv --inspect */ @@ -25,6 +26,7 @@ import { MAX_POINTS, MAX_POINTS_LINE, MAX_POINTS_STORY, + RELATION_LABEL, SIZES, classifyRelation, parseInput, @@ -154,9 +156,18 @@ export const main = async ({ die('dado ambíguo ou faltando — pergunte à pessoa antes de gerar (nada é completado).') } + if (flags['dual-positive'] && hasSeries) { + die( + '--dual-positive é a coluna de duas categorias positivas — não combina com a linha de duas séries.', + ) + } + const chartType = hasSeries ? (forcedType ?? 'line') : classifyRelation(dataset.rows, forcedType) + // The kicker names the data relation, not the forced layout: categories in + // vertical columns still read "Comparação"; few periods keep their own. + const kicker = hasSeries ? null : RELATION_LABEL[classifyRelation(dataset.rows)] spec = { chartType, size: flags.size ? String(flags.size) : 'feed', @@ -164,6 +175,8 @@ export const main = async ({ subtitle: flags.subtitle ? String(flags.subtitle) : '', source: flags.source ? String(flags.source) : '', note: flags.note ? String(flags.note) : '', + ...(kicker ? { kicker } : {}), + ...(flags.unit ? { unit: String(flags.unit) } : {}), ...(hasSeries ? { ...(flags.projected ? { projectedLabel: String(flags.projected) } : {}), @@ -171,7 +184,9 @@ export const main = async ({ series: applyTones(dataset.series, flags, die), } : { - highlight: flags.highlight ? String(flags.highlight) : null, + ...(flags.highlight ? { highlight: String(flags.highlight) } : {}), + ...(flags['no-highlight'] ? { noHighlight: true } : {}), + ...(flags['dual-positive'] ? { dualPositive: true } : {}), rows: dataset.rows, ...(chartType === 'delta' ? { startLabel: dataset.startLabel, endLabel: dataset.endLabel } diff --git a/scripts/lib/chartData.mjs b/scripts/lib/chartData.mjs index 90448201..8f79296f 100644 --- a/scripts/lib/chartData.mjs +++ b/scripts/lib/chartData.mjs @@ -562,6 +562,50 @@ export const validateSpec = (spec) => { `tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`, ) } + if (spec.unit !== undefined && spec.unit !== null && typeof spec.unit !== 'string') { + throw new Error('unidade inválida — use uma string (ex.: --unit="%").') + } + if (spec.noHighlight !== undefined && typeof spec.noHighlight !== 'boolean') { + throw new Error('noHighlight inválido — use booleano.') + } + if (spec.noHighlight && spec.highlight) { + throw new Error( + 'destaque e peça sem destaque são exclusivos — use --highlight ou --no-highlight, nunca os dois.', + ) + } + if (spec.dualPositive !== undefined && typeof spec.dualPositive !== 'boolean') { + throw new Error('dualPositive inválido — use booleano.') + } + if (spec.dualPositive) { + if (chartType !== 'column') { + throw new Error('a comparação positiva dupla exige o tipo coluna (--type=column).') + } + if (!Array.isArray(spec.rows) || spec.rows.length !== 2) { + throw new Error( + `a comparação positiva dupla desenha exatamente 2 categorias (${spec.rows?.length ?? 0} recebidas).`, + ) + } + if (spec.highlight) { + throw new Error('a comparação positiva dupla não usa destaque — remova o --highlight.') + } + if (spec.noHighlight) { + throw new Error( + 'a comparação positiva dupla já é a peça positiva — não combine com --no-highlight.', + ) + } + if (spec.size && spec.size !== 'feed') { + throw new Error( + 'a comparação positiva dupla está certificada no tamanho feed; peça o design das versões quadrada/story antes.', + ) + } + } + if ( + spec.kicker !== undefined && + spec.kicker !== null && + (typeof spec.kicker !== 'string' || spec.kicker.trim() === '') + ) { + throw new Error('kicker inválido — use uma string não vazia.') + } if (!CHART_TYPES.includes(chartType)) { if (chartType === 'pie') { throw new Error( diff --git a/scripts/lib/graficosInstagramRender.mjs b/scripts/lib/graficosInstagramRender.mjs index 4dde2e3e..14fe2b6e 100644 --- a/scripts/lib/graficosInstagramRender.mjs +++ b/scripts/lib/graficosInstagramRender.mjs @@ -29,12 +29,15 @@ export const SOLLA_PALETTE = { const FONT_STACK = "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" -const formatValue = (value) => - new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value) +const formatValue = (value, unit = '') => + `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value)}${unit}` const sortedByValue = (rows) => [...rows].sort((a, b) => b.value - a.value) -const highlightLabel = (spec) => spec.highlight ?? sortedByValue(spec.rows)[0]?.label ?? null +const highlightLabel = (spec) => { + if (spec.noHighlight) return null + return spec.highlight ?? sortedByValue(spec.rows)[0]?.label ?? null +} const rankingBody = (spec) => { const rows = sortedByValue(spec.rows) @@ -48,7 +51,7 @@ const rankingBody = (spec) => { return `
${htmlEscape(row.label)}
-
${formatValue(row.value)}
+
${formatValue(row.value, spec.unit)}
` }) @@ -60,13 +63,19 @@ const columnBody = (spec) => { const rows = spec.rows const max = Math.max(...rows.map((row) => row.value)) const winner = highlightLabel(spec) - return `
+ // CENA 02A (approved): the two-positive comparison caps and centers the pair + // and assigns the tones by input order — never by value. + const tracks = spec.dualPositive + ? 'repeat(2, minmax(0, 280px))' + : `repeat(${rows.length}, minmax(0, 320px))` + return `
${rows - .map((row) => { - const isHighlight = row.label === winner + .map((row, index) => { + const isHighlight = !spec.dualPositive && row.label === winner + const tone = spec.dualPositive ? ` positive-${index === 0 ? 'a' : 'b'}` : '' return `
- ${formatValue(row.value)} -
+ ${formatValue(row.value, spec.unit)} +
${htmlEscape(row.label)}
` }) @@ -123,8 +132,8 @@ const deltaBody = (spec) => { : `` }
- ${formatValue(row.initial)} - ${formatValue(row.final)} + ${formatValue(row.initial, spec.unit)} + ${formatValue(row.final, spec.unit)}
` }) .join('')} @@ -135,7 +144,7 @@ const anchorBody = (spec) => { const row = spec.rows[0] const copy = row.label || spec.subtitle || '' return `
-
${formatValue(row.value)}
+
${formatValue(row.value, spec.unit)}
${copy ? `

${htmlEscape(copy)}

` : ''}
` } @@ -167,7 +176,7 @@ const dualBody = (spec) => { crossingLabel: spec.crossingLabel ?? null, colors: DUAL_COLORS, valence: DUAL_VALENCE, - format: (row) => formatValue(row.value), + format: (row) => formatValue(row.value, spec.unit), })}` } @@ -181,7 +190,7 @@ const plotBody = (spec) => { rows: spec.rows, width: 880, height: 430, - format: (row) => formatValue(row.value), + format: (row) => formatValue(row.value, spec.unit), stroke: SOLLA_PALETTE.axis, highlight: SOLLA_PALETTE.highlight, paper: SOLLA_PALETTE.paper, @@ -280,12 +289,15 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; } 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}; } +.columns { display: grid; justify-content: center; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; } +.plot.dual-positive-plot { flex: none; height: 500px; margin-top: 34px; } +.column.positive-a { background: ${SOLLA_PALETTE.highlight}; } +.column.positive-b { background: ${SOLLA_PALETTE.highlight}; } .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; } .column { background: ${SOLLA_PALETTE.bar}; min-height: 6px; } .column.highlight { background: ${SOLLA_PALETTE.highlight}; } -.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; } +.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; white-space: nowrap; } .line-plot { height: 100%; display: flex; align-items: center; } .line-plot svg { width: 100%; height: auto; } .dual-line-plot { flex: none; } @@ -373,6 +385,11 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { const canvas = SIZES[sizeKey] const layout = layoutFor(sizeKey) const dual = Array.isArray(spec.series) && spec.series.length > 0 + const dualPositive = Boolean(spec.dualPositive) + const dualPositiveStyles = + !dualPositive || sizeKey !== 'feed' + ? '' + : '.headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; }' const dualStyles = !dual ? '' : sizeKey === 'feed' @@ -388,7 +405,8 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { : 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 kicker = + spec.kicker ?? (dual ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico')) const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle return ` @@ -406,6 +424,7 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { .plot { margin-top: ${layout.plotGap}px; } ${dualStyles} ${deltaStyles} + ${dualPositiveStyles} @@ -415,7 +434,7 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {

${htmlEscape(kicker)}

${htmlEscape(spec.headline)}

${showSubtitle ? `

${htmlEscape(spec.subtitle)}

` : ''} -
${plotBody(spec)}
+
${plotBody(spec)}
${footer(spec, brandLogoDataUri)} diff --git a/tests/unit/buildChartFromData.unit.spec.ts b/tests/unit/buildChartFromData.unit.spec.ts index 6ea01569..a6ec6475 100644 --- a/tests/unit/buildChartFromData.unit.spec.ts +++ b/tests/unit/buildChartFromData.unit.spec.ts @@ -252,6 +252,122 @@ describe('main — spec assembly, replay and the per-size log', () => { expect(stdout.join('')).toContain('tipo=delta') }) + it('assembles a vertical category comparison with the relation kicker and the unit', async () => { + const input = await writeInput( + dir, + 'Hospital;Moradores da cidade (%)\nMunicipal (Esaú Matos);60.57\nEstadual (CHVC);54.67\n', + ) + const { launchBrowser, close } = launchTracker() + const screenshot = screenshotStub() + + await main({ + argv: [ + `--in=${input}`, + '--type=column', + '--unit=%', + '--headline=Maioria e da cidade', + '--source=Ministério da Saúde — SIH/SUS', + `--out=${join(dir, 'coluna.png')}`, + ], + repoRoot: dir, + launchBrowser, + screenshot, + }) + + expect(close).toHaveBeenCalledOnce() + const spec = JSON.parse( + await readFile( + join(dir, 'data/graficos-instagram/maioria-e-da-cidade.chart-spec.json'), + 'utf8', + ), + ) + expect(spec).toMatchObject({ chartType: 'column', kicker: 'Comparação', unit: '%' }) + expect(stdout.join('')).toContain('tipo=column') + }) + + it('assembles the neutral piece with --no-highlight and refuses mixing both flags', async () => { + const input = await writeInput( + dir, + 'Hospital;Moradores da cidade\nHospital municipal;60.57\nHospital estadual;54.67\n', + ) + const { launchBrowser } = launchTracker() + const screenshot = screenshotStub() + + await main({ + argv: [ + `--in=${input}`, + '--type=column', + '--unit=%', + '--no-highlight', + '--headline=Neutro', + '--source=Ministério da Saúde — SIH/SUS', + `--out=${join(dir, 'neutro.png')}`, + ], + repoRoot: dir, + launchBrowser, + screenshot, + }) + + const spec = JSON.parse( + await readFile(join(dir, 'data/graficos-instagram/neutro.chart-spec.json'), 'utf8'), + ) + expect(spec.noHighlight).toBe(true) + expect(spec.highlight).toBeUndefined() + + await expect( + main({ + argv: [ + `--in=${input}`, + '--type=column', + '--no-highlight', + '--highlight=Hospital municipal', + '--headline=Conflito', + '--source=TSE', + ], + repoRoot: dir, + launchBrowser, + screenshot, + }), + ).rejects.toThrow(/exclusivos/) + }) + + it('assembles the two-positive pair with --dual-positive', async () => { + const input = await writeInput( + dir, + 'Hospital;Moradores da cidade\nMunicipal;60.57\nEstadual;54.67\n', + ) + const { launchBrowser } = launchTracker() + const screenshot = screenshotStub() + + await main({ + argv: [ + `--in=${input}`, + '--type=column', + '--unit=%', + '--dual-positive', + '--headline=Duas boas noticias', + '--source=Ministério da Saúde — SIH/SUS', + `--out=${join(dir, 'positivo.png')}`, + ], + repoRoot: dir, + launchBrowser, + screenshot, + }) + + const spec = JSON.parse( + await readFile( + join(dir, 'data/graficos-instagram/duas-boas-noticias.chart-spec.json'), + 'utf8', + ), + ) + expect(spec).toMatchObject({ + chartType: 'column', + unit: '%', + dualPositive: true, + kicker: 'Comparação', + }) + }) + 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 a7e70486..7f37b24a 100644 --- a/tests/unit/chartData.unit.spec.ts +++ b/tests/unit/chartData.unit.spec.ts @@ -218,6 +218,48 @@ describe('validateSpec — fail-closed guardrails', () => { it('refuses an unlabeled bar row', () => { expect(() => validateSpec({ ...base, rows: [{ label: '', value: 1 }] })).toThrow(/rótulo/) }) + + it('accepts an optional unit suffix and a relation kicker', () => { + expect(validateSpec({ ...base, unit: '%', kicker: 'Comparação' })).toMatchObject({ + unit: '%', + kicker: 'Comparação', + }) + }) + + it('refuses a non-string unit and an empty kicker', () => { + expect(() => validateSpec({ ...base, unit: 10 })).toThrow(/unidade/) + expect(() => validateSpec({ ...base, kicker: ' ' })).toThrow(/kicker/) + }) + + it('accepts the neutral piece flag and refuses the highlight conflict', () => { + expect(validateSpec({ ...base, noHighlight: true })).toMatchObject({ noHighlight: true }) + expect(() => validateSpec({ ...base, noHighlight: 'sim' })).toThrow(/noHighlight/) + expect(() => validateSpec({ ...base, noHighlight: true, highlight: 'A' })).toThrow(/exclusivos/) + }) + + it('certifies the two-positive column pair and fails closed outside it', () => { + const pair = { + ...base, + chartType: 'column', + rows: [ + { label: 'Municipal', value: 60.57 }, + { label: 'Estadual', value: 54.67 }, + ], + } + expect(validateSpec({ ...pair, dualPositive: true })).toMatchObject({ dualPositive: true }) + expect(() => validateSpec({ ...pair, dualPositive: 'sim' })).toThrow(/dualPositive/) + expect(() => validateSpec({ ...base, dualPositive: true })).toThrow(/coluna/) + expect(() => + validateSpec({ ...pair, dualPositive: true, rows: [{ label: 'A', value: 1 }] }), + ).toThrow(/2 categorias/) + expect(() => validateSpec({ ...pair, dualPositive: true, highlight: 'Municipal' })).toThrow( + /destaque/, + ) + expect(() => validateSpec({ ...pair, dualPositive: true, noHighlight: true })).toThrow( + /no-highlight/, + ) + expect(() => validateSpec({ ...pair, dualPositive: true, size: 'square' })).toThrow(/feed/) + }) }) describe('parseInput — two aligned measures become series, not a ranking', () => { diff --git a/tests/unit/graficosInstagramRender.unit.spec.ts b/tests/unit/graficosInstagramRender.unit.spec.ts index 2103c260..2c4a26f0 100644 --- a/tests/unit/graficosInstagramRender.unit.spec.ts +++ b/tests/unit/graficosInstagramRender.unit.spec.ts @@ -80,6 +80,69 @@ describe('renderChartHtml — canvas and template', () => { const html = render(spec({ chartType: 'column' })) expect(html).toContain('columns') expect(html).toContain('column highlight') + // Few columns are capped and centered; four or more keep the 1fr rhythm. + expect(html).toContain('grid-template-columns:repeat(3, minmax(0, 320px))') + expect(html).toContain('justify-content: center') + }) + + it('appends the optional unit to value labels and honors a relation kicker', () => { + const html = render( + spec({ + chartType: 'column', + unit: '%', + kicker: 'Comparação', + rows: [ + { label: 'Municipal (Esaú Matos)', value: 60.57 }, + { label: 'Estadual (CHVC)', value: 54.67 }, + ], + }), + ) + expect(html).toContain('60,6%') + expect(html).toContain('54,7%') + expect(html).toContain('>Comparação<') + expect(html).not.toContain('Poucos períodos') + }) + + it('carries the unit into the bar and the anchor value labels too', () => { + expect(render(spec({ unit: '%' }))).toContain('84%') + expect( + render(spec({ chartType: 'anchor', unit: '%', rows: [{ label: '', value: 72 }] })), + ).toContain('72%') + }) + + it(`renders the two-positive pair in the official red, by input order`, () => { + const html = render( + spec({ + chartType: 'column', + unit: '%', + dualPositive: true, + rows: [ + { label: 'Municipal', value: 60.57 }, + { label: 'Estadual', value: 54.67 }, + ], + }), + ) + expect(html).toContain('dual-positive-plot') + expect(html).toContain('column positive-a') + expect(html).toContain('column positive-b') + expect(html).not.toContain('column highlight') + expect(html).toContain('grid-template-columns:repeat(2, minmax(0, 280px))') + expect(html).toContain('60,6%') + expect(html).toContain('54,7%') + expect(html).toContain('font-size: 60px') + // C203 + designer re-decision: one shared official red — never a second tone. + expect(html).toContain('#e4102f') + expect(html).not.toContain('#a21c1c') + }) + + it('keeps the red out of the plot when the piece is explicitly neutral', () => { + const html = render(spec({ unit: '%', noHighlight: true })) + expect(html).not.toContain('bar highlight') + expect(html).toContain('84%') + const column = render(spec({ chartType: 'column', noHighlight: true })) + const plot = column.slice(column.indexOf('