From 39933fc004ae0a0f6faa800b60b2327ca21f0da0 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 29 Sep 2026 22:13:10 -0300 Subject: [PATCH] =?UTF-8?q?feat(C237-C239):=20colunas=20emparelhadas=20e?= =?UTF-8?q?=20motion=20determin=C3=ADstico=20no=20gerador=20de=20gr=C3=A1f?= =?UTF-8?q?icos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .agents/skills/graficos-dados/SKILL.md | 86 +- ...6-09-30-c237-c239-graficos-pares-motion.md | 1 + .../graficos-dados-instagram-ui-design.html | 1193 ++++++++++++++++- scripts/build-chart-from-data.mjs | 34 +- scripts/lib/chartData.mjs | 48 + scripts/lib/chartMotion.mjs | 172 +++ scripts/lib/chartMotionRender.mjs | 107 ++ scripts/lib/graficosInstagramRender.mjs | 144 +- scripts/lib/test-affected-core.mjs | 2 + tests/unit/buildChartFromData.unit.spec.ts | 132 ++ tests/unit/chartData.unit.spec.ts | 62 + tests/unit/chartMotion.unit.spec.ts | 138 ++ tests/unit/graficosDadosSkill.unit.spec.ts | 19 + .../unit/graficosInstagramRender.unit.spec.ts | 137 ++ 14 files changed, 2239 insertions(+), 36 deletions(-) create mode 100644 docs/changelog/2026-09-30-c237-c239-graficos-pares-motion.md create mode 100644 scripts/lib/chartMotion.mjs create mode 100644 scripts/lib/chartMotionRender.mjs create mode 100644 tests/unit/chartMotion.unit.spec.ts diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md index 75809798..37e68a77 100644 --- a/.agents/skills/graficos-dados/SKILL.md +++ b/.agents/skills/graficos-dados/SKILL.md @@ -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= --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= --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= --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/-frames-/` 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=`. @@ -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: diff --git a/docs/changelog/2026-09-30-c237-c239-graficos-pares-motion.md b/docs/changelog/2026-09-30-c237-c239-graficos-pares-motion.md new file mode 100644 index 00000000..05ebfb92 --- /dev/null +++ b/docs/changelog/2026-09-30-c237-c239-graficos-pares-motion.md @@ -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. diff --git a/docs/plans/graficos-dados-instagram-ui-design.html b/docs/plans/graficos-dados-instagram-ui-design.html index 8da406c3..a5cb2169 100644 --- a/docs/plans/graficos-dados-instagram-ui-design.html +++ b/docs/plans/graficos-dados-instagram-ui-design.html @@ -726,6 +726,246 @@ .story-canvas.delta .story-title { font-size: 64px; } + + /* ------------------------------------------------------------------ */ + /* CENA 02F · C237 · DEGRADED — colunas emparelhadas por categoria: */ + /* duas barras por categoria (inicial em cinza, recente em vermelho). */ + /* Escrita inline no tier degradado (quota do frontier esgotada); */ + /* exige sign-off humano — não certificada. */ + /* ------------------------------------------------------------------ */ + .paired-grade { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-top: 26px; + } + .paired-legend { + flex: none; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 26px; + margin-bottom: 16px; + color: #57534e; + 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: var(--bar); + } + .paired-swatch.final { + background: var(--solla); + } + .paired-columns { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: end; + gap: 30px; + padding: 0 5px 46px; + border-bottom: 3px solid var(--bar-strong); + } + .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: var(--ink); + text-align: center; + font-size: 31px; + line-height: 1; + font-weight: 800; + font-variant-numeric: tabular-nums; + } + .paired-bar { + background: var(--bar); + } + .paired-bar.recent { + background: var(--solla); + } + .paired-label { + position: absolute; + bottom: -40px; + left: 50%; + transform: translateX(-50%); + color: #57534e; + text-align: center; + white-space: nowrap; + font-size: 30px; + line-height: 1; + font-weight: 650; + } + .feed-canvas.paired .chart-headline { + max-width: 900px; + font-size: 60px; + } + .feed-canvas.paired .chart-subtitle { + margin-top: 18px; + font-size: 28px; + } + .story-canvas.paired .story-title { + font-size: 60px; + } + .story-canvas.paired .paired-grade { + margin-top: 40px; + } + + /* ------------------------------------------------------------------ */ + /* CENA 02G · C238 · DEGRADED — motion das colunas emparelhadas. */ + /* Mesmas classes/var do renderer; a demo do gate roda em loop (o */ + /* export roda uma vez e segura o estado final). */ + /* ------------------------------------------------------------------ */ + @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: cubic-bezier(0.22, 1, 0.36, 1); + 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; + } + + /* ------------------------------------------------------------------ */ + /* CENA 02H · C239 — demos do motion da coluna de 2 períodos e da */ + /* âncora (feed). Escopos só do gate; o port usa as classes do renderer.*/ + /* ------------------------------------------------------------------ */ + .motion-column-demo .columns { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 320px)); + justify-content: center; + align-items: end; + gap: 26px; + padding: 0 0 46px; + border-bottom: 3px solid var(--bar-strong); + } + .motion-column-demo .column-cell { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 100%; + } + .motion-column-demo .column-value { + margin-bottom: 10px; + text-align: center; + font-size: 31px; + line-height: 1; + font-weight: 800; + font-variant-numeric: tabular-nums; + } + .motion-column-demo .column { + background: var(--bar); + } + .motion-column-demo .column.recent { + background: var(--solla); + } + .motion-column-demo .column-label { + position: absolute; + bottom: -40px; + left: 0; + width: 100%; + color: #57534e; + text-align: center; + white-space: nowrap; + font-size: 30px; + line-height: 1; + font-weight: 650; + } + .motion-anchor-demo .anchor { + padding-top: 20px; + } + .motion-anchor-demo .anchor-number { + color: var(--solla); + font-size: 300px; + line-height: 0.86; + letter-spacing: -0.065em; + font-weight: 900; + font-variant-numeric: tabular-nums; + } + .motion-anchor-demo .anchor-copy { + max-width: 760px; + margin: 40px 0 0; + font-size: 52px; + line-height: 1.12; + font-weight: 750; + } .degraded-banner { display: flex; flex-wrap: wrap; @@ -1027,25 +1267,33 @@
BASE C203 APROVADA · C205/C206/C207 APROVADASBASE C203 APROVADA · C205/C206/C207 APROVADAS · C237 SIGN-OFF · C238 SIGN-OFF · C239 + MOTION DEGRADED

Base C203 aprovada em 2026-09-19 · revisões C205, C206 e C207 aprovadas no gate em - 2026-09-19. + 2026-09-19 · C237 aprovada por sign-off humano em 2026-09-29 · C238 (motion do par + emparelhado) aprovada por sign-off humano em 2026-09-30 · C239 (extensão do motion) + degradada, aguardando gate humano.

O aceite C203 permanece intacto. A C205 revisa no lugar a CENA 02D — linha temporal de três séries — e supera a C204, que nunca foi certificada. A C206 revisa somente o rodapé compartilhado para ampliar a marca oficial. A C207 estende a C205 com o último - período projetado (CENA 02E). + período projetado (CENA 02E). A C237 (CENA 02F) acrescenta as colunas emparelhadas por + categoria no tier degradado — aprovada por sign-off humano em 2026-09-29. A C238 (CENA + 02G) acrescenta o motion do par emparelhado — aprovado por sign-off humano em + 2026-09-30 — e a C239 (CENA 02H) o estende à coluna de 2 períodos e ao número-âncora, + aguardando gate. As três são a fonte de verdade do port desde o gate humano de 2026-09-19.As revisões certificadas seguem sendo a fonte de verdade do port desde o gate + humano de 2026-09-19.

@@ -1057,6 +1305,15 @@ C207 · aprovada no gate de 2026-09-19 + C237 · DEGRADED · sign-off humano 2026-09-29 + C238 · DEGRADED · sign-off humano 2026-09-30 + C239 · motion DEGRADED · aguardando gate Base C203 aprovada · C205/C206 aprovadas Instagram feed + Stories
@@ -3701,6 +3958,910 @@
+ + + + + + +
+
+ DEGRADED · SIGN-OFF HUMANO 2026-09-29 + Proposta criada no tier degradado (o frontier + gpt-5.6-sol + bateu no limite de uso e o fallback foi interrompido) e + aprovada por sign-off humano em 2026-09-29 — visual como está. Portada + classe-a-classe no renderer e nos testes na mesma data; a certificação pelo designer + frontier segue pendente. +
+ +
+
+

+ CENA 02F · C237 · OITAVA VARIAÇÃO · SIGN-OFF HUMANO 2026-09-29 +

+

+ Feed 4:5 · colunas emparelhadas por categoria (2006 → 2026) +

+
+
+ 1080 × 1350 + chartType: delta + pairedColumns +
+
+ +
+
+
+
+
+
+

Variação no período

+

+ Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero +

+

+ Leitos por tipo na rede estadual · cirurgia: 966 → 2.750 +

+ + + +
+

+ Fonte: DATASUS / Ministério da Saúde
Nota: oncologia: + não havia leitos na rede estadual em 2006. +

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

+ ADAPTAÇÃO STORIES · MESMA REGRA, PAR E RÓTULO INTEIROS +

+

+ Stories 9:16 · as quatro categorias entre as faixas seguras +

+

+ O story mantém os 4 pares (8 barras) dentro de y=250…1670, com o mesmo par de tons e a + legenda de períodos. A headline longa recompõe em 60 px; nenhum par é cortado. +

+ +
+
+ 250 px · evitar conteúdo essencial + 250 px · evitar conteúdo essencial +
+
+

Variação no período

+

+ Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero +

+

Leitos por tipo na rede estadual · cirurgia: 966 → 2.750

+ +
+
+ Fonte: DATASUS / Ministério da Saúde · Nota: oncologia: não + havia leitos na rede estadual em 2006. +
+
+ Jorge Solla — Deputado Federal +
+
+
+
+
+
+ +
+

+ REGRAS DA VARIANTE DE COLUNAS EMPARELHADAS (DEGRADED) +

+

+ Contrato visual proposto para o porte classe-a-classe — pendente de sign-off +

+
+
+

Entrada, teto e ordem

+

+ Mesmo dado da barra de variação: N categorias com + initial e final, + 0 ≤ initial ≤ final, com + startLabel/endLabel. + Máximo 4 categorias: com 4 pares o rótulo de categoria (30 px, + nowrap) ainda cabe no par + gap; acima disso o builder pergunta em + vez de colidir rótulos. Ordem: final desc (a mesma do delta). +

+
+
+

Tons e regra do vermelho

+

+ Par fixo por categoria: inicial = #d6d3d1, recente = + #e4102f (o dado positivo compartilhado por todos os + pares). Sem highlight, sem setas, sem “amplia/recua”, + sem hierarquia de vencedor. O vermelho marca só a barra recente de cada par; o azul + #184e92 não entra no dado. +

+
+
+

Períodos, valores e zero

+

+ Legenda de períodos com swatches (inicial cinza / recente vermelho) acima do plot; + valor sobre cada barra (tabular, pt-BR); rótulo da categoria sob o par. + initial = 0 → sem barra cinza (o “0” fica na base). + Sem min-height em barra zero; base zero real. +

+
+
+

Feed · 1080×1350

+

+ Padding 76/84/58; headline 60 px; subtítulo 28 px; plot margin-top + 26. Legenda 28 px (swatch 18 px, gap 26); colunas: 4 pares, gap 30, padding-bottom + 46; barra máx 86 px, gap interno 12; valores 31 px; rótulo 30 px (bottom −40). +

+
+
+

Quadrado · 1080×1080

+

+ Padding 56/84/48; headline 54 px; subtítulo 28 px; plot margin-top 20. Legenda 26 + px; valores 30 px; rótulo 30 px (bottom −36); mesmas 4 categorias, sem corte. +

+
+
+

Story · 1080×1920

+

+ Faixas de 250 px; conteúdo padding 306/84/310; headline 60 px; plot margin-top 40. + Legenda 28 px; valores 31 px; rótulo 30 px; 4 pares inteiros entre y=250 e y=1670. +

+
+
+

Fail-closed (proposto)

+

+ final < initial (retração) → recusar: barra de + base zero não representa queda; sugerir a linha de 2 séries. + initial ausente → perguntar. + final ≤ 0 → recusar. Mais de 4 categorias → + perguntar/agrupar. highlight → recusar (o par de tons + é fixo). Sem source → recusar. Nada de interpolar, + completar ou abreviar sozinho. +

+
+
+

Mapa de classes proposto

+

+ Grade: paired-grade; legenda + paired-legend + + paired-swatch initial / + paired-swatch final; colunas + paired-columns (grid de N pares); par + paired-pair; célula de barra + paired-bar-cell; valor + paired-value; barra + paired-bar (+ + .recent para a vermelha; ausente quando + initial = 0); rótulo + paired-label. Reaproveitados: + top-rule, + chart-context, + chart-headline, + chart-subtitle, + chart-footer, + source-line, + brand-logo-frame e o PNG oficial inteiro. +

+
+
+
+
+ + + + + + +
+
+ DEGRADED · SIGN-OFF HUMANO 2026-09-30 + Motion do par emparelhado escrito no tier degradado (frontier sem quota) e + aprovado por sign-off humano em 2026-09-30 — visual como está. A demo + ao lado roda em loop para o gate; o export roda uma vez e segura o estado final — + exatamente o PNG aprovado. +
+ +
+
+

+ CENA 02G · C238 · MOTION DO PAR EMPARELHADO · SIGN-OFF HUMANO 2026-09-30 +

+

+ Feed 4:5 · 6s @ 30fps, sem 3D e sem loop no export +

+
+
+ 1080 × 1350 · 180 frames + H.264 · yuv420p · faststart +
+
+ +
+
+
+
+
+
+

+ Variação no período +

+

+ Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero +

+

+ Leitos por tipo na rede estadual · cirurgia: 966 → 2.750 +

+ + + +
+

+ Fonte: DATASUS / Ministério da Saúde
Nota: oncologia: + não havia leitos na rede estadual em 2006. +

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

REGRAS DO MOTION (PROPOSTA DEGRADED)

+

+ Contrato proposto para o porte — pendente de sign-off +

+
+
+

Clock determinístico

+

+ 6s @ 30fps = 180 frames, grade fixa (0…5,967s). A captura dá seek + em cada animação CSS para o timestamp do frame e tela; nunca grava em tempo real. O + schedule é dado (chartMotion.mjs), não CSS: cada + elemento recebe --md (delay) e + --mud (duração). +

+
+
+

Estágios (segundos)

+

+ régua 0–0,45 (sweep) · kicker 0,2–0,65 · headline 0,35–0,9 · subtítulo 0,65–1,1 · + legenda 0,8–1,2 · pares k=0..3: barra em 1,1+0,3k por 0,75, rótulo +0,15, valor +0,8 + por 0,3 · rodapé 3,1–3,6 · hold estático 3,6–6,0. +

+
+
+

Vocabulário

+

+ Quatro gestos e só: sweep (scaleX da régua), + rise (opacidade + 14px), + fade e grow (scaleY da + barra, origem na base). Nenhum outro efeito entra — sem exceção por tipo. +

+
+
+

Export local

+

+ H.264 (libx264, preset medium, CRF 18), yuv420p, faststart, 30fps exatos. ffmpeg do + repo (FFMPEG_PATH → PATH → empacotado) — nenhum serviço remoto. Frames em + data/graficos-instagram/<slug>-frames-<size>/ + são temporários e apagados. +

+
+
+

Tamanhos

+

+ Feed 1080×1350, quadrado 1080×1080 e story 1080×1920 (faixas de 250px) com o mesmo + clock; é o protótipo certificado só no par emparelhado. +

+
+
+

Fail-closed

+

+ --motion só na variante pareada (as outras recusam + com mensagem acionável); schedule que não assenta dentro dos 6s é recusado; sem + fonte, sem peça. +

+
+
+

Mapa de classes do port

+

+ motion no canvas; classes + motion-sweep / + motion-rise / + motion-fade / + motion-grow; vars + --md/--mud inline por + elemento; keyframes motion-sweep|rise|fade|grow num + stylesheet próprio (só entra no modo motion). O corpo pareado reusa + paired-* intacto. +

+
+
+
+
+ + + + + + +
+
+ DEGRADED · INLINE (TIER 4) · AGUARDANDO GATE HUMANO + Extensão C239 do motion (mesmo clock e vocabulário da C238): a coluna de 2 períodos + cresce da esquerda para a direita — a recente vermelha por último — e o número-âncora + sobe seguido da frase, sem contagem dramática. Demos em loop; o export + roda uma vez e segura o estado final. +
+ +
+
+

+ CENA 02H · C239 · MOTION DA COLUNA E DA ÂNCORA · DEGRADED · PENDENTE DE GATE +

+

+ Feed 4:5 · mesmo clock de 6s @ 30fps, sem contagem e sem efeito de vitória +

+
+
+ 1080 × 1350 · 180 frames + colunas 1,1s + 0,35s · âncora 1,05s +
+
+ +
+
+
+
+
+
+

+ Poucos períodos +

+

+ UTIs na rede estadual crescem quase 9 vezes desde 2005 +

+

+ Leitos de UTI nos hospitais estaduais · Bahia +

+
+
+ 179 +
+ 2005 +
+
+ 1.536 +
+ 2026 +
+
+
+

Fonte: DATASUS / Ministério da Saúde

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

+ Número-âncora +

+

+ Bahia contrata mais de mil leitos privados para o SUS +

+

+ Rede complementar do SUS · Bahia · 2026 +

+
+
+ 1.130 +
+

+ leitos privados contratados para atendimento pelo SUS +

+
+
+

Fonte: DATASUS / Ministério da Saúde

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

REGRAS DA EXTENSÃO (PROPOSTA DEGRADED)

+

+ Contrato proposto para o porte — pendente de sign-off +

+
+
+

Nada novo no vocabulário

+

+ Mesmos 4 gestos (sweep, + rise, fade, + grow), mesmo easing, mesmo + fill-mode: backwards e mesmos estágios de casca. A + extensão só acrescenta o alvo dos gestos. +

+
+
+

Schedule da extensão

+

+ Coluna k: barra em 1,1+0,35k (0,75s), rótulo +0,15, valor +0,8 (0,3s) · âncora: + número 1,05–1,65 (rise de 0,6s), frase 1,7–2,15 (fade 0,45s) · rodapé 3,1–3,6 · hold + até 6,0. +

+
+
+

Sem contagem, sem ênfase

+

+ A âncora não conta de 0 a N, não pulsa e não ganha glow; a coluna recente não tem + pulso de destaque — o vermelho é o dado, não um prêmio animado. +

+
+
+

Fail-closed

+

+ Motion só na pareada, na coluna de 2 períodos e na âncora; as + demais variantes recusam com mensagem acionável, a coluna com ≠2 períodos recusa e o + clock que não assenta em 6s recusa. +

+
+
+

Tamanhos

+

+ Feed, quadrado e story com o mesmo schedule; a demo do gate usa o feed. +

+
+
+

Mapa de classes do port

+

+ Coluna: column-value, + column (recente com + highlight) e + column-label ganham + motion-rise / + motion-grow / + motion-fade + vars. Âncora: + anchor-number (rise) e + anchor-copy (fade). Os escopos + motion-column-demo/motion-anchor-demo + existem só no gate. +

+
+
+
+
+
@@ -3984,7 +5145,10 @@ base C203 aprovada · extensão C191 certificada pelo designer e aprovada pelo humano · C205 (supera a C204) + - C206 + C207 aprovadas no gate de 2026-09-19aprovadas no gate de 2026-09-19 · C237 (colunas emparelhadas) + DEGRADED · sign-off humano 2026-09-29 · C238 (motion do par) + DEGRADED · sign-off humano 2026-09-30 · C239 (motion estendido) + DEGRADED aguardando gate Kit 1313 oficial · valência redundante · projeção explícita · marca ampliada · NEEDS @@ -3992,5 +5156,22 @@ > + diff --git a/scripts/build-chart-from-data.mjs b/scripts/build-chart-from-data.mjs index fc897db6..0792b42a 100644 --- a/scripts/build-chart-from-data.mjs +++ b/scripts/build-chart-from-data.mjs @@ -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 }>, * 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= com os dados (ou --spec= 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, diff --git a/scripts/lib/chartData.mjs b/scripts/lib/chartData.mjs index cab4003f..4148f7bd 100644 --- a/scripts/lib/chartData.mjs +++ b/scripts/lib/chartData.mjs @@ -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) { diff --git a/scripts/lib/chartMotion.mjs b/scripts/lib/chartMotion.mjs new file mode 100644 index 00000000..1693609f --- /dev/null +++ b/scripts/lib/chartMotion.mjs @@ -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; }` diff --git a/scripts/lib/chartMotionRender.mjs b/scripts/lib/chartMotionRender.mjs new file mode 100644 index 00000000..e6efbd28 --- /dev/null +++ b/scripts/lib/chartMotionRender.mjs @@ -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 } +} diff --git a/scripts/lib/graficosInstagramRender.mjs b/scripts/lib/graficosInstagramRender.mjs index 5baee201..f852f8ea 100644 --- a/scripts/lib/graficosInstagramRender.mjs +++ b/scripts/lib/graficosInstagramRender.mjs @@ -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 `
- ${formatValue(row.value, spec.unit)} -
- ${htmlEscape(row.label)} + ${formatValue(row.value, spec.unit)} +
+ ${htmlEscape(row.label)}
` }) .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) => {
` } +/** + * 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 `` +} + 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 `
-
${formatValue(row.value, spec.unit)}
- ${copy ? `

${htmlEscape(copy)}

` : ''} +
${formatValue(row.value, spec.unit)}
+ ${copy ? `

${htmlEscape(copy)}

` : ''}
` } @@ -215,7 +284,7 @@ const tripleBody = (spec) => })}
` 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) => `
Jorge Solla — Deputado Federal
` -const footer = (spec, brandMarkDataUri) => `