feat(C207): projeção no último período do trio do gerador de gráficos (gate 2026-09-19)

This commit is contained in:
Francisco Solla 2026-09-19 15:41:28 -03:00
parent 5be9a1af4d
commit ca59523759
10 changed files with 938 additions and 67 deletions

View file

@ -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=<arquivo> \
--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=<arquivo> --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 \<período>" no topo e nota do método. No par, a variação da
gutter fecha no último ponto **observado** ("±N% até \<período observado>");
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=<período>`, 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:

View file

@ -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 \<período>" 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.

View file

@ -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 @@
<div
class="mb-6 rounded-2xl border border-[#184e92]/30 bg-[#184e92]/[0.05] p-5 sm:p-6"
role="note"
aria-label="Base C203 aprovada e revisões C205 e C206 aprovadas no gate de 2026-09-19"
aria-label="Base C203 e revisões C205, C206 e C207 aprovadas no gate humano de 2026-09-19"
>
<div class="flex flex-wrap items-start gap-x-5 gap-y-3">
<span
class="inline-flex items-center rounded-md bg-[#184e92] px-3 py-1 text-sm font-black tracking-[0.14em] text-white"
>BASE C203 APROVADA · C205/C206 APROVADAS</span
>BASE C203 APROVADA · C205/C206/C207 APROVADAS</span
>
<div class="max-w-3xl">
<p class="text-sm font-extrabold text-[#184e92]">
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.
</p>
<p class="mt-1 text-sm leading-6 text-stone-700">
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).
<strong
>As três são a fonte de verdade do port desde o gate humano de 2026-09-19.</strong
>
</p>
</div>
</div>
@ -1023,7 +1055,7 @@
<header class="max-w-4xl">
<div class="flex flex-wrap items-center gap-2">
<span class="scene-label border-blue-200 bg-blue-50 text-[#184e92]"
>Revisões C205 + C206 · aprovadas no gate</span
>C207 · aprovada no gate de 2026-09-19</span
>
<span class="scene-label">Base C203 aprovada · C205/C206 aprovadas</span>
<span class="scene-label">Instagram feed + Stories</span>
@ -1033,7 +1065,7 @@
</h1>
<p class="mt-4 max-w-3xl text-[15px] leading-6 text-stone-600">
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.
</p>
</header>
@ -1173,6 +1205,34 @@
</p>
</section>
<section
class="mt-4 rounded-2xl border border-blue-200 bg-blue-50/70 p-5 sm:p-6"
aria-labelledby="revision-c207-title"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="max-w-3xl">
<p class="text-xs font-extrabold tracking-[0.08em] text-[#184e92]">
REVISÃO VISÍVEL · C207 · AJUSTE PÓS-FEEDBACK DO GATE · APROVADA NO GATE 2026-09-19
</p>
<h2 id="revision-c207-title" class="mt-1 text-lg font-bold tracking-tight">
Três séries com o último período projetado
</h2>
<p class="mt-2 text-sm leading-6 text-stone-700">
A <strong>CENA 02E</strong> 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.
</p>
</div>
<span class="scene-label border-blue-200 bg-blue-50 text-[#184e92]"
>APROVADA · GATE 2026-09-19</span
>
</div>
</section>
<section class="scene-card mt-9 p-4 sm:p-6 lg:p-8" aria-labelledby="main-scene-title">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
@ -1374,7 +1434,7 @@
<div>
<p class="text-xs font-bold text-brand-dark">CENA 02 · SISTEMA</p>
<h2 id="variations-title" class="mt-1 text-xl font-bold tracking-tight">
Cinco variações-base + duas extensões, um mesmo template
Cinco variações-base + três extensões, um mesmo template
</h2>
</div>
<p class="max-w-xl text-sm leading-5 text-stone-600">
@ -2714,6 +2774,524 @@
</div>
</section>
<!-- ================================================================== -->
<!-- CENA 02E · C207 — EXTENSÃO DA C205, APROVADA NO GATE 2026-09-19. -->
<!-- Três séries; 2026 é projeção comum às três. CENA 02D não muda. -->
<!-- ================================================================== -->
<section
class="scene-card mt-10 border-2 border-blue-200 p-4 sm:p-6 lg:p-8"
aria-labelledby="triple-projected-title"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-bold text-brand-dark">
CENA 02E · REVISÃO C207 · AJUSTE PÓS-FEEDBACK · APROVADA NO GATE 2026-09-19
</p>
<h2 id="triple-projected-title" class="mt-1 text-xl font-bold tracking-tight">
Feed 4:5 · linha temporal de três séries com projeção final
</h2>
</div>
<div class="flex flex-wrap gap-2">
<span class="measure-tag"><span class="measure-dot"></span>1080 × 1350</span>
<span class="measure-tag">Plot SVG · viewBox 880 × 650</span>
<span class="measure-tag">33 observadas + 3 projetadas</span>
</div>
</div>
<div class="mt-6 grid items-start gap-7 lg:grid-cols-[594px_1fr]">
<div class="overflow-x-auto pb-2">
<div class="feed-stage">
<article
class="feed-canvas triple-series triple-projected"
aria-label="Consultas e exames estaduais crescem oito vezes enquanto os municipais caem pela metade em Vitória da Conquista entre 2015 e 2026. A faixa do último período está rotulada projeção 2026. Estadual: de 522.656 em 2015 para projeção de 4.213.052 em 2026, variação de mais 706 por cento até 2026 (projeção). Municipal: de 2.904.003 em 2015 para projeção de 1.492.020 em 2026, variação de menos 49 por cento até 2026 (projeção). Rede privada: de 833.679 em 2015 para projeção de 855.684 em 2026, variação de mais 3 por cento até 2026 (projeção). 2026 é projeção pela média de janeiro a junho multiplicada por dois. O último segmento e os três marcadores de 2026 são projetados."
>
<div class="feed-inner">
<div class="top-rule"></div>
<p class="chart-context">Comparação no tempo</p>
<h3 class="chart-headline">
Consultas e exames estaduais crescem 8x enquanto os municipais caem pela metade
</h3>
<p class="chart-subtitle">
Consultas e exames do SUS · Vitória da Conquista (BA) · 2015–2026
</p>
<div class="triple-line-plot tabular">
<svg
viewBox="0 0 880 650"
role="img"
aria-label="A faixa do último período está rotulada projeção 2026. Estadual: de 522.656 em 2015 para projeção de 4.213.052 em 2026, variação de mais 706 por cento até 2026 (projeção). Municipal: de 2.904.003 em 2015 para projeção de 1.492.020 em 2026, variação de menos 49 por cento até 2026 (projeção). Rede privada: de 833.679 em 2015 para projeção de 855.684 em 2026, variação de mais 3 por cento até 2026 (projeção). O último segmento e os três marcadores de 2026 são projetados."
preserveAspectRatio="xMidYMid meet"
>
<rect
class="triple-projection-band"
x="514"
y="0"
width="46"
height="544"
fill="#a8a29e"
fill-opacity="0.16"
/>
<line
class="triple-grid-line"
x1="52"
y1="56"
x2="560"
y2="56"
stroke="#a8a29e"
stroke-opacity="0.32"
stroke-width="2"
/>
<line
class="triple-grid-line"
x1="52"
y1="300"
x2="560"
y2="300"
stroke="#a8a29e"
stroke-opacity="0.32"
stroke-width="2"
/>
<line
class="triple-zero-line"
x1="52"
y1="544"
x2="560"
y2="544"
stroke="#a8a29e"
stroke-width="2"
/>
<text x="42" y="538" text-anchor="end" class="triple-axis-label">0</text>
<text x="552" y="45" text-anchor="end" class="triple-projection-label">
projeção 2026
</text>
<polyline
class="triple-series triple-series-good"
points="52,493 98,491 144,465 191,399 237,277 283,235 329,188 375,181 421,186 468,204 514,176"
fill="none"
stroke="#e4102f"
stroke-width="7"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
class="triple-series-projection triple-series-good"
x1="514"
y1="176"
x2="560"
y2="133"
stroke="#e4102f"
stroke-width="7"
stroke-linecap="round"
stroke-dasharray="18 12"
/>
<polyline
class="triple-series triple-series-neutral-dark"
points="52,261 98,316 144,336 191,348 237,352 283,425 329,380 375,362 421,347 468,343 514,355"
fill="none"
stroke="#1c1917"
stroke-width="7"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
class="triple-series-projection triple-series-neutral-dark"
x1="514"
y1="355"
x2="560"
y2="398"
stroke="#1c1917"
stroke-width="7"
stroke-linecap="round"
stroke-dasharray="18 12"
/>
<polyline
class="triple-series triple-series-neutral"
points="52,463 98,475 144,477 191,469 237,474 283,494 329,487 375,476 421,457 468,458 514,455"
fill="none"
stroke="#78716c"
stroke-width="7"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
class="triple-series-projection triple-series-neutral"
x1="514"
y1="455"
x2="560"
y2="460"
stroke="#78716c"
stroke-width="7"
stroke-linecap="round"
stroke-dasharray="18 12"
/>
<g class="triple-markers-good" fill="#e4102f">
<circle cx="52" cy="493" r="8" />
<circle cx="98" cy="491" r="8" />
<circle cx="144" cy="465" r="8" />
<circle cx="191" cy="399" r="8" />
<circle cx="237" cy="277" r="8" />
<circle cx="283" cy="235" r="8" />
<circle cx="329" cy="188" r="8" />
<circle cx="375" cy="181" r="8" />
<circle cx="421" cy="186" r="8" />
<circle cx="468" cy="204" r="8" />
<circle cx="514" cy="176" r="8" />
</g>
<g class="triple-markers-neutral-dark" fill="#1c1917">
<path d="M42 253 L62 253 L52 271 Z" />
<path d="M88 308 L108 308 L98 326 Z" />
<path d="M134 328 L154 328 L144 346 Z" />
<path d="M181 340 L201 340 L191 358 Z" />
<path d="M227 344 L247 344 L237 362 Z" />
<path d="M273 417 L293 417 L283 435 Z" />
<path d="M319 372 L339 372 L329 390 Z" />
<path d="M365 354 L385 354 L375 372 Z" />
<path d="M411 339 L431 339 L421 357 Z" />
<path d="M458 335 L478 335 L468 353 Z" />
<path d="M504 347 L524 347 L514 365 Z" />
</g>
<g class="triple-markers-neutral" fill="#78716c">
<rect x="45" y="456" width="14" height="14" transform="rotate(45 52 463)" />
<rect x="91" y="468" width="14" height="14" transform="rotate(45 98 475)" />
<rect
x="137"
y="470"
width="14"
height="14"
transform="rotate(45 144 477)"
/>
<rect
x="184"
y="462"
width="14"
height="14"
transform="rotate(45 191 469)"
/>
<rect
x="230"
y="467"
width="14"
height="14"
transform="rotate(45 237 474)"
/>
<rect
x="276"
y="487"
width="14"
height="14"
transform="rotate(45 283 494)"
/>
<rect
x="322"
y="480"
width="14"
height="14"
transform="rotate(45 329 487)"
/>
<rect
x="368"
y="469"
width="14"
height="14"
transform="rotate(45 375 476)"
/>
<rect
x="414"
y="450"
width="14"
height="14"
transform="rotate(45 421 457)"
/>
<rect
x="461"
y="451"
width="14"
height="14"
transform="rotate(45 468 458)"
/>
<rect
x="507"
y="448"
width="14"
height="14"
transform="rotate(45 514 455)"
/>
</g>
<circle
class="triple-marker-projected triple-marker-projected-good"
cx="560"
cy="133"
r="11"
fill="#faf9f7"
stroke="#e4102f"
stroke-width="5"
/>
<path
class="triple-marker-projected triple-marker-projected-neutral-dark"
d="M545 387 L575 387 L560 413 Z"
fill="#faf9f7"
stroke="#1c1917"
stroke-width="5"
stroke-linejoin="round"
/>
<path
class="triple-marker-projected triple-marker-projected-neutral"
d="M560 446 L574 460 L560 474 L546 460 Z"
fill="#faf9f7"
stroke="#78716c"
stroke-width="5"
stroke-linejoin="round"
/>
<polyline
class="triple-end-leader"
points="578,133 584,133 590,108 594,108"
stroke="#e4102f"
/>
<text x="600" y="62" class="triple-end-name">Estadual</text>
<text x="600" y="108" class="triple-end-metric">4.213.052</text>
<text x="874" y="108" text-anchor="end" class="triple-end-metric">
· +706%
</text>
<text x="600" y="150" class="triple-end-valence" fill="#e4102f">
↑ amplia
</text>
<polyline
class="triple-end-leader"
points="578,398 584,398 590,304 594,304"
stroke="#1c1917"
/>
<text x="600" y="258" class="triple-end-name">Municipal</text>
<text x="600" y="304" class="triple-end-metric">1.492.020</text>
<text x="874" y="304" text-anchor="end" class="triple-end-metric">
· −49%
</text>
<text x="600" y="346" class="triple-end-valence" fill="#1c1917">
↘ diminui
</text>
<polyline
class="triple-end-leader"
points="578,460 584,460 590,500 594,500"
stroke="#78716c"
/>
<text x="600" y="454" class="triple-end-name">Rede privada</text>
<text x="600" y="500" class="triple-end-metric">855.684</text>
<text x="874" y="500" text-anchor="end" class="triple-end-metric">· +3%</text>
<text x="600" y="542" class="triple-end-valence" fill="#78716c">
↗ cresce
</text>
<g class="triple-axis-label" text-anchor="middle">
<text x="52" y="636">2015</text>
<text x="144" y="636">2017</text>
<text x="237" y="636">2019</text>
<text x="329" y="636">2021</text>
<text x="421" y="636">2023</text>
<text x="560" y="636">2026</text>
</g>
</svg>
</div>
<footer class="chart-footer">
<p class="source-line">
<strong>Fonte:</strong> Ministério da Saúde — Sistema de Informações
Ambulatoriais do SUS (SIA/SUS)<br />Nota: 2026 é projeção pela média de
janeiro a junho × 2. Rede privada = Empresariais + Sem Fins Lucrativos.
</p>
<div class="brand-logo-frame">
<img
src="../../public/campaign-kit/jorge-solla-positivo.png"
alt="Jorge Solla — Deputado Federal"
/>
</div>
</footer>
</div>
</article>
</div>
</div>
<aside class="grid gap-4">
<div class="rounded-xl border border-amber-300 bg-amber-50/70 p-4">
<p class="text-xs font-extrabold text-amber-800">
PROJEÇÃO COMUM · TRÊS FORMAS VAZADAS
</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
Só 2026 é projetado. Círculo, triângulo e losango finais preservam a forma de cada
tom, crescem e recebem papel <span class="rule-code">#faf9f7</span> + contorno de 5
px. O vazio significa projeção em todas as séries; nunca ausência de dado.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">FAIXA NÃO VAZA PARA A GUTTER</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
A faixa ocupa <span class="rule-code">x=514…560</span>, exatamente o último passo
observado→projetado, e termina no limite do plot. “projeção 2026” ancora em
x=552/y=45, text-anchor=end, acima do grid: não toca o eixo, o corredor de leaders
nem o texto da gutter.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">VALOR E VARIAÇÃO FECHAM NA PROJEÇÃO</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
+706%, −49% e +3% comparam 2015 ao ponto projetado de 2026 — o mesmo ponto dos
valores grandes. O rótulo superior único “projeção 2026” nomeia a base final do
cálculo para os três blocos; a terceira lane contém somente a palavra de valência.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-xs font-bold text-brand-dark">C205 CONTINUA INTACTA</p>
<p class="mt-2 text-sm leading-6 text-stone-700">
A CENA 02D segue sendo a variante de três séries totalmente observadas. A C207 só
entra quando o rótulo projetado coincide com o último período das três séries e o
método pode viajar na nota e no aria-label.
</p>
</div>
</aside>
</div>
<div
class="mt-7 border-t border-stone-200 pt-6"
aria-labelledby="triple-projected-rules-title"
>
<p class="text-xs font-bold text-amber-800">
REGRAS DA LINHA DE 3 SÉRIES + PROJEÇÃO · C207
</p>
<h3 id="triple-projected-rules-title" class="mt-1 text-lg font-bold tracking-tight">
Contrato visual da extensão para o porte classe-a-classe
</h3>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Aplicação e fail-closed</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Exatamente 3 séries, 2–12 períodos alinhados, feed 1080×1350 e tons distintos
<span class="rule-code">good / neutral / neutral-dark</span>. O período informado em
<span class="rule-code">projectedLabel</span> tem de ser o último e comum às três.
Projeção ausente, parcial, intermediária ou sem método → perguntar; nunca inferir.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Faixa e label · geometria exata</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
No viewBox <span class="rule-code">880×650</span>, a faixa é
<span class="rule-code">rect x=514 y=0 width=46 height=544</span>, fill
<span class="rule-code">#a8a29e</span> a 16%. Ela cobre 2025→2026 e termina em
x=560. Literal <span class="rule-code">projeção 2026</span> em
<span class="rule-code">x=552 y=45 text-anchor=end</span>, 30/700 em #78716c; fica
11 px acima do grid de topo y=56 e 8 px antes do limite do plot.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Segmentos sólidos × projetados</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Cada <span class="rule-code">triple-series-*</span> sólido termina em x=514 (2025).
Somente x=514→560 usa <span class="rule-code">triple-series-projection</span>, linha
7 px, round cap e <span class="rule-code">stroke-dasharray="18 12"</span>. Nenhum
segmento anterior recebe dash; a escala compartilhada continua zero-based, 0…5 mi.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Vazados por tom · atributos exatos</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Good: círculo <span class="rule-code">cx=560 r=11</span>. Neutral: losango path
<span class="rule-code">M560 446 L574 460 L560 474 L546 460 Z</span> (28×28).
Neutral-dark: triângulo ▼
<span class="rule-code">M545 387 L575 387 L560 413 Z</span> (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.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Tons e vocabulário preservados</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Melhor = <span class="rule-code">#e4102f + círculo + ↑ “amplia”</span>; neutro =
<span class="rule-code">#78716c + losango + ↗ “cresce”</span>; neutro escuro =
<span class="rule-code">#1c1917 + triângulo + ↘ “diminui”</span>. Cor nunca viaja
sozinha; azul não entra nos dados; vermelho segue abaixo de 10% da peça.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Gutter, lanes e leaders</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
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.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Valor projetado + variação até a projeção</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Linha 1: nome. Linha 2: valor projetado de 2026 à esquerda + variação 2015→2026
projetado à direita, no literal <span class="rule-code">valor · ±N%</span>, 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:
<span class="rule-code">x=600 y=150 ↑ amplia</span>,
<span class="rule-code">x=600 y=346 ↘ diminui</span> e
<span class="rule-code">x=600 y=542 ↗ cresce</span>. Não há literal à direita e não
se cria quarta linha. Literais de métrica: <strong>4.213.052 · +706%</strong>;
<strong>1.492.020 · −49%</strong>; <strong>855.684 · +3%</strong>. No aria-label,
escrever por extenso:
<strong>“para projeção de X em 2026, variação de Y até 2026 (projeção)”</strong>.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Eixo, aria e nota obrigatórios</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
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
<strong>“2026 é projeção pela média de janeiro a junho × 2”</strong>, e a frase
final confirma que o último segmento e os três marcadores de 2026 são projetados.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
<p class="text-sm font-bold">Copy e fonte · literais desta cena</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
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”.
</p>
</div>
<div
class="rounded-xl border border-stone-200 bg-white p-4 md:col-span-2 xl:col-span-3"
>
<p class="text-sm font-bold">Mapa de classes C207</p>
<p class="mt-2 text-sm leading-5 text-stone-600">
Reusar a estrutura das classes C205
<span class="rule-code">triple-line-plot</span>,
<span class="rule-code">triple-series-*</span>,
<span class="rule-code">triple-markers-*</span>,
<span class="rule-code">triple-axis-label</span>,
<span class="rule-code">triple-end-leader</span>,
<span class="rule-code">triple-end-name</span>,
<span class="rule-code">triple-end-metric</span> e
<span class="rule-code">triple-end-valence</span>; nesta cena, métrica fica em 30 px
e valência preserva 30/850, sozinha na terceira lane. C207 acrescenta
<span class="rule-code">triple-projected</span> no canvas,
<span class="rule-code">triple-projection-band</span>,
<span class="rule-code">triple-projection-label</span>,
<span class="rule-code">triple-series-projection</span>,
<span class="rule-code">triple-marker-projected</span> e os modificadores
<span class="rule-code">triple-marker-projected-good</span>,
<span class="rule-code">-neutral</span> e
<span class="rule-code">-neutral-dark</span>. Rodapé e marca seguem C206.
</p>
</div>
</div>
</div>
</section>
<!-- ================================================================== -->
<!-- CENA 02C · PROPOSTA DEGRADED — variante "delta" (barra de -->
<!-- variação por categoria). NÃO CERTIFICADA. Escrita no tier -->
@ -3374,8 +3952,9 @@
<p class="mt-1 text-sm leading-5 text-stone-600">
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.
</p>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-4">
@ -3405,9 +3984,12 @@
<span
>base C203 <strong>aprovada</strong> · extensão C191
<strong>certificada pelo designer e aprovada pelo humano</strong> · C205 (supera a C204) +
C206 <strong>aprovadas no gate de 2026-09-19</strong></span
C206 + C207 <strong>aprovadas no gate de 2026-09-19</strong></span
>
<span
>Kit 1313 oficial · valência redundante · projeção explícita · marca ampliada · NEEDS
ASSET resolvido</span
>
<span>Kit 1313 oficial · valência redundante · marca ampliada · NEEDS ASSET resolvido</span>
</footer>
</main>
</body>

View file

@ -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.')
}

View file

@ -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 `<circle cx="${x}" cy="${y}" r="${cfg.dotRadius}" />`
}
/** 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 `<path class="${className}" d="M${x} ${y - half} L${x + half} ${y} L${x} ${y + half} L${x - half} ${y} Z" fill="${colors.paper}" stroke="${color}" stroke-width="5" stroke-linejoin="round" />`
}
if (tone === 'neutral-dark') {
const half = cfg.projectedTriangleWidth / 2
return `<path class="${className}" d="M${x - half} ${y - cfg.projectedTriangleTop} L${x + half} ${y - cfg.projectedTriangleTop} L${x} ${y + cfg.projectedTriangleApex} Z" fill="${colors.paper}" stroke="${color}" stroke-width="5" stroke-linejoin="round" />`
}
return `<circle class="${className}" cx="${x}" cy="${y}" r="${cfg.projectedDotRadius}" fill="${colors.paper}" stroke="${color}" stroke-width="5" />`
}
const band = projected
? `<rect class="triple-projection-band" x="${xAt(count - 2)}" y="0" width="${xAt(count - 1) - xAt(count - 2)}" height="${cfg.baseline}" fill="${colors.grid}" fill-opacity="0.16" />`
: ''
const projectionLabel = projected
? `<text x="${cfg.right - 8}" y="${cfg.top - 11}" text-anchor="end" class="triple-projection-label" font-size="${cfg.axisSize}">projeção ${htmlEscape(series[0].rows[lastIndex].label)}</text>`
: ''
const gridLines = `<line class="triple-grid-line" x1="${cfg.left}" y1="${cfg.top}" x2="${cfg.right}" y2="${cfg.top}" stroke="${colors.grid}" stroke-opacity="0.32" stroke-width="2" />
<line class="triple-grid-line" x1="${cfg.left}" y1="${Math.round((cfg.top + cfg.baseline) / 2)}" x2="${cfg.right}" y2="${Math.round((cfg.top + cfg.baseline) / 2)}" stroke="${colors.grid}" stroke-opacity="0.32" stroke-width="2" />
<line class="triple-zero-line" x1="${cfg.left}" y1="${cfg.baseline}" x2="${cfg.right}" y2="${cfg.baseline}" stroke="${colors.grid}" stroke-width="2" />
@ -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 `<polyline class="triple-series triple-series-${serie.tone}" points="${points.map((point) => `${point.x},${point.y}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${cfg.stroke}" stroke-linecap="round" stroke-linejoin="round" />
<g class="triple-markers-${serie.tone}" fill="${color}">${points.map((point) => marker(serie.tone, point.x, point.y)).join('')}</g>`
const solid = projected ? points.slice(0, -1) : points
const polyline = `<polyline class="triple-series triple-series-${serie.tone}" points="${solid.map((point) => `${point.x},${point.y}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${cfg.stroke}" stroke-linecap="round" stroke-linejoin="round" />`
const dashed = projected
? `<line class="triple-series-projection triple-series-${serie.tone}" x1="${points[count - 2].x}" y1="${points[count - 2].y}" x2="${points[count - 1].x}" y2="${points[count - 1].y}" stroke="${color}" stroke-width="${cfg.stroke}" stroke-linecap="round" stroke-dasharray="18 12" />`
: ''
const markers = `<g class="triple-markers-${serie.tone}" fill="${color}">${solid.map((point) => marker(serie.tone, point.x, point.y)).join('')}</g>`
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) =>
`<text x="${cfg.textX}" y="${baselines[index]}" class="${className}" font-size="${size}"${fill ? ` fill="${fill}"` : ''}>${htmlEscape(value)}</text>`
const text = (className, size, fill, value, index, options = {}) =>
`<text x="${options.x ?? cfg.textX}" y="${baselines[index]}"${options.anchor ? ` text-anchor="${options.anchor}"` : ''} class="${className}" font-size="${size}"${fill ? ` fill="${fill}"` : ''}>${htmlEscape(value)}</text>`
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 [
`<polyline class="triple-end-leader" points="${cfg.leader.start},${point.y} ${cfg.leader.rail},${point.y} ${cfg.leader.bend},${baselines[1]} ${cfg.leader.end},${baselines[1]}" fill="none" stroke="${block.color}" stroke-width="3" />`,
`<polyline class="triple-end-leader" points="${leader.start},${point.y} ${leader.rail},${point.y} ${leader.bend},${baselines[1]} ${leader.end},${baselines[1]}" fill="none" stroke="${block.color}" stroke-width="3" />`,
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 `<svg class="chart triple-chart" viewBox="0 0 ${cfg.width} ${cfg.height}" role="img" aria-label="${htmlEscape(ariaLabel)}" preserveAspectRatio="xMidYMid meet">
${band}
${gridLines}
${projectionLabel}
${lines}
${projectedMarkers}
${endBlocks}
${axis}
</svg>`

View file

@ -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) =>
`<div class="triple-line-plot">${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 } = {}) => {
</style>
</head>
<body>
<article class="canvas${dual ? ' dual-series' : ''}${triple ? ' triple-series' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<article class="canvas${dual ? ' dual-series' : ''}${triple ? ' triple-series' : ''}${tripleProjected ? ' triple-projected' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<div class="inner">
<div class="top-rule"></div>
<p class="context">${htmlEscape(kicker)}</p>

View file

@ -419,6 +419,49 @@ describe('main — spec assembly, replay and the per-size log', () => {
expect(stdout.join('')).toContain('3 séries × 3')
})
it('assembles the projected three-series spec (C207)', async () => {
const input = await writeInput(
dir,
'Ano,Estadual,Privada,Municipal\n2024,300,100,200\n2025,400,120,150\n2026,500,121,100\n',
)
const { launchBrowser } = launchTracker()
const screenshot = screenshotStub()
const outPath = join(dir, 'triple-projected.png')
await main({
argv: [
`--in=${input}`,
'--type=line',
'--headline=Produção estadual cresce e municipal cai',
'--source=Ministério da Saúde — SIA/SUS',
'--good=Estadual',
'--neutral=Privada',
'--neutral-dark=Municipal',
'--projected=2026',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(screenshot).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ width: 1080, height: 1350, outPath }),
)
const spec = JSON.parse(
await readFile(
join(
dir,
'data/graficos-instagram/producao-estadual-cresce-e-municipal-cai.chart-spec.json',
),
'utf8',
),
)
expect(spec).toMatchObject({ chartType: 'line', size: 'feed', projectedLabel: '2026' })
expect(stdout.join('')).toContain('3 séries × 3')
})
it('refuses an incomplete triad and a 2-series tone on the three-series line', async () => {
const input = await writeInput(dir, 'Ano,Estadual,Municipal,Privada\n2015,1,2,3\n2016,2,2,2\n')
const { launchBrowser } = launchTracker()

View file

@ -479,7 +479,7 @@ describe('validateSpec — the two-series time line', () => {
})
})
describe('validateSpec — the three-series time line (C205 extension)', () => {
describe('validateSpec — the three-series time line (C205 extension, C207 projection)', () => {
const annual = (values: number[]) =>
values.map((value, index) => ({ label: `${2015 + index}`, value }))
const tripleSpec = (overrides = {}) => ({
@ -530,8 +530,9 @@ describe('validateSpec — the three-series time line (C205 extension)', () => {
).toThrow(/tom inválido/)
})
it('refuses projection and crossing on the three-series extension', () => {
expect(() => validateSpec(tripleSpec({ projectedLabel: '2026' }))).toThrow(/projeção/)
it('certifies the projected last period (C207) and still refuses crossing', () => {
expect(validateSpec(tripleSpec({ projectedLabel: '2026' }))).toBeTruthy()
expect(() => validateSpec(tripleSpec({ projectedLabel: '2017' }))).toThrow(/último período/)
expect(() => validateSpec(tripleSpec({ crossingLabel: '2026' }))).toThrow(/cruzamento/)
})

View file

@ -403,3 +403,107 @@ describe('tripleLineChart — the approved three-series extension (C205, feed)',
expect(evil).toContain('&lt;b&gt;x&lt;/b&gt;')
})
})
describe('tripleLineChart — the projected final period (C207, feed)', () => {
const annual = (values: number[]) =>
values.map((value, index) => ({ label: `${2015 + index}`, value }))
const series = [
{
name: 'Estadual',
tone: 'good',
rows: annual([
522656, 545643, 804451, 1482193, 2733904, 3165119, 3646160, 3718176, 3669352, 3486832,
3774183, 4213052,
]),
},
{
name: 'Rede privada',
tone: 'neutral',
rows: annual([
833679, 708303, 688405, 767470, 715170, 512628, 583789, 700002, 888211, 880871, 908345,
855684,
]),
},
{
name: 'Municipal',
tone: 'neutral-dark',
rows: annual([
2904003, 2331052, 2133853, 2010696, 1968664, 1223339, 1682379, 1861214, 2014870, 2057296,
1937385, 1492020,
]),
},
]
const colors = {
good: '#e4102f',
neutral: '#78716c',
'neutral-dark': '#1c1917',
grid: '#a8a29e',
paper: '#faf9f7',
ink: '#1c1917',
}
const valence = { good: '↑ amplia', neutral: '↗ cresce', 'neutral-dark': '↘ diminui' }
const format = (row: { value: number }) =>
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(row.value)
const svg = tripleLineChart({ series, colors, valence, format, projected: true })
it('covers exactly the last step with the band and names the projected period on top', () => {
expect(svg).toContain('class="triple-projection-band" x="514" y="0" width="46" height="544"')
expect(svg).toContain(
'x="552" y="45" text-anchor="end" class="triple-projection-label" font-size="30">projeção 2026</text>',
)
expect(svg.match(/>projeção 2026</g)).toHaveLength(1)
})
it('dashes only the last segment of each series', () => {
expect(svg.match(/class="triple-series-projection/g)).toHaveLength(3)
expect(svg.match(/stroke-dasharray="18 12"/g)).toHaveLength(3)
expect(svg).toContain(
'points="52,493 98,491 144,465 191,399 237,277 283,235 329,188 375,181 421,186 468,204 514,176"',
)
expect(svg).toContain(
'class="triple-series-projection triple-series-good" x1="514" y1="176" x2="560" y2="133"',
)
})
it('hollows the three projected markers keeping the tone shape', () => {
expect(svg).toContain(
'class="triple-marker-projected triple-marker-projected-good" cx="560" cy="133" r="11" fill="#faf9f7" stroke="#e4102f" stroke-width="5"',
)
expect(svg).toContain(
'class="triple-marker-projected triple-marker-projected-neutral-dark" d="M545 387 L575 387 L560 413 Z" fill="#faf9f7" stroke="#1c1917"',
)
expect(svg).toContain(
'class="triple-marker-projected triple-marker-projected-neutral" d="M560 446 L574 460 L560 474 L546 460 Z" fill="#faf9f7" stroke="#78716c"',
)
})
it('shows the projected value with the variation that closes on the projected period', () => {
expect(svg).toContain('points="578,133 584,133 590,108 594,108"')
expect(svg).toContain('>4.213.052<')
expect(svg).toContain('>· +706%<')
expect(svg).toContain('>↑ amplia<')
expect(svg).toContain('>1.492.020<')
expect(svg).toContain('>· −49%<')
expect(svg).toContain('>↘ diminui<')
expect(svg).toContain('>855.684<')
expect(svg).toContain('>· +3%<')
expect(svg).toContain('>↗ cresce<')
expect(svg).not.toContain('até 2025')
expect(svg.indexOf('>Estadual<')).toBeLessThan(svg.indexOf('>Municipal<'))
expect(svg.indexOf('>Municipal<')).toBeLessThan(svg.indexOf('>Rede privada<'))
})
it('announces the projection and the observed variation in the aria label', () => {
expect(svg).toContain(
'Estadual: de 522.656 em 2015 para projeção de 4.213.052 em 2026, variação de mais 706 por cento até 2026 (projeção)',
)
expect(svg).toContain(
'Municipal: de 2.904.003 em 2015 para projeção de 1.492.020 em 2026, variação de menos 49 por cento até 2026 (projeção)',
)
expect(svg).toContain(
'Rede privada: de 833.679 em 2015 para projeção de 855.684 em 2026, variação de mais 3 por cento até 2026 (projeção)',
)
expect(svg).toContain('O último segmento e os três marcadores de 2026 são projetados')
expect(svg).not.toContain('Todos os pontos de cada série são observados')
})
})

View file

@ -411,6 +411,34 @@ describe('renderChartHtml — the three-series time line (C205)', () => {
})
})
// C207 approved extension: the projected final period rides the same triad
// (feed only) with the neutral band, the dashed last segment and the three
// hollow markers, while the gutter variation still closes on the last observed
// period.
describe('renderChartHtml — the projected three-series line (C207)', () => {
const html = render(tripleSpec({ projectedLabel: '2026' }))
const svg = html.slice(html.indexOf('<svg'), html.indexOf('</svg>'))
it('marks the canvas and brings band, label, dash and hollow markers', () => {
expect(html).toContain('canvas triple-series triple-projected')
expect(svg).toContain('class="triple-projection-band"')
expect(svg).toContain('projeção')
expect(svg.match(/class="triple-series-projection/g)).toHaveLength(3)
expect(svg).toContain('class="triple-marker-projected triple-marker-projected-good"')
expect(svg).toContain('class="triple-marker-projected triple-marker-projected-neutral"')
expect(svg).toContain('class="triple-marker-projected triple-marker-projected-neutral-dark"')
expect(svg).not.toContain('Todos os pontos de cada série são observados')
})
it('ports the C207 classes and the reduced projected metric size', () => {
expect(html).toContain('.triple-projection-band { fill:')
expect(html).toContain('.triple-projection-label { fill:')
expect(html).toContain('.triple-marker-projected { fill:')
expect(html).toContain('.triple-projected .triple-end-metric { font-size: 30px; }')
expect(html).toContain('.triple-projected .source { max-width: 620px; }')
})
})
describe('renderChartHtml — the shared footer (C206 brand mark)', () => {
it('hugs the official asset ratio on the feed canvas', () => {
const html = render(spec())