Estadual cresce e supera o municipal
+Internações em Vitória da Conquista, 2017–2026.
+diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md
index 78af7498..8bac2093 100644
--- a/.agents/skills/graficos-dados/SKILL.md
+++ b/.agents/skills/graficos-dados/SKILL.md
@@ -37,12 +37,17 @@ O template visual é o artefato aprovado
```bash
node scripts/build-chart-from-data.mjs --in=
- A superfície desenhada é a imagem exportada — não uma tela de app. Os dados abaixo são
- deliberadamente genéricos; as peças fixam hierarquia, grid, tratamento de marca e limites
- do gerador.
+ A superfície desenhada é a imagem exportada — não uma tela de app. Os exemplos-base são
+ deliberadamente genéricos; a variante de duas séries usa o caso fornecido de Vitória da
+ Conquista. As peças fixam hierarquia, grid, tratamento de marca e limites do gerador.
CENA 02 · SISTEMA
@@ -903,6 +1003,558 @@
Um valor grande + frase que explica.
Internações em Vitória da Conquista, 2017–2026. Linha · duas séries
+ Tendências, valência redundante e projeção explícita.
+ CENA 02B · VARIAÇÃO CERTIFICADA Comparação no tempo
+ AIH aprovadas por ano · Vitória da Conquista (BA) · 2017–2026
+ REGRAS DA LINHA DE 2 SÉRIES Entrada e teto
+ Exatamente 2 séries e os mesmos rótulos temporais, na mesma ordem. Teto:
+ 12 pontos por série no feed, quadrado e story (24 marcas no total).
+ Barra/coluna continuam em 7 pontos no feed/quadrado e 5 no story. Contagem ou tempo
+ desalinhado → perguntar; nunca interpolar, completar, repetir nem remover ponto. O
+ domínio y é compartilhado e parte do zero real.
+ Bom × ruim · tons presentes
+ Tons entram sempre em par. Bom =
+ #c51414 + círculo + ↑ “amplia”. Ruim =
+ #78716c + quadrado + ↓ “recua”. No caso: Estadual é
+ bom; Municipal é ruim. Vermelho é marca do mandato/PT e aponta o caminho desejável;
+ nunca significa perda. O ruim permanece neutro. Palavra, seta, forma e cor tornam a
+ leitura redundante, não decorativa.
+ Sem tons · comparação neutra
+ Se nenhum tom vier informado, não inferir juízo: série A =
+ #1c1917 + círculo; série B =
+ #78716c + quadrado. Rótulos finais têm nome + valor e
+ podem trazer a variação observada com sinal matemático, sem palavra valorativa. Sem
+ setas ou “amplia/recua”; nenhum vermelho no plot — ele fica apenas na régua superior
+ e na marca do template.
+ Kicker, rótulos e colisão
+ Kicker fixo: “Comparação no tempo”; não há legenda separada. Cada
+ linha termina em bloco nome + valor + glifo/palavra + variação observada, tudo ≥30
+ px. O gutter começa 42 px após o ponto e tem 238 px. Cada bloco usa 4 linhas: nome,
+ valor, valência e “±N% até AAAA” em uma linha. Leading interno ≥38 px; entre blocos,
+ ≥40 px livres. Leaders de 3 px voltam ao ponto. Nunca esconder série. Nome quebra em
+ até 2 linhas; se não couber, pedir forma curta, nunca abreviar sozinho.
+ Projeção
+ Só quando o último período for projetado: faixa neutra
+ #a8a29e a 16% do meio do penúltimo→último passo até
+ 33 px depois do ponto final; apenas esse segmento usa tracejado (feed/story
+ 18 12; quadrado
+ 15 11). Os dois marcadores finais preservam a forma e
+ ficam vazados, com stroke 5 px. O rótulo projetado tem de ser o último ponto das
+ duas séries. “projeção” fica no topo direito da faixa, não junto ao eixo nem
+ misturada aos rótulos finais; a nota traz o método. Sem projeção: remover faixa,
+ tracejado, palavra e ressalva automática.
+ Eixo X · 10 pontos anuais
+ Fonte 30 px, centralizada. Para 9–12 pontos, mostrar rótulos alternados, sempre o
+ primeiro e o último; omitir o penúltimo se colidir com o último. Neste caso, exibir
+ 2017, 2019, 2021, 2023 e 2026 — o rótulo final substitui 2025. A palavra “projeção”
+ fica acima do período, nunca espremida no eixo.
+ Variação e cruzamento
+ Se o último ponto é projeção, a variação compara o primeiro valor ao último
+ observado, nunca à projeção: aqui, +121% até 2025 no
+ estadual e −23% até 2025 no municipal. O cruzamento confirmado
+ recebe dual-crossing-*: anel no ponto estadual de
+ 2024, guia tracejada até uma caixa de 3 linhas: “2024” / “Estadual” / “ultrapassa”.
+ A copy permanece em 28 px; nunca comprimir em uma linha nem deixá-la tocar a borda.
+ Sem confirmação textual do cruzamento, não gerar a anotação automaticamente.
+ Feed · 1080×1350
+ Canvas padding 76/84/58 px; headline 60 px; plot
+ 880×620, margin-top 28. Plot L52, R280, T56, B76;
+ dados x=52…600; baseline y=544; grid em y=56/300/544. Os 10 pontos ficam em
+ x=52/113/174/235/296/356/417/478/539/600; a largura útil cai de 598 para 548 px, com
+ passo anual de ~61 px, e o eixo alternado usa 52/174/296/417/600 (≥121 px entre
+ rótulos). Linhas 8 px; círculos r=8; quadrados 16×16; finais r=11 / 20×20; dash 18
+ 12. Gutter x=642…880; nomes/valência/variação 30 px, valores 36 px. Baselines
+ finais: Estadual 54/96/136/176; Municipal 248/290/330/370. Cruzamento: guia x=478,
+ y=160…398; caixa x=351, y=398, 254×118, rx=8; textos em y=431/465/497.
+ Quadrado · 1080×1080
+ Canvas padding 56/84/48 px; headline 54 px; encurtar subtítulo antes de reduzir
+ texto. Plot 880×440, margin-top 20, L52, R280, T40,
+ B70; dados x=52…600; baseline y=370; grid em y=40/205/370. Linhas 7 px; círculos
+ r=7; quadrados 14×14; finais r=10 / 18×18; dash 15 11. Gutter x=642…880; textos 30
+ px, valores 32 px. Baselines finais: Estadual 32/70/108/146; Municipal
+ 186/224/262/300. Cruzamento: guia x=478, y=115…252; caixa x=351, y=252, 254×108,
+ rx=8; textos em y=282/311/340.
+ Story · 1080×1920
+ Faixas seguras de 250 px no topo e na base; conteúdo padding 306/84/310 px; headline
+ 64 px. Plot 880×700, margin-top 28, L52, R280, T64,
+ B78; dados x=52…600; baseline y=622; grid em y=64/343/622. Linhas 8 px; círculos
+ r=8; quadrados 16×16; finais r=11 / 20×20; dash 18 12. Gutter x=642…880; nomes,
+ valência e variação 32 px, valores 36 px. Baselines finais: Estadual 68/112/154/196;
+ Municipal 280/324/366/408. Plot, caixa, rótulos e “projeção” ficam integralmente
+ entre y=250 e y=1670 do canvas. Cruzamento: guia x=478, y=181…478; caixa x=351,
+ y=478, 254×118, rx=8; textos em y=511/545/577.
+ Mapa de classes do SVG
+ Chassi: dual-line-plot,
+ dual-axis-label,
+ dual-projection-band e
+ dual-projection-label. Com tons:
+ dual-series-good / dual-markers-good e
+ dual-series-bad / dual-markers-bad. Sem tons, trocar
+ apenas os modificadores por
+ dual-series-a / dual-markers-a e
+ dual-series-b / dual-markers-b. Projeção acrescenta
+ dual-series-projection e
+ dual-marker-projected-*. Saída direta:
+ dual-end-leader,
+ dual-end-name,
+ dual-end-value e, só com tons,
+ dual-end-valence; variação observada em
+ dual-end-change. Cruzamento, quando confirmado:
+ dual-crossing-guide,
+ dual-crossing-ring,
+ dual-crossing-box,
+ dual-crossing-year e
+ dual-crossing-copy.
+ Densidade
- Até 7 pontos por gráfico. Acima disso: resumir com orientação explícita.
+ Barra, coluna e linha simples: até 7 pontos. Exceção aprovada: linha de 2 séries,
+ até 12 pontos alinhados por série; story mantém 5 apenas para barra/coluna.
Gráficos claros, sóbrios e prontos para a comunicação
- Quatro relações, um mesmo template
+ Cinco variações, um mesmo template
Estadual cresce e supera o municipal
+
+ Feed 4:5 · linha temporal de duas séries
+
+
+ Internações crescem no hospital estadual e recuam no municipal
+
+
+ Contrato visual para o porte classe-a-classe
+
+
${htmlEscape(kicker)}
diff --git a/tests/unit/chartData.unit.spec.ts b/tests/unit/chartData.unit.spec.ts index 3b557428..773de957 100644 --- a/tests/unit/chartData.unit.spec.ts +++ b/tests/unit/chartData.unit.spec.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest' import { CHART_TYPES, MAX_POINTS, + MAX_POINTS_LINE, MAX_POINTS_STORY, + MAX_SERIES, RELATION_LABEL, SIZES, classifyRelation, @@ -218,6 +220,174 @@ describe('validateSpec — fail-closed guardrails', () => { }) }) +describe('parseInput — two aligned measures become series, not a ranking', () => { + it('reads a time table with two measures as two series', () => { + const dataset = parseInput({ + text: 'Ano,Estadual,Municipal\n2017,9807,24002\n2018,10636,24438\n', + format: 'csv', + }) + expect(dataset.rows).toEqual([]) + expect(dataset.series).toEqual([ + { + name: 'Estadual', + rows: [ + { label: '2017', value: 9807 }, + { label: '2018', value: 10636 }, + ], + }, + { + name: 'Municipal', + rows: [ + { label: '2017', value: 24002 }, + { label: '2018', value: 24438 }, + ], + }, + ]) + expect(dataset.issues).toEqual([]) + }) + + it('keeps a two-column table on the single-series path', () => { + const dataset = parseInput({ text: 'cidade,votos\nIlhéus,84\nItabuna,68\n', format: 'csv' }) + expect(dataset.series).toBeUndefined() + expect(dataset.rows).toHaveLength(2) + }) + + it('flags a non-numeric cell of a series table instead of dropping the column', () => { + const dataset = parseInput({ + text: 'Ano,Estadual,Municipal\n2017,9807,???\n2018,10636,24438\n', + format: 'csv', + }) + expect(dataset.issues.join(' ')).toContain('Municipal · 2017') + }) +}) + +describe('validateSpec — the two-series time line', () => { + const seriesSpec = (overrides = {}) => ({ + chartType: 'line', + headline: 'Internações crescem no hospital estadual e recuam no municipal', + series: [ + { + name: 'Estadual', + tone: 'good', + rows: [ + { label: '2017', value: 9807 }, + { label: '2026', value: 23294 }, + ], + }, + { + name: 'Municipal', + tone: 'bad', + rows: [ + { label: '2017', value: 24002 }, + { label: '2026', value: 15948 }, + ], + }, + ], + ...overrides, + }) + + it('accepts aligned temporal series with paired tones', () => { + expect(validateSpec(seriesSpec())).toBeTruthy() + }) + + it('refuses a multi-series spec that is not a line', () => { + expect(() => validateSpec(seriesSpec({ chartType: 'bar' }))).toThrow(/linha/) + }) + + it(`refuses more than ${MAX_SERIES} series`, () => { + const [first, second] = seriesSpec().series + expect(() => + validateSpec(seriesSpec({ series: [first, second, { ...first, name: 'Total' }] })), + ).toThrow(/séries/) + }) + + it('refuses misaligned periods between the series', () => { + const [first, second] = seriesSpec().series + const drifted = { + ...second, + rows: [ + { label: '2018', value: 1 }, + { label: '2026', value: 2 }, + ], + } + expect(() => validateSpec(seriesSpec({ series: [first, drifted] }))).toThrow(/mesmos períodos/) + }) + + it('refuses categorical labels on the time axis', () => { + const categorical = (name: string, tone: string) => ({ + name, + tone, + rows: [ + { label: 'Ilhéus', value: 1 }, + { label: 'Itabuna', value: 2 }, + ], + }) + expect(() => + validateSpec( + seriesSpec({ series: [categorical('Estadual', 'good'), categorical('Municipal', 'bad')] }), + ), + ).toThrow(/períodos/) + }) + + it('refuses a one-sided, repeated or unknown tone', () => { + const [first, second] = seriesSpec().series + expect(() => + validateSpec(seriesSpec({ series: [{ ...first, tone: undefined }, second] })), + ).toThrow(/tom/) + expect(() => validateSpec(seriesSpec({ series: [{ ...first, tone: 'bad' }, second] }))).toThrow( + /mesmo tom/, + ) + expect(() => + validateSpec(seriesSpec({ series: [{ ...first, tone: 'ótimo' }, second] })), + ).toThrow(/tom inválido/) + }) + + it(`caps each series at ${MAX_POINTS_LINE} points`, () => { + const [first, second] = seriesSpec().series + const rows = Array.from({ length: MAX_POINTS_LINE + 1 }, (_v, index) => ({ + label: `${2000 + index}`, + value: index + 1, + })) + expect(() => validateSpec(seriesSpec({ series: [{ ...first, rows }, second] }))).toThrow( + /resuma/, + ) + expect(MAX_POINTS_LINE).toBeGreaterThan(MAX_POINTS) + }) + + it('marks the projection only on the last period', () => { + expect(validateSpec(seriesSpec({ projectedLabel: '2026' }))).toBeTruthy() + expect(() => validateSpec(seriesSpec({ projectedLabel: '2017' }))).toThrow(/último período/) + }) + + it('refuses --highlight on a two-series line (the tones carry the meaning)', () => { + expect(() => validateSpec(seriesSpec({ highlight: 'Estadual' }))).toThrow(/highlight/) + }) + + it('accepts a confirmed crossing and refuses an unconfirmed one', () => { + expect(validateSpec(seriesSpec({ crossingLabel: '2026' }))).toBeTruthy() + expect(() => validateSpec(seriesSpec({ crossingLabel: '2017' }))).toThrow(/primeiro/) + const [first, second] = seriesSpec().series + const alreadyAhead = { + ...second, + rows: [ + { label: '2017', value: 1 }, + { label: '2026', value: 2 }, + ], + } + expect(() => + validateSpec(seriesSpec({ crossingLabel: '2026', series: [first, alreadyAhead] })), + ).toThrow(/ultrapassagem/) + }) + + it('refuses a crossing annotation without the tone pair', () => { + const { series } = seriesSpec() + const neutral = series.map(({ name, rows }) => ({ name, rows })) + expect(() => validateSpec(seriesSpec({ crossingLabel: '2026', series: neutral }))).toThrow( + /tons/, + ) + }) +}) + describe('contracts', () => { it('declares the four v1 types, labels and sizes', () => { expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor']) diff --git a/tests/unit/chartPrimitives.unit.spec.ts b/tests/unit/chartPrimitives.unit.spec.ts index a157390a..41ee3eb1 100644 --- a/tests/unit/chartPrimitives.unit.spec.ts +++ b/tests/unit/chartPrimitives.unit.spec.ts @@ -4,6 +4,7 @@ import { CHART_COLORS, barChart, columnChart, + dualLineChart, lineChart, proportionalPercent, stackedColumnChart, @@ -140,3 +141,156 @@ describe('lineChart — series with one highlighted end', () => { expect(svg).toContain('fill="#c51414"') }) }) + +describe('dualLineChart — the approved two-series time line', () => { + const annual = (values: number[]) => + values.map((value, index) => ({ label: `${2017 + index}`, value })) + const series = [ + { + name: 'Estadual', + tone: 'good', + rows: annual([9807, 10636, 11807, 10929, 13119, 14767, 14501, 20495, 21637, 23294]), + }, + { + name: 'Municipal', + tone: 'bad', + rows: annual([24002, 24438, 22269, 20305, 21562, 20608, 19983, 19726, 18588, 15948]), + }, + ] + const colors = { + good: '#c51414', + bad: '#78716c', + neutralA: '#1c1917', + neutralB: '#78716c', + grid: '#a8a29e', + paper: '#faf9f7', + ink: '#1c1917', + change: '#57534e', + } + const valence = { good: '↑ amplia', bad: '↓ recua' } + const format = (row: { value: number }) => + new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value) + const svg = dualLineChart({ + series, + size: 'feed', + projected: true, + crossingLabel: '2024', + colors, + valence, + format, + }) + + it('draws the good path in the mandate red and the bad one neutral', () => { + expect(svg).toContain('class="dual-series dual-series-good"') + expect(svg).toContain('stroke="#c51414"') + expect(svg).toContain('class="dual-series dual-series-bad"') + expect(svg).toContain('stroke="#78716c"') + expect(svg).toContain('