feat(C237-C239): colunas emparelhadas e motion determinístico no gerador de gráficos

- C237 (CENA 02F): --paired desenha duas colunas zero-based por categoria
  (inicial cinza, recente vermelha, valência positiva compartilhada), com
  legenda de períodos, valor sobre cada barra e rótulo sob o par; até 4
  categorias; design DEGRADED com sign-off humano de 2026-09-29.
- C238/C239 (CENAS 02G/02H): --motion gera MP4 H.264 local (6s @ 30fps,
  frames determinísticos por seek + ffmpeg do repo) para o par emparelhado,
  a coluna de 2 períodos e o número-âncora; C238 com sign-off humano de
  2026-09-30 e C239 aguardando gate.
- Superfície: CLI, SKILL.md, hi-fi e specs novos (chartMotion/
  chartMotionRender; SCRIPTS_SPEC_PINNED sincronizado).

Design tier: DEGRADED (inline, tier 4 — frontier sem quota).
This commit is contained in:
Francisco Solla 2026-09-29 22:13:10 -03:00
parent 10327a7451
commit 39933fc004
14 changed files with 2239 additions and 36 deletions

View file

@ -15,10 +15,12 @@ contexto). É a **base visual confiável**, não a peça acabada.
O template visual é o artefato aprovado
`docs/plans/graficos-dados-instagram-ui-design.html` (base C203 aprovada — marca
e paleta oficiais do kit 1313 —, com as revisões C205 da linha de três séries e
C206 do rodapé/marca aguardando gate humano); o renderer
(`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe.
**Nunca invente estrutura visual** — o dono é o `designer`. O rodapé usa o
e paleta oficiais do kit 1313 —, com as revisões C205 da linha de três séries,
C206 do rodapé/marca e C207 da projeção **aprovadas no gate de 2026-09-19**, e a
revisão C237 das colunas emparelhadas **DEGRADED — aprovada por sign-off humano
em 2026-09-29, certificação do frontier pendente**); o renderer
(`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe. **Nunca invente estrutura visual** — o dono é o `designer`. O
rodapé usa o
ativo oficial `public/campaign-kit/jorge-solla-positivo.png` (nunca recriar
lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
`#184e92` (sinal de marca, nunca dado), com amarelo/verde só dentro dos ativos.
@ -62,6 +64,14 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
- **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);
- **duas medidas por categoria em pares → `delta --paired`** (revisão C237,
design DEGRADED): duas colunas verticais por categoria — a inicial em cinza
`#d6d3d1`, a mais recente no vermelho oficial `#e4102f` (o dado positivo é o
recente, compartilhado por todos os pares, sem vencedor) —, com legenda de
períodos, valor sobre cada barra e rótulo sob o par; até **4 categorias**
(acima disso os rótulos colidem — pergunte), `initial = 0` sem barra cinza,
`--highlight`/`--no-highlight`/`--dual-positive` recusados; com `--motion`
gera a versão animada (MP4, protótipo C238);
- **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 é
@ -116,6 +126,14 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
--source="Ministério da Saúde — CNES" \
--note="Sem variação: EMAD, EMAP, ENASF-AB e ECR."
```
Duas colunas por categoria (inicial cinza, recente vermelho; C237 DEGRADED):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --paired --size=feed \
--headline="Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero" \
--subtitle="Leitos por tipo na rede estadual · cirurgia: 966 → 2.750" \
--source="DATASUS / Ministério da Saúde" \
--note="Oncologia: não havia leitos na rede estadual em 2006."
```
Duas categorias, ambos os resultados bons (dois tons de vermelho, sem hierarquia):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --type=column --size=feed \
@ -127,6 +145,23 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
(gitignored) + o `chart-spec.json` em `data/graficos-instagram/`. `--size`:
`feed` (1080×1350, default), `square` (1080×1080), `story` (1080×1920, com
faixas seguras de 250px no topo e na base). `--out=` sobrescreve o caminho.
Colunas emparelhadas animadas (protótipo C238 — MP4 local, **6s @ 30fps**,
frames determinísticos + ffmpeg do repo; a peça assenta no estado estático
aprovado e segura ~2,4s até o fim):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --paired --motion --size=story \
--headline="Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero" \
--subtitle="Leitos por tipo na rede estadual · cirurgia: 966 → 2.750" \
--source="DATASUS / Ministério da Saúde" \
--note="Oncologia: não havia leitos na rede estadual em 2006."
```
O motion cobre as **colunas emparelhadas** (C238), a **coluna de 2 períodos**
e o **número-âncora** (extensão C239): a coluna cresce da esquerda para a
direita (a recente vermelha por último, valor depois da barra) e a âncora
sobe o número e revela a frase — sem contagem dramática. O builder recusa
`--motion` nas demais variantes. Saída `.mp4` no mesmo diretório do PNG;
os frames em `data/graficos-instagram/<slug>-frames-<size>/` são temporários
e apagados no fim.
5. **Confira e ajuste.** A pessoa confere o PNG; ajuste tipo/rótulo/manchete/
tamanho/`--highlight` (ou `--good`/`--bad`/`--projected`/`--crossing`) e
regenere. **Replay** sem reparse: `--spec=<chart-spec.json>`.
@ -218,6 +253,11 @@ períodos da gutter (o vermelho não entra no plot e a variante não aceita
}
```
Nas **colunas emparelhadas** (C237, DEGRADED) o mesmo spec do delta carrega
`pairedColumns: true` e o renderer desenha duas colunas por categoria — inicial
cinza, recente vermelha — com a legenda de períodos; até 4 categorias, sem
`highlight` e sem `noHighlight`/`dualPositive`.
## Guardrails (fail-closed)
O builder **recusa** em vez de desenhar algo enganoso:
@ -235,6 +275,21 @@ O builder **recusa** em vez de desenhar algo enganoso:
retração (final < inicial) é recusada com a linha multi-série no lugar;
`--highlight`/valência não entram (o par de tons é fixo e o vermelho fica fora
do plot).
- **Colunas emparelhadas (`delta --paired`, C237)**: par fixo por categoria —
inicial `#d6d3d1`, recente `#e4102f` (dado positivo compartilhado, nunca
vencedor) —, base zero e até **4 categorias** (o rótulo de 30px sob o par
colide acima disso: pergunte); `initial = 0` sem barra cinza; legenda de
períodos + valor sobre cada barra + rótulo sob o par (**cor nunca sozinha**);
`--highlight`/`--no-highlight`/`--dual-positive` recusados. O design da
variante está **DEGRADED** (tier degradado por quota do frontier): aprovada
por sign-off humano em 2026-09-29, com a certificação do frontier pendente.
- **Motion (`--motion`, C238/C239)**: cobre as colunas emparelhadas, a coluna
de 2 períodos e o número-âncora (as demais variantes são recusadas); MP4
H.264 local (ffmpeg do repo), **6s @ 30fps**, frames determinísticos por seek
(nunca gravação em tempo real) e o estado final é exatamente o PNG aprovado;
sem contagem dramática na âncora. O motion do par (C238) está **DEGRADED**
com sign-off humano de 2026-09-30; a extensão (C239) segue **DEGRADED
aguardando gate humano**.
- **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
@ -278,9 +333,9 @@ O builder **recusa** em vez de desenhar algo enganoso:
valor e posição).
- **Dado de campanha nunca sai da máquina:** render 100% local, sem MCP/serviço
remoto por default.
- **Nada é commitado:** entrada, `chart-spec` e PNG vivem em
`data/graficos-instagram/` e `docs/research/graficos-instagram/`, ambos
gitignored (repo público).
- **Nada é commitado:** entrada, `chart-spec`, PNG/MP4 e os frames temporários
do motion vivem em `data/graficos-instagram/` e
`docs/research/graficos-instagram/`, ambos gitignored (repo público).
## Troubleshooting
@ -288,6 +343,13 @@ O builder **recusa** em vez de desenhar algo enganoso:
rótulo, valor não numérico, rótulo repetido. Pergunte à pessoa; não invente.
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. Na linha
multi-série o teto é 12 por série.
- **Colunas emparelhadas com mais de 4 categorias, `--highlight`,
`--no-highlight` ou `--dual-positive`** → o par de tons é fixo e o rótulo sob o
par colide acima de 4 pares: agrupe/remova categorias e tire o destaque; nunca
corte em top-N.
- **`--motion` recusado** → existe nas colunas emparelhadas, na coluna de 2
períodos e no número-âncora; barra/linha/etc. aguardam design de motion
próprio.
- **Séries desalinhadas / tom ímpar (par ou trio incompletos) / cruzamento na
linha de três séries / três séries fora do feed / cruzamento não confirmado /
projeção fora do último período** → o builder pede a correção com mensagem
@ -301,11 +363,11 @@ O builder **recusa** em vez de desenhar algo enganoso:
## Referências
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (inclui
a variação certificada de linha de duas séries, com valência bom/ruim; a
extensão C205 de três séries observadas; a revisão C207 da projeção no trio,
nova e aguardando gate humano; e a revisão C206 do rodapé/marca; base C203 —
marca e paleta oficiais do kit 1313).
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (base
C203 e revisões C205/C206/C207 aprovadas no gate de 2026-09-19; C237 das
colunas emparelhadas DEGRADED com sign-off humano de 2026-09-29; C238 do
motion do par DEGRADED com sign-off humano de 2026-09-30; C239 da extensão —
coluna de 2 períodos e âncora, CENA 02H — DEGRADED aguardando gate humano).
- Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl:
`docs/plans/graficos-dados-instagram-impl.md`.
- Marca oficial: `docs/plans/graficos-dados-marca-kit-1313.md` (impl:

View file

@ -0,0 +1 @@
**C237/C238/C239 — Gráficos do Instagram: colunas emparelhadas e motion determinístico (2026-09-30):** a skill `graficos-dados` (C191/C203/C205/C206/C207) ganha o par de colunas por categoria e o motion das peças. (1) **Colunas emparelhadas (revisão C237, CENA 02F):** `--paired` sobre o dado da barra de variação (`--type=delta`) desenha duas colunas zero-based por categoria — a inicial em cinza `#d6d3d1` e a recente no vermelho oficial `#e4102f` (dado positivo compartilhado por todos os pares, sem vencedor) — com legenda de períodos (swatches 2006/2026), valor sobre cada barra e rótulo sob o par (cor nunca sozinha); até **4 categorias** (o rótulo de 30px sob o par colide acima disso e o builder pergunta), `initial = 0` sem barra cinza, `--highlight`/`--no-highlight`/`--dual-positive` recusados. Design DEGRADED (escrito inline no tier 4 por quota do frontier) aprovado por sign-off humano em 2026-09-29, com certificação do frontier pendente. (2) **Motion (revisões C238/C239, CENAS 02G/02H):** `--motion` gera MP4 H.264 local (**6s @ 30fps**, 180 frames determinísticos por seek das animações CSS + encode com o ffmpeg do repo — nunca gravação em tempo real), com o estado final idêntico ao PNG aprovado e hold estático de ~2,4s; o vocabulário é fixo (sweep/rise/fade/grow, easing único, `fill-mode: backwards`) e cobre o par emparelhado (C238 — pares da esquerda para a direita, barra com rótulo junto e valor depois; sign-off humano em 2026-09-30), a coluna de 2 períodos (C239 — uma coluna por vez, recente vermelha por último) e o número-âncora (C239 — número sobe e frase revela, sem contagem dramática); barra/linha/etc. recusam `--motion`. (3) **Superfície:** CLI (`--paired`, `--motion`), `SKILL.md`/command, hi-fi (CENAS 02F/02G/02H — CENAS 02C/C205/C206/C207 intactas) e specs novos (`chartMotion.mjs`/`chartMotionRender.mjs`; `SCRIPTS_SPEC_PINNED` sincronizado). Peças de validação: leitos por tipo na rede estadual (cirurgia geral 592→1.433, ortopedia 182→755, neurocirurgia 42→170, oncologia 0→134) e os demais gráficos do pronunciamento (leitos totais 3.905→7.343, internações 155.624→384.719, UTI total 179→1.536, leitos privados contratados 1.130) nos 3 tamanhos. Verificação: unit 5.002 verdes (specs do gerador estendidos), lint/typecheck limpos; sem migration.

File diff suppressed because it is too large Load diff

View file

@ -11,8 +11,8 @@
* 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|column|line|anchor|delta] [--size=feed|square|story] \
* [--unit="%"] [--highlight="Território A"|--no-highlight|--dual-positive] \
* [--out=docs/research/graficos-instagram/x.png]
* [--unit="%"] [--highlight="Território A"|--no-highlight|--dual-positive|--paired] \
* [--motion] [--out=docs/research/graficos-instagram/x.png]
* node scripts/build-chart-from-data.mjs --in=dados.csv --inspect
*/
@ -33,6 +33,8 @@ import {
parseInput,
validateSpec,
} from './lib/chartData.mjs'
import { MOTION_DURATION_S, MOTION_FPS, validateMotion } from './lib/chartMotion.mjs'
import { renderChartMotion } from './lib/chartMotionRender.mjs'
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
import { renderChartHtml } from './lib/graficosInstagramRender.mjs'
@ -128,6 +130,7 @@ const readInput = async (flags) => {
* argv?: string[],
* launchBrowser?: () => Promise<{ close: () => Promise<void> }>,
* screenshot?: typeof screenshotHtmlPng,
* renderMotion?: typeof renderChartMotion,
* die?: (message: string) => never,
* repoRoot?: string,
* }} [options]
@ -136,6 +139,7 @@ export const main = async ({
argv = process.argv.slice(2),
launchBrowser = launchPdfBrowser,
screenshot = screenshotHtmlPng,
renderMotion = renderChartMotion,
die = dieWithLabel(LABEL),
repoRoot = process.cwd(),
} = {}) => {
@ -150,7 +154,7 @@ export const main = async ({
}
} else {
if (!flags.in) die('informe --in=<arquivo|-> com os dados (ou --spec=<json> para replay).')
const forcedType = flags.type ? String(flags.type) : null
const forcedType = flags.type ? String(flags.type) : flags.paired ? 'delta' : null
let input
try {
input = await readInput(flags)
@ -212,6 +216,7 @@ export const main = async ({
...(flags.highlight ? { highlight: String(flags.highlight) } : {}),
...(flags['no-highlight'] ? { noHighlight: true } : {}),
...(flags['dual-positive'] ? { dualPositive: true } : {}),
...(flags.paired ? { pairedColumns: true } : {}),
rows: dataset.rows,
...(chartType === 'delta'
? { startLabel: dataset.startLabel, endLabel: dataset.endLabel }
@ -220,8 +225,11 @@ export const main = async ({
}
}
if (flags.motion) spec = { ...spec, motion: true }
if (!spec.source) die('informe --source (a fonte viaja dentro da imagem).')
validateSpec(spec)
if (spec.motion) validateMotion(spec)
const slug = slugify(spec.headline)
const specPath = join(repoRoot, 'data/graficos-instagram', `${slug || 'grafico'}.chart-spec.json`)
@ -230,7 +238,7 @@ export const main = async ({
: join(
repoRoot,
'docs/research/graficos-instagram',
`${slug || 'grafico'}-${today()}-${spec.size ?? 'feed'}.png`,
`${slug || 'grafico'}-${today()}-${spec.size ?? 'feed'}.${spec.motion ? 'mp4' : 'png'}`,
)
let brandLogo = ''
@ -258,6 +266,24 @@ export const main = async ({
await mkdir(dirname(outPath), { recursive: true })
await mkdir(dirname(specPath), { recursive: true })
await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`)
if (spec.motion) {
const { size, source } = await renderMotion({
browser,
html,
width: canvas.width,
height: canvas.height,
outPath,
workDir: join(
repoRoot,
'data/graficos-instagram',
`${slug || 'grafico'}-frames-${spec.size ?? 'feed'}`,
),
})
console.log(
`[${LABEL}] MP4 ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · motion ${MOTION_DURATION_S}s @ ${MOTION_FPS}fps · ${pointCount} · ffmpeg ${source}`,
)
return
}
const { size } = await screenshot(browser, {
html,
width: canvas.width,

View file

@ -11,6 +11,8 @@
import { parse as parseCsv } from 'csv-parse/sync'
import XLSX from 'xlsx'
import { motionKindFor } from './chartMotion.mjs'
export const CHART_TYPES = ['bar', 'column', 'line', 'anchor', 'delta']
/** Max data points per chart (honest density; above this the person must summarize). */
@ -23,6 +25,16 @@ export const MAX_POINTS = 7
*/
export const MAX_POINTS_STORY = 5
/**
* Paired columns (C237, degraded design): the two-measure-per-category dataset
* of the delta drawn as two zero-based columns per category — the initial in
* the neutral tone, the recent one in the official red (the positive datum is
* shared by every pair, never a winner). The 30px category label under a pair
* caps the variant at four categories on the 1080 canvas; above that the labels
* collide and the builder asks instead of drawing.
*/
export const MAX_PAIRED = 4
/**
* Two-series and three-series time comparison (approved variants): an annual
* series carries more points than a ranking, per series. One tone per series
@ -643,6 +655,42 @@ export const validateSpec = (spec) => {
}
throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`)
}
if (spec.pairedColumns !== undefined && typeof spec.pairedColumns !== 'boolean') {
throw new Error('pairedColumns inválido — use booleano.')
}
if (spec.pairedColumns) {
if (chartType !== 'delta') {
throw new Error(
'as colunas emparelhadas exigem o tipo delta (--type=delta): o dado é rótulo + inicial + final.',
)
}
if (spec.highlight) {
throw new Error(
'as colunas emparelhadas não usam destaque — o par de tons é fixo (inicial cinza, recente vermelho).',
)
}
if (spec.noHighlight || spec.dualPositive) {
throw new Error(
'as colunas emparelhadas não combinam com --no-highlight/--dual-positive — o par de tons é fixo.',
)
}
if (!Array.isArray(spec.rows) || spec.rows.length === 0) {
throw new Error('sem pontos de dado para desenhar.')
}
if (spec.rows.length > MAX_PAIRED) {
throw new Error(
`${spec.rows.length} categorias (> ${MAX_PAIRED}): os rótulos colidem sob os pares — agrupe/remova categorias antes de gerar o gráfico.`,
)
}
}
if (spec.motion !== undefined && typeof spec.motion !== 'boolean') {
throw new Error('motion inválido — use booleano.')
}
if (spec.motion && !motionKindFor(spec)) {
throw new Error(
'o motion cobre as colunas emparelhadas (--paired), a coluna de 2 períodos e o número-âncora.',
)
}
if (chartType === 'delta') {
validateDelta(spec)
} else if (Array.isArray(series) && series.length > 0) {

172
scripts/lib/chartMotion.mjs Normal file
View file

@ -0,0 +1,172 @@
/**
* Chart motion (C238/C239): the deterministic timeline that animates the
* pieces without touching the approved static design. The schedule is data,
* not CSS — the renderer serializes each stage into `--md` (delay) and `--mud`
* (duration) custom properties on the element and the stylesheet carries only
* the keyframes and the easing, so the clock is pinned here and tested
* directly.
*
* Variants: the paired columns (C238 prototype) and, in the C239 extension,
* the two-period column and the anchor number. The capture never records in
* real time: every CSS animation is seeked to the frame time (30fps grid, 6s),
* screenshotted, and the MP4 is encoded offline from those exact timestamps
* (see `chartMotionRender.mjs`).
*/
export const MOTION_FPS = 30
export const MOTION_DURATION_S = 6
export const MOTION_FRAMES = MOTION_DURATION_S * MOTION_FPS
export const MOTION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'
/**
* Shell stages (seconds). The card is already composed for reading: the rule,
* kicker, headline and subtitle enter first, then the data marks grow, and the
* footer (source + brand) lands last — right before the static hold. `legend`
* belongs to the paired variant; `number`/`copy` belong to the anchor.
*/
export const MOTION_STAGES = {
topRule: { kind: 'sweep', delay: 0, duration: 0.45 },
kicker: { kind: 'rise', delay: 0.2, duration: 0.45 },
headline: { kind: 'rise', delay: 0.35, duration: 0.55 },
subtitle: { kind: 'rise', delay: 0.65, duration: 0.45 },
legend: { kind: 'fade', delay: 0.8, duration: 0.4 },
number: { kind: 'rise', delay: 1.05, duration: 0.6 },
copy: { kind: 'fade', delay: 1.7, duration: 0.45 },
footer: { kind: 'fade', delay: 3.1, duration: 0.5 },
}
/**
* Per-mark stages of the bar variants: the mark grows, the label enters with
* the growth and the value lands after the bar settles. The paired columns
* stagger by pair (the two columns of a pair grow together); the two-period
* column staggers per column, left to right (the recent red one last).
*/
export const PAIR_MOTION = {
firstAt: 1.1,
stagger: 0.3,
barDuration: 0.75,
labelOffset: 0.15,
labelDuration: 0.3,
valueOffset: 0.8,
valueDuration: 0.3,
}
export const COLUMN_MOTION = {
firstAt: 1.1,
stagger: 0.35,
barDuration: 0.75,
labelOffset: 0.15,
labelDuration: 0.3,
valueOffset: 0.8,
valueDuration: 0.3,
}
/** Milliseconds-rounded seconds: the inline CSS vars must read `1.4s`, not `1.4000000000000001s`. */
const round3 = (value) => Math.round(value * 1000) / 1000
const markMotion = (config, index) => {
const barAt = round3(config.firstAt + index * config.stagger)
return {
bar: { kind: 'grow', delay: barAt, duration: config.barDuration },
label: {
kind: 'fade',
delay: round3(barAt + config.labelOffset),
duration: config.labelDuration,
},
value: {
kind: 'rise',
delay: round3(barAt + config.valueOffset),
duration: config.valueDuration,
},
}
}
/** @param {number} index pair position, left to right (0-based) */
export const pairMotion = (index) => markMotion(PAIR_MOTION, index)
/** @param {number} index column position, left to right (0-based) */
export const columnMotion = (index) => markMotion(COLUMN_MOTION, index)
/**
* Motion variant of a spec — `null` means the shape has no certified motion
* yet (the builder refuses instead of animating something undesigned).
*/
export const motionKindFor = (spec) => {
if (spec.pairedColumns) return 'paired'
if (spec.chartType === 'anchor') return 'anchor'
if (spec.chartType === 'column') return 'column'
return null
}
const SHELL_STAGES = ['topRule', 'kicker', 'headline', 'subtitle', 'footer']
/** Every stage a spec animates — the clock guard reads this list. */
export const motionStages = (spec) => {
const kind = motionKindFor(spec)
const shell = SHELL_STAGES.map((name) => MOTION_STAGES[name])
if (kind === 'anchor') return [...shell, MOTION_STAGES.number, MOTION_STAGES.copy]
const rows = Array.isArray(spec.rows) ? spec.rows : []
const marks = rows.flatMap((_row, index) =>
Object.values(kind === 'column' ? columnMotion(index) : pairMotion(index)),
)
return kind === 'paired' ? [...shell, MOTION_STAGES.legend, ...marks] : [...shell, ...marks]
}
/** Last stage end (seconds) of a stage list. */
export const motionEnd = (stages) =>
Math.max(...stages.map((stage) => stage.delay + stage.duration))
/**
* Fail-closed: the shape must have a certified motion, the column motion is
* certified for the two-period pair (C239) and the piece must be fully settled
* inside the exported duration (the tail is a static hold the editor can cut
* into).
*/
export const validateMotion = (spec) => {
const kind = motionKindFor(spec)
if (!kind) {
throw new Error(
'motion indisponível para esta variante: o protótipo cobre as colunas emparelhadas, a coluna de 2 períodos e o número-âncora.',
)
}
if (kind === 'column' && (!Array.isArray(spec.rows) || spec.rows.length !== 2)) {
throw new Error(
`o motion da coluna está certificado para 2 períodos (${spec.rows?.length ?? 0} recebidos): o protótipo C239 cobre o par de períodos.`,
)
}
const end = motionEnd(motionStages(spec))
if (end > MOTION_DURATION_S) {
throw new Error(
`motion termina em ${end}s (> ${MOTION_DURATION_S}s): reduza os pontos antes de gerar.`,
)
}
return { kind, end, duration: MOTION_DURATION_S }
}
/** Deterministic 30fps grid (ms): the capture seeks every animation to these. */
export const motionFrameTimes = () =>
Array.from({ length: MOTION_FRAMES }, (_value, index) => (index * 1000) / MOTION_FPS)
/**
* Keyframes and the generic animated classes. Timings never live here: each
* element carries `--md`/`--mud` from the schedule above. `backwards` fill
* means the from-state holds through the delay and the element ends on its
* natural (static) styles — the last frame is exactly the approved PNG.
*/
export const MOTION_CSS = `@keyframes motion-sweep { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes motion-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
@keyframes motion-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes motion-grow { from { transform: scaleY(0); } to { transform: scaleY(1); } }
.motion .motion-sweep,
.motion .motion-rise,
.motion .motion-fade,
.motion .motion-grow {
animation-fill-mode: backwards;
animation-timing-function: ${MOTION_EASING};
animation-duration: var(--mud, 0.5s);
animation-delay: var(--md, 0s);
}
.motion .motion-sweep { animation-name: motion-sweep; transform-origin: left center; }
.motion .motion-rise { animation-name: motion-rise; }
.motion .motion-fade { animation-name: motion-fade; }
.motion .motion-grow { animation-name: motion-grow; transform-origin: bottom center; }`

View file

@ -0,0 +1,107 @@
/**
* C238 chart motion render: deterministic frame capture (one page, every CSS
* animation seeked to the frame time and paused) plus the offline H.264 encode
* with the packaged ffmpeg. Frames live in the gitignored `data/` workdir and
* are removed after the encode — the MP4 is the only artifact. Real-time
* recording is never used: the same reasoning of the reel capture applies (a
* fixed-rate recorder drifts from the clock; here the clock is exact anyway).
*/
import { mkdir, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { MOTION_FPS, MOTION_FRAMES } from './chartMotion.mjs'
import { encodeFrames, REEL_CRF, REEL_PRESET, resolveFfmpeg } from './reelFfmpeg.mjs'
/**
* Numbered PNG frames of the motion document: after `load` every CSS animation
* is seeked to the frame timestamp and paused, so the frame is exact no matter
* how fast Chromium renders it.
*
* @param {any} browser Chromium from `launchPdfBrowser`
* @param {{ html: string, width: number, height: number, dir: string, frames?: number, fps?: number }} options
*/
const captureMotionFrames = async (
browser,
{ html, width, height, dir, frames = MOTION_FRAMES, fps = MOTION_FPS },
) => {
const page = await browser.newPage()
try {
await page.setViewportSize({ width, height })
await page.setContent(html, { waitUntil: 'load' })
// Freeze the timeline before anything else: Chromium drops finished CSS
// animations, and a slow font load would make the first stages unseekable.
await page.evaluate(() => {
for (const animation of document.getAnimations()) {
animation.currentTime = 0
animation.pause()
}
})
await page.evaluate(() => document.fonts.ready)
const captured = []
for (let index = 0; index < frames; index += 1) {
const captureMs = (index * 1000) / fps
await page.evaluate((time) => {
for (const animation of document.getAnimations()) {
animation.currentTime = time
animation.pause()
}
}, captureMs)
const file = join(dir, `frame-${String(index).padStart(4, '0')}.png`)
await page.screenshot({ path: file, type: 'png', clip: { x: 0, y: 0, width, height } })
captured.push({ file, captureMs })
}
return captured
} finally {
await page.close()
}
}
/**
* Full local render of one animated chart: resolve ffmpeg (`FFMPEG_PATH` →
* PATH → packaged), capture the frames into `workDir`, encode and clean up.
* `resolveFfmpegImpl`/`encodeImpl` are injectable so the CLI spec runs without
* a real binary.
*
* @returns {Promise<{ size: number, source: string }>}
*/
export const renderChartMotion = async ({
browser,
html,
width,
height,
outPath,
workDir,
frames = MOTION_FRAMES,
fps = MOTION_FPS,
resolveFfmpegImpl = resolveFfmpeg,
encodeImpl = encodeFrames,
}) => {
await mkdir(workDir, { recursive: true })
let source = ''
try {
const ffmpeg = await resolveFfmpegImpl()
source = ffmpeg.source
const captured = await captureMotionFrames(browser, {
html,
width,
height,
dir: workDir,
frames,
fps,
})
await encodeImpl({
ffmpegBin: ffmpeg.bin,
frames: captured,
listPath: join(workDir, 'frames.txt'),
output: outPath,
fps,
preset: REEL_PRESET,
crf: REEL_CRF,
})
} finally {
await rm(workDir, { recursive: true, force: true })
}
const { size } = await stat(outPath)
return { size, source }
}

View file

@ -10,6 +10,7 @@
*/
import { RELATION_LABEL, SERIES_RELATION_LABEL, SIZES } from './chartData.mjs'
import { MOTION_CSS, MOTION_STAGES, columnMotion, pairMotion } from './chartMotion.mjs'
import {
dualLineChart,
lineChart,
@ -65,6 +66,7 @@ const rankingBody = (spec) => {
}
const columnBody = (spec) => {
const motion = spec.motion === true
const rows = spec.rows
const max = Math.max(...rows.map((row) => row.value))
const winner = highlightLabel(spec)
@ -78,10 +80,13 @@ const columnBody = (spec) => {
.map((row, index) => {
const isHighlight = !spec.dualPositive && row.label === winner
const tone = spec.dualPositive ? ` positive-${index === 0 ? 'a' : 'b'}` : ''
// C239: the two-period motion grows each column left to right (the
// recent red one last), with the value landing after the bar settles.
const timing = columnMotion(index)
return `<div class="column-cell">
<span class="column-value">${formatValue(row.value, spec.unit)}</span>
<div class="column${isHighlight ? ' highlight' : ''}${tone}" style="height:${proportionalPercent(row.value, max)}%"></div>
<span class="column-label">${htmlEscape(row.label)}</span>
<span class="column-value${motionClass(motion, timing.value)}"${styleAttr(motionVars(motion, timing.value))}>${formatValue(row.value, spec.unit)}</span>
<div class="column${isHighlight ? ' highlight' : ''}${tone}${motionClass(motion, timing.bar)}"${styleAttr(styleJoin(`height:${proportionalPercent(row.value, max)}%`, motionVars(motion, timing.bar)))}></div>
<span class="column-label${motionClass(motion, timing.label)}"${styleAttr(motionVars(motion, timing.label))}>${htmlEscape(row.label)}</span>
</div>`
})
.join('')}
@ -93,6 +98,13 @@ const formatPercent = (value) => {
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
/** Motion plumbing: the schedule travels as `--md`/`--mud` inline vars (C238). */
const styleAttr = (value) => (value ? ` style="${value}"` : '')
const styleJoin = (...parts) => parts.filter(Boolean).join(';')
const motionClass = (motion, timing) => (motion && timing ? ` motion-${timing.kind}` : '')
const motionVars = (motion, timing) =>
motion && timing ? `--md:${timing.delay}s;--mud:${timing.duration}s` : ''
const deltaOrder = (rows) =>
[...rows].sort(
(a, b) => b.final - a.final || a.initial - b.initial || a.label.localeCompare(b.label, 'pt-BR'),
@ -145,12 +157,69 @@ const deltaBody = (spec) => {
</div>`
}
/**
* Paired columns (C237 degraded design): the same two-measure dataset of the
* delta, drawn as two zero-based columns per category — the initial in the
* neutral tone and the recent one in the official red (the positive datum is
* shared by every pair, never a winner). The period legend carries the color
* cue, the value travels over each bar and the category label under the pair,
* so color never carries the reading alone. An initial of zero simply has no
* gray column.
*
* C238 motion: under `spec.motion` the pairs grow left to right (both columns
* together, labels with the growth, values after the bar lands) and the legend
* fades in with the shell staging — the timing comes from `chartMotion.mjs`.
*/
const pairedBody = (spec) => {
const motion = spec.motion === true
const rows = deltaOrder(spec.rows)
const max = Math.max(1, ...rows.map((row) => row.final))
const aria = `Colunas emparelhadas por categoria, ${spec.startLabel} e ${spec.endLabel}: ${rows
.map(
(row) =>
`${row.label} ${formatValue(row.initial, spec.unit)} e ${formatValue(row.final, spec.unit)}`,
)
.join('; ')}. A coluna cinza é o início do período; a vermelha é o dado mais recente.`
return `<div class="paired-grade" role="img" aria-label="${htmlEscape(aria)}">
<div class="paired-legend${motionClass(motion, MOTION_STAGES.legend)}"${styleAttr(motionVars(motion, MOTION_STAGES.legend))}>
<span><i class="paired-swatch initial"></i>${htmlEscape(spec.startLabel)}</span>
<span><i class="paired-swatch final"></i>${htmlEscape(spec.endLabel)}</span>
</div>
<div class="paired-columns" style="grid-template-columns:repeat(${rows.length}, minmax(0, 1fr))">
${rows
.map((row, index) => {
const timing = pairMotion(index)
const initialHeight = formatPercent((row.initial / max) * 100)
const finalHeight = formatPercent((row.final / max) * 100)
return `<div class="paired-pair">
<div class="paired-bar-cell">
<span class="paired-value${motionClass(motion, timing.value)}"${styleAttr(motionVars(motion, timing.value))}>${formatValue(row.initial, spec.unit)}</span>${
row.initial > 0
? `<span class="paired-bar${motionClass(motion, timing.bar)}"${styleAttr(styleJoin(`height:${initialHeight}%`, motionVars(motion, timing.bar)))}></span>`
: ''
}
</div>
<div class="paired-bar-cell">
<span class="paired-value${motionClass(motion, timing.value)}"${styleAttr(motionVars(motion, timing.value))}>${formatValue(row.final, spec.unit)}</span>
<span class="paired-bar recent${motionClass(motion, timing.bar)}"${styleAttr(styleJoin(`height:${finalHeight}%`, motionVars(motion, timing.bar)))}></span>
</div>
<span class="paired-label${motionClass(motion, timing.label)}"${styleAttr(motionVars(motion, timing.label))}>${deltaLabelHtml(row.label)}</span>
</div>`
})
.join('')}
</div>
</div>`
}
const anchorBody = (spec) => {
// C239: the anchor motion rises the hero number and fades the copy after it —
// same vocabulary, never a dramatic count-up.
const motion = spec.motion === true
const row = spec.rows[0]
const copy = row.label || spec.subtitle || ''
return `<div class="anchor">
<div class="anchor-number">${formatValue(row.value, spec.unit)}</div>
${copy ? `<p class="anchor-copy">${htmlEscape(copy)}</p>` : ''}
<div class="anchor-number${motionClass(motion, MOTION_STAGES.number)}"${styleAttr(motionVars(motion, MOTION_STAGES.number))}>${formatValue(row.value, spec.unit)}</div>
${copy ? `<p class="anchor-copy${motionClass(motion, MOTION_STAGES.copy)}"${styleAttr(motionVars(motion, MOTION_STAGES.copy))}>${htmlEscape(copy)}</p>` : ''}
</div>`
}
@ -215,7 +284,7 @@ const tripleBody = (spec) =>
})}</div>`
const plotBody = (spec) => {
if (spec.chartType === 'delta') return deltaBody(spec)
if (spec.chartType === 'delta') return spec.pairedColumns ? pairedBody(spec) : deltaBody(spec)
const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0
if (seriesCount === 3) return tripleBody(spec)
if (seriesCount > 0) return dualBody(spec)
@ -241,7 +310,8 @@ const brandMark = (brandMarkDataUri) => `<div class="brand-logo-frame">
<img src="${brandMarkDataUri}" alt="Jorge Solla — Deputado Federal" />
</div>`
const footer = (spec, brandMarkDataUri) => `<footer class="footer">
const footer = (spec, brandMarkDataUri, motion = false) =>
`<footer class="footer${motionClass(motion, MOTION_STAGES.footer)}"${styleAttr(motionVars(motion, MOTION_STAGES.footer))}>
<p class="source"><strong>Fonte:</strong> ${htmlEscape(spec.source)}${spec.note ? `<br />Nota: ${htmlEscape(spec.note)}` : ''}</p>
${brandMark(brandMarkDataUri)}
</footer>`
@ -325,6 +395,39 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
text-align: right;
white-space: nowrap;
}
.paired-grade { display: flex; flex-direction: column; height: 100%; }
.paired-legend {
flex: none; display: flex; justify-content: flex-end; align-items: center;
gap: 26px; margin-bottom: 16px; color: ${SOLLA_PALETTE.label};
font-size: 28px; line-height: 1; font-weight: 700;
}
.paired-legend span { display: inline-flex; align-items: center; gap: 10px; }
.paired-swatch { width: 18px; height: 18px; flex: 0 0 18px; }
.paired-swatch.initial { background: ${SOLLA_PALETTE.bar}; }
.paired-swatch.final { background: ${SOLLA_PALETTE.highlight}; }
.paired-columns {
flex: 1; min-height: 0; display: grid; align-items: end; gap: 30px;
padding: 0 5px 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong};
}
.paired-pair {
position: relative; display: flex; align-items: flex-end; justify-content: center;
gap: 12px; height: 100%;
}
.paired-bar-cell {
position: relative; display: flex; flex-direction: column; justify-content: flex-end;
width: 50%; max-width: 86px; height: 100%;
}
.paired-value {
margin-bottom: 10px; color: ${SOLLA_PALETTE.ink}; text-align: center;
font-size: 31px; line-height: 1; font-weight: 800; font-variant-numeric: tabular-nums;
}
.paired-bar { background: ${SOLLA_PALETTE.bar}; }
.paired-bar.recent { background: ${SOLLA_PALETTE.highlight}; }
.paired-label {
position: absolute; bottom: -40px; left: 50%; transform: translateX(-50%);
color: ${SOLLA_PALETTE.label}; text-align: center; white-space: nowrap;
font-size: 30px; line-height: 1; font-weight: 650;
}
.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}; }
@ -457,7 +560,11 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
: sizeKey === 'story'
? '.footer { min-height: 190px; padding-top: 28px; gap: 28px; } .brand-logo-frame { width: 279px; height: 160px; flex-basis: 279px; }'
: ''
const delta = spec.chartType === 'delta'
const paired = spec.chartType === 'delta' && Boolean(spec.pairedColumns)
const delta = spec.chartType === 'delta' && !paired
// C238: motion is the paired-columns prototype; the schedule lives in
// chartMotion.mjs and reaches each element as inline --md/--mud vars.
const motion = spec.motion === true
const deltaStyles = !delta
? ''
: sizeKey === 'feed'
@ -465,6 +572,13 @@ 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 pairedStyles = !paired
? ''
: sizeKey === 'feed'
? '.plot { margin-top: 26px; } .headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; }'
: sizeKey === 'square'
? '.inner { padding: 56px 84px 48px; } .plot { margin-top: 20px; } .headline { font-size: 54px; } .subtitle { font-size: 28px; } .paired-legend { font-size: 26px; } .paired-value { font-size: 30px; } .paired-label { bottom: -36px; }'
: '.plot { margin-top: 40px; } .headline { font-size: 60px; } .subtitle { font-size: 30px; }'
const kicker =
spec.kicker ??
(dual || triple ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico'))
@ -475,6 +589,7 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
<head>
<meta charset="utf-8" />
<style>${CSS}</style>
${motion ? `<style>${MOTION_CSS}</style>` : ''}
<style>
.canvas { width: ${canvas.width}px; height: ${canvas.height}px; }
.inner { padding: ${layout.padding}; }
@ -486,18 +601,19 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
${footerStyles}
${dualStyles}
${deltaStyles}
${pairedStyles}
${dualPositiveStyles}
</style>
</head>
<body>
<article class="canvas${dual ? ' dual-series' : ''}${triple ? ' triple-series' : ''}${tripleProjected ? ' triple-projected' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<article class="canvas${dual ? ' dual-series' : ''}${triple ? ' triple-series' : ''}${tripleProjected ? ' triple-projected' : ''}${motion ? ' motion' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<div class="inner">
<div class="top-rule"></div>
<p class="context">${htmlEscape(kicker)}</p>
<h1 class="headline">${htmlEscape(spec.headline)}</h1>
${showSubtitle ? `<p class="subtitle">${htmlEscape(spec.subtitle)}</p>` : ''}
<div class="top-rule${motionClass(motion, MOTION_STAGES.topRule)}"${styleAttr(motionVars(motion, MOTION_STAGES.topRule))}></div>
<p class="context${motionClass(motion, MOTION_STAGES.kicker)}"${styleAttr(motionVars(motion, MOTION_STAGES.kicker))}>${htmlEscape(kicker)}</p>
<h1 class="headline${motionClass(motion, MOTION_STAGES.headline)}"${styleAttr(motionVars(motion, MOTION_STAGES.headline))}>${htmlEscape(spec.headline)}</h1>
${showSubtitle ? `<p class="subtitle${motionClass(motion, MOTION_STAGES.subtitle)}"${styleAttr(motionVars(motion, MOTION_STAGES.subtitle))}>${htmlEscape(spec.subtitle)}</p>` : ''}
<div class="plot${dualPositive ? ' dual-positive-plot' : ''}">${plotBody(spec)}</div>
${footer(spec, brandLogoDataUri)}
${footer(spec, brandLogoDataUri, motion)}
</div>
</article>
</body>

View file

@ -54,6 +54,8 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/campaignKitAssets.mjs',
'scripts/lib/changelog.mjs',
'scripts/lib/chartData.mjs',
'scripts/lib/chartMotion.mjs',
'scripts/lib/chartMotionRender.mjs',
'scripts/lib/chartPrimitives.mjs',
'scripts/lib/cityReportBlocks.mjs',
'scripts/lib/cityReportDatabase.mjs',

View file

@ -252,6 +252,138 @@ describe('main — spec assembly, replay and the per-size log', () => {
expect(stdout.join('')).toContain('tipo=delta')
})
it('assembles the paired columns from the delta table with --paired', async () => {
const input = await writeInput(
dir,
'Tipo\t2006\t2026\nCirurgia geral\t592\t1433\nOncologia\t0\t134\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
await main({
argv: [
`--in=${input}`,
'--paired',
'--headline=Ortopedia cresce 4,1 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${join(dir, 'par.png')}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/ortopedia-cresce-4-1-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({
chartType: 'delta',
pairedColumns: true,
startLabel: '2006',
endLabel: '2026',
})
expect(stdout.join('')).toContain('tipo=delta')
})
it('renders the paired columns as an MP4 with --motion and skips the still screenshot', async () => {
const input = await writeInput(
dir,
'Tipo\t2006\t2026\nCirurgia geral\t592\t1433\nOncologia\t0\t134\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const renderMotion = vi.fn(async (_options: { outPath: string }) => ({
size: 4096,
source: 'empacotado',
}))
const outPath = join(dir, 'motion.mp4')
await main({
argv: [
`--in=${input}`,
'--paired',
'--motion',
'--headline=Ortopedia cresce 4,1 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
renderMotion,
})
expect(screenshot).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledOnce()
expect(renderMotion).toHaveBeenCalledWith(
expect.objectContaining({ outPath, width: 1080, height: 1350 }),
)
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/ortopedia-cresce-4-1-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({ motion: true, pairedColumns: true, chartType: 'delta' })
expect(stdout.join('')).toContain('MP4')
expect(stdout.join('')).toContain('motion 6s @ 30fps')
})
it('refuses --motion outside the paired variant without launching the browser', async () => {
const input = await writeInput(dir, 'Ilhéus 84\nItabuna 68\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${input}`, '--motion', '--headline=X', '--source=TSE'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/colunas emparelhadas/)
expect(launchBrowser).not.toHaveBeenCalled()
})
it('renders the two-period column as an MP4 with --motion (C239)', async () => {
const input = await writeInput(dir, 'Ano\tLeitos de UTI\n2005\t179\n2026\t1536\n')
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const renderMotion = vi.fn(async (_options: { outPath: string }) => ({
size: 4096,
source: 'empacotado',
}))
const outPath = join(dir, 'uti.mp4')
await main({
argv: [
`--in=${input}`,
'--motion',
'--headline=UTIs crescem quase 9 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
renderMotion,
})
expect(screenshot).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/utis-crescem-quase-9-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({ motion: true, chartType: 'column' })
expect(stdout.join('')).toContain('MP4')
})
it('assembles a vertical category comparison with the relation kicker and the unit', async () => {
const input = await writeInput(
dir,

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
CHART_TYPES,
MAX_PAIRED,
MAX_POINTS,
MAX_POINTS_LINE,
MAX_POINTS_STORY,
@ -631,6 +632,67 @@ describe('validateSpec — the variation bar (delta)', () => {
})
})
// C237 degraded variant: the same delta dataset drawn as two columns per
// category (initial gray, recent red). The category label under a pair caps it
// at four categories; the two-tone pair is fixed, so no highlight.
describe('validateSpec — paired columns (C237)', () => {
const pairedSpec = (overrides = {}) => ({
chartType: 'delta',
headline: 'Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero',
startLabel: '2006',
endLabel: '2026',
pairedColumns: true,
rows: [
{ label: 'Cirurgia geral', initial: 592, final: 1433 },
{ label: 'Ortopedia', initial: 182, final: 755 },
{ label: 'Oncologia', initial: 0, final: 134 },
],
...overrides,
})
it('accepts the two-measure table drawn as paired columns', () => {
expect(validateSpec(pairedSpec())).toBeTruthy()
})
it('requires the delta shape and type', () => {
expect(() =>
validateSpec(pairedSpec({ chartType: 'bar', rows: [{ label: 'Ortopedia', value: 755 }] })),
).toThrow(/exigem o tipo delta/)
})
it('refuses highlight (the two-tone pair is fixed)', () => {
expect(() => validateSpec(pairedSpec({ highlight: 'Ortopedia' }))).toThrow(/destaque/)
})
it('refuses --no-highlight and --dual-positive', () => {
expect(() => validateSpec(pairedSpec({ noHighlight: true }))).toThrow(/não combinam/)
expect(() => validateSpec(pairedSpec({ dualPositive: true }))).toThrow(/exige o tipo coluna/)
})
it(`caps the pairs at ${MAX_PAIRED} (the category label collides above that)`, () => {
const rows = Array.from({ length: MAX_PAIRED + 1 }, (_value, index) => ({
label: `Tipo ${index}`,
initial: 1,
final: 2,
}))
expect(validateSpec(pairedSpec({ rows: rows.slice(0, MAX_PAIRED) }))).toBeTruthy()
expect(() => validateSpec(pairedSpec({ rows }))).toThrow(/colidem/)
})
it('keeps the delta guardrails (zero base, no retraction)', () => {
expect(() =>
validateSpec(pairedSpec({ rows: [{ label: 'Ortopedia', initial: 755, final: 182 }] })),
).toThrow(/linha de duas séries/)
expect(() =>
validateSpec(pairedSpec({ rows: [{ label: 'Ortopedia', initial: -1, final: 2 }] })),
).toThrow(/negativo/)
})
it('rejects a non-boolean pairedColumns', () => {
expect(() => validateSpec(pairedSpec({ pairedColumns: 'sim' }))).toThrow(/booleano/)
})
})
describe('contracts', () => {
it('declares the v1 types plus the delta variant, labels and sizes', () => {
expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor', 'delta'])

View file

@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import {
COLUMN_MOTION,
columnMotion,
MOTION_CSS,
MOTION_DURATION_S,
MOTION_EASING,
MOTION_FPS,
MOTION_FRAMES,
MOTION_STAGES,
motionEnd,
motionFrameTimes,
motionKindFor,
motionStages,
PAIR_MOTION,
pairMotion,
validateMotion,
} from '../../scripts/lib/chartMotion.mjs'
// C238/C239: the motion clock is data — the renderer serializes the schedule
// into inline vars and the capture seeks the animations to the grid, so the
// timing is pinned here instead of living inside the stylesheet.
const pairedSpec = (pairCount = 4) => ({
chartType: 'delta',
pairedColumns: true,
startLabel: '2006',
endLabel: '2026',
rows: Array.from({ length: pairCount }, (_value, index) => ({
label: `Tipo ${index}`,
initial: 1,
final: 2,
})),
})
const columnSpec = (count = 2) => ({
chartType: 'column',
rows: Array.from({ length: count }, (_value, index) => ({
label: String(2005 + index),
value: 100 + index,
})),
})
const anchorSpec = () => ({
chartType: 'anchor',
rows: [{ label: 'leitos privados contratados para o atendimento pelo SUS', value: 1130 }],
})
describe('chartMotion — the deterministic clock', () => {
it('runs 6s at 30fps on an exact 180-frame grid', () => {
expect(MOTION_FPS).toBe(30)
expect(MOTION_DURATION_S).toBe(6)
expect(MOTION_FRAMES).toBe(180)
const times = motionFrameTimes()
expect(times).toHaveLength(MOTION_FRAMES)
expect(times[0]).toBe(0)
expect(times[times.length - 1]).toBeCloseTo(((MOTION_FRAMES - 1) * 1000) / MOTION_FPS, 6)
})
it('staggers the pairs left to right and lands the values after the bars', () => {
const first = pairMotion(0)
const second = pairMotion(1)
expect(first.bar.delay).toBe(PAIR_MOTION.firstAt)
expect(second.bar.delay - first.bar.delay).toBeCloseTo(PAIR_MOTION.stagger, 6)
expect(first.label.delay).toBeCloseTo(first.bar.delay + PAIR_MOTION.labelOffset, 6)
expect(first.value.delay).toBeCloseTo(first.bar.delay + PAIR_MOTION.valueOffset, 6)
expect(first.value.delay).toBeGreaterThan(first.bar.delay + first.bar.duration)
})
it('grows the two-period columns left to right, values after the bars', () => {
const first = columnMotion(0)
const second = columnMotion(1)
expect(first.bar.delay).toBe(COLUMN_MOTION.firstAt)
expect(second.bar.delay - first.bar.delay).toBeCloseTo(COLUMN_MOTION.stagger, 6)
expect(second.label.delay).toBeCloseTo(second.bar.delay + COLUMN_MOTION.labelOffset, 6)
expect(second.value.delay).toBeGreaterThan(second.bar.delay + second.bar.duration)
})
it('maps each motion shape to its variant and refuses the undesigned ones', () => {
expect(motionKindFor(pairedSpec())).toBe('paired')
expect(motionKindFor(columnSpec())).toBe('column')
expect(motionKindFor(anchorSpec())).toBe('anchor')
expect(motionKindFor({ chartType: 'bar', rows: [] })).toBeNull()
expect(motionKindFor({ chartType: 'line', rows: [] })).toBeNull()
})
it('settles every stage inside the duration, leaving a static tail to cut into', () => {
const { end, kind } = validateMotion(pairedSpec())
expect(kind).toBe('paired')
expect(end).toBeLessThanOrEqual(MOTION_DURATION_S)
expect(MOTION_DURATION_S - end).toBeGreaterThanOrEqual(2)
expect(validateMotion(columnSpec())).toMatchObject({ kind: 'column' })
expect(validateMotion(anchorSpec())).toMatchObject({ kind: 'anchor' })
})
it('lists the stages of each shape for the clock guard', () => {
// Shell (rule, kicker, headline, subtitle, footer) + the marks.
expect(motionStages(columnSpec())).toHaveLength(5 + 2 * 3)
expect(motionStages(pairedSpec())).toHaveLength(5 + 1 + 4 * 3) // + legend
expect(motionStages(anchorSpec())).toHaveLength(5 + 2) // + number + copy
expect(motionEnd(motionStages(anchorSpec()))).toBeGreaterThan(0)
})
it('fails closed when the shape is undesigned, the column is not a pair or the clock overflows', () => {
expect(() => validateMotion({ chartType: 'bar', rows: [{ label: 'x', value: 1 }] })).toThrow(
/indisponível/,
)
expect(() => validateMotion(columnSpec(3))).toThrow(/2 períodos/)
expect(() => validateMotion(pairedSpec(20))).toThrow(/reduza os pontos/)
})
it('gives the anchor the hero number and the copy, never a counting rule', () => {
expect(MOTION_STAGES.number.kind).toBe('rise')
expect(MOTION_STAGES.copy.kind).toBe('fade')
expect(MOTION_STAGES.copy.delay).toBeGreaterThan(MOTION_STAGES.number.delay)
})
it('carries only keyframes and easing; timings travel as inline vars', () => {
for (const kind of ['sweep', 'rise', 'fade', 'grow']) {
expect(MOTION_CSS).toContain(`@keyframes motion-${kind}`)
expect(MOTION_CSS).toContain(`.motion .motion-${kind}`)
}
expect(MOTION_CSS).toContain(MOTION_EASING)
expect(MOTION_CSS).toContain('var(--md, 0s)')
expect(MOTION_CSS).toContain('var(--mud, 0.5s)')
expect(MOTION_CSS).toContain('animation-fill-mode: backwards')
})
it('uses the same motion vocabulary in the shell and the pairs', () => {
const kinds = new Set([
...Object.values(MOTION_STAGES).map((stage) => stage.kind),
...Object.values(pairMotion(3)).map((stage) => stage.kind),
...Object.values(columnMotion(1)).map((stage) => stage.kind),
])
for (const kind of kinds) expect(['sweep', 'rise', 'fade', 'grow']).toContain(kind)
})
})

View file

@ -64,6 +64,25 @@ describe('skill /graficos-dados documents the flow', () => {
expect(skill).toContain('192×110')
expect(skill).toContain('279×160')
})
it('pins the paired columns of the C237 degraded revision', () => {
expect(skill).toContain('--paired')
expect(skill).toMatch(/colunas emparelhadas/i)
expect(skill).toMatch(/DEGRADED/)
expect(skill).toContain('4 categorias')
})
it('pins the C238/C239 motion of the paired columns, the column and the anchor', () => {
expect(skill).toContain('--motion')
expect(skill).toMatch(/MP4/)
expect(skill).toMatch(/6s @ 30fps/)
expect(skill).toMatch(/frames determin/)
expect(skill).toContain('C237')
expect(skill).toContain('C238')
expect(skill).toContain('C239')
expect(skill).toMatch(/2 períodos/)
expect(skill).toMatch(/número-âncora/)
})
})
describe('command /graficos-dados couples to the skill', () => {

View file

@ -339,6 +339,143 @@ describe('renderChartHtml — the variation bar (delta)', () => {
})
})
// C237 degraded variant: the same delta dataset drawn as paired columns — the
// initial in the neutral tone, the recent one in the official red (the positive
// datum is shared by every pair), with the period legend and the value over
// each bar. An initial of zero has no gray column at all.
const pairedRows = [
{ label: 'Cirurgia geral', initial: 592, final: 1433 },
{ label: 'Ortopedia', initial: 182, final: 755 },
{ label: 'Oncologia', initial: 0, final: 134 },
]
const pairedSpec = (overrides = {}) => ({
chartType: 'delta',
size: 'feed',
pairedColumns: true,
headline: 'Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero',
subtitle: 'Leitos por tipo na rede estadual · cirurgia: 966 → 2.750',
source: 'DATASUS / Ministério da Saúde',
startLabel: '2006',
endLabel: '2026',
rows: pairedRows,
...overrides,
})
describe('renderChartHtml — paired columns (C237)', () => {
it('renders the period legend, the pair labels and the recent red column', () => {
const html = render(pairedSpec())
expect(html).toContain('Variação no período')
expect(html).toContain('paired-swatch initial')
expect(html).toContain('paired-swatch final')
expect(html).toContain('>2006</span>')
expect(html).toContain('>2026</span>')
expect(html).toContain('paired-bar recent')
expect(html).toContain('class="paired-label">Cirurgia geral')
})
it('proportions both bars of a pair against the biggest final value', () => {
const html = render(pairedSpec())
expect(html).toContain('height:41.3%') // 592 / 1433
expect(html).toContain('height:52.7%') // 755 / 1433
expect(html).toContain('height:100%') // 1433
expect(html).toContain('height:9.4%') // 134 / 1433
})
it('leaves the zero initial without the gray column and keeps the value', () => {
const html = render(pairedSpec())
const labelAt = html.indexOf('paired-label">Oncologia')
const start = html.lastIndexOf('class="paired-pair"', labelAt)
const pair = html.slice(start, labelAt)
expect(pair).toContain('paired-value">0')
expect(pair).not.toContain('<span class="paired-bar" style')
expect(pair).toContain('paired-bar recent')
})
it('sorts the pairs by the final value', () => {
const html = render(pairedSpec())
const body = html.slice(html.indexOf('<div class="paired-grade'))
expect(body.indexOf('Cirurgia geral')).toBeLessThan(body.indexOf('Ortopedia'))
expect(body.indexOf('Ortopedia')).toBeLessThan(body.indexOf('Oncologia'))
})
it('adapts the rhythm and keeps every pair on the Stories canvas', () => {
const story = render(pairedSpec({ size: 'story' }))
expect(story).toContain('padding: 306px 84px 310px')
expect(story).toMatch(/\.headline \{ font-size: 60px/)
expect((story.match(/class="paired-pair"/g) ?? []).length).toBe(pairedRows.length)
})
})
// C238 prototype: under `spec.motion` the same paired piece carries the motion
// stylesheet and the schedule as inline `--md`/`--mud` vars; without it the
// markup stays byte-identical to the approved still.
describe('renderChartHtml — motion (C238)', () => {
it('injects the motion stylesheet and the animated classes only under spec.motion', () => {
const still = render(pairedSpec())
expect(still).not.toContain('motion-')
expect(still).not.toContain('--md:')
const html = render(pairedSpec({ motion: true }))
expect(html).toContain('class="canvas motion"')
expect(html).toContain('@keyframes motion-grow')
expect(html).toContain('.motion .motion-sweep')
expect(html).toContain('animation-fill-mode: backwards')
})
it('serializes the pair schedule into inline --md/--mud vars', () => {
const html = render(pairedSpec({ motion: true }))
expect(html).toContain('--md:1.1s;--mud:0.75s') // first pair bars
expect(html).toContain('--md:1.4s') // second pair
expect(html).toContain('--md:1.7s') // third pair
expect(html).toContain('--md:1.9s;--mud:0.3s') // first pair values, after the bar
expect(html).toContain('--md:1.25s;--mud:0.3s') // first pair label, with the growth
})
it('keeps the animated markup in the paired body of every size', () => {
const story = render(pairedSpec({ size: 'story', motion: true }))
expect((story.match(/class="paired-bar(?!-)/g) ?? []).length).toBe(5) // 3 pairs × 2 bars − the zero gray
expect((story.match(/ motion-grow"/g) ?? []).length).toBe(5)
expect(story).toContain('@keyframes motion-rise')
})
it('grows the two-period columns left to right and lands their values (C239)', () => {
const columnSpec = {
chartType: 'column',
size: 'feed',
motion: true,
headline: 'UTIs na rede estadual crescem quase 9 vezes desde 2005',
subtitle: 'Leitos de UTI nos hospitais estaduais · Bahia',
source: 'DATASUS / Ministério da Saúde',
rows: [
{ label: '2005', value: 179 },
{ label: '2026', value: 1536 },
],
}
const html = render(columnSpec)
expect(html).toContain('--md:1.1s;--mud:0.75s') // first column
expect(html).toContain('--md:1.45s;--mud:0.75s') // recent column, red
expect(html).toContain('--md:2.25s;--mud:0.3s') // recent value, after the bar
expect(html).toContain('column highlight motion-grow')
})
it('rises the anchor number and fades the copy, never counting (C239)', () => {
const anchorSpec = {
chartType: 'anchor',
size: 'feed',
motion: true,
headline: 'Bahia contrata mais de mil leitos privados para o SUS',
subtitle: 'Rede complementar do SUS · Bahia · 2026',
source: 'DATASUS / Ministério da Saúde',
rows: [{ label: 'leitos privados contratados para atendimento pelo SUS', value: 1130 }],
}
const html = render(anchorSpec)
expect(html).toContain('anchor-number motion-rise')
expect(html).toContain('--md:1.05s;--mud:0.6s')
expect(html).toContain('anchor-copy motion-fade')
expect(html).toContain('--md:1.7s;--mud:0.45s')
})
})
// C205 approved extension: the three-series observed line runs on the feed
// canvas only, with the triad good/neutral/neutral-dark (red circle "↑ amplia",
// gray diamond "↗ cresce", ink triangle "↘ diminui") and no projection.