From 8bf79ff4cc85939fcddc698b0e968eb5b5e3c69b Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 18 Sep 2026 22:50:20 -0300 Subject: [PATCH] =?UTF-8?q?feat(C191):=20linha=20de=20duas=20s=C3=A9ries?= =?UTF-8?q?=20no=20gr=C3=A1fico=20de=20Instagram=20=E2=80=94=20val=C3=AAnc?= =?UTF-8?q?ia=20bom/ruim,=20proje=C3=A7=C3=A3o=20e=20cruzamento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill, núcleo (chartData), desenho (dualLineChart/renderer), design CENA 02B certificado e testes. --- .agents/skills/graficos-dados/SKILL.md | 74 +- docs/changelog/2026-09-18-c191-duas-series.md | 1 + .../graficos-dados-instagram-ui-design.html | 671 +++++++++++++++++- scripts/build-chart-from-data.mjs | 62 +- scripts/lib/chartData.mjs | 206 +++++- scripts/lib/chartPrimitives.mjs | 355 +++++++++ scripts/lib/graficosInstagramRender.mjs | 65 +- tests/unit/chartData.unit.spec.ts | 170 +++++ tests/unit/chartPrimitives.unit.spec.ts | 154 ++++ .../unit/graficosInstagramRender.unit.spec.ts | 84 +++ 10 files changed, 1787 insertions(+), 55 deletions(-) create mode 100644 docs/changelog/2026-09-18-c191-duas-series.md diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md index 78af7498..8bac2093 100644 --- a/.agents/skills/graficos-dados/SKILL.md +++ b/.agents/skills/graficos-dados/SKILL.md @@ -37,12 +37,17 @@ O template visual é o artefato aprovado ```bash node scripts/build-chart-from-data.mjs --in= --inspect ``` - Devolve `{ rows, format, issues }` — a matriz parseada e os problemas. + Devolve `{ rows, series, format, issues }` — a matriz parseada (tabela com + 2+ colunas de medida vira `series`) 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); + - **duas séries no mesmo período → `line` multi-série**: tabela com 2 colunas + de medida e a primeira coluna temporal; `--good=`/`--bad=` marcam a valência + bom/ruim (par ou nenhum), `--projected=` marca o último período projetado e + `--crossing=` confirma a ultrapassagem que ganha anotação; - **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 @@ -55,12 +60,22 @@ O template visual é o artefato aprovado --source="TSE 2022" --type=bar --size=feed \ --highlight="Ilhéus" ``` + Duas séries no tempo (valência bom/ruim + projeção no último ponto): + ```bash + node scripts/build-chart-from-data.mjs --in= \ + --headline="Internações crescem no hospital estadual e recuam no municipal" \ + --source="Ministério da Saúde — SIH/SUS" \ + --note="2026 é projeção pela média de janeiro a junho × 2." \ + --type=line --size=feed \ + --good="Estadual" --bad="Municipal" --projected=2026 --crossing=2024 + ``` Saída default: `docs/research/graficos-instagram/--.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=`. + tamanho/`--highlight` (ou `--good`/`--bad`/`--projected`/`--crossing`) e + regenere. **Replay** sem reparse: `--spec=`. 6. **Entregue o gráfico.** A comunicação finaliza a peça. O dado não persiste. ## Contrato do `chart-spec.json` @@ -78,8 +93,32 @@ O template visual é o artefato aprovado } ``` +Na linha de 2 séries, `series` substitui `rows` e `projectedLabel` marca o +último período projetado: + +```json +{ + "chartType": "line", + "size": "feed", + "headline": "Internações crescem no hospital estadual e recuam no municipal", + "source": "Ministério da Saúde — SIH/SUS", + "projectedLabel": "2026", + "crossingLabel": "2024", + "series": [ + { "name": "Estadual", "tone": "good", "rows": [{ "label": "2017", "value": 9807 }] }, + { "name": "Municipal", "tone": "bad", "rows": [{ "label": "2017", "value": 24002 }] } + ] +} +``` + `chartType` ∈ `bar | column | line | anchor`; `highlight` é o rótulo que recebe o -único vermelho `#c51414` (default: o maior valor). +único vermelho `#c51414` (default: o maior valor). Em `series`, `tone` ∈ +`good | bad` (os dois tons ou nenhum) e as duas séries compartilham os mesmos +rótulos temporais; `rows` e `highlight` não entram nessa variante. +`projectedLabel` marca a projeção no último período e `crossingLabel` só é aceito +quando a série boa de fato ultrapassa a ruim naquele período (confirmação +textual, nunca inferência automática). Os rótulos finais trazem a variação +observada (primeiro → último ponto observado, fora a projeção). ## Guardrails (fail-closed) @@ -87,9 +126,23 @@ 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. +- **Até 7 pontos** por gráfico (barra, coluna e linha simples); a **linha de 2 + séries** comporta **até 12 pontos por série**, sempre com os mesmos rótulos + temporais alinhados. 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. +- **Valência só na linha de 2 séries**, em par (`good`/`bad`) e sempre por + palavra + seta + forma do marcador + cor — **cor nunca carrega sozinha**: + bom = vermelho `#c51414`, círculo, "↑ amplia" (o vermelho é a cor do + mandato/PT e nunca marca a perda); ruim = cinza `#78716c`, quadrado, + "↓ recua". Sem tons, a comparação é neutra: sem setas, sem palavras de + valência e **sem vermelho no plot**. +- **Projeção** (`--projected=`) só no último período das duas séries: último + segmento tracejado, marcador final vazado e nota do método; sem projeção não + há faixa, tracejado nem ressalva automática. +- **Cruzamento** (`--crossing=`) só com confirmação textual e quando a + série boa realmente ultrapassa a ruim naquele período; a anotação nunca é + inferida sozinha. - **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. @@ -109,7 +162,11 @@ O builder **recusa** em vez de desenhar algo enganoso: - **"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. +- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. Na linha de 2 + séries o teto é 12 por série. +- **Séries desalinhadas / tom ímpar / projeção fora do último ponto / + cruzamento não confirmado** → o builder pede a correção com mensagem + acionável; nunca interpola, completa nem infere ultrapassagem. - **"pizza não é gerada"** → ofereça barras horizontais. - **Número pt-BR:** `1.234,56` é lido como 1234,56; `1,5` como 1,5. **Separador único é sempre decimal:** `1.234` é lido como 1,234 e `1.500` @@ -119,7 +176,8 @@ O builder **recusa** em vez de desenhar algo enganoso: ## Referências -- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html`. +- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (inclui + a variação certificada de linha de duas séries, com valência bom/ruim). - 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. diff --git a/docs/changelog/2026-09-18-c191-duas-series.md b/docs/changelog/2026-09-18-c191-duas-series.md new file mode 100644 index 00000000..fe1f5b99 --- /dev/null +++ b/docs/changelog/2026-09-18-c191-duas-series.md @@ -0,0 +1 @@ +**C191 — Linha de duas séries no gráfico de Instagram (comparação no tempo) (2026-09-18):** a comunicação agora compara **duas séries temporais no mesmo canvas** com valência explícita e honesta, mantendo os guardrails do C191. (1) **Skill** `.agents/skills/graficos-dados/SKILL.md`: tabela temporal com 2 colunas de medida vira multi-série (`--good=`/`--bad=`/`--projected=`/`--crossing=`), teto de **12 pontos por série** (barra/coluna seguem em 7; stories 5) e a regra de valência: o vermelho `#c51414` é a cor do mandato e marca o **caminho bom** — o ruim fica neutro (`#78716c`, quadrado, "↓ recua"), nunca a cor do PT na perda; palavra + seta + forma do marcador + cor (cor nunca sozinha). (2) **Núcleo puro** `scripts/lib/chartData.mjs`: detecção de série por cabeçalho, rótulos temporais alinhados, tons em par (`good`/`bad`), projeção só no último período, cruzamento só com confirmação textual e ultrapassagem real dos valores — tudo fail-closed (nada inferido). (3) **Dono do desenho** `scripts/lib/chartPrimitives.mjs`: `dualLineChart` (escala compartilhada no zero, teto legível, gutter de rótulos diretos com variação observada, projeção tracejada com marcador vazado, anotação de cruzamento ring+guia+caixa) e `graficosInstagramRender.mjs` com kicker "Comparação no tempo" e geometria por canvas (`880×620` feed, `880×440` quadrado, `880×700` story). (4) **Design** `docs/plans/graficos-dados-instagram-ui-design.html`: CENA 02B **certificada** pelo `designer` após reprovação do dono (vermelho marcava a perda; vazio morto no feed) e duas rodadas de correção (copy da caixa de cruzamento fora dos limites; gutter de rótulos espremido). Testes `chartData`/`chartPrimitives`/`graficosInstagramRender` atualizados; `pnpm test:unit` 3.854 verdes; prettier/typecheck/lint/knip limpos. Estreia: internações hospitalares de Vitória da Conquista 2017–2026 (Estadual × Municipal, projeção 2026, cruzamento 2024). diff --git a/docs/plans/graficos-dados-instagram-ui-design.html b/docs/plans/graficos-dados-instagram-ui-design.html index 087dbb10..93564a6b 100644 --- a/docs/plans/graficos-dados-instagram-ui-design.html +++ b/docs/plans/graficos-dados-instagram-ui-design.html @@ -366,6 +366,106 @@ font-weight: 750; } + .mini-dual-line { + width: 100%; + height: 100%; + } + .mini-dual-label { + fill: var(--ink); + font-size: 7px; + font-weight: 800; + } + + .feed-canvas.dual-series .chart-headline { + max-width: 900px; + font-size: 60px; + } + .feed-canvas.dual-series .chart-subtitle { + margin-top: 18px; + font-size: 28px; + } + .feed-canvas.dual-series .source-line { + font-size: 25px; + } + .dual-line-plot { + flex: none; + width: 880px; + height: 620px; + margin-top: 28px; + } + .dual-line-plot svg { + display: block; + width: 880px; + height: 620px; + } + .dual-axis-label { + fill: #78716c; + font-size: 30px; + font-weight: 650; + } + .dual-projection-label { + fill: #78716c; + font-size: 30px; + font-weight: 700; + } + .dual-end-name { + fill: var(--ink); + font-size: 30px; + font-weight: 700; + } + .dual-end-value { + fill: var(--ink); + font-size: 34px; + font-weight: 850; + font-variant-numeric: tabular-nums; + } + .dual-end-valence { + font-size: 30px; + font-weight: 850; + } + .dual-end-change { + fill: #57534e; + font-size: 30px; + font-weight: 700; + } + .dual-crossing-guide { + stroke: var(--solla); + stroke-width: 2px; + stroke-dasharray: 8 8; + opacity: 0.45; + } + .dual-crossing-ring { + fill: var(--paper); + stroke: var(--solla); + stroke-width: 4px; + } + .dual-crossing-box { + fill: var(--paper); + stroke: #a8a29e; + stroke-width: 2px; + } + .dual-crossing-year { + fill: var(--solla); + font-size: 30px; + font-weight: 850; + } + .dual-crossing-copy { + fill: var(--ink); + font-size: 28px; + font-weight: 750; + } + .rule-code { + display: inline-flex; + padding: 2px 6px; + border: 1px solid #d6d3d1; + border-radius: 5px; + color: #44403c; + background: #fafaf9; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.4; + } + .story-stage { width: 351px; height: 624px; @@ -626,16 +726,16 @@
- Gate visual · template v1 + Design certificado · C191 Instagram feed + Stories

Gráficos claros, sóbrios e prontos para a comunicação

- A superfície desenhada é a imagem exportada — não uma tela de app. Os dados abaixo são - deliberadamente genéricos; as peças fixam hierarquia, grid, tratamento de marca e limites - do gerador. + A superfície desenhada é a imagem exportada — não uma tela de app. Os exemplos-base são + deliberadamente genéricos; a variante de duas séries usa o caso fornecido de Vitória da + Conquista. As peças fixam hierarquia, grid, tratamento de marca e limites do gerador.

@@ -767,7 +867,7 @@

CENA 02 · SISTEMA

- Quatro relações, um mesmo template + Cinco variações, um mesmo template

@@ -903,6 +1003,558 @@ Um valor grande + frase que explica.

+ +
+
+
+
Comparação no tempo
+

Estadual cresce e supera o municipal

+

Internações em Vitória da Conquista, 2017–2026.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Estadual + + 23.294 · ↑ amplia + + Municipal + + 15.948 · ↓ recua + + + 2026 · projeção + + +
+
+ Fonte: SIH/SUSJORGE SOLLA +
+
+

Linha · duas séries

+

+ Tendências, valência redundante e projeção explícita. +

+
+ + + +
+
+
+

CENA 02B · VARIAÇÃO CERTIFICADA

+

+ Feed 4:5 · linha temporal de duas séries +

+
+
+ 1080 × 1350 + Plot SVG · viewBox 880 × 620 +
+
+ +
+
+
+
+
+
+

Comparação no tempo

+

+ Internações crescem no hospital estadual e recuam no municipal +

+

+ AIH aprovadas por ano · Vitória da Conquista (BA) · 2017–2026 +

+ +
+ + + + + + 0 + + projeção + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2024 + + + Estadual + + + ultrapassa + + + + Estadual + 23.294 + ↑ amplia + +121% até 2025 + + Municipal + 15.948 + ↓ recua + −23% até 2025 + + + 2017 + 2019 + 2021 + 2023 + 2026 + + +
+ +
+

+ Fonte: Ministério da Saúde — SIH/SUS (AIH aprovadas por + ano)
+ Nota: 2026 é projeção pela média de janeiro a junho × 2. +

+
+ +
+
JORGE SOLLA
+
MANDATO DEPUTADO FEDERAL
+
+
+
+
+ NEEDS ASSET · LOGO OFICIAL +
+
+
+ + +
+ +
+

REGRAS DA LINHA DE 2 SÉRIES

+

+ Contrato visual para o porte classe-a-classe +

+
+
+

Entrada e teto

+

+ Exatamente 2 séries e os mesmos rótulos temporais, na mesma ordem. Teto: + 12 pontos por série no feed, quadrado e story (24 marcas no total). + Barra/coluna continuam em 7 pontos no feed/quadrado e 5 no story. Contagem ou tempo + desalinhado → perguntar; nunca interpolar, completar, repetir nem remover ponto. O + domínio y é compartilhado e parte do zero real. +

+
+
+

Bom × ruim · tons presentes

+

+ Tons entram sempre em par. Bom = + #c51414 + círculo + ↑ “amplia”. Ruim = + #78716c + quadrado + ↓ “recua”. No caso: Estadual é + bom; Municipal é ruim. Vermelho é marca do mandato/PT e aponta o caminho desejável; + nunca significa perda. O ruim permanece neutro. Palavra, seta, forma e cor tornam a + leitura redundante, não decorativa. +

+
+
+

Sem tons · comparação neutra

+

+ Se nenhum tom vier informado, não inferir juízo: série A = + #1c1917 + círculo; série B = + #78716c + quadrado. Rótulos finais têm nome + valor e + podem trazer a variação observada com sinal matemático, sem palavra valorativa. Sem + setas ou “amplia/recua”; nenhum vermelho no plot — ele fica apenas na régua superior + e na marca do template. +

+
+
+

Kicker, rótulos e colisão

+

+ Kicker fixo: “Comparação no tempo”; não há legenda separada. Cada + linha termina em bloco nome + valor + glifo/palavra + variação observada, tudo ≥30 + px. O gutter começa 42 px após o ponto e tem 238 px. Cada bloco usa 4 linhas: nome, + valor, valência e “±N% até AAAA” em uma linha. Leading interno ≥38 px; entre blocos, + ≥40 px livres. Leaders de 3 px voltam ao ponto. Nunca esconder série. Nome quebra em + até 2 linhas; se não couber, pedir forma curta, nunca abreviar sozinho. +

+
+
+

Projeção

+

+ Só quando o último período for projetado: faixa neutra + #a8a29e a 16% do meio do penúltimo→último passo até + 33 px depois do ponto final; apenas esse segmento usa tracejado (feed/story + 18 12; quadrado + 15 11). Os dois marcadores finais preservam a forma e + ficam vazados, com stroke 5 px. O rótulo projetado tem de ser o último ponto das + duas séries. “projeção” fica no topo direito da faixa, não junto ao eixo nem + misturada aos rótulos finais; a nota traz o método. Sem projeção: remover faixa, + tracejado, palavra e ressalva automática. +

+
+
+

Eixo X · 10 pontos anuais

+

+ Fonte 30 px, centralizada. Para 9–12 pontos, mostrar rótulos alternados, sempre o + primeiro e o último; omitir o penúltimo se colidir com o último. Neste caso, exibir + 2017, 2019, 2021, 2023 e 2026 — o rótulo final substitui 2025. A palavra “projeção” + fica acima do período, nunca espremida no eixo. +

+
+
+

Variação e cruzamento

+

+ Se o último ponto é projeção, a variação compara o primeiro valor ao último + observado, nunca à projeção: aqui, +121% até 2025 no + estadual e −23% até 2025 no municipal. O cruzamento confirmado + recebe dual-crossing-*: anel no ponto estadual de + 2024, guia tracejada até uma caixa de 3 linhas: “2024” / “Estadual” / “ultrapassa”. + A copy permanece em 28 px; nunca comprimir em uma linha nem deixá-la tocar a borda. + Sem confirmação textual do cruzamento, não gerar a anotação automaticamente. +

+
+
+

Feed · 1080×1350

+

+ Canvas padding 76/84/58 px; headline 60 px; plot + 880×620, margin-top 28. Plot L52, R280, T56, B76; + dados x=52…600; baseline y=544; grid em y=56/300/544. Os 10 pontos ficam em + x=52/113/174/235/296/356/417/478/539/600; a largura útil cai de 598 para 548 px, com + passo anual de ~61 px, e o eixo alternado usa 52/174/296/417/600 (≥121 px entre + rótulos). Linhas 8 px; círculos r=8; quadrados 16×16; finais r=11 / 20×20; dash 18 + 12. Gutter x=642…880; nomes/valência/variação 30 px, valores 36 px. Baselines + finais: Estadual 54/96/136/176; Municipal 248/290/330/370. Cruzamento: guia x=478, + y=160…398; caixa x=351, y=398, 254×118, rx=8; textos em y=431/465/497. +

+
+
+

Quadrado · 1080×1080

+

+ Canvas padding 56/84/48 px; headline 54 px; encurtar subtítulo antes de reduzir + texto. Plot 880×440, margin-top 20, L52, R280, T40, + B70; dados x=52…600; baseline y=370; grid em y=40/205/370. Linhas 7 px; círculos + r=7; quadrados 14×14; finais r=10 / 18×18; dash 15 11. Gutter x=642…880; textos 30 + px, valores 32 px. Baselines finais: Estadual 32/70/108/146; Municipal + 186/224/262/300. Cruzamento: guia x=478, y=115…252; caixa x=351, y=252, 254×108, + rx=8; textos em y=282/311/340. +

+
+
+

Story · 1080×1920

+

+ Faixas seguras de 250 px no topo e na base; conteúdo padding 306/84/310 px; headline + 64 px. Plot 880×700, margin-top 28, L52, R280, T64, + B78; dados x=52…600; baseline y=622; grid em y=64/343/622. Linhas 8 px; círculos + r=8; quadrados 16×16; finais r=11 / 20×20; dash 18 12. Gutter x=642…880; nomes, + valência e variação 32 px, valores 36 px. Baselines finais: Estadual 68/112/154/196; + Municipal 280/324/366/408. Plot, caixa, rótulos e “projeção” ficam integralmente + entre y=250 e y=1670 do canvas. Cruzamento: guia x=478, y=181…478; caixa x=351, + y=478, 254×118, rx=8; textos em y=511/545/577. +

+
+
+

Mapa de classes do SVG

+

+ Chassi: dual-line-plot, + dual-axis-label, + dual-projection-band e + dual-projection-label. Com tons: + dual-series-good / dual-markers-good e + dual-series-bad / dual-markers-bad. Sem tons, trocar + apenas os modificadores por + dual-series-a / dual-markers-a e + dual-series-b / dual-markers-b. Projeção acrescenta + dual-series-projection e + dual-marker-projected-*. Saída direta: + dual-end-leader, + dual-end-name, + dual-end-value e, só com tons, + dual-end-valence; variação observada em + dual-end-change. Cruzamento, quando confirmado: + dual-crossing-guide, + dual-crossing-ring, + dual-crossing-box, + dual-crossing-year e + dual-crossing-copy. +

+
+
@@ -1143,7 +1795,8 @@

Densidade

- Até 7 pontos por gráfico. Acima disso: resumir com orientação explícita. + Barra, coluna e linha simples: até 7 pontos. Exceção aprovada: linha de 2 séries, + até 12 pontos alinhados por série; story mantém 5 apenas para barra/coluna.

@@ -1159,8 +1812,10 @@
- Design hi-fi · graficos-dados-instagram · dados e fontes fictícios - Paleta Solla · Inter · sem gradiente, 3D ou chrome de dashboard + Design hi-fi certificado · graficos-dados-instagram · cinco variações integradas + Paleta Solla · valência redundante · sem gradiente, 3D ou chrome de dashboard
diff --git a/scripts/build-chart-from-data.mjs b/scripts/build-chart-from-data.mjs index 2bdc709f..07267066 100644 --- a/scripts/build-chart-from-data.mjs +++ b/scripts/build-chart-from-data.mjs @@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url' import { launchPdfBrowser, screenshotHtmlPng } from './lib/buildPdf.mjs' import { MAX_POINTS, + MAX_POINTS_LINE, MAX_POINTS_STORY, SIZES, classifyRelation, @@ -54,6 +55,29 @@ const slugify = (value) => const today = () => new Date().toISOString().slice(0, 10) +/** + * `--good`/`--bad` name the series that carry each tone of the approved + * two-series variant; the pair itself is validated by `validateSpec`. + */ +const applyTones = (series, flags, die) => { + const good = flags.good ? String(flags.good) : null + const bad = flags.bad ? String(flags.bad) : null + if (good && bad && good === bad) { + die(`--good e --bad apontam para a mesma série: ${JSON.stringify(good)}.`) + } + const known = series.map((serie) => serie.name) + for (const name of [good, bad].filter(Boolean)) { + if (!known.includes(name)) { + die(`série ${JSON.stringify(name)} não existe nos dados (nomes: ${known.join(', ')}).`) + } + } + return series.map((serie) => { + if (good && serie.name === good) return { ...serie, tone: 'good' } + if (bad && serie.name === bad) return { ...serie, tone: 'bad' } + return serie + }) +} + const readInput = async (flags) => { const path = flags.in === '-' || flags.in === true ? null : String(flags.in ?? '') if (!path) { @@ -111,14 +135,27 @@ export const main = async ({ return } - if (dataset.rows.length === 0 || dataset.issues.length > 0) { + const hasSeries = Array.isArray(dataset.series) && dataset.series.length > 0 + if ((!hasSeries && dataset.rows.length === 0) || dataset.issues.length > 0) { process.stdout.write( - `${JSON.stringify({ needsQuestion: true, issues: dataset.issues, rows: dataset.rows }, null, 2)}\n`, + `${JSON.stringify( + { + needsQuestion: true, + issues: dataset.issues, + rows: dataset.rows, + series: dataset.series ?? [], + }, + 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) + const forcedType = flags.type ? String(flags.type) : null + const chartType = hasSeries + ? (forcedType ?? 'line') + : classifyRelation(dataset.rows, forcedType) spec = { chartType, size: flags.size ? String(flags.size) : 'feed', @@ -126,8 +163,13 @@ export const main = async ({ 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, + ...(hasSeries + ? { + ...(flags.projected ? { projectedLabel: String(flags.projected) } : {}), + ...(flags.crossing ? { crossingLabel: String(flags.crossing) } : {}), + series: applyTones(dataset.series, flags, die), + } + : { highlight: flags.highlight ? String(flags.highlight) : null, rows: dataset.rows }), } } @@ -147,10 +189,14 @@ export const main = async ({ 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') + const pointCap = spec.series?.length + ? MAX_POINTS_LINE + : sizeKey === 'story' && (spec.chartType === 'bar' || spec.chartType === 'column') ? MAX_POINTS_STORY : MAX_POINTS + const pointCount = spec.series?.length + ? `${spec.series.length} séries × ${spec.series[0].rows.length}` + : `${spec.rows.length} ponto(s)` const browser = await launchBrowser() try { await mkdir(dirname(outPath), { recursive: true }) @@ -163,7 +209,7 @@ export const main = async ({ outPath, }) console.log( - `[${LABEL}] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${spec.rows.length} ponto(s) ≤ ${pointCap}`, + `[${LABEL}] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${pointCount} ≤ ${pointCap}`, ) } finally { await browser.close() diff --git a/scripts/lib/chartData.mjs b/scripts/lib/chartData.mjs index ac8e15ee..1077a1a4 100644 --- a/scripts/lib/chartData.mjs +++ b/scripts/lib/chartData.mjs @@ -23,6 +23,14 @@ export const MAX_POINTS = 7 */ export const MAX_POINTS_STORY = 5 +/** + * Two-series time comparison (approved variant): an annual series carries more + * points than a ranking, per series. One tone per series encodes good/bad. + */ +export const MAX_SERIES = 2 +export const MAX_POINTS_LINE = 12 +const SERIES_TONES = ['good', 'bad'] + /** 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 }, @@ -38,6 +46,9 @@ export const RELATION_LABEL = { anchor: 'Número-âncora', } +/** Kicker of the two-series time line (approved C191 variant). */ +export const SERIES_RELATION_LABEL = 'Comparação no tempo' + 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 @@ -138,11 +149,51 @@ const cleanMatrix = (matrix) => .map((row) => row.map((cell) => String(cell ?? '').trim())) .filter((row) => row.some(Boolean)) +/** + * A table whose header names two measures over a shared first column is a time + * comparison: `series` instead of a single ranking. Only the header (words, + * never numbers) may open that door — a data row with a missing value never + * turns into a series in silence. + */ +const looksLikeSeriesHeader = (header) => + header.length >= MAX_SERIES + 1 && + header.every((cell) => cell !== '' && parseNumber(cell) === null) + +const detectSeries = (cleaned) => { + const header = cleaned[0] + if (cleaned.length < 2 || !looksLikeSeriesHeader(header)) return null + const issues = [] + const series = header.slice(1).map((name) => ({ name, rows: [] })) + const seen = new Set() + for (const row of cleaned.slice(1)) { + const label = row[0] + if (!label) { + issues.push(`linha sem rótulo: ${JSON.stringify(row.join(' | '))}`) + continue + } + if (seen.has(label)) issues.push(`rótulo repetido: "${label}"`) + seen.add(label) + series.forEach((serie, index) => { + const raw = row[index + 1] + const value = parseNumber(raw) + if (value === null) { + issues.push(`valor não numérico em "${serie.name} · ${label}": ${JSON.stringify(raw)}`) + return + } + serie.rows.push({ label, value }) + }) + } + return { series, issues } +} + const matrixToDataset = (matrix, format) => { const cleaned = cleanMatrix(matrix) const issues = [] if (cleaned.length === 0) return { rows: [], format, issues: ['nenhum dado encontrado'] } + const detected = detectSeries(cleaned) + if (detected) return { rows: [], series: detected.series, format, issues: detected.issues } + let start = 0 if ( cleaned.length > 1 && @@ -284,12 +335,108 @@ export const classifyRelation = (rows, forcedType = null) => { return 'bar' } +/** + * Guardrails of the two-series time line (approved variant): aligned periods, + * at most `MAX_POINTS_LINE` per series, honest zero base, good/bad tones paired + * and the projection only on the last period. + */ +const validateSeries = (spec) => { + const { series } = spec + if (spec.chartType !== 'line') { + throw new Error('a comparação de duas séries exige o tipo linha (série de tempo).') + } + if (spec.highlight) { + throw new Error('a linha de duas séries marca bom/ruim pelos tons — não use --highlight.') + } + if (series.length !== MAX_SERIES) { + throw new Error( + `a linha multi-série desenha ${MAX_SERIES} séries (${series.length} recebidas).`, + ) + } + const names = new Set() + for (const serie of series) { + if (typeof serie.name !== 'string' || serie.name.trim() === '') + throw new Error('série sem nome.') + if (names.has(serie.name)) throw new Error(`série repetida: "${serie.name}".`) + names.add(serie.name) + if (!Array.isArray(serie.rows) || serie.rows.length < 2) { + throw new Error(`série "${serie.name}" precisa de pelo menos 2 pontos.`) + } + if (serie.rows.length > MAX_POINTS_LINE) { + throw new Error( + `${serie.rows.length} pontos na série "${serie.name}" (> ${MAX_POINTS_LINE}): resuma os períodos antes de gerar o gráfico.`, + ) + } + for (const row of serie.rows) { + if (!row.label) throw new Error(`ponto de dado sem rótulo na série "${serie.name}".`) + if (!Number.isFinite(row.value)) { + throw new Error(`valor ausente/inválido em "${serie.name} · ${row.label}".`) + } + if (row.value < 0) { + throw new Error(`valor negativo em "${serie.name} · ${row.label}" — a linha parte do zero.`) + } + } + } + const [first, second] = series + const aligned = + first.rows.length === second.rows.length && + first.rows.every((row, index) => row.label === second.rows[index].label) + if (!aligned) + throw new Error('as séries precisam compartilhar os mesmos períodos, na mesma ordem.') + if (!first.rows.every((row) => isTemporalLabel(row.label))) { + throw new Error('a linha de duas séries exige períodos no eixo (ex.: anos) — sem categorias.') + } + const tones = series.map((serie) => serie.tone ?? null) + const informed = tones.filter(Boolean) + if (informed.length > 0 && informed.length !== series.length) { + throw new Error('informe o tom das duas séries (bom e ruim) ou de nenhuma.') + } + for (const tone of informed) { + if (!SERIES_TONES.includes(tone)) { + throw new Error(`tom inválido: ${JSON.stringify(tone)} (use ${SERIES_TONES.join(', ')}).`) + } + } + if (informed.length === series.length && tones[0] === tones[1]) { + throw new Error('as duas séries não podem compartilhar o mesmo tom (bom × ruim).') + } + if (spec.projectedLabel) { + const last = first.rows[first.rows.length - 1].label + if (last !== spec.projectedLabel) { + throw new Error( + `a projeção só é marcada no último período (recebido "${spec.projectedLabel}"; último é "${last}").`, + ) + } + } + if (spec.crossingLabel) { + const good = series.find((serie) => serie.tone === 'good') + const bad = series.find((serie) => serie.tone === 'bad') + if (!good || !bad) { + throw new Error('a anotação de cruzamento exige os tons bom e ruim — informe --good e --bad.') + } + const labels = first.rows.map((row) => row.label) + const index = labels.indexOf(spec.crossingLabel) + if (index < 1) { + throw new Error( + `cruzamento "${spec.crossingLabel}" precisa ser um período depois do primeiro — confira o rótulo.`, + ) + } + const overtakes = + good.rows[index].value > bad.rows[index].value && + good.rows[index - 1].value <= bad.rows[index - 1].value + if (!overtakes) { + throw new Error( + `não há ultrapassagem confirmada em "${spec.crossingLabel}" — confira os valores antes de anotar.`, + ) + } + } +} + /** * 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 + const { chartType, rows, series, headline } = spec if (spec.size && !SIZES[spec.size]) { throw new Error( `tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`, @@ -303,32 +450,37 @@ export const validateSpec = (spec) => { } 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 (Array.isArray(series) && series.length > 0) { + validateSeries(spec) + } else { + 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.') diff --git a/scripts/lib/chartPrimitives.mjs b/scripts/lib/chartPrimitives.mjs index 809692bd..eb3b5233 100644 --- a/scripts/lib/chartPrimitives.mjs +++ b/scripts/lib/chartPrimitives.mjs @@ -136,6 +136,361 @@ export const stackedColumnChart = ({ ` } +/** + * Geometry of the approved two-series time line (C191 variant), per export + * size. The data box is the same on every canvas; only the vertical rhythm and + * the marker sizes change (design: "Feed / Quadrado / Story" rules). + */ +const DUAL_GEOMETRY = { + feed: { + width: 880, + height: 620, + left: 52, + right: 600, + top: 56, + baseline: 544, + stroke: 8, + dotRadius: 8, + squareSize: 16, + projectedDotRadius: 11, + projectedSquareSize: 20, + dash: '18 12', + nameSize: 30, + valueSize: 36, + valenceSize: 30, + changeSize: 30, + axisSize: 30, + blockOffsets: [-35, 7, 47, 87], + blockGap: 72, + crossing: { + boxWidth: 254, + boxHeight: 118, + boxY: 398, + yearOffset: 33, + copyOffset: 67, + copyOffset2: 99, + }, + }, + square: { + width: 880, + height: 440, + left: 52, + right: 600, + top: 40, + baseline: 370, + stroke: 7, + dotRadius: 7, + squareSize: 14, + projectedDotRadius: 10, + projectedSquareSize: 18, + dash: '15 11', + nameSize: 30, + valueSize: 32, + valenceSize: 30, + changeSize: 30, + axisSize: 30, + blockOffsets: [-31, 7, 45, 83], + blockGap: 40, + crossing: { + boxWidth: 254, + boxHeight: 108, + boxY: 252, + yearOffset: 30, + copyOffset: 59, + copyOffset2: 88, + }, + }, + story: { + width: 880, + height: 700, + left: 52, + right: 600, + top: 64, + baseline: 622, + stroke: 8, + dotRadius: 8, + squareSize: 16, + projectedDotRadius: 11, + projectedSquareSize: 20, + dash: '18 12', + nameSize: 32, + valueSize: 36, + valenceSize: 32, + changeSize: 32, + axisSize: 30, + blockOffsets: [-34, 10, 52, 94], + blockGap: 84, + crossing: { + boxWidth: 254, + boxHeight: 118, + boxY: 478, + yearOffset: 33, + copyOffset: 67, + copyOffset2: 99, + }, + }, +} + +/** Rounds the shared maximum up to the next legible step (never down). */ +const niceMax = (value) => { + if (!(value > 0)) return 1 + const magnitude = 10 ** Math.floor(Math.log10(value)) + for (const step of [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) { + const candidate = step * magnitude + if (candidate >= value) return candidate + } + return 10 * magnitude +} + +/** + * 9–12 annual points alternate their axis labels, always keeping the first and + * the last; the penultimate is dropped when it would collide with the last + * (design: "2017, 2019, 2021, 2023 e 2026"). + */ +const dualAxisIndices = (count) => { + if (count < 9) return [...Array(count).keys()] + const shown = new Set() + for (let index = 0; index < count; index += 2) shown.add(index) + const last = count - 1 + if (!shown.has(last)) { + shown.delete(last - 1) + shown.add(last) + } + return [...shown].sort((a, b) => a - b) +} + +/** Keeps the crossing box inside the data box while staying centered on the point. */ +const crossingCenter = (x, cfg) => + Math.min(Math.max(x, cfg.left + cfg.crossing.boxWidth / 2), cfg.right - cfg.crossing.boxWidth / 2) + +/** + * Two-series time line (Instagram, C191 approved variant): shared zero-based + * scale, direct end labels and valence carried by word + arrow + shape + color. + * `series` items are `{ name, tone?: 'good'|'bad', rows: [{ label, value }] }`; + * tones come in pairs, or not at all (neutral comparison, no red in the plot). + */ +export const dualLineChart = ({ + series, + size = 'feed', + projected = false, + crossingLabel = /** @type {string | null} */ (null), + colors, + valence, + format, +}) => { + const cfg = DUAL_GEOMETRY[size] ?? DUAL_GEOMETRY.feed + const count = series[0].rows.length + const max = niceMax(Math.max(...series.flatMap((serie) => serie.rows.map((row) => row.value)))) + const step = count > 1 ? (cfg.right - cfg.left) / (count - 1) : 0 + const xAt = (index) => Math.round(cfg.left + index * step) + const yAt = (value) => cfg.baseline - Math.round((value / max) * (cfg.baseline - cfg.top)) + const pointsOf = (serie) => serie.rows.map((row, index) => ({ x: xAt(index), y: yAt(row.value) })) + const styles = series.map((serie, index) => { + if (serie.tone === 'good') { + return { color: colors.good, shape: 'circle', valence: valence.good, tone: 'good' } + } + if (serie.tone === 'bad') { + return { color: colors.bad, shape: 'square', valence: valence.bad, tone: 'bad' } + } + return index === 0 + ? { color: colors.neutralA, shape: 'circle', valence: null, tone: 'a' } + : { color: colors.neutralB, shape: 'square', valence: null, tone: 'b' } + }) + + const marker = ({ x, y, shape, size: markerSize, fill, className = '' }) => + shape === 'square' + ? `` + : `` + + const band = projected + ? (() => { + const before = xAt(count - 2) + const last = xAt(count - 1) + const start = Math.round((before + last) / 2) + const end = last + 33 + return `` + })() + : '' + + const gridLines = ` + + + 0` + + const projectionLabel = projected + ? `projeção` + : '' + + const lines = series + .map((serie, index) => { + const points = pointsOf(serie) + const style = styles[index] + const solid = projected ? points.slice(0, -1) : points + const polyline = `` + const dashed = projected + ? `` + : '' + const regular = projected ? points.slice(0, -1) : points + const markers = `${regular + .map((point) => + marker({ + x: point.x, + y: point.y, + shape: style.shape, + size: style.shape === 'square' ? cfg.squareSize : cfg.dotRadius * 2, + fill: style.color, + }), + ) + .join('')}` + return `${polyline}${dashed}${markers}` + }) + .join('') + + const projectedMarkers = projected + ? series + .map((serie, index) => { + const point = pointsOf(serie)[count - 1] + const style = styles[index] + return marker({ + x: point.x, + y: point.y, + shape: style.shape, + size: style.shape === 'square' ? cfg.projectedSquareSize : cfg.projectedDotRadius * 2, + fill: colors.paper, + className: `dual-marker-projected dual-marker-projected-${style.tone}`, + }).replace('/>', ` stroke="${style.color}" stroke-width="5" />`) + }) + .join('') + : '' + + const observedIndex = projected ? count - 2 : count - 1 + const changeOf = (serie) => { + const firstValue = serie.rows[0].value + const observed = serie.rows[observedIndex]?.value + if (!(firstValue > 0) || !Number.isFinite(observed)) return null + const percent = Math.round(((observed - firstValue) / firstValue) * 100) + const sign = percent > 0 ? '+' : percent < 0 ? '−' : '' + const until = `até ${serie.rows[observedIndex].label}` + return { + percent: `${sign}${Math.abs(percent)}%`, + until, + label: `${sign}${Math.abs(percent)}% ${until}`, + } + } + + const blocks = series + .map((serie, index) => { + const point = pointsOf(serie)[count - 1] + const offsets = styles[index].valence ? cfg.blockOffsets : cfg.blockOffsets.slice(0, 3) + return { + name: serie.name, + style: styles[index], + point, + baselines: offsets.map((offset) => point.y + offset), + value: format(serie.rows[count - 1]), + change: changeOf(serie), + } + }) + .sort((a, b) => a.point.y - b.point.y) + let previousBottom = null + for (const block of blocks) { + if (previousBottom !== null) { + const minName = previousBottom + cfg.blockGap + const delta = minName - block.baselines[0] + if (delta > 0) block.baselines = block.baselines.map((baseline) => baseline + delta) + } + previousBottom = block.baselines[block.baselines.length - 1] + } + const clampOverflow = blocks[blocks.length - 1]?.baselines.at(-1) - (cfg.height - 12) + const endBlocks = blocks + .map((block) => { + const baselineOffset = clampOverflow > 0 ? -clampOverflow : 0 + const text = (className, size, fill, value, index) => + `${htmlEscape(value)}` + const parts = [ + ``, + text('dual-end-name', cfg.nameSize, colors.ink, block.name, 0), + text('dual-end-value', cfg.valueSize, colors.ink, block.value, 1), + ] + if (block.style.valence) { + parts.push( + text('dual-end-valence', cfg.valenceSize, block.style.color, block.style.valence, 2), + ) + } + if (block.change) { + parts.push( + text( + 'dual-end-change', + cfg.changeSize, + colors.change, + block.change.label, + block.style.valence ? 3 : 2, + ), + ) + } + return parts.join('') + }) + .join('') + + const goodSeries = series.find((serie) => serie.tone === 'good') + const crossingIndex = crossingLabel + ? series[0].rows.findIndex((row) => row.label === crossingLabel) + : -1 + const showCrossing = Boolean(goodSeries) && crossingIndex >= 1 + const crossingGuide = showCrossing + ? (() => { + const point = pointsOf(goodSeries)[crossingIndex] + const center = crossingCenter(point.x, cfg) + return `` + })() + : '' + const crossingAnnotation = showCrossing + ? (() => { + const point = pointsOf(goodSeries)[crossingIndex] + const box = cfg.crossing + const center = crossingCenter(point.x, cfg) + return ` + + + ${htmlEscape(crossingLabel)} + ${htmlEscape(goodSeries.name)} + ultrapassa` + })() + : '' + + const axis = dualAxisIndices(count) + .map( + (index) => + `${htmlEscape(series[0].rows[index].label)}`, + ) + .join('') + + const ariaLabel = `${series + .map((serie) => { + const change = changeOf(serie) + const destination = projected + ? ` para projeção de ${format(serie.rows[count - 1])} em ${serie.rows[count - 1].label}` + : ` para ${format(serie.rows[count - 1])} em ${serie.rows[count - 1].label}` + const trend = change + ? `, variação observada de ${change.percent} até ${serie.rows[observedIndex].label}` + : '' + return `${serie.name}: de ${format(serie.rows[0])} em ${serie.rows[0].label}${destination}${trend}` + }) + .join('. ')}${showCrossing ? `. ${goodSeries.name} ultrapassa em ${crossingLabel}` : ''}` + + return ` + ${band} + ${gridLines} + ${projectionLabel} + ${crossingGuide} + ${lines} + ${projectedMarkers} + ${crossingAnnotation} + ${endBlocks} + ${axis} + ` +} + /** * 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. diff --git a/scripts/lib/graficosInstagramRender.mjs b/scripts/lib/graficosInstagramRender.mjs index 368c8117..60f63f17 100644 --- a/scripts/lib/graficosInstagramRender.mjs +++ b/scripts/lib/graficosInstagramRender.mjs @@ -9,8 +9,8 @@ * only sets the viewport and clips the canvas. */ -import { RELATION_LABEL, SIZES } from './chartData.mjs' -import { lineChart, proportionalPercent } from './chartPrimitives.mjs' +import { RELATION_LABEL, SERIES_RELATION_LABEL, SIZES } from './chartData.mjs' +import { dualLineChart, lineChart, proportionalPercent } from './chartPrimitives.mjs' import { htmlEscape } from './reportText.mjs' /** Solla palette (verbatim from the intention). */ @@ -85,7 +85,39 @@ const anchorBody = (spec) => { ` } +/** + * Two-series time line (approved C191 variant): the valence travels by word + + * arrow + marker shape + color, never by color alone. Red belongs to the + * mandate (the good path); the falling series stays neutral. + */ +const DUAL_COLORS = { + good: SOLLA_PALETTE.highlight, + bad: SOLLA_PALETTE.axis, + neutralA: SOLLA_PALETTE.ink, + neutralB: SOLLA_PALETTE.axis, + grid: SOLLA_PALETTE.barStrong, + paper: SOLLA_PALETTE.paper, + ink: SOLLA_PALETTE.ink, + change: SOLLA_PALETTE.label, +} + +const DUAL_VALENCE = { good: '↑ amplia', bad: '↓ recua' } + +const dualBody = (spec) => { + const sizeKey = SIZES[spec.size] ? spec.size : 'feed' + return `
${dualLineChart({ + series: spec.series, + size: sizeKey, + projected: Boolean(spec.projectedLabel), + crossingLabel: spec.crossingLabel ?? null, + colors: DUAL_COLORS, + valence: DUAL_VALENCE, + format: (row) => formatValue(row.value), + })}
` +} + const plotBody = (spec) => { + if (Array.isArray(spec.series) && spec.series.length > 0) return dualBody(spec) if (spec.chartType === 'anchor') return anchorBody(spec) if (spec.chartType === 'column') return columnBody(spec) if (spec.chartType === 'line') { @@ -152,6 +184,22 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; } .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; } +.dual-line-plot { flex: none; } +.dual-line-plot svg { display: block; width: 880px; height: auto; } +.dual-projection-band { fill: ${SOLLA_PALETTE.barStrong}; fill-opacity: 0.16; } +.dual-grid-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-opacity: 0.32; stroke-width: 2; } +.dual-zero-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; } +.dual-axis-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 650; } +.dual-projection-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 700; } +.dual-end-name { fill: ${SOLLA_PALETTE.ink}; font-weight: 700; } +.dual-end-value { fill: ${SOLLA_PALETTE.ink}; font-weight: 850; font-variant-numeric: tabular-nums; } +.dual-end-valence { font-weight: 850; } +.dual-end-change { fill: ${SOLLA_PALETTE.label}; font-weight: 700; } +.dual-crossing-guide { stroke: ${SOLLA_PALETTE.highlight}; stroke-width: 2; stroke-dasharray: 8 8; opacity: 0.45; } +.dual-crossing-ring { fill: ${SOLLA_PALETTE.paper}; stroke: ${SOLLA_PALETTE.highlight}; stroke-width: 4; } +.dual-crossing-box { fill: ${SOLLA_PALETTE.paper}; stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; } +.dual-crossing-year { fill: ${SOLLA_PALETTE.highlight}; font-weight: 850; } +.dual-crossing-copy { fill: ${SOLLA_PALETTE.ink}; font-weight: 750; } .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; } @@ -208,7 +256,15 @@ 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 dual = Array.isArray(spec.series) && spec.series.length > 0 + const dualStyles = !dual + ? '' + : sizeKey === 'feed' + ? '.headline { font-size: 60px; max-width: 900px; } .subtitle { font-size: 28px; } .source { font-size: 25px; } .plot { margin-top: 28px; }' + : sizeKey === 'square' + ? '.inner { padding: 56px 84px 48px; } .headline { font-size: 54px; } .plot { margin-top: 20px; }' + : '.headline { font-size: 64px; } .plot { margin-top: 28px; }' + const kicker = dual ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico') const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle return ` @@ -224,10 +280,11 @@ export const renderChartHtml = (spec) => { .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; } + ${dualStyles} -