diff --git a/.gitignore b/.gitignore index b42cc6e1..4f316dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,8 @@ next-env.d.ts /data/ai-fill/ # Câmara pilot media/JSON cache (pnpm camara:pilot — VOD MP4 + report, never commit) /data/camara/ +# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs) +/data/solla-ceuci-salvador/ # Local-only sensitive material (agents may read; never commit content) /private/** diff --git a/docs/changelog/2026-09-14-c157.md b/docs/changelog/2026-09-14-c157.md new file mode 100644 index 00000000..67f0746b --- /dev/null +++ b/docs/changelog/2026-09-14-c157.md @@ -0,0 +1 @@ +**C157 — Relatório: sobreposição Solla × Ceuci em Salvador 2022 (2026-09-14):** documento para a assessoria da campanha comparando a votação de 2022 de Jorge Solla (federal nº 1313; 27.264 votos em Salvador) e Ceuci Nunes (estadual nº 13192; 19.776 em Salvador, 36.992 no estado) nas 19 zonas eleitorais da capital e nos bairros. Entrega: [`docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.pdf`](docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.pdf) (15 páginas: capa, sumário executivo, contexto, método, 4 mapas — choropleth de cada candidato, classes de sobreposição por ZE e malha de bairros do IBGE Censo 2022 colorida pela ZE do centroide —, gráficos de barras e dispersão, 3 tabelas, leitura da ciência política na voz da persona Prof. Helena Rocha e plano do coordenador na voz da persona Nivaldo Cerqueira) + companion Markdown com números/proveniência + JSON de entrada com conferências cruzadas. Números: índice de sobreposição 0,678; Pearson 0,45 e Spearman 0,35 (N=19, com ressalva); núcleo compartilhado ZE 1/6/7/10/13; Ceuci supera Solla em ZE 1, 2, 6, 10 e 13; 53,5% do voto dela está na capital contra 21,1% do dele. Gerador `scripts/build-solla-ceuci-salvador-report.mjs` (HTML→PDF via Chromium do Playwright já instalado; sem dependência nova; sem banco; malha IBGE em cache gitignored), métricas puras em `scripts/lib/sollaCeuciSalvadorMetrics.mjs` com 8 unit tests, planos de intenção/impl em `docs/plans/relatorio-sobreposicao-solla-ceuci-salvador{,-impl}.md`; limite duro documentado no relatório (não existe voto por bairro/seção nos dados). Issue #979. diff --git a/docs/plans/relatorio-sobreposicao-solla-ceuci-salvador-impl.md b/docs/plans/relatorio-sobreposicao-solla-ceuci-salvador-impl.md new file mode 100644 index 00000000..af2530e7 --- /dev/null +++ b/docs/plans/relatorio-sobreposicao-solla-ceuci-salvador-impl.md @@ -0,0 +1,58 @@ +# Implementação — Relatório Solla × Ceuci em Salvador (2022) + +Status: executado +Atualizado em: 2026-09-14 +Issue: #979 +Intenção: docs/plans/relatorio-sobreposicao-solla-ceuci-salvador.md +Appetite restante: 1 sessão + +## Leitura da intenção + +O entregável é o PDF; o gerador é meio. O relatório tem que funcionar impresso em A4, em preto-e-branco aceitável, com números conferíveis e sem promessa que os dados não sustentam (não há voto por bairro nem por eleitor). A análise política usa as duas personas como lentes declaradas — Helena Rocha (método) e Nivaldo Cerqueira (decisão) —, sem inventar números internos da campanha. + +## Abordagem recomendada + +**Opções** + +- **A (recomendada): script Node único + HTML→PDF via Chromium do Playwright** (`@playwright/test` já é devDependency e os browsers estão instalados), com SVGs gerados das geometrias commitadas + malha de bairros do IBGE, e JSON de dados commitado. Sem dependência nova; o projeto já rejeitou lib de PDF server-side (`docs/plans/dossie-municipio.md`) e prefere CSS/print. +- B: rota `/campanha` com print CSS — rejeitada: exige sessão autenticada, imprime Leaflet raster e não é um anexo entregável "amanhã" fora do app. +- C: lib de PDF (pdfkit/react-pdf) — rejeitada: dependência nova + precedente contrário. + +**Componentes/mudanças** + +- `scripts/build-solla-ceuci-salvador-report.mjs` — baixa/simplifica a malha de bairros (cache `data/geometries/`), lê o JSON de dados + artefato de Solla, projeta SVGs, monta o HTML e imprime o PDF. +- `docs/research/solla-ceuci-salvador-2022-dados.json` — entrada com proveniência (19 ZE: votos, válidos, posições, totais). +- `docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.md` + `.pdf` — entrega. +- `docs/research/README.md` — registro na tabela; `docs/changelog/2026-09-14-c157.md` — contrato OPS44. +- Sem `src/**` novo (não força suíte full), sem `package.json` (não força build/e2e curado). + +**Fases verificáveis** + +1. Exportar dados TSE 2022 de Salvador read-only (prod, só SELECT) → JSON; validar Solla contra o artefato (27.264) e Ceuci contra o total estadual conhecido (36.992) e contra 19.776 em Salvador. +2. Baixar/simplificar malha de bairros do IBGE; conferir bbox de Salvador e contagem de bairros. +3. Gerar HTML com mapas/tabelas; conferir em screenshot do Chromium (mapas legíveis, sem sobreposição de rótulos). +4. Gerar PDF; conferir nº de páginas, fontes e tamanho; revisar o companion `.md`. +5. Guards locais (`pnpm lint`, `pnpm format:check`, `gate:push`), commit, push, PR `Closes #979`, CI verde e merge (auto-merge nativo). + +## Rabbit holes + +- Mapa de bairros com geometria própria do IBGE mas voto por ZE: a legenda e a nota de método têm que dizer que a cor do bairro é a da ZE dele (centroide), nunca sugerir medição por bairro. +- Projeção: não introduzir d3; equiretangular local com `cos(lat)` resolve Salvador. +- Rótulos: não rotular 100+ bairros; rotular ZE e uma seleção de bairros fortes. + +## Riscos + +- Malha do IBGE indisponível no build (hoje acessível): o PDF já gerado é o entregável; o cache `data/geometries/` é local e gitignored. +- 19 ZE é N pequeno para correlação: reportar Pearson e Spearman com ressalva explícita (não usar como probabilidade). +- PDF pesado demais: simplificar malha de bairros e não embutir raster; meta < 5 MB. + +## Aceite de engenharia + +- Script roda de ponta a ponta sem banco; sem dependência nova. +- PDF + companion `.md` + JSON de dados + changelog commitados. +- `pnpm lint` e `pnpm format:check` verdes; PR com CI verde. + +## Débitos + self-score + +- O JSON de entrada foi extraído da base de produção (read-only) porque o CDN do TSE está bloqueado nesta rede; regenerar exige o mesmo caminho ou um seed local com os ZIPs. Registrado na proveniência do JSON. +- Gerador não é chamado por alias `pnpm` (evita suíte full no CI); documentado no cabeçalho do script. diff --git a/docs/plans/relatorio-sobreposicao-solla-ceuci-salvador.md b/docs/plans/relatorio-sobreposicao-solla-ceuci-salvador.md new file mode 100644 index 00000000..d87ae7b5 --- /dev/null +++ b/docs/plans/relatorio-sobreposicao-solla-ceuci-salvador.md @@ -0,0 +1,75 @@ +# Relatório — sobreposição Solla × Ceuci em Salvador (2022) + +Status: rascunho +Atualizado em: 2026-09-14 +Issue: #979 +Priority: P1 +Impeccable: N/A — documento impresso, não UI +Rascunho UI: N/A — sem UI +Appetite: 1 sessão (dados + mapas + PDF) +Responsável: — + +## Intenção + +Produzir um relatório em PDF, para os assessores da campanha, comparando a votação de 2022 de **Jorge Solla** (deputado federal, PT 1313) e **Ceuci Nunes** (deputada estadual, PT 13192) nas 19 zonas eleitorais de Salvador e nos bairros, com mapas, tabelas e análise política de como a rede e a história de Ceuci — apoiadora do mandato — podem potencializar a campanha de 2026. + +O uso é imediato: o coordenador e os assessores precisam decidir onde acionar Ceuci (eventos, agenda de saúde, mobilização de rede) por zona/bairro, com número na mão — não por sensação. + +## Persona e fluxo + +- **Quem usa:** coordenador-geral (persona Nivaldo Cerqueira — prioriza por % do NOSSO voto e decide agenda/material por fila de prioridade) e assessores territoriais (dossiê pré-agenda por território). +- **Fluxo:** reunião de planejamento → escolha das zonas/bairros onde a sobreposição Solla×Ceuci é maior → encaixe da agenda de Ceuci e do material. +- **Lente analítica:** persona Prof. Helena Rocha (cientista política) — conta do quociente, dominância×concentração, LQ, nunca % estadual absoluto; mapa que normaliza e mapa que não normaliza juntos. + +## Objetivo e aceite + +- PDF A4 em `docs/research/` com: sumário executivo; tabela por ZE (votos, % dos válidos, % do próprio voto, LQ, posição, sobreposição); tabela por bairro (bairros agrupados por ZE, fonte TRE-BA RA 02/2017); mapas de Salvador por ZE (votos de cada candidato + classes de sobreposição) e por bairro (malha IBGE Censo 2022); gráficos (barras por ZE, dispersão Solla×Ceuci); leitura da ciência política e plano do coordenador. +- Companion `.md` com os números e a proveniência (PDF binário não é revisável no diff). +- Gerador commitado (`scripts/*.mjs`) + JSON de entrada com proveniência; reproduzível sem banco e sem rede (JSON e cache já no repo/homeserver local). + +## Dados (intenção) + +- Solla: 2022 federal T1 nominal, por ZE de Salvador (19) — conferido contra o artefato commitado `bahia-federal-baseline.json`. +- Ceuci: 2022 estadual T1 nominal, por ZE de Salvador (19) — TSE. +- Tallies por ZE (válidos federal e estadual) para normalizar. +- Bairros: lista oficial TRE-BA RA 02/2017 (commitada em `municipalityZoneNeighborhoods.ts`); malha IBGE Censo 2022 (download no build do relatório, cache gitignored). +- **Limite duro:** o Teqo só tem município×zona — **não existe voto por seção/bairro**. O mapa de bairros pinta cada bairro com os números da sua ZE; o relatório diz isso na cara. + +## Dados da decisão (literais) + +- Ceuci 2022: 36.992 votos no estado, **19.776 em Salvador** (mais votada do PT na capital), não eleita; presidente da Bahiafarma desde 2023; descartou candidatura em 2026. +- Solla 2022: 128.968 votos no estado; **27.264 em Salvador** (soma das 19 ZE no artefato). +- Uso: Ceuci entra como **apoiadora do mandato**, sem candidatura própria — o relatório não a trata como candidata. + +## Direção no codebase (hipótese) + +- Script novo `scripts/build-solla-ceuci-salvador-report.mjs` (HTML→PDF via Chromium do Playwright já instalado; sem dependência nova), lendo o artefato de Solla e um JSON pequeno com Ceuci/tallies/posições. +- Geometrias: `src/lib/geometries/bahia-municipality-zones.topo.json` (commitado) para o mapa de ZE; malha de bairros IBGE baixada pelo mesmo script (mesma fonte do `build-municipality-zone-geometries`), simplificada com `scripts/lib/topology.mjs`. +- Cores: `src/lib/choroplethColorScale.ts` (dono da paleta) e projeção própria (equiretangular local; sem d3). +- Destino: `docs/research/` (CI barato; `docs` fora da imagem Docker) + entrada em `docs/changelog/`. + +## Dependências + +- Issue #979. Sem dependência de código em andamento. + +## Fora de escopo + +- Voto por seção/bairro (exigiria `votacao_secao` + DE-PARA — E5, não implementado). +- UI nova em `/campanha`; alteração de collections/migrations. +- Publicação em `public/` (imagem de produção) — o PDF é entregue por anexo/PR. + +## Rabbit holes de produto + +- Tentar inferir voto por bairro por proporcionalidade: proibido (falsa precisão). +- Inferir sobreposição de eleitor individual: não existe (voto não é observável por pessoa); só geografia. +- Quociente estadual detalhado: tangencial ao uso (a decisão é territorial). + +## Questões em aberto + +- Nenhuma bloqueante; a validação é com os assessores depois da entrega. + +## Referências + +- Personas: `docs/research/persona-cientista-politico-campanha-ba.md`, `docs/research/persona-emendas-coordenador-campanha.md`. +- Dados: `src/lib/electionAggregates/bahia-federal-baseline.json`, `src/lib/bahiaTseZones.ts`, `src/lib/municipalityZoneNeighborhoods.ts`. +- Precedente de relatório: `docs/research/piloto-fonte-videos-camara.md` (C152). diff --git a/docs/research/README.md b/docs/research/README.md index 43ba966c..000944be 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -57,3 +57,14 @@ Viabilidade da fonte do acervo de falas (Issue #954): 2–3 discursos de legisla | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | | [`piloto-fonte-videos-camara.md`](piloto-fonte-videos-camara.md) | Respostas às 4 incógnitas da fonte (permanência do link, trechos em 55ª/56ª, segmentos Deep Infra, casamento antigo), custos e fallbacks | concluído 2026-09-12 | | [`piloto-fonte-videos-camara-impl.md`](../plans/piloto-fonte-videos-camara-impl.md) | Plano de implementação do piloto (script + lib pura + unit tests) | executado | + +## Relatório — sobreposição Solla × Ceuci em Salvador (C157, 2026-09-14) + +Comparação da votação de 2022 de Jorge Solla (federal, nº 1313) e Ceuci Nunes (estadual, nº 13192) nas 19 zonas eleitorais de Salvador e nos bairros: mapas por ZE e por bairro, tabelas de sobreposição e plano de campanha para acionar a rede e a memória de Ceuci (apoiadora do mandato, sem candidatura em 2026). Dados públicos TSE 2022; o limite "não existe voto por bairro" está documentado no próprio relatório. + +| Artefato | O quê | Status | +| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------- | +| [`analise-sobreposicao-solla-ceuci-salvador-2022.pdf`](analise-sobreposicao-solla-ceuci-salvador-2022.pdf) | Relatório final em PDF (15 páginas) — mapas, tabelas, leitura da ciência política e plano | entregue 2026-09-14 | +| [`analise-sobreposicao-solla-ceuci-salvador-2022.md`](analise-sobreposicao-solla-ceuci-salvador-2022.md) | Companion em Markdown com os números e a proveniência (os mapas vivem no PDF) | entregue 2026-09-14 | +| [`solla-ceuci-salvador-2022-dados.json`](solla-ceuci-salvador-2022-dados.json) | Entrada do gerador: votos e tallies por ZE, com proveniência e conferências cruzadas | congelado | +| [`../plans/relatorio-sobreposicao-solla-ceuci-salvador.md`](../plans/relatorio-sobreposicao-solla-ceuci-salvador.md) | Plano de intenção (+ [`impl`](../plans/relatorio-sobreposicao-solla-ceuci-salvador-impl.md)) | executado | diff --git a/docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.md b/docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.md new file mode 100644 index 00000000..16b613d3 --- /dev/null +++ b/docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.md @@ -0,0 +1,184 @@ +# Solla × Ceuci em Salvador + +**Onde as votações de 2022 se encontram — e como usar essa memória na campanha de 2026** +Gerado em 14 de setembro de 2026 — dados públicos TSE 2022. Documento interno de campanha. + +## Sumário executivo + +- **Sobreposição parcial, com centro claro.** As duas distribuições de voto se sobrepõem em 67,8% (índice de sobreposição; 1,0 seriam distribuições idênticas), com correlação moderada entre as 19 zonas (Pearson 0,45; Spearman 0,35). O centro comum é ZE 1, ZE 6, ZE 7, ZE 10, ZE 13. Fora dele, as duas votações seguem lógicas diferentes — e é aí que está o trabalho de campo. +- **Ceuci é candidata da capital; Solla, do estado.** 53,5% do voto de Ceuci (19.776 de 36.992) está em Salvador, contra 21,1% do voto de Solla (27.264 de 128.968). Na cidade, ela é concentrada: as 3 zonas mais fortes dela somam 44,8% do voto dela; as 3 de Solla somam 21,3%. +- **Onde ela é maior que ele.** Ceuci supera Solla em 5 zonas — ZE 1 (2.374 × 1.298); ZE 2 (1.651 × 1.199); ZE 6 (1.632 × 1.475); ZE 10 (2.806 × 2.260); ZE 13 (3.670 × 1.721). +- **Onde os dois somam mais.** As maiores somas Solla + Ceuci estão em ZE 13 (5.391), ZE 10 (5.066), ZE 1 (3.672), ZE 6 (3.107), ZE 7 (2.962) — os territórios onde uma agenda conjunta tem mais público potencial. Ela sozinha é mais forte em ZE 13 (3.670), ZE 10 (2.806), ZE 1 (2.374), ZE 2 (1.651), ZE 6 (1.632). +- **Recomendação central.** Três movimentos: (1) defender os redutos compartilhados (ZE 1, ZE 6, ZE 7, ZE 10, ZE 13) com agenda e material conjuntos; (2) usar Ceuci como cabeça de ponte onde ela é acima da média e Solla abaixo (ZE 2); (3) acionar a rede dela como apoio declarado nas zonas de Base Solla (ZE 8, ZE 14, ZE 15, ZE 16, ZE 19) — sem tratá-la como candidata: ela declarou que não disputa 2026. + +> **Como ler** Todos os números são oficiais (TSE 2022, 1º turno). O relatório compara duas candidaturas de cargos diferentes: Solla foi deputado federal (nº 1313) e Ceuci, deputada estadual (nº 13192). A sobreposição medida é entre as duas geografias de voto, não entre eleitores — não se observa quem votou em quem. + +## Quem é Ceuci Nunes — e o que os números de 2022 dizem + +Ceuci de Lima Xavier Nunes é médica infectologista e ficou 15 anos à frente do Instituto Couto Maia, referência estadual em infectologia — inclusive na pandemia de covid-19. Em 2022, foi candidata a deputada estadual pelo PT (nº 13192): recebeu 36.992 votos, não se elegeu, mas foi a mais votada do PT na capital, com 19.776 votos em Salvador. Desde março de 2023 preside a Bahiafarma, fundação estadual de medicamentos. Declarou à imprensa, em 2025, que não será candidata em 2026. + +Para a campanha de Solla, isso define o papel dela: não é concorrente interna nem candidata a acomodar — é uma liderança com rede própria na saúde, voto concentrado na capital e história de gestão reconhecida. A pergunta deste relatório é onde essa rede encosta na votação de Solla e onde ela alcança eleitor que o mandato ainda não mobiliza. + +A resposta tem dois lados. O primeiro: existe um centro comum (ZE 1, ZE 6, ZE 7, ZE 10, ZE 13) em que os dois estão acima da média — nesses territórios, a memória de Ceuci reforça o voto que Solla já tem. O segundo: a votação dela é bem mais concentrada que a dele. 61,4% do voto dela na cidade está em ZE 13 (3.670), ZE 10 (2.806), ZE 1 (2.374), ZE 2 (1.651), ZE 6 (1.632); a de Solla se espalha por toda a cidade, com presença forte em zonas periféricas onde ela quase não aparece (ZE 8, 14, 15, 16 e 19). + +> **Alerta jurídico-eleitoral** Ceuci preside uma fundação estadual. Uso de imagem, agenda e declarações em ano eleitoral deve passar pelo jurídico da campanha antes de virar material. Este relatório é análise de dados públicos — não é parecer jurídico. + +## Método, fontes e limites + +Fonte: resultados oficiais do TSE para 2022 (votação nominal de 1º turno por município e zona eleitoral). Solla: deputado federal nº 1313 (PT), 128.968 votos no estado e 27.264 em Salvador. Ceuci: deputada estadual nº 13192 (PT), 36.992 votos no estado e 19.776 em Salvador. Salvador é lida pelas suas 19 zonas eleitorais (ZE 1–19). + +Os valores foram conferidos contra duas referências: o artefato federal já commitado no Teqo (votação de Solla por zona, conferida zona a zona) e os totais oficiais de Ceuci (36.992 no estado; 19.776 em Salvador). + +Bairros: a lista de bairros por zona é a circunscrição oficial do TRE-BA (Resolução Administrativa nº 2/2017, Anexo I). O mapa de bairros usa a malha de bairros do IBGE (Censo 2022) e pinta cada bairro com os números da zona que contém seu centroide. + +Força relativa: o LQ (quociente local) compara a fatia da zona no voto do candidato com a fatia da zona nos votos válidos. LQ = 1 é exatamente a média do candidato; acima de 1, a zona é relativamente forte para ele. + +Sobreposição: o índice soma, zona a zona, o menor valor entre as duas fatias de voto (0 a 1). A correlação mede se as duas votações sobem e descem juntas entre as zonas — com 19 zonas, é indício, não probabilidade. + +> **Limite duro: não existe voto por bairro** Os dados disponíveis têm votação por município e zona eleitoral. Não há voto por seção, local de votação ou bairro — nenhum número deste relatório deve ser lido como votação de um bairro. Bairro aqui é a lista oficial de bairros que compõem a zona. O mapa de bairros é ilustrativo: mostra a malha e a classe da zona de cada bairro. + +## Mapas + +_(mapa/gráfico disponível no PDF)_ + +_(mapa/gráfico disponível no PDF)_ + +_(mapa/gráfico disponível no PDF)_ + +_(mapa/gráfico disponível no PDF)_ + +## Votos por zona e dispersão + +_(mapa/gráfico disponível no PDF)_ + +_(mapa/gráfico disponível no PDF)_ + +## Tabelas + +**Votação 2022 por zona eleitoral de Salvador (ordem de ZE)** + +| ZE | Solla | Ceuci | Soma | % válidos Solla | % válidos Ceuci | Solla no estado | Ceuci no estado | +| ----- | ----- | ----- | ----- | --------------- | --------------- | --------------- | --------------- | +| ZE 1 | 1.298 | 2.374 | 3.672 | 2,4% | 4,6% | 8º de 547 | 3º de 690 | +| ZE 2 | 1.199 | 1.651 | 2.850 | 2,0% | 2,9% | 11º de 550 | 8º de 710 | +| ZE 3 | 1.257 | 474 | 1.731 | 1,8% | 0,7% | 13º de 582 | 35º de 698 | +| ZE 4 | 1.531 | 310 | 1.841 | 2,0% | 0,4% | 12º de 551 | 53º de 710 | +| ZE 5 | 893 | 632 | 1.525 | 1,6% | 1,2% | 21º de 548 | 19º de 704 | +| ZE 6 | 1.475 | 1.632 | 3.107 | 2,3% | 2,6% | 8º de 571 | 6º de 704 | +| ZE 7 | 1.774 | 1.188 | 2.962 | 2,8% | 2,0% | 9º de 568 | 6º de 703 | +| ZE 8 | 1.455 | 348 | 1.803 | 2,2% | 0,5% | 11º de 587 | 40º de 703 | +| ZE 9 | 1.265 | 532 | 1.797 | 2,1% | 0,9% | 11º de 568 | 23º de 681 | +| ZE 10 | 2.260 | 2.806 | 5.066 | 2,4% | 3,1% | 8º de 588 | 4º de 751 | +| ZE 11 | 1.460 | 335 | 1.795 | 1,9% | 0,5% | 12º de 575 | 46º de 723 | +| ZE 12 | 1.436 | 983 | 2.419 | 1,8% | 1,3% | 16º de 591 | 12º de 720 | +| ZE 13 | 1.721 | 3.670 | 5.391 | 2,2% | 5,0% | 9º de 554 | 3º de 699 | +| ZE 14 | 1.682 | 572 | 2.254 | 2,3% | 0,8% | 10º de 578 | 25º de 719 | +| ZE 15 | 1.289 | 286 | 1.575 | 2,2% | 0,5% | 9º de 542 | 41º de 661 | +| ZE 16 | 1.643 | 653 | 2.296 | 2,4% | 1,0% | 7º de 571 | 22º de 702 | +| ZE 17 | 990 | 342 | 1.332 | 1,6% | 0,6% | 17º de 554 | 41º de 687 | +| ZE 18 | 860 | 437 | 1.297 | 1,8% | 0,9% | 12º de 565 | 26º de 665 | +| ZE 19 | 1.776 | 551 | 2.327 | 2,2% | 0,7% | 9º de 584 | 34º de 745 | + +**Força relativa e sobreposição por zona (LQ = quociente local)** + +| ZE | % do voto Solla | % do voto Ceuci | LQ Solla | LQ Ceuci | Sobreposição | Classe | +| ----- | --------------- | --------------- | -------- | -------- | ------------ | -------------------- | +| ZE 1 | 4,8% | 12,0% | 1,15 | 2,86 | 4,8% | Reduto compartilhado | +| ZE 2 | 4,4% | 8,3% | 0,93 | 1,79 | 4,4% | Ponte Ceuci | +| ZE 3 | 4,6% | 2,4% | 0,86 | 0,44 | 2,4% | Disputa ampla | +| ZE 4 | 5,6% | 1,6% | 0,95 | 0,27 | 1,6% | Disputa ampla | +| ZE 5 | 3,3% | 3,2% | 0,74 | 0,73 | 3,2% | Disputa ampla | +| ZE 6 | 5,4% | 8,3% | 1,08 | 1,63 | 5,4% | Reduto compartilhado | +| ZE 7 | 6,5% | 6,0% | 1,32 | 1,22 | 6,0% | Reduto compartilhado | +| ZE 8 | 5,3% | 1,8% | 1,02 | 0,34 | 1,8% | Base Solla | +| ZE 9 | 4,6% | 2,7% | 0,99 | 0,56 | 2,7% | Disputa ampla | +| ZE 10 | 8,3% | 14,2% | 1,12 | 1,92 | 8,3% | Reduto compartilhado | +| ZE 11 | 5,4% | 1,7% | 0,92 | 0,29 | 1,7% | Disputa ampla | +| ZE 12 | 5,3% | 5,0% | 0,87 | 0,82 | 5,0% | Disputa ampla | +| ZE 13 | 6,3% | 18,6% | 1,06 | 3,12 | 6,3% | Reduto compartilhado | +| ZE 14 | 6,2% | 2,9% | 1,09 | 0,51 | 2,9% | Base Solla | +| ZE 15 | 4,7% | 1,4% | 1,05 | 0,33 | 1,4% | Base Solla | +| ZE 16 | 6,0% | 3,3% | 1,16 | 0,64 | 3,3% | Base Solla | +| ZE 17 | 3,6% | 1,7% | 0,75 | 0,35 | 1,7% | Disputa ampla | +| ZE 18 | 3,2% | 2,2% | 0,85 | 0,58 | 2,2% | Disputa ampla | +| ZE 19 | 6,5% | 2,8% | 1,02 | 0,44 | 2,8% | Base Solla | + +**Bairros oficiais por zona eleitoral (TRE-BA RA 02/2017) e votos da ZE** + +| ZE | Classe | Solla | Ceuci | Bairros da zona | +| ----- | -------------------- | ----- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ZE 13 | Reduto compartilhado | 1.721 | 3.670 | Caminho das Árvores · Itaigara · Nordeste de Amaralina · Pituba · Santa Cruz · Vale das Pedrinhas | +| ZE 10 | Reduto compartilhado | 2.260 | 2.806 | Bairro da Paz · Boca do Rio · Costa Azul · Imbuí · Jardim Armação · Patamares · Piatã · Pituaçu · STIEP | +| ZE 1 | Reduto compartilhado | 1.298 | 2.374 | Alto das Pombas · Barra · Calabar · Canela · Centro · Centro Histórico · Garcia · Graça · Vitória | +| ZE 6 | Reduto compartilhado | 1.475 | 1.632 | Acupe · Boa Vista de Brotas · Brotas · Candeal · Engenho Velho de Brotas | +| ZE 7 | Reduto compartilhado | 1.774 | 1.188 | Barbalho · Barris · Comércio · Cosme de Farias · Luiz Anselmo · Macaúbas · Matatu · Nazaré · Santo Agostinho · Santo Antônio · Saúde · Tororó · Vila Laura | +| ZE 2 | Ponte Ceuci | 1.199 | 1.651 | Amaralina · Chapada do Rio Vermelho · Engenho Velho da Federação · Federação · Ondina · Rio Vermelho | +| ZE 12 | Disputa ampla | 1.436 | 983 | Aeroporto · Alto do Coqueirinho · Areia Branca · Itapuã · Itinga · Jardim das Margaridas · São Cristóvão · Stella Maris | +| ZE 19 | Base Solla | 1.776 | 551 | Boca da Mata · Cajazeiras X · Cajazeiras XI · Cassange · Fazenda Grande I · Fazenda Grande II · Fazenda Grande III · Fazenda Grande IV · Jaguaripe I · Jardim Nova Esperança · Mussurunga · Nova Brasília · Nova Esperança · Novo Marotinho · Trobogy | +| ZE 16 | Base Solla | 1.643 | 653 | Cabula VI · Canabrava · Centro Administrativo da Bahia · Doron · Narandiba · Nova Sussuarana · Pernambués · Saboeiro · São Rafael · Saramandaia · Vale dos Lagos | +| ZE 14 | Base Solla | 1.682 | 572 | Arenoso · Novo Horizonte · São Marcos · Sussuarana · Tancredo Neves | +| ZE 4 | Disputa ampla | 1.531 | 310 | Coutos · Fazenda Coutos · Ilha de Bom Jesus dos Passos · Ilha de Maré · Ilha dos Frades · Nova Constituinte · Paripe · Periperi (parte ao norte da Rua das Pedrinhas) · São Tomé | +| ZE 8 | Base Solla | 1.455 | 348 | Cajazeiras II · Cajazeiras IV · Cajazeiras V · Cajazeiras VI · Cajazeiras VII · Cajazeiras VIII · Campinas de Pirajá · Marechal Rondon · Moradas da Lagoa · Palestina · Pirajá · Valéria | +| ZE 9 | Disputa ampla | 1.265 | 532 | Boa Viagem · Bonfim · Jardim Cruzeiro · Mangueira · Massaranduba · Monte Serrat · Ribeira · Santa Luzia · Uruguai · Vila Ruy Barbosa | +| ZE 11 | Disputa ampla | 1.460 | 335 | Águas Claras · Calabetão · Castelo Branco · Dom Avelar · Granjas Rurais Presidente Vargas · Jardim Cajazeiras · Jardim Santo Inácio · Pau da Lima · Porto Seco Pirajá · Sete de Abril · Vila Canária | +| ZE 3 | Disputa ampla | 1.257 | 474 | Baixa de Quintas · Cidade Nova · Fazenda Grande do Retiro · IAPI · Pau Miúdo · Pero Vaz · Santa Mônica | +| ZE 15 | Base Solla | 1.289 | 286 | Arraial do Retiro · Boa Vista de São Caetano · Bom Juá · Capelinha · Lobato · São Caetano | +| ZE 5 | Disputa ampla | 893 | 632 | Barreiras · Cabula · Engomadeira · Mata Escura · Resgate · Retiro · São Gonçalo | +| ZE 17 | Disputa ampla | 990 | 342 | Alto da Terezinha · Alto do Cabrito · Itacaranha · Periperi (parte ao sul da Rua das Pedrinhas) · Plataforma · Praia Grande · Rio Sena · São João do Cabrito | +| ZE 18 | Disputa ampla | 860 | 437 | Caixa D'água · Calçada · Caminho de Areia · Curuzu · Lapinha · Liberdade · Mares · Roma | + +## Leitura da ciência política + +_Lente da persona pesquisadora — Prof. Helena Rocha (sintética)_ + +A conta do quociente. Em 2022, a Bahia teve 7.175.437 votos válidos para deputado estadual e 63 cadeiras — quociente eleitoral de aproximadamente 113.896 votos. Ceuci fez 36.992 (cerca de um terço de um quociente): não se elegeu sozinha, mas construiu uma base de capital que, em 2026, vale como rede de apoio para um deputado federal que precisa de 150 mil votos. + +Dominância e concentração são coisas diferentes. Ceuci tem dominância concentrada: LQ 3,12 na ZE 13, LQ 2,86 na ZE 1, e 44,8% do voto dela nas três maiores zonas. Solla tem dominância dispersa: as três maiores zonas dele somam 21,3%. Não é defeito de um nem virtude do outro — é a diferença entre uma candidata de uma cidade e um deputado de um estado. A estratégia mora no cruzamento: nos redutos compartilhados, somar; nas zonas de Ceuci sem Solla, transferir presença; nas de Solla sem Ceuci, não forçar. + +Eleito é o que dispersa a tempo. Solla já dispersa no estado; o risco em Salvador é concentrar esforço onde ele já vai bem. As zonas mais fortes de Ceuci (1, 2, 6, 10 e 13) são urbanas, de classe média e de serviços — não são o reduto histórico do mandato, e é exatamente aí que a rede dela muda o patamar. + +Campanha não persuade; organiza e orienta. A função de Ceuci não é converter adversários: é orientar quem já votou nela a votar 1313. São quase 37 mil pessoas no estado — 19.776 só na capital — e a literatura de campanha mostra que contato pessoal via rede de confiança é o que mobiliza. O custo é baixo porque a lista de apoiadores de 2022 existe. + +O mandato é a campanha. A memória que Ceuci carrega é de gestão e de saúde pública (Couto Maia, pandemia, medicamentos). O material e os eventos devem falar de entrega e continuidade do mandato de Solla — não de cargo, nem de futura candidatura dela. + +> **O que é dado e o que é leitura** Dado: todos os números TSE deste relatório, conferidos na fonte. Leitura: as hipóteses de transferência de voto e os papéis sugeridos para Ceuci. Nenhuma transferência de voto é medida aqui — isso só se verifica na urna de 2026. + +## Plano do coordenador + +_Lente da persona coordenadora — Nivaldo Cerqueira (sintética, ancorada em evidência real)_ + +O critério é o de sempre: % do nosso voto decide a fila, e a agenda segue a fila. A tabela abaixo traduz a análise em quatro filas de trabalho. As zonas de prioridade 1 e 2 concentram 49,0% da soma das duas votações na cidade — é onde o esforço conjunto tem retorno mais claro. + +Cuidado com fogo amigo: Ceuci não é candidata e não deve virar palanque paralelo. O material é de apoio ao mandato; o alinhamento com o PT estadual evita disputa de narrativa. + +**Fila de prioridade da campanha (critério: % do nosso voto e classe de sobreposição)** + +| Prioridade | Zonas | Por quê | Ação com Ceuci | +| ---------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 — Defender | ZE 1, ZE 6, ZE 7, ZE 10, ZE 13 | Redutos compartilhados: os dois acima da média; maior soma de votos. | Evento conjunto por zona (saúde + prestação de contas); material “Ceuci apoia Solla” na porta das unidades, rádio e digital geolocalizado. | +| 2 — Ponte Ceuci | ZE 2 | Ela acima da média, ele abaixo: rede dela alcança eleitor que o mandato ainda não mobiliza. | Agenda dela com lideranças de saúde e comunitárias; Solla entra no fim; meta: elevar o LQ de Solla na zona para ≥ 1,0. | +| 3 — Consolidar | ZE 8, ZE 14, ZE 15, ZE 16, ZE 19 | Base Solla: ele acima da média; ela tem voto residual. | Apoio declarado dela em material e visitas pontuais onde houver rede de saúde; não gastar agenda dela em zona de baixo retorno. | +| 4 — Decidir por volume | ZE 3, ZE 4, ZE 5, ZE 9, ZE 11, ZE 12, ZE 17, ZE 18 | Ambos abaixo da média; decidir pelo volume absoluto (ex.: ZE 12 e ZE 4 somam mais que ZE 5 e ZE 18). | Sem agenda dedicada dela; entra apenas em ação municipal de saúde (mutirão, anúncio de política pública). | + +- **Semana 1 — rede.** Fechar a lista de convites da rede de Ceuci (saúde, profissionais, apoiadores de 2022) e cruzar com as zonas 1, 2, 6, 10 e 13. Sem lista, não há evento. +- **Semana 2 — redutos.** Dois eventos conjuntos: ZE 13 (5.391 votos somados) e ZE 10 (5.066). Formato: agenda de saúde + prestação de contas do mandato. +- **Semana 3 — capital.** ZE 1 (3.672) e ZE 6 (3.107). Material “Ceuci apoia Solla” na porta das unidades e no digital geolocalizado. +- **Semana 4 — ponte.** ZE 2 (2.850): agenda dela com lideranças comunitárias; Solla entra no fim. Balanço e repriorização com os deltas do campo. + +> **Como usar este documento na assessoria** A tabela por bairro (seção Tabelas) é o anexo do dossiê pré-agenda: antes de qualquer visita, confira a zona do bairro e a classe. Nas zonas 1 e 2, a presença de Ceuci é o argumento; nas zonas 3 e 4, o mandato fala sozinho. + +## Anexo — fontes e reprodução + +- **Dados eleitorais.** TSE — Resultados 2022, votação nominal de 1º turno por município e zona (arquivos de dados abertos). Extração de 14/09/2026, conferida contra o artefato interno do Teqo e contra os totais oficiais. +- **Bairros.** TRE-BA, Resolução Administrativa nº 2/2017, Anexo I (circunscrição das zonas por bairro). Malha de bairros: IBGE, Censo 2022 (shapefile BA_bairros_CD2022). +- **Biografia de Ceuci.** Registro público: candidatura 2022 (TSE), votação de 36.992/19.776, presidência da Bahiafarma (2023) e declaração de não candidatura em 2026 (imprensa). +- **Reprodução.** NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-solla-ceuci-salvador-report.mjs — lê o JSON de entrada commitado e regenera o PDF e este Markdown. Sem banco de dados. +- **Aviso.** Documento interno de campanha. Não contém dados pessoais; usa apenas dados públicos de votação. Os mapas de zona são derivados de malha de bairros (aproximação) — não são limites oficiais do TSE. + +--- + +Proveniência dos dados: TSE — Resultados 2022 (votação nominal por município e zona; detalhe da votação por município e zona). Extração: 2026-09-14. Base de produção do Teqo (teqo_1313) via SELECT read-only — o CDN de dados abertos do TSE está bloqueado nesta rede. + +- Solla Salvador (soma das 19 ZE) = 27.264 = soma do artefato commitado src/lib/electionAggregates/bahia-federal-baseline.json (conferida zona a zona). +- Ceuci estado = 36.992 = total_votes_state do registro TSE e valor noticiado na imprensa. +- Ceuci Salvador (soma das 19 ZE) = 19.776 = valor noticiado na imprensa (mais votada do PT na capital). diff --git a/docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.pdf b/docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.pdf new file mode 100644 index 00000000..1987fcb8 Binary files /dev/null and b/docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.pdf differ diff --git a/docs/research/solla-ceuci-salvador-2022-dados.json b/docs/research/solla-ceuci-salvador-2022-dados.json new file mode 100644 index 00000000..79a935b4 --- /dev/null +++ b/docs/research/solla-ceuci-salvador-2022-dados.json @@ -0,0 +1,378 @@ +{ + "year": 2022, + "turn": 1, + "city": { + "name": "Salvador", + "tseCityCode": "38490", + "ibgeCode": "2927408" + }, + "offices": { + "federal": "deputado_federal", + "state": "deputado_estadual" + }, + "candidates": { + "solla": { + "candidateNumber": 1313, + "office": "deputado_federal", + "urnaName": "JORGE SOLLA", + "completeName": "JORGE JOSE SANTOS PEREIRA SOLLA", + "party": "PT", + "coalition": "FEDERAÇÃO", + "elected": true, + "stateVotes": 128968 + }, + "ceuci": { + "candidateNumber": 13192, + "office": "deputado_estadual", + "urnaName": "DR. CEUCI", + "completeName": "CEUCI DE LIMA XAVIER NUNES", + "party": "PT", + "coalition": "FEDERAÇÃO", + "elected": false, + "stateVotes": 36992 + } + }, + "stateContext": { + "federalValid": 7605138, + "stateValid": 7175437, + "federalCandidates": 669, + "stateCandidates": 847 + }, + "salvadorTotals": { + "sollaVotes": 27264, + "ceuciVotes": 19776, + "federalValid": 1290350, + "stateValid": 1232082, + "federalComparecimento": 1627906, + "stateComparecimento": 1627906 + }, + "zones": [ + { + "zoneNumber": 1, + "sollaVotes": 1298, + "ceuciVotes": 2374, + "federalValid": 53575, + "stateValid": 51697, + "federalComparecimento": 62489, + "stateComparecimento": 62489, + "sollaFederalRank": 8, + "federalCandidates": 547, + "ceuciStateRank": 3, + "stateCandidates": 690 + }, + { + "zoneNumber": 2, + "sollaVotes": 1199, + "ceuciVotes": 1651, + "federalValid": 61247, + "stateValid": 57599, + "federalComparecimento": 74547, + "stateComparecimento": 74547, + "sollaFederalRank": 11, + "federalCandidates": 550, + "ceuciStateRank": 8, + "stateCandidates": 710 + }, + { + "zoneNumber": 3, + "sollaVotes": 1257, + "ceuciVotes": 474, + "federalValid": 69090, + "stateValid": 67283, + "federalComparecimento": 91018, + "stateComparecimento": 91018, + "sollaFederalRank": 13, + "federalCandidates": 582, + "ceuciStateRank": 35, + "stateCandidates": 698 + }, + { + "zoneNumber": 4, + "sollaVotes": 1531, + "ceuciVotes": 310, + "federalValid": 76325, + "stateValid": 72120, + "federalComparecimento": 100352, + "stateComparecimento": 100352, + "sollaFederalRank": 12, + "federalCandidates": 551, + "ceuciStateRank": 53, + "stateCandidates": 710 + }, + { + "zoneNumber": 5, + "sollaVotes": 893, + "ceuciVotes": 632, + "federalValid": 57013, + "stateValid": 54068, + "federalComparecimento": 71095, + "stateComparecimento": 71095, + "sollaFederalRank": 21, + "federalCandidates": 548, + "ceuciStateRank": 19, + "stateCandidates": 704 + }, + { + "zoneNumber": 6, + "sollaVotes": 1475, + "ceuciVotes": 1632, + "federalValid": 64496, + "stateValid": 62230, + "federalComparecimento": 79572, + "stateComparecimento": 79572, + "sollaFederalRank": 8, + "federalCandidates": 571, + "ceuciStateRank": 6, + "stateCandidates": 704 + }, + { + "zoneNumber": 7, + "sollaVotes": 1774, + "ceuciVotes": 1188, + "federalValid": 63799, + "stateValid": 60694, + "federalComparecimento": 77593, + "stateComparecimento": 77593, + "sollaFederalRank": 9, + "federalCandidates": 568, + "ceuciStateRank": 6, + "stateCandidates": 703 + }, + { + "zoneNumber": 8, + "sollaVotes": 1455, + "ceuciVotes": 348, + "federalValid": 67371, + "stateValid": 63750, + "federalComparecimento": 87276, + "stateComparecimento": 87276, + "sollaFederalRank": 11, + "federalCandidates": 587, + "ceuciStateRank": 40, + "stateCandidates": 703 + }, + { + "zoneNumber": 9, + "sollaVotes": 1265, + "ceuciVotes": 532, + "federalValid": 60344, + "stateValid": 59204, + "federalComparecimento": 79818, + "stateComparecimento": 79818, + "sollaFederalRank": 11, + "federalCandidates": 568, + "ceuciStateRank": 23, + "stateCandidates": 681 + }, + { + "zoneNumber": 10, + "sollaVotes": 2260, + "ceuciVotes": 2806, + "federalValid": 95719, + "stateValid": 90859, + "federalComparecimento": 113017, + "stateComparecimento": 113017, + "sollaFederalRank": 8, + "federalCandidates": 588, + "ceuciStateRank": 4, + "stateCandidates": 751 + }, + { + "zoneNumber": 11, + "sollaVotes": 1460, + "ceuciVotes": 335, + "federalValid": 75491, + "stateValid": 71434, + "federalComparecimento": 97411, + "stateComparecimento": 97411, + "sollaFederalRank": 12, + "federalCandidates": 575, + "ceuciStateRank": 46, + "stateCandidates": 723 + }, + { + "zoneNumber": 12, + "sollaVotes": 1436, + "ceuciVotes": 983, + "federalValid": 78414, + "stateValid": 74998, + "federalComparecimento": 97061, + "stateComparecimento": 97061, + "sollaFederalRank": 16, + "federalCandidates": 591, + "ceuciStateRank": 12, + "stateCandidates": 720 + }, + { + "zoneNumber": 13, + "sollaVotes": 1721, + "ceuciVotes": 3670, + "federalValid": 76839, + "stateValid": 73307, + "federalComparecimento": 90093, + "stateComparecimento": 90093, + "sollaFederalRank": 9, + "federalCandidates": 554, + "ceuciStateRank": 3, + "stateCandidates": 699 + }, + { + "zoneNumber": 14, + "sollaVotes": 1682, + "ceuciVotes": 572, + "federalValid": 72988, + "stateValid": 69464, + "federalComparecimento": 94242, + "stateComparecimento": 94242, + "sollaFederalRank": 10, + "federalCandidates": 578, + "ceuciStateRank": 25, + "stateCandidates": 719 + }, + { + "zoneNumber": 15, + "sollaVotes": 1289, + "ceuciVotes": 286, + "federalValid": 57956, + "stateValid": 53814, + "federalComparecimento": 74565, + "stateComparecimento": 74565, + "sollaFederalRank": 9, + "federalCandidates": 542, + "ceuciStateRank": 41, + "stateCandidates": 661 + }, + { + "zoneNumber": 16, + "sollaVotes": 1643, + "ceuciVotes": 653, + "federalValid": 67117, + "stateValid": 64030, + "federalComparecimento": 84583, + "stateComparecimento": 84583, + "sollaFederalRank": 7, + "federalCandidates": 571, + "ceuciStateRank": 22, + "stateCandidates": 702 + }, + { + "zoneNumber": 17, + "sollaVotes": 990, + "ceuciVotes": 342, + "federalValid": 62509, + "stateValid": 60593, + "federalComparecimento": 85769, + "stateComparecimento": 85769, + "sollaFederalRank": 17, + "federalCandidates": 554, + "ceuciStateRank": 41, + "stateCandidates": 687 + }, + { + "zoneNumber": 18, + "sollaVotes": 860, + "ceuciVotes": 437, + "federalValid": 47708, + "stateValid": 46874, + "federalComparecimento": 63580, + "stateComparecimento": 63580, + "sollaFederalRank": 12, + "federalCandidates": 565, + "ceuciStateRank": 26, + "stateCandidates": 665 + }, + { + "zoneNumber": 19, + "sollaVotes": 1776, + "ceuciVotes": 551, + "federalValid": 82349, + "stateValid": 78064, + "federalComparecimento": 103825, + "stateComparecimento": 103825, + "sollaFederalRank": 9, + "federalCandidates": 584, + "ceuciStateRank": 34, + "stateCandidates": 745 + } + ], + "topCities": { + "solla": [ + { + "city_name": "Salvador", + "votes": 27264 + }, + { + "city_name": "Amargosa", + "votes": 7602 + }, + { + "city_name": "Vitória da Conquista", + "votes": 5005 + }, + { + "city_name": "Feira de Santana", + "votes": 3074 + }, + { + "city_name": "Santa Inês", + "votes": 2925 + }, + { + "city_name": "Mutuípe", + "votes": 2816 + }, + { + "city_name": "Itaberaba", + "votes": 2558 + }, + { + "city_name": "Governador Mangabeira", + "votes": 2336 + } + ], + "ceuci": [ + { + "city_name": "Salvador", + "votes": 19776 + }, + { + "city_name": "Amargosa", + "votes": 4570 + }, + { + "city_name": "Miguel Calmon", + "votes": 1775 + }, + { + "city_name": "Lauro de Freitas", + "votes": 804 + }, + { + "city_name": "Feira de Santana", + "votes": 552 + }, + { + "city_name": "Itaberaba", + "votes": 488 + }, + { + "city_name": "Castro Alves", + "votes": 426 + }, + { + "city_name": "Camaçari", + "votes": 409 + } + ] + }, + "provenance": { + "source": "TSE — Resultados 2022 (votação nominal por município e zona; detalhe da votação por município e zona).", + "extractedAt": "2026-09-14", + "extractedFrom": "Base de produção do Teqo (teqo_1313) via SELECT read-only — o CDN de dados abertos do TSE está bloqueado nesta rede.", + "crossChecks": [ + "Solla Salvador (soma das 19 ZE) = 27.264 = soma do artefato commitado src/lib/electionAggregates/bahia-federal-baseline.json (conferida zona a zona).", + "Ceuci estado = 36.992 = total_votes_state do registro TSE e valor noticiado na imprensa.", + "Ceuci Salvador (soma das 19 ZE) = 19.776 = valor noticiado na imprensa (mais votada do PT na capital)." + ] + } +} diff --git a/scripts/build-solla-ceuci-salvador-report.mjs b/scripts/build-solla-ceuci-salvador-report.mjs new file mode 100644 index 00000000..75e1d0da --- /dev/null +++ b/scripts/build-solla-ceuci-salvador-report.mjs @@ -0,0 +1,1086 @@ +/** + * One-off report generator (C157): Solla × Ceuci in Salvador, 2022 TSE. + * + * Produces, from the committed input JSON and the committed geometries: + * - docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.md + * - docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.pdf + * + * Why a script instead of a Payload route: the deliverable is a printable + * document for the campaign staff, and the project's standing decision is + * CSS/HTML print over a server-side PDF library (docs/plans/dossie-municipio.md). + * Chromium comes from the already-installed @playwright/test — no new dependency. + * + * Data: docs/research/solla-ceuci-salvador-2022-dados.json (public TSE 2022, + * provenance inside). The script re-checks Solla zone by zone against the + * committed federal artifact before rendering, so the report can never drift + * from the app's own baseline. + * + * Geometry: Salvador zones from src/lib/geometries/bahia-municipality-zones.topo.json + * (committed); Salvador neighborhoods from the IBGE Censo 2022 shapefile, + * downloaded once to the gitignored data/geometries cache (the same source the + * zone build uses). Each neighborhood is painted with the numbers of the zone + * that contains its centroid — the votes themselves exist only per zone. + * + * Usage (no pnpm alias on purpose: a package.json change would force the full + * CI suite for a one-off report): + * NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-solla-ceuci-salvador-report.mjs + * + * Safety: read-only; no database, no network when the geometry cache exists. + */ + +import { chromium } from '@playwright/test' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import shp from 'shpjs' +import { feature } from 'topojson-client' + +import { dieWithLabel, ensureCachedDownload } from './lib/cli.mjs' +import { + buildReportSummary, + buildZoneMetrics, + ZONE_CLASSES, +} from './lib/sollaCeuciSalvadorMetrics.mjs' + +const { discreteChoroplethFill, NO_DATA_FILL } = await import('../src/lib/choroplethColorScale.ts') +const { featureCentroid, featureContainsPoint, haversineKm } = + await import('../src/lib/municipalityProximity.ts') +const { municipalityZoneNeighborhoods } = + await import('../src/lib/municipalityZoneNeighborhoods.ts') +const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts') + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const LABEL = 'build-solla-ceuci-salvador-report' +const die = dieWithLabel(LABEL) + +const DATA_PATH = join(ROOT, 'docs/research/solla-ceuci-salvador-2022-dados.json') +const ARTIFACT_PATH = join(ROOT, 'src/lib/electionAggregates/bahia-federal-baseline.json') +const ZONE_TOPOLOGY_PATH = join(ROOT, 'src/lib/geometries/bahia-municipality-zones.topo.json') +const REPORT_BASE = 'docs/research/analise-sobreposicao-solla-ceuci-salvador-2022' +const CACHE_DIR = join(ROOT, 'data/geometries') + +const BAIRRO_SOURCE = { + key: 'bahia-bairros-cd2022', + url: 'http://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_de_setores_censitarios__divisoes_intramunicipais/censo_2022/bairros/shp/UF/BA_bairros_CD2022.zip', + ext: 'zip', +} + +const int = new Intl.NumberFormat('pt-BR') +const nf = (value) => int.format(Math.round(value)) +const dec = (value, digits = 2) => value.toFixed(digits).replace('.', ',') +const pct = (value, digits = 1) => `${dec(value * 100, digits)}%` +const list = (values) => values.map((value) => `ZE ${value}`).join(', ') +const zoneList = (zones) => zones.map((zone) => zone.zoneNumber) + +const htmlEscape = (value) => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + +// --------------------------------------------------------------------------- +// Input data +// --------------------------------------------------------------------------- + +const data = JSON.parse(await readFile(DATA_PATH, 'utf8')) +const artifact = JSON.parse(await readFile(ARTIFACT_PATH, 'utf8')) + +for (const zone of data.zones) { + const baseline = artifact.municipalities[`salvador-ze-${zone.zoneNumber}`] + const committed = baseline?.votesByYear?.['2022'] + if (committed !== zone.sollaVotes) { + die( + `Solla drift in ZE ${zone.zoneNumber}: committed artifact has ${committed}, ` + + `report input has ${zone.sollaVotes}.`, + ) + } +} + +const metrics = buildZoneMetrics({ zones: data.zones, salvadorTotals: data.salvadorTotals }) +const summary = buildReportSummary({ + metrics, + candidates: data.candidates, + salvadorTotals: data.salvadorTotals, +}) +const metricsByZone = new Map(metrics.map((zone) => [zone.zoneNumber, zone])) + +const neighborhoodsByZone = new Map( + municipalityZoneNeighborhoods.map((record) => [record.zoneNumber, record.neighborhoods]), +) + +// --------------------------------------------------------------------------- +// Geometry +// --------------------------------------------------------------------------- + +const zoneTopology = JSON.parse(await readFile(ZONE_TOPOLOGY_PATH, 'utf8')) +const zoneFeatures = feature(zoneTopology, zoneTopology.objects.municipalityZones).features +if (zoneFeatures.length !== 19) die(`Expected 19 Salvador zones, got ${zoneFeatures.length}.`) + +const { buffer: bairroBuffer, hash: bairroHash } = await ensureCachedDownload({ + label: LABEL, + ...BAIRRO_SOURCE, + cacheDir: CACHE_DIR, + download: downloadToBuffer, +}) + +const bairroCollections = [await shp(bairroBuffer)].flat() +const bairros = bairroCollections + .flatMap((collection) => collection.features) + .filter((item) => String(item.properties?.CD_MUN) === data.city.ibgeCode) +if (bairros.length === 0) die('No Salvador neighborhoods in the IBGE mesh.') + +const zoneBySlug = new Map(zoneFeatures.map((zone) => [zone.properties.municipalitySlug, zone])) + +let nearestFallbacks = 0 +for (const bairro of bairros) { + const centroid = featureCentroid(bairro) + const containing = zoneFeatures.find((zone) => featureContainsPoint(zone, centroid)) + if (containing) { + bairro.zoneSlug = containing.properties.municipalitySlug + continue + } + + nearestFallbacks += 1 + const nearest = [...zoneFeatures] + .map((zone) => ({ + slug: zone.properties.municipalitySlug, + distanceKm: haversineKm(centroid, featureCentroid(zone)), + })) + .sort((left, right) => left.distanceKm - right.distanceKm)[0] + bairro.zoneSlug = nearest.slug + console.log( + `[${LABEL}] "${bairro.properties?.NM_BAIRRO}" has no zone at its centroid — ` + + `nearest ${nearest.slug} (${nearest.distanceKm.toFixed(1)} km).`, + ) +} + +const zoneNumberBySlug = new Map( + [...zoneBySlug.keys()].map((slug) => [slug, Number(slug.replace('salvador-ze-', ''))]), +) +for (const bairro of bairros) { + const zoneNumber = zoneNumberBySlug.get(bairro.zoneSlug) + if (!Number.isFinite(zoneNumber)) die(`Bad zone slug ${bairro.zoneSlug}.`) + bairro.zoneNumber = zoneNumber +} + +// --------------------------------------------------------------------------- +// Projection (local equirectangular; the mesh is ~30 km wide, no d3 needed) +// --------------------------------------------------------------------------- + +const ringPositions = (geometry) => { + const polygons = geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.coordinates + return polygons.flatMap((rings) => rings.flat()) +} + +const positions = bairros.flatMap((bairro) => ringPositions(bairro.geometry)) +const bounds = positions.reduce( + (acc, [lng, lat]) => [ + Math.min(acc[0], lng), + Math.min(acc[1], lat), + Math.max(acc[2], lng), + Math.max(acc[3], lat), + ], + [Infinity, Infinity, -Infinity, -Infinity], +) + +const VIEW_WIDTH = 1000 +const VIEW_PADDING = 8 +const lat0 = (bounds[1] + bounds[3]) / 2 +const lngScale = Math.cos((lat0 * Math.PI) / 180) +const mapWidth = (bounds[2] - bounds[0]) * lngScale +const mapHeight = bounds[3] - bounds[1] +const mapScale = (VIEW_WIDTH - VIEW_PADDING * 2) / mapWidth +const VIEW_HEIGHT = Math.round(VIEW_PADDING * 2 + mapHeight * mapScale) + +const project = (lng, lat) => [ + VIEW_PADDING + (lng - bounds[0]) * lngScale * mapScale, + VIEW_PADDING + (bounds[3] - lat) * mapScale, +] + +const pathOf = (geometry) => { + const polygons = geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.coordinates + let path = '' + for (const rings of polygons) { + for (const ring of rings) { + ring.forEach(([lng, lat], index) => { + const [x, y] = project(lng, lat) + path += `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}` + }) + path += 'Z' + } + } + return path +} + +const centroidPoint = (item) => { + const { lng, lat } = featureCentroid(item) + return project(lng, lat) +} + +// --------------------------------------------------------------------------- +// SVG building blocks +// --------------------------------------------------------------------------- + +const zoneLabelSvg = () => + zoneFeatures + .map((zone) => { + const [x, y] = centroidPoint(zone) + const zoneNumber = Number(zone.properties.municipalitySlug.replace('salvador-ze-', '')) + return `${zoneNumber}` + }) + .join('') + +const bairroOutlineSvg = (stroke, width) => + bairros + .map( + (bairro) => + ``, + ) + .join('') + +const legendSwatch = (color, label, border = '#d4d4d8') => + `${label}` + +const quantileClasses = (values, classCount = 5) => { + const sorted = [...values].sort((left, right) => right - left) + const byValue = new Map() + sorted.forEach((value, index) => { + if (byValue.has(value)) return + const rankClass = Math.min(classCount - 1, Math.floor((index * classCount) / sorted.length)) + byValue.set(value, classCount - 1 - rankClass) + }) + return byValue +} + +const classRanges = (values, classCount = 5) => { + const sorted = [...values].sort((left, right) => left - right) + const ranges = [] + for (let index = 0; index < classCount; index += 1) { + const start = Math.floor((index * sorted.length) / classCount) + const end = Math.floor(((index + 1) * sorted.length) / classCount) - 1 + ranges.push({ min: sorted[start], max: sorted[end] }) + } + return ranges +} + +const renderZoneChoropleth = ({ values, title, caption, legendTitle }) => { + const entries = [...values.values()] + const classes = quantileClasses(entries) + const ranges = classRanges(entries) + const fills = zoneFeatures + .map((zone) => { + const zoneNumber = Number(zone.properties.municipalitySlug.replace('salvador-ze-', '')) + const value = values.get(zoneNumber) ?? 0 + const classIndex = classes.get(value) ?? 0 + const fill = value > 0 ? discreteChoroplethFill(classIndex, 5) : NO_DATA_FILL + return `` + }) + .join('') + + const legend = ranges + .map((range, index) => + legendSwatch(discreteChoroplethFill(index, 5), `${nf(range.min)}–${nf(range.max)}`), + ) + .join('') + + return ` +
+
${htmlEscape(title)}${htmlEscape(caption)}
+ + ${bairroOutlineSvg('rgba(255,255,255,0.5)', 0.45)} + ${fills} + ${zoneLabelSvg()} + +
${htmlEscape(legendTitle)}${legend}
+
` +} + +const renderZoneClassMap = () => { + const fills = zoneFeatures + .map((zoneFeature) => { + const zoneNumber = Number(zoneFeature.properties.municipalitySlug.replace('salvador-ze-', '')) + const zoneMetric = metricsByZone.get(zoneNumber) + const fill = zoneMetric?.zoneClass.color ?? NO_DATA_FILL + return `` + }) + .join('') + + const legend = Object.values(ZONE_CLASSES) + .map((zoneClass) => { + const count = metrics.filter((zone) => zone.zoneClass.key === zoneClass.key).length + return legendSwatch(zoneClass.color, `${zoneClass.label} (${count})`) + }) + .join('') + + return ` +
+
Classes de sobreposição por zona eleitoralCruzamento do quociente local (LQ) de cada candidato: acima ou abaixo da média dele em Salvador.
+ + ${bairroOutlineSvg('rgba(255,255,255,0.5)', 0.45)} + ${fills} + ${zoneLabelSvg()} + +
${legend}
+
` +} + +const renderBairroClassMap = () => { + const fills = bairros + .map((bairro) => { + const zone = metricsByZone.get(bairro.zoneNumber) + const fill = zone?.zoneClass.color ?? NO_DATA_FILL + return `` + }) + .join('') + + const zoneBorders = zoneFeatures + .map( + (zone) => + ``, + ) + .join('') + + const legend = Object.values(ZONE_CLASSES) + .map((zoneClass) => legendSwatch(zoneClass.color, zoneClass.label)) + .join('') + + return ` +
+
Salvador por bairros — classes da zona eleitoral de cada bairroMalha de bairros do IBGE (Censo 2022); cada bairro recebe a classe da ZE que contém seu centroide. As linhas grossas são os limites das 19 ZE. Os votos existem por zona, não por bairro.
+ + ${fills} + ${zoneBorders} + ${zoneLabelSvg()} + +
${legend}
+
` +} + +const renderBars = () => { + const rows = [...metrics].sort((left, right) => right.combinedVotes - left.combinedVotes) + const maxValue = Math.max(...metrics.map((zone) => Math.max(zone.sollaVotes, zone.ceuciVotes))) + const barMaxWidth = 760 + const rowHeight = 27 + const top = 34 + const height = top + rows.length * rowHeight + 12 + + const bars = rows + .map((zone, index) => { + const y = top + index * rowHeight + const sollaWidth = (zone.sollaVotes / maxValue) * barMaxWidth + const ceuciWidth = (zone.ceuciVotes / maxValue) * barMaxWidth + return ` + ZE ${zone.zoneNumber} + + ${nf(zone.sollaVotes)} + + ${nf(zone.ceuciVotes)}` + }) + .join('') + + return ` +
+
Votos por zona eleitoral — Solla e Ceuci (2022)Zonas ordenadas pela soma Solla + Ceuci. Vermelho: Solla (federal 1313). Verde: Ceuci (estadual 13192).
+ + ${bars} + +
` +} + +const renderScatter = () => { + const width = 1000 + const height = 520 + const left = 80 + const right = 40 + const top = 30 + const bottom = 60 + const maxX = Math.max(...metrics.map((zone) => zone.sollaVotes)) + const maxY = Math.max(...metrics.map((zone) => zone.ceuciVotes)) + const xOf = (value) => left + (value / maxX) * (width - left - right) + const yOf = (value) => height - bottom - (value / maxY) * (height - top - bottom) + const xTicks = 5 + const yTicks = 5 + + const grid = Array.from({ length: xTicks + 1 }, (_, index) => { + const value = (maxX / xTicks) * index + const x = xOf(value) + return `${nf(value)}` + }).join('') + + const yGrid = Array.from({ length: yTicks + 1 }, (_, index) => { + const value = (maxY / yTicks) * index + const y = yOf(value) + return `${nf(value)}` + }).join('') + + const medianX = [...metrics].map((zone) => zone.sollaVotes).sort((a, b) => a - b)[ + Math.floor(metrics.length / 2) + ] + const medianY = [...metrics].map((zone) => zone.ceuciVotes).sort((a, b) => a - b)[ + Math.floor(metrics.length / 2) + ] + + const points = metrics + .map((zone) => { + const x = xOf(zone.sollaVotes) + const y = yOf(zone.ceuciVotes) + return `ZE ${zone.zoneNumber}` + }) + .join('') + + return ` +
+
Dispersão — Solla × Ceuci por zonaCada ponto é uma ZE; cor = classe de sobreposição. Linhas tracejadas: medianas (Solla ${nf(medianX)}; Ceuci ${nf(medianY)}). Correlação de Pearson ${dec(summary.pearson)}; Spearman ${dec(summary.spearman)} (N=19).
+ + ${grid}${yGrid} + + + ${points} + Votos de Solla (federal 1313) + Votos de Ceuci (estadual 13192) + + +
` +} + +// --------------------------------------------------------------------------- +// Tables +// --------------------------------------------------------------------------- + +const classBadge = (zoneClass) => + `${htmlEscape(zoneClass.label)}` + +const zoneTable = () => ({ + caption: 'Votação 2022 por zona eleitoral de Salvador (ordem de ZE)', + head: [ + 'ZE', + 'Solla', + 'Ceuci', + 'Soma', + '% válidos Solla', + '% válidos Ceuci', + 'Solla no estado', + 'Ceuci no estado', + ], + align: ['left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'], + rows: metrics.map((zone) => [ + `ZE ${zone.zoneNumber}`, + nf(zone.sollaVotes), + nf(zone.ceuciVotes), + nf(zone.combinedVotes), + pct(zone.sollaShareValid), + pct(zone.ceuciShareValid), + `${zone.sollaFederalRank}º de ${nf(zone.federalCandidates)}`, + `${zone.ceuciStateRank}º de ${nf(zone.stateCandidates)}`, + ]), +}) + +const overlapTable = () => ({ + caption: 'Força relativa e sobreposição por zona (LQ = quociente local)', + head: [ + 'ZE', + '% do voto Solla', + '% do voto Ceuci', + 'LQ Solla', + 'LQ Ceuci', + 'Sobreposição', + 'Classe', + ], + align: ['left', 'right', 'right', 'right', 'right', 'right', 'left'], + rows: metrics.map((zone) => [ + `ZE ${zone.zoneNumber}`, + pct(zone.sollaShareOwn), + pct(zone.ceuciShareOwn), + dec(zone.sollaLq), + dec(zone.ceuciLq), + pct(zone.overlapMin), + classBadge(zone.zoneClass), + ]), +}) + +const bairroTable = () => { + const rows = [...metrics] + .sort((left, right) => right.combinedVotes - left.combinedVotes) + .map((zone) => [ + `ZE ${zone.zoneNumber}`, + classBadge(zone.zoneClass), + nf(zone.sollaVotes), + nf(zone.ceuciVotes), + (neighborhoodsByZone.get(zone.zoneNumber) ?? []).join(' · '), + ]) + return { + caption: 'Bairros oficiais por zona eleitoral (TRE-BA RA 02/2017) e votos da ZE', + head: ['ZE', 'Classe', 'Solla', 'Ceuci', 'Bairros da zona'], + align: ['left', 'left', 'right', 'right', 'left'], + rows, + } +} + +const priorityTable = () => ({ + caption: 'Fila de prioridade da campanha (critério: % do nosso voto e classe de sobreposição)', + head: ['Prioridade', 'Zonas', 'Por quê', 'Ação com Ceuci'], + align: ['left', 'left', 'left', 'left'], + rows: [ + [ + '1 — Defender', + list(zoneList(summary.shared)), + 'Redutos compartilhados: os dois acima da média; maior soma de votos.', + 'Evento conjunto por zona (saúde + prestação de contas); material “Ceuci apoia Solla” na porta das unidades, rádio e digital geolocalizado.', + ], + [ + '2 — Ponte Ceuci', + list(zoneList(summary.ceuciOnly)), + 'Ela acima da média, ele abaixo: rede dela alcança eleitor que o mandato ainda não mobiliza.', + 'Agenda dela com lideranças de saúde e comunitárias; Solla entra no fim; meta: elevar o LQ de Solla na zona para ≥ 1,0.', + ], + [ + '3 — Consolidar', + list(zoneList(summary.sollaOnly)), + 'Base Solla: ele acima da média; ela tem voto residual.', + 'Apoio declarado dela em material e visitas pontuais onde houver rede de saúde; não gastar agenda dela em zona de baixo retorno.', + ], + [ + '4 — Decidir por volume', + list(zoneList(summary.open)), + 'Ambos abaixo da média; decidir pelo volume absoluto (ex.: ZE 12 e ZE 4 somam mais que ZE 5 e ZE 18).', + 'Sem agenda dedicada dela; entra apenas em ação municipal de saúde (mutirão, anúncio de política pública).', + ], + ], +}) + +// --------------------------------------------------------------------------- +// Narrative model (rendered to HTML and Markdown from the same blocks) +// --------------------------------------------------------------------------- + +const strongestCeuci = summary.strongestCeuci +const strongestCombined = summary.strongestCombined +const ceuciAheadText = summary.ceuciAhead + .map((zone) => `ZE ${zone.zoneNumber} (${nf(zone.ceuciVotes)} × ${nf(zone.sollaVotes)})`) + .join('; ') +const combinedTop = strongestCombined + .slice(0, 5) + .map((zone) => `ZE ${zone.zoneNumber} (${nf(zone.combinedVotes)})`) + .join(', ') +const ceuciTop = strongestCeuci + .slice(0, 5) + .map((zone) => `ZE ${zone.zoneNumber} (${nf(zone.ceuciVotes)})`) + .join(', ') +const ceuciTop5Share = strongestCeuci.slice(0, 5).reduce((sum, zone) => sum + zone.ceuciShareOwn, 0) + +const executiveBullets = [ + { + label: 'Sobreposição parcial, com centro claro.', + text: + `As duas distribuições de voto se sobrepõem em ${pct(summary.overlapCoefficient)} ` + + `(índice de sobreposição; 1,0 seriam distribuições idênticas), com correlação moderada entre as 19 zonas ` + + `(Pearson ${dec(summary.pearson)}; Spearman ${dec(summary.spearman)}). O centro comum é ${list(zoneList(summary.shared))}. ` + + `Fora dele, as duas votações seguem lógicas diferentes — e é aí que está o trabalho de campo.`, + }, + { + label: 'Ceuci é candidata da capital; Solla, do estado.', + text: + `${pct(summary.salvadorShareOfCeuciState)} do voto de Ceuci (${nf(data.salvadorTotals.ceuciVotes)} de ${nf(data.candidates.ceuci.stateVotes)}) ` + + `está em Salvador, contra ${pct(summary.salvadorShareOfSollaState)} do voto de Solla (${nf(data.salvadorTotals.sollaVotes)} de ${nf(data.candidates.solla.stateVotes)}). ` + + `Na cidade, ela é concentrada: as 3 zonas mais fortes dela somam ${pct(summary.ceuciTop3Share)} do voto dela; as 3 de Solla somam ${pct(summary.sollaTop3Share)}.`, + }, + { + label: 'Onde ela é maior que ele.', + text: `Ceuci supera Solla em ${summary.ceuciAhead.length} zonas — ${ceuciAheadText}.`, + }, + { + label: 'Onde os dois somam mais.', + text: + `As maiores somas Solla + Ceuci estão em ${combinedTop} — os territórios onde uma agenda conjunta tem mais público potencial. ` + + `Ela sozinha é mais forte em ${ceuciTop}.`, + }, + { + label: 'Recomendação central.', + text: + `Três movimentos: (1) defender os redutos compartilhados (${list(zoneList(summary.shared))}) com agenda e material conjuntos; ` + + `(2) usar Ceuci como cabeça de ponte onde ela é acima da média e Solla abaixo (${list(zoneList(summary.ceuciOnly))}); ` + + `(3) acionar a rede dela como apoio declarado nas zonas de Base Solla (${list(zoneList(summary.sollaOnly))}) — ` + + `sem tratá-la como candidata: ela declarou que não disputa 2026.`, + }, +] + +const priorityZones = [...summary.shared, ...summary.ceuciOnly] +const priorityVotes = priorityZones.reduce((sum, zone) => sum + zone.combinedVotes, 0) +const priorityShare = + priorityVotes / (data.salvadorTotals.sollaVotes + data.salvadorTotals.ceuciVotes) + +const report = { + title: 'Solla × Ceuci em Salvador', + subtitle: 'Onde as votações de 2022 se encontram — e como usar essa memória na campanha de 2026', + date: new Intl.DateTimeFormat('pt-BR', { dateStyle: 'long' }).format(new Date()), + kpis: [ + { + value: nf(data.salvadorTotals.sollaVotes), + label: 'votos de Solla em Salvador (federal 1313)', + }, + { + value: nf(data.salvadorTotals.ceuciVotes), + label: 'votos de Ceuci em Salvador (estadual 13192)', + }, + { + value: pct(summary.overlapCoefficient, 0), + label: 'índice de sobreposição das distribuições', + }, + { value: '19', label: 'zonas eleitorais · 170 bairros na malha' }, + ], + sections: [ + { + id: 'sumario', + title: 'Sumário executivo', + blocks: [ + { type: 'bullets', items: executiveBullets }, + { + type: 'note', + title: 'Como ler', + text: + 'Todos os números são oficiais (TSE 2022, 1º turno). O relatório compara duas candidaturas de cargos diferentes: ' + + 'Solla foi deputado federal (nº 1313) e Ceuci, deputada estadual (nº 13192). A sobreposição medida é entre as duas ' + + 'geografias de voto, não entre eleitores — não se observa quem votou em quem.', + }, + ], + }, + { + id: 'ceuci', + title: 'Quem é Ceuci Nunes — e o que os números de 2022 dizem', + blocks: [ + { + type: 'paragraphs', + items: [ + 'Ceuci de Lima Xavier Nunes é médica infectologista e ficou 15 anos à frente do Instituto Couto Maia, referência estadual em infectologia — inclusive na pandemia de covid-19. Em 2022, foi candidata a deputada estadual pelo PT (nº 13192): recebeu 36.992 votos, não se elegeu, mas foi a mais votada do PT na capital, com 19.776 votos em Salvador. Desde março de 2023 preside a Bahiafarma, fundação estadual de medicamentos. Declarou à imprensa, em 2025, que não será candidata em 2026.', + `Para a campanha de Solla, isso define o papel dela: não é concorrente interna nem candidata a acomodar — é uma liderança com rede própria na saúde, voto concentrado na capital e história de gestão reconhecida. A pergunta deste relatório é onde essa rede encosta na votação de Solla e onde ela alcança eleitor que o mandato ainda não mobiliza.`, + `A resposta tem dois lados. O primeiro: existe um centro comum (${list(zoneList(summary.shared))}) em que os dois estão acima da média — nesses territórios, a memória de Ceuci reforça o voto que Solla já tem. O segundo: a votação dela é bem mais concentrada que a dele. ${pct(ceuciTop5Share)} do voto dela na cidade está em ${ceuciTop}; a de Solla se espalha por toda a cidade, com presença forte em zonas periféricas onde ela quase não aparece (ZE 8, 14, 15, 16 e 19).`, + ], + }, + { + type: 'note', + title: 'Alerta jurídico-eleitoral', + text: 'Ceuci preside uma fundação estadual. Uso de imagem, agenda e declarações em ano eleitoral deve passar pelo jurídico da campanha antes de virar material. Este relatório é análise de dados públicos — não é parecer jurídico.', + }, + ], + }, + { + id: 'metodo', + title: 'Método, fontes e limites', + blocks: [ + { + type: 'paragraphs', + items: [ + 'Fonte: resultados oficiais do TSE para 2022 (votação nominal de 1º turno por município e zona eleitoral). Solla: deputado federal nº 1313 (PT), 128.968 votos no estado e 27.264 em Salvador. Ceuci: deputada estadual nº 13192 (PT), 36.992 votos no estado e 19.776 em Salvador. Salvador é lida pelas suas 19 zonas eleitorais (ZE 1–19).', + 'Os valores foram conferidos contra duas referências: o artefato federal já commitado no Teqo (votação de Solla por zona, conferida zona a zona) e os totais oficiais de Ceuci (36.992 no estado; 19.776 em Salvador).', + 'Bairros: a lista de bairros por zona é a circunscrição oficial do TRE-BA (Resolução Administrativa nº 2/2017, Anexo I). O mapa de bairros usa a malha de bairros do IBGE (Censo 2022) e pinta cada bairro com os números da zona que contém seu centroide.', + 'Força relativa: o LQ (quociente local) compara a fatia da zona no voto do candidato com a fatia da zona nos votos válidos. LQ = 1 é exatamente a média do candidato; acima de 1, a zona é relativamente forte para ele.', + 'Sobreposição: o índice soma, zona a zona, o menor valor entre as duas fatias de voto (0 a 1). A correlação mede se as duas votações sobem e descem juntas entre as zonas — com 19 zonas, é indício, não probabilidade.', + ], + }, + { + type: 'note', + title: 'Limite duro: não existe voto por bairro', + text: 'Os dados disponíveis têm votação por município e zona eleitoral. Não há voto por seção, local de votação ou bairro — nenhum número deste relatório deve ser lido como votação de um bairro. Bairro aqui é a lista oficial de bairros que compõem a zona. O mapa de bairros é ilustrativo: mostra a malha e a classe da zona de cada bairro.', + }, + ], + }, + { + id: 'mapas', + title: 'Mapas', + blocks: [ + { + type: 'html', + html: renderZoneChoropleth({ + values: new Map(metrics.map((zone) => [zone.zoneNumber, zone.sollaVotes])), + title: 'Solla em Salvador — votos por ZE (2022)', + caption: + 'Classes por quantis da própria votação. Quanto mais escuro, mais votos na zona.', + legendTitle: 'Votos de Solla:', + }), + }, + { + type: 'html', + html: renderZoneChoropleth({ + values: new Map(metrics.map((zone) => [zone.zoneNumber, zone.ceuciVotes])), + title: 'Ceuci em Salvador — votos por ZE (2022)', + caption: + 'Classes por quantis da própria votação. A mancha dela é bem mais concentrada que a de Solla.', + legendTitle: 'Votos de Ceuci:', + }), + }, + { type: 'html', html: renderZoneClassMap() }, + { type: 'html', html: renderBairroClassMap() }, + ], + }, + { + id: 'graficos', + title: 'Votos por zona e dispersão', + blocks: [ + { type: 'html', html: renderBars() }, + { type: 'html', html: renderScatter() }, + ], + }, + { + id: 'tabelas', + title: 'Tabelas', + blocks: [ + { type: 'table', ...zoneTable() }, + { type: 'table', ...overlapTable() }, + { type: 'table', ...bairroTable() }, + ], + }, + { + id: 'ciencia', + title: 'Leitura da ciência política', + subtitle: 'Lente da persona pesquisadora — Prof. Helena Rocha (sintética)', + blocks: [ + { + type: 'paragraphs', + items: [ + `A conta do quociente. Em 2022, a Bahia teve ${nf(data.stateContext.stateValid)} votos válidos para deputado estadual e 63 cadeiras — quociente eleitoral de aproximadamente ${nf(data.stateContext.stateValid / 63)} votos. Ceuci fez 36.992 (cerca de um terço de um quociente): não se elegeu sozinha, mas construiu uma base de capital que, em 2026, vale como rede de apoio para um deputado federal que precisa de 150 mil votos.`, + `Dominância e concentração são coisas diferentes. Ceuci tem dominância concentrada: LQ ${dec(metricsByZone.get(13).ceuciLq)} na ZE 13, LQ ${dec(metricsByZone.get(1).ceuciLq)} na ZE 1, e ${pct(summary.ceuciTop3Share)} do voto dela nas três maiores zonas. Solla tem dominância dispersa: as três maiores zonas dele somam ${pct(summary.sollaTop3Share)}. Não é defeito de um nem virtude do outro — é a diferença entre uma candidata de uma cidade e um deputado de um estado. A estratégia mora no cruzamento: nos redutos compartilhados, somar; nas zonas de Ceuci sem Solla, transferir presença; nas de Solla sem Ceuci, não forçar.`, + `Eleito é o que dispersa a tempo. Solla já dispersa no estado; o risco em Salvador é concentrar esforço onde ele já vai bem. As zonas mais fortes de Ceuci (1, 2, 6, 10 e 13) são urbanas, de classe média e de serviços — não são o reduto histórico do mandato, e é exatamente aí que a rede dela muda o patamar.`, + `Campanha não persuade; organiza e orienta. A função de Ceuci não é converter adversários: é orientar quem já votou nela a votar 1313. São quase 37 mil pessoas no estado — 19.776 só na capital — e a literatura de campanha mostra que contato pessoal via rede de confiança é o que mobiliza. O custo é baixo porque a lista de apoiadores de 2022 existe.`, + `O mandato é a campanha. A memória que Ceuci carrega é de gestão e de saúde pública (Couto Maia, pandemia, medicamentos). O material e os eventos devem falar de entrega e continuidade do mandato de Solla — não de cargo, nem de futura candidatura dela.`, + ], + }, + { + type: 'note', + title: 'O que é dado e o que é leitura', + text: 'Dado: todos os números TSE deste relatório, conferidos na fonte. Leitura: as hipóteses de transferência de voto e os papéis sugeridos para Ceuci. Nenhuma transferência de voto é medida aqui — isso só se verifica na urna de 2026.', + }, + ], + }, + { + id: 'plano', + title: 'Plano do coordenador', + subtitle: + 'Lente da persona coordenadora — Nivaldo Cerqueira (sintética, ancorada em evidência real)', + blocks: [ + { + type: 'paragraphs', + items: [ + 'O critério é o de sempre: % do nosso voto decide a fila, e a agenda segue a fila. A tabela abaixo traduz a análise em quatro filas de trabalho. As zonas de prioridade 1 e 2 concentram ' + + `${pct(priorityShare)} ` + + 'da soma das duas votações na cidade — é onde o esforço conjunto tem retorno mais claro.', + `Cuidado com fogo amigo: Ceuci não é candidata e não deve virar palanque paralelo. O material é de apoio ao mandato; o alinhamento com o PT estadual evita disputa de narrativa.`, + ], + }, + { type: 'table', ...priorityTable() }, + { + type: 'bullets', + items: [ + { + label: 'Semana 1 — rede.', + text: 'Fechar a lista de convites da rede de Ceuci (saúde, profissionais, apoiadores de 2022) e cruzar com as zonas 1, 2, 6, 10 e 13. Sem lista, não há evento.', + }, + { + label: 'Semana 2 — redutos.', + text: `Dois eventos conjuntos: ZE 13 (${nf(metricsByZone.get(13).combinedVotes)} votos somados) e ZE 10 (${nf(metricsByZone.get(10).combinedVotes)}). Formato: agenda de saúde + prestação de contas do mandato.`, + }, + { + label: 'Semana 3 — capital.', + text: `ZE 1 (${nf(metricsByZone.get(1).combinedVotes)}) e ZE 6 (${nf(metricsByZone.get(6).combinedVotes)}). Material “Ceuci apoia Solla” na porta das unidades e no digital geolocalizado.`, + }, + { + label: 'Semana 4 — ponte.', + text: `ZE 2 (${nf(metricsByZone.get(2).combinedVotes)}): agenda dela com lideranças comunitárias; Solla entra no fim. Balanço e repriorização com os deltas do campo.`, + }, + ], + }, + { + type: 'note', + title: 'Como usar este documento na assessoria', + text: 'A tabela por bairro (seção Tabelas) é o anexo do dossiê pré-agenda: antes de qualquer visita, confira a zona do bairro e a classe. Nas zonas 1 e 2, a presença de Ceuci é o argumento; nas zonas 3 e 4, o mandato fala sozinho.', + }, + ], + }, + { + id: 'anexo', + title: 'Anexo — fontes e reprodução', + blocks: [ + { + type: 'bullets', + items: [ + { + label: 'Dados eleitorais.', + text: 'TSE — Resultados 2022, votação nominal de 1º turno por município e zona (arquivos de dados abertos). Extração de 14/09/2026, conferida contra o artefato interno do Teqo e contra os totais oficiais.', + }, + { + label: 'Bairros.', + text: 'TRE-BA, Resolução Administrativa nº 2/2017, Anexo I (circunscrição das zonas por bairro). Malha de bairros: IBGE, Censo 2022 (shapefile BA_bairros_CD2022).', + }, + { + label: 'Biografia de Ceuci.', + text: 'Registro público: candidatura 2022 (TSE), votação de 36.992/19.776, presidência da Bahiafarma (2023) e declaração de não candidatura em 2026 (imprensa).', + }, + { + label: 'Reprodução.', + text: 'NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-solla-ceuci-salvador-report.mjs — lê o JSON de entrada commitado e regenera o PDF e este Markdown. Sem banco de dados.', + }, + { + label: 'Aviso.', + text: 'Documento interno de campanha. Não contém dados pessoais; usa apenas dados públicos de votação. Os mapas de zona são derivados de malha de bairros (aproximação) — não são limites oficiais do TSE.', + }, + ], + }, + ], + }, + ], +} + +// --------------------------------------------------------------------------- +// Markdown companion +// --------------------------------------------------------------------------- + +const renderTableMd = ({ caption, head, rows }) => { + const plainRows = rows.map((row) => + row.map((cell) => + String(cell) + .replace(/]*>([^<]*)<\/span>/g, '$1') + .replaceAll('|', '\\|'), + ), + ) + const widths = head.map((cell, index) => + Math.max(cell.length, ...plainRows.map((row) => row[index].length)), + ) + const pad = (text, index) => text + ' '.repeat(widths[index] - text.length) + const lines = [ + `**${caption}**`, + '', + `| ${head.map(pad).join(' | ')} |`, + `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`, + ...plainRows.map((row) => `| ${row.map(pad).join(' | ')} |`), + ] + return lines.join('\n') +} + +const renderBlocksMd = (blocks) => + blocks + .map((block) => { + switch (block.type) { + case 'paragraphs': + return block.items.join('\n\n') + case 'bullets': + return block.items.map((item) => `- **${item.label}** ${item.text}`).join('\n') + case 'note': + return `> **${block.title}** ${block.text}` + case 'table': + return renderTableMd(block) + case 'html': + return '_(mapa/gráfico disponível no PDF)_' + default: + return '' + } + }) + .join('\n\n') + +const markdown = [ + `# ${report.title}`, + '', + `**${report.subtitle}**`, + `Gerado em ${report.date} — dados públicos TSE 2022. Documento interno de campanha.`, + '', + report.sections + .map( + (section) => + `## ${section.title}${section.subtitle ? `\n\n_${section.subtitle}_` : ''}\n\n${renderBlocksMd(section.blocks)}`, + ) + .join('\n\n'), + '', + `---`, + '', + `Proveniência dos dados: ${data.provenance.source} Extração: ${data.provenance.extractedAt}. ${data.provenance.extractedFrom}`, + '', + ...data.provenance.crossChecks.map((check) => `- ${check}`), + '', +].join('\n') + +// --------------------------------------------------------------------------- +// HTML +// --------------------------------------------------------------------------- + +const renderKpis = () => + report.kpis + .map( + (kpi) => + `
${htmlEscape(kpi.value)}${htmlEscape(kpi.label)}
`, + ) + .join('') + +const renderBlocksHtml = (blocks) => + blocks + .map((block) => { + switch (block.type) { + case 'paragraphs': + return block.items.map((item) => `

${htmlEscape(item)}

`).join('') + case 'bullets': + return `
    ${block.items + .map( + (item) => + `
  • ${htmlEscape(item.label)} ${htmlEscape(item.text)}
  • `, + ) + .join('')}
` + case 'note': + return `
${htmlEscape(block.title)}

${htmlEscape(block.text)}

` + case 'table': + return renderTableHtml(block) + case 'html': + return block.html + default: + return '' + } + }) + .join('') + +const renderTableHtml = ({ caption, head, rows, align }) => { + const alignClass = (index) => (align?.[index] === 'right' ? ' class="num"' : '') + return ` + + + ${head.map((cell, index) => `${htmlEscape(cell)}`).join('')} + ${rows + .map( + (row) => + `${row + .map( + (cell, index) => + `${cell.startsWith('`, + ) + .join('')}`, + ) + .join('')} +
${htmlEscape(caption)}
` +} + +const renderSectionHtml = (section) => ` +
+

${htmlEscape(section.title)}

+ ${section.subtitle ? `

${htmlEscape(section.subtitle)}

` : ''} + ${renderBlocksHtml(section.blocks)} +
` + +const css = ` + * { box-sizing: border-box; } + body { + margin: 0; + font-family: 'Fira Sans', 'DejaVu Sans', sans-serif; + color: #18181b; + font-size: 9.6pt; + line-height: 1.5; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + h1 { font-size: 26pt; line-height: 1.08; margin: 0 0 4mm; letter-spacing: -0.01em; } + h2 { font-size: 15pt; margin: 0 0 3mm; color: #c51414; letter-spacing: -0.01em; } + h3 { font-size: 11pt; } + p { margin: 0 0 3mm; } + .cover { height: 256mm; display: flex; flex-direction: column; justify-content: space-between; break-after: page; } + .cover-top { padding-top: 6mm; } + .brand { color: #c51414; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; font-size: 8.5pt; } + .cover .subtitle { font-size: 13pt; color: #3f3f46; max-width: 150mm; } + .cover-meta { color: #71717a; font-size: 9pt; } + .cover-rule { height: 2.4mm; background: #c51414; width: 34mm; margin: 5mm 0; } + .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3mm; margin: 4mm 0 0; } + .kpi { border: .8px solid #e4e4e7; border-radius: 2mm; padding: 3mm; background: #fafafa; } + .kpi-value { display: block; font-size: 15pt; font-weight: 700; color: #c51414; } + .kpi-label { display: block; font-size: 7.8pt; color: #52525b; } + .section { padding: 0; } + .page-break { break-before: page; } + .section-subtitle { color: #71717a; font-style: italic; margin-top: -1mm; } + .bullets { margin: 0 0 3mm; padding-left: 4.5mm; } + .bullets li { margin-bottom: 2mm; } + .note { border-left: 2.4mm solid #c51414; background: #fafafa; padding: 3mm 4mm; border-radius: 0 2mm 2mm 0; margin: 3mm 0; } + .note strong { color: #c51414; } + .note p { margin: 1mm 0 0; } + table { width: 100%; border-collapse: collapse; font-size: 8.2pt; margin: 0 0 6mm; } + caption { caption-side: top; text-align: left; font-weight: 700; font-size: 8.8pt; padding-bottom: 2mm; color: #18181b; } + th { text-align: left; background: #f4f4f5; border-bottom: 1px solid #d4d4d8; padding: 2.1mm 1.6mm; font-size: 7.3pt; text-transform: uppercase; letter-spacing: .03em; color: #52525b; } + td { border-bottom: .6px solid #e4e4e7; padding: 2.1mm 1.6mm; vertical-align: top; } + td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } + .badge { display: inline-block; color: #ffffff; font-size: 7pt; font-weight: 600; padding: .6mm 1.6mm; border-radius: 6mm; white-space: nowrap; } + figure.map { margin: 0 0 6mm; border: .8px solid #e4e4e7; border-radius: 2mm; padding: 3.5mm; break-inside: avoid; } + figure.map + figure.map { margin-top: 10mm; } + figure.map figcaption { display: flex; flex-direction: column; gap: .8mm; margin-bottom: 2.5mm; } + figure.map figcaption strong { font-size: 9.6pt; } + figure.map figcaption span { font-size: 7.8pt; color: #52525b; } + figure.map svg { width: 100%; height: auto; display: block; } + .legend { display: flex; flex-wrap: wrap; gap: 3mm; align-items: center; margin-top: 2.5mm; font-size: 7.6pt; color: #3f3f46; } + .legend-title { font-weight: 700; color: #52525b; } + .legend-item { display: inline-flex; align-items: center; gap: 1.2mm; } + .legend-swatch { width: 3.4mm; height: 3.4mm; border-radius: .8mm; border: .6px solid #d4d4d8; display: inline-block; } + .zone-label { font-family: 'Fira Sans', sans-serif; font-size: 9px; font-weight: 700; fill: #18181b; text-anchor: middle; paint-order: stroke; stroke: #ffffff; stroke-width: 2.6px; } + .chart-label { font-family: 'Fira Sans', sans-serif; font-size: 10px; fill: #3f3f46; } + .chart-value { font-family: 'Fira Sans', sans-serif; font-size: 9px; fill: #52525b; } + .axis-label { font-family: 'Fira Sans', sans-serif; font-size: 10px; fill: #52525b; } + .axis-title { font-family: 'Fira Sans', sans-serif; font-size: 11px; fill: #3f3f46; font-weight: 600; } + .point-label { font-family: 'Fira Sans', sans-serif; font-size: 10px; fill: #3f3f46; font-weight: 600; paint-order: stroke; stroke: #ffffff; stroke-width: 2.4px; } + .grid { stroke: #e4e4e7; stroke-width: 1; } + .median { stroke: #a1a1aa; stroke-width: 1.2; stroke-dasharray: 5 4; } + .page-break + section { break-before: page; } +` + +const html = ` + +${htmlEscape(report.title)} + +
+
+
Campanha Jorge Solla 1313 · Análise territorial
+

${htmlEscape(report.title)}

+
+

${htmlEscape(report.subtitle)}

+
+
+
${renderKpis()}
+

${htmlEscape(report.date)} · Fonte: TSE 2022 (dados públicos) · Uso interno da assessoria
+ Solla: deputado federal nº 1313 · Ceuci: deputada estadual nº 13192 · Salvador, 19 zonas eleitorais

+
+
+ ${report.sections.map(renderSectionHtml).join('')} + +` + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +await mkdir(join(ROOT, 'data/solla-ceuci-salvador'), { recursive: true }) +const htmlPath = join(ROOT, 'data/solla-ceuci-salvador/relatorio.html') +await writeFile(htmlPath, html) + +await mkdir(join(ROOT, dirname(REPORT_BASE)), { recursive: true }) +await writeFile(join(ROOT, `${REPORT_BASE}.md`), markdown) +console.log(`[${LABEL}] wrote ${REPORT_BASE}.md`) + +const browser = await chromium.launch() +try { + const page = await browser.newPage() + await page.setContent(html, { waitUntil: 'load' }) + await page.emulateMedia({ media: 'print' }) + await page.pdf({ + path: join(ROOT, `${REPORT_BASE}.pdf`), + format: 'A4', + printBackground: true, + displayHeaderFooter: true, + headerTemplate: '
', + footerTemplate: `
+ Solla × Ceuci — Salvador 2022 · dados públicos TSE · documento interno de campanha + pág. / +
`, + margin: { top: '12mm', bottom: '14mm', left: '12mm', right: '12mm' }, + }) +} finally { + await browser.close() +} + +console.log(`[${LABEL}] wrote ${REPORT_BASE}.pdf`) +console.log( + `[${LABEL}] overlap=${pct(summary.overlapCoefficient)} pearson=${dec(summary.pearson)} ` + + `spearman=${dec(summary.spearman)} zones=${metrics.length} bairros=${bairros.length} ` + + `(centroid fallbacks: ${nearestFallbacks}, mesh sha256=${bairroHash.slice(0, 16)})`, +) +console.log( + `[${LABEL}] shared=${list(zoneList(summary.shared))} | ceuci-only=${list(zoneList(summary.ceuciOnly))} | ` + + `solla-only=${list(zoneList(summary.sollaOnly))} | open=${list(zoneList(summary.open))}`, +) diff --git a/scripts/lib/sollaCeuciSalvadorMetrics.mjs b/scripts/lib/sollaCeuciSalvadorMetrics.mjs new file mode 100644 index 00000000..842b1406 --- /dev/null +++ b/scripts/lib/sollaCeuciSalvadorMetrics.mjs @@ -0,0 +1,191 @@ +/** + * Pure metrics for the Solla × Ceuci Salvador 2022 report (C157). + * + * The report compares two candidates of different offices (federal vs. state + * deputy) inside the same geography (Salvador's 19 electoral zones). No voter + * is observable twice — the overlap here is between two *geographies of vote*, + * never between individual ballots — so every function works on the zone + * vectors and the shared denominator is the zone's valid votes for each + * office. + * + * Kept out of the generator script so the arithmetic is unit-testable; the + * generator owns only I/O (geometry, HTML, PDF). + */ + +/** Share of a total, 0 when the denominator is missing (never NaN in the report). */ +export const shareOf = (value, total) => (total > 0 ? value / total : 0) + +/** + * Local quotient (LQ): the candidate's share of his own geography vote divided + * by the geography's share of the valid votes. 1 = exactly his average; the + * report's "above/below own average" line. + */ +export const localQuotient = (value, ownTotal, valid, validTotal) => { + const ownShare = shareOf(value, ownTotal) + const validShare = shareOf(valid, validTotal) + return validShare > 0 ? ownShare / validShare : 0 +} + +/** Pearson correlation; returns 0 for degenerate vectors (n < 2 or no variance). */ +export const pearson = (xs, ys) => { + const n = Math.min(xs.length, ys.length) + if (n < 2) return 0 + const meanX = xs.reduce((sum, value) => sum + value, 0) / n + const meanY = ys.reduce((sum, value) => sum + value, 0) / n + let covariance = 0 + let varianceX = 0 + let varianceY = 0 + for (let index = 0; index < n; index += 1) { + const dx = xs[index] - meanX + const dy = ys[index] - meanY + covariance += dx * dy + varianceX += dx * dx + varianceY += dy * dy + } + if (varianceX === 0 || varianceY === 0) return 0 + return covariance / Math.sqrt(varianceX * varianceY) +} + +/** Competition ranks with ties averaged — the honest rank for Spearman. */ +export const averageRanks = (values) => { + const order = values + .map((value, index) => [value, index]) + .sort((left, right) => left[0] - right[0]) + const ranks = new Array(values.length).fill(0) + let start = 0 + while (start < order.length) { + let end = start + while (end + 1 < order.length && order[end + 1][0] === order[start][0]) end += 1 + const rank = (start + end) / 2 + 1 + for (let index = start; index <= end; index += 1) ranks[order[index][1]] = rank + start = end + 1 + } + return ranks +} + +/** Spearman rank correlation (Pearson over average ranks). */ +export const spearman = (xs, ys) => pearson(averageRanks(xs), averageRanks(ys)) + +/** + * Overlap coefficient between two share vectors: Σ min(aᵢ, bᵢ). 1 = identical + * distributions, 0 = disjoint. Scale-free (both vectors sum to ~1), which is + * what makes a federal candidate comparable with a state one. + */ +export const overlapCoefficient = (a, b) => { + const n = Math.min(a.length, b.length) + let total = 0 + for (let index = 0; index < n; index += 1) total += Math.min(a[index], b[index]) + return total +} + +/** Cosine similarity between two vectors (scale-invariant). */ +export const cosineSimilarity = (a, b) => { + const n = Math.min(a.length, b.length) + let dot = 0 + let normA = 0 + let normB = 0 + for (let index = 0; index < n; index += 1) { + dot += a[index] * b[index] + normA += a[index] * a[index] + normB += b[index] * b[index] + } + if (normA === 0 || normB === 0) return 0 + return dot / Math.sqrt(normA * normB) +} + +/** Herfindahl–Hirschman index over shares (concentration; 1/19 ≈ uniform). */ +export const hhi = (shares) => shares.reduce((sum, value) => sum + value * value, 0) + +/** Sum of the `n` largest shares — the "top-3 zones" concentration reading. */ +export const topShare = (shares, n) => + [...shares] + .sort((left, right) => right - left) + .slice(0, n) + .reduce((sum, value) => sum + value, 0) + +export const ZONE_CLASSES = { + shared: { + key: 'shared', + label: 'Reduto compartilhado', + short: 'Ambos acima da média', + color: '#c51414', + }, + solla: { key: 'solla', label: 'Base Solla', short: 'Só Solla acima da média', color: '#1e40af' }, + ceuci: { key: 'ceuci', label: 'Ponte Ceuci', short: 'Só Ceuci acima da média', color: '#0d9488' }, + open: { key: 'open', label: 'Disputa ampla', short: 'Ambos abaixo da média', color: '#d4d4d8' }, +} + +/** Crosses the two LQs into the report's four operational classes. */ +export const classifyZone = (sollaLq, ceuciLq) => { + if (sollaLq >= 1 && ceuciLq >= 1) return ZONE_CLASSES.shared + if (sollaLq >= 1) return ZONE_CLASSES.solla + if (ceuciLq >= 1) return ZONE_CLASSES.ceuci + return ZONE_CLASSES.open +} + +/** Per-zone table rows, sorted by zone number (the printed order). */ +export const buildZoneMetrics = ({ zones, salvadorTotals }) => + zones.map((zone) => { + const sollaShareOwn = shareOf(zone.sollaVotes, salvadorTotals.sollaVotes) + const ceuciShareOwn = shareOf(zone.ceuciVotes, salvadorTotals.ceuciVotes) + const sollaLq = localQuotient( + zone.sollaVotes, + salvadorTotals.sollaVotes, + zone.federalValid, + salvadorTotals.federalValid, + ) + const ceuciLq = localQuotient( + zone.ceuciVotes, + salvadorTotals.ceuciVotes, + zone.stateValid, + salvadorTotals.stateValid, + ) + return { + ...zone, + sollaShareOwn, + ceuciShareOwn, + sollaShareValid: shareOf(zone.sollaVotes, zone.federalValid), + ceuciShareValid: shareOf(zone.ceuciVotes, zone.stateValid), + sollaLq, + ceuciLq, + overlapMin: Math.min(sollaShareOwn, ceuciShareOwn), + combinedVotes: zone.sollaVotes + zone.ceuciVotes, + sollaLeads: zone.sollaVotes > zone.ceuciVotes, + zoneClass: classifyZone(sollaLq, ceuciLq), + } + }) + +/** Summary readings printed in the executive summary and the method annex. */ +export const buildReportSummary = ({ metrics, candidates, salvadorTotals }) => { + const sollaShares = metrics.map((zone) => zone.sollaShareOwn) + const ceuciShares = metrics.map((zone) => zone.ceuciShareOwn) + const sollaVotes = metrics.map((zone) => zone.sollaVotes) + const ceuciVotes = metrics.map((zone) => zone.ceuciVotes) + + const byClass = (key) => metrics.filter((zone) => zone.zoneClass.key === key) + const strongestCeuci = [...metrics].sort((left, right) => right.ceuciVotes - left.ceuciVotes) + const strongestCombined = [...metrics].sort( + (left, right) => right.combinedVotes - left.combinedVotes, + ) + const ceuciAhead = metrics.filter((zone) => zone.ceuciVotes > zone.sollaVotes) + + return { + overlapCoefficient: overlapCoefficient(sollaShares, ceuciShares), + cosine: cosineSimilarity(sollaShares, ceuciShares), + pearson: pearson(sollaVotes, ceuciVotes), + spearman: spearman(sollaVotes, ceuciVotes), + sollaTop3Share: topShare(sollaShares, 3), + ceuciTop3Share: topShare(ceuciShares, 3), + sollaHhi: hhi(sollaShares), + ceuciHhi: hhi(ceuciShares), + salvadorShareOfSollaState: shareOf(salvadorTotals.sollaVotes, candidates.solla.stateVotes), + salvadorShareOfCeuciState: shareOf(salvadorTotals.ceuciVotes, candidates.ceuci.stateVotes), + shared: byClass('shared'), + sollaOnly: byClass('solla'), + ceuciOnly: byClass('ceuci'), + open: byClass('open'), + strongestCeuci, + strongestCombined, + ceuciAhead, + } +} diff --git a/tests/unit/sollaCeuciSalvadorMetrics.unit.spec.ts b/tests/unit/sollaCeuciSalvadorMetrics.unit.spec.ts new file mode 100644 index 00000000..86ea08c9 --- /dev/null +++ b/tests/unit/sollaCeuciSalvadorMetrics.unit.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' + +import { + averageRanks, + buildReportSummary, + buildZoneMetrics, + classifyZone, + cosineSimilarity, + hhi, + localQuotient, + overlapCoefficient, + pearson, + shareOf, + spearman, + topShare, + ZONE_CLASSES, +} from '../../scripts/lib/sollaCeuciSalvadorMetrics.mjs' + +describe('sollaCeuciSalvadorMetrics', () => { + it('keeps shareOf safe on a missing denominator', () => { + expect(shareOf(10, 40)).toBe(0.25) + expect(shareOf(10, 0)).toBe(0) + }) + + it('computes the local quotient against the valid-vote share', () => { + // 10% of the candidate's vote in a zone holding 5% of the valid votes → LQ 2. + expect(localQuotient(10, 100, 50, 1000)).toBeCloseTo(2) + // Zone with no valid votes never divides by zero. + expect(localQuotient(10, 100, 0, 1000)).toBe(0) + }) + + it('computes Pearson on a perfectly correlated pair', () => { + expect(pearson([1, 2, 3], [2, 4, 6])).toBeCloseTo(1) + expect(pearson([1, 2, 3], [6, 4, 2])).toBeCloseTo(-1) + expect(pearson([1], [2])).toBe(0) + expect(pearson([1, 1, 1], [1, 2, 3])).toBe(0) + }) + + it('averages tied ranks and correlates ranks for Spearman', () => { + expect(averageRanks([10, 20, 20, 30])).toEqual([1, 2.5, 2.5, 4]) + expect(spearman([1, 2, 3], [10, 20, 30])).toBeCloseTo(1) + }) + + it('measures distribution overlap and cosine similarity', () => { + expect(overlapCoefficient([0.5, 0.5], [0.5, 0.5])).toBeCloseTo(1) + expect(overlapCoefficient([1, 0], [0, 1])).toBe(0) + expect(cosineSimilarity([1, 0], [1, 0])).toBeCloseTo(1) + expect(cosineSimilarity([0, 0], [1, 0])).toBe(0) + }) + + it('measures concentration with HHI and top shares', () => { + expect(hhi([0.5, 0.5])).toBeCloseTo(0.5) + expect(topShare([0.1, 0.4, 0.3, 0.2], 2)).toBeCloseTo(0.7) + }) + + it('classifies the four LQ quadrants', () => { + expect(classifyZone(1.2, 1.1)).toBe(ZONE_CLASSES.shared) + expect(classifyZone(1.2, 0.9)).toBe(ZONE_CLASSES.solla) + expect(classifyZone(0.9, 1.2)).toBe(ZONE_CLASSES.ceuci) + expect(classifyZone(0.9, 0.9)).toBe(ZONE_CLASSES.open) + }) + + it('builds per-zone metrics and the summary from a two-zone fixture', () => { + const zones = [ + { + zoneNumber: 1, + sollaVotes: 80, + ceuciVotes: 20, + federalValid: 800, + stateValid: 800, + sollaFederalRank: 1, + federalCandidates: 10, + ceuciStateRank: 9, + stateCandidates: 10, + }, + { + zoneNumber: 2, + sollaVotes: 20, + ceuciVotes: 80, + federalValid: 500, + stateValid: 200, + sollaFederalRank: 9, + federalCandidates: 10, + ceuciStateRank: 1, + stateCandidates: 10, + }, + ] + const salvadorTotals = { + sollaVotes: 100, + ceuciVotes: 100, + federalValid: 1000, + stateValid: 1000, + } + const metrics = buildZoneMetrics({ zones, salvadorTotals }) + // Zone 1: 80% of Solla's vote over 80% of the valid votes → LQ 1; Ceuci 0.2/0.8 → 0.25. + expect(metrics[0].sollaLq).toBeCloseTo(1) + expect(metrics[0].ceuciLq).toBeCloseTo(0.25) + expect(metrics[0].zoneClass).toBe(ZONE_CLASSES.solla) + expect(metrics[1].zoneClass).toBe(ZONE_CLASSES.ceuci) + + const summary = buildReportSummary({ + metrics, + candidates: { solla: { stateVotes: 200 }, ceuci: { stateVotes: 400 } }, + salvadorTotals, + }) + expect(summary.overlapCoefficient).toBeCloseTo(0.4) + expect(summary.pearson).toBeCloseTo(-1) + expect(summary.salvadorShareOfSollaState).toBeCloseTo(0.5) + expect(summary.salvadorShareOfCeuciState).toBeCloseTo(0.25) + expect(summary.ceuciAhead.map((zone: { zoneNumber: number }) => zone.zoneNumber)).toEqual([2]) + }) +})