From ca59523759484cf53c9a0f7dc358b02c39454a74 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 19 Sep 2026 15:41:28 -0300 Subject: [PATCH] =?UTF-8?q?feat(C207):=20proje=C3=A7=C3=A3o=20no=20=C3=BAl?= =?UTF-8?q?timo=20per=C3=ADodo=20do=20trio=20do=20gerador=20de=20gr=C3=A1f?= =?UTF-8?q?icos=20(gate=202026-09-19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/graficos-dados/SKILL.md | 45 +- ...9-19-c207-graficos-projecao-tres-series.md | 1 + .../graficos-dados-instagram-ui-design.html | 606 +++++++++++++++++- scripts/lib/chartData.mjs | 8 +- scripts/lib/chartPrimitives.mjs | 143 ++++- scripts/lib/graficosInstagramRender.mjs | 20 +- tests/unit/buildChartFromData.unit.spec.ts | 43 ++ tests/unit/chartData.unit.spec.ts | 7 +- tests/unit/chartPrimitives.unit.spec.ts | 104 +++ .../unit/graficosInstagramRender.unit.spec.ts | 28 + 10 files changed, 938 insertions(+), 67 deletions(-) create mode 100644 docs/changelog/2026-09-19-c207-graficos-projecao-tres-series.md diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md index ddb4ae35..75809798 100644 --- a/.agents/skills/graficos-dados/SKILL.md +++ b/.agents/skills/graficos-dados/SKILL.md @@ -56,8 +56,9 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor), 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; + escuro = tinta/triângulo/"↘ diminui" —, só no feed, sem cruzamento e com + projeção no último período desde a C207 (marcadores finais vazados, faixa e + tracejado próprios); - **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); @@ -97,6 +98,16 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor), --type=line --size=feed \ --good="Hospital Estadual" --neutral="Rede municipal" --neutral-dark="Rede privada" ``` + Três séries com o último período projetado (extensão C207, feed): + ```bash + node scripts/build-chart-from-data.mjs --in= \ + --headline="Produção estadual cresce 8x enquanto a municipal cai pela metade" \ + --subtitle="Procedimentos ambulatoriais do SUS · Vitória da Conquista (BA) · 2015–2026" \ + --source="Ministério da Saúde — Sistema de Informações Ambulatoriais do SUS (SIA/SUS)" \ + --note="2026 é projeção pela média de janeiro a junho × 2." \ + --type=line --size=feed \ + --good="Estadual" --neutral="Rede privada" --neutral-dark="Municipal" --projected=2026 + ``` 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 \ @@ -176,7 +187,8 @@ 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) ou `good | neutral | neutral-dark` (o trio -da extensão C205, só no feed, sem `projectedLabel` nem `crossingLabel`); as +da extensão C205/C207, só no feed, sem `crossingLabel`; a projeção no último +período é certificada desde a C207); 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 @@ -237,10 +249,15 @@ O builder **recusa** em vez de desenhar algo enganoso: 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. +- **Projeção** (`--projected=`) só no último período da linha multi-série + (certificada no par de 2 séries e no trio de 3 desde a C207): último segmento + tracejado, marcador final vazado (nas três formas do trio), faixa neutra com o + rótulo "projeção \" no topo e nota do método. No par, a variação da + gutter fecha no último ponto **observado** ("±N% até \"); + no trio (C207), a variação fecha no próprio ponto projetado — o mesmo par da + manchete — também nomeado no rótulo superior (feedback do gate humano: + porcentagem tem de bater com o valor exibido e com a manchete). 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. @@ -271,10 +288,10 @@ O builder **recusa** em vez de desenhar algo enganoso: rótulo, valor não numérico, rótulo repetido. Pergunte à pessoa; não invente. - **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. Na linha multi-série o teto é 12 por série. -- **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. +- **Séries desalinhadas / tom ímpar (par ou trio incompletos) / cruzamento na + linha de três séries / três séries fora do feed / cruzamento não confirmado / + projeção fora do último período** → o builder pede a correção com mensagem + 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` @@ -286,9 +303,9 @@ O builder **recusa** em vez de desenhar algo enganoso: - Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (inclui a variação certificada de linha de duas séries, com valência bom/ruim; a - extensão C205 de três séries observadas e a revisão C206 do rodapé/marca, - ambas pendentes de gate humano; base C203 — marca e paleta oficiais do kit - 1313). + extensão C205 de três séries observadas; a revisão C207 da projeção no trio, + nova e aguardando gate humano; e a revisão C206 do rodapé/marca; base C203 — + marca e paleta oficiais do kit 1313). - 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-c207-graficos-projecao-tres-series.md b/docs/changelog/2026-09-19-c207-graficos-projecao-tres-series.md new file mode 100644 index 00000000..161889de --- /dev/null +++ b/docs/changelog/2026-09-19-c207-graficos-projecao-tres-series.md @@ -0,0 +1 @@ +**C207 — Gráficos do Instagram: projeção no último período da linha de três séries (2026-09-19):** a skill `graficos-dados` (C191/C203/C205/C206) certifica a variante do trio no feed com o último período projetado. (1) **Contrato (C207):** `validateSeries` aceita `projectedLabel` na linha de 3 séries — o período tem de ser o último e comum às três; cruzamento e tamanhos ≠ feed seguem recusados — e `tripleLineChart` ganha a projeção: faixa neutra cobrindo o último passo com o rótulo "projeção \" no topo (x=552/y=45), só o último segmento tracejado (`18 12`), os três marcadores finais vazados mantendo a forma do tom (círculo r=11, losango 28×28, triângulo ▼ 30×26, papel `#faf9f7` + stroke 5) e a gutter com o valor projetado + a variação 2015→projeção, com a terceira lane só a valência; aria-label anuncia as três projeções e a variação até o período projetado. (2) **Feedback do gate humano:** a copy troca "produção/procedimentos ambulatoriais" por "consultas e exames" (termo popular); a porcentagem passa a fechar no mesmo par da manchete (2015→2026 projetado: +706% / −49% / +3%) e o "até 2025" é aposentado; após a colisão medida no render (Chromium, getBBox) entre o literal e as palavras de valência, "projeção 2026" migra para o rótulo superior e a lane 3 fica limpa. (3) **Superfície:** `SKILL.md` atualizado (exemplo CLI com `--projected` no trio, guardrail da projeção e troubleshooting), hi-fi com a CENA 02E/C207 aprovada no gate de 2026-09-19. Peça de validação: produção ambulatorial do SUS (SIA/SUS) em Vitória da Conquista, 2015–2026 (2026 = projeção jan–jun ×2) — estadual 522.656→4.213.052 (+706%, 8,1x), municipal 2.904.003→1.492.020 (−49%) e rede privada (Empresariais + Sem Fins Lucrativos) 833.679→855.684 (+3%). Verificação: unit 4.158 verdes (specs do gerador estendidos em chartData/chartPrimitives/graficosInstagramRender/buildChartFromData), lint/format/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 4e08d7ad..8da406c3 100644 --- a/docs/plans/graficos-dados-instagram-ui-design.html +++ b/docs/plans/graficos-dados-instagram-ui-design.html @@ -508,6 +508,35 @@ stroke-linecap: square; stroke-linejoin: round; } + /* C207 · extensão projetada da C205. A faixa termina no limite do plot + (x=560): nunca entra no corredor de leaders nem na gutter. */ + .triple-projection-band { + fill: #a8a29e; + fill-opacity: 0.16; + } + .triple-projection-label { + fill: #78716c; + font-size: 30px; + font-weight: 700; + } + .triple-series-projection { + stroke-dasharray: 18 12; + } + .triple-marker-projected { + fill: var(--paper); + stroke-width: 5px; + stroke-linejoin: round; + } + .triple-projected .triple-end-metric { + font-size: 30px; + } + .feed-canvas.triple-projected .chart-headline { + max-width: 910px; + font-size: 48px; + } + .feed-canvas.triple-projected .source-line { + max-width: 620px; + } .rule-code { display: inline-flex; padding: 2px 6px; @@ -998,23 +1027,26 @@
BASE C203 APROVADA · C205/C206 APROVADASBASE C203 APROVADA · C205/C206/C207 APROVADAS

- Base C203 aprovada em 2026-09-19 · revisões C205 e C206 aprovadas no gate em + Base C203 aprovada em 2026-09-19 · revisões C205, C206 e C207 aprovadas no gate em 2026-09-19.

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. + rodapé compartilhado para ampliar a marca oficial. A C207 estende a C205 com o último + período projetado (CENA 02E). + As três são a fonte de verdade do port desde o gate humano de 2026-09-19.

@@ -1023,7 +1055,7 @@
Revisões C205 + C206 · aprovadas no gateC207 · aprovada no gate de 2026-09-19 Base C203 aprovada · C205/C206 aprovadas Instagram feed + Stories @@ -1033,7 +1065,7 @@

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 + deliberadamente genéricos; as linhas multi-série usam casos fornecidos de Vitória da Conquista. As peças fixam hierarquia, grid, tratamento de marca e limites do gerador.

@@ -1173,6 +1205,34 @@

+
+
+
+

+ REVISÃO VISÍVEL · C207 · AJUSTE PÓS-FEEDBACK DO GATE · APROVADA NO GATE 2026-09-19 +

+

+ Três séries com o último período projetado +

+

+ A CENA 02E estende a C205 no feed, sem alterar a variante observada: + aplica às três formas a linguagem de projeção já certificada na CENA 02B. Faixa, + tracejado final, marcadores vazados, aria-label e nota tornam 2026 inequívoco. Após o + feedback humano no gate, valor e variação da gutter agora fecham no mesmo ponto + projetado de 2026. O ano migra para o rótulo superior “projeção 2026”; a terceira lane + da gutter preserva somente a valência, sem colisão. A peça final foi aprovada pelo + humano no gate de 2026-09-19. +

+
+ APROVADA · GATE 2026-09-19 +
+
+
@@ -1374,7 +1434,7 @@

CENA 02 · SISTEMA

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

@@ -2714,6 +2774,524 @@

+ + + + +
+
+
+

+ CENA 02E · REVISÃO C207 · AJUSTE PÓS-FEEDBACK · APROVADA NO GATE 2026-09-19 +

+

+ Feed 4:5 · linha temporal de três séries com projeção final +

+
+
+ 1080 × 1350 + Plot SVG · viewBox 880 × 650 + 33 observadas + 3 projetadas +
+
+ +
+
+
+
+
+
+

Comparação no tempo

+

+ Consultas e exames estaduais crescem 8x enquanto os municipais caem pela metade +

+

+ Consultas e exames do SUS · Vitória da Conquista (BA) · 2015–2026 +

+ +
+ + + + + + 0 + + projeção 2026 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Estadual + 4.213.052 + + · +706% + + + ↑ amplia + + + + Municipal + 1.492.020 + + · −49% + + + ↘ diminui + + + + Rede privada + 855.684 + · +3% + + ↗ cresce + + + + 2015 + 2017 + 2019 + 2021 + 2023 + 2026 + + +
+ +
+

+ Fonte: Ministério da Saúde — Sistema de Informações + Ambulatoriais do SUS (SIA/SUS)
Nota: 2026 é projeção pela média de + janeiro a junho × 2. Rede privada = Empresariais + Sem Fins Lucrativos. +

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

+ REGRAS DA LINHA DE 3 SÉRIES + PROJEÇÃO · C207 +

+

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

+
+
+

Aplicação e fail-closed

+

+ Exatamente 3 séries, 2–12 períodos alinhados, feed 1080×1350 e tons distintos + good / neutral / neutral-dark. O período informado em + projectedLabel tem de ser o último e comum às três. + Projeção ausente, parcial, intermediária ou sem método → perguntar; nunca inferir. +

+
+
+

Faixa e label · geometria exata

+

+ No viewBox 880×650, a faixa é + rect x=514 y=0 width=46 height=544, fill + #a8a29e a 16%. Ela cobre 2025→2026 e termina em + x=560. Literal projeção 2026 em + x=552 y=45 text-anchor=end, 30/700 em #78716c; fica + 11 px acima do grid de topo y=56 e 8 px antes do limite do plot. +

+
+
+

Segmentos sólidos × projetados

+

+ Cada triple-series-* sólido termina em x=514 (2025). + Somente x=514→560 usa triple-series-projection, linha + 7 px, round cap e stroke-dasharray="18 12". Nenhum + segmento anterior recebe dash; a escala compartilhada continua zero-based, 0…5 mi. +

+
+
+

Vazados por tom · atributos exatos

+

+ Good: círculo cx=560 r=11. Neutral: losango path + M560 446 L574 460 L560 474 L546 460 Z (28×28). + Neutral-dark: triângulo ▼ + M545 387 L575 387 L560 413 Z (30×26). Todos usam fill + #faf9f7, stroke do tom, stroke-width 5 e linejoin round; são maiores que r=8, + losango 14×14 rotado e triângulo 20×18 observados. +

+
+
+

Tons e vocabulário preservados

+

+ Melhor = #e4102f + círculo + ↑ “amplia”; neutro = + #78716c + losango + ↗ “cresce”; neutro escuro = + #1c1917 + triângulo + ↘ “diminui”. Cor nunca viaja + sozinha; azul não entra nos dados; vermelho segue abaixo de 10% da peça. +

+
+
+

Gutter, lanes e leaders

+

+ Gutter x=600…880 e lanes C205 intactas: 62/108/150, 258/304/346 e 454/500/542, + ordenadas pelo y projetado. O footprint dos vazados termina até x=577,5; por isso o + leader começa em x=578, passa x=584, dobra em x=590 e termina x=594, com 6 px antes + do texto. Faixa/label nunca entram no corredor x=560…600; ali só ficam o marcador + final e seu leader, sem texto. A terceira lane usa somente a valência em x=600, + 30/850; não existe literal ancorado à direita nessa baseline. +

+
+
+

Valor projetado + variação até a projeção

+

+ Linha 1: nome. Linha 2: valor projetado de 2026 à esquerda + variação 2015→2026 + projetado à direita, no literal valor · ±N%, em 30 px + tabular (redução controlada dos 34 px da C205 para os valores milionários). Linha 3: + somente seta/palavra à esquerda, em 30/850: + x=600 y=150 ↑ amplia, + x=600 y=346 ↘ diminui e + x=600 y=542 ↗ cresce. Não há literal à direita e não + se cria quarta linha. Literais de métrica: 4.213.052 · +706%; + 1.492.020 · −49%; 855.684 · +3%. No aria-label, + escrever por extenso: + “para projeção de X em 2026, variação de Y até 2026 (projeção)”. +

+
+
+

Eixo, aria e nota obrigatórios

+

+ Eixo mantém 2015/2017/2019/2021/2023/2026 em x=52/144/237/329/421/560; “projeção + 2026” nunca desce para o eixo. O SVG tem role=img e aria-label que anuncia o rótulo + da faixa, as três projeções e as três variações que incluem a projeção. A nota + informa o método + “2026 é projeção pela média de janeiro a junho × 2”, e a frase + final confirma que o último segmento e os três marcadores de 2026 são projetados. +

+
+
+

Copy e fonte · literais desta cena

+

+ Manchete: “Consultas e exames estaduais crescem 8x enquanto os municipais caem pela + metade”. Subtítulo: “Consultas e exames do SUS · Vitória da Conquista (BA) · + 2015–2026”. Fonte: “Ministério da Saúde — Sistema de Informações Ambulatoriais do + SUS (SIA/SUS)”. Nota: “2026 é projeção pela média de janeiro a junho × 2. Rede + privada = Empresariais + Sem Fins Lucrativos.” Rótulo superior: “projeção 2026”. + Lane 3 da gutter: somente “↑ amplia”, “↘ diminui” e “↗ cresce”. +

+
+
+

Mapa de classes C207

+

+ Reusar a estrutura das classes C205 + triple-line-plot, + triple-series-*, + triple-markers-*, + triple-axis-label, + triple-end-leader, + triple-end-name, + triple-end-metric e + triple-end-valence; nesta cena, métrica fica em 30 px + e valência preserva 30/850, sozinha na terceira lane. C207 acrescenta + triple-projected no canvas, + triple-projection-band, + triple-projection-label, + triple-series-projection, + triple-marker-projected e os modificadores + triple-marker-projected-good, + -neutral e + -neutral-dark. Rodapé e marca seguem C206. +

+
+
+
+
+ @@ -3374,8 +3952,9 @@

Barra, coluna e linha simples: até 7 pontos. Exceção aprovada: linha de 2 séries, 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. + até 12 pontos alinhados por série (36 marcas). A C207 certifica o mesmo teto com o + último período projetado, aprovada no gate de 2026-09-19. Story mantém 5 apenas para + barra/coluna; C205/C207 não certificam quadrado nem story.

@@ -3405,9 +3984,12 @@ base C203 aprovada · extensão C191 certificada pelo designer e aprovada pelo humano · C205 (supera a C204) + - C206 aprovadas no gate de 2026-09-19aprovadas no gate de 2026-09-19 + Kit 1313 oficial · valência redundante · projeção explícita · marca ampliada · NEEDS + ASSET resolvido - Kit 1313 oficial · valência redundante · marca ampliada · NEEDS ASSET resolvido diff --git a/scripts/lib/chartData.mjs b/scripts/lib/chartData.mjs index 2813c041..cab4003f 100644 --- a/scripts/lib/chartData.mjs +++ b/scripts/lib/chartData.mjs @@ -423,8 +423,9 @@ export const classifyRelation = (rows, forcedType = null) => { /** * 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. + * pair (2 series) or triad (3 series, C207 — feed only, projection on the last + * period, no crossing) informed together and the projection only on the last + * period. */ const validateSeries = (spec) => { const { series } = spec @@ -440,9 +441,6 @@ const validateSeries = (spec) => { ) } 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.') } diff --git a/scripts/lib/chartPrimitives.mjs b/scripts/lib/chartPrimitives.mjs index d8e8824e..719ccc7d 100644 --- a/scripts/lib/chartPrimitives.mjs +++ b/scripts/lib/chartPrimitives.mjs @@ -541,11 +541,12 @@ export const lineChart = ({ } /** - * 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). + * Geometry of the approved three-series time line (C205 extension, feed only; + * projected final period added by C207). 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: { @@ -561,31 +562,52 @@ const TRIPLE_GEOMETRY = { triangleWidth: 20, triangleTop: 8, triangleApex: 10, + projectedDotRadius: 11, + projectedDiamondSize: 28, + projectedTriangleWidth: 30, + projectedTriangleTop: 11, + projectedTriangleApex: 15, axisSize: 30, axisY: 636, zeroX: 42, zeroY: 538, nameSize: 30, metricSize: 34, + projectedMetricSize: 30, valenceSize: 30, + changeX: 874, textX: 600, leader: { start: 570, rail: 580, bend: 590, end: 594 }, + projectedLeader: { start: 578, rail: 584, 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". + * Three-series time line (C205 extension of the two-series variant, projected + * final period certified by C207): three 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 observed — no band, no dash, no hollow + * marker, no "projeção". When `projected` is on (last period only): the band + * covers the final step, its top label names the projected period ("projeção + * 2026"), only the last segment dashes and the three final markers are hollow, + * and the gutter shows the projected value beside the variation that runs + * first → projected period (human gate feedback on C207: the percentage must + * close on the same pair as the headline). * * `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 }) => { +export const tripleLineChart = ({ + series, + size = 'feed', + projected = false, + 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)))) @@ -606,6 +628,29 @@ export const tripleLineChart = ({ series, size = 'feed', colors, valence, format return `` } + /** Hollow final marker of the projected period (C207): same tone shape, big. */ + const projectedMarker = (tone, x, y) => { + const color = colors[tone] ?? colors.neutral + const className = `triple-marker-projected triple-marker-projected-${tone}` + if (tone === 'neutral') { + const half = cfg.projectedDiamondSize / 2 + return `` + } + if (tone === 'neutral-dark') { + const half = cfg.projectedTriangleWidth / 2 + return `` + } + return `` + } + + const band = projected + ? `` + : '' + + const projectionLabel = projected + ? `projeção ${htmlEscape(series[0].rows[lastIndex].label)}` + : '' + const gridLines = ` @@ -615,14 +660,28 @@ export const tripleLineChart = ({ series, size = 'feed', colors, valence, format .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('')}` + const solid = projected ? points.slice(0, -1) : points + const polyline = `` + const dashed = projected + ? `` + : '' + const markers = `${solid.map((point) => marker(serie.tone, point.x, point.y)).join('')}` + return `${polyline}${dashed}${markers}` }) .join('') + const projectedMarkers = projected + ? series + .map((serie) => { + const point = { x: xAt(lastIndex), y: yAt(serie.rows[lastIndex].value) } + return projectedMarker(serie.tone, point.x, point.y) + }) + .join('') + : '' + const changeOf = (serie) => { const firstValue = serie.rows[0].value - const observed = serie.rows[lastIndex].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 ? '−' : '' @@ -645,22 +704,36 @@ export const tripleLineChart = ({ series, size = 'feed', colors, valence, format const endBlocks = blocks .map((block) => { const { baselines, point } = block + const leader = projected ? cfg.projectedLeader : cfg.leader + const metricSize = projected ? cfg.projectedMetricSize : cfg.metricSize const metric = `${format(block.serie.rows[lastIndex])}${ block.change ? ` · ${block.change.percentLabel}` : '' }` - const text = (className, size, fill, value, index) => - `${htmlEscape(value)}` + const text = (className, size, fill, value, index, options = {}) => + `${htmlEscape(value)}` + const metricLine = projected + ? [ + text('triple-end-metric', metricSize, null, format(block.serie.rows[lastIndex]), 1), + block.change + ? text('triple-end-metric', metricSize, null, `· ${block.change.percentLabel}`, 1, { + x: cfg.changeX, + anchor: 'end', + }) + : '', + ].join('') + : text('triple-end-metric', metricSize, null, metric, 1) + const valenceLine = text( + 'triple-end-valence', + cfg.valenceSize, + block.color, + valence[block.serie.tone] ?? '', + 2, + ) 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, - ), + metricLine, + valenceLine, ].join('') }) .join('') @@ -682,14 +755,28 @@ export const tripleLineChart = ({ series, size = 'feed', colors, valence, format : 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}` + const destination = projected + ? ` para projeção de ${format(serie.rows[lastIndex])} em ${serie.rows[lastIndex].label}` + : ` para ${format(serie.rows[lastIndex])} em ${serie.rows[lastIndex].label}` + const trend = !change + ? '' + : projected + ? `, variação de ${change.percent > 0 ? 'mais ' : change.percent < 0 ? 'menos ' : ''}${Math.abs(change.percent)} por cento até ${serie.rows[lastIndex].label} (projeção)` + : `, ${direction} de ${Math.abs(change.percent)} por cento` + return `${serie.name}: de ${format(serie.rows[0])} em ${serie.rows[0].label}${destination}${trend}` }) - .join('. ')}. Todos os pontos de cada série são observados.` + .join('. ')}. ${ + projected + ? `O último segmento e os três marcadores de ${series[0].rows[lastIndex].label} são projetados.` + : 'Todos os pontos de cada série são observados.' + }` return ` + ${band} ${gridLines} + ${projectionLabel} ${lines} + ${projectedMarkers} ${endBlocks} ${axis} ` diff --git a/scripts/lib/graficosInstagramRender.mjs b/scripts/lib/graficosInstagramRender.mjs index ebbf9d76..5baee201 100644 --- a/scripts/lib/graficosInstagramRender.mjs +++ b/scripts/lib/graficosInstagramRender.mjs @@ -187,16 +187,18 @@ 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. + * projected final period certified by C207; 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, + paper: SOLLA_PALETTE.paper, ink: SOLLA_PALETTE.ink, } @@ -206,6 +208,7 @@ const tripleBody = (spec) => `
${tripleLineChart({ series: spec.series, size: 'feed', + projected: Boolean(spec.projectedLabel), colors: TRIPLE_COLORS, valence: TRIPLE_VALENCE, format: (row) => formatValue(row.value), @@ -358,6 +361,12 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; } .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; } +.triple-projection-band { fill: ${SOLLA_PALETTE.barStrong}; fill-opacity: 0.16; } +.triple-projection-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 700; } +.triple-series-projection { stroke-dasharray: 18 12; } +.triple-marker-projected { fill: ${SOLLA_PALETTE.paper}; stroke-width: 5; stroke-linejoin: round; } +.triple-projected .triple-end-metric { font-size: 30px; } +.triple-projected .source { max-width: 620px; } .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; } @@ -426,6 +435,7 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0 const dual = seriesCount === 2 const triple = seriesCount === 3 + const tripleProjected = triple && Boolean(spec.projectedLabel) const dualPositive = Boolean(spec.dualPositive) const dualPositiveStyles = !dualPositive || sizeKey !== 'feed' @@ -480,7 +490,7 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { -