mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C191): gráficos de dados para Instagram — skill + builder local (Closes #1145)
This commit is contained in:
parent
b5666d6647
commit
e91dea637f
19 changed files with 1868 additions and 112 deletions
122
.agents/skills/graficos-dados/SKILL.md
Normal file
122
.agents/skills/graficos-dados/SKILL.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
name: graficos-dados
|
||||
description: 'Transforma números que a comunicação recebeu (xlsx/csv/md/txt/texto colado) num gráfico PNG pronto para o Instagram — 1080×1350 feed, 1080×1080 quadrado ou 1080×1920 stories — com a paleta Jorge Solla, título-manchete, um único destaque de cor e guardrails de correção; dado ambíguo vira pergunta, nunca invenção.'
|
||||
---
|
||||
|
||||
# Gráficos de dados para Instagram (C191)
|
||||
|
||||
Entrega **um gráfico PNG** por invocação, no tamanho do Instagram e com a cara
|
||||
do mandato, a partir do dado **como ele veio** — planilha, CSV, markdown ou
|
||||
texto solto colado no comando. A skill lê, entende a **relação** dos dados
|
||||
(comparação, tempo, parte-de-um-todo, número único), **propõe o tipo** correto,
|
||||
gera a peça com **título-manchete** e **um destaque vermelho**, e deixa a
|
||||
comunicação transformar o gráfico no material final (legenda, carrossel,
|
||||
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`; o renderer
|
||||
(`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe.
|
||||
**Nunca invente estrutura visual** — o dono é o `designer`.
|
||||
|
||||
## Quando usar
|
||||
|
||||
- A comunicação pede "um gráfico disso", "transforma essa planilha em post",
|
||||
"faz um gráfico para o Instagram" — e fornece os números (arquivo ou colado).
|
||||
- **Não** use para: limpar/interpretar estatisticamente os dados, escrever a
|
||||
legenda/carrossel, publicar/agendar, gerar dossiê/relatório em PDF (irmãos
|
||||
C163/C186/C187) ou montar dashboard interativo. A skill **representa** o dado;
|
||||
não o interpreta.
|
||||
|
||||
## Fluxo
|
||||
|
||||
1. **Receba o dado.** Texto colado → grave em
|
||||
`data/graficos-instagram/<slug>-input.txt` (o diretório é gitignored) e aponte
|
||||
com `--in=`. Arquivo → aponte o caminho direto (`xlsx`, `xls`, `csv`, `tsv`,
|
||||
`md`, `txt`).
|
||||
2. **Inspecione antes de desenhar:**
|
||||
```bash
|
||||
node scripts/build-chart-from-data.mjs --in=<arquivo> --inspect
|
||||
```
|
||||
Devolve `{ rows, format, issues }` — a matriz parseada e os problemas.
|
||||
3. **Decida o tipo e a copy.** A relação dos dados manda:
|
||||
- **ranking / comparação → `bar`** (barras horizontais, ordenadas pelo valor,
|
||||
rótulo direto e valor na ponta);
|
||||
- **poucos períodos (≤4) → `column`** (base zero comum);
|
||||
- **série de tempo contínua → `line`** (ponto final destacado);
|
||||
- **uma única medida → `anchor`** (número grande + frase que explica).
|
||||
Ambíguo (mais de um tipo plausível) → **pergunte**; não escolha no escuro.
|
||||
Escreva a **manchete = takeaway** (não "Gráfico de X"), subtítulo opcional e a
|
||||
**fonte** (viaja dentro da imagem).
|
||||
4. **Gere o PNG:**
|
||||
```bash
|
||||
node scripts/build-chart-from-data.mjs --in=<arquivo> \
|
||||
--headline="Um território concentra o maior resultado do período" \
|
||||
--subtitle="Valores de 2022" \
|
||||
--source="TSE 2022" --type=bar --size=feed \
|
||||
--highlight="Ilhéus"
|
||||
```
|
||||
Saída default: `docs/research/graficos-instagram/<slug>-<data>-<size>.png`
|
||||
(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.
|
||||
5. **Confira e ajuste.** A pessoa confere o PNG; ajuste tipo/rótulo/manchete/
|
||||
tamanho/`--highlight` e regenere. **Replay** sem reparse: `--spec=<chart-spec.json>`.
|
||||
6. **Entregue o gráfico.** A comunicação finaliza a peça. O dado não persiste.
|
||||
|
||||
## Contrato do `chart-spec.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"chartType": "bar",
|
||||
"size": "feed",
|
||||
"headline": "Um território concentra o maior resultado do período",
|
||||
"subtitle": "Valores de 2022",
|
||||
"source": "TSE 2022",
|
||||
"note": "valores arredondados",
|
||||
"highlight": "Ilhéus",
|
||||
"rows": [{ "label": "Ilhéus", "value": 84 }]
|
||||
}
|
||||
```
|
||||
|
||||
`chartType` ∈ `bar | column | line | anchor`; `highlight` é o rótulo que recebe o
|
||||
único vermelho `#c51414` (default: o maior valor).
|
||||
|
||||
## Guardrails (fail-closed)
|
||||
|
||||
O builder **recusa** em vez de desenhar algo enganoso:
|
||||
|
||||
- **Barras e colunas partem do zero**; valor negativo é recusado; sem 3D/eixo
|
||||
truncado.
|
||||
- **Até 7 pontos** por gráfico; em **stories**, o ranking comporta **até 5**
|
||||
pontos (a adaptação vertical reduz um ponto, conforme o design aprovado).
|
||||
Acima disso, resuma as categorias e explique.
|
||||
- **Número-âncora:** a manchete é o takeaway (não repete o número) e o valor
|
||||
aparece uma vez, grande, seguido da frase que explica.
|
||||
- **Pizza não é gerada no v1** — pedido `pie` é recusado com sugestão de barras.
|
||||
- **Dado faltando/ambíguo → pergunta, nunca completa.** O builder sai com
|
||||
`{ needsQuestion: true, issues: [...] }` e não gera imagem.
|
||||
- **Sem fonte, sem peça:** `--source` é obrigatório.
|
||||
- Legibilidade: headline ≥48px e rótulos ≥30px no canvas 1080; contraste ≥4.5:1
|
||||
no texto e ≥3:1 nas marcas; **cor nunca é a única pista** (o destaque tem
|
||||
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).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"dado ambíguo ou faltando"** → leia `issues` no JSON de saída: linha sem
|
||||
rótulo, valor não numérico, rótulo repetido. Pergunte à pessoa; não invente.
|
||||
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa.
|
||||
- **"pizza não é gerada"** → ofereça barras horizontais.
|
||||
- **Número pt-BR:** `1.234,56` é lido como 1234,56; `1,5` como 1,5.
|
||||
- **PNG grande:** a guarda de 8MB falha fechado; simplifique o gráfico.
|
||||
|
||||
## Referências
|
||||
|
||||
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html`.
|
||||
- Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl:
|
||||
`docs/plans/graficos-dados-instagram-impl.md`.
|
||||
- Irmãos (mesmo padrão skill + command + builder local): C186/C187.
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -95,6 +95,10 @@ next-env.d.ts
|
|||
# C190 — dossiê Solla por tema/área + boletim modelo: same rule (C186).
|
||||
/data/dossie-solla-tema/
|
||||
/docs/research/dossie-solla-tema/
|
||||
# C191 — Instagram data charts: raw input + chart-spec intermediates + final
|
||||
# PNG/SVG carry campaign-internal data; the public repo never commits them.
|
||||
/data/graficos-instagram/
|
||||
/docs/research/graficos-instagram/
|
||||
|
||||
# Local-only sensitive material (agents may read; never commit content)
|
||||
/private/**
|
||||
|
|
|
|||
9
.opencode/commands/graficos-dados.md
Normal file
9
.opencode/commands/graficos-dados.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description: Gera um gráfico PNG pronto para o Instagram (1080×1350 feed, 1080×1080 quadrado ou 1080×1920 stories) a partir de dados em xlsx/csv/md/txt/texto colado, na paleta Jorge Solla, com título-manchete e um único destaque de cor
|
||||
---
|
||||
|
||||
Carregue a skill `graficos-dados` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: leia os `$ARGUMENTS` (arquivo apontado ou números colados no comando), inspecione a matriz com `--inspect`, classifique a relação dos dados (comparação, tempo, número único), proponha o tipo e a manchete/fonte, gere o PNG no tamanho pedido com o builder local (`node scripts/build-chart-from-data.mjs`) e confira com a pessoa. A fonte canônica é `.agents/skills/graficos-dados/SKILL.md` — não a transcreva nem recrie o fluxo.
|
||||
|
||||
Uso: `/graficos-dados <arquivo-ou-dados>` — ex.: `/graficos-dados data/graficos-instagram/votos.csv` ou `/graficos-dados Ilhéus 84, Itabuna 68, Porto Seguro 53`. Dado ambíguo/faltando → pergunte; nunca invente.
|
||||
|
||||
$ARGUMENTS
|
||||
1
docs/changelog/2026-09-18-c191-graficos-instagram.md
Normal file
1
docs/changelog/2026-09-18-c191-graficos-instagram.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C191 — Gráficos de dados para Instagram (skill de comunicação) (2026-09-18):** a comunicação agora transforma números como eles vieram (`xlsx`/`csv`/`md`/`txt`/texto colado) num **gráfico PNG pronto para o Instagram**, na paleta Jorge Solla e no formato do canal. (1) **Superfície:** skill `.agents/skills/graficos-dados/SKILL.md` + command `/graficos-dados` (sem subagente — a etapa semântica cabe no agente principal) e o entry `scripts/build-chart-from-data.mjs` (`--in`/`--inspect`/`--spec` replay/`--headline`/`--source`/`--type`/`--size`/`--highlight`). (2) **Núcleo puro** `scripts/lib/chartData.mjs`: parse dos formatos com `xlsx`/`csv-parse` (já devDeps, zero dependência nova), classificação da relação dos dados (ranking→`bar`, poucos períodos→`column`, série→`line`, medida única→`anchor`) e guardrails **fail-closed** (base zero, ≤7 pontos — 5 em stories, pizza recusada, `--source` obrigatório, nada de valor negativo); dado faltando/ambíguo vira pergunta, nunca invenção. (3) **Dono do desenho** `scripts/lib/chartPrimitives.mjs`: os primitivos SVG do dossiê (`barChart`/`valueList`/`columnChart`/`stackedColumnChart` + `CHART_COLORS`, extraídos byte-idênticos de `dossieRender.mjs`, golden pin) mais o `lineChart` e a escala `proportionalPercent`; **sem segundo renderer** ("edite o dono"). (4) **Renderer** `scripts/lib/graficosInstagramRender.mjs` porta classe-a-classe o hi-fi aprovado (`docs/plans/graficos-dados-instagram-ui-design.html`) em 3 canvases — `1080×1350` feed (default), `1080×1080` quadrado e `1080×1920` stories com faixas seguras de 250px derivadas de `SIZES`; título-manchete, um único destaque `#c51414`, fonte e lockup provisório dentro da imagem. (5) **Chromium:** `scripts/lib/buildPdf.mjs` ganhou `screenshotHtmlPng` (aditivo, viewport exato + clip, `document.fonts.ready`, guarda fail-closed de 8 MB) sem tocar no caminho A4. (6) **Privacidade/determinismo:** render 100% local; entrada, `chart-spec.json` e PNG gitignored (`data/graficos-instagram/`, `docs/research/graficos-instagram/`); sem schema/migration/DB. Crítica de fechamento do `designer` certificada (`Design tier: openai/gpt-5.6-sol`), sem bloqueantes; ajustes absorvidos (âncora sem repetir o número na manchete; stories com ≤5 pontos). Testes: `chartData`/`chartPrimitives`(golden)/`graficosInstagramRender`/prosa da skill + pinos de CI (`SCRIPTS_SPEC_PINNED`). `pnpm gate:fast` verde; prettier/typecheck/lint/knip limpos. Débito de runtime (entry/PNG real e parse ambíguo pt-BR) registrado em `C191-runtime` (#1151).
|
||||
154
docs/plans/graficos-dados-instagram-impl.md
Normal file
154
docs/plans/graficos-dados-instagram-impl.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Impl: Gráficos de dados para Instagram (skill de comunicação)
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-18
|
||||
Issue: #1145
|
||||
Intenção: docs/plans/graficos-dados-instagram.md
|
||||
Appetite restante: herdado (~2–3 dias eng) — sem corte
|
||||
Design tier: openai/gpt-5.6-sol (crítica de fechamento certificada; sem bloqueantes)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** numa invocação, transformar dados colados/em arquivo (`xlsx/csv/md/txt/texto`) num **gráfico PNG** legível, na paleta Solla, com título-manchete e um destaque de cor, no tamanho do Instagram (`1080×1350` default, `1080×1080`, `1080×1920`). O tipo segue a relação dos dados (barra, coluna, linha, número-âncora); dado ambíguo/faltando → pergunta, nunca completa; render 100% local; artefato gitignored.
|
||||
- **O que NÃO negociar:** bars/colunas partem do zero; ≤ ~7 pontos; sem pizza grande, 3D ou eixo truncado; rótulos ≥30px e headline ≥48px; contraste ≥4.5:1 (texto) / ≥3:1 (marcas); cor nunca é a única pista; sem persistir dado/schema/migration; **sem segundo pipeline irmão do renderer do dossiê**; dado de campanha nunca sai da máquina; PNG/intermediários nunca commitados (repo público).
|
||||
- **O que reavaliar (hipóteses do "Direção no codebase"):** (a) que os primitivos de `dossieRender.mjs` são reaproveitáveis como estão — são `const` privados, A4/mm/pt e com `CHART_COLORS` de domínio dossiê; o reuso exige **extração parametrizada**, não `import` direto. (b) que `buildPdf.mjs` serve a saída imagem — ele só tem caminho `page.pdf()`; falta um caminho `page.screenshot()`. (c) que `xlsx`/`csv-parse` exigem dependência nova — já são devDeps. (d) que o subagente é necessário — a etapa semântica é pequena (1 dataset, 1 escolha) e não isola contexto pesado como o C186.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Comunicação: /graficos-dados <dados>"] --> S["skill graficos-dados (orquestra)"]
|
||||
S -->|arquivo ou texto colado| B["scripts/build-chart-from-data.mjs"]
|
||||
B --> D["scripts/lib/chartData.mjs — parse + classifica relação + valida guardrails"]
|
||||
D -->|ambíguo ou faltando| Q["skill pergunta — nunca inventa"]
|
||||
Q --> B
|
||||
D -->|chart-spec ok| R["scripts/lib/graficosInstagramRender.mjs — canvas 1080, headline, plot, rodapé"]
|
||||
R --> G["scripts/lib/chartPrimitives.mjs — barra/coluna/linha/âncora (emissão SVG)"]
|
||||
G -. mesmo dono .-> P["dossieRender.mjs (A4, saída inalterada)"]
|
||||
R --> H["scripts/lib/buildPdf.mjs — screenshotHtmlPng"]
|
||||
H --> O["PNG gitignored: docs/research/graficos-instagram/"]
|
||||
O --> U
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (extrair o dono dos primitivos + caminho de screenshot no `buildPdf`; superfície só skill+command) | B (renderer Instagram novo e isolado, sem tocar `dossieRender`; subagente classificador) | C (dependência de chart library, ex. vega/Chart.js, e/ou MCP de gráfico).
|
||||
**Recomendação:** A — é o que a intenção manda ("estender/extrair o dono"), cabe no appetite e mantém o núcleo de parsing/classificação puro e testável.
|
||||
**Rejeitadas:** B porque gemina o desenho de gráfico (anti-goal explícito) e um subagente sem contexto pesado a isolar só acrescenta handoff/pin; C porque traz bundler/CDN/dep para um caminho que precisa ser local, determinístico e sem rede.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**(i) Reuso/extração dos primitivos de gráfico sem geminar renderer nem quebrar o A4**
|
||||
|
||||
- **Opções:** A) Extrair a **emissão SVG** dos tipos (barra, coluna, linha, âncora) para `scripts/lib/chartPrimitives.mjs`, parametrizada por `{ width, height, colors, classNames, format, highlight }`, e rewiring de `dossieRender.mjs` para consumi-la com os defaults A4 atuais (saída byte-idêntica); o renderer Instagram chama os mesmos emissores com canvas 1080 e paleta Solla. | B) Renderer Instagram copia os primitivos (twin). | C) Instagram renderer importa `dossieRender.mjs` e reescreve CSS por cima.
|
||||
- **Recomendação:** A — `barChart/valueList/columnChart/stackedColumnChart` e `CHART_COLORS` (L898–993) são usados **só dentro** de `dossieRender.mjs` (8 call sites em L1791–1840), então a extração é contida: o dono passa a ser um módulo deep de desenho de gráfico; cada consumidor mantém a **composição de página** (dossiê A4 × canvas Instagram). Linha e número-âncora não existem hoje e nascem nesse mesmo dono (não num irmão). A extração é provada sem mudança por um **golden spec** que compara a string SVG emitida antes/depois para fixtures fixas.
|
||||
- **Alternativas rejeitadas:** B porque o anti-goal é explícito e correção de palette/guardrail passaria a exigir dois edits (drift); C porque os primitivos não são exportados e arrastam CSS A4/`CHART_COLORS` de domínio para dentro do caminho Instagram (o médium é genuinamente outro: px, paleta Solla, headline/rodapé).
|
||||
|
||||
**(ii) Artefato intermediário (HTML→PNG) e execução do Chromium/screenshot**
|
||||
|
||||
- **Opções:** A) Adicionar `screenshotHtmlPng(browser, { html, width, height, outPath })` ao dono `scripts/lib/buildPdf.mjs` (reusa `launchPdfBrowser`; viewport exato; `page.screenshot({ type:'png', clip:[0,0,w,h] })`; `document.fonts.ready`; guarda de tamanho ≤8MB) e o renderer devolve HTML com CSS **inline**. | B) Rasterizar o PDF A4 existente. | C) Renderizar com `sharp`/`resvg` ou serviço externo.
|
||||
- **Recomendação:** A — o `buildPdf.mjs` é o dono do launch/emit do Chromium; o novo caminho é **aditivo** e não toca constantes A4, `emulateMedia('print')` nem a lógica de fit (o PNG usa mídia `screen` e viewport `1080×(1350|1080|1920)`; o `.canvas` tem exatamente o tamanho de saída, então `clip` é o canvas). CSS inline porque o pipeline não tem bundler — o hi-fi usa Tailwind por CDN, que **não** pode ser dependência de rede no render. PNG do Chromium já é sRGB; a guarda de 8MB falha fechado (`die`).
|
||||
- **Alternativas rejeitadas:** B porque exige o caminho A4 (páginas mm/pt, `preferCSSPageSize`) e ainda um rasterizador extra; C porque adiciona dependência/pipeline novo e (no serviço externo) viola "dado nunca sai da máquina".
|
||||
|
||||
**(iii) Superfície pública: skill + command + (sub)agente**
|
||||
|
||||
- **Opções:** A) **skill** `.agents/skills/graficos-dados/SKILL.md` (canônica) + **command** `.opencode/commands/graficos-dados.md` (homônimo, carrega a skill pelo nome e passa `$ARGUMENTS`); **sem** subagente. | B) skill + command + subagente classificador (`redator/classificador`) que devolve um recibo. | C) skill + command + subagente renderizador.
|
||||
- **Recomendação:** A — a etapa semântica é pequena e cabe no agente principal (parse do formato + leitura da tabela + escolha do tipo + copy), e o builder determinístico faz a validação; o par skill+command é exigido pelo pin `opencodeCommands.unit.spec.ts` (nome exato, sem `model:`). Não há contexto pesado (pesquisa web multi-era) a isolar como no C186. Gatilho de revisitação: quando a invocação passar a aceitar **lote** (vários gráficos por comando) ou pesquisa web, reavalie um subagente por lote.
|
||||
- **Alternativas rejeitadas:** B porque duplicaria os guardrails da skill num recibo/handoff sem ganho de contexto; C porque renderização é etapa determinística de script — nunca de LLM.
|
||||
|
||||
**(iv) Parsing de xlsx/csv/md/txt/texto com o mínimo de dependência**
|
||||
|
||||
- **Opções:** A) `xlsx` (já devDep L194) para `xlsx/xls`; `csv-parse/sync` (já devDep L171) para `csv`; sniffer de delimitador local para `txt` (`,`/`;`/`\t`); parser local de tabela GFM para `md`; fallback de pares `rótulo<sep>valor` para texto solto; `node:fs` para leitura. | B) Adicionar `papaparse` e/ou `mammoth` (docx). | C) Shell-out para `libreoffice`/`ssconvert`.
|
||||
- **Recomendação:** A — zero dependência nova; tudo isolado em `scripts/lib/chartData.mjs` (puro, sem Chromium), com detecção de header e inferência de tipo numérico, e falha explícita quando a matriz não tem 2 colunas mínimas (`label` + `value`). `doc/docx` fica **fora do v1** (a intenção já decidiu) com gatilho de revisitação se houver demanda real.
|
||||
- **Alternativas rejeitadas:** B porque `papaparse` é redundante com `csv-parse` e `mammoth` reabre o `docx` já cortado; C porque depende de binário externo e torna o determinismo ambiental.
|
||||
|
||||
**(v) Onde ficam tipos/classificação de relação e o que é unit-testável**
|
||||
|
||||
- **Opções:** A) Núcleo puro em `scripts/lib/chartData.mjs` — `parseInput`, `classifyRelation`, `validateSpec` e o shape `ChartSpec`; o entry `scripts/build-chart-from-data.mjs` é orquestrador fino; testes unit cobrem o núcleo. | B) Lógica dentro do entry `.mjs`. | C) Lógica em `src/lib/*.ts` compartilhada com o app.
|
||||
- **Recomendação:** A — Dependency Rule: o núcleo (parse/classificação/guardrails) não conhece Chromium nem CSS e roda em `test:unit` sem DB/rede; o renderer/emissor é a camada externa. Unit-testável e pinado: parsing de cada formato (fixtures inline, sem PII), classificação (`tempo→linha/coluna`, `ranking/comparação→barra`, `1 medida→âncora`, ambíguo→`needsQuestion`), guardrails (base zero, ≤7 pontos, `pie` recusada no v1), mapeamento de tamanho→viewport, **golden** dos primitivos (`chartPrimitives`) e estrutura do HTML Instagram (canvas exato, paleta hex, headline ≥48px, rótulos ≥30px, rodapé/fonte).
|
||||
- **Alternativas rejeitadas:** B porque não é importável e foge do pin de CI; C porque não é superfície de app (é ferramenta local) e travaria o núcleo ao build do Next.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`.agents/skills/graficos-dados/SKILL.md`** (novo): frontmatter só `name`+`description` (aspas simples). Corpo: quando usar; fluxo (parse → propõe tipo → gera → confere/ajusta); **contrato do `chart-spec.json`**; uso do CLI (plain `node`, sem `tsx` — o builder é `.mjs` puro); guardrails; troubleshooting; links. Dado colado vira arquivo em `data/graficos-instagram/` antes do `--in`.
|
||||
- **`.opencode/commands/graficos-dados.md`** (novo): frontmatter só `description:` (sem `model:`); corpo carrega a skill `graficos-dados` pelo nome exato, aponta `.agents/skills/graficos-dados/SKILL.md` e passa `$ARGUMENTS`. Satisfaz `tests/unit/opencodeCommands.unit.spec.ts`.
|
||||
- **Sem `.opencode/agent/graficos-dados.md` no v1** (decisão iii).
|
||||
- **`scripts/build-chart-from-data.mjs`** (novo entry, auto-entry em `knip.json` `scripts/*.mjs`): flags `--in=<path>|-` / `--text-file=`, `--spec=<json>` (replay sem reparse), `--inspect` (imprime a matriz parseada em JSON para o agente), `--type=`, `--headline=`, `--source=`, `--size=feed|square|story`, `--out=`. Lê → `parseInput` → (`classifyRelation` se `--type` ausente) → `validateSpec` (fail-closed) → `renderChartHtml` → `screenshotHtmlPng` → grava PNG + `chart-spec.json` intermediário. `die` via `dieWithLabel`/`parseEqualsFlags` de `scripts/lib/cli.mjs`.
|
||||
- **`scripts/lib/chartData.mjs`** (novo): `parseInput`, `classifyRelation`, `validateSpec`, `SIZES` (1080×1350 / 1080×1080 / 1080×1920 + safe zones de stories 250px) e `CHART_TYPES` (`bar|column|line|anchor`). JSDoc para knip `types: error`. Sem Chromium/DOM.
|
||||
- **`scripts/lib/chartPrimitives.mjs`** (novo): dono da emissão SVG dos tipos, extraído de `dossieRender.mjs` L895–993, parametrizado (`width/height/colors/classNames/format/highlight`); inclui o `lineChart` (novo) e o `proportionalPercent` (escala honesta compartilhada). A composição de barra/coluna/âncora do Instagram é CSS do template aprovado, com a proporção vinda do dono. Exportado e consumido pelos dois renderers.
|
||||
- **`scripts/lib/graficosInstagramRender.mjs`** (novo): compõe canvas 1080 + regra de topo + kicker/contexto + headline + subtítulo + plot (via primitivos) + rodapé (fonte/nota + lockup tipográfico de marca) para os 3 tamanhos; CSS **inline**; paleta Solla verbatim; sem `asset-flag`/anotações do gate.
|
||||
- **`scripts/lib/buildPdf.mjs`** (editar o dono): adicionar `screenshotHtmlPng` (aditivo; constantes A4 e `emitHtmlPairPdf` intactos).
|
||||
- **`scripts/lib/dossieRender.mjs`** (editar o dono): remover os 5 `const` e importar de `chartPrimitives.mjs` com os defaults A4 atuais (saída inalterada).
|
||||
- **Testes (novos):** `tests/unit/chartData.unit.spec.ts`, `tests/unit/chartPrimitives.unit.spec.ts` (golden), `tests/unit/graficosInstagramRender.unit.spec.ts`, `tests/unit/graficosDadosSkill.unit.spec.ts` (prosa da skill por strings literais).
|
||||
- **`tests/unit/opencodeCommands.unit.spec.ts`** (editar): incluir `'graficos-dados'` no array `commands`.
|
||||
- **`scripts/lib/test-affected-core.mjs`** (editar): adicionar `scripts/lib/chartData.mjs`, `scripts/lib/chartPrimitives.mjs`, `scripts/lib/graficosInstagramRender.mjs` a `SCRIPTS_SPEC_PINNED` (L31–90) — obrigatório porque os specs novos os importam; o invariante em `ciSkipInvariants.unit.spec.ts:192-216` recalcula o closure e exige igualdade exata.
|
||||
- **`.gitignore`** (editar, após L93): bloco `# C191 — ...` com `/data/graficos-instagram/` e `/docs/research/graficos-instagram/`.
|
||||
- **`docs/changelog/2026-09-18-c191-graficos-instagram.md`** (novo).
|
||||
- **`package.json`:** sem dep nova e **sem script novo** (o builder é chamado direto, como os irmãos); nada de migration/schema/DB.
|
||||
- **Access / Consent:** não se aplica (ferramenta local; sem collection, sem PII, sem escrita de banco).
|
||||
|
||||
### Dados → forma (pergunta 3 de data-presentation)
|
||||
|
||||
- **Forma escolhida:** **barras horizontais** para ranking/comparação (rótulo direto + valor na ponta, ordenado por valor, barras do zero); **colunas** para poucos períodos (base zero, `≤~7`); **linha** para série temporal contínua (ponto final destacado); **número-âncora** para uma única medida. Um único destaque `#c51414` (~≤10% da peça) sobre o resto neutro; fonte/ressalva dentro da imagem.
|
||||
- **Rejeitadas:** pizza (v1 **não** gera; pedido `pie` é recusado com sugestão de barras — cena 05 do gate); pizza >5 fatias (recusa fail-closed); 3D/perspectiva/eixo truncado (nunca implementados); dashboard/segundo renderer/interatividade (fora de escopo).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — extração sem mudança de comportamento** (quota: ~0,5 dia). Mover os primitivos para `chartPrimitives.mjs`, rewiring de `dossieRender.mjs`, golden spec comparando a string SVG. Prova: `pnpm test:unit` verde e `git diff` em `dossieRender.mjs` apenas de import/remoção.
|
||||
2. **Tracer — screenshot PNG** (~0,5 dia). `screenshotHtmlPng` em `buildPdf.mjs` + smoke com um canvas dummy em cada tamanho; verificar dimensões e `<8MB`.
|
||||
3. **Núcleo de dados** (~0,5 dia). `chartData.mjs` + `chart-spec` + unit specs de parse/classificação/guardrails.
|
||||
4. **Renderer Instagram** (~0,5 dia). `graficosInstagramRender.mjs` para os 4 tipos × 3 tamanhos, paleta/contraste/tipografia mínimas, rodapé/lockup.
|
||||
5. **Superfície + pinos** (~0,5 dia). skill + command; `opencodeCommands.unit.spec.ts`; `SCRIPTS_SPEC_PINNED`; `.gitignore`; changelog. Rodar a skill de ponta a ponta com `xlsx`, `csv`, `md` e texto.
|
||||
6. **Gates** — `pnpm gate:fast` (lint + typecheck + unit); conferir `knip` (exports consumidos) e `check:cycles`; `pnpm push` (o PR CI roda a cascata).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Segundo renderer de gráfico:** proibido; o dono é `chartPrimitives.mjs` (decisão i).
|
||||
- **Parametrizar o dossiê A4 além do necessário:** não mudar layout, classes ou CSS do dossiê; a extração preserva a saída.
|
||||
- **`docx`/`mammoth`, MCP de gráfico, serviço remoto:** fora do v1 (render local).
|
||||
- **Editor gráfico:** sem cores/fontes/eixos configuráveis além de tipo/rótulo/tamanho/destaque.
|
||||
- **Fontes self-hosted / logo oficial:** v1 usa stack de sistema e o lockup tipográfico do hi-fi; o asset oficial (NEEDS ASSET) é gatilho futuro.
|
||||
- **DRY especulativo:** não criar abstrações para tipos não pedidos (pizza, stacked, área).
|
||||
- **Migration/schema/DB e persistência de dado:** fora; artefato gitignored.
|
||||
|
||||
## Adiado com gatilho (triage pós-simplify)
|
||||
|
||||
- **Módulo-folha de constantes** (`SIZES`/`RELATION_LABEL` sem `xlsx`/`csv-parse`)
|
||||
— hoje o renderer importa de `chartData.mjs`, arrastando os parsers. Gatilho:
|
||||
um consumidor de `SIZES`/`RELATION_LABEL` sem parsers (renderer em bundle/browser).
|
||||
- **Decompor `main()` do entry** em `buildSpec`/`emitPng`. Gatilho: modo lote/2ª
|
||||
saída ou entry > ~200 linhas.
|
||||
|
||||
Cobertura de runtime (parse ambíguo pt-BR, teste do entry, smoke real do PNG):
|
||||
registrada como `C191-runtime` (`depends: [1145]`), plano
|
||||
`docs/plans/graficos-dados-instagram-runtime-followups.md`.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão no dossiê por tocar `dossieRender.mjs` (high-risk/spec-pinned):** extração mínima, golden spec dos primitivos, defaults idênticos; rodar os builders de dossiê num fixture e comparar HTML intermediário (amostra).
|
||||
- **Fonte não determinística no PNG (Inter ausente / fallback):** v1 usa stack de sistema com `document.fonts.ready`; medir nos dois ambientes; se houver drift, avaliar embed de woff2 (novo item, não neste PR).
|
||||
- **`page.screenshot` capturar tamanho errado:** `.canvas` com dimensões exatas + `clip` e viewport iguais; teste de dimensão do PNG.
|
||||
- **PNG >8MB:** guarda falha fechado; arte é vetorial/flat (esperado ≪8MB).
|
||||
- **Dados ambíguos/enganosos:** `validateSpec` fail-closed + fluxo de pergunta da skill; nunca completa nem infere.
|
||||
- **`xlsx@0.18.5` tem CVEs conhecidas (devDep):** uso local com insumo da própria equipe; preferir CSV quando possível; documentar.
|
||||
- **Pin de CI esquecido:** adicionar os 3 libs a `SCRIPTS_SPEC_PINNED` no mesmo commit; o invariante falha se divergir.
|
||||
- **knip `exports: error`:** todo export novo precisa de consumidor real (entry/renderer/spec) — já coberto pelo desenho.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Rodar a skill/entry com `xlsx`, `csv`, `md` e texto gera PNG `1080×1350` legível (default) e `1080×1080`/`1080×1920` sob `--size`, todos sRGB e <8MB.
|
||||
- [x] Tipos essenciais (barra, coluna, linha, número-âncora) escolhidos pela relação dos dados; `pie` recusada; base zero; ≤7 pontos (5 em stories); um destaque `#c51414`; headline ≥48px e rótulos ≥30px; contraste atendido.
|
||||
- [x] Dado ambíguo/faltando → pergunta; nada é inventado nem completado.
|
||||
- [x] Nenhum dado bruto/PNG commitado: `.gitignore` cobre `data/graficos-instagram/` e `docs/research/graficos-instagram/`.
|
||||
- [x] Saída do dossiê A4 inalterada após a extração dos primitivos (golden + specs de dossiê verdes).
|
||||
- [x] Invariantes AGENTS/engineering-standards: sem schema/migration/DB; sem PII; identificadores em inglês, copy pt-BR; "edite o dono".
|
||||
- [x] Testes de domínio previstos: `chartData` (parse/classificação/guardrails), `chartPrimitives` (golden), `graficosInstagramRender` (estrutura/medidas), prosa da skill, e `opencodeCommands` atualizado; `SCRIPTS_SPEC_PINNED` sincronizado.
|
||||
- [x] `pnpm gate:fast` verde; knip sem export órfão; push via `pnpm push`.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
1. **Decisões caras têm rejeitadas? — 5/5.** As cinco decisões exigidas (extração dos primitivos, HTML→PNG/screenshot, superfície pública, parsing, núcleo de tipos) trazem Opções/Recomendação/Rejeitadas nomeadas.
|
||||
2. **Abordagem cabe no appetite? — 4/5.** Extração contida + 3 módulos + 1 entry cabem em ~2–3 dias; o único risco de appetite é o rewrite de `dossieRender.mjs`, mitigado por extração mínima e golden.
|
||||
3. **Rabbit holes nomeados? — 5/5.** Segundo renderer, editor, `docx`, MCP, fontes/logo, DRY especulativo e DB estão explicitamente cortados.
|
||||
4. **Depth check (reusa shells/helpers)? — 5/5.** Reusa `buildPdf.launchPdfBrowser`/emit como dono do Chromium, `cli.mjs` (`dieWithLabel`/`parseEqualsFlags`), os primitivos extraídos do próprio dono e o padrão skill+command dos irmãos; nenhum caminho paralelo.
|
||||
5. **Intenção (aceite de produto) permanece satisfeita? — 5/5.** O outcome, os guardrails e os anti-goals seguem intactos; a engenharia não reescreveu o produto.
|
||||
|
||||
**Média: 4,8 — passa o gate ≥4.** (Critério mais fraco: appetite, 4.)
|
||||
50
docs/plans/graficos-dados-instagram-runtime-followups.md
Normal file
50
docs/plans/graficos-dados-instagram-runtime-followups.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Runtime follow-ups C191 — cobertura do entry/PNG e parse ambíguo
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-09-18
|
||||
Issue: a registrar (`C191-runtime`, `depends: [1145]`)
|
||||
Pai: #1145 / `docs/plans/graficos-dados-instagram-impl.md`
|
||||
Appetite: ~0,5 dia eng (fill-in)
|
||||
Impeccable: A (só backend/ferramenta; sem superfície de UI)
|
||||
|
||||
## Outcome
|
||||
|
||||
Fechar a cobertura de runtime da skill `graficos-dados` e tornar explícito o
|
||||
único parsing hoje silencioso, sem mudar o comportamento entregue no C191.
|
||||
|
||||
## Fases (ordenadas por ROI)
|
||||
|
||||
1. **Parse ambíguo pt-BR (score 3).** `parseNumber('1.234')` hoje decide
|
||||
milhar × decimal em silêncio. Documentar e cobrir com unit a regra do
|
||||
separador único; se a decisão for por perguntar, expor no `issues` do
|
||||
`parseInput` (fail-closed), nunca adivinhar.
|
||||
2. **Teste do entry (score 3).** Cobrir `scripts/build-chart-from-data.mjs`:
|
||||
`--inspect`, montagem do spec, `--spec` replay, `needsQuestion`, `--source`
|
||||
obrigatório e a linha de log por tamanho. Sem Chromium (injetar o emit ou
|
||||
mockar o browser).
|
||||
3. **Smoke real do PNG (score 3).** Exercitar `screenshotHtmlPng` com Chromium
|
||||
real por tamanho (feed/quadrado/story), verificando dimensões exatas e o
|
||||
guard de 8 MB — hoje só há guard com browser fake.
|
||||
|
||||
## Já resolvido no simplify/critique (não reabrir)
|
||||
|
||||
- Perda silenciosa de linha com valor faltando (header heurístico) — corrigido
|
||||
e pinado (`chartData.unit.spec.ts`).
|
||||
- `--size` inválido e `--highlight` inexistente agora falham fechado.
|
||||
- `escapeHtml` gêmeo removido (usa `reportText.htmlEscape`).
|
||||
- Padding de stories derivado de `SIZES.story.safe*`; cap de 5 pontos em stories.
|
||||
- `proportionalPercent` com zero honesto (0 → 0%); typo de copy.
|
||||
|
||||
## Explicitamente fora (descartes e defers deste triage)
|
||||
|
||||
- `SIZES.*.label` sem consumidor no código — dobra no item de fronteira abaixo
|
||||
se `SIZES` for movido; não vira Issue.
|
||||
- `SOLLA_PALETTE.ptRed` sem consumidor — contrato verbatim da intenção,
|
||||
intencional.
|
||||
- Contexto extra em logs do entry — ENOENT/`--spec` inválido já embrulhados;
|
||||
valor marginal.
|
||||
- **Adiado com gatilho:** mover `SIZES`/`RELATION_LABEL` para um módulo-folha
|
||||
sem `xlsx`/`csv-parse` (fronteira do renderer). Gatilho: um consumidor de
|
||||
`SIZES`/`RELATION_LABEL` sem parsers (renderer em bundle/browser).
|
||||
- **Adiado com gatilho:** decompor `main()` do entry em `buildSpec`/`emitPng`.
|
||||
Gatilho: o entry ganhar modo lote/2ª saída ou passar de ~200 linhas.
|
||||
156
scripts/build-chart-from-data.mjs
Normal file
156
scripts/build-chart-from-data.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Build an Instagram-ready chart PNG from pasted/attached data (C191).
|
||||
*
|
||||
* The skill `graficos-dados` orchestrates: it parses the input, decides the
|
||||
* chart relation and writes the headline/source, then calls this entry. The
|
||||
* entry is deterministic and fail-closed: guardrail violations and missing
|
||||
* data exit non-zero instead of drawing something misleading. Rendered local,
|
||||
* never committed (data/ and docs/research/ are gitignored).
|
||||
*
|
||||
* Usage:
|
||||
* 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] [--size=feed|square|story] \
|
||||
* [--highlight="Território A"] [--out=docs/research/graficos-instagram/x.png]
|
||||
* node scripts/build-chart-from-data.mjs --in=dados.csv --inspect
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join } from 'node:path'
|
||||
|
||||
import { launchPdfBrowser, screenshotHtmlPng } from './lib/buildPdf.mjs'
|
||||
import {
|
||||
MAX_POINTS,
|
||||
MAX_POINTS_STORY,
|
||||
SIZES,
|
||||
classifyRelation,
|
||||
parseInput,
|
||||
validateSpec,
|
||||
} from './lib/chartData.mjs'
|
||||
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
|
||||
import { renderChartHtml } from './lib/graficosInstagramRender.mjs'
|
||||
|
||||
const die = dieWithLabel('graficos-dados')
|
||||
const repoRoot = process.cwd()
|
||||
|
||||
const FORMAT_BY_EXT = {
|
||||
'.xlsx': 'xlsx',
|
||||
'.xls': 'xls',
|
||||
'.csv': 'csv',
|
||||
'.tsv': 'tsv',
|
||||
'.md': 'md',
|
||||
'.markdown': 'md',
|
||||
'.txt': 'txt',
|
||||
}
|
||||
|
||||
const slugify = (value) =>
|
||||
String(value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48)
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
const readInput = async (flags) => {
|
||||
const path = flags.in === '-' || flags.in === true ? null : String(flags.in ?? '')
|
||||
if (!path) {
|
||||
const chunks = []
|
||||
for await (const chunk of process.stdin) chunks.push(chunk)
|
||||
return { text: Buffer.concat(chunks).toString('utf8'), format: 'txt' }
|
||||
}
|
||||
const format = FORMAT_BY_EXT[extname(path).toLowerCase()] ?? 'txt'
|
||||
const buffer = await readFile(path)
|
||||
if (format === 'xlsx' || format === 'xls') return { buffer, format }
|
||||
return { text: buffer.toString('utf8'), format }
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const { flags } = parseEqualsFlags(process.argv.slice(2))
|
||||
|
||||
let spec
|
||||
if (flags.spec) {
|
||||
try {
|
||||
spec = JSON.parse(await readFile(String(flags.spec), 'utf8'))
|
||||
} catch (error) {
|
||||
die(`falha ao ler --spec=${flags.spec}: ${error?.message ?? error}`)
|
||||
}
|
||||
} else {
|
||||
if (!flags.in) die('informe --in=<arquivo|-> com os dados (ou --spec=<json> para replay).')
|
||||
let input
|
||||
try {
|
||||
input = await readInput(flags)
|
||||
} catch (error) {
|
||||
die(`falha ao ler --in=${flags.in}: ${error?.message ?? error}`)
|
||||
}
|
||||
const dataset = parseInput(input)
|
||||
|
||||
if (flags.inspect) {
|
||||
process.stdout.write(`${JSON.stringify(dataset, null, 2)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (dataset.rows.length === 0 || dataset.issues.length > 0) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ needsQuestion: true, issues: dataset.issues, rows: dataset.rows }, null, 2)}\n`,
|
||||
)
|
||||
die('dado ambíguo ou faltando — pergunte à pessoa antes de gerar (nada é completado).')
|
||||
}
|
||||
|
||||
const chartType = classifyRelation(dataset.rows, flags.type ? String(flags.type) : null)
|
||||
spec = {
|
||||
chartType,
|
||||
size: flags.size ? String(flags.size) : 'feed',
|
||||
headline: flags.headline ? String(flags.headline) : '',
|
||||
subtitle: flags.subtitle ? String(flags.subtitle) : '',
|
||||
source: flags.source ? String(flags.source) : '',
|
||||
note: flags.note ? String(flags.note) : '',
|
||||
highlight: flags.highlight ? String(flags.highlight) : null,
|
||||
rows: dataset.rows,
|
||||
}
|
||||
}
|
||||
|
||||
if (!spec.source) die('informe --source (a fonte viaja dentro da imagem).')
|
||||
validateSpec(spec)
|
||||
|
||||
const slug = slugify(spec.headline)
|
||||
const specPath = join(repoRoot, 'data/graficos-instagram', `${slug || 'grafico'}.chart-spec.json`)
|
||||
const outPath = flags.out
|
||||
? String(flags.out)
|
||||
: join(
|
||||
repoRoot,
|
||||
'docs/research/graficos-instagram',
|
||||
`${slug || 'grafico'}-${today()}-${spec.size ?? 'feed'}.png`,
|
||||
)
|
||||
|
||||
const html = renderChartHtml(spec)
|
||||
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
|
||||
const canvas = SIZES[sizeKey]
|
||||
const pointCap =
|
||||
sizeKey === 'story' && (spec.chartType === 'bar' || spec.chartType === 'column')
|
||||
? MAX_POINTS_STORY
|
||||
: MAX_POINTS
|
||||
const browser = await launchPdfBrowser()
|
||||
try {
|
||||
await mkdir(dirname(outPath), { recursive: true })
|
||||
await mkdir(dirname(specPath), { recursive: true })
|
||||
await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`)
|
||||
const { size } = await screenshotHtmlPng(browser, {
|
||||
html,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
outPath,
|
||||
})
|
||||
console.log(
|
||||
`[graficos-dados] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${spec.rows.length} ponto(s) ≤ ${pointCap}`,
|
||||
)
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message ?? String(error))
|
||||
})
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
*/
|
||||
|
||||
import { chromium } from '@playwright/test'
|
||||
import { stat } from 'node:fs/promises'
|
||||
|
||||
const MM_TO_PX = 96 / 25.4
|
||||
export const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
|
||||
|
|
@ -21,6 +22,38 @@ export const A4_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
|
|||
|
||||
export const launchPdfBrowser = () => chromium.launch()
|
||||
|
||||
/**
|
||||
* Exact-size PNG of a screen-media document (C191 Instagram canvas). The
|
||||
* `.canvas` element is authored at the output size, so the viewport and the
|
||||
* clip are the same rectangle; no A4 constant is touched. Chromium PNGs are
|
||||
* already sRGB. Fails closed when the file exceeds `maxBytes` (default 8 MB).
|
||||
*/
|
||||
export const screenshotHtmlPng = async (
|
||||
browser,
|
||||
{ html, width, height, outPath, maxBytes = 8 * 1024 * 1024 },
|
||||
) => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
await page.setViewportSize({ width, height })
|
||||
await page.setContent(html, { waitUntil: 'load' })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.screenshot({
|
||||
path: outPath,
|
||||
type: 'png',
|
||||
clip: { x: 0, y: 0, width, height },
|
||||
})
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
const { size } = await stat(outPath)
|
||||
if (size > maxBytes) {
|
||||
throw new Error(
|
||||
`PNG ${outPath} tem ${size} bytes (> ${maxBytes}); simplifique o gráfico ou reduza pontos.`,
|
||||
)
|
||||
}
|
||||
return { size }
|
||||
}
|
||||
|
||||
/** Every `[data-page]` taller than the budget, in document order. */
|
||||
const measurePageOverflows = (page, budgetPx = A4_PAGE_BUDGET_PX) =>
|
||||
page.evaluate(
|
||||
|
|
|
|||
334
scripts/lib/chartData.mjs
Normal file
334
scripts/lib/chartData.mjs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
/**
|
||||
* Chart data core (C191): parse the numbers the communication team pasted or
|
||||
* dropped (xlsx/csv/md/txt/free text), pick the chart relation and enforce the
|
||||
* correctness guardrails — all pure, no Chromium and no DOM, so the unit suite
|
||||
* covers it without a database or a browser.
|
||||
*
|
||||
* The renderer (`graficosInstagramRender.mjs`) owns the look; this module never
|
||||
* invents a value: anything missing or ambiguous becomes an explicit question.
|
||||
*/
|
||||
|
||||
import { parse as parseCsv } from 'csv-parse/sync'
|
||||
import XLSX from 'xlsx'
|
||||
|
||||
export const CHART_TYPES = ['bar', 'column', 'line', 'anchor']
|
||||
|
||||
/** Max data points per chart (honest density; above this the person must summarize). */
|
||||
export const MAX_POINTS = 7
|
||||
|
||||
/**
|
||||
* Stories recompose the vertical rhythm with one fewer ranking point (approved
|
||||
* design: "reduz um ponto do ranking"), so a six/seven-point ranking is refused
|
||||
* instead of crowded.
|
||||
*/
|
||||
export const MAX_POINTS_STORY = 5
|
||||
|
||||
/** Output canvases: feed 4:5 (default), square, stories/reels 9:16 with safe bands. */
|
||||
export const SIZES = {
|
||||
feed: { width: 1080, height: 1350, label: '1080×1350', safeTop: 0, safeBottom: 0 },
|
||||
square: { width: 1080, height: 1080, label: '1080×1080', safeTop: 0, safeBottom: 0 },
|
||||
story: { width: 1080, height: 1920, label: '1080×1920', safeTop: 250, safeBottom: 250 },
|
||||
}
|
||||
|
||||
/** Kicker of each relation, matching the approved hi-fi template. */
|
||||
export const RELATION_LABEL = {
|
||||
bar: 'Comparação',
|
||||
column: 'Poucos períodos',
|
||||
line: 'Série de tempo',
|
||||
anchor: 'Número-âncora',
|
||||
}
|
||||
|
||||
const TEMPORAL_RE =
|
||||
/^(\d{4}|\d{2}|[A-Za-zÀ-ÿ]{3,9}[./-]?\d{2,4}|\d{1,2}[./-]\d{2,4}|[TQ]\d|P\d+|\d{4}[./-]\d{1,2})$/u
|
||||
|
||||
/** True when the label reads as a period (year, month, quarter, P1…), not a category. */
|
||||
export const isTemporalLabel = (label) => TEMPORAL_RE.test(String(label).trim())
|
||||
|
||||
/**
|
||||
* pt-BR/en number: strips currency/spaces/percent, treats the last separator as
|
||||
* the decimal when both `.` and `,` appear, and comma-as-decimal otherwise.
|
||||
* Returns null (never 0) for anything non-numeric, so "missing" stays honest.
|
||||
*/
|
||||
export const parseNumber = (raw) => {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null
|
||||
let value = String(raw ?? '')
|
||||
.replace(/[R$\s%]/gi, '')
|
||||
.trim()
|
||||
if (value === '') return null
|
||||
const hasDot = value.includes('.')
|
||||
const hasComma = value.includes(',')
|
||||
if (hasDot && hasComma) {
|
||||
value =
|
||||
value.lastIndexOf(',') > value.lastIndexOf('.')
|
||||
? value.replaceAll('.', '').replace(',', '.')
|
||||
: value.replaceAll(',', '')
|
||||
} else if (hasComma) {
|
||||
value = value.replace(',', '.')
|
||||
}
|
||||
if (!/^-?\d+(\.\d+)?$/.test(value)) return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const lastCell = (row) => row[row.length - 1]
|
||||
|
||||
const normalizeWord = (value) =>
|
||||
String(value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
|
||||
/**
|
||||
* A header row is only recognized by an explicit header word in some cell —
|
||||
* never by "the first value is not numeric", which would drop a real data row
|
||||
* with a missing value ("Ilhéus n/d") in silence. Unknown headers survive as a
|
||||
* data row and become an explicit question instead.
|
||||
*/
|
||||
const HEADER_HINTS = new Set([
|
||||
'valor',
|
||||
'value',
|
||||
'total',
|
||||
'quantidade',
|
||||
'qtd',
|
||||
'numero',
|
||||
'montante',
|
||||
'percentual',
|
||||
'percent',
|
||||
'%',
|
||||
'taxa',
|
||||
'media',
|
||||
'indice',
|
||||
'votos',
|
||||
'count',
|
||||
'amount',
|
||||
'resultado',
|
||||
'unidade',
|
||||
'ano',
|
||||
'mes',
|
||||
'periodo',
|
||||
'data',
|
||||
'indicador',
|
||||
'metrica',
|
||||
'r$',
|
||||
'populacao',
|
||||
'eleitores',
|
||||
'eleitorado',
|
||||
'cidade',
|
||||
'municipio',
|
||||
'categoria',
|
||||
'grupo',
|
||||
'regiao',
|
||||
'estado',
|
||||
'partido',
|
||||
'candidato',
|
||||
])
|
||||
|
||||
const looksLikeHeaderRow = (row) =>
|
||||
row.some((cell) =>
|
||||
normalizeWord(cell)
|
||||
.split(/[^a-z0-9%$]+/)
|
||||
.some((word) => HEADER_HINTS.has(word)),
|
||||
)
|
||||
|
||||
const cleanMatrix = (matrix) =>
|
||||
matrix
|
||||
.map((row) => row.map((cell) => String(cell ?? '').trim()))
|
||||
.filter((row) => row.some(Boolean))
|
||||
|
||||
const matrixToDataset = (matrix, format) => {
|
||||
const cleaned = cleanMatrix(matrix)
|
||||
const issues = []
|
||||
if (cleaned.length === 0) return { rows: [], format, issues: ['nenhum dado encontrado'] }
|
||||
|
||||
let start = 0
|
||||
if (
|
||||
cleaned.length > 1 &&
|
||||
parseNumber(lastCell(cleaned[0])) === null &&
|
||||
looksLikeHeaderRow(cleaned[0])
|
||||
) {
|
||||
start = 1
|
||||
}
|
||||
|
||||
const rows = []
|
||||
for (const row of cleaned.slice(start)) {
|
||||
const label = row[0]
|
||||
const rawValue = lastCell(row)
|
||||
const value = parseNumber(rawValue)
|
||||
if (!label && value === null) {
|
||||
issues.push(`linha sem rótulo: ${JSON.stringify(row.join(' | '))}`)
|
||||
continue
|
||||
}
|
||||
if (value === null) {
|
||||
issues.push(`valor não numérico em "${label}": ${JSON.stringify(rawValue)}`)
|
||||
continue
|
||||
}
|
||||
rows.push({ label, value })
|
||||
}
|
||||
|
||||
const seen = new Set()
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.label)) issues.push(`rótulo repetido: "${row.label}"`)
|
||||
seen.add(row.label)
|
||||
}
|
||||
return { rows, format, issues }
|
||||
}
|
||||
|
||||
const sniffDelimiter = (line) => {
|
||||
let best = null
|
||||
let bestCount = 0
|
||||
for (const delimiter of ['\t', ';', ',']) {
|
||||
const count = line.split(delimiter).length - 1
|
||||
if (count > bestCount) {
|
||||
bestCount = count
|
||||
best = delimiter
|
||||
}
|
||||
}
|
||||
return bestCount > 0 ? best : null
|
||||
}
|
||||
|
||||
const isMarkdownTable = (lines) =>
|
||||
lines.length > 1 && lines[0].includes('|') && /^\s*\|?[\s:-]*-{3,}[\s:|-]*\|?\s*$/.test(lines[1])
|
||||
|
||||
const parseMarkdownTable = (lines) =>
|
||||
lines
|
||||
.filter((line) => line.includes('|'))
|
||||
.filter((_line, index) => index !== 1)
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/^\s*\|/, '')
|
||||
.replace(/\|\s*$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim()),
|
||||
)
|
||||
|
||||
/** Last separator wins, so labels may contain spaces: "Votos em Ilhéus: 84". */
|
||||
const splitPair = (line) => {
|
||||
const separators = [':', '=', '\t']
|
||||
for (const separator of separators) {
|
||||
const at = line.lastIndexOf(separator)
|
||||
if (at > 0) return [line.slice(0, at), line.slice(at + 1)]
|
||||
}
|
||||
const match = line.match(/^(.*\S)\s+([^\s]+)$/s)
|
||||
if (match) return [match[1], match[2]]
|
||||
// A bare number is an unlabeled single measure (the anchor relation).
|
||||
if (parseNumber(line) !== null) return ['', line]
|
||||
return [line, '']
|
||||
}
|
||||
|
||||
const parseFreeText = (text) => {
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
|
||||
if (lines.length === 0) return { rows: [], format: 'txt', issues: ['nenhum dado encontrado'] }
|
||||
if (isMarkdownTable(lines)) return matrixToDataset(parseMarkdownTable(lines), 'md')
|
||||
const delimiter = sniffDelimiter(lines[0])
|
||||
if (delimiter) {
|
||||
const matrix = lines.map((line) => line.split(delimiter))
|
||||
return matrixToDataset(matrix, delimiter === '\t' ? 'tsv' : 'csv')
|
||||
}
|
||||
const matrix = lines.map(splitPair)
|
||||
return matrixToDataset(matrix, 'txt')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the raw input into `{ rows, format, issues }`. `buffer` is required for
|
||||
* xlsx/xls; `text` for csv/md/txt. `format` overrides the extension inference.
|
||||
*
|
||||
* @param {{ text?: string | null, buffer?: Buffer | null, format?: string | null }} [options]
|
||||
*/
|
||||
export const parseInput = ({ text = null, buffer = null, format = null } = {}) => {
|
||||
if (format === 'xlsx' || format === 'xls') {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' })
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false })
|
||||
return matrixToDataset(matrix, 'xlsx')
|
||||
}
|
||||
const body = String(text ?? '')
|
||||
if (format === 'csv' || format === 'tsv') {
|
||||
const delimiter =
|
||||
format === 'tsv' ? '\t' : (sniffDelimiter(body.split(/\r?\n/)[0] ?? '') ?? ',')
|
||||
const matrix = parseCsv(body, {
|
||||
delimiter,
|
||||
skip_empty_lines: true,
|
||||
relax_column_count: true,
|
||||
})
|
||||
return matrixToDataset(matrix, format)
|
||||
}
|
||||
return parseFreeText(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation of the data → chart type. One measure is an anchor; every-temporal
|
||||
* labels are a series (few periods as columns, many as a line); anything else
|
||||
* is a ranking/comparison in horizontal bars.
|
||||
*
|
||||
* @param {{ label: string, value: number }[]} rows
|
||||
* @param {string | null} [forcedType]
|
||||
*/
|
||||
export const classifyRelation = (rows, forcedType = null) => {
|
||||
if (forcedType) {
|
||||
// `pie` passes through so `validateSpec` answers with the actionable
|
||||
// "use barras horizontais" message instead of a generic unknown type.
|
||||
if (forcedType !== 'pie' && !CHART_TYPES.includes(forcedType)) {
|
||||
throw new Error(
|
||||
`tipo de gráfico desconhecido: ${JSON.stringify(forcedType)} (use ${CHART_TYPES.join(', ')})`,
|
||||
)
|
||||
}
|
||||
return forcedType
|
||||
}
|
||||
if (rows.length === 1) return 'anchor'
|
||||
if (rows.length > 1 && rows.every((row) => isTemporalLabel(row.label))) {
|
||||
return rows.length <= 4 ? 'column' : 'line'
|
||||
}
|
||||
return 'bar'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed guardrails of the approved template. Throws with the actionable
|
||||
* reason instead of drawing a misleading chart.
|
||||
*/
|
||||
export const validateSpec = (spec) => {
|
||||
const { chartType, rows, headline } = spec
|
||||
if (spec.size && !SIZES[spec.size]) {
|
||||
throw new Error(
|
||||
`tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`,
|
||||
)
|
||||
}
|
||||
if (!CHART_TYPES.includes(chartType)) {
|
||||
if (chartType === 'pie') {
|
||||
throw new Error(
|
||||
'pizza não é gerada no v1 (a comparação fica escondida); use barras horizontais.',
|
||||
)
|
||||
}
|
||||
throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`)
|
||||
}
|
||||
if (!Array.isArray(rows) || rows.length === 0)
|
||||
throw new Error('sem pontos de dado para desenhar.')
|
||||
if (rows.length > MAX_POINTS) {
|
||||
throw new Error(
|
||||
`${rows.length} pontos (> ${MAX_POINTS}): resuma as categorias antes de gerar o gráfico.`,
|
||||
)
|
||||
}
|
||||
if (
|
||||
spec.size === 'story' &&
|
||||
(chartType === 'bar' || chartType === 'column') &&
|
||||
rows.length > MAX_POINTS_STORY
|
||||
) {
|
||||
throw new Error(
|
||||
`stories comporta até ${MAX_POINTS_STORY} pontos no ranking (${rows.length} recebidos): resuma para gerar a versão vertical.`,
|
||||
)
|
||||
}
|
||||
if (chartType === 'anchor' && rows.length !== 1) {
|
||||
throw new Error('número-âncora exige exatamente um valor.')
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (!row.label && chartType !== 'anchor') throw new Error('ponto de dado sem rótulo.')
|
||||
if (!Number.isFinite(row.value)) throw new Error(`valor ausente/inválido em "${row.label}".`)
|
||||
if (row.value < 0) throw new Error(`valor negativo em "${row.label}" — barras partem do zero.`)
|
||||
}
|
||||
if (spec.highlight && !rows.some((row) => row.label === spec.highlight)) {
|
||||
throw new Error(`destaque "${spec.highlight}" não existe nos dados — confira o rótulo.`)
|
||||
}
|
||||
if (typeof headline !== 'string' || headline.trim() === '') {
|
||||
throw new Error('título-manchete ausente — a manchete é obrigatória.')
|
||||
}
|
||||
return spec
|
||||
}
|
||||
185
scripts/lib/chartPrimitives.mjs
Normal file
185
scripts/lib/chartPrimitives.mjs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Chart primitives (owner of the drawing): inline-SVG emitters shared by the
|
||||
* A4 dossier renderer (C186/C187) and the Instagram chart renderer (C191).
|
||||
*
|
||||
* Extracted from `dossieRender.mjs` when the Instagram generator became the
|
||||
* second consumer, so a fix to the honest-scale/guardrail drawing lives once.
|
||||
* The dossier keeps its mm/pt page composition; each consumer composes its own
|
||||
* page around these primitives — never a second copy of the drawing.
|
||||
*/
|
||||
|
||||
import { htmlEscape } from './reportText.mjs'
|
||||
|
||||
/**
|
||||
* Domain palette of the dossier charts. Instagram passes its own Solla palette
|
||||
* explicitly; this map stays the dossier default so its output is unchanged.
|
||||
*/
|
||||
export const CHART_COLORS = {
|
||||
municipio: '#315c75',
|
||||
regiao: '#9d8b64',
|
||||
polo: '#8f819c',
|
||||
instituicao: '#315c75',
|
||||
setor: '#9d8b64',
|
||||
rede: '#8f819c',
|
||||
empenhado: '#6b4918',
|
||||
liquidado: '#4a6b7c',
|
||||
pago: '#285338',
|
||||
autorizado: '#8a5a18',
|
||||
restos: '#5b6470',
|
||||
nao_informado: '#8b96a2',
|
||||
default: '#315c75',
|
||||
}
|
||||
|
||||
/**
|
||||
* Honest-scale proportion of a value against the maximum, as a percentage
|
||||
* (0..100). Zero stays zero (no bar is drawn); a present small value keeps a
|
||||
* visible minimum so it is not mistaken for absent. Used for the ranking width
|
||||
* and the column height of the Instagram renderer.
|
||||
*/
|
||||
export const proportionalPercent = (value, max) =>
|
||||
value <= 0 ? 0 : Math.min(100, Math.max(2, Math.round((value / Math.max(1, max)) * 100)))
|
||||
|
||||
export const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.count))
|
||||
const barMax = width - labelWidth - 26
|
||||
const height = rows.length * rowHeight + 4
|
||||
return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img">
|
||||
${rows
|
||||
.map((row, index) => {
|
||||
const y = index * rowHeight + 3
|
||||
const barWidth = Math.max(2, Math.round((row.count / max) * barMax))
|
||||
return `<text x="0" y="${y + 11}" class="chart-label">${htmlEscape(row.label)}</text>
|
||||
<rect x="${labelWidth}" y="${y + 2}" width="${barWidth}" height="${rowHeight - 7}" rx="1" fill="${row.color ?? CHART_COLORS.default}" />
|
||||
<text x="${labelWidth + barWidth + 4}" y="${y + 11}" class="chart-value">${row.count}</text>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */
|
||||
export const valueList = ({ rows, format }) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value))
|
||||
return `<ul class="value-list">
|
||||
${rows
|
||||
.map(
|
||||
(row) => `<li>
|
||||
<span>${htmlEscape(row.label)}</span>
|
||||
<span class="value-list-bar"><span style="width:${Math.max(2, Math.round((row.value / max) * 100))}%"></span></span>
|
||||
<span class="value-list-value">${htmlEscape(format(row))}</span>
|
||||
</li>`,
|
||||
)
|
||||
.join('')}
|
||||
</ul>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical columns with the value label above and the year label below.
|
||||
* `fullYearLabel` prints the whole year (theme axis); the institution keeps the
|
||||
* 2-digit label.
|
||||
*/
|
||||
export const columnChart = ({
|
||||
rows,
|
||||
width = 330,
|
||||
height = 132,
|
||||
format = (row) => String(row.count),
|
||||
fullYearLabel = false,
|
||||
}) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value ?? row.count))
|
||||
const baseY = height - 18
|
||||
const plotHeight = baseY - 16
|
||||
const columnWidth = (width - 8) / Math.max(1, rows.length)
|
||||
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
|
||||
${rows
|
||||
.map((row, index) => {
|
||||
const value = row.value ?? row.count
|
||||
const barHeight = Math.max(2, Math.round((value / max) * plotHeight))
|
||||
const x = 4 + index * columnWidth
|
||||
const y = baseY - barHeight
|
||||
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${barHeight}" fill="${row.color ?? CHART_COLORS.default}" />
|
||||
<text x="${x + columnWidth / 2}" y="${y - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
|
||||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/** Vertical columns stacked by phase (money by year), total label above. */
|
||||
export const stackedColumnChart = ({
|
||||
rows,
|
||||
width = 330,
|
||||
height = 132,
|
||||
format,
|
||||
fullYearLabel = false,
|
||||
}) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value))
|
||||
const baseY = height - 18
|
||||
const plotHeight = baseY - 16
|
||||
const columnWidth = (width - 8) / Math.max(1, rows.length)
|
||||
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
|
||||
${rows
|
||||
.map((row, index) => {
|
||||
const x = 4 + index * columnWidth
|
||||
let y = baseY
|
||||
const bars = row.segments
|
||||
.map((segment) => {
|
||||
const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight))
|
||||
y -= segmentHeight
|
||||
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${segmentHeight}" fill="${CHART_COLORS[segment.key] ?? CHART_COLORS.default}" />`
|
||||
})
|
||||
.join('')
|
||||
const top = baseY - Math.round((row.value / max) * plotHeight)
|
||||
return `${bars}
|
||||
<text x="${x + columnWidth / 2}" y="${top - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
|
||||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series line (Instagram): every point a hollow marker, the last one the
|
||||
* single red highlight with its value. Honest scale — the baseline is zero.
|
||||
*/
|
||||
export const lineChart = ({
|
||||
rows,
|
||||
width = 880,
|
||||
height = 420,
|
||||
format,
|
||||
stroke = '#78716c',
|
||||
highlight = '#c51414',
|
||||
paper = '#faf9f7',
|
||||
ink = '#1c1917',
|
||||
grid = '#a8a29e',
|
||||
}) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value))
|
||||
const padX = 46
|
||||
const padTop = 42
|
||||
const baseline = height - 46
|
||||
const plotHeight = baseline - padTop
|
||||
const step = rows.length > 1 ? (width - padX * 2) / (rows.length - 1) : 0
|
||||
const pointAt = (row, index) => {
|
||||
const x = padX + index * step
|
||||
const y = baseline - Math.round((row.value / max) * plotHeight)
|
||||
return { x, y }
|
||||
}
|
||||
const points = rows.map(pointAt)
|
||||
const last = points[points.length - 1]
|
||||
return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img" preserveAspectRatio="xMidYMid meet">
|
||||
<line x1="${padX}" y1="${baseline}" x2="${width - padX}" y2="${baseline}" stroke="${grid}" stroke-width="2" />
|
||||
<polyline points="${points.map((point) => `${point.x},${point.y}`).join(' ')}" fill="none" stroke="${stroke}" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<g fill="${paper}" stroke="${stroke}" stroke-width="4">
|
||||
${points
|
||||
.slice(0, -1)
|
||||
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="7" />`)
|
||||
.join('')}
|
||||
</g>
|
||||
<circle cx="${last.x}" cy="${last.y}" r="10" fill="${highlight}" />
|
||||
<text x="${last.x}" y="${last.y - 22}" text-anchor="end" fill="${ink}" font-size="34" font-weight="800">${htmlEscape(format(rows[rows.length - 1]))}</text>
|
||||
${rows
|
||||
.map(
|
||||
(row, index) =>
|
||||
`<text x="${pointAt(row, index).x}" y="${height - 10}" text-anchor="middle" fill="#57534e" font-size="30" font-weight="650">${htmlEscape(row.label)}</text>`,
|
||||
)
|
||||
.join('')}
|
||||
</svg>`
|
||||
}
|
||||
|
|
@ -5,6 +5,13 @@
|
|||
* shared escaping/source-token contract lives in `reportText.mjs`.
|
||||
*/
|
||||
|
||||
import {
|
||||
CHART_COLORS,
|
||||
barChart,
|
||||
columnChart,
|
||||
stackedColumnChart,
|
||||
valueList,
|
||||
} from './chartPrimitives.mjs'
|
||||
import { formatDateBr, formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
|
||||
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
|
||||
import { MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit } from './dossieUnit.mjs'
|
||||
|
|
@ -897,118 +904,6 @@ const groupUnits = (chunk) => {
|
|||
const tableHead = (columns) =>
|
||||
`<thead><tr>${columns.map((column) => `<th>${htmlEscape(column)}</th>`).join('')}</tr></thead>`
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* Synthesis charts (inline SVG, print-safe): aggregations of the *
|
||||
* sourced rows only — never a new fact, never a combined total *
|
||||
* across abrangências. *
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
const CHART_COLORS = {
|
||||
municipio: '#315c75',
|
||||
regiao: '#9d8b64',
|
||||
polo: '#8f819c',
|
||||
instituicao: '#315c75',
|
||||
setor: '#9d8b64',
|
||||
rede: '#8f819c',
|
||||
empenhado: '#6b4918',
|
||||
liquidado: '#4a6b7c',
|
||||
pago: '#285338',
|
||||
autorizado: '#8a5a18',
|
||||
restos: '#5b6470',
|
||||
nao_informado: '#8b96a2',
|
||||
default: '#315c75',
|
||||
}
|
||||
|
||||
const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.count))
|
||||
const barMax = width - labelWidth - 26
|
||||
const height = rows.length * rowHeight + 4
|
||||
return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img">
|
||||
${rows
|
||||
.map((row, index) => {
|
||||
const y = index * rowHeight + 3
|
||||
const barWidth = Math.max(2, Math.round((row.count / max) * barMax))
|
||||
return `<text x="0" y="${y + 11}" class="chart-label">${htmlEscape(row.label)}</text>
|
||||
<rect x="${labelWidth}" y="${y + 2}" width="${barWidth}" height="${rowHeight - 7}" rx="1" fill="${row.color ?? CHART_COLORS.default}" />
|
||||
<text x="${labelWidth + barWidth + 4}" y="${y + 11}" class="chart-value">${row.count}</text>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */
|
||||
const valueList = ({ rows, format }) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value))
|
||||
return `<ul class="value-list">
|
||||
${rows
|
||||
.map(
|
||||
(row) => `<li>
|
||||
<span>${htmlEscape(row.label)}</span>
|
||||
<span class="value-list-bar"><span style="width:${Math.max(2, Math.round((row.value / max) * 100))}%"></span></span>
|
||||
<span class="value-list-value">${htmlEscape(format(row))}</span>
|
||||
</li>`,
|
||||
)
|
||||
.join('')}
|
||||
</ul>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical columns with the value label above and the year label below. `fullYearLabel`
|
||||
* prints the whole year (theme axis); the institution keeps the 2-digit label.
|
||||
*/
|
||||
const columnChart = ({
|
||||
rows,
|
||||
width = 330,
|
||||
height = 132,
|
||||
format = (row) => String(row.count),
|
||||
fullYearLabel = false,
|
||||
}) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value ?? row.count))
|
||||
const baseY = height - 18
|
||||
const plotHeight = baseY - 16
|
||||
const columnWidth = (width - 8) / Math.max(1, rows.length)
|
||||
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
|
||||
${rows
|
||||
.map((row, index) => {
|
||||
const value = row.value ?? row.count
|
||||
const barHeight = Math.max(2, Math.round((value / max) * plotHeight))
|
||||
const x = 4 + index * columnWidth
|
||||
const y = baseY - barHeight
|
||||
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${barHeight}" fill="${row.color ?? CHART_COLORS.default}" />
|
||||
<text x="${x + columnWidth / 2}" y="${y - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
|
||||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/** Vertical columns stacked by phase (money by year), total label above. */
|
||||
const stackedColumnChart = ({ rows, width = 330, height = 132, format, fullYearLabel = false }) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value))
|
||||
const baseY = height - 18
|
||||
const plotHeight = baseY - 16
|
||||
const columnWidth = (width - 8) / Math.max(1, rows.length)
|
||||
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
|
||||
${rows
|
||||
.map((row, index) => {
|
||||
const x = 4 + index * columnWidth
|
||||
let y = baseY
|
||||
const bars = row.segments
|
||||
.map((segment) => {
|
||||
const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight))
|
||||
y -= segmentHeight
|
||||
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${segmentHeight}" fill="${CHART_COLORS[segment.key] ?? CHART_COLORS.default}" />`
|
||||
})
|
||||
.join('')
|
||||
const top = baseY - Math.round((row.value / max) * plotHeight)
|
||||
return `${bars}
|
||||
<text x="${x + columnWidth / 2}" y="${top - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
|
||||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
const identityBadgeList = (report) =>
|
||||
report.meta.identityBadges
|
||||
.map((badge) => `<span class="identity-badge">${htmlEscape(badge)}</span>`)
|
||||
|
|
|
|||
242
scripts/lib/graficosInstagramRender.mjs
Normal file
242
scripts/lib/graficosInstagramRender.mjs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* Instagram chart renderer (C191): composes the approved hi-fi template
|
||||
* (`docs/plans/graficos-dados-instagram-ui-design.html`) into a single canvas
|
||||
* HTML document, class-by-class, with inline CSS (the pipeline has no bundler
|
||||
* and the render must never need the network). One red highlight, honest scale,
|
||||
* headline-manchete, source and brand inside the image.
|
||||
*
|
||||
* The page is authored at the exact export size; `buildPdf.screenshotHtmlPng`
|
||||
* only sets the viewport and clips the canvas.
|
||||
*/
|
||||
|
||||
import { RELATION_LABEL, SIZES } from './chartData.mjs'
|
||||
import { lineChart, proportionalPercent } from './chartPrimitives.mjs'
|
||||
import { htmlEscape } from './reportText.mjs'
|
||||
|
||||
/** Solla palette (verbatim from the intention). */
|
||||
export const SOLLA_PALETTE = {
|
||||
highlight: '#c51414',
|
||||
brandDark: '#ae1603',
|
||||
ptRed: '#a21c1c',
|
||||
ptYellow: '#ffe607',
|
||||
ink: '#1c1917',
|
||||
hairline: '#e7e5e4',
|
||||
paper: '#faf9f7',
|
||||
bar: '#d6d3d1',
|
||||
barStrong: '#a8a29e',
|
||||
axis: '#78716c',
|
||||
label: '#57534e',
|
||||
}
|
||||
|
||||
const FONT_STACK =
|
||||
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
|
||||
const formatValue = (value) =>
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value)
|
||||
|
||||
const sortedByValue = (rows) => [...rows].sort((a, b) => b.value - a.value)
|
||||
|
||||
const highlightLabel = (spec) => spec.highlight ?? sortedByValue(spec.rows)[0]?.label ?? null
|
||||
|
||||
const rankingBody = (spec) => {
|
||||
const rows = sortedByValue(spec.rows)
|
||||
const max = Math.max(...rows.map((row) => row.value))
|
||||
const winner = highlightLabel(spec)
|
||||
return `<div class="rank">
|
||||
${rows
|
||||
.map((row) => {
|
||||
const percent = proportionalPercent(row.value, max)
|
||||
const isHighlight = row.label === winner
|
||||
return `<div class="rank-row">
|
||||
<span class="rank-label">${htmlEscape(row.label)}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar${isHighlight ? ' highlight' : ''}${percent === 0 ? ' zero' : ''}" style="width:${percent}%">${formatValue(row.value)}</div>
|
||||
</div>
|
||||
</div>`
|
||||
})
|
||||
.join('')}
|
||||
</div>`
|
||||
}
|
||||
|
||||
const columnBody = (spec) => {
|
||||
const rows = spec.rows
|
||||
const max = Math.max(...rows.map((row) => row.value))
|
||||
const winner = highlightLabel(spec)
|
||||
return `<div class="columns" style="grid-template-columns:repeat(${rows.length}, 1fr)">
|
||||
${rows
|
||||
.map((row) => {
|
||||
const isHighlight = row.label === winner
|
||||
return `<div class="column-cell">
|
||||
<span class="column-value">${formatValue(row.value)}</span>
|
||||
<div class="column${isHighlight ? ' highlight' : ''}" style="height:${proportionalPercent(row.value, max)}%"></div>
|
||||
<span class="column-label">${htmlEscape(row.label)}</span>
|
||||
</div>`
|
||||
})
|
||||
.join('')}
|
||||
</div>`
|
||||
}
|
||||
|
||||
const anchorBody = (spec) => {
|
||||
const row = spec.rows[0]
|
||||
const copy = row.label || spec.subtitle || ''
|
||||
return `<div class="anchor">
|
||||
<div class="anchor-number">${formatValue(row.value)}</div>
|
||||
${copy ? `<p class="anchor-copy">${htmlEscape(copy)}</p>` : ''}
|
||||
</div>`
|
||||
}
|
||||
|
||||
const plotBody = (spec) => {
|
||||
if (spec.chartType === 'anchor') return anchorBody(spec)
|
||||
if (spec.chartType === 'column') return columnBody(spec)
|
||||
if (spec.chartType === 'line') {
|
||||
return `<div class="line-plot">${lineChart({
|
||||
rows: spec.rows,
|
||||
width: 880,
|
||||
height: 430,
|
||||
format: (row) => formatValue(row.value),
|
||||
stroke: SOLLA_PALETTE.axis,
|
||||
highlight: SOLLA_PALETTE.highlight,
|
||||
paper: SOLLA_PALETTE.paper,
|
||||
ink: SOLLA_PALETTE.ink,
|
||||
grid: SOLLA_PALETTE.barStrong,
|
||||
})}</div>`
|
||||
}
|
||||
return rankingBody(spec)
|
||||
}
|
||||
|
||||
const brandLockup = () => `<div class="brand">
|
||||
<span class="brand-signal"></span>
|
||||
<div>
|
||||
<div class="brand-name">JORGE SOLLA</div>
|
||||
<div class="brand-office">MANDATO DEPUTADO FEDERAL</div>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
const footer = (spec) => `<footer class="footer">
|
||||
<p class="source"><strong>Fonte:</strong> ${htmlEscape(spec.source)}${spec.note ? `<br />Nota: ${htmlEscape(spec.note)}` : ''}</p>
|
||||
${brandLockup()}
|
||||
</footer>`
|
||||
|
||||
const CSS = `* { box-sizing: border-box; }
|
||||
html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
|
||||
.canvas {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: ${SOLLA_PALETTE.ink};
|
||||
background: ${SOLLA_PALETTE.paper};
|
||||
font-family: ${FONT_STACK};
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.inner { position: absolute; inset: 0; display: flex; flex-direction: column; }
|
||||
.top-rule { width: 64px; height: 10px; background: ${SOLLA_PALETTE.highlight}; }
|
||||
.context { margin: 0; color: ${SOLLA_PALETTE.brandDark}; font-size: 30px; line-height: 1.2; font-weight: 750; }
|
||||
.headline { margin: 0; font-weight: 800; line-height: 1.03; letter-spacing: -0.045em; }
|
||||
.subtitle { margin: 0; color: ${SOLLA_PALETTE.label}; font-weight: 450; line-height: 1.35; }
|
||||
.plot { flex: 1; min-height: 0; }
|
||||
.rank { display: grid; align-content: start; }
|
||||
.rank-row { display: grid; grid-template-columns: 250px 1fr; align-items: center; gap: 22px; }
|
||||
.rank-label { font-size: 31px; line-height: 1.08; font-weight: 650; }
|
||||
.bar-track { position: relative; height: 53px; border-left: 2px solid ${SOLLA_PALETTE.axis}; }
|
||||
.bar {
|
||||
display: flex; align-items: center; justify-content: flex-end; height: 100%;
|
||||
min-width: 94px; padding-right: 16px; color: #292524; background: ${SOLLA_PALETTE.bar};
|
||||
font-size: 31px; line-height: 1; font-weight: 800; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bar.highlight { color: #fff; background: ${SOLLA_PALETTE.highlight}; }
|
||||
.bar.zero { min-width: 0; padding: 0 0 0 8px; justify-content: flex-start; }
|
||||
.columns { display: grid; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; }
|
||||
.column-cell { position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
|
||||
.column-value { margin-bottom: 10px; text-align: center; font-size: 31px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.column { background: ${SOLLA_PALETTE.bar}; min-height: 6px; }
|
||||
.column.highlight { background: ${SOLLA_PALETTE.highlight}; }
|
||||
.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; }
|
||||
.line-plot { height: 100%; display: flex; align-items: center; }
|
||||
.line-plot svg { width: 100%; height: auto; }
|
||||
.anchor { padding-top: 20px; }
|
||||
.anchor-number { color: ${SOLLA_PALETTE.highlight}; font-size: 300px; line-height: 0.86; letter-spacing: -0.065em; font-weight: 900; font-variant-numeric: tabular-nums; }
|
||||
.anchor-copy { max-width: 760px; margin: 40px 0 0; font-size: 52px; line-height: 1.12; font-weight: 750; }
|
||||
.footer {
|
||||
margin-top: auto; padding-top: 25px; border-top: 2px solid ${SOLLA_PALETTE.hairline};
|
||||
display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 30px;
|
||||
}
|
||||
.source { margin: 0; color: ${SOLLA_PALETTE.label}; font-size: 27px; line-height: 1.32; }
|
||||
.source strong { color: ${SOLLA_PALETTE.ink}; font-weight: 750; }
|
||||
.brand { display: flex; align-items: center; gap: 13px; justify-content: flex-end; }
|
||||
.brand-signal { width: 12px; height: 48px; background: ${SOLLA_PALETTE.ptYellow}; }
|
||||
.brand-name { color: ${SOLLA_PALETTE.brandDark}; font-size: 29px; line-height: 0.88; font-weight: 900; letter-spacing: -0.045em; }
|
||||
.brand-office { margin-top: 7px; color: ${SOLLA_PALETTE.label}; font-size: 17px; line-height: 1; font-weight: 750; letter-spacing: 0.04em; }`
|
||||
|
||||
/** Per-size vertical rhythm of the template (feed 4:5, square, stories 9:16). */
|
||||
const layoutFor = (size) => {
|
||||
if (size === 'story') {
|
||||
const { safeTop, safeBottom } = SIZES.story
|
||||
return {
|
||||
// The content clears the Stories safe bands, plus the template's margin.
|
||||
padding: `${safeTop + 56}px 84px ${safeBottom + 60}px`,
|
||||
ruleGap: 34,
|
||||
gapAfterContext: 18,
|
||||
headline: 78,
|
||||
subtitle: 32,
|
||||
plotGap: 78,
|
||||
}
|
||||
}
|
||||
if (size === 'square') {
|
||||
return {
|
||||
padding: '64px 84px 56px',
|
||||
ruleGap: 28,
|
||||
gapAfterContext: 16,
|
||||
headline: 62,
|
||||
subtitle: 30,
|
||||
plotGap: 44,
|
||||
}
|
||||
}
|
||||
return {
|
||||
padding: '76px 84px 58px',
|
||||
ruleGap: 34,
|
||||
gapAfterContext: 18,
|
||||
headline: 67,
|
||||
subtitle: 31,
|
||||
plotGap: 54,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full HTML document of the chart, at the exact export size. `spec` must have
|
||||
* passed `validateSpec` (the entry does it before rendering).
|
||||
*/
|
||||
export const renderChartHtml = (spec) => {
|
||||
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
|
||||
const canvas = SIZES[sizeKey]
|
||||
const layout = layoutFor(sizeKey)
|
||||
const kicker = RELATION_LABEL[spec.chartType] ?? 'Gráfico'
|
||||
const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label
|
||||
const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle
|
||||
return `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>${CSS}</style>
|
||||
<style>
|
||||
.canvas { width: ${canvas.width}px; height: ${canvas.height}px; }
|
||||
.inner { padding: ${layout.padding}; }
|
||||
.top-rule { margin-bottom: ${layout.ruleGap}px; }
|
||||
.context { margin-bottom: ${layout.gapAfterContext}px; }
|
||||
.headline { font-size: ${layout.headline}px; max-width: ${canvas.width - 200}px; }
|
||||
.subtitle { margin-top: ${Math.round(layout.gapAfterContext * 1.28)}px; font-size: ${layout.subtitle}px; max-width: ${canvas.width - 260}px; }
|
||||
.plot { margin-top: ${layout.plotGap}px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="canvas" 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="plot">${plotBody(spec)}</div>
|
||||
${footer(spec)}
|
||||
</div>
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
|
@ -47,6 +47,8 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/camaraFetch.mjs',
|
||||
'scripts/lib/camaraSpeeches.mjs',
|
||||
'scripts/lib/changelog.mjs',
|
||||
'scripts/lib/chartData.mjs',
|
||||
'scripts/lib/chartPrimitives.mjs',
|
||||
'scripts/lib/cityReportBlocks.mjs',
|
||||
'scripts/lib/cityReportDatabase.mjs',
|
||||
'scripts/lib/cityReportFormat.mjs',
|
||||
|
|
@ -71,6 +73,7 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/github-api.mjs',
|
||||
'scripts/lib/github-branch-protection.mjs',
|
||||
'scripts/lib/github-pr-flow.mjs',
|
||||
'scripts/lib/graficosInstagramRender.mjs',
|
||||
'scripts/lib/imageFill.mjs',
|
||||
'scripts/lib/imageResize.mjs',
|
||||
'scripts/lib/issues-panel.mjs',
|
||||
|
|
|
|||
|
|
@ -43,3 +43,57 @@ describe('emitHtmlPairPdf', () => {
|
|||
expect(typeof emitHtmlPairPdf).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { screenshotHtmlPng } from '../../scripts/lib/buildPdf.mjs'
|
||||
|
||||
// C191: the PNG path is additive to the Chromium owner. The guard is on the
|
||||
// critical path (a >8 MB export must fail closed), so it is pinned with a fake
|
||||
// browser — no real Chromium launch in the unit suite.
|
||||
|
||||
const fakeBrowser = (calls: string[]) => ({
|
||||
newPage: async () => ({
|
||||
setViewportSize: async (size: { width: number; height: number }) =>
|
||||
calls.push(`viewport:${size.width}x${size.height}`),
|
||||
setContent: async () => calls.push('content'),
|
||||
evaluate: async () => calls.push('fonts'),
|
||||
screenshot: async (options: { clip: { width: number; height: number } }) =>
|
||||
calls.push(`shot:${options.clip.width}x${options.clip.height}`),
|
||||
close: async () => calls.push('close'),
|
||||
}),
|
||||
})
|
||||
|
||||
describe('screenshotHtmlPng', () => {
|
||||
it('sets the exact viewport, clips the canvas and closes the page', async () => {
|
||||
const calls: string[] = []
|
||||
const outPath = join(await mkdtemp(join(tmpdir(), 'c191-')), 'out.png')
|
||||
await writeFile(outPath, 'x')
|
||||
const result = await screenshotHtmlPng(fakeBrowser(calls), {
|
||||
html: '<html></html>',
|
||||
width: 1080,
|
||||
height: 1350,
|
||||
outPath,
|
||||
})
|
||||
expect(result.size).toBe(1)
|
||||
expect(calls).toContain('viewport:1080x1350')
|
||||
expect(calls).toContain('shot:1080x1350')
|
||||
expect(calls.at(-1)).toBe('close')
|
||||
})
|
||||
|
||||
it('fails closed when the PNG exceeds maxBytes', async () => {
|
||||
const outPath = join(await mkdtemp(join(tmpdir(), 'c191-')), 'big.png')
|
||||
await writeFile(outPath, 'x'.repeat(10))
|
||||
await expect(
|
||||
screenshotHtmlPng(fakeBrowser([]), {
|
||||
html: '',
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
outPath,
|
||||
maxBytes: 4,
|
||||
}),
|
||||
).rejects.toThrow(/> 4/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
209
tests/unit/chartData.unit.spec.ts
Normal file
209
tests/unit/chartData.unit.spec.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CHART_TYPES,
|
||||
MAX_POINTS,
|
||||
MAX_POINTS_STORY,
|
||||
RELATION_LABEL,
|
||||
SIZES,
|
||||
classifyRelation,
|
||||
isTemporalLabel,
|
||||
parseInput,
|
||||
parseNumber,
|
||||
validateSpec,
|
||||
} from '../../scripts/lib/chartData.mjs'
|
||||
|
||||
describe('parseNumber — pt-BR/en amounts, never invents a zero', () => {
|
||||
it('reads thousands and decimals', () => {
|
||||
expect(parseNumber('1.234,56')).toBe(1234.56)
|
||||
expect(parseNumber('1,5')).toBe(1.5)
|
||||
expect(parseNumber('1,234.56')).toBe(1234.56)
|
||||
expect(parseNumber('R$ 84')).toBe(84)
|
||||
expect(parseNumber('12%')).toBe(12)
|
||||
expect(parseNumber(-3)).toBe(-3)
|
||||
})
|
||||
|
||||
it('returns null for non-numeric input', () => {
|
||||
expect(parseNumber('oitenta')).toBeNull()
|
||||
expect(parseNumber('')).toBeNull()
|
||||
expect(parseNumber(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInput — the data as it came', () => {
|
||||
it('reads a free text of label<space>value pairs', () => {
|
||||
const dataset = parseInput({ text: 'Ilhéus 84\nItabuna 68\n', format: 'txt' })
|
||||
expect(dataset.rows).toEqual([
|
||||
{ label: 'Ilhéus', value: 84 },
|
||||
{ label: 'Itabuna', value: 68 },
|
||||
])
|
||||
expect(dataset.issues).toEqual([])
|
||||
})
|
||||
|
||||
it('reads colon-separated pairs and labels with spaces', () => {
|
||||
const dataset = parseInput({ text: 'Votos em Ilhéus: 84\nVotos em Itabuna: 68', format: 'txt' })
|
||||
expect(dataset.rows[0]).toEqual({ label: 'Votos em Ilhéus', value: 84 })
|
||||
})
|
||||
|
||||
it('reads a GFM markdown table and drops the header', () => {
|
||||
const dataset = parseInput({
|
||||
text: '| Cidade | Votos |\n| --- | --- |\n| Ilhéus | 84 |\n| Itabuna | 68 |',
|
||||
format: 'md',
|
||||
})
|
||||
expect(dataset.format).toBe('md')
|
||||
expect(dataset.rows).toEqual([
|
||||
{ label: 'Ilhéus', value: 84 },
|
||||
{ label: 'Itabuna', value: 68 },
|
||||
])
|
||||
})
|
||||
|
||||
it('reads CSV with a header', () => {
|
||||
const dataset = parseInput({ text: 'cidade,votos\nIlhéus,84\nItabuna,68\n', format: 'csv' })
|
||||
expect(dataset.rows).toEqual([
|
||||
{ label: 'Ilhéus', value: 84 },
|
||||
{ label: 'Itabuna', value: 68 },
|
||||
])
|
||||
})
|
||||
|
||||
it('flags a non-numeric value instead of dropping it silently', () => {
|
||||
const dataset = parseInput({ text: 'Ilhéus 84\nItabuna ???\n', format: 'txt' })
|
||||
expect(dataset.rows).toHaveLength(1)
|
||||
expect(dataset.issues.join(' ')).toContain('Itabuna')
|
||||
})
|
||||
|
||||
it('does not mistake a first data row with a missing value for a header', () => {
|
||||
const dataset = parseInput({ text: 'Ilhéus n/d\nItabuna 68\n', format: 'txt' })
|
||||
expect(dataset.rows).toEqual([{ label: 'Itabuna', value: 68 }])
|
||||
expect(dataset.issues.join(' ')).toContain('Ilhéus')
|
||||
})
|
||||
|
||||
it('flags repeated labels', () => {
|
||||
const dataset = parseInput({ text: 'A 1\nA 2\n', format: 'txt' })
|
||||
expect(dataset.issues.join(' ')).toContain('repetido')
|
||||
})
|
||||
|
||||
it('reads a bare number as an unlabeled single measure', () => {
|
||||
const dataset = parseInput({ text: '72', format: 'txt' })
|
||||
expect(dataset.rows).toEqual([{ label: '', value: 72 }])
|
||||
expect(dataset.issues).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRelation — the relation of the data picks the type', () => {
|
||||
it('single measure is an anchor', () => {
|
||||
expect(classifyRelation([{ label: '', value: 72 }])).toBe('anchor')
|
||||
})
|
||||
|
||||
it('few temporal periods are columns, many are a line', () => {
|
||||
expect(
|
||||
classifyRelation([
|
||||
{ label: '2020', value: 1 },
|
||||
{ label: '2021', value: 2 },
|
||||
]),
|
||||
).toBe('column')
|
||||
expect(
|
||||
classifyRelation(
|
||||
['2018', '2019', '2020', '2021', '2022', '2023', '2024'].map((label) => ({
|
||||
label,
|
||||
value: 1,
|
||||
})),
|
||||
),
|
||||
).toBe('line')
|
||||
})
|
||||
|
||||
it('non-temporal categories are a ranking (bar)', () => {
|
||||
expect(
|
||||
classifyRelation([
|
||||
{ label: 'Ilhéus', value: 1 },
|
||||
{ label: 'Itabuna', value: 2 },
|
||||
]),
|
||||
).toBe('bar')
|
||||
})
|
||||
|
||||
it('honors a forced type and refuses an unknown one', () => {
|
||||
expect(classifyRelation([{ label: 'A', value: 1 }], 'bar')).toBe('bar')
|
||||
expect(classifyRelation([{ label: 'A', value: 1 }], 'pie')).toBe('pie')
|
||||
expect(() => classifyRelation([{ label: 'A', value: 1 }], 'donut')).toThrow(/desconhecido/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTemporalLabel', () => {
|
||||
it('detects years, months, quarters and P-periods', () => {
|
||||
for (const label of ['2024', '2024-06', '06/2024', 'jan/24', 'Q1', 'T3', 'P2']) {
|
||||
expect(isTemporalLabel(label), label).toBe(true)
|
||||
}
|
||||
expect(isTemporalLabel('Ilhéus')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSpec — fail-closed guardrails', () => {
|
||||
const base = { chartType: 'bar', rows: [{ label: 'A', value: 1 }], headline: 'Takeaway' }
|
||||
|
||||
it('accepts a valid spec', () => {
|
||||
expect(validateSpec(base)).toBe(base)
|
||||
})
|
||||
|
||||
it('refuses pizza with an actionable message', () => {
|
||||
expect(() => validateSpec({ ...base, chartType: 'pie' })).toThrow(/barras horizontais/)
|
||||
})
|
||||
|
||||
it(`refuses more than ${MAX_POINTS} points`, () => {
|
||||
const rows = Array.from({ length: MAX_POINTS + 1 }, (_v, index) => ({
|
||||
label: `A${index}`,
|
||||
value: 1,
|
||||
}))
|
||||
expect(() => validateSpec({ ...base, rows })).toThrow(/resuma/)
|
||||
})
|
||||
|
||||
it('caps a story ranking at five points (approved vertical rhythm)', () => {
|
||||
const rows = Array.from({ length: MAX_POINTS_STORY + 1 }, (_v, index) => ({
|
||||
label: `A${index}`,
|
||||
value: index + 1,
|
||||
}))
|
||||
expect(() => validateSpec({ ...base, size: 'feed', rows })).not.toThrow()
|
||||
expect(() => validateSpec({ ...base, size: 'story', rows })).toThrow(/cinco|5/)
|
||||
})
|
||||
|
||||
it('refuses negative values (bars come from zero)', () => {
|
||||
expect(() => validateSpec({ ...base, rows: [{ label: 'A', value: -1 }] })).toThrow(/zero/)
|
||||
})
|
||||
|
||||
it('refuses a missing headline', () => {
|
||||
expect(() => validateSpec({ ...base, headline: '' })).toThrow(/manchete/)
|
||||
})
|
||||
|
||||
it('refuses an unknown size', () => {
|
||||
expect(() => validateSpec({ ...base, size: 'huge' })).toThrow(/tamanho inválido/)
|
||||
})
|
||||
|
||||
it('refuses a highlight that is not in the data', () => {
|
||||
expect(() => validateSpec({ ...base, highlight: 'Sumiu' })).toThrow(/destaque/)
|
||||
})
|
||||
|
||||
it('refuses an anchor with more than one value', () => {
|
||||
expect(() =>
|
||||
validateSpec({
|
||||
...base,
|
||||
chartType: 'anchor',
|
||||
rows: [
|
||||
{ label: 'A', value: 1 },
|
||||
{ label: 'B', value: 2 },
|
||||
],
|
||||
}),
|
||||
).toThrow(/âncora/)
|
||||
})
|
||||
|
||||
it('refuses an unlabeled bar row', () => {
|
||||
expect(() => validateSpec({ ...base, rows: [{ label: '', value: 1 }] })).toThrow(/rótulo/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contracts', () => {
|
||||
it('declares the four v1 types, labels and sizes', () => {
|
||||
expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor'])
|
||||
expect(RELATION_LABEL.line).toBe('Série de tempo')
|
||||
expect(SIZES.story).toMatchObject({ width: 1080, height: 1920, safeTop: 250, safeBottom: 250 })
|
||||
expect(SIZES.feed).toMatchObject({ width: 1080, height: 1350 })
|
||||
expect(SIZES.square).toMatchObject({ width: 1080, height: 1080 })
|
||||
})
|
||||
})
|
||||
142
tests/unit/chartPrimitives.unit.spec.ts
Normal file
142
tests/unit/chartPrimitives.unit.spec.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CHART_COLORS,
|
||||
barChart,
|
||||
columnChart,
|
||||
lineChart,
|
||||
proportionalPercent,
|
||||
stackedColumnChart,
|
||||
valueList,
|
||||
} from '../../scripts/lib/chartPrimitives.mjs'
|
||||
|
||||
// C191: the chart primitives were extracted from dossieRender.mjs so the
|
||||
// Instagram generator (second consumer) never grows a twin. These snapshots
|
||||
// freeze the dossier emission byte-for-byte — a change here is a change to the
|
||||
// published dossiê, not a cosmetic tweak.
|
||||
|
||||
describe('chartPrimitives — dossier emitters (byte-identical)', () => {
|
||||
it('barChart', () => {
|
||||
expect(
|
||||
barChart({
|
||||
rows: [
|
||||
{ label: 'A', count: 3 },
|
||||
{ label: 'B', count: 1 },
|
||||
],
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"<svg class="chart" viewBox="0 0 330 42" role="img">
|
||||
<text x="0" y="14" class="chart-label">A</text>
|
||||
<rect x="118" y="5" width="186" height="12" rx="1" fill="#315c75" />
|
||||
<text x="308" y="14" class="chart-value">3</text><text x="0" y="33" class="chart-label">B</text>
|
||||
<rect x="118" y="24" width="62" height="12" rx="1" fill="#315c75" />
|
||||
<text x="184" y="33" class="chart-value">1</text>
|
||||
</svg>"
|
||||
`)
|
||||
})
|
||||
|
||||
it('columnChart', () => {
|
||||
expect(
|
||||
columnChart({
|
||||
rows: [
|
||||
{ key: '2020', value: 2 },
|
||||
{ key: '2021', value: 4 },
|
||||
],
|
||||
format: (row: { value: number }) => String(row.value),
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"<svg class="chart" viewBox="0 0 330 152" role="img">
|
||||
<rect x="6" y="65" width="156" height="49" fill="#315c75" />
|
||||
<text x="84.5" y="62" class="chart-value" text-anchor="middle">2</text>
|
||||
<text x="84.5" y="144" class="chart-label" text-anchor="middle">20</text><rect x="167" y="16" width="156" height="98" fill="#315c75" />
|
||||
<text x="245.5" y="13" class="chart-value" text-anchor="middle">4</text>
|
||||
<text x="245.5" y="144" class="chart-label" text-anchor="middle">21</text>
|
||||
</svg>"
|
||||
`)
|
||||
})
|
||||
|
||||
it('stackedColumnChart', () => {
|
||||
expect(
|
||||
stackedColumnChart({
|
||||
rows: [
|
||||
{
|
||||
key: '2020',
|
||||
value: 3,
|
||||
segments: [
|
||||
{ key: 'pago', amount: 2 },
|
||||
{ key: 'empenhado', amount: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
format: (row: { value: number }) => String(row.value),
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"<svg class="chart" viewBox="0 0 330 152" role="img">
|
||||
<rect x="6" y="49" width="317" height="65" fill="#285338" /><rect x="6" y="16" width="317" height="33" fill="#6b4918" />
|
||||
<text x="165" y="13" class="chart-value" text-anchor="middle">3</text>
|
||||
<text x="165" y="144" class="chart-label" text-anchor="middle">20</text>
|
||||
</svg>"
|
||||
`)
|
||||
})
|
||||
|
||||
it('valueList', () => {
|
||||
expect(
|
||||
valueList({
|
||||
rows: [
|
||||
{ label: 'A', value: 2 },
|
||||
{ label: 'B', value: 1 },
|
||||
],
|
||||
format: (row: { value: number }) => String(row.value),
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"<ul class="value-list">
|
||||
<li>
|
||||
<span>A</span>
|
||||
<span class="value-list-bar"><span style="width:100%"></span></span>
|
||||
<span class="value-list-value">2</span>
|
||||
</li><li>
|
||||
<span>B</span>
|
||||
<span class="value-list-bar"><span style="width:50%"></span></span>
|
||||
<span class="value-list-value">1</span>
|
||||
</li>
|
||||
</ul>"
|
||||
`)
|
||||
})
|
||||
|
||||
it('escapes untrusted labels', () => {
|
||||
expect(barChart({ rows: [{ label: '<b>x</b>', count: 1 }] })).toContain('<b>')
|
||||
})
|
||||
|
||||
it('keeps the dossier domain palette as the default', () => {
|
||||
expect(CHART_COLORS.default).toBe('#315c75')
|
||||
expect(CHART_COLORS.pago).toBe('#285338')
|
||||
})
|
||||
})
|
||||
|
||||
describe('proportionalPercent — honest scale, zero stays zero', () => {
|
||||
it('maps value/max to 0..100 and floors a present value at 2%', () => {
|
||||
expect(proportionalPercent(84, 84)).toBe(100)
|
||||
expect(proportionalPercent(42, 84)).toBe(50)
|
||||
expect(proportionalPercent(1, 84)).toBe(2)
|
||||
expect(proportionalPercent(0, 84)).toBe(0)
|
||||
expect(proportionalPercent(5, 0)).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lineChart — series with one highlighted end', () => {
|
||||
const rows = [
|
||||
{ label: '2020', value: 2 },
|
||||
{ label: '2021', value: 4 },
|
||||
]
|
||||
const svg = lineChart({ rows, format: (row: { value: number }) => String(row.value) })
|
||||
|
||||
it('draws every label and the last value', () => {
|
||||
expect(svg).toContain('>2020<')
|
||||
expect(svg).toContain('>2021<')
|
||||
expect(svg).toContain('>4<')
|
||||
})
|
||||
|
||||
it('marks the last point with the Solla red', () => {
|
||||
expect(svg).toContain('fill="#c51414"')
|
||||
})
|
||||
})
|
||||
54
tests/unit/graficosDadosSkill.unit.spec.ts
Normal file
54
tests/unit/graficosDadosSkill.unit.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// C191: the skill and the command are prompt text, so the product contract
|
||||
// lives in the SKILL.md prose. This guard fails the build if the flow, the
|
||||
// guardrails, the gitignored artifacts or the CLI contract are dropped.
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
|
||||
const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8')
|
||||
|
||||
const skill = read('.agents/skills/graficos-dados/SKILL.md')
|
||||
const command = read('.opencode/commands/graficos-dados.md')
|
||||
|
||||
describe('skill /graficos-dados documents the flow', () => {
|
||||
it('declares when to use it and the canonical builder CLI', () => {
|
||||
expect(skill).toContain('## Quando usar')
|
||||
expect(skill).toContain('node scripts/build-chart-from-data.mjs')
|
||||
expect(skill).toContain('--inspect')
|
||||
expect(skill).toContain('--spec=')
|
||||
})
|
||||
|
||||
it('pins the launch command and the gitignored artifacts', () => {
|
||||
expect(skill).toContain('/graficos-dados')
|
||||
expect(skill).toContain('data/graficos-instagram/')
|
||||
expect(skill).toContain('docs/research/graficos-instagram/')
|
||||
expect(skill).toContain('gitignored')
|
||||
})
|
||||
|
||||
it('pins the three output sizes', () => {
|
||||
expect(skill).toContain('1080×1350')
|
||||
expect(skill).toContain('1080×1080')
|
||||
expect(skill).toContain('1080×1920')
|
||||
})
|
||||
|
||||
it('pins the fail-closed guardrails', () => {
|
||||
expect(skill).toMatch(/partem do zero/i)
|
||||
expect(skill).toMatch(/7 pontos/)
|
||||
expect(skill).toMatch(/pizza/i)
|
||||
expect(skill).toMatch(/pergunta, nunca completa/i)
|
||||
expect(skill).toContain('--source')
|
||||
expect(skill).toMatch(/fail-closed/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('command /graficos-dados couples to the skill', () => {
|
||||
it('references the canonical skill file and passes arguments', () => {
|
||||
expect(existsSync(resolve(repoRoot, '.opencode/commands/graficos-dados.md'))).toBe(true)
|
||||
expect(command).toContain('`graficos-dados`')
|
||||
expect(command).toContain('$ARGUMENTS')
|
||||
expect(command).toContain('.agents/skills/graficos-dados/SKILL.md')
|
||||
})
|
||||
})
|
||||
108
tests/unit/graficosInstagramRender.unit.spec.ts
Normal file
108
tests/unit/graficosInstagramRender.unit.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { renderChartHtml, SOLLA_PALETTE } from '../../scripts/lib/graficosInstagramRender.mjs'
|
||||
|
||||
// C191: the renderer ports the approved hi-fi template
|
||||
// (docs/plans/graficos-dados-instagram-ui-design.html) class-by-class. These
|
||||
// pins freeze the export canvas size, the palette, the minimum typography and
|
||||
// the safe-band adaptation for Stories.
|
||||
|
||||
const rows = [
|
||||
{ label: 'Território A', value: 84 },
|
||||
{ label: 'Território B', value: 68 },
|
||||
{ label: 'Território C', value: 53 },
|
||||
]
|
||||
|
||||
const spec = (overrides = {}) => ({
|
||||
chartType: 'bar',
|
||||
size: 'feed',
|
||||
headline: 'Um território concentra o maior resultado',
|
||||
subtitle: 'Valores de 2026',
|
||||
source: 'TSE 2026',
|
||||
highlight: 'Território B',
|
||||
rows,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('renderChartHtml — canvas and template', () => {
|
||||
it('authors the feed canvas at exactly 1080×1350', () => {
|
||||
const html = renderChartHtml(spec())
|
||||
expect(html).toContain('width: 1080px; height: 1350px')
|
||||
})
|
||||
|
||||
it('adapts to square and to Stories safe bands', () => {
|
||||
expect(renderChartHtml(spec({ size: 'square' }))).toContain('width: 1080px; height: 1080px')
|
||||
const story = renderChartHtml(spec({ size: 'story' }))
|
||||
expect(story).toContain('width: 1080px; height: 1920px')
|
||||
expect(story).toContain('padding: 306px 84px 310px')
|
||||
})
|
||||
|
||||
it('keeps the headline at/above the minimum size', () => {
|
||||
expect(renderChartHtml(spec())).toMatch(/\.headline \{ font-size: 67px/)
|
||||
expect(renderChartHtml(spec({ size: 'story' }))).toMatch(/\.headline \{ font-size: 78px/)
|
||||
})
|
||||
|
||||
it('carries the source and the provisional brand lockup inside the image', () => {
|
||||
const html = renderChartHtml(spec())
|
||||
expect(html).toContain('TSE 2026')
|
||||
expect(html).toContain('JORGE SOLLA')
|
||||
expect(html).toContain('MANDATO DEPUTADO FEDERAL')
|
||||
})
|
||||
|
||||
it('marks exactly one row with the Solla highlight', () => {
|
||||
const html = renderChartHtml(spec())
|
||||
expect((html.match(/bar highlight/g) ?? []).length).toBe(1)
|
||||
expect(html).toContain('Território B')
|
||||
expect(html).toContain('#c51414')
|
||||
})
|
||||
|
||||
it('sorts the ranking by value, descending', () => {
|
||||
const html = renderChartHtml(spec())
|
||||
expect(html.indexOf('Território A')).toBeLessThan(html.indexOf('Território B'))
|
||||
expect(html.indexOf('Território B')).toBeLessThan(html.indexOf('Território C'))
|
||||
})
|
||||
|
||||
it('renders a column chart with a zero base line', () => {
|
||||
const html = renderChartHtml(spec({ chartType: 'column' }))
|
||||
expect(html).toContain('columns')
|
||||
expect(html).toContain('column highlight')
|
||||
})
|
||||
|
||||
it('renders the line chart with the last point highlighted', () => {
|
||||
const html = renderChartHtml(spec({ chartType: 'line' }))
|
||||
expect(html).toContain('<polyline')
|
||||
expect(html).toContain('#c51414')
|
||||
})
|
||||
|
||||
it('renders the anchor with the value and the copy', () => {
|
||||
const html = renderChartHtml(
|
||||
spec({
|
||||
chartType: 'anchor',
|
||||
rows: [{ label: '', value: 72 }],
|
||||
subtitle: 'unidades no período',
|
||||
}),
|
||||
)
|
||||
expect(html).toContain('72')
|
||||
expect(html).toContain('unidades no período')
|
||||
expect(html).toContain('anchor-number')
|
||||
})
|
||||
|
||||
it('escapes untrusted headline and source', () => {
|
||||
const html = renderChartHtml(spec({ headline: '<script>x</script>', source: 'a & b' }))
|
||||
expect(html).not.toContain('<script>x</script>')
|
||||
expect(html).toContain('<script>')
|
||||
expect(html).toContain('a & b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SOLLA_PALETTE — the verbatim brand colors', () => {
|
||||
it('matches the intention literals', () => {
|
||||
expect(SOLLA_PALETTE).toMatchObject({
|
||||
highlight: '#c51414',
|
||||
brandDark: '#ae1603',
|
||||
ptRed: '#a21c1c',
|
||||
ptYellow: '#ffe607',
|
||||
paper: '#faf9f7',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -19,6 +19,7 @@ const commands = [
|
|||
'dossie-solla-cidade',
|
||||
'dossie-solla-instituicao',
|
||||
'dossie-solla-tema',
|
||||
'graficos-dados',
|
||||
] as const
|
||||
|
||||
describe('opencode commands couple to their skill by exact name', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue