From ca19b2c86fd2ab476b59d8f73caeb26474e26fa5 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 19 Sep 2026 12:45:08 -0300 Subject: [PATCH] =?UTF-8?q?feat(C205):=20linha=20de=20tr=C3=AAs=20s=C3=A9r?= =?UTF-8?q?ies=20e=20marca=20maior=20no=20rodap=C3=A9=20do=20gerador=20de?= =?UTF-8?q?=20gr=C3=A1ficos=20(Closes=20#1212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/graficos-dados/SKILL.md | 122 ++- ...6-09-19-c205-graficos-tres-series-marca.md | 1 + .../graficos-dados-instagram-ui-design.html | 712 ++++++++++++++++-- scripts/build-chart-from-data.mjs | 45 +- scripts/lib/chartData.mjs | 75 +- scripts/lib/chartPrimitives.mjs | 162 +++- scripts/lib/graficosInstagramRender.mjs | 88 ++- tests/unit/buildChartFromData.unit.spec.ts | 104 +++ tests/unit/chartData.unit.spec.ts | 98 ++- tests/unit/chartPrimitives.unit.spec.ts | 109 +++ tests/unit/graficosDadosSkill.unit.spec.ts | 15 + .../unit/graficosInstagramRender.unit.spec.ts | 92 +++ 12 files changed, 1471 insertions(+), 152 deletions(-) create mode 100644 docs/changelog/2026-09-19-c205-graficos-tres-series-marca.md diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md index dfa47101..ddb4ae35 100644 --- a/.agents/skills/graficos-dados/SKILL.md +++ b/.agents/skills/graficos-dados/SKILL.md @@ -14,13 +14,14 @@ 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` (revisão C203 — marca e -paleta oficiais do kit 1313); o renderer +`docs/plans/graficos-dados-instagram-ui-design.html` (base C203 aprovada — marca +e paleta oficiais do kit 1313 —, com as revisões C205 da linha de três séries e +C206 do rodapé/marca aguardando gate humano); o renderer (`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe. **Nunca invente estrutura visual** — o dono é o `designer`. O rodapé usa o ativo oficial `public/campaign-kit/jorge-solla-positivo.png` (nunca recriar -lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa), -`#184e92` (sinal de marca), com amarelo/verde só dentro dos ativos. +lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor), +`#184e92` (sinal de marca, nunca dado), com amarelo/verde só dentro dos ativos. ## Quando usar @@ -48,10 +49,15 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa), 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; + - **duas ou três séries no mesmo período → `line` multi-série**: tabela com 2 + ou 3 colunas de medida e a primeira coluna temporal; o par + `--good=`/`--bad=` marca a valência bom/ruim (par ou nenhum), `--projected=` + marca o último período projetado e `--crossing=` confirma a + ultrapassagem que ganha anotação. A **extensão de três séries** (C205) usa o + trio `--good=`/`--neutral=`/`--neutral-dark=` informado junto — melhor = + vermelho/círculo/"↑ amplia", neutro = cinza/losango/"↗ cresce", neutro + escuro = tinta/triângulo/"↘ diminui" —, só no feed e sem projeção nem + cruzamento; - **duas medidas por categoria → `delta`**: barra de variação com a base (0 → inicial) num tom e a extensão (inicial → final) noutro, os dois valores numa gutter de duas colunas e o vermelho fora do plot (sem valência); @@ -82,6 +88,15 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência boa), --type=line --size=feed \ --good="Estadual" --bad="Municipal" --projected=2026 --crossing=2024 ``` + Três séries no feed (extensão C205, os três tons entram juntos): + ```bash + node scripts/build-chart-from-data.mjs --in= \ + --headline="Hospital estadual puxa a ampliação de leitos de internação" \ + --subtitle="Vitória da Conquista (BA) · leitos existentes · 2015–2026" \ + --source="Ministério da Saúde — CNES · leitos de internação por esfera jurídica" \ + --type=line --size=feed \ + --good="Hospital Estadual" --neutral="Rede municipal" --neutral-dark="Rede privada" + ``` Barra de variação (base + extensão, tabela de rótulo + inicial + final): ```bash node scripts/build-chart-from-data.mjs --in= --type=delta --size=feed \ @@ -139,13 +154,31 @@ Na linha de 2 séries, `series` substitui `rows` e `projectedLabel` marca o } ``` +Na extensão de três séries (C205), o mesmo `series` carrega o trio de tons: + +```json +{ + "chartType": "line", + "size": "feed", + "headline": "Hospital estadual puxa a ampliação de leitos de internação", + "source": "Ministério da Saúde — CNES · leitos de internação por esfera jurídica", + "series": [ + { "name": "Hospital Estadual", "tone": "good", "rows": [{ "label": "2015", "value": 235 }] }, + { "name": "Rede municipal", "tone": "neutral", "rows": [{ "label": "2015", "value": 93 }] }, + { "name": "Rede privada", "tone": "neutral-dark", "rows": [{ "label": "2015", "value": 832 }] } + ] +} +``` + `chartType` ∈ `bar | column | line | anchor | delta`; `highlight` é o rótulo que recebe o único vermelho `#e4102f` (default: o maior valor); `unit` anexa o sufixo aos rótulos de valor (ex.: `"%"`). O `kicker` (spec) segue a **relação** dos dados, não o layout — colunas verticais de categorias leem "Comparação", poucos períodos leem "Poucos períodos". 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. +`good | bad` (os dois tons ou nenhum) ou `good | neutral | neutral-dark` (o trio +da extensão C205, só no feed, sem `projectedLabel` nem `crossingLabel`); as +séries compartilham os mesmos rótulos temporais; `rows` e `highlight` não entram +nessas variantes. `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 @@ -179,44 +212,50 @@ 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 (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. A **barra de variação** (`delta`) também +- **Até 7 pontos** por gráfico (barra, coluna e linha simples); a **linha + multi-série** (2 ou 3) 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. A **barra de variação** (`delta`) também comporta **até 7 categorias**, e no story ela **não corta** (um top-N apagaria a manchete "quatro dos sete"). - **Barra de variação (`delta`)** parte do zero com `0 ≤ inicial ≤ final`; - retração (final < inicial) é recusada com a linha de 2 séries no lugar; + retração (final < inicial) é recusada com a linha multi-série no lugar; `--highlight`/valência não entram (o par de tons é fixo e o vermelho fica fora do plot). - **Comparação positiva dupla (`--dual-positive`)** exige `column`, exatamente 2 categorias e `feed`; recusa `--highlight` e `--no-highlight`; os tons seguem a ordem de entrada (nunca o maior) e ambos leem como resultado bom — cor nunca carrega sozinha (cada coluna tem rótulo e valor). -- **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 `#e4102f`, 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. +- **Valência só na linha multi-série** e sempre por palavra + seta + forma do + marcador + cor — **cor nunca carrega sozinha**. No par de 2 séries + (`good`/`bad`): bom = vermelho `#e4102f`, 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**). Na extensão de 3 séries (C205, só no + feed): o trio `good`/`neutral`/`neutral-dark` entra junto — melhor = vermelho, + círculo, "↑ amplia"; neutro = cinza `#78716c`, losango, "↗ cresce"; neutro + escuro = tinta `#1c1917`, triângulo, "↘ diminui" (direções factuais, nunca + "recua"/perda) — e o azul `#184e92` não entra em dado nesta variante. +- **Projeção** (`--projected=`) só no último período das duas séries (o trio de + três séries não certifica projeção): ú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ó no par de 2 séries) 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. - **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. -- **Marca oficial (C203):** o rodapé usa o ativo - `public/campaign-kit/jorge-solla-positivo.png` como data URI (paleta oficial: - destaque/valência boa `#e4102f`, sinal de marca `#184e92`; amarelo/verde só - dentro dos ativos). Sem o ativo, o builder falha — nunca recria o lockup - tipográfico. +- **Marca oficial (C203/C206):** o rodapé usa o ativo + `public/campaign-kit/jorge-solla-positivo.png` como data URI numa moldura na + razão do próprio ativo (feed 251×144, quadrado 192×110, story 279×160) — + paleta oficial: destaque/valência melhor `#e4102f`, sinal de marca `#184e92` + (nunca dado); amarelo/verde só dentro dos ativos. Sem o ativo, o builder + falha — nunca recria o lockup tipográfico. - 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). @@ -230,11 +269,12 @@ 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. 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. +- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. Na linha + multi-série o teto é 12 por série. +- **Séries desalinhadas / tom ímpar (par ou trio incompletos) / projeção ou + cruzamento na linha de três séries / três séries fora do feed / 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` @@ -245,8 +285,10 @@ O builder **recusa** em vez de desenhar algo enganoso: ## Referências - Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (inclui - a variação certificada de linha de duas séries, com valência bom/ruim; revisão - C203 — marca e paleta oficiais do kit 1313). + a variação certificada de linha de duas séries, com valência bom/ruim; a + extensão C205 de três séries observadas e a revisão C206 do rodapé/marca, + ambas pendentes de gate humano; base C203 — marca e paleta oficiais do kit + 1313). - Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl: `docs/plans/graficos-dados-instagram-impl.md`. - Marca oficial: `docs/plans/graficos-dados-marca-kit-1313.md` (impl: diff --git a/docs/changelog/2026-09-19-c205-graficos-tres-series-marca.md b/docs/changelog/2026-09-19-c205-graficos-tres-series-marca.md new file mode 100644 index 00000000..d51bba9b --- /dev/null +++ b/docs/changelog/2026-09-19-c205-graficos-tres-series-marca.md @@ -0,0 +1 @@ +**C205 — Gráficos do Instagram: linha de três séries e marca oficial maior no rodapé (2026-09-19):** a skill `graficos-dados` (C191/C203) ganha a variante de **três séries observadas** no feed e um rodapé com a marca ampliada. (1) **Linha de 3 séries (revisão C205):** o contrato aceita 2 ou 3 séries temporais alinhadas (`MIN_SERIES`/`MAX_SERIES`; alinhamento e rótulos checados em todas as séries) e o trio de tons `good`/`neutral`/`neutral-dark` entra junto (ou nenhum), com projeção, cruzamento e tamanho ≠ feed recusados; no renderer, melhor = vermelho/círculo/"↑ amplia", neutro = cinza `#78716c`/losango/"↗ cresce" e neutro escuro = tinta `#1c1917`/triângulo/"↘ diminui" (o azul `#184e92` sai dos dados), com gutter de três linhas (nome / "valor · ±N%" / palavra), leaders em x=570–594 e texto em x=600. (2) **Rodapé (revisão C206):** a moldura passa a abraçar a razão do ativo oficial (feed 251×144, quadrado 192×110, story 279×160; rodapé mínimo 171/128/190 px, fonte 25 px nas cenas densas) — a marca deixa de boiar numa caixa 286×90 com ~129 px vazios. (3) **Superfície:** CLI com `--good/--neutral/--neutral-dark` (par `--good/--bad` intacto; `--medium` aposentado com mensagem acionável), `SKILL.md`/command e o hi-fi (`docs/plans/graficos-dados-instagram-ui-design.html`, C205/C206 aprovadas no gate de 2026-09-19) atualizados no mesmo PR. Peça de validação: leitos de internação do SUS em Vitória da Conquista (CNES, 2015–jul/2026) — hospital estadual 235→369 (+57%), rede municipal 93→113 (+22%) e rede privada 832→510 (−39%). Verificação: unit 4.096 verdes (specs do gerador atualizados), lint/format/knip/typecheck limpos; sem migration. diff --git a/docs/plans/graficos-dados-instagram-ui-design.html b/docs/plans/graficos-dados-instagram-ui-design.html index 446eb2f9..4e08d7ad 100644 --- a/docs/plans/graficos-dados-instagram-ui-design.html +++ b/docs/plans/graficos-dados-instagram-ui-design.html @@ -201,6 +201,7 @@ grid-template-columns: 1fr auto; align-items: end; gap: 30px; + min-height: 171px; } .source-line { margin: 0; @@ -212,25 +213,35 @@ color: var(--ink); font-weight: 750; } - /* Ativo oficial do kit (jorge-solla-positivo.png): exibido inteiro, sem recorte e sem - distorção. O arquivo é o lockup do nome; nunca recriar o lockup com tipografia. */ + /* C206 · ativo oficial 1037×595 (≈1,743:1). A moldura abraça a razão do PNG para eliminar + o letterbox criado pela antiga caixa 286×90. A transparência interna do próprio ativo é + preservada: sem crop, sem scale desproporcional e sem lockup tipográfico recriado. */ .brand-logo-frame { - width: 286px; - height: 90px; - flex: 0 0 286px; - display: flex; - align-items: center; - justify-content: flex-end; + width: 251px; + height: 144px; + flex: 0 0 251px; } .brand-logo-frame img { display: block; - max-width: 100%; - max-height: 100%; - width: auto; - height: auto; + width: 100%; + height: 100%; object-fit: contain; } + /* Porte por canvas do template exportado. Feed é o default acima. */ + .square-canvas .chart-footer { + min-height: 128px; + padding-top: 16px; + } + .square-canvas .source-line { + font-size: 25px; + } + .square-canvas .brand-logo-frame { + width: 192px; + height: 110px; + flex-basis: 192px; + } + .mini-canvas { position: relative; width: 100%; @@ -285,19 +296,14 @@ line-height: 1.2; } .mini-logo-frame { - width: 70px; - height: 23px; - flex: 0 0 70px; - display: flex; - align-items: center; - justify-content: flex-end; + width: 63px; + height: 36px; + flex: 0 0 63px; } .mini-logo-frame img { display: block; - max-width: 100%; - max-height: 100%; - width: auto; - height: auto; + width: 100%; + height: 100%; object-fit: contain; } .mini-bars { @@ -448,6 +454,60 @@ font-size: 28px; font-weight: 750; } + /* ------------------------------------------------------------------ */ + /* CENA 02D · C205 — revisão da extensão para três séries observadas. */ + /* C204 é superada: bom + neutro + neutro escuro; a 02B não muda. */ + /* ------------------------------------------------------------------ */ + .feed-canvas.triple-series .chart-headline { + max-width: 900px; + font-size: 56px; + } + .feed-canvas.triple-series .chart-subtitle { + max-width: 900px; + margin-top: 18px; + font-size: 27px; + } + .feed-canvas.triple-series .source-line { + max-width: 570px; + font-size: 25px; + } + .triple-line-plot { + flex: none; + width: 880px; + height: 650px; + margin-top: 16px; + } + .triple-line-plot svg { + display: block; + width: 880px; + height: 650px; + } + .triple-axis-label { + fill: #78716c; + font-size: 30px; + font-weight: 650; + } + .triple-end-name { + fill: var(--ink); + font-size: 30px; + font-weight: 700; + } + .triple-end-metric { + fill: var(--ink); + font-size: 34px; + font-weight: 850; + font-variant-numeric: tabular-nums; + } + .triple-end-valence { + font-size: 30px; + font-weight: 850; + } + .triple-end-leader { + fill: none; + stroke-width: 3px; + stroke-linecap: square; + stroke-linejoin: round; + } .rule-code { display: inline-flex; padding: 2px 6px; @@ -781,6 +841,7 @@ align-items: end; justify-content: space-between; gap: 28px; + min-height: 190px; } .story-source { max-width: 570px; @@ -788,6 +849,11 @@ font-size: 27px; line-height: 1.3; } + .story-canvas .brand-logo-frame { + width: 279px; + height: 160px; + flex-basis: 279px; + } .state-piece { position: relative; @@ -874,19 +940,14 @@ font-size: 11px; } .state-logo-frame { - width: 116px; - height: 36px; - flex: 0 0 116px; - display: flex; - align-items: center; - justify-content: flex-end; + width: 87px; + height: 50px; + flex: 0 0 87px; } .state-logo-frame img { display: block; - max-width: 100%; - max-height: 100%; - width: auto; - height: auto; + width: 100%; + height: 100%; object-fit: contain; } @@ -937,21 +998,23 @@
APROVADOBASE C203 APROVADA · C205/C206 APROVADAS

- Aprovado no gate em 2026-09-19 — revisão C203 (marca e paleta oficiais do kit 1313). + Base C203 aprovada em 2026-09-19 · revisões C205 e C206 aprovadas no gate em + 2026-09-19.

- O humano validou a substituição do lockup tipográfico pelo ativo oficial e o - mapeamento de cor fixado aqui; este artefato é o registro do aceite visual e a fonte - de verdade do port. + O aceite C203 permanece intacto. A C205 revisa no lugar a CENA 02D — linha temporal de + três séries — e supera a C204, que nunca foi certificada. A C206 revisa somente o + rodapé compartilhado para ampliar a marca oficial. As duas são a fonte de verdade do + port desde o gate humano de 2026-09-19.

@@ -960,9 +1023,9 @@
Revisão C203 · marca oficial do kit 1313Revisões C205 + C206 · aprovadas no gate - Aprovado no gate · 2026-09-19 + Base C203 aprovada · C205/C206 aprovadas Instagram feed + Stories

@@ -1025,6 +1088,91 @@

+
+
+
+

+ REVISÃO VISÍVEL · C205 · APROVADA NO GATE 2026-09-19 +

+

+ Três séries observadas: semântica neutra e gutter com mais respiro +

+

+ A CENA 02D troca bom/médio/neutro por + bom/neutro/neutro escuro, usando apenas neutros oficiais além do + vermelho do melhor resultado. A gutter passa de quatro para três linhas por bloco, com + valor + variação na mesma linha e faixas mais arejadas. O último ponto segue + jul/2026 observado, sem qualquer artefato de projeção. A CENA 02B + preserva integralmente o par bom/ruim aprovado. +

+
+ APROVADA · GATE 2026-09-19 +
+
+ +
+
+
+

+ REVISÃO VISÍVEL · C206 · APROVADA NO GATE 2026-09-19 +

+

+ Marca oficial maior, sem caixa vazia artificial no rodapé +

+

+ O ativo oficial 1037×595 permanece inteiro. A antiga moldura + 286×90 era larga demais para a razão do PNG e fazia a imagem ocupar + só cerca de 157×90 px. A C206 troca a moldura por caixas na mesma razão do ativo: + 251×144 no feed, 192×110 no quadrado e + 279×160 no story. O PNG passa a ocupar 100% dessas caixas com + object-fit: contain; só a transparência nativa do + arquivo permanece. +

+
+ APROVADA · GATE 2026-09-19 +
+
+
+

Feed · 1080×1350

+

+ brand-logo-frame 251×144; rodapé mínimo 171 px, + padding-top 25 px, gap 30 px. Fonte/nota 27 px; nas linhas densas 02B/02D, 25 px. +

+
+
+

Quadrado · 1080×1080

+

+ brand-logo-frame 192×110; rodapé mínimo 128 px, + padding-top 16 px, gap 30 px. Fonte/nota 25 px. O porte menor protege a altura útil do + canvas mais curto sem voltar à marca miúda da C203. +

+
+
+

Story · 1080×1920

+

+ brand-logo-frame 279×160; rodapé mínimo 190 px, + padding-top 28 px, gap 28 px. Fonte/nota 27 px; tudo termina acima da safe zone. +

+
+
+

+ Compatibilidade: o crescimento é absorvido pelo espaço flexível antes do + rodapé; não se altera headline, subtítulo, plot, escala, eixo ou gutter das CENAS 01, 02, + 02B e 02D. O rodapé segue off-white, com filete #e7e5e4 e texto + #57534e; o contraste e as margens laterais de 84 px permanecem. +

+
+
@@ -1226,7 +1374,7 @@

CENA 02 · SISTEMA

- Cinco variações, um mesmo template + Cinco variações-base + duas extensões, um mesmo template

@@ -2129,11 +2277,448 @@

+ + + + +
+
+
+

+ CENA 02D · REVISÃO C205 · C204 SUPERADA · APROVADA NO GATE 2026-09-19 +

+

+ Feed 4:5 · linha temporal de três séries observadas +

+
+
+ 1080 × 1350 + Plot SVG · viewBox 880 × 650 + 36 marcas · sem projeção +
+
+ +
+
+
+
+
+
+

Comparação no tempo

+

+ Hospital estadual puxa a ampliação de leitos de internação +

+

+ Vitória da Conquista (BA) · leitos existentes · 2015–2026 +

+ +
+ + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rede privada + 510 · −39% + + ↘ diminui + + + + Hospital Estadual + 369 · +57% + + ↑ amplia + + + + Rede municipal + 113 · +22% + + ↗ cresce + + + + 2015 + 2017 + 2019 + 2021 + 2023 + 2026 + + +
+ +
+

+ Fonte: Ministério da Saúde — CNES · leitos de internação por + esfera jurídica
Nota: Último ponto: jul/2026. Rede privada = leitos + empresariais + sem fins lucrativos. +

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

REGRAS DA LINHA DE 3 SÉRIES · C205

+

+ Contrato visual da extensão para o porte classe-a-classe +

+
+
+

Extensão, entrada e teto

+

+ Esta variante estende a CENA 02B; não a substitui. Exatamente 3 + séries, mesmos períodos e mesma ordem. Teto aprovado: + 12 pontos por série no feed (36 marcas). É o limite exibido nesta + cena: linha 7 px + passo mínimo 46 px deixam ≥26 px livres entre marcadores de 20 + px. Acima de 12, período desalinhado ou valor ausente → perguntar; nunca apertar, + interpolar ou omitir. Esta extensão não certifica quadrado nem story. +

+
+
+

Mapa de tons · sem depender de cor

+

+ Bom/melhor = + #e4102f + círculo r=8 + ↑ “amplia”. Neutro = + #78716c + losango 14×14 rotado 45° + ↗ “cresce”. + Neutro escuro = + #1c1917 + triângulo ▼ 20×18 + ↘ “diminui”. Marcadores + são preenchidos porque os 36 pontos são observados. Azul + #184e92 fica só na marca/kicker/régua, nunca em dado. +

+
+
+

Convivência com a CENA 02B

+

+ O contrato de 2 séries não muda: bom = vermelho/círculo/“↑ amplia”; ruim = + #78716c + quadrado + ↓ “recua”; comparação sem tons + também permanece. Na variante de 3 séries, losango + ↗ “cresce” distinguem o neutro + municipal, e triângulo + ↘ “diminui” distinguem o neutro escuro privado. Não + converter direção em juízo nem reaproveitar “recua” fora do tom ruim da CENA 02B. +

+
+
+

Plot e escala · feed 1080×1350

+

+ Canvas padding 76 / 84 / 58; headline 56 px; + subtítulo 27 px; plot margin-top 16. SVG + viewBox 0 0 880 650. Dados: x=52…560, y=56…544; topo + y=56; baseline y=544; grid y=56/300/544; domínio compartilhado 0…1.000. Gutter de + texto x=600…880 (280 px), começando 40 px após o + último ponto. Linhas 7 px. O corredor x=560…600 pertence só aos leaders; texto nunca + invade o plot. +

+
+
+

Gutter, leading e colisão

+

+ Cada bloco cai de quatro para três linhas: nome; valor + variação; + seta + palavra. Fontes/line-box: nome 30/36 px; + métrica 34/40 px; valência + 30/36 px. Baselines: Privada + 62/108/150, Estadual + 258/304/346, Municipal + 454/500/542. O pitch é 196 px; descontados os + line-boxes, sobram 70+ px entre blocos. Ordenar pelo y do ponto final e alocar, + nessa ordem, as três lanes fixas; nunca aproximar blocos para seguir o ponto. +

+
+
+

Leaders e rótulos finais

+

+ Texto começa em x=600. Leader 3 px sai de x=570 no y + do ponto, passa pelo trilho x=580, dobra em x=590 até o baseline da métrica e + termina em x=594; sobra um respiro de 6 px antes do texto. Alvos: + y=108/304/500. Como finais e blocos têm a mesma + ordem, leaders não se cruzam. Nome em uma linha; se exceder 280 px, pedir forma + curta — nunca reduzir abaixo de 30 px, quebrar ou abreviar sozinho. A métrica usa + tabular-nums no formato literal “valor · ±N%”. +

+
+
+

Eixo X · 12 pontos observados

+

+ Pontos em x= + 52/98/144/191/237/283/329/375/421/468/514/560 + (passo ≈46 px). Fonte 30 px. Para 9–12 pontos, manter a regra da 02B: alternar desde + o primeiro; se o último não cair na alternância, remover o penúltimo exibido e fixar + o último. Aqui: 2015, 2017, 2019, 2021, 2023, 2026. “2026” continua + simples no eixo; “último ponto: jul/2026” fica na nota. Nunca anexar “projeção”. +

+
+
+

Variação observada e ausência de projeção

+

+ A variação compara 2015 ao último ponto observado, jul/2026: Estadual + 235→369 · +57%; Municipal 93→113 · +22%; Privada + 832→510 · −39%. A gutter funde valor e variação e remove a palavra + “observado”; a nota carrega a precisão mensal. Proibidos nesta cena: + projection-band, tracejado, marcador vazado e label + “projeção”. +

+
+
+

Copy e fonte · literais desta cena

+

+ Manchete: “Hospital estadual puxa a ampliação de leitos de internação”. Subtítulo: + “Vitória da Conquista (BA) · leitos existentes · 2015–2026”. Fonte: “Ministério da + Saúde — CNES · leitos de internação por esfera jurídica”. Nota: “Último ponto: + jul/2026. Rede privada = leitos empresariais + sem fins lucrativos.” +

+
+
+

Mapa de classes da extensão

+

+ Chassi: triple-line-plot, + triple-axis-label. Séries/marcadores: + triple-series-good / triple-markers-good, + triple-series-neutral / triple-markers-neutral e + triple-series-neutral-dark / triple-markers-neutral-dark. Saída: triple-end-leader, + triple-end-name, + triple-end-metric e + triple-end-valence. Reusar sem alteração no corpo: + top-rule, + chart-context, + chart-headline, + chart-subtitle, + chart-footer. No rodapé, aplicar a C206: + source-line ≥25 px e + brand-logo-frame 251×144 no feed. +

+
+
+
+
+ - - + +
Fonte: Ministério da Saúde — CNES
Nota: sem variação: EMAD, EMAP, ENASF-AB e ECR.

-
- -
-
JORGE SOLLA
-
MANDATO DEPUTADO FEDERAL
-
+
+ Jorge Solla — Deputado Federal
- NEEDS ASSET · LOGO OFICIAL @@ -2419,10 +3002,14 @@ Fonte: Ministério da Saúde — CNES · Nota: sem variação: EMAD, EMAP, ENASF-AB e ECR. -
JORGE SOLLA
+
+ Jorge Solla — Deputado Federal +
- NEEDS ASSET · LOGO OFICIAL @@ -2529,8 +3116,7 @@ chart-subtitle, chart-footer, source-line, - brand-lockup e - asset-flag. + brand-logo-frame e o PNG oficial inteiro.

@@ -2787,7 +3373,9 @@

Densidade

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. + até 12 pontos alinhados por série. Revisão C205 aprovada: linha de 3 séries no feed, + até 12 pontos alinhados por série (36 marcas). Story mantém 5 apenas para + barra/coluna; C205 não certifica quadrado nem story.

@@ -2798,6 +3386,15 @@ vermelhas quando as duas categorias forem explicitamente positivas.

+
+

Rodapé e marca · C206 aprovada

+

+ Fonte/nota: 27 px no feed/story; 25 px no quadrado e nas cenas densas 02B/02D. Marca + oficial inteira: 251×144 (feed), 192×110 (quadrado), 279×160 (story). Rodapé mínimo: + 171/128/190 px, respectivamente. Sem recorte, distorção, recoloração ou lockup + recriado. +

+
@@ -2807,9 +3404,10 @@ > base C203 aprovada · extensão C191 - certificada pelo designer e aprovada pelo humano · 2026-09-19certificada pelo designer e aprovada pelo humano · C205 (supera a C204) + + C206 aprovadas no gate de 2026-09-19 - Kit 1313 oficial · valência redundante · NEEDS ASSET resolvido + Kit 1313 oficial · valência redundante · marca ampliada · NEEDS ASSET resolvido diff --git a/scripts/build-chart-from-data.mjs b/scripts/build-chart-from-data.mjs index 944cfd07..fc897db6 100644 --- a/scripts/build-chart-from-data.mjs +++ b/scripts/build-chart-from-data.mjs @@ -26,6 +26,7 @@ import { MAX_POINTS, MAX_POINTS_LINE, MAX_POINTS_STORY, + MAX_SERIES, RELATION_LABEL, SIZES, classifyRelation, @@ -59,25 +60,49 @@ 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`. + * `--good`/`--bad` name the series that carry the pair of the approved + * two-series variant; `--good`/`--neutral`/`--neutral-dark` name the triad of + * the three-series extension (C205). The vocabularies never mix: the tone flags + * enter together (the whole pair or the whole triad) or not at all, and + * `validateSpec` re-checks the contract. */ 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 triad = series.length === MAX_SERIES + const allowed = triad ? ['good', 'neutral', 'neutral-dark'] : ['good', 'bad'] + const informed = ['good', 'bad', 'medium', 'neutral', 'neutral-dark'].filter( + (tone) => flags[tone], + ) + if (informed.length === 0) return series + if (informed.includes('medium')) { + die( + 'o tom --medium saiu na revisão C205: use --neutral (neutro) e --neutral-dark (neutro escuro).', + ) + } + for (const tone of informed) { + if (!allowed.includes(tone)) { + die( + triad + ? `--${tone} é do par de duas séries: a linha de três séries usa --good, --neutral e --neutral-dark.` + : `--${tone} é do trio de três séries: a linha de duas séries usa --good e --bad.`, + ) + } + } + if (informed.length !== allowed.length) { + die(`informe os tons juntos (${allowed.map((tone) => `--${tone}`).join(', ')}) ou nenhum.`) + } + const names = informed.map((tone) => String(flags[tone])) + if (new Set(names).size !== names.length) { + die(`os tons apontam para a mesma série: ${names.join(', ')}.`) } const known = series.map((serie) => serie.name) - for (const name of [good, bad].filter(Boolean)) { + for (const name of names) { 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 tone = allowed.find((candidate) => String(flags[candidate]) === serie.name) + return tone ? { ...serie, tone } : serie }) } diff --git a/scripts/lib/chartData.mjs b/scripts/lib/chartData.mjs index 8f79296f..2813c041 100644 --- a/scripts/lib/chartData.mjs +++ b/scripts/lib/chartData.mjs @@ -24,12 +24,20 @@ 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. + * Two-series and three-series time comparison (approved variants): an annual + * series carries more points than a ranking, per series. One tone per series + * encodes the valence — the pair of C191 (good/bad) or the triad of the C205 + * extension (good/neutral/neutral-dark, feed only, no projection and no + * crossing). */ -export const MAX_SERIES = 2 +export const MIN_SERIES = 2 +export const MAX_SERIES = 3 export const MAX_POINTS_LINE = 12 -const SERIES_TONES = ['good', 'bad'] +const PAIR_TONES = ['good', 'bad'] +const TRIAD_TONES = ['good', 'neutral', 'neutral-dark'] + +/** Allowed tone vocabulary of the informed series count (never a cross-vocabulary). */ +const tonesFor = (count) => (count === MAX_SERIES ? TRIAD_TONES : PAIR_TONES) /** Output canvases: feed 4:5 (default), square, stories/reels 9:16 with safe bands. */ export const SIZES = { @@ -157,7 +165,7 @@ const cleanMatrix = (matrix) => * turns into a series in silence. */ const looksLikeSeriesHeader = (header) => - header.length >= MAX_SERIES + 1 && + header.length >= MIN_SERIES + 1 && header.every((cell) => cell !== '' && parseNumber(cell) === null) const detectSeries = (cleaned) => { @@ -413,23 +421,35 @@ export const classifyRelation = (rows, forcedType = null) => { } /** - * 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. + * Guardrails of the multi-series time line (approved variants): aligned + * periods, at most `MAX_POINTS_LINE` per series, honest zero base, the tone + * pair (2 series) or triad (3 series, C204 — feed only, no projection, no + * crossing) informed together 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).') + throw new Error('a comparação de 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.') + throw new Error('a linha multi-série marca a valência pelos tons — não use --highlight.') } - if (series.length !== MAX_SERIES) { + if (series.length < MIN_SERIES || series.length > MAX_SERIES) { throw new Error( - `a linha multi-série desenha ${MAX_SERIES} séries (${series.length} recebidas).`, + `a linha multi-série desenha ${MIN_SERIES} ou ${MAX_SERIES} séries (${series.length} recebidas).`, ) } + if (series.length === MAX_SERIES) { + if (spec.projectedLabel) { + throw new Error('a linha de três séries não certifica projeção — remova --projected.') + } + if (spec.crossingLabel) { + throw new Error('a linha de três séries não certifica cruzamento — remova --crossing.') + } + if (spec.size && spec.size !== 'feed') { + throw new Error('a linha de três séries só está certificada no feed (1080×1350).') + } + } const names = new Set() for (const serie of series) { if (typeof serie.name !== 'string' || serie.name.trim() === '') @@ -454,27 +474,38 @@ const validateSeries = (spec) => { } } } - const [first, second] = series - const aligned = - first.rows.length === second.rows.length && - first.rows.every((row, index) => row.label === second.rows[index].label) + const [first] = series + const aligned = series.every( + (serie) => + serie.rows.length === first.rows.length && + serie.rows.every((row, index) => row.label === first.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.') + throw new Error('a linha multi-série exige períodos no eixo (ex.: anos) — sem categorias.') } + const allowedTones = tonesFor(series.length) 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.') + throw new Error( + allowedTones === TRIAD_TONES + ? 'informe os três tons (melhor, neutro e neutro escuro) ou de nenhum.' + : '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 (!allowedTones.includes(tone)) { + throw new Error(`tom inválido: ${JSON.stringify(tone)} (use ${allowedTones.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 (informed.length === series.length && new Set(tones).size !== series.length) { + throw new Error( + allowedTones === TRIAD_TONES + ? 'os três tons precisam ser distintos (melhor, neutro e neutro escuro).' + : '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 diff --git a/scripts/lib/chartPrimitives.mjs b/scripts/lib/chartPrimitives.mjs index 0cd1ba5c..d8e8824e 100644 --- a/scripts/lib/chartPrimitives.mjs +++ b/scripts/lib/chartPrimitives.mjs @@ -245,9 +245,10 @@ const niceMax = (value) => { /** * 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"). + * (design: "2017, 2019, 2021, 2023 e 2026"). Shared by the two- and the + * three-series lines. */ -const dualAxisIndices = (count) => { +const axisLabelIndices = (count) => { if (count < 9) return [...Array(count).keys()] const shown = new Set() for (let index = 0; index < count; index += 2) shown.add(index) @@ -458,7 +459,7 @@ export const dualLineChart = ({ })() : '' - const axis = dualAxisIndices(count) + const axis = axisLabelIndices(count) .map( (index) => `${htmlEscape(series[0].rows[index].label)}`, @@ -538,3 +539,158 @@ export const lineChart = ({ .join('')} ` } + +/** + * Geometry of the approved three-series time line (C205 extension, feed only). + * The data box is narrower than the two-series one (x=52…560) so the wider end + * gutter (x=600…880, 280px) holds three three-line blocks; x=560…600 belongs to + * the leaders only. Lanes: name / metric / valence with 30–40px line boxes and + * a 196px pitch (design: 62/108/150 · 258/304/346 · 454/500/542). + */ +const TRIPLE_GEOMETRY = { + feed: { + width: 880, + height: 650, + left: 52, + right: 560, + top: 56, + baseline: 544, + stroke: 7, + dotRadius: 8, + diamondSize: 14, + triangleWidth: 20, + triangleTop: 8, + triangleApex: 10, + axisSize: 30, + axisY: 636, + zeroX: 42, + zeroY: 538, + nameSize: 30, + metricSize: 34, + valenceSize: 30, + textX: 600, + leader: { start: 570, rail: 580, bend: 590, end: 594 }, + laneOffsets: [62, 108, 150], + lanePitch: 196, + }, +} + +/** + * Three-series time line (C205 extension of the two-series variant): three + * observed series on a shared zero-based scale and the triad of redundant tones + * (melhor = red circle ↑ "amplia"; neutro = gray diamond ↗ "cresce"; neutro + * escuro = ink triangle ↘ "diminui") with a three-line end gutter. Every point + * is observed — no band, no dash, no hollow marker, no "projeção". + * + * `series` items are `{ name, tone: 'good'|'neutral'|'neutral-dark', rows }`; + * the caller owns the palette and the valence words (never color alone). + */ +export const tripleLineChart = ({ series, size = 'feed', colors, valence, format }) => { + const cfg = TRIPLE_GEOMETRY[size] ?? TRIPLE_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 lastIndex = count - 1 + + const marker = (tone, x, y) => { + if (tone === 'neutral') { + const half = cfg.diamondSize / 2 + return `` + } + if (tone === 'neutral-dark') { + const half = cfg.triangleWidth / 2 + return `` + } + return `` + } + + const gridLines = ` + + + 0` + + const lines = series + .map((serie) => { + const color = colors[serie.tone] ?? colors.neutral + const points = serie.rows.map((row, index) => ({ x: xAt(index), y: yAt(row.value) })) + return ` + ${points.map((point) => marker(serie.tone, point.x, point.y)).join('')}` + }) + .join('') + + const changeOf = (serie) => { + const firstValue = serie.rows[0].value + const observed = serie.rows[lastIndex].value + if (!(firstValue > 0) || !Number.isFinite(observed)) return null + const percent = Math.round(((observed - firstValue) / firstValue) * 100) + const sign = percent > 0 ? '+' : percent < 0 ? '−' : '' + return { percent, percentLabel: `${sign}${Math.abs(percent)}%` } + } + + const blocks = series + .map((serie) => ({ + serie, + color: colors[serie.tone] ?? colors.neutral, + change: changeOf(serie), + point: { y: yAt(serie.rows[lastIndex].value) }, + })) + .sort((a, b) => a.point.y - b.point.y) + .map((block, lane) => ({ + ...block, + baselines: cfg.laneOffsets.map((offset) => offset + lane * cfg.lanePitch), + })) + + const endBlocks = blocks + .map((block) => { + const { baselines, point } = block + const metric = `${format(block.serie.rows[lastIndex])}${ + block.change ? ` · ${block.change.percentLabel}` : '' + }` + const text = (className, size, fill, value, index) => + `${htmlEscape(value)}` + return [ + ``, + text('triple-end-name', cfg.nameSize, null, block.serie.name, 0), + text('triple-end-metric', cfg.metricSize, null, metric, 1), + text( + 'triple-end-valence', + cfg.valenceSize, + block.color, + valence[block.serie.tone] ?? '', + 2, + ), + ].join('') + }) + .join('') + + const axis = axisLabelIndices(count) + .map( + (index) => + `${htmlEscape(series[0].rows[index].label)}`, + ) + .join('') + + const ariaLabel = `${series + .map((serie) => { + const change = changeOf(serie) + const direction = !change + ? '' + : change.percent > 0 + ? 'aumento' + : change.percent < 0 + ? 'diminuição' + : 'sem variação' + const trend = change ? `, ${direction} de ${Math.abs(change.percent)} por cento` : '' + return `${serie.name}: de ${format(serie.rows[0])} em ${serie.rows[0].label} para ${format(serie.rows[lastIndex])} em ${serie.rows[lastIndex].label}${trend}` + }) + .join('. ')}. Todos os pontos de cada série são observados.` + + return ` + ${gridLines} + ${lines} + ${endBlocks} + ${axis} + ` +} diff --git a/scripts/lib/graficosInstagramRender.mjs b/scripts/lib/graficosInstagramRender.mjs index 14fe2b6e..ebbf9d76 100644 --- a/scripts/lib/graficosInstagramRender.mjs +++ b/scripts/lib/graficosInstagramRender.mjs @@ -10,7 +10,12 @@ */ import { RELATION_LABEL, SERIES_RELATION_LABEL, SIZES } from './chartData.mjs' -import { dualLineChart, lineChart, proportionalPercent } from './chartPrimitives.mjs' +import { + dualLineChart, + lineChart, + proportionalPercent, + tripleLineChart, +} from './chartPrimitives.mjs' import { htmlEscape } from './reportText.mjs' /** Official kit 1313 palette, mapped by role (C203). */ @@ -180,9 +185,37 @@ const dualBody = (spec) => { })}` } +/** + * Three-series time line (approved C205 extension of the two-series variant, + * feed only): melhor = red + circle + "↑ amplia"; neutro = gray + diamond + + * "↗ cresce"; neutro escuro = ink + triangle + "↘ diminui" (factual direction, + * never the bad valence "↓ recua"). Every tone travels by word + arrow + shape + * + color, and the brand blue leaves the data entirely. + */ +const TRIPLE_COLORS = { + good: SOLLA_PALETTE.highlight, + neutral: SOLLA_PALETTE.axis, + 'neutral-dark': SOLLA_PALETTE.ink, + grid: SOLLA_PALETTE.barStrong, + ink: SOLLA_PALETTE.ink, +} + +const TRIPLE_VALENCE = { good: '↑ amplia', neutral: '↗ cresce', 'neutral-dark': '↘ diminui' } + +const tripleBody = (spec) => + `
${tripleLineChart({ + series: spec.series, + size: 'feed', + colors: TRIPLE_COLORS, + valence: TRIPLE_VALENCE, + format: (row) => formatValue(row.value), + })}
` + const plotBody = (spec) => { if (spec.chartType === 'delta') return deltaBody(spec) - if (Array.isArray(spec.series) && spec.series.length > 0) return dualBody(spec) + const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0 + if (seriesCount === 3) return tripleBody(spec) + if (seriesCount > 0) return dualBody(spec) if (spec.chartType === 'anchor') return anchorBody(spec) if (spec.chartType === 'column') return columnBody(spec) if (spec.chartType === 'line') { @@ -316,22 +349,28 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; } .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; } +.triple-line-plot { flex: none; } +.triple-line-plot svg { display: block; width: 880px; height: auto; } +.triple-grid-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-opacity: 0.32; stroke-width: 2; } +.triple-zero-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; } +.triple-axis-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 650; } +.triple-end-name { fill: ${SOLLA_PALETTE.ink}; font-weight: 700; } +.triple-end-metric { fill: ${SOLLA_PALETTE.ink}; font-weight: 850; font-variant-numeric: tabular-nums; } +.triple-end-valence { font-weight: 850; } +.triple-end-leader { fill: none; stroke-width: 3; stroke-linecap: square; stroke-linejoin: round; } .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; + min-height: 171px; } .source { margin: 0; color: ${SOLLA_PALETTE.label}; font-size: 27px; line-height: 1.32; } .source strong { color: ${SOLLA_PALETTE.ink}; font-weight: 750; } -.brand-logo-frame { - width: 286px; height: 90px; flex: 0 0 286px; - display: flex; align-items: center; justify-content: flex-end; -} +.brand-logo-frame { width: 251px; height: 144px; flex: 0 0 251px; } .brand-logo-frame img { - display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; - object-fit: contain; + display: block; width: 100%; height: 100%; object-fit: contain; }` /** Per-size vertical rhythm of the template (feed 4:5, square, stories 9:16). */ @@ -384,19 +423,30 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { const sizeKey = SIZES[spec.size] ? spec.size : 'feed' const canvas = SIZES[sizeKey] const layout = layoutFor(sizeKey) - const dual = Array.isArray(spec.series) && spec.series.length > 0 + const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0 + const dual = seriesCount === 2 + const triple = seriesCount === 3 const dualPositive = Boolean(spec.dualPositive) const dualPositiveStyles = !dualPositive || sizeKey !== 'feed' ? '' : '.headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; }' - 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 dualStyles = + !dual && !triple + ? '' + : triple + ? '.headline { font-size: 56px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 27px; } .source { max-width: 570px; font-size: 25px; } .plot { margin-top: 16px; }' + : 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 footerStyles = + sizeKey === 'square' + ? '.footer { min-height: 128px; padding-top: 16px; } .source { font-size: 25px; } .brand-logo-frame { width: 192px; height: 110px; flex-basis: 192px; }' + : sizeKey === 'story' + ? '.footer { min-height: 190px; padding-top: 28px; gap: 28px; } .brand-logo-frame { width: 279px; height: 160px; flex-basis: 279px; }' + : '' const delta = spec.chartType === 'delta' const deltaStyles = !delta ? '' @@ -406,7 +456,8 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { ? '.inner { padding: 56px 84px 48px; } .plot { margin-top: 0; } .headline { font-size: 54px; } .subtitle { font-size: 28px; } .delta-grade { margin-top: 24px; --delta-bar-h: 42px; --delta-gap: 12px; --delta-v1-w: 84px; --delta-v2-w: 104px; } .delta-value { font-size: 30px; }' : '.plot { margin-top: 0; } .headline { font-size: 64px; } .delta-grade { margin-top: 40px; --delta-gap: 22px; --delta-bar-h: 52px; }' const kicker = - spec.kicker ?? (dual ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico')) + spec.kicker ?? + (dual || triple ? 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 ` @@ -422,13 +473,14 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { .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; } + ${footerStyles} ${dualStyles} ${deltaStyles} ${dualPositiveStyles} -