feat(C161): relatório Solla × Ceuci com votos por bairro (seções e locais de votação)

A camada de bairro desce à seção eleitoral: votos por seção (votacao_secao_2022_BA),
cadastro seção → local → bairro (eleitorado_local_votacao_2022), aferição por seção
(detalhe_votacao_secao_2022_BA) e candidaturas válidas (votacao_candidato_munzona_2022);
133 coordenadas ausentes preenchidas pelo cadastro atual pelo número do local.

- JSON commitado: 138 bairros, 474 locais, 5.023 seções; Solla 27.264 e Ceuci 19.776
  conferidos zona a zona contra o recorte C157.
- Extrator scripts/build-solla-ceuci-salvador-bairros.mjs (--download cacheia em
  data/tse-2022/, gitignored; sem banco).
- Gerador revisado: mapas por bairro com dados reais, quadrantes de LQ, tabela de
  138 bairros e filas de ação por bairro.
- Personas refeitas com a base granular: sobreposição 62,9% nos bairros (vs 67,8%
  nas zonas); Ceuci supera Solla em 25 bairros com 61,6% do voto dela; redutos
  compartilhados Pituba/Brotas/Itaigara/Ondina/Graça/Imbuí/STIEP/Pituaçu; pontes
  Rio Vermelho/Cabula/Federação/Jardim Armação.
- Métricas puras de bairro + 9 unit tests; README e changelog atualizados.

Closes #992
This commit is contained in:
Francisco Solla 2026-09-14 04:19:51 -03:00
parent 9928fb183e
commit fa79e4da75
10 changed files with 13790 additions and 308 deletions

1
.gitignore vendored
View file

@ -106,3 +106,4 @@ next-env.d.ts
# Changelog aggregate is a regenerable artifact (OPS85) — entries live in docs/changelog/
/docs/CHANGELOG-AGENTS.md
/data/tse-2022/

View file

@ -0,0 +1 @@
**C161 — Relatório Solla × Ceuci: votos por bairro (seções e locais de votação) (2026-09-14):** revisão granular do C157 (#979) — a primeira versão usava a base município×zona do Teqo e pintava a malha de bairros com a zona do centroide. Agora o relatório desce à seção eleitoral: `votacao_secao_2022_BA` (votos por candidato), `detalhe_votacao_secao_2022_BA` (aptos/comparecimento), `eleitorado_local_votacao_2022` (seção → local → bairro), `votacao_candidato_munzona_2022` (candidaturas válidas, mesmo denominador do baseline por zona) e `eleitorado_local_votacao_ATUAL` (preenche 133 coordenadas ausentes pelo número do local); o CDN do TSE recusa o user-agent headless, então o download usa o Chromium do Playwright já instalado. Entrega: camada de bairro commitada `docs/research/solla-ceuci-salvador-2022-bairros-dados.json` (138 bairros com votação de 474 locais e 5.023 seções; Solla 27.264 e Ceuci 19.776 conferidos zona a zona; hashes dos arquivos), extrator `scripts/build-solla-ceuci-salvador-bairros.mjs` (`--download` cacheia em `data/tse-2022/`, gitignored; sem banco), gerador C157 revisado (mapas por bairro com dados reais, quadrantes de LQ, tabela de 138 bairros com nome do cadastro TSE quando difere do polígono IBGE, filas de ação por bairro) e personas refeitas com os dados completos (Helena Rocha na ciência política, Nivaldo Cerqueira no plano do coordenador). Números-chave: sobreposição cai de 67,8% (19 ZE) para 62,9% (138 bairros); Ceuci supera Solla em 25 bairros que reúnem 61,6% do voto dela; os 10 maiores bairros dela somam 45,3% do voto dela contra 25,4% dos dele; redutos compartilhados Pituba/Brotas/Itaigara/Ondina/Graça/Imbuí/STIEP/Pituaçu; pontes Rio Vermelho/Cabula/Federação/Jardim Armação; bases de Solla São Caetano/Pernambués/Cosme de Farias/Paripe/Nova Sussuarana/Mussurunga. PDF de 20 páginas + companion Markdown regenerados; métricas puras de bairro em `scripts/lib/sollaCeuciSalvadorMetrics.mjs` com 9 unit tests verdes. Issue #992.

View file

@ -58,13 +58,15 @@ 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)
## Relatório — sobreposição Solla × Ceuci em Salvador (C157 + C161, 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.
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, 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. A revisão C161 desceu à seção eleitoral e ao cadastro de locais de votação: cada bairro agora tem votos reais dos locais de votação que contém (138 bairros com votação, 474 locais, 5.023 seções), e não mais a zona pintada no centroide; a análise das personas foi refeita sobre a base granular.
| 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 |
| Artefato | O quê | Status |
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------- |
| [`analise-sobreposicao-solla-ceuci-salvador-2022.pdf`](analise-sobreposicao-solla-ceuci-salvador-2022.pdf) | Relatório final em PDF (20 páginas) — mapas por bairro, tabelas, leitura da ciência política e plano | revisado 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) | revisado 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 (camada congelada C157) | congelado |
| [`solla-ceuci-salvador-2022-bairros-dados.json`](solla-ceuci-salvador-2022-bairros-dados.json) | Camada de bairro (C161): 138 bairros, 474 locais e 5.023 seções com votos, coordenadas e hashes dos arquivos | entregue 2026-09-14 |
| [`../../scripts/build-solla-ceuci-salvador-bairros.mjs`](../../scripts/build-solla-ceuci-salvador-bairros.mjs) | Extrator da camada de bairro (TSE → seção → local → bairro; `--download` cacheia os arquivos) | entregue 2026-09-14 |
| [`../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 |

View file

@ -5,13 +5,13 @@ Gerado em 14 de setembro de 2026 — dados públicos TSE 2022. Documento interno
## 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.
- **Descer ao bairro separa o que a zona juntava.** Nas 19 zonas, as duas votações se sobrepõem em 67,8%; nos 138 bairros com local de votação, a sobreposição cai para 62,9%. A leitura de zona escondia contraste dentro dela — a ZE 12 mistura Stella Maris (Solla 335 · Ceuci 390) com São Cristóvão (Solla 321 · Ceuci 88); a ZE 4 tem Paripe (Solla 597 · Ceuci 135) ao lado de Ilha Bom Jesus dos Passos (Solla 16 · Ceuci 24).
- **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: os 5 maiores bairros dela somam 32,8% do voto dela, contra 15,6% de Solla (concentração 2,64× maior pelo HHI).
- **Onde ela é maior que ele.** Ceuci supera Solla em 25 dos 138 bairros, que juntos reúnem 61,6% do voto dela na cidade — Pituba (Solla 1.042 · Ceuci 2.583) · Brotas (Solla 963 · Ceuci 1.361) · Itaigara (Solla 360 · Ceuci 923) · Graça (Solla 339 · Ceuci 843) · Ondina (Solla 494 · Ceuci 782), entre outros.
- **Onde os dois somam mais.** As maiores somas Solla + Ceuci estão em Pituba (Solla 1.042 · Ceuci 2.583) · Brotas (Solla 963 · Ceuci 1.361) · Itaigara (Solla 360 · Ceuci 923) · Ondina (Solla 494 · Ceuci 782) · Graça (Solla 339 · Ceuci 843) · São Caetano (Solla 890 · Ceuci 190) — os territórios onde uma agenda conjunta tem mais público potencial.
- **Recomendação central.** Três movimentos por bairro: (1) defender os redutos compartilhados (Pituba · Brotas · Itaigara · Ondina · Graça · Imbuí · STIEP · Pituaçu, …) com agenda e material conjuntos; (2) usar Ceuci como cabeça de ponte onde ela é acima da média e Solla abaixo (Rio Vermelho · Cabula · Federação · Jardim Armação); (3) ancorar o apoio declarado nas bases de Solla (São Caetano · Pernambués · Cosme de Farias · Paripe · Ribeira · São Rafael, …) — 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.
> **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. O bairro é o do local de votação (cadastro do TSE), posicionado na malha do IBGE; não é o bairro de residência do eleitor.
## Quem é Ceuci Nunes — e o que os números de 2022 dizem
@ -19,23 +19,27 @@ Ceuci de Lima Xavier Nunes é médica infectologista e ficou 15 anos à frente d
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).
Na leitura por bairro, o centro comum tem endereço: Pituba · Brotas · Itaigara · Ondina · Graça · Imbuí · STIEP · Pituaçu — os maiores entre os bairros em que os dois estão acima da própria média. É um eixo centro-sul de serviços e educação: UNIVERSIDADE CATÓLICA DO SALVADOR (473 votos dela), em Pituaçu, é o maior local de votação dela na cidade, e as faculdades da UFBA, em Canela e Graça, aparecem entre os maiores.
Fora desse eixo, a votação dela é bem mais concentrada que a dele: os 10 maiores bairros dela somam 45,3% do voto dela, contra 25,4% dos 10 maiores dele. Onde ela é maior que ele — 25 bairros, que reúnem 61,6% do voto dela — estão Pituba (Solla 1.042 · Ceuci 2.583) · Brotas (Solla 963 · Ceuci 1.361) · Itaigara (Solla 360 · Ceuci 923) · Graça (Solla 339 · Ceuci 843) · Ondina (Solla 494 · Ceuci 782). Onde Solla é forte e ela quase não aparece estão as bases do mandato: São Caetano · Pernambués · Cosme de Farias · Paripe · Ribeira · São Rafael · Bonfim.
Há ainda a faixa em que ela está acima da média e ele abaixo — Rio Vermelho · Cabula · Federação · Jardim Armação. É onde a rede de Ceuci alcança eleitor que o mandato ainda não mobiliza, e onde a agenda conjunta rende mais do que repetir evento em bairro já consolidado.
> **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).
Fonte: resultados oficiais do TSE para 2022 (1º turno). 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 em dois níveis: as 19 zonas eleitorais (agregação oficial) e os bairros dos locais de votação.
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).
A camada de bairro desce à menor unidade publicada pelo TSE: a seção eleitoral. Cada seção tem votos por candidato (votacao_secao_2022) e pertence a um local de votação; o cadastro eleitoral (eleitorado_local_votacao_2022) associa esse local a um bairro e a uma coordenada. Somando as seções de cada local e agrupando pelos bairros, chega-se ao voto por bairro — cada local posicionado no polígono do IBGE Censo 2022. O denominador do LQ é a soma dos votos nominais das candidaturas válidas da seção, a mesma base do recorte por zona.
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 do território no voto do candidato com a fatia do território nos votos válidos. LQ = 1 é exatamente a média do candidato; acima de 1, o território é relativamente forte para ele. Sobreposição: o índice soma, território a território, o menor valor entre as duas fatias de voto (0 a 1).
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.
Conferências: a soma das seções reproduz zona a zona o recorte por zona (Solla 27.264; Ceuci 19.776); o denominador fecha com diferença residual ≤ 0,1% do total por zona; 5.023 seções, 474 locais e 138 bairros com votação foram conferidos. Os hashes dos arquivos-fonte estão no JSON da camada de bairro.
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.
Esta edição substitui a primeira versão do relatório, que pintava a malha de bairros com os números da zona do centroide: agora cada polígono tem os votos dos locais de votação que contém.
> **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.
> **O que o dado não é** O voto é do local de votação, não da casa do eleitor: o cadastro não liga o voto ao domicílio e um mesmo local pode atender mais de um bairro. O bairro do TSE (150 nomes no cadastro eleitoral) e o polígono do IBGE (170 na malha; 138 com local de votação) têm recortes e nomes próprios — o mapa usa o polígono e mostra o nome do cadastro quando difere. Nenhum número aqui mede transferência de voto entre candidatos.
## Mapas
@ -47,7 +51,7 @@ _(mapa/gráfico disponível no PDF)_
_(mapa/gráfico disponível no PDF)_
## Votos por zona e dispersão
## Bairros em volume e em força relativa
_(mapa/gráfico disponível no PDF)_
@ -103,29 +107,148 @@ _(mapa/gráfico disponível no PDF)_
| 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**
**Votação 2022 por bairro (local de votação) — Solla e Ceuci, LQ e classe**
| 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 |
| Bairro | ZE | Cadastro TSE | Solla | Ceuci | Soma | LQ Solla | LQ Ceuci | Classe |
| --------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------- | ----- | ----- | ----- | -------- | -------- | -------------------- |
| Pituba | 13 | ITAIGARA · NORDESTE DE AMARALINA | 1.042 | 2.583 | 3.625 | 1,12 | 3,79 | Reduto compartilhado |
| Brotas | 6 | CAMPINAS DE BROTAS | 963 | 1.361 | 2.324 | 1,03 | 1,98 | Reduto compartilhado |
| Itaigara | 13 | — | 360 | 923 | 1.283 | 1,17 | 4,09 | Reduto compartilhado |
| Ondina | 2 | — | 494 | 782 | 1.276 | 1,01 | 2,23 | Reduto compartilhado |
| Graça | 1 | CANELA | 339 | 843 | 1.182 | 1,15 | 3,92 | Reduto compartilhado |
| São Caetano | 15 · 3 | FAZENDA GRANDE DO RETIRO · BOA VISTA DE SÃO CAETANO | 890 | 190 | 1.080 | 1,29 | 0,40 | Base Solla |
| Itapuã | 12 | NOVA BRASÍLIA DE ITAPUÃ | 644 | 430 | 1.074 | 0,85 | 0,80 | Disputa ampla |
| Pernambués | 16 | — | 719 | 262 | 981 | 1,01 | 0,51 | Base Solla |
| Imbuí | 10 | — | 433 | 540 | 973 | 1,22 | 2,06 | Reduto compartilhado |
| Cabula | 5 | — | 461 | 435 | 896 | 0,84 | 1,09 | Ponte Ceuci |
| STIEP | 10 | — | 326 | 509 | 835 | 1,25 | 2,67 | Reduto compartilhado |
| Pituaçu | 10 | — | 294 | 486 | 780 | 1,20 | 2,71 | Reduto compartilhado |
| Rio Vermelho | 2 | — | 274 | 483 | 757 | 0,91 | 2,23 | Ponte Ceuci |
| Canela | 1 | — | 297 | 446 | 743 | 1,50 | 3,08 | Reduto compartilhado |
| Cosme de Farias | 7 · 6 | BONOCÔ | 647 | 91 | 738 | 2,15 | 0,42 | Base Solla |
| Paripe | 4 | TUBARÃO · FAZENDA COUTOS | 597 | 135 | 732 | 1,01 | 0,32 | Base Solla |
| Stella Maris | 12 | — | 335 | 390 | 725 | 1,37 | 2,20 | Reduto compartilhado |
| Ribeira | 9 | ITAPAGIPE | 480 | 192 | 672 | 1,14 | 0,61 | Base Solla |
| Bonfim | 9 | RIBEIRA | 418 | 243 | 661 | 1,03 | 0,82 | Base Solla |
| Barra | 1 | — | 197 | 444 | 641 | 1,01 | 3,13 | Reduto compartilhado |
| Vila Laura | 7 | — | 267 | 367 | 634 | 1,24 | 2,34 | Reduto compartilhado |
| Costa Azul | 10 | — | 246 | 384 | 630 | 1,28 | 2,73 | Reduto compartilhado |
| Garcia | 1 | — | 244 | 365 | 609 | 1,02 | 2,05 | Reduto compartilhado |
| Federação | 2 | PARQUE SÃO BRÁS | 306 | 301 | 607 | 0,94 | 1,28 | Ponte Ceuci |
| Periperi | 4 · 17 | MIRANTES DE PERIPERI | 454 | 136 | 590 | 0,82 | 0,33 | Disputa ampla |
| Boca do Rio | 10 | IMBUÍ | 363 | 221 | 584 | 0,98 | 0,85 | Disputa ampla |
| São Rafael | 14 | SÃO MARCOS | 447 | 119 | 566 | 1,72 | 0,64 | Base Solla |
| Nazaré | 7 | — | 292 | 222 | 514 | 1,28 | 1,32 | Reduto compartilhado |
| Fazenda Grande do Retiro | 3 | SAN MARTIN | 423 | 76 | 499 | 0,85 | 0,21 | Disputa ampla |
| Mussurunga | 19 | MUSSURUNGA I · MUSSURUNGA II | 374 | 125 | 499 | 1,03 | 0,47 | Base Solla |
| Beiru / Tancredo Neves | 14 | TANCREDO NEVES | 336 | 134 | 470 | 0,79 | 0,43 | Disputa ampla |
| Nova Sussuarana | 16 · 14 | SUSSUARANA NOVA · SUSSUARANA | 407 | 51 | 458 | 3,46 | 0,59 | Base Solla |
| Engenho Velho de Brotas | 6 | VASCO DA GAMA | 281 | 171 | 452 | 0,97 | 0,82 | Disputa ampla |
| Pirajá | 8 | — | 338 | 102 | 440 | 1,06 | 0,44 | Base Solla |
| Patamares | 10 | — | 129 | 304 | 433 | 1,13 | 3,61 | Reduto compartilhado |
| Barbalho | 7 | — | 249 | 180 | 429 | 1,12 | 1,11 | Reduto compartilhado |
| Caixa D'água | 18 | LIBERDADE | 290 | 136 | 426 | 1,04 | 0,65 | Base Solla |
| Pau da Lima | 11 | — | 368 | 44 | 412 | 1,57 | 0,26 | Base Solla |
| São Cristóvão | 12 | PARQUE SÃO CRISTÓVÃO | 321 | 88 | 409 | 0,74 | 0,27 | Disputa ampla |
| Centro | 1 | CAMPO GRANDE · POLITEAMA · AFLITOS · SÃO PEDRO · PIEDADE | 173 | 227 | 400 | 1,11 | 1,99 | Reduto compartilhado |
| Jardim Armação | 10 | BOCA DO RIO · ARMAÇÃO | 187 | 209 | 396 | 0,89 | 1,40 | Ponte Ceuci |
| Valéria | 8 | — | 333 | 54 | 387 | 1,16 | 0,27 | Base Solla |
| Castelo Branco | 11 | CASTELO BRANCO (2A ETAPA) · CASTELO BRANCO (1A ETAPA) · CASTELO BRANCO (3A ETAPA) · CASTELO BRANCO (4A ETAPA) | 276 | 101 | 377 | 0,74 | 0,38 | Disputa ampla |
| Vale dos Lagos | 14 | SÃO MARCOS | 210 | 152 | 362 | 0,95 | 0,95 | Disputa ampla |
| Pau Miúdo | 3 | — | 213 | 135 | 348 | 0,80 | 0,69 | Disputa ampla |
| Matatu | 7 | PITANGUEIRAS | 203 | 139 | 342 | 1,12 | 1,07 | Reduto compartilhado |
| Coutos | 4 | ALTO DE COUTOS · PERIPERI | 304 | 35 | 339 | 1,37 | 0,22 | Base Solla |
| Jaguaripe I | 19 | CAJAZEIRAS | 239 | 66 | 305 | 1,06 | 0,41 | Base Solla |
| Santa Cruz | 13 | NORDESTE DE AMARALINA | 205 | 100 | 305 | 0,92 | 0,64 | Disputa ampla |
| Nova Brasília | 19 | — | 251 | 51 | 302 | 1,27 | 0,35 | Base Solla |
| São Marcos | 14 · 11 | PAU DA LIMA | 252 | 47 | 299 | 1,48 | 0,38 | Base Solla |
| Uruguai | 9 | VILA RUI BARBOSA | 237 | 62 | 299 | 0,77 | 0,27 | Disputa ampla |
| Lapinha | 18 | LIBERDADE · SOLEDADE | 172 | 108 | 280 | 0,92 | 0,77 | Disputa ampla |
| Plataforma | 17 | ITACARANHA | 210 | 57 | 267 | 0,66 | 0,24 | Disputa ampla |
| Pero Vaz | 3 | IAPI | 197 | 61 | 258 | 0,97 | 0,41 | Disputa ampla |
| Lobato | 15 | BOA VISTA DO LOBATO | 193 | 64 | 257 | 0,63 | 0,29 | Disputa ampla |
| Luiz Anselmo | 7 | MATATU | 148 | 109 | 257 | 0,89 | 0,94 | Disputa ampla |
| Águas Claras | 11 | — | 196 | 58 | 254 | 0,65 | 0,27 | Disputa ampla |
| Centro Administrativo da Bahia - CAB | 16 | CENTRO ADMINISTRATIVO DA BAHIA | 113 | 141 | 254 | 1,02 | 1,74 | Reduto compartilhado |
| Liberdade | 18 | — | 179 | 71 | 250 | 0,73 | 0,39 | Disputa ampla |
| Bom Juá | 3 · 15 · 5 | FAZENDA GRANDE DO RETIRO · RETIRO | 176 | 65 | 241 | 0,95 | 0,47 | Disputa ampla |
| Cabula VI | 16 | — | 147 | 87 | 234 | 0,82 | 0,66 | Disputa ampla |
| Sussuarana | 14 | — | 182 | 52 | 234 | 0,89 | 0,35 | Disputa ampla |
| Bairro da Paz | 10 | — | 197 | 33 | 230 | 0,96 | 0,24 | Disputa ampla |
| Campinas de Pirajá | 8 | CAMPINAS | 193 | 29 | 222 | 0,97 | 0,20 | Disputa ampla |
| IAPI | 3 | — | 143 | 67 | 210 | 0,87 | 0,55 | Disputa ampla |
| Fazenda Grande I | 19 | CAJAZEIRAS · BOCA DA MATA | 144 | 65 | 209 | 0,70 | 0,45 | Disputa ampla |
| Novo Horizonte | 14 · 16 | SUSSUARANA · SUSSUARANA NOVA | 165 | 40 | 205 | 1,17 | 0,39 | Base Solla |
| Piatã | 10 | — | 85 | 120 | 205 | 1,22 | 2,33 | Reduto compartilhado |
| São João do Cabrito | 17 | PLATAFORMA | 156 | 47 | 203 | 0,68 | 0,26 | Disputa ampla |
| Nova Esperança | 19 | CEASA · CASSANGE | 188 | 10 | 198 | 2,05 | 0,15 | Base Solla |
| Mata Escura | 5 | JARDIM PAMPULHA | 146 | 51 | 197 | 0,58 | 0,29 | Disputa ampla |
| Fazenda Grande II | 19 | BOCA DA MATA · CAJAZEIRAS | 144 | 48 | 192 | 0,95 | 0,45 | Disputa ampla |
| Itacaranha | 17 | — | 146 | 43 | 189 | 0,85 | 0,34 | Disputa ampla |
| Alto do Cabrito | 17 · 15 | BOA VISTA DO LOBATO | 121 | 66 | 187 | 1,09 | 0,81 | Base Solla |
| Jardim Cajazeiras | 11 | JARDIM CAJAZEIRA · CALABETÃO | 176 | 10 | 186 | 1,43 | 0,11 | Base Solla |
| Boa Vista de Brotas | 6 | — | 108 | 76 | 184 | 1,40 | 1,33 | Reduto compartilhado |
| Cidade Nova | 3 · 4 | ILHA DE MARÉ | 116 | 67 | 183 | 0,83 | 0,66 | Disputa ampla |
| Nordeste do Amaralina | 13 | PITUBA | 114 | 64 | 178 | 0,70 | 0,56 | Disputa ampla |
| Arenoso | 14 | — | 143 | 27 | 170 | 0,94 | 0,25 | Disputa ampla |
| Palestina | 8 | — | 155 | 8 | 163 | 2,63 | 0,20 | Base Solla |
| Cajazeiras XI | 19 | — | 120 | 32 | 152 | 0,88 | 0,33 | Disputa ampla |
| Vila Canária | 11 | — | 118 | 26 | 144 | 1,02 | 0,32 | Base Solla |
| Canabrava | 16 | — | 103 | 33 | 136 | 0,98 | 0,45 | Disputa ampla |
| Curuzu | 18 | LIBERDADE | 96 | 36 | 132 | 0,72 | 0,36 | Disputa ampla |
| Cajazeiras V | 8 | — | 81 | 50 | 131 | 0,77 | 0,68 | Disputa ampla |
| Doron | 16 | SABOEIRO | 90 | 39 | 129 | 0,96 | 0,57 | Disputa ampla |
| Barris | 7 | — | 53 | 73 | 126 | 0,91 | 1,70 | Ponte Ceuci |
| Trobogy | 19 | — | 55 | 69 | 124 | 0,94 | 1,58 | Ponte Ceuci |
| Capelinha | 15 | CAPELINHA DE SÃO CAETANO · LOBATO | 104 | 18 | 122 | 0,88 | 0,21 | Disputa ampla |
| Dom Avelar | 11 | CASTELO BRANCO | 85 | 37 | 122 | 0,78 | 0,47 | Disputa ampla |
| Engenho Velho da Federação | 2 | FEDERAÇÃO | 78 | 38 | 116 | 0,64 | 0,45 | Disputa ampla |
| Cajazeiras VIII | 8 | — | 91 | 24 | 115 | 0,94 | 0,35 | Disputa ampla |
| Jardim das Margaridas | 12 · 19 | CASSANGE | 71 | 44 | 115 | 0,69 | 0,58 | Disputa ampla |
| São Gonçalo | 5 | SÃO GONÇALO DO RETIRO | 81 | 33 | 114 | 0,64 | 0,36 | Disputa ampla |
| Resgate | 5 | CABULA | 52 | 59 | 111 | 0,82 | 1,18 | Ponte Ceuci |
| Vila Ruy Barbosa / Jardim Cruzeiro | 9 | VILA RUI BARBOSA · URUGUAI | 84 | 25 | 109 | 0,75 | 0,29 | Disputa ampla |
| Jardim Santo Inácio | 11 | — | 78 | 30 | 108 | 0,97 | 0,51 | Disputa ampla |
| Saboeiro | 16 | — | 60 | 48 | 108 | 0,57 | 0,65 | Disputa ampla |
| Moradas da Lagoa | 4 · 8 | FAZENDA COUTOS · NOVA BRASÍLIA DE VALÉRIA | 81 | 23 | 104 | 0,74 | 0,29 | Disputa ampla |
| Cajazeiras X | 19 | — | 74 | 29 | 103 | 0,94 | 0,52 | Disputa ampla |
| Fazenda Coutos | 4 | — | 91 | 12 | 103 | 0,52 | 0,10 | Disputa ampla |
| Marechal Rondon | 8 | — | 81 | 18 | 99 | 0,72 | 0,21 | Disputa ampla |
| Sete de Abril | 11 | — | 75 | 22 | 97 | 0,45 | 0,19 | Disputa ampla |
| Ilha de Maré | 4 | — | 94 | 0 | 94 | 1,99 | 0,00 | Base Solla |
| Vale das Pedrinhas | 2 | RIO VERMELHO | 47 | 47 | 94 | 0,85 | 1,21 | Ponte Ceuci |
| Cajazeiras IV | 8 | — | 64 | 27 | 91 | 0,88 | 0,51 | Disputa ampla |
| Jardim Nova Esperança | 19 · 11 | SETE DE ABRIL | 72 | 18 | 90 | 0,58 | 0,20 | Disputa ampla |
| Barreiras | 5 | CABULA | 66 | 22 | 88 | 0,78 | 0,37 | Disputa ampla |
| Alto da Terezinha | 17 | — | 69 | 14 | 83 | 0,72 | 0,21 | Disputa ampla |
| Cajazeiras VI | 8 | — | 65 | 18 | 83 | 0,73 | 0,28 | Disputa ampla |
| Colinas de Periperi | 17 | PERIPERI | 55 | 28 | 83 | 0,80 | 0,56 | Disputa ampla |
| Vista Alegre | 4 | COUTOS | 71 | 12 | 83 | 0,86 | 0,21 | Disputa ampla |
| Engomadeira | 5 | — | 57 | 20 | 77 | 0,63 | 0,32 | Disputa ampla |
| Mangueira | 18 | CAMINHO DE AREIA | 47 | 28 | 75 | 0,98 | 0,79 | Disputa ampla |
| Saúde | 7 | — | 38 | 31 | 69 | 0,99 | 1,10 | Ponte Ceuci |
| Cassange | 12 | SÃO CRISTÓVÃO | 52 | 16 | 68 | 0,63 | 0,25 | Disputa ampla |
| Chame-Chame | 1 | — | 16 | 49 | 65 | 0,63 | 2,68 | Ponte Ceuci |
| Ilha Amarela | 17 | ITACARANHA | 47 | 17 | 64 | 0,59 | 0,31 | Disputa ampla |
| Boa Vista de São Caetano | 15 | — | 52 | 9 | 61 | 0,77 | 0,19 | Disputa ampla |
| Roma | 18 | — | 32 | 29 | 61 | 0,74 | 0,91 | Disputa ampla |
| Novo Marotinho | 19 | — | 46 | 14 | 60 | 1,07 | 0,44 | Base Solla |
| São Tomé | 4 | SÃO TOMÉ DE PARIPE | 47 | 10 | 57 | 0,55 | 0,16 | Disputa ampla |
| Boa Viagem | 9 | — | 46 | 10 | 56 | 1,68 | 0,50 | Base Solla |
| Fazenda Grande III | 19 | CAJAZEIRAS | 33 | 14 | 47 | 0,94 | 0,56 | Disputa ampla |
| Fazenda Grande IV | 19 | CAJAZEIRAS | 35 | 9 | 44 | 0,98 | 0,35 | Disputa ampla |
| Praia Grande | 17 | — | 25 | 17 | 42 | 0,61 | 0,60 | Disputa ampla |
| Ilha Bom Jesus dos Passos | 4 | BOM JESUS DOS PASSOS | 16 | 24 | 40 | 0,65 | 1,38 | Ponte Ceuci |
| Alto do Coqueirinho | 12 | — | 23 | 16 | 39 | 0,59 | 0,57 | Disputa ampla |
| Mares | 18 | — | 23 | 16 | 39 | 0,49 | 0,47 | Disputa ampla |
| Caminho de Areia | 18 | — | 21 | 13 | 34 | 0,74 | 0,63 | Disputa ampla |
| Centro Histórico | 1 | PELOURINHO | 32 | 0 | 32 | 1,40 | 0,00 | Base Solla |
| Arraial do Retiro | 15 | — | 28 | 3 | 31 | 0,79 | 0,11 | Disputa ampla |
| Cajazeiras VII | 8 | — | 18 | 8 | 26 | 0,52 | 0,32 | Disputa ampla |
| Calabetão | 11 | — | 19 | 0 | 19 | 0,65 | 0,00 | Disputa ampla |
| Santa Luzia | 15 | LOBATO | 13 | 3 | 16 | 0,63 | 0,21 | Disputa ampla |
| Granjas Rurais Presidente Vargas | 11 | CALABETÃO | 11 | 0 | 11 | 0,86 | 0,00 | Disputa ampla |
| Ilha dos Frades / Ilha de Santo Antônio | 4 | PARAMANA | 1 | 0 | 1 | 0,08 | 0,00 | Disputa ampla |
## Leitura da ciência política
@ -133,11 +256,11 @@ _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.
Dominância e concentração são coisas diferentes. Ceuci tem dominância concentrada: seus dez maiores bairros somam 45,3% do voto dela, contra 25,4% dos dez maiores de Solla — e o HHI dela é 2,64× o dele. Solla tem dominância dispersa: aparece acima da média em 112 bairros, contra 25 de Ceuci. A estratégia mora no cruzamento: nos redutos compartilhados, somar; nos bairros de Ceuci sem Solla, transferir presença; nos 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.
Descer ao bairro separa o que a zona juntava. A sobreposição das duas distribuições cai de 67,8% (19 zonas) para 62,9% (138 bairros): no agregado, os dois parecem mais parecidos do que são. O eixo forte dela é de serviços e educação — Pituba, Itaigara, Brotas, Canela/Graça, Ondina, Imbuí, STIEP/Pituaçu —, com as faculdades da UFBA entre os maiores locais de votação; o dele é a periferia e o centro-norte — São Caetano, Pernambués, Cosme de Farias, Paripe, Nova Sussuarana, Mussurunga, as Cajazeiras.
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.
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, 61,6% delas em bairros onde ela bateu Solla. 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 e os locais de votação têm endereço.
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.
@ -147,33 +270,33 @@ O mandato é a campanha. A memória que Ceuci carrega é de gestão e de saúde
_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.
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 bairro. Os redutos compartilhados e as pontes de Ceuci concentram 31,7% 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)**
**Fila de prioridade da campanha por bairro (critério: volume 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). |
| Prioridade | Bairros | Por quê | Ação com Ceuci |
| ----------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 — Defender | Pituba · Brotas · Itaigara · Ondina · Graça · Imbuí · STIEP · Pituaçu | Redutos compartilhados: os dois acima da média; maior soma de votos. | Evento conjunto no bairro (saúde + prestação de contas); material “Ceuci apoia Solla” na porta dos maiores locais, rádio e digital geolocalizado. |
| 2 — Ponte Ceuci | Rio Vermelho · Cabula · Federação · Jardim Armação | Ela acima da média, ele abaixo: a 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 no bairro para ≥ 1,0. |
| 3 — Consolidar | São Caetano · Pernambués · Cosme de Farias · Paripe · Ribeira · São Rafael · Bonfim · Nova Sussuarana | 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 bairro de baixo retorno. |
| 4 — Volume aberto | Itapuã · Periperi · Boca do Rio · Fazenda Grande do Retiro · Beiru / Tancredo Neves · Engenho Velho de Brotas | Ambos abaixo da média nos dois quocientes; decidir pelo volume absoluto. | 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.
- **Semana 1 — rede.** Fechar a lista de convites da rede de Ceuci (saúde, profissionais, apoiadores de 2022) e cruzar com o eixo: Pituba · Brotas · Itaigara · Ondina · Graça. Sem lista, não há evento.
- **Semana 2 — redutos.** Dois eventos conjuntos: Pituba (3.625 votos somados) e Brotas (2.324 votos somados). Formato: agenda de saúde + prestação de contas do mandato.
- **Semana 3 — ponte.** Rio Vermelho · Cabula: agenda dela com lideranças comunitárias e de saúde; Solla entra no fim.
- **Semana 4 — consolidar.** São Caetano · Pernambués · Cosme de Farias: apoio declarado no material e visita do mandato com a rede de saúde local. 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.
> **Como usar este documento na assessoria** A tabela por bairro (seção Tabelas) é o anexo do dossiê pré-agenda: antes de qualquer visita, confira o bairro, o LQ e a classe. Nos redutos compartilhados e nas pontes, a presença de Ceuci é o argumento; nas bases de Solla, o mandato fala sozinho. Os locais de votação de cada bairro estão no JSON da camada de bairro (docs/research/solla-ceuci-salvador-2022-bairros-dados.json).
## 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).
- **Dados eleitorais.** TSE — Portal de Dados Abertos. Camada de bairro: votação por seção (votacao_secao_2022_BA), aferição por seção (detalhe_votacao_secao_2022_BA), cadastro de locais e bairros (eleitorado_local_votacao_2022; coordenadas faltantes completadas pelo cadastro atual, pelo número do local) e candidaturas válidas (votacao_candidato_munzona_2022). Extração de 14/09/2026; hashes e conferências no JSON da camada de bairro.
- **Bairros.** Malha de bairros: IBGE, Censo 2022 (shapefile BA_bairros_CD2022). Nomes do cadastro eleitoral do TSE quando diferem do polígono. A lista TRE-BA (RA 02/2017) permanece na camada por zona.
- **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.
- **Reprodução.** Camada de bairro: NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-solla-ceuci-salvador-bairros.mjs --download (baixa os arquivos do TSE para o cache gitignored). Relatório: NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-solla-ceuci-salvador-report.mjs — lê os JSONs commitados 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 polígonos dos mapas são a malha de bairros do IBGE — não são limites oficiais do TSE — e o voto é atribuído ao local de votação, não à residência do eleitor.
---
@ -182,3 +305,12 @@ Proveniência dos dados: TSE — Resultados 2022 (votação nominal por municíp
- 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).
Camada de bairro: TSE — Portal de Dados Abertos (Resultados 2022 e Eleitorado 2022/Atual) Extração: 2026-09-14.
- Solla Salvador = 27264 = soma das seções, conferida zona a zona contra o JSON de zonas (C157).
- Ceuci Salvador = 19776 = soma das seções, conferida zona a zona contra o JSON de zonas (C157).
- Denominador (votos nominais de candidaturas válidas): federal 1290680 (zona: 1290350); estadual 1232318 (zona: 1232082) — diferença residual ≤ 0,1%, abaixo da precisão dos quocientes.
- 5023 seções com votação, todas com local de votação e bairro no cadastro do TSE; 474 locais.
- 138 bairros (malha IBGE) com local de votação; 170 polígonos na malha do Censo 2022; o cadastro do TSE nomeia 150 bairros.
- 133 locais sem coordenada no cadastro de 2022 tiveram a coordenada preenchida pelo cadastro atual (mesmo número de local); 0 locais caíram para o polígono mais próximo.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,689 @@
/**
* Builds the bairro-level dataset of the C157 report (C161 follow-up).
*
* Output: docs/research/solla-ceuci-salvador-2022-bairros-dados.json
*
* Method — the finest official unit is the seção eleitoral:
* votacao_secao_2022_BA votes per candidate per section (Solla 1313,
* Ceuci 13192, and the valid-candidacy set)
* detalhe_votacao_secao_2022_BA per-section tallies (aptos, comparecimento)
* eleitorado_local_votacao_2022 section → local de votação → bairro (TSE registry)
* votacao_candidato_munzona_2022 valid-candidacy set per município/zone — the
* same base prod uses, so the LQ denominator is
* the sum of nominal votes of valid candidacies
* eleitorado_local_votacao_ATUAL fills coordinates missing (sentinel -1) in the
* 2022 snapshot, keyed by zone + local number
*
* The bairro is the TSE registry bairro of the polling place (never the voter's
* home). Locals are also pinned to the IBGE Censo 2022 bairro mesh by their
* coordinates, so the report map and the report table stay consistent: each
* polygon is assigned to the TSE bairro that holds most of its votes.
*
* Usage (the heavy TSE zips are gitignored cache in data/tse-2022):
* node --import=tsx/esm scripts/build-solla-ceuci-salvador-bairros.mjs --download
* node --import=tsx/esm scripts/build-solla-ceuci-salvador-bairros.mjs
*
* `--download` fetches the zips with a real-browser user agent (the TSE CDN
* refuses the headless one); without it the script only reads the cache. No
* database, no production access.
*/
import { chromium } from '@playwright/test'
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import { fileURLToPath } from 'node:url'
import shp from 'shpjs'
import { dieWithLabel, ensureCachedDownload, sha256Hex } from './lib/cli.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-solla-ceuci-salvador-bairros'
const die = dieWithLabel(LABEL)
const OUT_PATH = join(ROOT, 'docs/research/solla-ceuci-salvador-2022-bairros-dados.json')
const ZONE_DATA_PATH = join(ROOT, 'docs/research/solla-ceuci-salvador-2022-dados.json')
const CACHE_DIR = join(ROOT, 'data/tse-2022')
const CSV_DIR = join(CACHE_DIR, 'csv')
const ZIPS_DIR = join(CACHE_DIR, 'zips')
const GEOMETRY_CACHE = join(ROOT, 'data/geometries')
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36'
const CITY_CODE = '38490'
const CITY_IBGE_CODE = '2927408'
const FEDERAL_OFFICE = '6'
const STATE_OFFICE = '7'
const SOLLA_NUMBER = '1313'
const CEUCI_NUMBER = '13192'
const SOURCES = [
{
zip: 'votacao_secao_2022_BA.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_secao/votacao_secao_2022_BA.zip',
csv: 'votacao_secao_2022_BA.csv',
},
{
zip: 'detalhe_votacao_secao_2022.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_secao/detalhe_votacao_secao_2022.zip',
csv: 'detalhe_votacao_secao_2022_BA.csv',
},
{
zip: 'eleitorado_local_votacao_2022.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/eleitorado_locais_votacao/eleitorado_local_votacao_2022.zip',
csv: 'eleitorado_local_votacao_2022.csv',
},
{
zip: 'eleitorado_local_votacao_ATUAL.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/perfil_eleitorado/eleitorado_local_votacao_ATUAL.zip',
csv: 'eleitorado_local_votacao_ATUAL.csv',
},
{
zip: 'votacao_candidato_munzona_2022.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip',
csv: 'votacao_candidato_munzona_2022_BA.csv',
},
]
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',
}
// ---------------------------------------------------------------------------
// CSV streaming (TSE: ISO-8859-1, `;` separator, every field quoted)
// ---------------------------------------------------------------------------
const parseCsvLine = (line) => {
const fields = []
let current = ''
let quoted = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
if (quoted) {
if (char === '"') {
if (line[index + 1] === '"') {
current += '"'
index += 1
} else {
quoted = false
}
} else {
current += char
}
} else if (char === '"') {
quoted = true
} else if (char === ';') {
fields.push(current)
current = ''
} else {
current += char
}
}
fields.push(current)
return fields
}
/** Yields rows as objects, decoding the file as latin1. `filter` sees the raw line. */
async function* csvRows(path, filter) {
const stream = createReadStream(path, { encoding: 'latin1' })
const lines = createInterface({ input: stream, crlfDelay: Infinity })
let header = null
for await (const line of lines) {
if (!header) {
header = parseCsvLine(line)
continue
}
if (!line || (filter && !filter(line))) continue
const fields = parseCsvLine(line)
const row = {}
header.forEach((name, index) => {
row[name] = fields[index] ?? ''
})
yield row
}
}
const includesSalvador = (line) => line.includes(CITY_CODE)
const sectionKey = (zone, section) => `${Number(zone)}|${Number(section)}`
/** TSE mixes dot and comma decimal separators across snapshots. */
const toCoordinate = (value) => Number(String(value).trim().replace(',', '.'))
// ---------------------------------------------------------------------------
// Download + unzip (cache only; never committed)
// ---------------------------------------------------------------------------
async function downloadZips() {
await mkdir(ZIPS_DIR, { recursive: true })
await mkdir(CSV_DIR, { recursive: true })
const browser = await chromium.launch({
headless: true,
args: ['--disable-blink-features=AutomationControlled'],
})
try {
const context = await browser.newContext({ userAgent: BROWSER_UA, locale: 'pt-BR' })
const page = await context.newPage()
await page.goto('https://dadosabertos.tse.jus.br/dataset/resultados-2022', {
waitUntil: 'domcontentloaded',
timeout: 120000,
})
await page.waitForTimeout(3000)
for (const source of SOURCES) {
const destination = join(ZIPS_DIR, source.zip)
try {
await readFile(destination)
console.log(`[${LABEL}] cache hit ${source.zip}`)
continue
} catch {
// not cached yet
}
console.log(`[${LABEL}] downloading ${source.zip}`)
const response = await page.request.get(source.url, { timeout: 0 })
if (!response.ok()) die(`download failed (${response.status()}): ${source.url}`)
await writeFile(destination, await response.body())
}
} finally {
await browser.close()
}
const { open } = await import('yauzl')
for (const source of SOURCES) {
const csvPath = join(CSV_DIR, source.csv)
try {
await readFile(csvPath)
continue
} catch {
// needs extraction
}
console.log(`[${LABEL}] extracting ${source.csv}`)
await new Promise((resolve, reject) => {
open(join(ZIPS_DIR, source.zip), { lazyEntries: true }, (error, zipfile) => {
if (error || !zipfile) {
reject(error ?? new Error(`cannot open ${source.zip}`))
return
}
zipfile.readEntry()
zipfile.on('entry', (entry) => {
if (!entry.fileName.endsWith(source.csv)) {
zipfile.readEntry()
return
}
zipfile.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) {
reject(streamError ?? new Error(`cannot read ${entry.fileName}`))
return
}
const writer = createWriteStream(csvPath)
stream.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
})
zipfile.on('error', reject)
})
})
}
}
// ---------------------------------------------------------------------------
// Pipeline
// ---------------------------------------------------------------------------
const csvPath = (name) => join(CSV_DIR, name)
async function loadValidCandidates() {
const valid = { federal: new Set(), state: new Set() }
for await (const row of csvRows(
csvPath('votacao_candidato_munzona_2022_BA.csv'),
includesSalvador,
)) {
if (row['CD_MUNICIPIO'] !== CITY_CODE) continue
const office =
row['CD_CARGO'] === FEDERAL_OFFICE
? valid.federal
: row['CD_CARGO'] === STATE_OFFICE
? valid.state
: null
if (!office) continue
office.add(`${Number(row['NR_ZONA'])}|${Number(row['NR_CANDIDATO'])}`)
}
return valid
}
async function loadSectionVotes(valid) {
const sections = new Map()
const bump = (key, field, value) => {
const current = sections.get(key) ?? { solla: 0, ceuci: 0, federalNominal: 0, stateNominal: 0 }
current[field] += value
sections.set(key, current)
}
for await (const row of csvRows(csvPath('votacao_secao_2022_BA.csv'), includesSalvador)) {
if (row['CD_MUNICIPIO'] !== CITY_CODE) continue
const office = row['CD_CARGO']
if (office !== FEDERAL_OFFICE && office !== STATE_OFFICE) continue
const key = sectionKey(row['NR_ZONA'], row['NR_SECAO'])
const number = Number(row['NR_VOTAVEL'])
const votes = Number(row['QT_VOTOS'])
const candidateKey = `${Number(row['NR_ZONA'])}|${number}`
if (office === FEDERAL_OFFICE) {
if (valid.federal.has(candidateKey)) bump(key, 'federalNominal', votes)
if (number === Number(SOLLA_NUMBER)) bump(key, 'solla', votes)
} else {
if (valid.state.has(candidateKey)) bump(key, 'stateNominal', votes)
if (number === Number(CEUCI_NUMBER)) bump(key, 'ceuci', votes)
}
}
return sections
}
async function loadSectionTallies() {
const tallies = new Map()
for await (const row of csvRows(csvPath('detalhe_votacao_secao_2022_BA.csv'), includesSalvador)) {
if (row['CD_MUNICIPIO'] !== CITY_CODE || row['CD_CARGO'] !== FEDERAL_OFFICE) continue
tallies.set(sectionKey(row['NR_ZONA'], row['NR_SECAO']), {
aptos: Number(row['QT_APTOS']),
comparecimento: Number(row['QT_COMPARECIMENTO']),
})
}
return tallies
}
async function loadSectionLocals() {
const bySection = new Map()
const locals = new Map()
for await (const row of csvRows(csvPath('eleitorado_local_votacao_2022.csv'), includesSalvador)) {
if (row['SG_UF'] !== 'BA' || row['CD_MUNICIPIO'] !== CITY_CODE) continue
const key = sectionKey(row['NR_ZONA'], row['NR_SECAO'])
if (bySection.has(key)) continue
const localKey = `${Number(row['NR_ZONA'])}|${Number(row['NR_LOCAL_VOTACAO'])}`
const local = locals.get(localKey) ?? {
zoneNumber: Number(row['NR_ZONA']),
localNumber: Number(row['NR_LOCAL_VOTACAO']),
name: row['NM_LOCAL_VOTACAO'].trim(),
bairro: row['NM_BAIRRO'].trim(),
latitude: toCoordinate(row['NR_LATITUDE']),
longitude: toCoordinate(row['NR_LONGITUDE']),
coordinateSource: 'tse-2022',
sections: 0,
aptos: 0,
comparecimento: 0,
federalNominal: 0,
stateNominal: 0,
sollaVotes: 0,
ceuciVotes: 0,
}
locals.set(localKey, local)
bySection.set(key, localKey)
}
return { bySection, locals }
}
async function fillMissingCoordinates(locals) {
const byNumber = new Map()
for await (const row of csvRows(
csvPath('eleitorado_local_votacao_ATUAL.csv'),
includesSalvador,
)) {
if (row['SG_UF'] !== 'BA' || row['CD_MUNICIPIO'] !== CITY_CODE) continue
const latitude = toCoordinate(row['NR_LATITUDE'])
const longitude = toCoordinate(row['NR_LONGITUDE'])
if (!isSalvadorCoordinate(latitude, longitude)) continue
byNumber.set(`${Number(row['NR_ZONA'])}|${Number(row['NR_LOCAL_VOTACAO'])}`, {
latitude,
longitude,
})
}
let filled = 0
for (const [key, local] of locals) {
if (isSalvadorCoordinate(local.latitude, local.longitude)) continue
const hit = byNumber.get(key)
if (!hit) continue
local.latitude = hit.latitude
local.longitude = hit.longitude
local.coordinateSource = 'tse-atual-local-number'
filled += 1
}
return filled
}
const isSalvadorCoordinate = (latitude, longitude) =>
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
latitude < -12.5 &&
latitude > -13.5 &&
longitude < -37.8 &&
longitude > -39
const normalizeName = (value) =>
String(value)
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/[.,/()-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
async function loadBairroMesh() {
const { buffer } = await ensureCachedDownload({
label: LABEL,
...BAIRRO_SOURCE,
cacheDir: GEOMETRY_CACHE,
download: async (url) => {
const response = await fetch(url, { headers: { 'User-Agent': BROWSER_UA } })
if (!response.ok) throw new Error(`IBGE download failed (${response.status})`)
return Buffer.from(await response.arrayBuffer())
},
})
const collections = [await shp(buffer)].flat()
return collections
.flatMap((collection) => collection.features)
.filter((item) => String(item.properties?.CD_MUN) === CITY_IBGE_CODE)
.map((item) => ({ name: item.properties.NM_BAIRRO, feature: item }))
}
async function main() {
if (process.argv.includes('--download')) await downloadZips()
const { featureContainsPoint, featureCentroid, haversineKm } =
await import('../src/lib/municipalityProximity.ts')
const valid = await loadValidCandidates()
const [sections, tallies, { bySection, locals }, mesh] = await Promise.all([
loadSectionVotes(valid),
loadSectionTallies(),
loadSectionLocals(),
loadBairroMesh(),
])
const filled = await fillMissingCoordinates(locals)
console.log(
`[${LABEL}] valid candidates: federal=${valid.federal.size} state=${valid.state.size}; ` +
`sections=${sections.size}; locals=${locals.size}; mesh=${mesh.length}; ` +
`coordinate fills=${filled}`,
)
if (sections.size === 0 || locals.size === 0) die('No Salvador sections/locals parsed.')
let missingLocal = 0
for (const key of sections.keys()) {
if (!bySection.has(key)) missingLocal += 1
}
if (missingLocal > 0) die(`${missingLocal} sections with votes have no local mapping.`)
// Aggregate per local.
for (const [key, localKey] of bySection) {
const local = locals.get(localKey)
const votes = sections.get(key)
if (!votes) continue
local.sections += 1
const tally = tallies.get(key)
if (tally) {
local.aptos += tally.aptos
local.comparecimento += tally.comparecimento
}
local.sollaVotes += votes.solla
local.ceuciVotes += votes.ceuci
local.federalNominal += votes.federalNominal
local.stateNominal += votes.stateNominal
}
// Pin locals to the IBGE mesh.
const withCoordinates = [...locals.values()].filter((local) =>
isSalvadorCoordinate(local.latitude, local.longitude),
)
let nearestFallbacks = 0
for (const local of withCoordinates) {
const point = { lng: local.longitude, lat: local.latitude }
const containing = mesh.find((item) => featureContainsPoint(item.feature, point))
if (containing) {
local.polygonName = containing.name
local.matchType = 'polygon'
continue
}
nearestFallbacks += 1
const nearest = [...mesh]
.map((item) => ({
name: item.name,
distanceKm: haversineKm(point, featureCentroid(item.feature)),
}))
.sort((left, right) => left.distanceKm - right.distanceKm)[0]
local.polygonName = nearest.name
local.matchType = 'nearest-polygon'
}
// Locals without coordinates (2022 and ATUAL) join the polygon of their own
// TSE bairro's locals; a lone local (e.g. Patamares) falls back to the
// polygon named like its TSE bairro. Votes never leave their bairro.
const meshByName = new Map(mesh.map((item) => [normalizeName(item.name), item.name]))
for (const local of locals.values()) {
if (local.polygonName) continue
const siblings = [...locals.values()].filter(
(item) => item !== local && item.bairro === local.bairro && item.polygonName,
)
const byPolygon = new Map()
for (const sibling of siblings) {
byPolygon.set(
sibling.polygonName,
(byPolygon.get(sibling.polygonName) ?? 0) + sibling.sollaVotes + sibling.ceuciVotes,
)
}
const majority = [...byPolygon.entries()].sort((left, right) => right[1] - left[1])[0]
if (majority) {
local.polygonName = majority[0]
local.matchType = 'bairro-majority'
continue
}
const sameName = meshByName.get(normalizeName(local.bairro))
if (sameName) {
local.polygonName = sameName
local.matchType = 'bairro-name'
}
}
const withoutPolygon = [...locals.values()].filter((local) => !local.polygonName)
if (withoutPolygon.length > 0) {
console.warn(
`[${LABEL}] ${withoutPolygon.length} local(is) without a polygon (table only): ` +
withoutPolygon
.map((local) => `${local.zoneNumber}/${local.localNumber} ${local.name}`)
.join('; '),
)
}
// Aggregate per IBGE bairro polygon — the map unit. Each polygon keeps the
// TSE registry names of its locals (labels with their own votes), so the
// report can print both vocabularies without mixing them.
const polygonMap = new Map()
for (const local of locals.values()) {
if (!local.polygonName) continue
const polygon = polygonMap.get(local.polygonName) ?? {
name: local.polygonName,
tseBairros: new Map(),
zones: new Map(),
sections: 0,
locais: 0,
aptos: 0,
comparecimento: 0,
federalNominal: 0,
stateNominal: 0,
sollaVotes: 0,
ceuciVotes: 0,
}
polygonMap.set(local.polygonName, polygon)
const tse = polygon.tseBairros.get(local.bairro) ?? {
name: local.bairro,
sollaVotes: 0,
ceuciVotes: 0,
}
tse.sollaVotes += local.sollaVotes
tse.ceuciVotes += local.ceuciVotes
polygon.tseBairros.set(local.bairro, tse)
polygon.sections += local.sections
polygon.locais += 1
polygon.aptos += local.aptos
polygon.comparecimento += local.comparecimento
polygon.federalNominal += local.federalNominal
polygon.stateNominal += local.stateNominal
polygon.sollaVotes += local.sollaVotes
polygon.ceuciVotes += local.ceuciVotes
const zone = polygon.zones.get(local.zoneNumber) ?? {
zoneNumber: local.zoneNumber,
sollaVotes: 0,
ceuciVotes: 0,
}
zone.sollaVotes += local.sollaVotes
zone.ceuciVotes += local.ceuciVotes
polygon.zones.set(local.zoneNumber, zone)
}
const bairros = [...polygonMap.values()]
.map((polygon) => ({
name: polygon.name,
tseBairros: [...polygon.tseBairros.values()].sort(
(left, right) => right.sollaVotes + right.ceuciVotes - (left.sollaVotes + left.ceuciVotes),
),
zones: [...polygon.zones.values()].sort(
(left, right) => right.sollaVotes + right.ceuciVotes - (left.sollaVotes + left.ceuciVotes),
),
sections: polygon.sections,
locais: polygon.locais,
aptos: polygon.aptos,
comparecimento: polygon.comparecimento,
federalNominal: polygon.federalNominal,
stateNominal: polygon.stateNominal,
sollaVotes: polygon.sollaVotes,
ceuciVotes: polygon.ceuciVotes,
}))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
const totals = {
sollaVotes: [...locals.values()].reduce((sum, local) => sum + local.sollaVotes, 0),
ceuciVotes: [...locals.values()].reduce((sum, local) => sum + local.ceuciVotes, 0),
federalNominal: [...locals.values()].reduce((sum, local) => sum + local.federalNominal, 0),
stateNominal: [...locals.values()].reduce((sum, local) => sum + local.stateNominal, 0),
aptos: [...locals.values()].reduce((sum, local) => sum + local.aptos, 0),
comparecimento: [...locals.values()].reduce((sum, local) => sum + local.comparecimento, 0),
sections: sections.size,
locais: locals.size,
bairros: bairros.length,
}
// Cross-checks against the zone-level input of the report.
const zoneData = JSON.parse(await readFile(ZONE_DATA_PATH, 'utf8'))
const validation = []
for (const zone of zoneData.zones) {
const sectionSolla = [...sections.entries()]
.filter(([key]) => Number(key.split('|')[0]) === zone.zoneNumber)
.reduce((sum, [, votes]) => sum + votes.solla, 0)
const sectionCeuci = [...sections.entries()]
.filter(([key]) => Number(key.split('|')[0]) === zone.zoneNumber)
.reduce((sum, [, votes]) => sum + votes.ceuci, 0)
if (sectionSolla !== zone.sollaVotes || sectionCeuci !== zone.ceuciVotes) {
die(
`Zone ${zone.zoneNumber} drift: sections ${sectionSolla}/${sectionCeuci} vs zone ${zone.sollaVotes}/${zone.ceuciVotes}.`,
)
}
}
if (totals.sollaVotes !== zoneData.salvadorTotals.sollaVotes) {
die(`Solla total drift: ${totals.sollaVotes} vs ${zoneData.salvadorTotals.sollaVotes}.`)
}
if (totals.ceuciVotes !== zoneData.salvadorTotals.ceuciVotes) {
die(`Ceuci total drift: ${totals.ceuciVotes} vs ${zoneData.salvadorTotals.ceuciVotes}.`)
}
validation.push(
`Solla Salvador = ${totals.sollaVotes} = soma das seções, conferida zona a zona contra o JSON de zonas (C157).`,
`Ceuci Salvador = ${totals.ceuciVotes} = soma das seções, conferida zona a zona contra o JSON de zonas (C157).`,
`Denominador (votos nominais de candidaturas válidas): federal ${totals.federalNominal} (zona: ${zoneData.salvadorTotals.federalValid}); estadual ${totals.stateNominal} (zona: ${zoneData.salvadorTotals.stateValid}) — diferença residual ≤ 0,1%, abaixo da precisão dos quocientes.`,
`${totals.sections} seções com votação, todas com local de votação e bairro no cadastro do TSE; ${totals.locais} locais.`,
`${totals.bairros} bairros (malha IBGE) com local de votação; ${mesh.length} polígonos na malha do Censo 2022; o cadastro do TSE nomeia ${new Set([...locals.values()].map((local) => local.bairro)).size} bairros.`,
)
const files = []
for (const source of SOURCES) {
const buffer = await readFile(csvPath(source.csv))
files.push({
name: source.csv,
url: source.url,
bytes: buffer.length,
sha256: sha256Hex(buffer),
})
}
validation.push(
`${filled} locais sem coordenada no cadastro de 2022 tiveram a coordenada preenchida pelo cadastro atual (mesmo número de local); ${nearestFallbacks} locais caíram para o polígono mais próximo.` +
(withoutPolygon.length > 0
? ` ${withoutPolygon.length} local(is) ficaram fora do mapa (sem polígono), mas seguem na tabela do bairro.`
: ''),
)
const output = {
year: 2022,
turn: 1,
city: { name: 'Salvador', tseCityCode: CITY_CODE, ibgeCode: CITY_IBGE_CODE },
offices: { federal: 'deputado_federal', state: 'deputado_estadual' },
candidates: {
solla: { candidateNumber: Number(SOLLA_NUMBER), office: 'deputado_federal' },
ceuci: { candidateNumber: Number(CEUCI_NUMBER), office: 'deputado_estadual' },
},
method: {
unit: 'Bairro do local de votação (cadastro eleitoral do TSE), com o local posicionado na malha de bairros do IBGE Censo 2022.',
join: 'votação por seção (votacao_secao_2022_BA) × seção → local → bairro (eleitorado_local_votacao_2022) × aferição por seção (detalhe_votacao_secao_2022_BA).',
denominator:
'Votos nominais das candidaturas válidas por seção — conjunto de candidaturas extraído de votacao_candidato_munzona_2022, a mesma base do baseline por zona.',
caveat:
'O voto é do local de votação, não da residência: o eleitor vota perto de casa, mas o cadastro não liga o voto ao domicílio. Um mesmo local pode atender mais de um bairro. Nenhum número aqui mede transferência de voto entre candidatos.',
},
provenance: {
source: 'TSE — Portal de Dados Abertos (Resultados 2022 e Eleitorado 2022/Atual)',
extractedAt: '2026-09-14',
files,
geometry: {
source: 'IBGE — malha de bairros do Censo 2022 (BA_bairros_CD2022)',
cache: 'data/geometries/bahia-bairros-cd2022.zip',
},
},
totals,
bairros,
locals: [...locals.values()]
.map((local) => ({
zoneNumber: local.zoneNumber,
localNumber: local.localNumber,
name: local.name,
bairro: local.bairro,
polygonName: local.polygonName,
matchType: local.matchType,
coordinateSource: local.coordinateSource,
latitude: local.latitude,
longitude: local.longitude,
sections: local.sections,
aptos: local.aptos,
comparecimento: local.comparecimento,
federalNominal: local.federalNominal,
stateNominal: local.stateNominal,
sollaVotes: local.sollaVotes,
ceuciVotes: local.ceuciVotes,
}))
.sort(
(left, right) => left.zoneNumber - right.zoneNumber || left.localNumber - right.localNumber,
),
polygonsWithoutVotes: mesh
.map((item) => item.name)
.filter((name) => !polygonMap.has(name))
.sort((left, right) => left.localeCompare(right, 'pt-BR')),
validation,
}
await mkdir(dirname(OUT_PATH), { recursive: true })
await writeFile(OUT_PATH, `${JSON.stringify(output, null, 2)}\n`)
console.log(
`[${LABEL}] wrote ${OUT_PATH}\n[${LABEL}] bairros=${bairros.length} locals=${locals.size} ` +
`sections=${sections.size} solla=${totals.sollaVotes} ceuci=${totals.ceuciVotes} ` +
`fedNominal=${totals.federalNominal} estNominal=${totals.stateNominal}`,
)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View file

@ -1,7 +1,7 @@
/**
* One-off report generator (C157): Solla × Ceuci in Salvador, 2022 TSE.
* Report generator (C157, granular layer C161): Solla × Ceuci in Salvador, 2022 TSE.
*
* Produces, from the committed input JSON and the committed geometries:
* Produces, from the committed input JSONs and the committed geometries:
* - docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.md
* - docs/research/analise-sobreposicao-solla-ceuci-salvador-2022.pdf
*
@ -10,16 +10,17 @@
* 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.
* Data:
* - docs/research/solla-ceuci-salvador-2022-dados.json (zone layer, C157)
* - docs/research/solla-ceuci-salvador-2022-bairros-dados.json (bairro layer,
* built from the TSE section files by scripts/build-solla-ceuci-salvador-bairros.mjs)
* 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.
* downloaded once to the gitignored data/geometries cache. Each IBGE polygon is
* painted with the votes of the polling places it contains — the bairro layer.
*
* Usage (no pnpm alias on purpose: a package.json change would force the full
* CI suite for a one-off report):
@ -37,16 +38,15 @@ import { feature } from 'topojson-client'
import { dieWithLabel, ensureCachedDownload } from './lib/cli.mjs'
import {
buildBairroMetrics,
buildBairroSummary,
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 { featureCentroid } = await import('../src/lib/municipalityProximity.ts')
const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
@ -54,6 +54,7 @@ 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 BAIRRO_DATA_PATH = join(ROOT, 'docs/research/solla-ceuci-salvador-2022-bairros-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'
@ -69,8 +70,14 @@ 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 normalizeBairroName = (value) =>
String(value)
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/[.,/()-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const htmlEscape = (value) =>
String(value)
@ -105,9 +112,39 @@ const summary = buildReportSummary({
})
const metricsByZone = new Map(metrics.map((zone) => [zone.zoneNumber, zone]))
const neighborhoodsByZone = new Map(
municipalityZoneNeighborhoods.map((record) => [record.zoneNumber, record.neighborhoods]),
)
const bairroData = JSON.parse(await readFile(BAIRRO_DATA_PATH, 'utf8'))
const bairroMetrics = buildBairroMetrics({ bairros: bairroData.bairros, totals: bairroData.totals })
const bairroSummary = buildBairroSummary({ metrics: bairroMetrics })
const bairroMetricsByName = new Map(bairroMetrics.map((bairro) => [bairro.name, bairro]))
if (bairroData.totals.sollaVotes !== data.salvadorTotals.sollaVotes) {
die(
`Bairro layer Solla drift: ${bairroData.totals.sollaVotes} vs zone layer ${data.salvadorTotals.sollaVotes}.`,
)
}
if (bairroData.totals.ceuciVotes !== data.salvadorTotals.ceuciVotes) {
die(
`Bairro layer Ceuci drift: ${bairroData.totals.ceuciVotes} vs zone layer ${data.salvadorTotals.ceuciVotes}.`,
)
}
/** Bairros sorted by combined votes, capped — the one ordering the report uses. */
const topByCombined = (list, count) =>
[...list].sort((left, right) => right.combinedVotes - left.combinedVotes).slice(0, count)
const bairroNumbers = (bairro) =>
`(Solla ${nf(bairro.sollaVotes)} · Ceuci ${nf(bairro.ceuciVotes)})`
const bairroNames = (list) => list.map((bairro) => bairro.name).join(' · ')
const bairroLines = (list, count) =>
list
.slice(0, count)
.map(
(bairro) =>
`${bairro.name} (Solla ${nf(bairro.sollaVotes)} · Ceuci ${nf(bairro.ceuciVotes)})`,
)
.join(' · ')
// ---------------------------------------------------------------------------
// Geometry
@ -129,39 +166,8 @@ 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
if (bairros.length !== bairroData.polygonsWithoutVotes.length + bairroData.bairros.length) {
die('IBGE mesh size does not match the bairro layer (polygons with and without votes).')
}
// ---------------------------------------------------------------------------
@ -264,38 +270,6 @@ const classRanges = (values, classCount = 5) => {
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 `<path d="${pathOf(zone.geometry)}" fill="${fill}" stroke="#52525b" stroke-width="1.1"/>`
})
.join('')
const legend = ranges
.map((range, index) =>
legendSwatch(discreteChoroplethFill(index, 5), `${nf(range.min)}–${nf(range.max)}`),
)
.join('')
return `
<figure class="map">
<figcaption><strong>${htmlEscape(title)}</strong><span>${htmlEscape(caption)}</span></figcaption>
<svg viewBox="0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}" role="img" aria-label="${htmlEscape(title)}">
${bairroOutlineSvg('rgba(255,255,255,0.5)', 0.45)}
${fills}
${zoneLabelSvg()}
</svg>
<div class="legend"><span class="legend-title">${htmlEscape(legendTitle)}</span>${legend}</div>
</figure>`
}
const renderZoneClassMap = () => {
const fills = zoneFeatures
.map((zoneFeature) => {
@ -325,12 +299,51 @@ const renderZoneClassMap = () => {
</figure>`
}
const renderBairroChoropleth = ({ valueOf, title, caption, legendTitle }) => {
const entries = bairroMetrics.map(valueOf).filter((value) => value > 0)
const classes = quantileClasses(entries)
const ranges = classRanges(entries)
const fills = bairros
.map((item) => {
const metric = bairroMetricsByName.get(item.properties?.NM_BAIRRO)
const value = metric ? valueOf(metric) : 0
const classIndex = classes.get(value) ?? 0
const fill = value > 0 ? discreteChoroplethFill(classIndex, 5) : NO_DATA_FILL
return `<path d="${pathOf(item.geometry)}" fill="${fill}" stroke="#ffffff" stroke-width="0.4"/>`
})
.join('')
const zoneBorders = zoneFeatures
.map(
(zone) =>
`<path d="${pathOf(zone.geometry)}" fill="none" stroke="#3f3f46" stroke-width="1.2"/>`,
)
.join('')
const legend = ranges
.map((range, index) =>
legendSwatch(discreteChoroplethFill(index, 5), `${nf(range.min)}–${nf(range.max)}`),
)
.join('')
return `
<figure class="map">
<figcaption><strong>${htmlEscape(title)}</strong><span>${htmlEscape(caption)}</span></figcaption>
<svg viewBox="0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}" role="img" aria-label="${htmlEscape(title)}">
${fills}
${zoneBorders}
${zoneLabelSvg()}
</svg>
<div class="legend"><span class="legend-title">${htmlEscape(legendTitle)}</span>${legend}</div>
</figure>`
}
const renderBairroClassMap = () => {
const fills = bairros
.map((bairro) => {
const zone = metricsByZone.get(bairro.zoneNumber)
const fill = zone?.zoneClass.color ?? NO_DATA_FILL
return `<path d="${pathOf(bairro.geometry)}" fill="${fill}" stroke="#ffffff" stroke-width="0.4"/>`
.map((item) => {
const metric = bairroMetricsByName.get(item.properties?.NM_BAIRRO)
const fill = metric?.bairroClass.color ?? NO_DATA_FILL
return `<path d="${pathOf(item.geometry)}" fill="${fill}" stroke="#ffffff" stroke-width="0.4"/>`
})
.join('')
@ -342,13 +355,18 @@ const renderBairroClassMap = () => {
.join('')
const legend = Object.values(ZONE_CLASSES)
.map((zoneClass) => legendSwatch(zoneClass.color, zoneClass.label))
.map((bairroClass) => {
const count = bairroMetrics.filter(
(bairro) => bairro.bairroClass.key === bairroClass.key,
).length
return legendSwatch(bairroClass.color, `${bairroClass.label} (${count} bairros)`)
})
.join('')
return `
<figure class="map">
<figcaption><strong>Salvador por bairros — classes da zona eleitoral de cada bairro</strong><span>Malha 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.</span></figcaption>
<svg viewBox="0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}" role="img" aria-label="Bairros de Salvador por classe de sobreposição">
<figcaption><strong>Classes de sobreposição por bairro</strong><span>Cada polígono IBGE é o bairro do local de votação; cinza = sem local de votação. Classes pelo quociente local (LQ) de cada candidato: acima ou abaixo da média dele em Salvador.</span></figcaption>
<svg viewBox="0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}" role="img" aria-label="Classes de sobreposição por bairro">
${fills}
${zoneBorders}
${zoneLabelSvg()}
@ -358,87 +376,88 @@ const renderBairroClassMap = () => {
}
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 rows = topByCombined(bairroMetrics, 25)
const maxValue = Math.max(...rows.map((bairro) => Math.max(bairro.sollaVotes, bairro.ceuciVotes)))
const barMaxWidth = 640
const labelWidth = 190
const rowHeight = 27
const top = 34
const height = top + rows.length * rowHeight + 12
const bars = rows
.map((zone, index) => {
.map((bairro, index) => {
const y = top + index * rowHeight
const sollaWidth = (zone.sollaVotes / maxValue) * barMaxWidth
const ceuciWidth = (zone.ceuciVotes / maxValue) * barMaxWidth
const sollaWidth = (bairro.sollaVotes / maxValue) * barMaxWidth
const ceuciWidth = (bairro.ceuciVotes / maxValue) * barMaxWidth
const label = bairro.name.length > 30 ? `${bairro.name.slice(0, 29)}…` : bairro.name
return `
<text x="46" y="${y + 13}" class="chart-label" text-anchor="end">ZE ${zone.zoneNumber}</text>
<rect x="56" y="${y + 2}" width="${sollaWidth.toFixed(1)}" height="9" rx="1.5" fill="#c51414"/>
<text x="${(60 + sollaWidth).toFixed(1)}" y="${y + 10}" class="chart-value">${nf(zone.sollaVotes)}</text>
<rect x="56" y="${y + 13}" width="${ceuciWidth.toFixed(1)}" height="9" rx="1.5" fill="#0d9488"/>
<text x="${(60 + ceuciWidth).toFixed(1)}" y="${y + 21}" class="chart-value">${nf(zone.ceuciVotes)}</text>`
<text x="${labelWidth - 10}" y="${y + 13}" class="chart-label" text-anchor="end">${htmlEscape(label)}</text>
<rect x="${labelWidth}" y="${y + 2}" width="${sollaWidth.toFixed(1)}" height="9" rx="1.5" fill="#c51414"/>
<text x="${(labelWidth + 4 + sollaWidth).toFixed(1)}" y="${y + 10}" class="chart-value">${nf(bairro.sollaVotes)}</text>
<rect x="${labelWidth}" y="${y + 13}" width="${ceuciWidth.toFixed(1)}" height="9" rx="1.5" fill="#0d9488"/>
<text x="${(labelWidth + 4 + ceuciWidth).toFixed(1)}" y="${y + 21}" class="chart-value">${nf(bairro.ceuciVotes)}</text>`
})
.join('')
return `
<figure class="map">
<figcaption><strong>Votos por zona eleitoral — Solla e Ceuci (2022)</strong><span>Zonas ordenadas pela soma Solla + Ceuci. Vermelho: Solla (federal 1313). Verde: Ceuci (estadual 13192).</span></figcaption>
<svg viewBox="0 0 1000 ${height}" role="img" aria-label="Votos por zona eleitoral">
<figcaption><strong>Top 25 bairros — Solla e Ceuci (2022)</strong><span>Bairros ordenados pela soma Solla + Ceuci. Vermelho: Solla (federal 1313). Verde: Ceuci (estadual 13192).</span></figcaption>
<svg viewBox="0 0 1000 ${height}" role="img" aria-label="Votos por bairro">
<g class="chart">${bars}</g>
</svg>
</figure>`
}
const renderScatter = () => {
const renderBairroQuadrant = () => {
const width = 1000
const height = 520
const left = 80
const right = 40
const height = 560
const left = 90
const right = 50
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 limit = 3 // log2 domain: LQ 0,125 a 8
const xOf = (lq) => left + ((Math.log2(lq) + limit) / (2 * limit)) * (width - left - right)
const yOf = (lq) =>
height - bottom - ((Math.log2(lq) + limit) / (2 * limit)) * (height - top - bottom)
const ticks = [0.25, 0.5, 1, 2, 4, 8]
const grid = Array.from({ length: xTicks + 1 }, (_, index) => {
const value = (maxX / xTicks) * index
const x = xOf(value)
return `<line x1="${x}" y1="${top}" x2="${x}" y2="${height - bottom}" class="grid"/><text x="${x}" y="${height - bottom + 18}" class="axis-label" text-anchor="middle">${nf(value)}</text>`
}).join('')
const grid = ticks
.map((tick) => {
const x = xOf(tick)
const y = yOf(tick)
return `<line x1="${x}" y1="${top}" x2="${x}" y2="${height - bottom}" class="grid"/><text x="${x}" y="${height - bottom + 18}" class="axis-label" text-anchor="middle">${dec(tick)}</text><line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" class="grid"/><text x="${left - 10}" y="${y + 4}" class="axis-label" text-anchor="end">${dec(tick)}</text>`
})
.join('')
const yGrid = Array.from({ length: yTicks + 1 }, (_, index) => {
const value = (maxY / yTicks) * index
const y = yOf(value)
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" class="grid"/><text x="${left - 10}" y="${y + 4}" class="axis-label" text-anchor="end">${nf(value)}</text>`
}).join('')
const maxCombined = Math.max(...bairroMetrics.map((bairro) => bairro.combinedVotes))
const pointRadius = (bairro) => 3.5 + Math.sqrt(bairro.combinedVotes / maxCombined) * 9
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 labelCandidates = new Set(topByCombined(bairroMetrics, 14).map((bairro) => bairro.name))
for (const bairro of bairroSummary.topCeuciLq.slice(0, 4)) labelCandidates.add(bairro.name)
for (const bairro of bairroSummary.topSollaLq.slice(0, 4)) labelCandidates.add(bairro.name)
const points = metrics
.map((zone) => {
const x = xOf(zone.sollaVotes)
const y = yOf(zone.ceuciVotes)
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="6" fill="${zone.zoneClass.color}" stroke="#ffffff" stroke-width="1.5"/><text x="${(x + 9).toFixed(1)}" y="${(y + 4).toFixed(1)}" class="point-label">ZE ${zone.zoneNumber}</text>`
const points = bairroMetrics
.map((bairro) => {
const x = xOf(Math.max(bairro.sollaLq, 0.13))
const y = yOf(Math.max(bairro.ceuciLq, 0.13))
const radius = pointRadius(bairro)
const circle = `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${radius.toFixed(1)}" fill="${bairro.bairroClass.color}" stroke="#ffffff" stroke-width="1.4"/>`
if (!labelCandidates.has(bairro.name)) return circle
const label = bairro.name.length > 24 ? `${bairro.name.slice(0, 23)}…` : bairro.name
return `${circle}<text x="${(x + radius + 3).toFixed(1)}" y="${(y + 3.5).toFixed(1)}" class="point-label">${htmlEscape(label)}</text>`
})
.join('')
return `
<figure class="map">
<figcaption><strong>Dispersão — Solla × Ceuci por zona</strong><span>Cada 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).</span></figcaption>
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Dispersão Solla por Ceuci">
<g class="chart">${grid}${yGrid}
<line x1="${xOf(medianX)}" y1="${top}" x2="${xOf(medianX)}" y2="${height - bottom}" class="median"/>
<line x1="${left}" y1="${yOf(medianY)}" x2="${width - right}" y2="${yOf(medianY)}" class="median"/>
<figcaption><strong>Bairros no plano do quociente local (LQ)</strong><span>Cada bolha é um bairro; tamanho = votos somados; cor = classe. Eixos em escala log: à direita/acima, o bairro pesa mais que a média do candidato. Linhas nos LQ 1,0 (médias de Solla e de Ceuci). LQ 0 é desenhado no piso do eixo. Bairros rotulados: os 14 maiores em volume e os extremos de LQ.</span></figcaption>
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Bairros no plano do quociente local">
<g class="chart">${grid}
<line x1="${xOf(1)}" y1="${top}" x2="${xOf(1)}" y2="${height - bottom}" class="median"/>
<line x1="${left}" y1="${yOf(1)}" x2="${width - right}" y2="${yOf(1)}" class="median"/>
${points}
<text x="${(width - right) / 2}" y="${height - 14}" class="axis-title" text-anchor="middle">Votos de Solla (federal 1313)</text>
<text x="18" y="${(height - bottom + top) / 2}" class="axis-title" transform="rotate(-90 18 ${(height - bottom + top) / 2})" text-anchor="middle">Votos de Ceuci (estadual 13192)</text>
<text x="${(width - right) / 2}" y="${height - 14}" class="axis-title" text-anchor="middle">LQ de Solla (federal 1313)</text>
<text x="18" y="${(height - bottom + top) / 2}" class="axis-title" transform="rotate(-90 18 ${(height - bottom + top) / 2})" text-anchor="middle">LQ de Ceuci (estadual 13192)</text>
</g>
</svg>
</figure>`
@ -499,51 +518,67 @@ const overlapTable = () => ({
]),
})
const bairroTable = () => {
const rows = [...metrics]
const bairroTable = () => ({
caption: 'Votação 2022 por bairro (local de votação) — Solla e Ceuci, LQ e classe',
head: [
'Bairro',
'ZE',
'Cadastro TSE',
'Solla',
'Ceuci',
'Soma',
'LQ Solla',
'LQ Ceuci',
'Classe',
],
align: ['left', 'left', 'left', 'right', 'right', 'right', 'right', 'right', 'left'],
rows: [...bairroMetrics]
.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,
}
}
.map((bairro) => {
const registryNames = bairro.tseBairros
.map((record) => record.name)
.filter((name) => normalizeBairroName(name) !== normalizeBairroName(bairro.name))
return [
bairro.name,
bairro.zones.map((zone) => zone.zoneNumber).join(' · '),
registryNames.length > 0 ? registryNames.join(' · ') : '—',
nf(bairro.sollaVotes),
nf(bairro.ceuciVotes),
nf(bairro.combinedVotes),
dec(bairro.sollaLq),
dec(bairro.ceuciLq),
classBadge(bairro.bairroClass),
]
}),
})
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'],
caption: 'Fila de prioridade da campanha por bairro (critério: volume e classe de sobreposição)',
head: ['Prioridade', 'Bairros', 'Por quê', 'Ação com Ceuci'],
align: ['left', 'left', 'left', 'left'],
rows: [
[
'1 — Defender',
list(zoneList(summary.shared)),
bairroNames(sharedTop8),
'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.',
'Evento conjunto no bairro (saúde + prestação de contas); material “Ceuci apoia Solla” na porta dos maiores locais, 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.',
bairroNames(ceuciPonte),
'Ela acima da média, ele abaixo: a 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 no bairro para ≥ 1,0.',
],
[
'3 — Consolidar',
list(zoneList(summary.sollaOnly)),
bairroNames(sollaBase.slice(0, 8)),
'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.',
'Apoio declarado dela em material e visitas pontuais onde houver rede de saúde; não gastar agenda dela em bairro 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).',
'4 — Volume aberto',
bairroNames(openTop6),
'Ambos abaixo da média nos dois quocientes; decidir pelo volume absoluto.',
'Sem agenda dedicada dela; entra apenas em ação municipal de saúde (mutirão, anúncio de política pública).',
],
],
@ -553,62 +588,76 @@ const priorityTable = () => ({
// 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
const sharedTop8 = topByCombined(bairroSummary.shared, 8)
const ceuciPonte = [...bairroSummary.ceuciOnly]
.filter((bairro) => bairro.ceuciVotes >= 100)
.sort((left, right) => right.ceuciVotes - left.ceuciVotes)
const sollaBase = [...bairroSummary.sollaOnly]
.filter((bairro) => bairro.sollaVotes >= 200)
.sort((left, right) => right.sollaVotes - left.sollaVotes)
const openTop6 = topByCombined(bairroSummary.open, 6)
const ceuciAheadTop5 = [...bairroSummary.ceuciAhead]
.sort((left, right) => right.ceuciVotes - left.ceuciVotes)
.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 ceuciAheadShare = bairroSummary.ceuciAhead.reduce(
(sum, bairro) => sum + bairro.ceuciShareOwn,
0,
)
const ceuciConcentration = bairroSummary.ceuciHhi / bairroSummary.sollaHhi
const priorityBairros = [...sharedTop8, ...ceuciPonte]
const priorityBairroShare =
priorityBairros.reduce((sum, bairro) => sum + bairro.combinedVotes, 0) /
(data.salvadorTotals.sollaVotes + data.salvadorTotals.ceuciVotes)
const localsByCeuci = [...bairroData.locals]
.filter((local) => local.polygonName)
.sort((left, right) => right.ceuciVotes - left.ceuciVotes)
const biggestCeuciLocal = localsByCeuci[0]
const bairroPick = (name) => {
const metric = bairroMetricsByName.get(name)
if (!metric) die(`Bairro not found in the bairro layer: ${name}`)
return metric
}
const executiveBullets = [
{
label: 'Sobreposição parcial, com centro claro.',
label: 'Descer ao bairro separa o que a zona juntava.',
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.`,
`Nas 19 zonas, as duas votações se sobrepõem em ${pct(summary.overlapCoefficient)}; nos ` +
`${nf(bairroMetrics.length)} bairros com local de votação, a sobreposição cai para ${pct(bairroSummary.overlapCoefficient)}. ` +
`A leitura de zona escondia contraste dentro dela — a ZE 12 mistura Stella Maris ${bairroNumbers(bairroPick('Stella Maris'))} ` +
`com São Cristóvão ${bairroNumbers(bairroPick('São Cristóvão'))}; a ZE 4 tem Paripe ${bairroNumbers(bairroPick('Paripe'))} ` +
`ao lado de Ilha Bom Jesus dos Passos ${bairroNumbers(bairroPick('Ilha Bom Jesus dos Passos'))}.`,
},
{
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)}.`,
`Na cidade, ela é concentrada: os 5 maiores bairros dela somam ${pct(bairroSummary.ceuciTop5Share)} do voto dela, contra ` +
`${pct(bairroSummary.sollaTop5Share)} de Solla (concentração ${dec(ceuciConcentration)}× maior pelo HHI).`,
},
{
label: 'Onde ela é maior que ele.',
text: `Ceuci supera Solla em ${summary.ceuciAhead.length} zonas — ${ceuciAheadText}.`,
text:
`Ceuci supera Solla em ${bairroSummary.ceuciAhead.length} dos ${nf(bairroMetrics.length)} bairros, que juntos reúnem ` +
`${pct(ceuciAheadShare)} do voto dela na cidade — ${bairroLines(ceuciAheadTop5, 5)}, entre outros.`,
},
{
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}.`,
text: `As maiores somas Solla + Ceuci estão em ${bairroLines(bairroSummary.strongestCombined, 6)} — os territórios onde uma agenda conjunta tem mais público potencial.`,
},
{
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))}) — ` +
`Três movimentos por bairro: (1) defender os redutos compartilhados (${bairroNames(sharedTop8)}, …) com agenda e material conjuntos; ` +
`(2) usar Ceuci como cabeça de ponte onde ela é acima da média e Solla abaixo (${bairroNames(ceuciPonte)}); ` +
`(3) ancorar o apoio declarado nas bases de Solla (${bairroNames(sollaBase.slice(0, 6))}, …) — ` +
`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',
@ -623,10 +672,13 @@ const report = {
label: 'votos de Ceuci em Salvador (estadual 13192)',
},
{
value: pct(summary.overlapCoefficient, 0),
label: 'índice de sobreposição das distribuições',
value: pct(bairroSummary.overlapCoefficient, 0),
label: 'sobreposição das distribuições nos bairros',
},
{
value: nf(bairroMetrics.length),
label: `bairros com local de votação · ${nf(bairroData.totals.locais)} locais`,
},
{ value: '19', label: 'zonas eleitorais · 170 bairros na malha' },
],
sections: [
{
@ -640,7 +692,8 @@ const report = {
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.',
'geografias de voto, não entre eleitores — não se observa quem votou em quem. O bairro é o do local de votação ' +
'(cadastro do TSE), posicionado na malha do IBGE; não é o bairro de residência do eleitor.',
},
],
},
@ -653,7 +706,9 @@ const report = {
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).`,
`Na leitura por bairro, o centro comum tem endereço: ${bairroNames(sharedTop8)} — os maiores entre os bairros em que os dois estão acima da própria média. É um eixo centro-sul de serviços e educação: ${biggestCeuciLocal.name} (${nf(biggestCeuciLocal.ceuciVotes)} votos dela), em ${biggestCeuciLocal.polygonName}, é o maior local de votação dela na cidade, e as faculdades da UFBA, em Canela e Graça, aparecem entre os maiores.`,
`Fora desse eixo, a votação dela é bem mais concentrada que a dele: os 10 maiores bairros dela somam ${pct(bairroSummary.ceuciTop10Share)} do voto dela, contra ${pct(bairroSummary.sollaTop10Share)} dos 10 maiores dele. Onde ela é maior que ele — ${nf(bairroSummary.ceuciAhead.length)} bairros, que reúnem ${pct(ceuciAheadShare)} do voto dela — estão ${bairroLines(ceuciAheadTop5, 5)}. Onde Solla é forte e ela quase não aparece estão as bases do mandato: ${bairroNames(sollaBase.slice(0, 7))}.`,
`Há ainda a faixa em que ela está acima da média e ele abaixo — ${bairroNames(ceuciPonte)}. É onde a rede de Ceuci alcança eleitor que o mandato ainda não mobiliza, e onde a agenda conjunta rende mais do que repetir evento em bairro já consolidado.`,
],
},
{
@ -670,17 +725,17 @@ const report = {
{
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.',
'Fonte: resultados oficiais do TSE para 2022 (1º turno). 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 em dois níveis: as 19 zonas eleitorais (agregação oficial) e os bairros dos locais de votação.',
'A camada de bairro desce à menor unidade publicada pelo TSE: a seção eleitoral. Cada seção tem votos por candidato (votacao_secao_2022) e pertence a um local de votação; o cadastro eleitoral (eleitorado_local_votacao_2022) associa esse local a um bairro e a uma coordenada. Somando as seções de cada local e agrupando pelos bairros, chega-se ao voto por bairro — cada local posicionado no polígono do IBGE Censo 2022. O denominador do LQ é a soma dos votos nominais das candidaturas válidas da seção, a mesma base do recorte por zona.',
'Força relativa: o LQ (quociente local) compara a fatia do território no voto do candidato com a fatia do território nos votos válidos. LQ = 1 é exatamente a média do candidato; acima de 1, o território é relativamente forte para ele. Sobreposição: o índice soma, território a território, o menor valor entre as duas fatias de voto (0 a 1).',
`Conferências: a soma das seções reproduz zona a zona o recorte por zona (Solla ${nf(data.salvadorTotals.sollaVotes)}; Ceuci ${nf(data.salvadorTotals.ceuciVotes)}); o denominador fecha com diferença residual ≤ 0,1% do total por zona; ${nf(bairroData.totals.sections)} seções, ${nf(bairroData.totals.locais)} locais e ${nf(bairroData.totals.bairros)} bairros com votação foram conferidos. Os hashes dos arquivos-fonte estão no JSON da camada de bairro.`,
'Esta edição substitui a primeira versão do relatório, que pintava a malha de bairros com os números da zona do centroide: agora cada polígono tem os votos dos locais de votação que contém.',
],
},
{
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.',
title: 'O que o dado não é',
text: 'O voto é do local de votação, não da casa do eleitor: o cadastro não liga o voto ao domicílio e um mesmo local pode atender mais de um bairro. O bairro do TSE (150 nomes no cadastro eleitoral) e o polígono do IBGE (170 na malha; 138 com local de votação) têm recortes e nomes próprios — o mapa usa o polígono e mostra o nome do cadastro quando difere. Nenhum número aqui mede transferência de voto entre candidatos.',
},
],
},
@ -690,34 +745,34 @@ const report = {
blocks: [
{
type: 'html',
html: renderZoneChoropleth({
values: new Map(metrics.map((zone) => [zone.zoneNumber, zone.sollaVotes])),
title: 'Solla em Salvador — votos por ZE (2022)',
html: renderBairroChoropleth({
valueOf: (bairro) => bairro.sollaVotes,
title: 'Solla em Salvador — votos por bairro (2022)',
caption:
'Classes por quantis da própria votação. Quanto mais escuro, mais votos na zona.',
'Cada polígono é um bairro do IBGE com local de votação; classes por quantis. Quanto mais escuro, mais votos. Cinza: bairro sem local de votação.',
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)',
html: renderBairroChoropleth({
valueOf: (bairro) => bairro.ceuciVotes,
title: 'Ceuci em Salvador — votos por bairro (2022)',
caption:
'Classes por quantis da própria votação. A mancha dela é bem mais concentrada que a de Solla.',
'A mancha dela é bem mais concentrada: um eixo centro-sul (Pituba, Itaigara, Canela/Graça, Ondina) contra uma base de Solla espalhada pela periferia.',
legendTitle: 'Votos de Ceuci:',
}),
},
{ type: 'html', html: renderZoneClassMap() },
{ type: 'html', html: renderBairroClassMap() },
{ type: 'html', html: renderZoneClassMap() },
],
},
{
id: 'graficos',
title: 'Votos por zona e dispersão',
title: 'Bairros em volume e em força relativa',
blocks: [
{ type: 'html', html: renderBars() },
{ type: 'html', html: renderScatter() },
{ type: 'html', html: renderBairroQuadrant() },
],
},
{
@ -738,9 +793,9 @@ const report = {
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.`,
`Dominância e concentração são coisas diferentes. Ceuci tem dominância concentrada: seus dez maiores bairros somam ${pct(bairroSummary.ceuciTop10Share)} do voto dela, contra ${pct(bairroSummary.sollaTop10Share)} dos dez maiores de Solla — e o HHI dela é ${dec(ceuciConcentration)}× o dele. Solla tem dominância dispersa: aparece acima da média em ${bairroSummary.sollaAhead.length} bairros, contra ${bairroSummary.ceuciAhead.length} de Ceuci. A estratégia mora no cruzamento: nos redutos compartilhados, somar; nos bairros de Ceuci sem Solla, transferir presença; nos de Solla sem Ceuci, não forçar.`,
`Descer ao bairro separa o que a zona juntava. A sobreposição das duas distribuições cai de ${pct(summary.overlapCoefficient)} (19 zonas) para ${pct(bairroSummary.overlapCoefficient)} (138 bairros): no agregado, os dois parecem mais parecidos do que são. O eixo forte dela é de serviços e educação — Pituba, Itaigara, Brotas, Canela/Graça, Ondina, Imbuí, STIEP/Pituaçu —, com as faculdades da UFBA entre os maiores locais de votação; o dele é a periferia e o centro-norte — São Caetano, Pernambués, Cosme de Farias, Paripe, Nova Sussuarana, Mussurunga, as Cajazeiras.`,
`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, ${pct(ceuciAheadShare)} delas em bairros onde ela bateu Solla. 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 e os locais de votação têm endereço.`,
`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.`,
],
},
@ -760,8 +815,8 @@ const report = {
{
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)} ` +
'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 bairro. Os redutos compartilhados e as pontes de Ceuci concentram ' +
`${pct(priorityBairroShare)} ` +
'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.`,
],
@ -772,26 +827,28 @@ const report = {
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.',
text: `Fechar a lista de convites da rede de Ceuci (saúde, profissionais, apoiadores de 2022) e cruzar com o eixo: ${bairroNames(sharedTop8.slice(0, 5))}. 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.`,
text: `Dois eventos conjuntos: ${topByCombined(bairroSummary.shared, 2)
.map((bairro) => `${bairro.name} (${nf(bairro.combinedVotes)} votos somados)`)
.join(' e ')}. 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 3 — ponte.',
text: `${bairroNames(ceuciPonte.slice(0, 2))}: agenda dela com lideranças comunitárias e de saúde; Solla entra no fim.`,
},
{
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.`,
label: 'Semana 4 — consolidar.',
text: `${bairroNames(sollaBase.slice(0, 3))}: apoio declarado no material e visita do mandato com a rede de saúde local. 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.',
text: `A tabela por bairro (seção Tabelas) é o anexo do dossiê pré-agenda: antes de qualquer visita, confira o bairro, o LQ e a classe. Nos redutos compartilhados e nas pontes, a presença de Ceuci é o argumento; nas bases de Solla, o mandato fala sozinho. Os locais de votação de cada bairro estão no JSON da camada de bairro (docs/research/solla-ceuci-salvador-2022-bairros-dados.json).`,
},
],
},
@ -804,11 +861,11 @@ const report = {
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.',
text: 'TSE — Portal de Dados Abertos. Camada de bairro: votação por seção (votacao_secao_2022_BA), aferição por seção (detalhe_votacao_secao_2022_BA), cadastro de locais e bairros (eleitorado_local_votacao_2022; coordenadas faltantes completadas pelo cadastro atual, pelo número do local) e candidaturas válidas (votacao_candidato_munzona_2022). Extração de 14/09/2026; hashes e conferências no JSON da camada de bairro.',
},
{
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).',
text: 'Malha de bairros: IBGE, Censo 2022 (shapefile BA_bairros_CD2022). Nomes do cadastro eleitoral do TSE quando diferem do polígono. A lista TRE-BA (RA 02/2017) permanece na camada por zona.',
},
{
label: 'Biografia de Ceuci.',
@ -816,11 +873,11 @@ const report = {
},
{
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.',
text: 'Camada de bairro: NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-solla-ceuci-salvador-bairros.mjs --download (baixa os arquivos do TSE para o cache gitignored). Relatório: NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-solla-ceuci-salvador-report.mjs — lê os JSONs commitados 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.',
text: 'Documento interno de campanha. Não contém dados pessoais; usa apenas dados públicos de votação. Os polígonos dos mapas são a malha de bairros do IBGE — não são limites oficiais do TSE — e o voto é atribuído ao local de votação, não à residência do eleitor.',
},
],
},
@ -894,6 +951,10 @@ const markdown = [
'',
...data.provenance.crossChecks.map((check) => `- ${check}`),
'',
`Camada de bairro: ${bairroData.provenance.source} Extração: ${bairroData.provenance.extractedAt}.`,
'',
...bairroData.validation.map((check) => `- ${check}`),
'',
].join('\n')
// ---------------------------------------------------------------------------
@ -1076,11 +1137,11 @@ try {
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)})`,
`[${LABEL}] zone-overlap=${pct(summary.overlapCoefficient)} bairro-overlap=${pct(bairroSummary.overlapCoefficient)} ` +
`pearson=${dec(bairroSummary.pearson)} spearman=${dec(bairroSummary.spearman)} zones=${metrics.length} ` +
`bairros=${bairroMetrics.length} mesh=${bairros.length} (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))}`,
`[${LABEL}] shared=${bairroNames(sharedTop8)} | ponte=${bairroNames(ceuciPonte)} | ` +
`base-solla=${bairroNames(sollaBase.slice(0, 6))} | aberto=${bairroNames(openTop6)}`,
)

View file

@ -189,3 +189,76 @@ export const buildReportSummary = ({ metrics, candidates, salvadorTotals }) => {
ceuciAhead,
}
}
/**
* Per-bairro rows (IBGE Censo 2022 mesh, keyed by the polygon name). Same
* arithmetic as the zones, but the denominator is the bairro's share of the
* valid nominal votes of each office and N is the number of bairros with a
* polling place — a finer reading of the same two geographies of vote.
*/
export const buildBairroMetrics = ({ bairros, totals }) =>
bairros.map((bairro) => {
const sollaShareOwn = shareOf(bairro.sollaVotes, totals.sollaVotes)
const ceuciShareOwn = shareOf(bairro.ceuciVotes, totals.ceuciVotes)
const sollaLq = localQuotient(
bairro.sollaVotes,
totals.sollaVotes,
bairro.federalNominal,
totals.federalNominal,
)
const ceuciLq = localQuotient(
bairro.ceuciVotes,
totals.ceuciVotes,
bairro.stateNominal,
totals.stateNominal,
)
return {
...bairro,
sollaShareOwn,
ceuciShareOwn,
sollaShareValid: shareOf(bairro.sollaVotes, bairro.federalNominal),
ceuciShareValid: shareOf(bairro.ceuciVotes, bairro.stateNominal),
sollaLq,
ceuciLq,
overlapMin: Math.min(sollaShareOwn, ceuciShareOwn),
combinedVotes: bairro.sollaVotes + bairro.ceuciVotes,
bairroClass: classifyZone(sollaLq, ceuciLq),
}
})
/** Bairro cuts used by the executive summary, the personas and the action plan. */
export const buildBairroSummary = ({ metrics }) => {
const sollaShares = metrics.map((bairro) => bairro.sollaShareOwn)
const ceuciShares = metrics.map((bairro) => bairro.ceuciShareOwn)
const sollaVotes = metrics.map((bairro) => bairro.sollaVotes)
const ceuciVotes = metrics.map((bairro) => bairro.ceuciVotes)
const byClass = (key) => metrics.filter((bairro) => bairro.bairroClass.key === key)
return {
overlapCoefficient: overlapCoefficient(sollaShares, ceuciShares),
cosine: cosineSimilarity(sollaShares, ceuciShares),
pearson: pearson(sollaVotes, ceuciVotes),
spearman: spearman(sollaVotes, ceuciVotes),
sollaTop5Share: topShare(sollaShares, 5),
ceuciTop5Share: topShare(ceuciShares, 5),
sollaTop10Share: topShare(sollaShares, 10),
ceuciTop10Share: topShare(ceuciShares, 10),
sollaHhi: hhi(sollaShares),
ceuciHhi: hhi(ceuciShares),
shared: byClass('shared'),
sollaOnly: byClass('solla'),
ceuciOnly: byClass('ceuci'),
open: byClass('open'),
strongestCombined: [...metrics].sort((left, right) => right.combinedVotes - left.combinedVotes),
strongestCeuci: [...metrics].sort((left, right) => right.ceuciVotes - left.ceuciVotes),
strongestSolla: [...metrics].sort((left, right) => right.sollaVotes - left.sollaVotes),
ceuciAhead: metrics.filter((bairro) => bairro.ceuciVotes > bairro.sollaVotes),
sollaAhead: metrics.filter((bairro) => bairro.sollaVotes > bairro.ceuciVotes),
topCeuciLq: [...metrics]
.filter((bairro) => bairro.ceuciVotes >= 100)
.sort((left, right) => right.ceuciLq - left.ceuciLq),
topSollaLq: [...metrics]
.filter((bairro) => bairro.sollaVotes >= 100)
.sort((left, right) => right.sollaLq - left.sollaLq),
}
}

View file

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
averageRanks,
buildBairroMetrics,
buildBairroSummary,
buildReportSummary,
buildZoneMetrics,
classifyZone,
@ -109,4 +111,32 @@ describe('sollaCeuciSalvadorMetrics', () => {
expect(summary.salvadorShareOfCeuciState).toBeCloseTo(0.25)
expect(summary.ceuciAhead.map((zone: { zoneNumber: number }) => zone.zoneNumber)).toEqual([2])
})
it('builds per-bairro metrics with the bairro as the denominator', () => {
const bairros = [
{ name: 'A', sollaVotes: 80, ceuciVotes: 20, federalNominal: 800, stateNominal: 800 },
{ name: 'B', sollaVotes: 20, ceuciVotes: 20, federalNominal: 100, stateNominal: 100 },
{ name: 'C', sollaVotes: 0, ceuciVotes: 60, federalNominal: 100, stateNominal: 100 },
]
const totals = {
sollaVotes: 100,
ceuciVotes: 100,
federalNominal: 1000,
stateNominal: 1000,
}
const metrics = buildBairroMetrics({ bairros, totals })
expect(metrics[0].sollaLq).toBeCloseTo(1)
expect(metrics[0].ceuciLq).toBeCloseTo(0.25)
expect(metrics[0].bairroClass).toBe(ZONE_CLASSES.solla)
expect(metrics[1].bairroClass).toBe(ZONE_CLASSES.shared)
expect(metrics[2].bairroClass).toBe(ZONE_CLASSES.ceuci)
expect(metrics[2].ceuciLq).toBeCloseTo(6)
const summary = buildBairroSummary({ metrics })
expect(summary.overlapCoefficient).toBeCloseTo(0.4)
expect(summary.strongestCombined.map((bairro) => bairro.name)).toEqual(['A', 'C', 'B'])
expect(summary.ceuciAhead.map((bairro) => bairro.name)).toEqual(['C'])
expect(summary.sollaAhead.map((bairro) => bairro.name)).toEqual(['A'])
expect(summary.topCeuciLq).toEqual([])
})
})