Comparação no tempo
++ Hospital estadual puxa a ampliação de leitos de internação +
++ Vitória da Conquista (BA) · leitos existentes · 2015–2026 +
+ +From c51ac75bf55bb72b395cc0a474df9c2814da6726 Mon Sep 17 00:00:00 2001
From: Francisco Solla
- Aprovado no gate em 2026-09-19 — revisão C203 (marca e paleta oficiais do kit 1313).
+ Base C203 aprovada em 2026-09-19 · revisões C205 e C206 aprovadas no gate em
+ 2026-09-19.
- O humano validou a substituição do lockup tipográfico pelo ativo oficial e o
- mapeamento de cor fixado aqui; este artefato é o registro do aceite visual e a fonte
- de verdade do port.
+ O aceite C203 permanece intacto. A C205 revisa no lugar a CENA 02D — linha temporal de
+ três séries — e supera a C204, que nunca foi certificada. A C206 revisa somente o
+ rodapé compartilhado para ampliar a marca oficial. As duas são a fonte de verdade do
+ port desde o gate humano de 2026-09-19.
+ REVISÃO VISÍVEL · C205 · APROVADA NO GATE 2026-09-19
+
+ A CENA 02D troca bom/médio/neutro por
+ bom/neutro/neutro escuro, usando apenas neutros oficiais além do
+ vermelho do melhor resultado. A gutter passa de quatro para três linhas por bloco, com
+ valor + variação na mesma linha e faixas mais arejadas. O último ponto segue
+ jul/2026 observado, sem qualquer artefato de projeção. A CENA 02B
+ preserva integralmente o par bom/ruim aprovado.
+
+ REVISÃO VISÍVEL · C206 · APROVADA NO GATE 2026-09-19
+
+ O ativo oficial 1037×595 permanece inteiro. A antiga moldura
+ 286×90 era larga demais para a razão do PNG e fazia a imagem ocupar
+ só cerca de 157×90 px. A C206 troca a moldura por caixas na mesma razão do ativo:
+ 251×144 no feed, 192×110 no quadrado e
+ 279×160 no story. O PNG passa a ocupar 100% dessas caixas com
+ object-fit: contain; só a transparência nativa do
+ arquivo permanece.
+ Feed · 1080×1350
+ brand-logo-frame 251×144; rodapé mínimo 171 px,
+ padding-top 25 px, gap 30 px. Fonte/nota 27 px; nas linhas densas 02B/02D, 25 px.
+ Quadrado · 1080×1080
+ brand-logo-frame 192×110; rodapé mínimo 128 px,
+ padding-top 16 px, gap 30 px. Fonte/nota 25 px. O porte menor protege a altura útil do
+ canvas mais curto sem voltar à marca miúda da C203.
+ Story · 1080×1920
+ brand-logo-frame 279×160; rodapé mínimo 190 px,
+ padding-top 28 px, gap 28 px. Fonte/nota 27 px; tudo termina acima da safe zone.
+
+ Compatibilidade: o crescimento é absorvido pelo espaço flexível antes do
+ rodapé; não se altera headline, subtítulo, plot, escala, eixo ou gutter das CENAS 01, 02,
+ 02B e 02D. O rodapé segue off-white, com filete #e7e5e4 e texto
+ #57534e; o contraste e as margens laterais de 84 px permanecem.
+ CENA 02 · SISTEMA
@@ -2129,11 +2277,448 @@
+ CENA 02D · REVISÃO C205 · C204 SUPERADA · APROVADA NO GATE 2026-09-19
+ Comparação no tempo
+ Vitória da Conquista (BA) · leitos existentes · 2015–2026
+ REGRAS DA LINHA DE 3 SÉRIES · C205 Extensão, entrada e teto
+ Esta variante estende a CENA 02B; não a substitui. Exatamente 3
+ séries, mesmos períodos e mesma ordem. Teto aprovado:
+ 12 pontos por série no feed (36 marcas). É o limite exibido nesta
+ cena: linha 7 px + passo mínimo 46 px deixam ≥26 px livres entre marcadores de 20
+ px. Acima de 12, período desalinhado ou valor ausente → perguntar; nunca apertar,
+ interpolar ou omitir. Esta extensão não certifica quadrado nem story.
+ Mapa de tons · sem depender de cor
+ Bom/melhor =
+ #e4102f + círculo r=8 + ↑ “amplia”. Neutro =
+ #78716c + losango 14×14 rotado 45° + ↗ “cresce”.
+ Neutro escuro =
+ #1c1917 + triângulo ▼ 20×18 + ↘ “diminui”. Marcadores
+ são preenchidos porque os 36 pontos são observados. Azul
+ #184e92 fica só na marca/kicker/régua, nunca em dado.
+ Convivência com a CENA 02B
+ O contrato de 2 séries não muda: bom = vermelho/círculo/“↑ amplia”; ruim =
+ #78716c + quadrado + ↓ “recua”; comparação sem tons
+ também permanece. Na variante de 3 séries, losango + ↗ “cresce” distinguem o neutro
+ municipal, e triângulo + ↘ “diminui” distinguem o neutro escuro privado. Não
+ converter direção em juízo nem reaproveitar “recua” fora do tom ruim da CENA 02B.
+ Plot e escala · feed 1080×1350
+ Canvas padding 76 / 84 / 58; headline 56 px;
+ subtítulo 27 px; plot margin-top 16. SVG
+ viewBox 0 0 880 650. Dados: x=52…560, y=56…544; topo
+ y=56; baseline y=544; grid y=56/300/544; domínio compartilhado 0…1.000. Gutter de
+ texto x=600…880 (280 px), começando 40 px após o
+ último ponto. Linhas 7 px. O corredor x=560…600 pertence só aos leaders; texto nunca
+ invade o plot.
+ Gutter, leading e colisão
+ Cada bloco cai de quatro para três linhas: nome; valor + variação;
+ seta + palavra. Fontes/line-box: nome 30/36 px;
+ métrica 34/40 px; valência
+ 30/36 px. Baselines: Privada
+ 62/108/150, Estadual
+ 258/304/346, Municipal
+ 454/500/542. O pitch é 196 px; descontados os
+ line-boxes, sobram 70+ px entre blocos. Ordenar pelo y do ponto final e alocar,
+ nessa ordem, as três lanes fixas; nunca aproximar blocos para seguir o ponto.
+ Leaders e rótulos finais
+ Texto começa em x=600. Leader 3 px sai de x=570 no y
+ do ponto, passa pelo trilho x=580, dobra em x=590 até o baseline da métrica e
+ termina em x=594; sobra um respiro de 6 px antes do texto. Alvos:
+ y=108/304/500. Como finais e blocos têm a mesma
+ ordem, leaders não se cruzam. Nome em uma linha; se exceder 280 px, pedir forma
+ curta — nunca reduzir abaixo de 30 px, quebrar ou abreviar sozinho. A métrica usa
+ tabular-nums no formato literal “valor · ±N%”.
+ Eixo X · 12 pontos observados
+ Pontos em x=
+ 52/98/144/191/237/283/329/375/421/468/514/560
+ (passo ≈46 px). Fonte 30 px. Para 9–12 pontos, manter a regra da 02B: alternar desde
+ o primeiro; se o último não cair na alternância, remover o penúltimo exibido e fixar
+ o último. Aqui: 2015, 2017, 2019, 2021, 2023, 2026. “2026” continua
+ simples no eixo; “último ponto: jul/2026” fica na nota. Nunca anexar “projeção”.
+ Variação observada e ausência de projeção
+ A variação compara 2015 ao último ponto observado, jul/2026: Estadual
+ 235→369 · +57%; Municipal 93→113 · +22%; Privada
+ 832→510 · −39%. A gutter funde valor e variação e remove a palavra
+ “observado”; a nota carrega a precisão mensal. Proibidos nesta cena:
+ projection-band, tracejado, marcador vazado e label
+ “projeção”.
+ Copy e fonte · literais desta cena
+ Manchete: “Hospital estadual puxa a ampliação de leitos de internação”. Subtítulo:
+ “Vitória da Conquista (BA) · leitos existentes · 2015–2026”. Fonte: “Ministério da
+ Saúde — CNES · leitos de internação por esfera jurídica”. Nota: “Último ponto:
+ jul/2026. Rede privada = leitos empresariais + sem fins lucrativos.”
+ Mapa de classes da extensão
+ Chassi: triple-line-plot,
+ triple-axis-label. Séries/marcadores:
+ triple-series-good / triple-markers-good,
+ triple-series-neutral / triple-markers-neutral e
+ triple-series-neutral-dark / triple-markers-neutral-dark. Saída: triple-end-leader,
+ triple-end-name,
+ triple-end-metric e
+ triple-end-valence. Reusar sem alteração no corpo:
+ top-rule,
+ chart-context,
+ chart-headline,
+ chart-subtitle,
+ chart-footer. No rodapé, aplicar a C206:
+ source-line ≥25 px e
+ brand-logo-frame 251×144 no feed.
+
@@ -1025,6 +1088,91 @@
+ Três séries observadas: semântica neutra e gutter com mais respiro
+
+
+ Marca oficial maior, sem caixa vazia artificial no rodapé
+
+
- Cinco variações, um mesmo template
+ Cinco variações-base + duas extensões, um mesmo template
+ Feed 4:5 · linha temporal de três séries observadas
+
+
+ Hospital estadual puxa a ampliação de leitos de internação
+
+
+ Contrato visual da extensão para o porte classe-a-classe
+
+
Nota: sem variação:
EMAD, EMAP, ENASF-AB e ECR.
-
+
Densidade
Barra, coluna e linha simples: até 7 pontos. Exceção aprovada: linha de 2 séries, - até 12 pontos alinhados por série; story mantém 5 apenas para barra/coluna. + até 12 pontos alinhados por série. Revisão C205 aprovada: linha de 3 séries no feed, + até 12 pontos alinhados por série (36 marcas). Story mantém 5 apenas para + barra/coluna; C205 não certifica quadrado nem story.
Rodapé e marca · C206 aprovada
++ Fonte/nota: 27 px no feed/story; 25 px no quadrado e nas cenas densas 02B/02D. Marca + oficial inteira: 251×144 (feed), 192×110 (quadrado), 279×160 (story). Rodapé mínimo: + 171/128/190 px, respectivamente. Sem recorte, distorção, recoloração ou lockup + recriado. +
+${htmlEscape(kicker)}
diff --git a/tests/unit/buildChartFromData.unit.spec.ts b/tests/unit/buildChartFromData.unit.spec.ts index a6ec6475..9541932f 100644 --- a/tests/unit/buildChartFromData.unit.spec.ts +++ b/tests/unit/buildChartFromData.unit.spec.ts @@ -368,6 +368,110 @@ describe('main — spec assembly, replay and the per-size log', () => { }) }) + it('assembles the three-series spec with the informed triad (C205)', async () => { + const input = await writeInput( + dir, + 'Ano,Estadual,Municipal,Privada\n2015,235,93,832\n2016,235,93,724\n2017,256,105,707\n', + ) + const { launchBrowser, close } = launchTracker() + const screenshot = screenshotStub() + const outPath = join(dir, 'triple.png') + + await main({ + argv: [ + `--in=${input}`, + '--type=line', + '--headline=Redes públicas ampliam leitos; a privada recua', + '--source=Ministério da Saúde — CNES', + '--good=Estadual', + '--neutral=Municipal', + '--neutral-dark=Privada', + `--out=${outPath}`, + ], + repoRoot: dir, + launchBrowser, + screenshot, + }) + + expect(screenshot).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ width: 1080, height: 1350, outPath }), + ) + expect(close).toHaveBeenCalledOnce() + + const spec = JSON.parse( + await readFile( + join( + dir, + 'data/graficos-instagram/redes-publicas-ampliam-leitos-a-privada-recua.chart-spec.json', + ), + 'utf8', + ), + ) + expect( + spec.series.map((serie: { name: string; tone: string }) => [serie.name, serie.tone]), + ).toEqual([ + ['Estadual', 'good'], + ['Municipal', 'neutral'], + ['Privada', 'neutral-dark'], + ]) + expect(stdout.join('')).toContain('tipo=line') + expect(stdout.join('')).toContain('3 séries × 3') + }) + + it('refuses an incomplete triad and a 2-series tone on the three-series line', async () => { + const input = await writeInput(dir, 'Ano,Estadual,Municipal,Privada\n2015,1,2,3\n2016,2,2,2\n') + const { launchBrowser } = launchTracker() + + await expect( + main({ + argv: [`--in=${input}`, '--headline=X', '--source=Y', '--good=Estadual'], + repoRoot: dir, + launchBrowser, + die: throwingDie, + }), + ).rejects.toThrow(/juntos/) + + await expect( + main({ + argv: [`--in=${input}`, '--headline=X', '--source=Y', '--good=Estadual', '--bad=Municipal'], + repoRoot: dir, + launchBrowser, + die: throwingDie, + }), + ).rejects.toThrow(/par de duas séries/) + + expect(launchBrowser).not.toHaveBeenCalled() + }) + + it('retires --medium and keeps the vocabularies apart', async () => { + const tripleInput = await writeInput( + dir, + 'Ano,Estadual,Municipal,Privada\n2015,1,2,3\n2016,2,2,2\n', + ) + const pairInput = await writeInput(dir, 'Ano,Estadual,Municipal\n2017,1,2\n2018,2,1\n') + const { launchBrowser } = launchTracker() + + await expect( + main({ + argv: [`--in=${tripleInput}`, '--headline=X', '--source=Y', '--medium=Estadual'], + repoRoot: dir, + launchBrowser, + die: throwingDie, + }), + ).rejects.toThrow(/saiu na revisão C205/) + + await expect( + main({ + argv: [`--in=${pairInput}`, '--headline=X', '--source=Y', '--neutral=Estadual'], + repoRoot: dir, + launchBrowser, + die: throwingDie, + }), + ).rejects.toThrow(/trio de três séries/) + expect(launchBrowser).not.toHaveBeenCalled() + }) + it('replays a --spec without reparsing the input and closes the browser', async () => { const specPath = join(dir, 'replay.chart-spec.json') await writeFile( diff --git a/tests/unit/chartData.unit.spec.ts b/tests/unit/chartData.unit.spec.ts index 7f37b24a..4c793b71 100644 --- a/tests/unit/chartData.unit.spec.ts +++ b/tests/unit/chartData.unit.spec.ts @@ -6,6 +6,7 @@ import { MAX_POINTS_LINE, MAX_POINTS_STORY, MAX_SERIES, + MIN_SERIES, RELATION_LABEL, SIZES, classifyRelation, @@ -288,6 +289,16 @@ describe('parseInput — two aligned measures become series, not a ranking', () expect(dataset.issues).toEqual([]) }) + it('reads a time table with three measures as three series (C204)', () => { + const dataset = parseInput({ + text: 'Ano,Estadual,Municipal,Privada\n2015,235,93,832\n2016,235,93,724\n', + format: 'csv', + }) + expect(dataset.series).toHaveLength(3) + expect(dataset.series?.[2]).toMatchObject({ name: 'Privada' }) + expect(dataset.issues).toEqual([]) + }) + it('keeps a two-column table on the single-series path', () => { const dataset = parseInput({ text: 'cidade,votos\nIlhéus,84\nItabuna,68\n', format: 'csv' }) expect(dataset.series).toBeUndefined() @@ -372,10 +383,12 @@ describe('validateSpec — the two-series time line', () => { expect(() => validateSpec(seriesSpec({ chartType: 'bar' }))).toThrow(/linha/) }) - it(`refuses more than ${MAX_SERIES} series`, () => { + it(`refuses fewer than ${MIN_SERIES} and more than ${MAX_SERIES} series`, () => { const [first, second] = seriesSpec().series + const third = { ...first, name: 'Terceira' } + expect(() => validateSpec(seriesSpec({ series: [first] }))).toThrow(/séries/) expect(() => - validateSpec(seriesSpec({ series: [first, second, { ...first, name: 'Total' }] })), + validateSpec(seriesSpec({ series: [first, second, third, { ...third, name: 'Quarta' }] })), ).toThrow(/séries/) }) @@ -466,6 +479,87 @@ describe('validateSpec — the two-series time line', () => { }) }) +describe('validateSpec — the three-series time line (C205 extension)', () => { + const annual = (values: number[]) => + values.map((value, index) => ({ label: `${2015 + index}`, value })) + const tripleSpec = (overrides = {}) => ({ + chartType: 'line', + size: 'feed', + headline: 'Hospital estadual puxa a ampliação de leitos de internação', + series: [ + { + name: 'Hospital Estadual', + tone: 'good', + rows: annual([235, 235, 256, 256, 256, 256, 256, 256, 369, 369, 369, 369]), + }, + { + name: 'Rede municipal', + tone: 'neutral', + rows: annual([93, 93, 105, 93, 93, 93, 93, 93, 113, 113, 113, 113]), + }, + { + name: 'Rede privada', + tone: 'neutral-dark', + rows: annual([832, 724, 707, 644, 521, 496, 459, 489, 508, 536, 500, 510]), + }, + ], + ...overrides, + }) + + it('accepts three aligned temporal series with the informed triad', () => { + expect(validateSpec(tripleSpec())).toBeTruthy() + }) + + it('accepts the twelve points per series of the certified extension', () => { + expect(tripleSpec().series[0].rows).toHaveLength(MAX_POINTS_LINE) + }) + + it('refuses a partial or repeated triad', () => { + const [first, second, third] = tripleSpec().series + expect(() => + validateSpec(tripleSpec({ series: [{ ...first, tone: undefined }, second, third] })), + ).toThrow(/três tons/) + expect(() => + validateSpec(tripleSpec({ series: [{ ...first, tone: 'neutral' }, second, third] })), + ).toThrow(/distintos/) + expect(() => + validateSpec(tripleSpec({ series: [first, second, { ...third, tone: 'bad' }] })), + ).toThrow(/tom inválido/) + expect(() => + validateSpec(tripleSpec({ series: [first, second, { ...third, tone: 'medium' }] })), + ).toThrow(/tom inválido/) + }) + + it('refuses projection and crossing on the three-series extension', () => { + expect(() => validateSpec(tripleSpec({ projectedLabel: '2026' }))).toThrow(/projeção/) + expect(() => validateSpec(tripleSpec({ crossingLabel: '2026' }))).toThrow(/cruzamento/) + }) + + it('certifies the three-series line on the feed canvas only', () => { + expect(validateSpec(tripleSpec({ size: 'feed' }))).toBeTruthy() + expect(() => validateSpec(tripleSpec({ size: 'square' }))).toThrow(/feed/) + expect(() => validateSpec(tripleSpec({ size: 'story' }))).toThrow(/feed/) + }) + + it('keeps the alignment, temporal and ceiling guardrails of the pair', () => { + const [first, second, third] = tripleSpec().series + const drifted = { + ...third, + rows: third.rows.map((row, index) => (index === 0 ? { ...row, label: '2014' } : row)), + } + expect(() => validateSpec(tripleSpec({ series: [first, second, drifted] }))).toThrow( + /mesmos períodos/, + ) + const rows = Array.from({ length: MAX_POINTS_LINE + 1 }, (_v, index) => ({ + label: `${2000 + index}`, + value: index + 1, + })) + expect(() => validateSpec(tripleSpec({ series: [{ ...first, rows }, second, third] }))).toThrow( + /resuma/, + ) + }) +}) + describe('validateSpec — the variation bar (delta)', () => { const deltaSpec = (overrides = {}) => ({ chartType: 'delta', diff --git a/tests/unit/chartPrimitives.unit.spec.ts b/tests/unit/chartPrimitives.unit.spec.ts index 19e089ac..6778aadc 100644 --- a/tests/unit/chartPrimitives.unit.spec.ts +++ b/tests/unit/chartPrimitives.unit.spec.ts @@ -8,6 +8,7 @@ import { lineChart, proportionalPercent, stackedColumnChart, + tripleLineChart, valueList, } from '../../scripts/lib/chartPrimitives.mjs' @@ -294,3 +295,111 @@ describe('dualLineChart — the approved two-series time line', () => { expect(evil).toContain('<b>x</b>') }) }) + +describe('tripleLineChart — the approved three-series extension (C205, feed)', () => { + const annual = (values: number[]) => + values.map((value, index) => ({ label: `${2015 + index}`, value })) + const series = [ + { + name: 'Hospital Estadual', + tone: 'good', + rows: annual([235, 235, 256, 256, 256, 256, 256, 256, 369, 369, 369, 369]), + }, + { + name: 'Rede municipal', + tone: 'neutral', + rows: annual([93, 93, 105, 93, 93, 93, 93, 93, 113, 113, 113, 113]), + }, + { + name: 'Rede privada', + tone: 'neutral-dark', + rows: annual([832, 724, 707, 644, 521, 496, 459, 489, 508, 536, 500, 510]), + }, + ] + const colors = { + good: '#e4102f', + neutral: '#78716c', + 'neutral-dark': '#1c1917', + grid: '#a8a29e', + ink: '#1c1917', + } + const valence = { good: '↑ amplia', neutral: '↗ cresce', 'neutral-dark': '↘ diminui' } + const format = (row: { value: number }) => + new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value) + const svg = tripleLineChart({ series, colors, valence, format }) + + it('draws the triad with color, word and one marker shape per tone', () => { + expect(svg).toContain('class="triple-series triple-series-good"') + expect(svg).toContain('stroke="#e4102f"') + expect(svg).toContain('class="triple-series triple-series-neutral"') + expect(svg).toContain('stroke="#78716c"') + expect(svg).toContain('class="triple-series triple-series-neutral-dark"') + expect(svg).toContain('stroke="#1c1917"') + expect(svg).toContain('