mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
10327a7451
commit
39933fc004
14 changed files with 2239 additions and 36 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
172
scripts/lib/chartMotion.mjs
Normal 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; }`
|
||||
107
scripts/lib/chartMotionRender.mjs
Normal file
107
scripts/lib/chartMotionRender.mjs
Normal 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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
138
tests/unit/chartMotion.unit.spec.ts
Normal file
138
tests/unit/chartMotion.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue