Deliver A8 IBGE voter profile on Eleitorado tab with post-simplify scale plan.
Adds static SIDRA demographics, territory-weighted derivation, electorate UI with opt-in copy to manual profiles, shared territory-city resolution, and registers A8+ fill-in debt from capture-review-debts.
This commit is contained in:
parent
c2381cecde
commit
87c36e4856
21 changed files with 6621 additions and 92 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -47,6 +47,8 @@ next-env.d.ts
|
|||
|
||||
# IBGE geometry download cache (pnpm build:geometries)
|
||||
/data/geometries/
|
||||
# IBGE demographics download cache (pnpm build:demographics)
|
||||
/data/demographics/
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
|
|
|
|||
117
docs/plans/escala-dry-pos-a8.md
Normal file
117
docs/plans/escala-dry-pos-a8.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Escala e DRY pós-A8 (perfil IBGE Eleitorado)
|
||||
|
||||
Status: Fases 1–3 pendentes (engenharia A8 em implementação no branch)
|
||||
Atualizado em: 2026-07-20 (`capture-review-debts` pós-dois `/simplify` na entrega A8)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, fill-in **A8+** pós-A8)
|
||||
Impeccable: A — N/A (sem superfície UI; perf/integridade de dado)
|
||||
Appetite: ~0,5–1 dia eng (3 fases pequenas; PR único ou 2 PRs)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O A8 ([perfil-eleitorado-ibge.md](perfil-eleitorado-ibge.md)) entrega artefato estático `bahiaMunicipalityDemographics.ts`, derivação `getNucleusIbgeVoterProfile`, aba Eleitorado com dynamic import de `NucleusElectorateTab`, e opt-in “Usar como perfil manual”. Duas passagens `/simplify` (pré e pós-rebase em `main` com A7 F2) já limparam o que cabia em cleanup: `safeMessages` + `router.refresh`, `NucleusVoterProfileCard`, helpers `voterProfileNullsToUndefined` / `normalizedVoterProfilesForWrite`, `resolveNucleusTerritoryCities` compartilhado com `nucleusElectionGeography`, JSDoc de zone rules restaurado, formatters `Intl` em cache.
|
||||
|
||||
Os revisores (performance / reuse / quality) marcaram como **maiores que cleanup** os follow-ups abaixo. Sem registro, o cold start da aba Eleitorado e da server action de cópia continuam pagando parse do módulo ~68KB, e núcleos com município inválido na lista podem exibir perfil parcial sem aviso.
|
||||
|
||||
**Já resolvido no simplify/critique (não reabrir):** form action com `updateNucleus` + `CampaignFormActionState`; lazy import da aba; DRY card manual + normalização de write; `toNucleusElectionGeographyInput` na leitura; `NucleusElectionGeographyInput` unificado com `NucleusTerritoryCitiesInput`; `export` removido de `AGE_BAND_CATEGORY_IDS` no script; testes unit/int A8 verdes.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Cold start da aba Eleitorado e da action de cópia não avaliam `bahiaMunicipalityDemographics.ts` até `getNucleusIbgeVoterProfile` ser chamado (padrão B5 F1 / lazy geometries).
|
||||
- Agregação IBGE **não mente por omissão**: quando `matchedCities < effectiveCities`, o produto falha fechado ou sinaliza cobertura parcial de forma explícita na UI — sem PII, sem migration.
|
||||
- Artefato demographics versionado ganha soft budget de bytes nos testes int (regressão de codegen), espelhando B2 geometries.
|
||||
- Guardrails: sem migration, sem collection, sem Consent, sem HTTP SIDRA em runtime; access inalterado (staff-only).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um fill-in A8+, três fases ordenadas.** Mesmo racional de A7/C6/B5: um slug de plano, PRs por fase. Ordem: lazy load (impacto cold start) → integridade de cobertura (produto) → size budget (higiene). **Rejeitado:** item de roadmap separado `A9` — follow-up pós-entrega, não feature nova.
|
||||
- **Dependência dura de A8.** Só faz sentido com `bahiaMunicipalityDemographics.ts`, `getNucleusIbgeVoterProfile` e aba Eleitorado já mergeados; não reabre escopo SIDRA/renda/RAIS.
|
||||
- **Lazy load no deriver, não no componente.** Dynamic import da aba já isola rotas não-Eleitorado; F1 move o custo do módulo estático para dentro de `getNucleusIbgeVoterProfile` (e callers async se necessário). **Rejeitado:** segundo dynamic import só no card IBGE — duplicaria fronteira async.
|
||||
- **Cortável pós-propaganda.** Fill-in de qualidade/perf; não bloqueia Onda 0 nem propaganda 16/08 se A8 principal já estiver em prod.
|
||||
- **Cache CLI do build demographics → B5 F2**, não este plano. `scripts/build-bahia-demographics.mjs` é 4º clone de `ensureCached*`; absorvido em [escala-dry-pos-b2.md](escala-dry-pos-b2.md).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadDemographicsModule`, `demographicsCoverageStatus`); strings visíveis em pt-BR só na UI de aviso de cobertura parcial.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Fail-closed vs aviso parcial na F2?** **Opções:** (A) `status: 'semPerfil'` quando qualquer cidade falha; (B) `available` com flag `partialCoverage` + copy na UI; (C) aviso só em `notes`. **Recomendação:** (B) — coordenação ainda vê o perfil ponderado dos municípios válidos, com badge/linha “Cobertura parcial (N de M municípios)”; evita empty state enganoso sem esconder dado útil.
|
||||
- **`getNucleusIbgeVoterProfile` sync ou async após F1?** **Opções:** função `async` sempre; loader interno com `await import()` e API sync que lança se módulo não carregado. **Recomendação:** `async` + `await getNucleusIbgeVoterProfile` em `NucleusElectorateTab` (server) e na form action — poucos call sites, sem Promise em cada render de card.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A8["A8 perfil IBGE ✓/em merge"] --> F1
|
||||
F1["Fase 1 — lazy bahiaMunicipalityDemographics"]
|
||||
F1 --> F2["Fase 2 — cobertura parcial explícita"]
|
||||
F2 --> F3["Fase 3 — size budget int"]
|
||||
B5F2["B5 F2 ensureCachedDownload"] -.4º script.-> DemoCLI["build-bahia-demographics.mjs"]
|
||||
```
|
||||
|
||||
### Fase 1 — Lazy load do artefato demographics
|
||||
|
||||
- Introduzir `loadBahiaMunicipalityDemographicsModule()` (ou dynamic `import('@/lib/bahiaMunicipalityDemographics')` cacheado em `let modulePromise`) consumido por `getNucleusIbgeVoterProfile`.
|
||||
- Tornar `getNucleusIbgeVoterProfile` **async**; atualizar `NucleusElectorateTab`, `nucleusIbgeVoterProfileFormActions.ts` e testes unit.
|
||||
- Critério: import estático de `NucleusActiveTab` / rotas não-Eleitorado não puxam `bahiaMunicipalityDemographics.ts` no grafo (inspeção de bundle ou análise de imports).
|
||||
|
||||
### Fase 2 — Cobertura parcial explícita
|
||||
|
||||
- Contar `matchedCities` vs `effectiveCities` em `getNucleusIbgeVoterProfile`.
|
||||
- Se `matchedCities === 0` → `{ status: 'semPerfil' }` (inalterado).
|
||||
- Se `matchedCities < effectiveCities` → `available` com metadado de cobertura (`partialCoverage: true`, contagens) e copy pt-BR na UI (`NucleusIbgeVoterProfile` ou view model).
|
||||
- Testes unit: cidade inválida no meio de duas válidas → parcial; todas inválidas → `semPerfil`.
|
||||
|
||||
### Fase 3 — Size budget no artefato
|
||||
|
||||
- Teste int em `tests/int/bahiaMunicipalityDemographics.int.spec.ts`: `stat` de `src/lib/bahiaMunicipalityDemographics.ts` ≤ ~100 KB (margem sobre ~68 KB atuais).
|
||||
- Sem mudança de comportamento de runtime.
|
||||
|
||||
**Migration:** nenhuma nas três fases.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Dura:** A8 [perfil-eleitorado-ibge.md](perfil-eleitorado-ibge.md) — artefato + derivação + aba (merge em `main`).
|
||||
- **Suave:** B5 F1 lazy geometries — precedente de padrão, não bloqueia.
|
||||
- Reusa: `resolveNucleusTerritoryCities`, `NucleusElectorateTab` dynamic import, `tests/int/bahiaGeometries.int.spec.ts` (modelo de size budget).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- HTTP SIDRA em request path → rabbit hole A8 (perfil-eleitorado-ibge.md).
|
||||
- Pré-agregar demographics por TI no build → defer no plano-pai A8 (gatilho: region-only lento).
|
||||
- Renomear `semPerfil` para inglês → higiene score 1; descartado no triage.
|
||||
- `Promise.all` nos fetches do build script → só build-time; descartado.
|
||||
- Helper CLI `ensureCachedDownload` → **B5 F2** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)).
|
||||
- Impeccable polish classe B da aba → defer no plano-pai até marcar A8 entregue em prod.
|
||||
- Alinhar semântica IBGE vs mapa TSE (cidades vs zonas) → produto/documentação no plano-pai, não engenharia neste fill-in.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Re-exportar demographics no barrel `bahiaGeometries` ou entry de campanha.** Mitigação: import lazy **só** dentro de `nucleusIbgeVoterProfile.ts`.
|
||||
- **Collection Payload “só para cachear demographics”.** Mitigação: manter artefato estático versionado (decisão A8).
|
||||
- **Unificar perfil IBGE com baseline TSE (A3/A4).** Mitigação: fontes distintas; sem join neste fill-in.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Pré-agregação demographics por TI no build.** Revisitar quando núcleos region-only com >15 municípios mostrarem latência perceptível em prod.
|
||||
- **`formatPercent` compartilhado cross-codebase.** Revisitar no 3º consumer além de IBGE + insights.
|
||||
- **Testes unit dedicados a `resolveNucleusTerritoryCities`.** Revisitar no 3º consumer (hoje: election geography + IBGE).
|
||||
- **Semântica IBGE (todas as cidades) vs mapa (geografia TSE).** Documentado no plano-pai; revisitar se usuários reportarem footprint vazio com perfil IBGE visível.
|
||||
|
||||
## Explicitamente fora (triage `capture-review-debts` 2026-07-20)
|
||||
|
||||
- Renomear `semPerfil` → `noProfile` (pureza de naming).
|
||||
- `Promise.all` paralelo nos 28 fetches SIDRA do build (só CLI).
|
||||
- Harmonizar `revalidatePath` slug vs `[slug]` page entre form actions.
|
||||
- Impeccable critique/polish da aba Eleitorado antes de prod (defer no plano-pai A8).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha A, A8 + fill-in A8+)
|
||||
- `docs/plans/perfil-eleitorado-ibge.md` — plano pai A8
|
||||
- `docs/plans/escala-dry-pos-b2.md` — B5 F2 cache CLI (4º call site `build-bahia-demographics.mjs`)
|
||||
- `src/lib/bahiaMunicipalityDemographics.ts` — artefato ~68KB
|
||||
- `src/utilities/nucleusIbgeVoterProfile.ts` — derivação
|
||||
- `src/components/campaign/NucleusElectorateTab.tsx` — consumidor server
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIbgeVoterProfileFormActions.ts` — action de cópia
|
||||
- `scripts/build-bahia-demographics.mjs` — CLI (cache → B5 F2)
|
||||
- `tests/int/bahiaGeometries.int.spec.ts` — precedente size budget
|
||||
- AGENTS.md — naming EN/pt-BR; dado estático B2; sem PII
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-B2 (geometrias + scripts CLI)
|
||||
|
||||
Status: Fase 1 entregue com B3 (2026-07-19); Fases 2–3 pendentes
|
||||
Atualizado em: 2026-07-20 (nota cruzada `capture-review-debts` pós-A7 F2: `build-bahia-tse-city-codes.mjs` reforça gatilho F2)
|
||||
Atualizado em: 2026-07-20 (`capture-review-debts` pós-A8: `build-bahia-demographics.mjs` reforça gatilho F2 como 4º call site)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B5)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ Responsável: —
|
|||
O B2 ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 1) entregou a fundação estática do mapa: `*.topo.json` de municípios e Territórios de Identidade, `bahiaMunicipalityCodes`, helpers em `bahiaGeometries.ts`, script `pnpm build:geometries`. A passagem `/simplify` de 2026-07-18 limpou dead code e churns baratos no script/codegen, mas **deixou de fora** dois follow-ups que os revisores (performance / reuse) marcaram como importantes e maiores que cleanup pontual:
|
||||
|
||||
1. **Carregamento eager de geometrias no cliente.** `src/lib/bahiaGeometries.ts` importa os dois TopoJSON, roda `topojson-client` `feature()` em 417+27 polígonos e monta índices no load do módulo — custo de bundle/memória antes de qualquer superfície Leaflet (B3).
|
||||
2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse. **Pós-A7 F2:** `scripts/build-bahia-tse-city-codes.mjs` (`pnpm build:tse-city-codes`) clona o mesmo padrão `ensureCachedZip` — terceiro call site, reforça prioridade da Fase 2 deste plano sem novo ID.
|
||||
2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse. **Pós-A7 F2:** `scripts/build-bahia-tse-city-codes.mjs` (`pnpm build:tse-city-codes`) clona o mesmo padrão `ensureCachedZip` — terceiro call site. **Pós-A8 (`capture-review-debts` 2026-07-20):** `scripts/build-bahia-demographics.mjs` (`pnpm build:demographics`) clona `ensureCachedJson` — quarto call site; reforça prioridade da Fase 2 sem novo ID de roadmap.
|
||||
3. **Factory duplicada mun/TI pós-B3 F1.** `bahiaMunicipalityGeometries.ts` e `bahiaTerritoryGeometries.ts` repetem o mesmo pipeline `topojson.feature` → array → `Map` por chave → export do módulo indexado — só mudam topology object, propriedade de chave (`codarea` vs `code`) e getter.
|
||||
|
||||
Este plano é o registro canônico desses follow-ups. Sem ele, o B3 pode embarcar o mapa com payload desnecessário no path default, e o próximo script de dado aberto volta a clonar o cache.
|
||||
|
|
@ -63,7 +63,7 @@ flowchart TD
|
|||
- Extrair `ensureCachedDownload({ cacheDir, key, url, ext?, logPrefix })` a partir de `ensureCachedJson` / `ensureCachedZip`.
|
||||
- Retorno: `{ url, buffer, hash }` (parse JSON/ZIP fica no caller — `build-bahia-geometries` já faz `JSON.parse`; seed TSE usa `readZipEntry`).
|
||||
- Continuar usando `downloadToBuffer` (`src/lib/electionResultsZip.ts`).
|
||||
- Migrar `scripts/build-bahia-geometries.mjs`, `scripts/seed-tse-results.mjs` e `scripts/build-bahia-tse-city-codes.mjs`; sem mudança de comportamento nem de pastas `data/geometries/` / `data/tse/`.
|
||||
- Migrar `scripts/build-bahia-geometries.mjs`, `scripts/seed-tse-results.mjs`, `scripts/build-bahia-tse-city-codes.mjs` e `scripts/build-bahia-demographics.mjs`; sem mudança de comportamento nem de pastas `data/geometries/` / `data/tse/` / `data/demographics/`.
|
||||
|
||||
### Fase 3 — Factory compartilhada mun/TI
|
||||
|
||||
|
|
@ -98,6 +98,7 @@ flowchart TD
|
|||
- `src/lib/bahiaMunicipalityGeometries.ts` / `bahiaTerritoryGeometries.ts` — near-duplicate pós-F1
|
||||
- `src/lib/bahiaGeometriesTypes.ts` — tipos `*GeometryModule`
|
||||
- `scripts/build-bahia-geometries.mjs` — `ensureCachedJson`
|
||||
- `scripts/build-bahia-demographics.mjs` — `ensureCachedJson` (4º call site; A8)
|
||||
- `scripts/seed-tse-results.mjs` — `ensureCachedZip`
|
||||
- `src/lib/electionResultsZip.ts` — `downloadToBuffer`
|
||||
- AGENTS.md — naming inglês; Bahia implícita; dado estático versionado
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# Perfis do eleitorado — perfil médio IBGE + manuais
|
||||
|
||||
Status: rascunho
|
||||
Status: em implementação
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A8)
|
||||
Responsável: —
|
||||
Appetite: ~1–1,5 dia eng (artefato estático + derivação + UI encaixe + opt-in de cópia)
|
||||
Impeccable: classe B (UI encaixada na aba Eleitorado existente)
|
||||
|
||||
## Contexto
|
||||
|
||||
|
|
@ -15,33 +17,29 @@ Já temos o join estável município → código IBGE 7 dígitos em `src/lib/bah
|
|||
|
||||
## Objetivos
|
||||
|
||||
- Importar (CLI, estático) indicadores demográficos municipais BA suficientes para montar um “perfil médio” legível (idade dominante, sexo, renda, escolaridade/ocupação quando a tabela cobrir).
|
||||
- Importar (CLI, estático) indicadores demográficos municipais BA suficientes para montar um “perfil médio” legível (faixa etária dominante, sexo, idade mediana).
|
||||
- Derivar em leitura o perfil do núcleo a partir de `cities[]` (média ponderada pela população quando houver >1 município; só `regions` → união das cidades do TI via `citiesForTerritory`).
|
||||
- Exibir o perfil calculado na aba Eleitorado, claramente marcado como **Calculado (IBGE)**, separado dos manuais.
|
||||
- Manter CRUD de `voterProfiles` manuais; opcionalmente oferecer “Adicionar como perfil” (copia o calculado para o array, editável depois).
|
||||
- Manter CRUD de `voterProfiles` manuais; botão opt-in “Usar como perfil manual” (copia o calculado para o array, editável depois).
|
||||
- Guardrails: dado público agregado (sem PII, sem `Consent`); access só staff (mesmo escopo da aba Eleitorado hoje); sem chamada HTTP ao IBGE em runtime de request; `overrideAccess: false` nas leituras de núcleo.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Perfil calculado é derivado em leitura, não sobrescreve `voterProfiles`.** Persistir no array misturaria fonte IBGE com edição humana e apagaria trabalho do coordenador em re-seed. O cartão IBGE é view model; o array continua 100% manual.
|
||||
- **Dado estático versionado no repo** (padrão B2/`bahiaTseZones`/`bahiaMunicipalityCodes`), não collection Payload nem fetch SIDRA por request. Script re-executável baixa Agregados/SIDRA (ou CSV oficial), emite `src/lib/bahiaMunicipalityDemographics.ts` (+ fixture). Sem migration, sem `Consent`, sem revalidate (páginas de campanha dinâmicas).
|
||||
- **Dado estático versionado no repo** (padrão B2/`bahiaTseZones`/`bahiaMunicipalityCodes`), não collection Payload nem fetch SIDRA por request. Script re-executável baixa SIDRA, emite `src/lib/bahiaMunicipalityDemographics.ts` (+ fixture). Sem migration, sem `Consent`, sem revalidate (páginas de campanha dinâmicas).
|
||||
- **Granularidade município.** Núcleo indexa `cities[]`; bairro/seção não têm série IBGE estável alinhada ao app. Multi-cidade = ponderação por população residente do Censo.
|
||||
- **Sem geografia → estado “sem perfil IBGE”** (mesmo espírito do baseline TSE sem território).
|
||||
- **v1 cobre BA, Censo 2022** (e tabelas de rendimento/trabalho já divulgadas no mesmo ciclo). Séries antigas (2010) e PNAD contínua ficam fora.
|
||||
- **v1 cobre BA, Censo 2022** — tabelas SIDRA **9514** (população por sexo/idade) + **9515** (idade mediana). Faixas agregadas: `0–17` / `18–29` / `30–59` / `60+`. `incomeBand` / `occupation` / cor-raça **fora** até haver tabela municipal estável no Agregados.
|
||||
- **Staff-only na v1** — aba Eleitorado continua redirecionando `lideranca` para overview.
|
||||
- **UI:** números + 1 linha de síntese template (constantes versionadas), sem LLM.
|
||||
- **Copiar para `voterProfiles`:** botão opt-in “Usar como perfil manual” via Server Action de append; nunca auto-append no load.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`bahiaMunicipalityDemographics`, `getNucleusIbgeVoterProfile`, `ComputedVoterProfileViewModel`); strings visíveis em pt-BR (“Perfil médio do território”, “Calculado (IBGE Censo 2022)”).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Quais indicadores entram no cartão v1?** Faixa etária modal + % sexo + renda domiciliar per capita (ou faixa) + cor/raça dominante são o mínimo útil e mapam bem aos campos já existentes (`ageRange`/`incomeBand`). Ocupação formal (RAIS) é outra fonte. **Recomendação:** v1 = população + pirâmide etária resumida (3–4 faixas) + sexo + rendimento; `occupation`/`localTraits` só no manual ou numa linha “destaque” textual gerada se houver variável estável; definir tabelas SIDRA exatas na implementação e documentar no cabeçalho do módulo.
|
||||
- **`lideranca` vê o perfil IBGE?** Hoje a aba Eleitorado redireciona `lideranca` para overview. **Recomendação:** manter staff-only na v1 (dado agregado público, mas a superfície de inteligência já é staff); reavaliar com produto se quiserem kit de campo para liderança.
|
||||
- **Texto gerado vs. só números?** Um parágrafo (“Predominam adultos 30–59, renda …”) ajuda o empty state; números sozinhos evitam tom “científico” falso. **Recomendação:** números + 1 linha de síntese template (constantes versionadas), sem LLM.
|
||||
- **Copiar para `voterProfiles`?** **Recomendação:** botão opt-in “Usar como perfil manual” na aba (Server Action que faz append de um item pré-preenchido); nunca auto-append no load.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SIDRA["IBGE Agregados / SIDRA<br/>Censo 2022 BA N6"]
|
||||
SIDRA["IBGE SIDRA<br/>9514 + 9515 N6 BA"]
|
||||
Script["scripts/build-bahia-demographics.mjs<br/>pnpm build:demographics"]
|
||||
Static["bahiaMunicipalityDemographics.ts<br/>codarea → indicadores"]
|
||||
Nuc["electoralNucleus.cities[]"]
|
||||
|
|
@ -57,19 +55,35 @@ flowchart LR
|
|||
|
||||
Componentes:
|
||||
|
||||
- **`bahiaMunicipalityDemographics`** (`src/lib/bahiaMunicipalityDemographics.ts`): mapa `codarea` → `{ population, ageBands, sexShare, incomeBand, … }` + helpers `demographicsForCode` / `demographicsForMunicipalityName`. Cabeçalho com URL + SHA-256 dos downloads (padrão `bahiaMunicipalityCodes`).
|
||||
- **`getNucleusIbgeVoterProfile`** (`src/utilities/nucleusIbgeVoterProfile.ts`, `server-only`): entrada `{ cities, regions }` → `ComputedVoterProfileViewModel | { status: 'semPerfil' }`. Resolve cidades efetivas (`cities` ou `citiesForTerritory` de `bahiaTerritories.ts`); pondera indicadores; formata `label` fixo (“Perfil médio do território”) + campos alinhados ao schema manual para a UI e para o opt-in de cópia.
|
||||
- **`ElectorateContent`** (`NucleusActiveTab.tsx`): acima dos manuais, card `NucleusIbgeVoterProfile` (novo, PascalCase) com badge “Calculado (IBGE)”; empty state só quando não há calculado **nem** manuais; atualizar copy (“nenhum perfil cadastrado” → distinguir ausência de IBGE vs. ausência de manuais).
|
||||
- **`NucleusIntelligenceDialog`**: sem mudança obrigatória no schema; opcionalmente botão/ação “Adicionar perfil a partir do IBGE” que pré-preenche um `EditableProfile` no estado cliente (ou Server Action de append).
|
||||
- **Script** `scripts/build-bahia-demographics.mjs` (`pnpm build:demographics`): baixa Agregados para os 417 municípios BA; reusa `downloadToBuffer` / cache sob `data/demographics/` (gitignored); **não toca banco** (sem `assertLocalDatabase`). Fora de `pnpm build` / `pnpm dev`.
|
||||
- **Testes:** unit do ponderador (1 cidade, 2 cidades, sem geografia, município sem row); int de cobertura 417 `codarea` vs `bahiaMunicipalityCodes` + fixture `tests/fixtures/bahia-municipality-demographics.official.json`.
|
||||
- **Migration:** nenhuma. Sem collection nova. Sem Consent. Server Action só se o opt-in de cópia for mutação dedicada (senão reusa o update de inteligência existente).
|
||||
- **`bahiaMunicipalityDemographics`** (`src/lib/bahiaMunicipalityDemographics.ts`): mapa `codarea` → `{ population, ageBands, sexShareFemale, medianAge }` + helpers `demographicsForCode` / `demographicsForMunicipalityName`. Cabeçalho com URL + SHA-256 dos downloads (padrão `bahiaMunicipalityCodes`).
|
||||
- **`getNucleusIbgeVoterProfile`** (`src/utilities/nucleusIbgeVoterProfile.ts`): entrada `{ cities, regions }` → `ComputedVoterProfileViewModel | { status: 'semPerfil' }`. Resolve cidades efetivas (`cities` ou `citiesForTerritory`); pondera indicadores; formata `label` fixo (“Perfil médio do território”) + campos alinhados ao schema manual para a UI e para o opt-in de cópia.
|
||||
- **`ElectorateContent`** (`NucleusActiveTab.tsx`): acima dos manuais, card `NucleusIbgeVoterProfile` com badge “Calculado (IBGE)”; empty state só quando não há calculado **nem** manuais.
|
||||
- **Script** `scripts/build-bahia-demographics.mjs` (`pnpm build:demographics`): baixa SIDRA para os 417 municípios BA; cache `data/demographics/` (gitignored); **não toca banco**. Fora de `pnpm build` / `pnpm dev`.
|
||||
- **Testes:** unit do ponderador; int de cobertura 417 `codarea` vs `bahiaMunicipalityCodes` + fixture `tests/fixtures/bahia-municipality-demographics.official.json`.
|
||||
- **Migration:** nenhuma.
|
||||
|
||||
## Rabbit holes (não tocar)
|
||||
|
||||
- HTTP SIDRA em request path
|
||||
- Microdados / perfilamento individual
|
||||
- Abrir Eleitorado para `lideranca`
|
||||
- Camada demográfica no mapa Leaflet (B3/B4)
|
||||
- Acoplar loaders A3/A4/TSE
|
||||
- Collection Payload / migration “só por precaução”
|
||||
- RAIS/PNAD / Censo 2010 / renda sem tabela municipal estável
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Pré-agregar demographics por TI no build** (`demographicsForTerritory`). Revisitar quando núcleos só-`regions` com >15 municípios mostrarem latência perceptível em prod.
|
||||
- **Semântica IBGE vs mapa TSE.** Perfil IBGE usa todas as cidades do território (`resolveNucleusTerritoryCities`); o coroplético/mapa usa geografia TSE com zonas (`resolveNucleusElectionGeography`) — footprint vazio com perfil IBGE visível é possível. Revisitar se coordenação reportar confusão; copy de empty state ou nota na UI, não mudança de fonte.
|
||||
- **Impeccable polish classe B** (shape/critique/polish da aba Eleitorado). Revisitar antes de marcar A8 entregue em prod com dados reais.
|
||||
- **Follow-ups de engenharia pós-`/simplify`** (lazy load ~68KB, cobertura parcial, size budget) → [escala-dry-pos-a8.md](escala-dry-pos-a8.md) (fill-in **A8+**).
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Dura:** A1 Território multi-município/bairro ✓ — `cities[]` / `regions[]` e `citiesForTerritory`.
|
||||
- **Suave:** B2 ✓ — `bahiaMunicipalityCodes` / `codeForMunicipality` (sem isso o script teria que reimplementar o join nome→`codarea`).
|
||||
- **Nenhuma dependência de A3/A4** — fonte IBGE ≠ TSE; o baseline de votos e o perfil demográfico convivem na mesma ficha sem acoplamento de dados.
|
||||
- **Suave:** B2 ✓ — `bahiaMunicipalityCodes` / `codeForMunicipality`.
|
||||
- **Nenhuma dependência de A3/A4** — fonte IBGE ≠ TSE.
|
||||
- Reusa: `voterProfiles` + `NucleusIntelligenceDialog` + aba Eleitorado staff-only; padrão de artefato estático de B2.
|
||||
|
||||
## Não escopo
|
||||
|
|
@ -77,13 +91,13 @@ Componentes:
|
|||
- Microdados IBGE / perfilamento de indivíduos (LGPD; fora de escopo absoluto).
|
||||
- Perfil por bairro/seção (sem série alinhada; E5 cobre Salvador por bairro no eixo eleitoral TSE, não demográfico).
|
||||
- Insights de voto A5 (conversão, classificação, alavancagem…) — planos `insight-*.md`.
|
||||
- Camada demográfica no mapa Leaflet (B3) — eventual follow-up se o coroplético quiser densidade/renda.
|
||||
- PNAD contínua, RAIS/CAGED, Estimativas anuais pós-2022 — fase futura se o Censo 2022 cobrir o ciclo.
|
||||
- Camada demográfica no mapa Leaflet (B3) — eventual follow-up.
|
||||
- PNAD contínua, RAIS/CAGED, Estimativas anuais pós-2022 — fase futura.
|
||||
- Abrir a aba Eleitorado para `lideranca` (mudança de access à parte).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha A, item A8; Janela 3)
|
||||
- `docs/roadmap.md` (Trilha A, item A8; Janela 2)
|
||||
- `src/collections/ElectoralNucleus.ts` — campo `voterProfiles`
|
||||
- `src/components/campaign/NucleusActiveTab.tsx` — `ElectorateContent` (staff-only)
|
||||
- `src/components/campaign/NucleusIntelligenceDialog.tsx` — editor manual
|
||||
|
|
@ -92,6 +106,6 @@ Componentes:
|
|||
- `src/lib/bahiaTerritories.ts` — `citiesForTerritory`
|
||||
- `docs/plans/mapa-bahia-geometrias.md` — precedente de artefato estático + script CLI
|
||||
- `docs/plans/baseline-eleitoral-tse.md` — precedente de dado público agregado (TSE; fonte distinta)
|
||||
- IBGE API Agregados: https://servicodados.ibge.gov.br/api/v3/agregados
|
||||
- `docs/plans/escala-dry-pos-a8.md` — fill-in A8+ (lazy demographics, cobertura parcial, size budget)
|
||||
- IBGE SIDRA: https://apisidra.ibge.gov.br/
|
||||
- AGENTS.md — naming EN/pt-BR; Campaign auth; sem PII; padrão estático B2
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-19 (A5 mobilização ✓ em `main`; A5 aberto: só inteligência competitiva)
|
||||
Atualizado em: 2026-07-20 (A8+ fill-in pós-`/simplify`; A8 em implementação)
|
||||
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
|
|
@ -49,6 +49,7 @@ Engenharia de núcleos / C2 / C3 / baseline / PWA / Trilha E parcial já em `mai
|
|||
- **A** — A6 dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · [plano](plans/insight-dobradinha-2026.md)
|
||||
- **A** — A7 F2–F5: `cityCode` TSE, Alert/Progress DRY, fetch 2022 único lista+mapa, batch flip na lista · gatilho F4: pós-B3 ✓; F5: pós-A5 · [plano](plans/escala-dry-pos-a4.md)
|
||||
- **A** — A8 perfil médio do eleitorado (IBGE) na aba Eleitorado sem sobrescrever manuais · [plano](plans/perfil-eleitorado-ibge.md)
|
||||
- **A** — A8+ escala pós-A8: lazy demographics, cobertura parcial, size budget · gatilho: merge A8 · [plano](plans/escala-dry-pos-a8.md)
|
||||
- **B** — B4 camada de zonas TSE no mapa coroplético · [mapa](plans/mapa-bahia-geometrias.md)
|
||||
- **B** — B5 F2–F3: cache CLI compartilhado + factory mun/TI · [plano](plans/escala-dry-pos-b2.md)
|
||||
- **B** — B6 `BahiaMap` setStyle incremental ao trocar métrica · gatilho: troca frequente no overview · [plano](plans/escala-dry-pos-b3.md)
|
||||
|
|
@ -96,6 +97,7 @@ flowchart TD
|
|||
A7["A7 F2–F5 escala pós-A4"]
|
||||
A6["A6 Dobradinha 2026"]
|
||||
A8["A8 Perfis IBGE"]
|
||||
A8DRY["A8+ escala pós-A8"]
|
||||
end
|
||||
|
||||
subgraph TrilhaB["Trilha B"]
|
||||
|
|
@ -131,6 +133,7 @@ flowchart TD
|
|||
A4 --> A5open
|
||||
A5done -.mesmo loader.-> A7
|
||||
A4 --> A6
|
||||
A8 -.após merge.-> A8DRY
|
||||
TSE2026["TSE candidaturas 2026<br/>(após 15/08)"] --> A6
|
||||
B3 --> B6
|
||||
B3 --> B4
|
||||
|
|
@ -188,6 +191,7 @@ flowchart TD
|
|||
### Fill-ins abertos
|
||||
|
||||
- **O0+** — revalidate globals, módulo de chaves Consent, testes SQL, DRY Lexical/layout · [plano](plans/escala-dry-pos-onda0.md)
|
||||
- **A8+** — lazy `bahiaMunicipalityDemographics`, cobertura parcial explícita, size budget int · gatilho: A8 ✓ · [plano](plans/escala-dry-pos-a8.md)
|
||||
- **VR+** — refresh bfcache/storage do painel recentes + DRY de lista · gatilho F1: painel stale · [plano](plans/escala-dry-pos-visitados-recentemente.md)
|
||||
- **RS+** — auth read leve + shells/campos de senha compartilhados · gatilho F1: `/campanha` lento sob carga · [plano](plans/escala-dry-pos-reset-senha-perfil.md)
|
||||
- **FD+** — shell size / heading / strips Field Desk · gatilho strips: 3ª strip no home ou Fase 3 VoteGoals · [plano](plans/escala-dry-pos-field-desk.md)
|
||||
|
|
@ -207,7 +211,7 @@ Plano histórico: [mapa-projecao-municipios.md](plans/mapa-projecao-municipios.m
|
|||
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent), C2 dados reais, C3 agenda (já ✓), A4 baseline (já ✓), E1+E3 paridade mínima com a planilha (já ✓) — risco legal, base nominal, operação e instrumento de alocação.
|
||||
|
||||
**Cortes seguros** (se o prazo apertar): E5; B4; B6; B5 F2–F3; A8; E7 F1/F3/F4; E6 F2–F4 (preferir F1 se lista lenta); A7 F2–F5 (F4 vale mais com mapa na lista; F5 se filtros ≤10 núcleos); A5 competitiva; C4; D2 push (manter sino); C10/C11 se base/agenda pequenas; O0+/VR+/RS+/FD+ fases cosméticas; FD2 Fase 5 motion; fill-ins.
|
||||
**Cortes seguros** (se o prazo apertar): E5; B4; B6; B5 F2–F3; A8; **A8+**; E7 F1/F3/F4; E6 F2–F4 (preferir F1 se lista lenta); A7 F2–F5 (F4 vale mais com mapa na lista; F5 se filtros ≤10 núcleos); A5 competitiva; C4; D2 push (manter sino); C10/C11 se base/agenda pequenas; O0+/VR+/RS+/FD+ fases cosméticas; FD2 Fase 5 motion; fill-ins.
|
||||
|
||||
## Bloqueadores atuais
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
|
||||
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
|
||||
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
|
||||
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
|
||||
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
|
||||
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
|
||||
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
|
|
|
|||
299
scripts/build-bahia-demographics.mjs
Normal file
299
scripts/build-bahia-demographics.mjs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* Builds static Bahia municipality demographics from IBGE SIDRA (Censo 2022).
|
||||
*
|
||||
* Provenance:
|
||||
* - SIDRA table 9514 — População residente, por sexo, idade e forma de declaração da idade (2022, N6):
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022
|
||||
* - SIDRA table 9515 — Idade mediana (2022, N6):
|
||||
* https://apisidra.ibge.gov.br/values/t/9515/n3/29/n6/all/v/10613/p/2022
|
||||
*
|
||||
* Safety: does not touch any database — only downloads public SIDRA JSON and writes
|
||||
* versioned artifacts under src/lib/ and tests/fixtures/.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm build:demographics
|
||||
* DEMOGRAPHICS_CACHE_DIR=./data/demographics pnpm build:demographics
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const { bahiaMunicipalityCodes } = await import('../src/lib/bahiaMunicipalityCodes.ts')
|
||||
const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts')
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const SOURCES = {
|
||||
femalePopulation: {
|
||||
key: 'sidra-9514-ba-municipios-mulheres',
|
||||
url: 'https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/5/c287/100362',
|
||||
},
|
||||
medianAge: {
|
||||
key: 'sidra-9515-ba-municipios-idade-mediana',
|
||||
url: 'https://apisidra.ibge.gov.br/values/t/9515/n3/29/n6/all/v/10613/p/2022',
|
||||
},
|
||||
}
|
||||
|
||||
/** SIDRA classification 287 (Idade) category ids grouped for the app bands. */
|
||||
const AGE_BAND_CATEGORY_IDS = {
|
||||
'0-17': [93070, 93084, 93085, 6572, 6573, 6574],
|
||||
'18-29': [6575, 6576, 6577, 6578, 6579, 6580, 6581, 93088],
|
||||
'30-59': [93089, 93090, 93091, 93092, 93093, 93094],
|
||||
'60+': [93095, 93096, 93097, 93098, 49108, 49109, 60040, 60041, 6653],
|
||||
}
|
||||
|
||||
const AGE_CATEGORY_TO_BAND = Object.fromEntries(
|
||||
Object.entries(AGE_BAND_CATEGORY_IDS).flatMap(([band, ids]) =>
|
||||
ids.map((id) => [String(id), band]),
|
||||
),
|
||||
)
|
||||
|
||||
const die = (message) => {
|
||||
console.error(`\n[build:demographics] ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
const cacheDir = () => process.env.DEMOGRAPHICS_CACHE_DIR || join(ROOT, 'data', 'demographics')
|
||||
|
||||
const ensureCachedJson = async ({ key, url }) => {
|
||||
const dir = cacheDir()
|
||||
await mkdir(dir, { recursive: true })
|
||||
const path = join(dir, `${key}.json`)
|
||||
try {
|
||||
await access(path)
|
||||
console.log(`[build:demographics] cache hit ${path}`)
|
||||
const buffer = await readFile(path)
|
||||
return { url, hash: sha256(buffer), json: JSON.parse(buffer.toString('utf8')) }
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
console.log(`[build:demographics] downloading ${url}`)
|
||||
const buffer = await downloadToBuffer(url)
|
||||
const hash = sha256(buffer)
|
||||
await writeFile(path, buffer)
|
||||
console.log(`[build:demographics] saved ${path} (${buffer.length} bytes, sha256=${hash})`)
|
||||
return { url, hash, json: JSON.parse(buffer.toString('utf8')) }
|
||||
}
|
||||
|
||||
const writeText = async (relativePath, body) => {
|
||||
const path = join(ROOT, relativePath)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, body)
|
||||
console.log(`[build:demographics] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
|
||||
}
|
||||
|
||||
const parseSidraValue = (value) => {
|
||||
if (value === '...' || value === '-' || value === 'X' || value == null || value === '') return 0
|
||||
const parsed = Number.parseInt(String(value).replace(/\./g, ''), 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
const municipalityRows = (rows) =>
|
||||
rows.filter((row) => row?.NC === '6' && String(row?.D1C ?? '').startsWith('29'))
|
||||
|
||||
const buildDemographicsByCode = ({ ageCategoryRows, femaleRows, medianRows }) => {
|
||||
const byCode = new Map()
|
||||
|
||||
for (const { categoryId, rows } of ageCategoryRows) {
|
||||
const band = AGE_CATEGORY_TO_BAND[String(categoryId)]
|
||||
if (!band) continue
|
||||
|
||||
for (const row of municipalityRows(rows)) {
|
||||
const code = String(row.D1C)
|
||||
const current = byCode.get(code) ?? {
|
||||
population: 0,
|
||||
ageBands: { '0-17': 0, '18-29': 0, '30-59': 0, '60+': 0 },
|
||||
femalePopulation: 0,
|
||||
medianAge: null,
|
||||
}
|
||||
current.ageBands[band] += parseSidraValue(row.V)
|
||||
byCode.set(code, current)
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of municipalityRows(femaleRows)) {
|
||||
const code = String(row.D1C)
|
||||
const current = byCode.get(code)
|
||||
if (!current) continue
|
||||
current.femalePopulation = parseSidraValue(row.V)
|
||||
}
|
||||
|
||||
for (const row of municipalityRows(medianRows)) {
|
||||
const code = String(row.D1C)
|
||||
const current = byCode.get(code)
|
||||
if (!current) continue
|
||||
const median = Number.parseFloat(String(row.V).replace(',', '.'))
|
||||
current.medianAge = Number.isFinite(median) ? median : null
|
||||
}
|
||||
|
||||
for (const [code, record] of byCode.entries()) {
|
||||
record.population = Object.values(record.ageBands).reduce((sum, value) => sum + value, 0)
|
||||
record.sexShareFemale =
|
||||
record.population > 0 ? record.femalePopulation / record.population : 0
|
||||
delete record.femalePopulation
|
||||
}
|
||||
|
||||
return byCode
|
||||
}
|
||||
|
||||
const buildModuleSource = (demographicsByCode, provenance) => {
|
||||
const entries = [...demographicsByCode.entries()].sort(([left], [right]) => left.localeCompare(right))
|
||||
const lines = entries
|
||||
.map(([code, record]) => {
|
||||
const bands = Object.entries(record.ageBands)
|
||||
.map(([band, value]) => `${JSON.stringify(band)}: ${value}`)
|
||||
.join(', ')
|
||||
const median =
|
||||
record.medianAge == null ? 'null' : JSON.stringify(record.medianAge)
|
||||
const sexShare = record.sexShareFemale.toFixed(6)
|
||||
return ` ${JSON.stringify(code)}: { population: ${record.population}, ageBands: { ${bands} }, sexShareFemale: ${sexShare}, medianAge: ${median} },`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `/**
|
||||
* IBGE Censo 2022 municipality demographics for Bahia (codarea → indicators).
|
||||
*
|
||||
* Source (SIDRA table 9514 — população por idade, UF BA N6; accessed ${provenance.accessed}):
|
||||
* ${provenance.ageCategoryUrls.join('\n * ')}
|
||||
* Download SHA-256: ${provenance.ageCategorySha256.join(', ')}
|
||||
*
|
||||
* Source (SIDRA table 9514 — população feminina total, UF BA N6; accessed ${provenance.accessed}):
|
||||
* ${provenance.femaleUrl}
|
||||
* Download SHA-256: ${provenance.femaleSha256}
|
||||
*
|
||||
* Source (SIDRA table 9515 — idade mediana, UF BA N6; accessed ${provenance.accessed}):
|
||||
* ${provenance.medianUrl}
|
||||
* Download SHA-256: ${provenance.medianSha256}
|
||||
*
|
||||
* Age bands aggregate SIDRA classification 287 categories documented in scripts/build-bahia-demographics.mjs.
|
||||
* Coverage is independently checked by tests/fixtures/bahia-municipality-demographics.official.json.
|
||||
*/
|
||||
|
||||
import { codeForMunicipality } from '@/lib/bahiaMunicipalityCodes'
|
||||
|
||||
export type MunicipalityAgeBandKey = '0-17' | '18-29' | '30-59' | '60+'
|
||||
|
||||
export type MunicipalityDemographics = {
|
||||
population: number
|
||||
ageBands: Record<MunicipalityAgeBandKey, number>
|
||||
sexShareFemale: number
|
||||
medianAge: number | null
|
||||
}
|
||||
|
||||
export const bahiaMunicipalityDemographics: Readonly<Record<string, MunicipalityDemographics>> = {
|
||||
${lines}
|
||||
}
|
||||
|
||||
export const demographicsForCode = (codarea: string): MunicipalityDemographics | undefined =>
|
||||
bahiaMunicipalityDemographics[codarea]
|
||||
|
||||
export const demographicsForMunicipalityName = (
|
||||
municipality: string,
|
||||
): MunicipalityDemographics | undefined => {
|
||||
const code = codeForMunicipality(municipality)
|
||||
return code ? demographicsForCode(code) : undefined
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
const buildFixture = (demographicsByCode) => {
|
||||
const assignments = [...demographicsByCode.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([code, record]) => ({
|
||||
code,
|
||||
population: record.population,
|
||||
ageBands: record.ageBands,
|
||||
sexShareFemale: Number(record.sexShareFemale.toFixed(6)),
|
||||
medianAge: record.medianAge,
|
||||
}))
|
||||
|
||||
const evidenceRows = assignments
|
||||
.map(
|
||||
({ code, population, ageBands, sexShareFemale, medianAge }) =>
|
||||
`D\t${code}\t${population}\t${ageBands['0-17']}\t${ageBands['18-29']}\t${ageBands['30-59']}\t${ageBands['60+']}\t${sexShareFemale}\t${medianAge ?? ''}\n`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
return {
|
||||
municipalityCount: assignments.length,
|
||||
assignments,
|
||||
evidenceSha256: sha256(Buffer.from(evidenceRows, 'utf8')),
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const accessed = new Date().toISOString().slice(0, 10)
|
||||
const uniqueCategoryIds = [
|
||||
...new Set(Object.values(AGE_BAND_CATEGORY_IDS).flatMap((ids) => ids)),
|
||||
].sort((left, right) => left - right)
|
||||
|
||||
const ageCategoryDownloads = []
|
||||
for (const categoryId of uniqueCategoryIds) {
|
||||
const download = await ensureCachedJson({
|
||||
key: `sidra-9514-ba-idade-${categoryId}`,
|
||||
url: `https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/${categoryId}`,
|
||||
})
|
||||
ageCategoryDownloads.push({ categoryId, ...download })
|
||||
}
|
||||
|
||||
const female = await ensureCachedJson(SOURCES.femalePopulation)
|
||||
const median = await ensureCachedJson(SOURCES.medianAge)
|
||||
|
||||
const demographicsByCode = buildDemographicsByCode({
|
||||
ageCategoryRows: ageCategoryDownloads.map(({ categoryId, json }) => ({
|
||||
categoryId,
|
||||
rows: json,
|
||||
})),
|
||||
femaleRows: female.json,
|
||||
medianRows: median.json,
|
||||
})
|
||||
|
||||
const expectedCodes = new Set(Object.values(bahiaMunicipalityCodes))
|
||||
if (demographicsByCode.size !== expectedCodes.size) {
|
||||
die(
|
||||
`expected ${expectedCodes.size} municipality rows, got ${demographicsByCode.size} from SIDRA`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const code of expectedCodes) {
|
||||
if (!demographicsByCode.has(code)) {
|
||||
die(`missing demographics for codarea ${code}`)
|
||||
}
|
||||
const record = demographicsByCode.get(code)
|
||||
if (record.population <= 0) {
|
||||
die(`non-positive population for codarea ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
const provenance = {
|
||||
accessed,
|
||||
ageCategoryUrls: ageCategoryDownloads.map(({ url }) => url),
|
||||
ageCategorySha256: ageCategoryDownloads.map(({ hash }) => hash),
|
||||
femaleUrl: female.url,
|
||||
femaleSha256: female.hash,
|
||||
medianUrl: median.url,
|
||||
medianSha256: median.hash,
|
||||
}
|
||||
|
||||
await writeText(
|
||||
'src/lib/bahiaMunicipalityDemographics.ts',
|
||||
buildModuleSource(demographicsByCode, provenance),
|
||||
)
|
||||
await writeText(
|
||||
'tests/fixtures/bahia-municipality-demographics.official.json',
|
||||
`${JSON.stringify(buildFixture(demographicsByCode), null, 2)}\n`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[build:demographics] done — ${demographicsByCode.size} municipalities, total population ${[...demographicsByCode.values()].reduce((sum, row) => sum + row.population, 0)}`,
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus'
|
||||
import { requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import {
|
||||
type CampaignFormActionState,
|
||||
mapCampaignFormActionError,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
|
||||
import {
|
||||
getNucleusIbgeVoterProfile,
|
||||
IBGE_VOTER_PROFILE_LABEL,
|
||||
} from '@/utilities/nucleusIbgeVoterProfile'
|
||||
import { voterProfileNullsToUndefined } from '@/utilities/nucleusIntelligenceUi'
|
||||
|
||||
export type AppendIbgeVoterProfileFormState = CampaignFormActionState
|
||||
|
||||
const safeMessages = [
|
||||
'Somente a coordenação pode editar a inteligência do núcleo.',
|
||||
'Não há perfil IBGE disponível para este território.',
|
||||
'Já existe um perfil manual com este rótulo.',
|
||||
] as const
|
||||
|
||||
const errorState = (error: unknown): AppendIbgeVoterProfileFormState =>
|
||||
mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages,
|
||||
genericMessage: 'Não foi possível adicionar o perfil IBGE. Tente novamente.',
|
||||
})
|
||||
|
||||
export const appendIbgeVoterProfileFormAction = async (
|
||||
_state: AppendIbgeVoterProfileFormState,
|
||||
formData: FormData,
|
||||
): Promise<AppendIbgeVoterProfileFormState> => {
|
||||
try {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
if (actor.role === 'lideranca') {
|
||||
throw new Error('Somente a coordenação pode editar a inteligência do núcleo.')
|
||||
}
|
||||
|
||||
const nucleusId = requiredRelationshipFormValue(formData, 'nucleus')
|
||||
const nucleus = await payload.findByID({
|
||||
collection: 'electoralNucleus',
|
||||
id: nucleusId,
|
||||
depth: 0,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
select: {
|
||||
slug: true,
|
||||
cities: true,
|
||||
regions: true,
|
||||
voterProfiles: {
|
||||
label: true,
|
||||
ageRange: true,
|
||||
incomeBand: true,
|
||||
occupation: true,
|
||||
localTraits: true,
|
||||
notes: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const computed = getNucleusIbgeVoterProfile(toNucleusElectionGeographyInput(nucleus))
|
||||
|
||||
if (computed.status !== 'available') {
|
||||
throw new Error('Não há perfil IBGE disponível para este território.')
|
||||
}
|
||||
|
||||
const existingProfiles = (nucleus.voterProfiles ?? []).map(voterProfileNullsToUndefined)
|
||||
if (existingProfiles.some((profile) => profile.label === IBGE_VOTER_PROFILE_LABEL)) {
|
||||
throw new Error('Já existe um perfil manual com este rótulo.')
|
||||
}
|
||||
|
||||
const updated = await updateNucleus({
|
||||
id: nucleusId,
|
||||
voterProfiles: [
|
||||
...existingProfiles,
|
||||
{
|
||||
label: computed.profile.label,
|
||||
ageRange: computed.profile.ageRange,
|
||||
localTraits: computed.profile.localTraits,
|
||||
notes: computed.profile.notes,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
revalidatePath(`/campanha/nucleos/${updated.slug}`)
|
||||
return { status: 'success', message: 'Perfil IBGE adicionado aos perfis manuais.' }
|
||||
} catch (error) {
|
||||
return errorState(error)
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ import type {
|
|||
NucleusDetailViewModel,
|
||||
NucleusElectoralBaselineViewModel,
|
||||
NucleusTabsViewModel,
|
||||
StaffNucleusTabsViewModel,
|
||||
} from '@/utilities/nucleusViewModels'
|
||||
import { buildWhatsAppUrl } from '@/utilities/phone'
|
||||
import { buildNucleusDetailMapHighlight } from '@/utilities/nucleusChoropleth'
|
||||
|
|
@ -345,35 +344,6 @@ const TerritoryContent = ({ nucleus }: { nucleus: NucleusTabsViewModel }) => (
|
|||
</div>
|
||||
)
|
||||
|
||||
const ElectorateContent = ({ nucleus }: { nucleus: StaffNucleusTabsViewModel }) =>
|
||||
nucleus.voterProfiles.length ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{nucleus.voterProfiles.map((profile) => (
|
||||
<Card key={profile.label}>
|
||||
<CardHeader>
|
||||
<CardTitle>{profile.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
{profile.ageRange ? <p>Faixa etária: {profile.ageRange}</p> : null}
|
||||
{profile.incomeBand ? <p>Renda: {profile.incomeBand}</p> : null}
|
||||
{profile.occupation ? <p>Ocupação: {profile.occupation}</p> : null}
|
||||
{profile.localTraits ? <p>{profile.localTraits}</p> : null}
|
||||
{profile.notes ? <p className="text-muted-foreground">{profile.notes}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Nenhum perfil do eleitorado cadastrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Perfis e inteligência eleitoral serão trabalhados na próxima etapa.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
|
||||
export const NucleusActiveTabLoading = () => (
|
||||
<div aria-label="Carregando seção" className="grid gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-40" />
|
||||
|
|
@ -418,7 +388,14 @@ export const NucleusActiveTab = async ({
|
|||
if (data.tab === 'territory') return <TerritoryContent nucleus={view.tabs} />
|
||||
if (data.tab === 'electorate') {
|
||||
if (view.tabs.kind !== 'staff') redirect(`/campanha/nucleos/${view.slug}?tab=overview`)
|
||||
return <ElectorateContent nucleus={view.tabs} />
|
||||
const { NucleusElectorateTab } = await import('@/components/campaign/NucleusElectorateTab')
|
||||
return (
|
||||
<NucleusElectorateTab
|
||||
canEditIntelligence={user.role !== 'lideranca'}
|
||||
nucleus={view.tabs}
|
||||
nucleusId={view.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.tab === 'leaderships') {
|
||||
|
|
|
|||
69
src/components/campaign/NucleusElectorateTab.tsx
Normal file
69
src/components/campaign/NucleusElectorateTab.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { NucleusIbgeVoterProfile } from '@/components/campaign/NucleusIbgeVoterProfile'
|
||||
import { NucleusManualVoterProfileCard } from '@/components/campaign/NucleusVoterProfileCard'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/Empty'
|
||||
import {
|
||||
getNucleusIbgeVoterProfile,
|
||||
IBGE_VOTER_PROFILE_LABEL,
|
||||
} from '@/utilities/nucleusIbgeVoterProfile'
|
||||
import type { StaffNucleusTabsViewModel } from '@/utilities/nucleusViewModels'
|
||||
|
||||
type NucleusElectorateTabProps = {
|
||||
canEditIntelligence: boolean
|
||||
nucleus: StaffNucleusTabsViewModel
|
||||
nucleusId: number
|
||||
}
|
||||
|
||||
export const NucleusElectorateTab = ({
|
||||
canEditIntelligence,
|
||||
nucleus,
|
||||
nucleusId,
|
||||
}: NucleusElectorateTabProps) => {
|
||||
const computed = getNucleusIbgeVoterProfile({
|
||||
cities: nucleus.cities,
|
||||
regions: nucleus.regions,
|
||||
})
|
||||
const hasManualProfiles = nucleus.voterProfiles.length > 0
|
||||
const hasIbgeProfile = computed.status === 'available'
|
||||
const hasManualProfileWithSameLabel = nucleus.voterProfiles.some(
|
||||
(profile) => profile.label === IBGE_VOTER_PROFILE_LABEL,
|
||||
)
|
||||
|
||||
if (!hasIbgeProfile && !hasManualProfiles) {
|
||||
return (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Nenhum perfil do eleitorado disponível</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Cadastre o território do núcleo para ver o perfil médio do IBGE ou adicione perfis
|
||||
manuais pela inteligência do núcleo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{hasIbgeProfile ? (
|
||||
<NucleusIbgeVoterProfile
|
||||
canCopyToManual={canEditIntelligence}
|
||||
hasManualProfileWithSameLabel={hasManualProfileWithSameLabel}
|
||||
nucleusId={nucleusId}
|
||||
profile={computed.profile}
|
||||
/>
|
||||
) : null}
|
||||
{hasManualProfiles ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{nucleus.voterProfiles.map((profile) => (
|
||||
<NucleusManualVoterProfileCard key={profile.label} profile={profile} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum perfil manual cadastrado. Use o botão acima para copiar o perfil IBGE ou edite pela
|
||||
inteligência do núcleo.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
src/components/campaign/NucleusIbgeVoterProfile.tsx
Normal file
88
src/components/campaign/NucleusIbgeVoterProfile.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
appendIbgeVoterProfileFormAction,
|
||||
type AppendIbgeVoterProfileFormState,
|
||||
} from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIbgeVoterProfileFormActions'
|
||||
import { NucleusVoterProfileCardBody } from '@/components/campaign/NucleusVoterProfileCard'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { ComputedVoterProfileViewModel } from '@/utilities/nucleusIbgeVoterProfile'
|
||||
|
||||
type NucleusIbgeVoterProfileProps = {
|
||||
canCopyToManual: boolean
|
||||
hasManualProfileWithSameLabel: boolean
|
||||
nucleusId: number
|
||||
profile: ComputedVoterProfileViewModel
|
||||
}
|
||||
|
||||
const FormFeedback = ({ state }: { state: AppendIbgeVoterProfileFormState }) => {
|
||||
if (!state.message || state.status === 'success') return null
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertTitle>Não foi possível adicionar o perfil</AlertTitle>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export const NucleusIbgeVoterProfile = ({
|
||||
canCopyToManual,
|
||||
hasManualProfileWithSameLabel,
|
||||
nucleusId,
|
||||
profile,
|
||||
}: NucleusIbgeVoterProfileProps) => {
|
||||
const router = useRouter()
|
||||
const [state, formAction, pending] = useActionState<
|
||||
AppendIbgeVoterProfileFormState,
|
||||
FormData
|
||||
>(appendIbgeVoterProfileFormAction, {})
|
||||
const displayState = state.status === 'success' ? {} : state
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== 'success' || !state.message) return
|
||||
toast.success(state.message)
|
||||
router.refresh()
|
||||
}, [router, state.message, state.status])
|
||||
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle>{profile.label}</CardTitle>
|
||||
<Badge variant="secondary">Calculado (IBGE Censo 2022)</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p>{profile.localTraits}</p>
|
||||
<NucleusVoterProfileCardBody profile={{ ageRange: profile.ageRange }} />
|
||||
<p className="text-muted-foreground">{profile.notes}</p>
|
||||
{canCopyToManual ? (
|
||||
<form action={formAction} className="pt-1">
|
||||
<input name="nucleus" type="hidden" value={String(nucleusId)} />
|
||||
<Button
|
||||
disabled={pending || hasManualProfileWithSameLabel}
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant="outline"
|
||||
>
|
||||
{hasManualProfileWithSameLabel
|
||||
? 'Já adicionado como perfil manual'
|
||||
: pending
|
||||
? 'Adicionando…'
|
||||
: 'Usar como perfil manual'}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
<FormFeedback state={displayState} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import { Input } from '@/components/ui/input'
|
|||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import { normalizedVoterProfilesForWrite } from '@/utilities/nucleusIntelligenceUi'
|
||||
import {
|
||||
nucleusDobradinhaNotesDescription,
|
||||
nucleusDobradinhaNotesLabel,
|
||||
|
|
@ -72,18 +73,6 @@ const emptyProfile = (): EditableProfile => ({
|
|||
const normalizedInsights = (values: string[]) =>
|
||||
values.map((text) => ({ text: text.trim() })).filter(({ text }) => Boolean(text))
|
||||
|
||||
const normalizedProfiles = (profiles: EditableProfile[]) =>
|
||||
profiles
|
||||
.map((profile) => ({
|
||||
label: profile.label.trim(),
|
||||
ageRange: profile.ageRange?.trim() || undefined,
|
||||
incomeBand: profile.incomeBand?.trim() || undefined,
|
||||
occupation: profile.occupation?.trim() || undefined,
|
||||
localTraits: profile.localTraits?.trim() || undefined,
|
||||
notes: profile.notes?.trim() || undefined,
|
||||
}))
|
||||
.filter(({ label }) => Boolean(label))
|
||||
|
||||
const FormFeedback = ({ state }: { state: NucleusIntelligenceFormState }) => {
|
||||
if (!state.message || state.status === 'success') return null
|
||||
|
||||
|
|
@ -166,7 +155,7 @@ export const NucleusIntelligenceDialog = ({
|
|||
<input
|
||||
type="hidden"
|
||||
name="voterProfiles"
|
||||
value={JSON.stringify(normalizedProfiles(profiles))}
|
||||
value={JSON.stringify(normalizedVoterProfilesForWrite(profiles))}
|
||||
/>
|
||||
<FormFeedback state={state} />
|
||||
|
||||
|
|
|
|||
42
src/components/campaign/NucleusVoterProfileCard.tsx
Normal file
42
src/components/campaign/NucleusVoterProfileCard.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export type NucleusVoterProfileCardFields = {
|
||||
label: string
|
||||
ageRange?: string | null
|
||||
incomeBand?: string | null
|
||||
occupation?: string | null
|
||||
localTraits?: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export const NucleusVoterProfileCardBody = ({
|
||||
profile,
|
||||
}: {
|
||||
profile: Pick<
|
||||
NucleusVoterProfileCardFields,
|
||||
'ageRange' | 'incomeBand' | 'occupation' | 'localTraits' | 'notes'
|
||||
>
|
||||
}) => (
|
||||
<>
|
||||
{profile.ageRange ? <p>Faixa etária: {profile.ageRange}</p> : null}
|
||||
{profile.incomeBand ? <p>Renda: {profile.incomeBand}</p> : null}
|
||||
{profile.occupation ? <p>Ocupação: {profile.occupation}</p> : null}
|
||||
{profile.localTraits ? <p>{profile.localTraits}</p> : null}
|
||||
{profile.notes ? <p className="text-muted-foreground">{profile.notes}</p> : null}
|
||||
</>
|
||||
)
|
||||
|
||||
export const NucleusManualVoterProfileCard = ({
|
||||
profile,
|
||||
}: {
|
||||
profile: NucleusVoterProfileCardFields
|
||||
}) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{profile.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
<NucleusVoterProfileCardBody profile={profile} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
487
src/lib/bahiaMunicipalityDemographics.ts
Normal file
487
src/lib/bahiaMunicipalityDemographics.ts
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
/**
|
||||
* IBGE Censo 2022 municipality demographics for Bahia (codarea → indicators).
|
||||
*
|
||||
* Source (SIDRA table 9514 — população por idade, UF BA N6; accessed 2026-07-20):
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6572
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6573
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6574
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6575
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6576
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6577
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6578
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6579
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6580
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6581
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/6653
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/49108
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/49109
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/60040
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/60041
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93070
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93084
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93085
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93088
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93089
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93090
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93091
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93092
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93093
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93094
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93095
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93096
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93097
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/93098
|
||||
* Download SHA-256: f86bf59de0d8afb5d61e49c9d839846f185d12774fa802db160ca912bb7c7ee1, 1881dc8e9162aa54fde56dfa027d53b84e46991b2400899a8cd8deaae9aacf46, 9a2ecd1e0f11d998d7c1966f78cc97ed758509d9f33fb269f63b39edf60ed0f4, 50e4863e19ec51df03edf2f4957ea0627d995dd4ed76ce459ea4174850cf54b3, 4cce7d106470128fea1e34d9c1c00ab71eee3395930e873129f354c6854e156e, 89d0f4db58500307f569d031b629e7566a360f8372345cb531c023d0f292f0b2, aa87fb15cc10e363847a73e2d472b0e119618adf501fcc4c973b7b330b8404dd, 470e5d2469a23c9c8555ea1d12b2a6f63d74229ad6ea00e9abf3140cd4816519, cb249824f40eb32167dd3518468be9f55146be60192ea66819102d18d1321e7d, 53fa99084b8c11e8afa39ffb1d91f9d6fc6b8fab853b0a04d9b9e8f33eff96c0, 4019a6c9b7be17b3e2a534f58f7522356c67466a9ff10abdd9bf77df81ef4ae4, 0f690efc75538ca79f15527525abcc5dcad89986a937320e1d5b1c25da0b1a2e, c504ecf6fa661d81f42094d0c92dc92b014af87f67265c3063d20f21350b494c, 3ea94b58779460035f8ec4cf34532eb96f9b8af136889c1a8ad840572ec88c4a, cac9ce52a4a175823fceccb35fc96c2bc36ff6aec1ce94af884523cee81247b7, 1012e2f02dd54c33aa1a3611d748e7cd889982e9124090e65591f4975c7b6fed, b6f3d4831499be4b7ec77b9f49fcf02da1a6357d7c96676c2349c45a7fb1665e, 513508fa7f32cce520f3b245c5e3ab339c5418403b09b1bfab930818617b8db7, bb1034d2e52ae3dc0f30c5ec70b49a002633c18b7bc8b642ff9246f00493146e, c430366cc2c66cacd848103590071d5fe22e564c46d5c43534d5baadb12927ce, 5831f5d92a82bd4c22c3d7a188a400a59d2835fa63331e7a31a6e0411c6a48c4, d7339f8216e85bb68911d039096336756190f6149434a73ce9e3f500c36c641f, 72dbaddff14e5d400e770fb92c84e32798b2e83759e51c05ffdeefadb591a60d, f113609923f9a0020c23cb57c576e280a4435a4b45d4a5a83c502db89e7ecabd, 7c6527c1cdede87a2fa7a34c893d0e275c692138ec1bfdca2fa0e17a131ec058, 29d515dedd5a339086eb94131efb2027c4e63f127af1e26ccb09f1710989093e, f9986ea56489a6ed4a335edbe3450ebb19c2db619f36a40a62bfeb3e19f9388d, 89777d7ba67ebed6039bc8745b759a776783d4448c1ab1f3a1df84a56ee01e59, c6d239a370853f014afd3cf9ddacb6ae2c3268eec2c36c4b509eaf046ccbb724
|
||||
*
|
||||
* Source (SIDRA table 9514 — população feminina total, UF BA N6; accessed 2026-07-20):
|
||||
* https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/5/c287/100362
|
||||
* Download SHA-256: 818eb751d25f88345de96772f25f77022c16cc849da43ececd528566cb85ff6c
|
||||
*
|
||||
* Source (SIDRA table 9515 — idade mediana, UF BA N6; accessed 2026-07-20):
|
||||
* https://apisidra.ibge.gov.br/values/t/9515/n3/29/n6/all/v/10613/p/2022
|
||||
* Download SHA-256: 6eac88bf0963ff529addf390d9cf36fdad70f8d923923cf925251a500583d241
|
||||
*
|
||||
* Age bands aggregate SIDRA classification 287 categories documented in scripts/build-bahia-demographics.mjs.
|
||||
* Coverage is independently checked by tests/fixtures/bahia-municipality-demographics.official.json.
|
||||
*/
|
||||
|
||||
import { codeForMunicipality } from '@/lib/bahiaMunicipalityCodes'
|
||||
|
||||
export type MunicipalityAgeBandKey = '0-17' | '18-29' | '30-59' | '60+'
|
||||
|
||||
export type MunicipalityDemographics = {
|
||||
population: number
|
||||
ageBands: Record<MunicipalityAgeBandKey, number>
|
||||
sexShareFemale: number
|
||||
medianAge: number | null
|
||||
}
|
||||
|
||||
export const bahiaMunicipalityDemographics: Readonly<Record<string, MunicipalityDemographics>> = {
|
||||
"2900108": { population: 7301, ageBands: { "0-17": 1356, "18-29": 967, "30-59": 3015, "60+": 1963 }, sexShareFemale: 0.511574, medianAge: 43 },
|
||||
"2900207": { population: 17639, ageBands: { "0-17": 5376, "18-29": 3442, "30-59": 6579, "60+": 2242 }, sexShareFemale: 0.506605, medianAge: 30 },
|
||||
"2900306": { population: 13795, ageBands: { "0-17": 3667, "18-29": 2485, "30-59": 5356, "60+": 2287 }, sexShareFemale: 0.511997, medianAge: 34 },
|
||||
"2900355": { population: 14201, ageBands: { "0-17": 3316, "18-29": 2711, "30-59": 5877, "60+": 2297 }, sexShareFemale: 0.502218, medianAge: 34 },
|
||||
"2900405": { population: 14497, ageBands: { "0-17": 3485, "18-29": 2511, "30-59": 5845, "60+": 2656 }, sexShareFemale: 0.514313, medianAge: 36 },
|
||||
"2900504": { population: 10604, ageBands: { "0-17": 2243, "18-29": 1992, "30-59": 4376, "60+": 1993 }, sexShareFemale: 0.501603, medianAge: 36 },
|
||||
"2900603": { population: 4447, ageBands: { "0-17": 1076, "18-29": 726, "30-59": 1794, "60+": 851 }, sexShareFemale: 0.501462, medianAge: 37 },
|
||||
"2900702": { population: 151055, ageBands: { "0-17": 36332, "18-29": 26395, "30-59": 66436, "60+": 21892 }, sexShareFemale: 0.531727, medianAge: 35 },
|
||||
"2900801": { population: 24530, ageBands: { "0-17": 6863, "18-29": 4117, "30-59": 9751, "60+": 3799 }, sexShareFemale: 0.495516, medianAge: 33 },
|
||||
"2900900": { population: 5218, ageBands: { "0-17": 1198, "18-29": 754, "30-59": 2104, "60+": 1162 }, sexShareFemale: 0.510349, medianAge: 39 },
|
||||
"2901007": { population: 36521, ageBands: { "0-17": 8711, "18-29": 7045, "30-59": 14851, "60+": 5914 }, sexShareFemale: 0.518880, medianAge: 34 },
|
||||
"2901106": { population: 24138, ageBands: { "0-17": 5818, "18-29": 4102, "30-59": 10226, "60+": 3992 }, sexShareFemale: 0.533640, medianAge: 36 },
|
||||
"2901155": { population: 15137, ageBands: { "0-17": 4383, "18-29": 2683, "30-59": 5767, "60+": 2304 }, sexShareFemale: 0.495937, medianAge: 32 },
|
||||
"2901205": { population: 25438, ageBands: { "0-17": 5762, "18-29": 4079, "30-59": 10162, "60+": 5435 }, sexShareFemale: 0.496580, medianAge: 38 },
|
||||
"2901304": { population: 13080, ageBands: { "0-17": 3491, "18-29": 2448, "30-59": 5117, "60+": 2024 }, sexShareFemale: 0.498089, medianAge: 33 },
|
||||
"2901353": { population: 15012, ageBands: { "0-17": 3542, "18-29": 2696, "30-59": 6354, "60+": 2420 }, sexShareFemale: 0.502598, medianAge: 35 },
|
||||
"2901403": { population: 13732, ageBands: { "0-17": 3625, "18-29": 2371, "30-59": 5438, "60+": 2298 }, sexShareFemale: 0.485071, medianAge: 34 },
|
||||
"2901502": { population: 11031, ageBands: { "0-17": 2503, "18-29": 2002, "30-59": 4679, "60+": 1847 }, sexShareFemale: 0.510652, medianAge: 36 },
|
||||
"2901601": { population: 14206, ageBands: { "0-17": 2896, "18-29": 2648, "30-59": 6059, "60+": 2603 }, sexShareFemale: 0.498381, medianAge: 36 },
|
||||
"2901700": { population: 11146, ageBands: { "0-17": 3011, "18-29": 2091, "30-59": 4351, "60+": 1693 }, sexShareFemale: 0.514265, medianAge: 32 },
|
||||
"2901809": { population: 10862, ageBands: { "0-17": 2779, "18-29": 1916, "30-59": 4451, "60+": 1716 }, sexShareFemale: 0.499632, medianAge: 34 },
|
||||
"2901908": { population: 15922, ageBands: { "0-17": 4037, "18-29": 2834, "30-59": 6279, "60+": 2772 }, sexShareFemale: 0.496608, medianAge: 35 },
|
||||
"2901957": { population: 6913, ageBands: { "0-17": 1775, "18-29": 1195, "30-59": 2661, "60+": 1282 }, sexShareFemale: 0.496167, medianAge: 35 },
|
||||
"2902005": { population: 13936, ageBands: { "0-17": 3211, "18-29": 2304, "30-59": 5656, "60+": 2765 }, sexShareFemale: 0.489165, medianAge: 37 },
|
||||
"2902054": { population: 11557, ageBands: { "0-17": 3203, "18-29": 2310, "30-59": 4642, "60+": 1402 }, sexShareFemale: 0.516916, medianAge: 31 },
|
||||
"2902104": { population: 48294, ageBands: { "0-17": 13010, "18-29": 9431, "30-59": 18633, "60+": 7220 }, sexShareFemale: 0.501097, medianAge: 32 },
|
||||
"2902203": { population: 9833, ageBands: { "0-17": 2429, "18-29": 1786, "30-59": 4131, "60+": 1487 }, sexShareFemale: 0.515916, medianAge: 35 },
|
||||
"2902252": { population: 10191, ageBands: { "0-17": 2737, "18-29": 1869, "30-59": 3985, "60+": 1600 }, sexShareFemale: 0.472181, medianAge: 33 },
|
||||
"2902302": { population: 8677, ageBands: { "0-17": 2261, "18-29": 1602, "30-59": 3639, "60+": 1175 }, sexShareFemale: 0.495448, medianAge: 33 },
|
||||
"2902401": { population: 11179, ageBands: { "0-17": 3258, "18-29": 1881, "30-59": 4287, "60+": 1753 }, sexShareFemale: 0.507738, medianAge: 33 },
|
||||
"2902500": { population: 13614, ageBands: { "0-17": 3668, "18-29": 2449, "30-59": 5297, "60+": 2200 }, sexShareFemale: 0.485897, medianAge: 33 },
|
||||
"2902609": { population: 18220, ageBands: { "0-17": 4500, "18-29": 3192, "30-59": 7054, "60+": 3474 }, sexShareFemale: 0.503238, medianAge: 36 },
|
||||
"2902658": { population: 11958, ageBands: { "0-17": 3360, "18-29": 2096, "30-59": 4583, "60+": 1919 }, sexShareFemale: 0.496153, medianAge: 33 },
|
||||
"2902708": { population: 51092, ageBands: { "0-17": 16288, "18-29": 9907, "30-59": 18520, "60+": 6377 }, sexShareFemale: 0.491290, medianAge: 29 },
|
||||
"2902807": { population: 26026, ageBands: { "0-17": 6778, "18-29": 5221, "30-59": 10605, "60+": 3422 }, sexShareFemale: 0.495504, medianAge: 32 },
|
||||
"2902906": { population: 36539, ageBands: { "0-17": 10124, "18-29": 6696, "30-59": 14500, "60+": 5219 }, sexShareFemale: 0.507540, medianAge: 32 },
|
||||
"2903003": { population: 13836, ageBands: { "0-17": 3320, "18-29": 2314, "30-59": 5383, "60+": 2819 }, sexShareFemale: 0.495302, medianAge: 37 },
|
||||
"2903102": { population: 5775, ageBands: { "0-17": 1469, "18-29": 919, "30-59": 2377, "60+": 1010 }, sexShareFemale: 0.477576, medianAge: 36 },
|
||||
"2903201": { population: 159734, ageBands: { "0-17": 43478, "18-29": 32658, "30-59": 67612, "60+": 15986 }, sexShareFemale: 0.510943, medianAge: 31 },
|
||||
"2903235": { population: 13453, ageBands: { "0-17": 3375, "18-29": 2431, "30-59": 5434, "60+": 2213 }, sexShareFemale: 0.494908, medianAge: 35 },
|
||||
"2903276": { population: 15203, ageBands: { "0-17": 4005, "18-29": 2742, "30-59": 6371, "60+": 2085 }, sexShareFemale: 0.505558, medianAge: 33 },
|
||||
"2903300": { population: 5833, ageBands: { "0-17": 1354, "18-29": 955, "30-59": 2420, "60+": 1104 }, sexShareFemale: 0.499914, medianAge: 37 },
|
||||
"2903409": { population: 20121, ageBands: { "0-17": 5662, "18-29": 3459, "30-59": 7881, "60+": 3119 }, sexShareFemale: 0.496446, medianAge: 33 },
|
||||
"2903508": { population: 18412, ageBands: { "0-17": 4472, "18-29": 3144, "30-59": 7193, "60+": 3603 }, sexShareFemale: 0.501195, medianAge: 36 },
|
||||
"2903607": { population: 15146, ageBands: { "0-17": 3852, "18-29": 2663, "30-59": 6108, "60+": 2523 }, sexShareFemale: 0.497425, medianAge: 34 },
|
||||
"2903706": { population: 13690, ageBands: { "0-17": 3430, "18-29": 2343, "30-59": 5196, "60+": 2721 }, sexShareFemale: 0.501899, medianAge: 35 },
|
||||
"2903805": { population: 16873, ageBands: { "0-17": 4650, "18-29": 2774, "30-59": 6506, "60+": 2943 }, sexShareFemale: 0.492977, medianAge: 34 },
|
||||
"2903904": { population: 65550, ageBands: { "0-17": 18854, "18-29": 12667, "30-59": 25605, "60+": 8424 }, sexShareFemale: 0.509260, medianAge: 31 },
|
||||
"2903953": { population: 9730, ageBands: { "0-17": 2415, "18-29": 1810, "30-59": 3754, "60+": 1751 }, sexShareFemale: 0.483864, medianAge: 34 },
|
||||
"2904001": { population: 13622, ageBands: { "0-17": 3496, "18-29": 2319, "30-59": 5329, "60+": 2478 }, sexShareFemale: 0.510277, medianAge: 35 },
|
||||
"2904050": { population: 15844, ageBands: { "0-17": 4686, "18-29": 3410, "30-59": 6066, "60+": 1682 }, sexShareFemale: 0.500063, medianAge: 29 },
|
||||
"2904100": { population: 19322, ageBands: { "0-17": 4699, "18-29": 3340, "30-59": 7484, "60+": 3799 }, sexShareFemale: 0.508074, medianAge: 36 },
|
||||
"2904209": { population: 11024, ageBands: { "0-17": 2518, "18-29": 2083, "30-59": 4374, "60+": 2049 }, sexShareFemale: 0.497823, medianAge: 35 },
|
||||
"2904308": { population: 12943, ageBands: { "0-17": 3268, "18-29": 2213, "30-59": 5124, "60+": 2338 }, sexShareFemale: 0.507997, medianAge: 35 },
|
||||
"2904407": { population: 9108, ageBands: { "0-17": 2222, "18-29": 1300, "30-59": 3758, "60+": 1828 }, sexShareFemale: 0.485617, medianAge: 38 },
|
||||
"2904506": { population: 11765, ageBands: { "0-17": 2906, "18-29": 1833, "30-59": 4489, "60+": 2537 }, sexShareFemale: 0.487123, medianAge: 37 },
|
||||
"2904605": { population: 70510, ageBands: { "0-17": 16094, "18-29": 12337, "30-59": 30638, "60+": 11441 }, sexShareFemale: 0.512523, medianAge: 36 },
|
||||
"2904704": { population: 14804, ageBands: { "0-17": 3609, "18-29": 2298, "30-59": 5830, "60+": 3067 }, sexShareFemale: 0.512497, medianAge: 37 },
|
||||
"2904753": { population: 19589, ageBands: { "0-17": 5733, "18-29": 3806, "30-59": 7509, "60+": 2541 }, sexShareFemale: 0.479249, medianAge: 30 },
|
||||
"2904803": { population: 6205, ageBands: { "0-17": 1435, "18-29": 1001, "30-59": 2627, "60+": 1142 }, sexShareFemale: 0.486382, medianAge: 37 },
|
||||
"2904852": { population: 16559, ageBands: { "0-17": 4683, "18-29": 3292, "30-59": 6318, "60+": 2266 }, sexShareFemale: 0.517362, medianAge: 31 },
|
||||
"2904902": { population: 29250, ageBands: { "0-17": 7030, "18-29": 5402, "30-59": 12121, "60+": 4697 }, sexShareFemale: 0.521846, medianAge: 35 },
|
||||
"2905008": { population: 22462, ageBands: { "0-17": 5044, "18-29": 3987, "30-59": 9293, "60+": 4138 }, sexShareFemale: 0.506411, medianAge: 36 },
|
||||
"2905107": { population: 10384, ageBands: { "0-17": 2806, "18-29": 1890, "30-59": 3948, "60+": 1740 }, sexShareFemale: 0.503948, medianAge: 33 },
|
||||
"2905156": { population: 11266, ageBands: { "0-17": 2663, "18-29": 1921, "30-59": 4556, "60+": 2126 }, sexShareFemale: 0.488638, medianAge: 36 },
|
||||
"2905206": { population: 52012, ageBands: { "0-17": 12293, "18-29": 9487, "30-59": 21605, "60+": 8627 }, sexShareFemale: 0.508979, medianAge: 34 },
|
||||
"2905305": { population: 17466, ageBands: { "0-17": 4890, "18-29": 3328, "30-59": 6865, "60+": 2383 }, sexShareFemale: 0.494733, medianAge: 32 },
|
||||
"2905404": { population: 17761, ageBands: { "0-17": 4887, "18-29": 3701, "30-59": 7453, "60+": 1720 }, sexShareFemale: 0.510669, medianAge: 30 },
|
||||
"2905503": { population: 13080, ageBands: { "0-17": 3634, "18-29": 2411, "30-59": 5006, "60+": 2029 }, sexShareFemale: 0.506040, medianAge: 32 },
|
||||
"2905602": { population: 22579, ageBands: { "0-17": 6159, "18-29": 3892, "30-59": 8514, "60+": 4014 }, sexShareFemale: 0.513265, medianAge: 34 },
|
||||
"2905701": { population: 300372, ageBands: { "0-17": 77870, "18-29": 53220, "30-59": 136559, "60+": 32723 }, sexShareFemale: 0.519696, medianAge: 34 },
|
||||
"2905800": { population: 30469, ageBands: { "0-17": 9565, "18-29": 5474, "30-59": 11196, "60+": 4234 }, sexShareFemale: 0.510289, medianAge: 30 },
|
||||
"2905909": { population: 30671, ageBands: { "0-17": 9034, "18-29": 5266, "30-59": 11674, "60+": 4697 }, sexShareFemale: 0.493952, medianAge: 32 },
|
||||
"2906006": { population: 71377, ageBands: { "0-17": 19162, "18-29": 13848, "30-59": 28262, "60+": 10105 }, sexShareFemale: 0.502711, medianAge: 32 },
|
||||
"2906105": { population: 10225, ageBands: { "0-17": 2785, "18-29": 1866, "30-59": 3882, "60+": 1692 }, sexShareFemale: 0.481174, medianAge: 33 },
|
||||
"2906204": { population: 24206, ageBands: { "0-17": 6536, "18-29": 4573, "30-59": 9467, "60+": 3630 }, sexShareFemale: 0.501983, medianAge: 33 },
|
||||
"2906303": { population: 32683, ageBands: { "0-17": 8439, "18-29": 5498, "30-59": 13139, "60+": 5607 }, sexShareFemale: 0.500382, medianAge: 35 },
|
||||
"2906402": { population: 7772, ageBands: { "0-17": 1763, "18-29": 1350, "30-59": 3112, "60+": 1547 }, sexShareFemale: 0.502316, medianAge: 38 },
|
||||
"2906501": { population: 72382, ageBands: { "0-17": 17722, "18-29": 12420, "30-59": 32220, "60+": 10020 }, sexShareFemale: 0.531334, medianAge: 35 },
|
||||
"2906600": { population: 13016, ageBands: { "0-17": 2868, "18-29": 2002, "30-59": 5390, "60+": 2756 }, sexShareFemale: 0.497849, medianAge: 38 },
|
||||
"2906709": { population: 25247, ageBands: { "0-17": 6386, "18-29": 4170, "30-59": 10177, "60+": 4514 }, sexShareFemale: 0.502515, medianAge: 36 },
|
||||
"2906808": { population: 37439, ageBands: { "0-17": 9773, "18-29": 7548, "30-59": 14553, "60+": 5565 }, sexShareFemale: 0.497182, medianAge: 32 },
|
||||
"2906824": { population: 16105, ageBands: { "0-17": 4539, "18-29": 2813, "30-59": 6332, "60+": 2421 }, sexShareFemale: 0.494753, medianAge: 33 },
|
||||
"2906857": { population: 10744, ageBands: { "0-17": 2284, "18-29": 1658, "30-59": 4455, "60+": 2347 }, sexShareFemale: 0.502699, medianAge: 39 },
|
||||
"2906873": { population: 33235, ageBands: { "0-17": 8822, "18-29": 6196, "30-59": 13457, "60+": 4760 }, sexShareFemale: 0.513886, medianAge: 33 },
|
||||
"2906899": { population: 9940, ageBands: { "0-17": 2229, "18-29": 1553, "30-59": 3837, "60+": 2321 }, sexShareFemale: 0.492656, medianAge: 39 },
|
||||
"2906907": { population: 20580, ageBands: { "0-17": 5638, "18-29": 3633, "30-59": 8060, "60+": 3249 }, sexShareFemale: 0.501555, medianAge: 33 },
|
||||
"2907004": { population: 8365, ageBands: { "0-17": 2457, "18-29": 1700, "30-59": 3231, "60+": 977 }, sexShareFemale: 0.516916, medianAge: 30 },
|
||||
"2907103": { population: 28869, ageBands: { "0-17": 8498, "18-29": 5315, "30-59": 11113, "60+": 3943 }, sexShareFemale: 0.489764, medianAge: 31 },
|
||||
"2907202": { population: 72086, ageBands: { "0-17": 20699, "18-29": 13940, "30-59": 27931, "60+": 9516 }, sexShareFemale: 0.488652, medianAge: 31 },
|
||||
"2907301": { population: 24712, ageBands: { "0-17": 5938, "18-29": 4382, "30-59": 10385, "60+": 4007 }, sexShareFemale: 0.526020, medianAge: 35 },
|
||||
"2907400": { population: 3434, ageBands: { "0-17": 941, "18-29": 491, "30-59": 1359, "60+": 643 }, sexShareFemale: 0.481072, medianAge: 36 },
|
||||
"2907509": { population: 48148, ageBands: { "0-17": 11182, "18-29": 7790, "30-59": 21652, "60+": 7524 }, sexShareFemale: 0.532836, medianAge: 37 },
|
||||
"2907558": { population: 8841, ageBands: { "0-17": 1939, "18-29": 1513, "30-59": 3584, "60+": 1805 }, sexShareFemale: 0.491460, medianAge: 37 },
|
||||
"2907608": { population: 16348, ageBands: { "0-17": 4218, "18-29": 2710, "30-59": 6514, "60+": 2906 }, sexShareFemale: 0.493883, medianAge: 35 },
|
||||
"2907707": { population: 10579, ageBands: { "0-17": 2990, "18-29": 2045, "30-59": 3957, "60+": 1587 }, sexShareFemale: 0.494092, medianAge: 31 },
|
||||
"2907806": { population: 30907, ageBands: { "0-17": 6712, "18-29": 5412, "30-59": 12931, "60+": 5852 }, sexShareFemale: 0.506973, medianAge: 37 },
|
||||
"2907905": { population: 17230, ageBands: { "0-17": 4762, "18-29": 3195, "30-59": 6861, "60+": 2412 }, sexShareFemale: 0.512362, medianAge: 32 },
|
||||
"2908002": { population: 17333, ageBands: { "0-17": 4061, "18-29": 2657, "30-59": 6743, "60+": 3872 }, sexShareFemale: 0.523337, medianAge: 38 },
|
||||
"2908101": { population: 19151, ageBands: { "0-17": 4884, "18-29": 3645, "30-59": 7375, "60+": 3247 }, sexShareFemale: 0.489269, medianAge: 33 },
|
||||
"2908200": { population: 20800, ageBands: { "0-17": 5174, "18-29": 3976, "30-59": 8683, "60+": 2967 }, sexShareFemale: 0.525817, medianAge: 34 },
|
||||
"2908309": { population: 15794, ageBands: { "0-17": 3335, "18-29": 2616, "30-59": 6916, "60+": 2927 }, sexShareFemale: 0.522730, medianAge: 38 },
|
||||
"2908408": { population: 67825, ageBands: { "0-17": 17610, "18-29": 12242, "30-59": 27937, "60+": 10036 }, sexShareFemale: 0.514943, medianAge: 34 },
|
||||
"2908507": { population: 35308, ageBands: { "0-17": 8455, "18-29": 6412, "30-59": 15506, "60+": 4935 }, sexShareFemale: 0.524584, medianAge: 35 },
|
||||
"2908606": { population: 23654, ageBands: { "0-17": 6892, "18-29": 4956, "30-59": 8755, "60+": 3051 }, sexShareFemale: 0.504777, medianAge: 29 },
|
||||
"2908705": { population: 17053, ageBands: { "0-17": 3817, "18-29": 2609, "30-59": 6976, "60+": 3651 }, sexShareFemale: 0.507242, medianAge: 39 },
|
||||
"2908804": { population: 4333, ageBands: { "0-17": 1005, "18-29": 729, "30-59": 1721, "60+": 878 }, sexShareFemale: 0.494115, medianAge: 36 },
|
||||
"2908903": { population: 26692, ageBands: { "0-17": 6380, "18-29": 5015, "30-59": 11202, "60+": 4095 }, sexShareFemale: 0.519069, medianAge: 34 },
|
||||
"2909000": { population: 7546, ageBands: { "0-17": 1618, "18-29": 1136, "30-59": 3129, "60+": 1663 }, sexShareFemale: 0.509674, medianAge: 40 },
|
||||
"2909109": { population: 13990, ageBands: { "0-17": 3355, "18-29": 2194, "30-59": 5698, "60+": 2743 }, sexShareFemale: 0.488277, medianAge: 37 },
|
||||
"2909208": { population: 17056, ageBands: { "0-17": 4364, "18-29": 3443, "30-59": 6774, "60+": 2475 }, sexShareFemale: 0.487394, medianAge: 32 },
|
||||
"2909307": { population: 32457, ageBands: { "0-17": 8374, "18-29": 5784, "30-59": 13168, "60+": 5131 }, sexShareFemale: 0.498629, medianAge: 34 },
|
||||
"2909406": { population: 13063, ageBands: { "0-17": 3557, "18-29": 2272, "30-59": 5106, "60+": 2128 }, sexShareFemale: 0.486948, medianAge: 33 },
|
||||
"2909505": { population: 4415, ageBands: { "0-17": 1085, "18-29": 671, "30-59": 1832, "60+": 827 }, sexShareFemale: 0.504190, medianAge: 37 },
|
||||
"2909604": { population: 19729, ageBands: { "0-17": 5124, "18-29": 3476, "30-59": 7891, "60+": 3238 }, sexShareFemale: 0.498657, medianAge: 35 },
|
||||
"2909703": { population: 13993, ageBands: { "0-17": 3520, "18-29": 2538, "30-59": 5667, "60+": 2268 }, sexShareFemale: 0.486815, medianAge: 34 },
|
||||
"2909802": { population: 60348, ageBands: { "0-17": 13723, "18-29": 10492, "30-59": 26851, "60+": 9282 }, sexShareFemale: 0.534135, medianAge: 36 },
|
||||
"2909901": { population: 34180, ageBands: { "0-17": 9974, "18-29": 6420, "30-59": 13254, "60+": 4532 }, sexShareFemale: 0.489585, medianAge: 31 },
|
||||
"2910008": { population: 10820, ageBands: { "0-17": 2976, "18-29": 1772, "30-59": 4142, "60+": 1930 }, sexShareFemale: 0.481701, medianAge: 34 },
|
||||
"2910057": { population: 71485, ageBands: { "0-17": 19227, "18-29": 12666, "30-59": 31098, "60+": 8494 }, sexShareFemale: 0.519326, medianAge: 33 },
|
||||
"2910107": { population: 11884, ageBands: { "0-17": 2585, "18-29": 2061, "30-59": 5170, "60+": 2068 }, sexShareFemale: 0.489650, medianAge: 36 },
|
||||
"2910206": { population: 4407, ageBands: { "0-17": 997, "18-29": 741, "30-59": 1912, "60+": 757 }, sexShareFemale: 0.515770, medianAge: 37 },
|
||||
"2910305": { population: 7808, ageBands: { "0-17": 1724, "18-29": 1132, "30-59": 3310, "60+": 1642 }, sexShareFemale: 0.504355, medianAge: 39 },
|
||||
"2910404": { population: 19107, ageBands: { "0-17": 5136, "18-29": 3309, "30-59": 7460, "60+": 3202 }, sexShareFemale: 0.483906, medianAge: 34 },
|
||||
"2910503": { population: 38098, ageBands: { "0-17": 10048, "18-29": 7282, "30-59": 15658, "60+": 5110 }, sexShareFemale: 0.519922, medianAge: 33 },
|
||||
"2910602": { population: 32554, ageBands: { "0-17": 9194, "18-29": 6571, "30-59": 12574, "60+": 4215 }, sexShareFemale: 0.513792, medianAge: 31 },
|
||||
"2910701": { population: 61456, ageBands: { "0-17": 15452, "18-29": 10987, "30-59": 24817, "60+": 10200 }, sexShareFemale: 0.501188, medianAge: 34 },
|
||||
"2910727": { population: 113710, ageBands: { "0-17": 30851, "18-29": 20849, "30-59": 47401, "60+": 14609 }, sexShareFemale: 0.515161, medianAge: 33 },
|
||||
"2910750": { population: 17895, ageBands: { "0-17": 3976, "18-29": 3285, "30-59": 7564, "60+": 3070 }, sexShareFemale: 0.499357, medianAge: 36 },
|
||||
"2910776": { population: 5631, ageBands: { "0-17": 1362, "18-29": 876, "30-59": 2345, "60+": 1048 }, sexShareFemale: 0.487658, medianAge: 37 },
|
||||
"2910800": { population: 616272, ageBands: { "0-17": 150536, "18-29": 116997, "30-59": 269294, "60+": 79445 }, sexShareFemale: 0.534266, medianAge: 34 },
|
||||
"2910859": { population: 17897, ageBands: { "0-17": 4622, "18-29": 3346, "30-59": 7229, "60+": 2700 }, sexShareFemale: 0.501034, medianAge: 33 },
|
||||
"2910909": { population: 4873, ageBands: { "0-17": 1229, "18-29": 819, "30-59": 1947, "60+": 878 }, sexShareFemale: 0.496204, medianAge: 35 },
|
||||
"2911006": { population: 11059, ageBands: { "0-17": 2515, "18-29": 1765, "30-59": 4588, "60+": 2191 }, sexShareFemale: 0.491184, medianAge: 37 },
|
||||
"2911105": { population: 25899, ageBands: { "0-17": 7730, "18-29": 5289, "30-59": 9757, "60+": 3123 }, sexShareFemale: 0.494421, medianAge: 29 },
|
||||
"2911204": { population: 32178, ageBands: { "0-17": 8512, "18-29": 5687, "30-59": 13469, "60+": 4510 }, sexShareFemale: 0.511095, medianAge: 34 },
|
||||
"2911253": { population: 4360, ageBands: { "0-17": 960, "18-29": 693, "30-59": 1774, "60+": 933 }, sexShareFemale: 0.486697, medianAge: 38 },
|
||||
"2911303": { population: 10884, ageBands: { "0-17": 2709, "18-29": 1947, "30-59": 4294, "60+": 1934 }, sexShareFemale: 0.486953, medianAge: 35 },
|
||||
"2911402": { population: 15524, ageBands: { "0-17": 4230, "18-29": 2576, "30-59": 6425, "60+": 2293 }, sexShareFemale: 0.509469, medianAge: 34 },
|
||||
"2911501": { population: 5555, ageBands: { "0-17": 1459, "18-29": 922, "30-59": 2150, "60+": 1024 }, sexShareFemale: 0.501350, medianAge: 35 },
|
||||
"2911600": { population: 20605, ageBands: { "0-17": 5149, "18-29": 3563, "30-59": 8686, "60+": 3207 }, sexShareFemale: 0.531230, medianAge: 35 },
|
||||
"2911659": { population: 8050, ageBands: { "0-17": 1677, "18-29": 1320, "30-59": 3200, "60+": 1853 }, sexShareFemale: 0.500870, medianAge: 40 },
|
||||
"2911709": { population: 87817, ageBands: { "0-17": 20825, "18-29": 15921, "30-59": 37560, "60+": 13511 }, sexShareFemale: 0.515117, medianAge: 35 },
|
||||
"2911808": { population: 19049, ageBands: { "0-17": 4875, "18-29": 2935, "30-59": 7625, "60+": 3614 }, sexShareFemale: 0.485747, medianAge: 36 },
|
||||
"2911857": { population: 12309, ageBands: { "0-17": 2676, "18-29": 2406, "30-59": 5075, "60+": 2152 }, sexShareFemale: 0.497928, medianAge: 36 },
|
||||
"2911907": { population: 24607, ageBands: { "0-17": 6448, "18-29": 4253, "30-59": 9625, "60+": 4281 }, sexShareFemale: 0.516032, medianAge: 35 },
|
||||
"2912004": { population: 10429, ageBands: { "0-17": 2049, "18-29": 1671, "30-59": 4426, "60+": 2283 }, sexShareFemale: 0.500431, medianAge: 40 },
|
||||
"2912103": { population: 21665, ageBands: { "0-17": 5065, "18-29": 3617, "30-59": 8526, "60+": 4457 }, sexShareFemale: 0.518024, medianAge: 37 },
|
||||
"2912202": { population: 20785, ageBands: { "0-17": 6169, "18-29": 4327, "30-59": 8277, "60+": 2012 }, sexShareFemale: 0.498773, medianAge: 29 },
|
||||
"2912301": { population: 13934, ageBands: { "0-17": 3303, "18-29": 2226, "30-59": 5687, "60+": 2718 }, sexShareFemale: 0.502655, medianAge: 37 },
|
||||
"2912400": { population: 16603, ageBands: { "0-17": 4196, "18-29": 2702, "30-59": 6818, "60+": 2887 }, sexShareFemale: 0.494429, medianAge: 36 },
|
||||
"2912509": { population: 13863, ageBands: { "0-17": 3118, "18-29": 2590, "30-59": 5543, "60+": 2612 }, sexShareFemale: 0.502489, medianAge: 36 },
|
||||
"2912608": { population: 3725, ageBands: { "0-17": 990, "18-29": 631, "30-59": 1464, "60+": 640 }, sexShareFemale: 0.488322, medianAge: 35 },
|
||||
"2912707": { population: 25344, ageBands: { "0-17": 7211, "18-29": 4981, "30-59": 9411, "60+": 3741 }, sexShareFemale: 0.483546, medianAge: 31 },
|
||||
"2912806": { population: 8896, ageBands: { "0-17": 2316, "18-29": 1518, "30-59": 3565, "60+": 1497 }, sexShareFemale: 0.497639, medianAge: 34 },
|
||||
"2912905": { population: 18792, ageBands: { "0-17": 4850, "18-29": 2874, "30-59": 7602, "60+": 3466 }, sexShareFemale: 0.513995, medianAge: 36 },
|
||||
"2913002": { population: 14637, ageBands: { "0-17": 3187, "18-29": 2525, "30-59": 5850, "60+": 3075 }, sexShareFemale: 0.501674, medianAge: 37 },
|
||||
"2913101": { population: 16969, ageBands: { "0-17": 4186, "18-29": 2790, "30-59": 6968, "60+": 3025 }, sexShareFemale: 0.497613, medianAge: 36 },
|
||||
"2913200": { population: 26309, ageBands: { "0-17": 7256, "18-29": 4823, "30-59": 10449, "60+": 3781 }, sexShareFemale: 0.510016, medianAge: 32 },
|
||||
"2913309": { population: 6190, ageBands: { "0-17": 1444, "18-29": 1079, "30-59": 2521, "60+": 1146 }, sexShareFemale: 0.513409, medianAge: 36 },
|
||||
"2913408": { population: 15527, ageBands: { "0-17": 3427, "18-29": 2795, "30-59": 6460, "60+": 2845 }, sexShareFemale: 0.507825, medianAge: 36 },
|
||||
"2913457": { population: 13151, ageBands: { "0-17": 3627, "18-29": 2525, "30-59": 5266, "60+": 1733 }, sexShareFemale: 0.485210, medianAge: 32 },
|
||||
"2913507": { population: 21491, ageBands: { "0-17": 5467, "18-29": 3426, "30-59": 8608, "60+": 3990 }, sexShareFemale: 0.493788, medianAge: 36 },
|
||||
"2913606": { population: 178649, ageBands: { "0-17": 42464, "18-29": 30693, "30-59": 76088, "60+": 29404 }, sexShareFemale: 0.521173, medianAge: 36 },
|
||||
"2913705": { population: 33790, ageBands: { "0-17": 8968, "18-29": 6430, "30-59": 13416, "60+": 4976 }, sexShareFemale: 0.511246, medianAge: 33 },
|
||||
"2913804": { population: 13709, ageBands: { "0-17": 3340, "18-29": 2742, "30-59": 5271, "60+": 2356 }, sexShareFemale: 0.513458, medianAge: 34 },
|
||||
"2913903": { population: 40706, ageBands: { "0-17": 10013, "18-29": 6629, "30-59": 16610, "60+": 7454 }, sexShareFemale: 0.518646, medianAge: 36 },
|
||||
"2914000": { population: 56876, ageBands: { "0-17": 14090, "18-29": 10098, "30-59": 22601, "60+": 10087 }, sexShareFemale: 0.517002, medianAge: 35 },
|
||||
"2914109": { population: 9935, ageBands: { "0-17": 2275, "18-29": 1678, "30-59": 4006, "60+": 1976 }, sexShareFemale: 0.501963, medianAge: 37 },
|
||||
"2914208": { population: 6101, ageBands: { "0-17": 1547, "18-29": 1003, "30-59": 2425, "60+": 1126 }, sexShareFemale: 0.506966, medianAge: 36 },
|
||||
"2914307": { population: 10752, ageBands: { "0-17": 2585, "18-29": 2078, "30-59": 4249, "60+": 1840 }, sexShareFemale: 0.489676, medianAge: 34 },
|
||||
"2914406": { population: 23879, ageBands: { "0-17": 6517, "18-29": 4791, "30-59": 9188, "60+": 3383 }, sexShareFemale: 0.505046, medianAge: 31 },
|
||||
"2914505": { population: 28043, ageBands: { "0-17": 6939, "18-29": 4758, "30-59": 11601, "60+": 4745 }, sexShareFemale: 0.519987, medianAge: 35 },
|
||||
"2914604": { population: 74507, ageBands: { "0-17": 19554, "18-29": 14739, "30-59": 31361, "60+": 8853 }, sexShareFemale: 0.520434, medianAge: 32 },
|
||||
"2914653": { population: 28165, ageBands: { "0-17": 8426, "18-29": 5265, "30-59": 10868, "60+": 3606 }, sexShareFemale: 0.496574, medianAge: 30 },
|
||||
"2914703": { population: 65073, ageBands: { "0-17": 16627, "18-29": 11807, "30-59": 26992, "60+": 9647 }, sexShareFemale: 0.517342, medianAge: 34 },
|
||||
"2914802": { population: 186708, ageBands: { "0-17": 41498, "18-29": 31997, "30-59": 80428, "60+": 32785 }, sexShareFemale: 0.534664, medianAge: 37 },
|
||||
"2914901": { population: 27704, ageBands: { "0-17": 8109, "18-29": 5368, "30-59": 11131, "60+": 3096 }, sexShareFemale: 0.491734, medianAge: 30 },
|
||||
"2915007": { population: 13472, ageBands: { "0-17": 3847, "18-29": 2429, "30-59": 5006, "60+": 2190 }, sexShareFemale: 0.499926, medianAge: 32 },
|
||||
"2915106": { population: 13803, ageBands: { "0-17": 3525, "18-29": 2365, "30-59": 5371, "60+": 2542 }, sexShareFemale: 0.496486, medianAge: 35 },
|
||||
"2915205": { population: 15310, ageBands: { "0-17": 3861, "18-29": 2512, "30-59": 6167, "60+": 2770 }, sexShareFemale: 0.497061, medianAge: 36 },
|
||||
"2915304": { population: 6347, ageBands: { "0-17": 1698, "18-29": 989, "30-59": 2518, "60+": 1142 }, sexShareFemale: 0.500867, medianAge: 35 },
|
||||
"2915353": { population: 12311, ageBands: { "0-17": 3611, "18-29": 2180, "30-59": 4708, "60+": 1812 }, sexShareFemale: 0.488100, medianAge: 32 },
|
||||
"2915403": { population: 6037, ageBands: { "0-17": 1783, "18-29": 1013, "30-59": 2254, "60+": 987 }, sexShareFemale: 0.504887, medianAge: 33 },
|
||||
"2915502": { population: 18781, ageBands: { "0-17": 4511, "18-29": 3011, "30-59": 7697, "60+": 3562 }, sexShareFemale: 0.512380, medianAge: 37 },
|
||||
"2915601": { population: 59605, ageBands: { "0-17": 15869, "18-29": 10570, "30-59": 23920, "60+": 9246 }, sexShareFemale: 0.507256, medianAge: 34 },
|
||||
"2915700": { population: 7051, ageBands: { "0-17": 1815, "18-29": 1140, "30-59": 2777, "60+": 1319 }, sexShareFemale: 0.498227, medianAge: 36 },
|
||||
"2915809": { population: 24394, ageBands: { "0-17": 5959, "18-29": 4365, "30-59": 10285, "60+": 3785 }, sexShareFemale: 0.495778, medianAge: 35 },
|
||||
"2915908": { population: 5914, ageBands: { "0-17": 1652, "18-29": 1198, "30-59": 2263, "60+": 801 }, sexShareFemale: 0.509469, medianAge: 31 },
|
||||
"2916005": { population: 17813, ageBands: { "0-17": 3920, "18-29": 2723, "30-59": 7261, "60+": 3909 }, sexShareFemale: 0.495762, medianAge: 39 },
|
||||
"2916104": { population: 19789, ageBands: { "0-17": 4613, "18-29": 3378, "30-59": 8352, "60+": 3446 }, sexShareFemale: 0.528122, medianAge: 37 },
|
||||
"2916203": { population: 10341, ageBands: { "0-17": 2572, "18-29": 1712, "30-59": 4353, "60+": 1704 }, sexShareFemale: 0.498308, medianAge: 35 },
|
||||
"2916302": { population: 9174, ageBands: { "0-17": 2566, "18-29": 1510, "30-59": 3560, "60+": 1538 }, sexShareFemale: 0.494441, medianAge: 34 },
|
||||
"2916401": { population: 65897, ageBands: { "0-17": 16587, "18-29": 11562, "30-59": 27423, "60+": 10325 }, sexShareFemale: 0.519432, medianAge: 35 },
|
||||
"2916500": { population: 31679, ageBands: { "0-17": 8697, "18-29": 5834, "30-59": 12525, "60+": 4623 }, sexShareFemale: 0.493860, medianAge: 32 },
|
||||
"2916609": { population: 10279, ageBands: { "0-17": 2682, "18-29": 1808, "30-59": 4156, "60+": 1633 }, sexShareFemale: 0.494503, medianAge: 34 },
|
||||
"2916708": { population: 8153, ageBands: { "0-17": 1819, "18-29": 1424, "30-59": 3521, "60+": 1389 }, sexShareFemale: 0.500061, medianAge: 36 },
|
||||
"2916807": { population: 17052, ageBands: { "0-17": 4231, "18-29": 2791, "30-59": 6981, "60+": 3049 }, sexShareFemale: 0.492376, medianAge: 36 },
|
||||
"2916856": { population: 15737, ageBands: { "0-17": 4338, "18-29": 2973, "30-59": 6349, "60+": 2077 }, sexShareFemale: 0.511978, medianAge: 32 },
|
||||
"2916906": { population: 10999, ageBands: { "0-17": 2560, "18-29": 1850, "30-59": 4439, "60+": 2150 }, sexShareFemale: 0.516956, medianAge: 37 },
|
||||
"2917003": { population: 33872, ageBands: { "0-17": 9083, "18-29": 6267, "30-59": 13088, "60+": 5434 }, sexShareFemale: 0.495867, medianAge: 33 },
|
||||
"2917102": { population: 16617, ageBands: { "0-17": 4082, "18-29": 2689, "30-59": 6505, "60+": 3341 }, sexShareFemale: 0.509117, medianAge: 37 },
|
||||
"2917201": { population: 17914, ageBands: { "0-17": 4163, "18-29": 3150, "30-59": 7420, "60+": 3181 }, sexShareFemale: 0.501563, medianAge: 36 },
|
||||
"2917300": { population: 21902, ageBands: { "0-17": 5809, "18-29": 3684, "30-59": 8980, "60+": 3429 }, sexShareFemale: 0.516209, medianAge: 34 },
|
||||
"2917334": { population: 11118, ageBands: { "0-17": 2888, "18-29": 2216, "30-59": 4487, "60+": 1527 }, sexShareFemale: 0.495413, medianAge: 32 },
|
||||
"2917359": { population: 9275, ageBands: { "0-17": 2322, "18-29": 1487, "30-59": 3831, "60+": 1635 }, sexShareFemale: 0.486038, medianAge: 35 },
|
||||
"2917409": { population: 14436, ageBands: { "0-17": 2801, "18-29": 2381, "30-59": 6056, "60+": 3198 }, sexShareFemale: 0.489748, medianAge: 39 },
|
||||
"2917508": { population: 82590, ageBands: { "0-17": 21086, "18-29": 14803, "30-59": 34410, "60+": 12291 }, sexShareFemale: 0.522158, medianAge: 34 },
|
||||
"2917607": { population: 45964, ageBands: { "0-17": 12205, "18-29": 8336, "30-59": 18135, "60+": 7288 }, sexShareFemale: 0.517253, medianAge: 34 },
|
||||
"2917706": { population: 32703, ageBands: { "0-17": 7973, "18-29": 5743, "30-59": 13636, "60+": 5351 }, sexShareFemale: 0.498272, medianAge: 35 },
|
||||
"2917805": { population: 17659, ageBands: { "0-17": 4431, "18-29": 3137, "30-59": 7506, "60+": 2585 }, sexShareFemale: 0.500595, medianAge: 34 },
|
||||
"2917904": { population: 9285, ageBands: { "0-17": 2818, "18-29": 1798, "30-59": 3472, "60+": 1197 }, sexShareFemale: 0.499192, medianAge: 30 },
|
||||
"2918001": { population: 158813, ageBands: { "0-17": 39567, "18-29": 27462, "30-59": 66669, "60+": 25115 }, sexShareFemale: 0.520789, medianAge: 35 },
|
||||
"2918100": { population: 37626, ageBands: { "0-17": 9769, "18-29": 6876, "30-59": 15000, "60+": 5981 }, sexShareFemale: 0.505209, medianAge: 34 },
|
||||
"2918209": { population: 13629, ageBands: { "0-17": 3286, "18-29": 2347, "30-59": 5820, "60+": 2176 }, sexShareFemale: 0.500624, medianAge: 35 },
|
||||
"2918308": { population: 14355, ageBands: { "0-17": 3548, "18-29": 2474, "30-59": 5730, "60+": 2603 }, sexShareFemale: 0.493487, medianAge: 35 },
|
||||
"2918357": { population: 24854, ageBands: { "0-17": 7320, "18-29": 4621, "30-59": 9648, "60+": 3265 }, sexShareFemale: 0.498954, medianAge: 31 },
|
||||
"2918407": { population: 237821, ageBands: { "0-17": 68289, "18-29": 48381, "30-59": 95014, "60+": 26137 }, sexShareFemale: 0.511914, medianAge: 30 },
|
||||
"2918456": { population: 9655, ageBands: { "0-17": 2533, "18-29": 1560, "30-59": 3885, "60+": 1677 }, sexShareFemale: 0.473123, medianAge: 35 },
|
||||
"2918506": { population: 16354, ageBands: { "0-17": 4443, "18-29": 2793, "30-59": 6498, "60+": 2620 }, sexShareFemale: 0.486976, medianAge: 34 },
|
||||
"2918555": { population: 5888, ageBands: { "0-17": 1439, "18-29": 995, "30-59": 2344, "60+": 1110 }, sexShareFemale: 0.496433, medianAge: 36 },
|
||||
"2918605": { population: 7379, ageBands: { "0-17": 1374, "18-29": 1067, "30-59": 3183, "60+": 1755 }, sexShareFemale: 0.510367, medianAge: 41 },
|
||||
"2918704": { population: 4075, ageBands: { "0-17": 894, "18-29": 599, "30-59": 1672, "60+": 910 }, sexShareFemale: 0.489325, medianAge: 38 },
|
||||
"2918753": { population: 14105, ageBands: { "0-17": 3198, "18-29": 2816, "30-59": 5777, "60+": 2314 }, sexShareFemale: 0.490890, medianAge: 34 },
|
||||
"2918803": { population: 21052, ageBands: { "0-17": 5178, "18-29": 3687, "30-59": 9121, "60+": 3066 }, sexShareFemale: 0.502280, medianAge: 35 },
|
||||
"2918902": { population: 3845, ageBands: { "0-17": 946, "18-29": 555, "30-59": 1574, "60+": 770 }, sexShareFemale: 0.507932, medianAge: 38 },
|
||||
"2919009": { population: 3527, ageBands: { "0-17": 904, "18-29": 558, "30-59": 1456, "60+": 609 }, sexShareFemale: 0.502694, medianAge: 36 },
|
||||
"2919058": { population: 7494, ageBands: { "0-17": 1772, "18-29": 1277, "30-59": 3113, "60+": 1332 }, sexShareFemale: 0.500133, medianAge: 36 },
|
||||
"2919108": { population: 9015, ageBands: { "0-17": 2268, "18-29": 1616, "30-59": 3556, "60+": 1575 }, sexShareFemale: 0.513810, medianAge: 34 },
|
||||
"2919157": { population: 25736, ageBands: { "0-17": 6857, "18-29": 4734, "30-59": 10256, "60+": 3889 }, sexShareFemale: 0.501515, medianAge: 33 },
|
||||
"2919207": { population: 203331, ageBands: { "0-17": 47931, "18-29": 35337, "30-59": 94593, "60+": 25470 }, sexShareFemale: 0.531385, medianAge: 35 },
|
||||
"2919306": { population: 10774, ageBands: { "0-17": 3007, "18-29": 2091, "30-59": 4440, "60+": 1236 }, sexShareFemale: 0.507240, medianAge: 31 },
|
||||
"2919405": { population: 11834, ageBands: { "0-17": 2671, "18-29": 1776, "30-59": 4863, "60+": 2524 }, sexShareFemale: 0.494761, medianAge: 38 },
|
||||
"2919504": { population: 43903, ageBands: { "0-17": 9955, "18-29": 7583, "30-59": 18979, "60+": 7386 }, sexShareFemale: 0.499009, medianAge: 36 },
|
||||
"2919553": { population: 107909, ageBands: { "0-17": 32888, "18-29": 25039, "30-59": 44439, "60+": 5543 }, sexShareFemale: 0.498513, medianAge: 28 },
|
||||
"2919603": { population: 10454, ageBands: { "0-17": 2821, "18-29": 1676, "30-59": 4056, "60+": 1901 }, sexShareFemale: 0.511575, medianAge: 35 },
|
||||
"2919702": { population: 21599, ageBands: { "0-17": 4843, "18-29": 3752, "30-59": 9849, "60+": 3155 }, sexShareFemale: 0.512616, medianAge: 35 },
|
||||
"2919801": { population: 41859, ageBands: { "0-17": 10015, "18-29": 7669, "30-59": 16536, "60+": 7639 }, sexShareFemale: 0.503787, medianAge: 35 },
|
||||
"2919900": { population: 7256, ageBands: { "0-17": 2038, "18-29": 1286, "30-59": 2739, "60+": 1193 }, sexShareFemale: 0.499862, medianAge: 33 },
|
||||
"2919926": { population: 18504, ageBands: { "0-17": 4347, "18-29": 3267, "30-59": 8282, "60+": 2608 }, sexShareFemale: 0.530588, medianAge: 36 },
|
||||
"2919959": { population: 6973, ageBands: { "0-17": 1536, "18-29": 1092, "30-59": 2799, "60+": 1546 }, sexShareFemale: 0.493331, medianAge: 39 },
|
||||
"2920007": { population: 8731, ageBands: { "0-17": 2221, "18-29": 1546, "30-59": 3531, "60+": 1433 }, sexShareFemale: 0.499256, medianAge: 35 },
|
||||
"2920106": { population: 17674, ageBands: { "0-17": 4024, "18-29": 3066, "30-59": 7094, "60+": 3490 }, sexShareFemale: 0.503791, medianAge: 37 },
|
||||
"2920205": { population: 15398, ageBands: { "0-17": 4502, "18-29": 2851, "30-59": 5973, "60+": 2072 }, sexShareFemale: 0.490843, medianAge: 31 },
|
||||
"2920304": { population: 8670, ageBands: { "0-17": 1942, "18-29": 1489, "30-59": 3629, "60+": 1610 }, sexShareFemale: 0.490081, medianAge: 37 },
|
||||
"2920403": { population: 13860, ageBands: { "0-17": 3453, "18-29": 2404, "30-59": 5365, "60+": 2638 }, sexShareFemale: 0.493939, medianAge: 36 },
|
||||
"2920452": { population: 13919, ageBands: { "0-17": 3516, "18-29": 2801, "30-59": 5682, "60+": 1920 }, sexShareFemale: 0.485523, medianAge: 32 },
|
||||
"2920502": { population: 27620, ageBands: { "0-17": 6700, "18-29": 4997, "30-59": 11364, "60+": 4559 }, sexShareFemale: 0.509124, medianAge: 35 },
|
||||
"2920601": { population: 35859, ageBands: { "0-17": 8500, "18-29": 6268, "30-59": 15677, "60+": 5414 }, sexShareFemale: 0.511726, medianAge: 36 },
|
||||
"2920700": { population: 24527, ageBands: { "0-17": 7062, "18-29": 4917, "30-59": 9996, "60+": 2552 }, sexShareFemale: 0.489787, medianAge: 30 },
|
||||
"2920809": { population: 9267, ageBands: { "0-17": 2477, "18-29": 1640, "30-59": 3636, "60+": 1514 }, sexShareFemale: 0.500917, medianAge: 33 },
|
||||
"2920908": { population: 13544, ageBands: { "0-17": 3547, "18-29": 2364, "30-59": 5230, "60+": 2403 }, sexShareFemale: 0.496899, medianAge: 34 },
|
||||
"2921005": { population: 42566, ageBands: { "0-17": 11187, "18-29": 7588, "30-59": 18313, "60+": 5478 }, sexShareFemale: 0.522107, medianAge: 34 },
|
||||
"2921054": { population: 10330, ageBands: { "0-17": 2678, "18-29": 1969, "30-59": 4058, "60+": 1625 }, sexShareFemale: 0.497967, medianAge: 33 },
|
||||
"2921104": { population: 22194, ageBands: { "0-17": 5479, "18-29": 3626, "30-59": 8949, "60+": 4140 }, sexShareFemale: 0.495720, medianAge: 36 },
|
||||
"2921203": { population: 24661, ageBands: { "0-17": 6236, "18-29": 3930, "30-59": 9963, "60+": 4532 }, sexShareFemale: 0.507765, medianAge: 36 },
|
||||
"2921302": { population: 11071, ageBands: { "0-17": 2822, "18-29": 2024, "30-59": 4438, "60+": 1787 }, sexShareFemale: 0.512691, medianAge: 34 },
|
||||
"2921401": { population: 15734, ageBands: { "0-17": 4141, "18-29": 2772, "30-59": 6251, "60+": 2570 }, sexShareFemale: 0.500636, medianAge: 34 },
|
||||
"2921450": { population: 10187, ageBands: { "0-17": 2391, "18-29": 1783, "30-59": 4122, "60+": 1891 }, sexShareFemale: 0.483656, medianAge: 36 },
|
||||
"2921500": { population: 47780, ageBands: { "0-17": 12185, "18-29": 8290, "30-59": 19131, "60+": 8174 }, sexShareFemale: 0.493428, medianAge: 35 },
|
||||
"2921609": { population: 7996, ageBands: { "0-17": 2139, "18-29": 1298, "30-59": 3016, "60+": 1543 }, sexShareFemale: 0.487869, medianAge: 35 },
|
||||
"2921708": { population: 33594, ageBands: { "0-17": 9427, "18-29": 5745, "30-59": 13183, "60+": 5239 }, sexShareFemale: 0.505299, medianAge: 33 },
|
||||
"2921807": { population: 11143, ageBands: { "0-17": 2504, "18-29": 1500, "30-59": 4578, "60+": 2561 }, sexShareFemale: 0.507224, medianAge: 40 },
|
||||
"2921906": { population: 12137, ageBands: { "0-17": 3241, "18-29": 2191, "30-59": 5019, "60+": 1686 }, sexShareFemale: 0.506056, medianAge: 33 },
|
||||
"2922003": { population: 37977, ageBands: { "0-17": 10822, "18-29": 6829, "30-59": 15506, "60+": 4820 }, sexShareFemale: 0.504832, medianAge: 32 },
|
||||
"2922052": { population: 13152, ageBands: { "0-17": 3805, "18-29": 2729, "30-59": 5033, "60+": 1585 }, sexShareFemale: 0.501597, medianAge: 30 },
|
||||
"2922102": { population: 17305, ageBands: { "0-17": 4459, "18-29": 2901, "30-59": 6778, "60+": 3167 }, sexShareFemale: 0.507252, medianAge: 36 },
|
||||
"2922201": { population: 7190, ageBands: { "0-17": 1522, "18-29": 1134, "30-59": 3306, "60+": 1228 }, sexShareFemale: 0.507789, medianAge: 38 },
|
||||
"2922250": { population: 10443, ageBands: { "0-17": 3178, "18-29": 2057, "30-59": 3893, "60+": 1315 }, sexShareFemale: 0.473044, medianAge: 29 },
|
||||
"2922300": { population: 28707, ageBands: { "0-17": 6765, "18-29": 4908, "30-59": 12441, "60+": 4593 }, sexShareFemale: 0.533285, medianAge: 36 },
|
||||
"2922409": { population: 20037, ageBands: { "0-17": 4852, "18-29": 3478, "30-59": 8468, "60+": 3239 }, sexShareFemale: 0.519289, medianAge: 35 },
|
||||
"2922508": { population: 27060, ageBands: { "0-17": 6498, "18-29": 4648, "30-59": 11894, "60+": 4020 }, sexShareFemale: 0.518514, medianAge: 35 },
|
||||
"2922607": { population: 12063, ageBands: { "0-17": 3331, "18-29": 2057, "30-59": 4947, "60+": 1728 }, sexShareFemale: 0.495648, medianAge: 34 },
|
||||
"2922656": { population: 18523, ageBands: { "0-17": 4867, "18-29": 3678, "30-59": 7491, "60+": 2487 }, sexShareFemale: 0.498029, medianAge: 32 },
|
||||
"2922706": { population: 13715, ageBands: { "0-17": 3296, "18-29": 2185, "30-59": 5469, "60+": 2765 }, sexShareFemale: 0.496610, medianAge: 38 },
|
||||
"2922730": { population: 7967, ageBands: { "0-17": 1716, "18-29": 1275, "30-59": 3406, "60+": 1570 }, sexShareFemale: 0.510983, medianAge: 39 },
|
||||
"2922755": { population: 6501, ageBands: { "0-17": 1703, "18-29": 1000, "30-59": 2724, "60+": 1074 }, sexShareFemale: 0.483618, medianAge: 36 },
|
||||
"2922805": { population: 7780, ageBands: { "0-17": 2183, "18-29": 1503, "30-59": 2955, "60+": 1139 }, sexShareFemale: 0.494216, medianAge: 31 },
|
||||
"2922854": { population: 7538, ageBands: { "0-17": 1977, "18-29": 1419, "30-59": 2844, "60+": 1298 }, sexShareFemale: 0.497745, medianAge: 33 },
|
||||
"2922904": { population: 24236, ageBands: { "0-17": 6307, "18-29": 4296, "30-59": 9425, "60+": 4208 }, sexShareFemale: 0.502311, medianAge: 35 },
|
||||
"2923001": { population: 39509, ageBands: { "0-17": 11496, "18-29": 7119, "30-59": 15332, "60+": 5562 }, sexShareFemale: 0.509099, medianAge: 32 },
|
||||
"2923035": { population: 11162, ageBands: { "0-17": 2765, "18-29": 1995, "30-59": 4562, "60+": 1840 }, sexShareFemale: 0.492564, medianAge: 34 },
|
||||
"2923050": { population: 10660, ageBands: { "0-17": 2553, "18-29": 1975, "30-59": 4337, "60+": 1795 }, sexShareFemale: 0.505535, medianAge: 35 },
|
||||
"2923100": { population: 22633, ageBands: { "0-17": 6014, "18-29": 3938, "30-59": 9076, "60+": 3605 }, sexShareFemale: 0.513145, medianAge: 34 },
|
||||
"2923209": { population: 20715, ageBands: { "0-17": 5029, "18-29": 3622, "30-59": 7991, "60+": 4073 }, sexShareFemale: 0.494376, medianAge: 36 },
|
||||
"2923308": { population: 7716, ageBands: { "0-17": 1842, "18-29": 1307, "30-59": 3185, "60+": 1382 }, sexShareFemale: 0.518015, medianAge: 36 },
|
||||
"2923357": { population: 19243, ageBands: { "0-17": 5665, "18-29": 3511, "30-59": 7669, "60+": 2398 }, sexShareFemale: 0.495609, medianAge: 31 },
|
||||
"2923407": { population: 20078, ageBands: { "0-17": 4730, "18-29": 3444, "30-59": 8236, "60+": 3668 }, sexShareFemale: 0.494422, medianAge: 36 },
|
||||
"2923506": { population: 10339, ageBands: { "0-17": 2786, "18-29": 1920, "30-59": 4185, "60+": 1448 }, sexShareFemale: 0.506239, medianAge: 32 },
|
||||
"2923605": { population: 20351, ageBands: { "0-17": 4294, "18-29": 3506, "30-59": 8616, "60+": 3935 }, sexShareFemale: 0.502825, medianAge: 37 },
|
||||
"2923704": { population: 29252, ageBands: { "0-17": 8621, "18-29": 5050, "30-59": 11103, "60+": 4478 }, sexShareFemale: 0.497675, medianAge: 32 },
|
||||
"2923803": { population: 26604, ageBands: { "0-17": 6229, "18-29": 5000, "30-59": 10888, "60+": 4487 }, sexShareFemale: 0.509585, medianAge: 34 },
|
||||
"2923902": { population: 9370, ageBands: { "0-17": 2815, "18-29": 1584, "30-59": 3473, "60+": 1498 }, sexShareFemale: 0.493703, medianAge: 32 },
|
||||
"2924009": { population: 112870, ageBands: { "0-17": 29258, "18-29": 20648, "30-59": 46732, "60+": 16232 }, sexShareFemale: 0.524311, medianAge: 33 },
|
||||
"2924058": { population: 13243, ageBands: { "0-17": 3196, "18-29": 2154, "30-59": 5470, "60+": 2423 }, sexShareFemale: 0.509703, medianAge: 36 },
|
||||
"2924108": { population: 6235, ageBands: { "0-17": 1540, "18-29": 1119, "30-59": 2427, "60+": 1149 }, sexShareFemale: 0.517723, medianAge: 35 },
|
||||
"2924207": { population: 13954, ageBands: { "0-17": 4077, "18-29": 2843, "30-59": 5456, "60+": 1578 }, sexShareFemale: 0.498208, medianAge: 30 },
|
||||
"2924306": { population: 20086, ageBands: { "0-17": 5112, "18-29": 3307, "30-59": 7758, "60+": 3909 }, sexShareFemale: 0.513641, medianAge: 35 },
|
||||
"2924405": { population: 35357, ageBands: { "0-17": 11121, "18-29": 6421, "30-59": 12900, "60+": 4915 }, sexShareFemale: 0.490115, medianAge: 30 },
|
||||
"2924504": { population: 14731, ageBands: { "0-17": 3423, "18-29": 2404, "30-59": 6039, "60+": 2865 }, sexShareFemale: 0.492838, medianAge: 37 },
|
||||
"2924603": { population: 19083, ageBands: { "0-17": 5261, "18-29": 3398, "30-59": 7532, "60+": 2892 }, sexShareFemale: 0.503432, medianAge: 33 },
|
||||
"2924652": { population: 10325, ageBands: { "0-17": 2336, "18-29": 1957, "30-59": 4076, "60+": 1956 }, sexShareFemale: 0.500726, medianAge: 35 },
|
||||
"2924678": { population: 10974, ageBands: { "0-17": 2825, "18-29": 2283, "30-59": 4532, "60+": 1334 }, sexShareFemale: 0.487425, medianAge: 32 },
|
||||
"2924702": { population: 9152, ageBands: { "0-17": 2103, "18-29": 1337, "30-59": 3677, "60+": 2035 }, sexShareFemale: 0.510708, medianAge: 39 },
|
||||
"2924801": { population: 17566, ageBands: { "0-17": 4461, "18-29": 2900, "30-59": 6898, "60+": 3307 }, sexShareFemale: 0.507799, medianAge: 36 },
|
||||
"2924900": { population: 8022, ageBands: { "0-17": 1897, "18-29": 1268, "30-59": 3310, "60+": 1547 }, sexShareFemale: 0.485914, medianAge: 37 },
|
||||
"2925006": { population: 23334, ageBands: { "0-17": 5885, "18-29": 3993, "30-59": 9442, "60+": 4014 }, sexShareFemale: 0.515514, medianAge: 35 },
|
||||
"2925105": { population: 48293, ageBands: { "0-17": 12384, "18-29": 8718, "30-59": 18642, "60+": 8549 }, sexShareFemale: 0.512435, medianAge: 34 },
|
||||
"2925204": { population: 32136, ageBands: { "0-17": 7837, "18-29": 5429, "30-59": 14450, "60+": 4420 }, sexShareFemale: 0.531927, medianAge: 35 },
|
||||
"2925253": { population: 17938, ageBands: { "0-17": 4911, "18-29": 3367, "30-59": 7008, "60+": 2652 }, sexShareFemale: 0.505853, medianAge: 32 },
|
||||
"2925303": { population: 168326, ageBands: { "0-17": 48481, "18-29": 34456, "30-59": 69723, "60+": 15666 }, sexShareFemale: 0.508715, medianAge: 30 },
|
||||
"2925402": { population: 10274, ageBands: { "0-17": 2762, "18-29": 1864, "30-59": 4083, "60+": 1565 }, sexShareFemale: 0.495523, medianAge: 33 },
|
||||
"2925501": { population: 35003, ageBands: { "0-17": 10352, "18-29": 6554, "30-59": 13341, "60+": 4756 }, sexShareFemale: 0.494243, medianAge: 31 },
|
||||
"2925600": { population: 15130, ageBands: { "0-17": 3544, "18-29": 2489, "30-59": 6537, "60+": 2560 }, sexShareFemale: 0.498612, medianAge: 37 },
|
||||
"2925709": { population: 12621, ageBands: { "0-17": 2808, "18-29": 2077, "30-59": 5104, "60+": 2632 }, sexShareFemale: 0.491245, medianAge: 38 },
|
||||
"2925758": { population: 27734, ageBands: { "0-17": 7897, "18-29": 5445, "30-59": 11065, "60+": 3327 }, sexShareFemale: 0.495637, medianAge: 31 },
|
||||
"2925808": { population: 25988, ageBands: { "0-17": 6522, "18-29": 4758, "30-59": 10251, "60+": 4457 }, sexShareFemale: 0.506195, medianAge: 35 },
|
||||
"2925907": { population: 25272, ageBands: { "0-17": 6288, "18-29": 4451, "30-59": 9967, "60+": 4566 }, sexShareFemale: 0.489316, medianAge: 35 },
|
||||
"2925931": { population: 9461, ageBands: { "0-17": 2183, "18-29": 1616, "30-59": 3863, "60+": 1799 }, sexShareFemale: 0.495719, medianAge: 36 },
|
||||
"2925956": { population: 19662, ageBands: { "0-17": 4992, "18-29": 3504, "30-59": 7482, "60+": 3684 }, sexShareFemale: 0.518309, medianAge: 35 },
|
||||
"2926004": { population: 40586, ageBands: { "0-17": 10952, "18-29": 7109, "30-59": 16078, "60+": 6447 }, sexShareFemale: 0.501331, medianAge: 34 },
|
||||
"2926103": { population: 13651, ageBands: { "0-17": 3480, "18-29": 2276, "30-59": 5693, "60+": 2202 }, sexShareFemale: 0.510219, medianAge: 35 },
|
||||
"2926202": { population: 21642, ageBands: { "0-17": 6076, "18-29": 3692, "30-59": 8501, "60+": 3373 }, sexShareFemale: 0.478837, medianAge: 33 },
|
||||
"2926301": { population: 33386, ageBands: { "0-17": 7655, "18-29": 5620, "30-59": 13929, "60+": 6182 }, sexShareFemale: 0.515366, medianAge: 37 },
|
||||
"2926400": { population: 30711, ageBands: { "0-17": 7510, "18-29": 5484, "30-59": 12379, "60+": 5338 }, sexShareFemale: 0.497346, medianAge: 35 },
|
||||
"2926509": { population: 13841, ageBands: { "0-17": 3587, "18-29": 2669, "30-59": 5413, "60+": 2172 }, sexShareFemale: 0.492450, medianAge: 33 },
|
||||
"2926608": { population: 54010, ageBands: { "0-17": 13519, "18-29": 10133, "30-59": 21835, "60+": 8523 }, sexShareFemale: 0.511757, medianAge: 34 },
|
||||
"2926657": { population: 9740, ageBands: { "0-17": 2538, "18-29": 1690, "30-59": 3933, "60+": 1579 }, sexShareFemale: 0.473717, medianAge: 35 },
|
||||
"2926707": { population: 13184, ageBands: { "0-17": 2762, "18-29": 1993, "30-59": 5449, "60+": 2980 }, sexShareFemale: 0.509481, medianAge: 39 },
|
||||
"2926806": { population: 13146, ageBands: { "0-17": 2799, "18-29": 2219, "30-59": 5536, "60+": 2592 }, sexShareFemale: 0.498479, medianAge: 38 },
|
||||
"2926905": { population: 10497, ageBands: { "0-17": 2229, "18-29": 1763, "30-59": 4274, "60+": 2231 }, sexShareFemale: 0.508717, medianAge: 38 },
|
||||
"2927002": { population: 35362, ageBands: { "0-17": 9518, "18-29": 6450, "30-59": 14363, "60+": 5031 }, sexShareFemale: 0.504072, medianAge: 33 },
|
||||
"2927101": { population: 10308, ageBands: { "0-17": 3349, "18-29": 2105, "30-59": 3795, "60+": 1059 }, sexShareFemale: 0.507179, medianAge: 28 },
|
||||
"2927200": { population: 28282, ageBands: { "0-17": 7219, "18-29": 5250, "30-59": 10954, "60+": 4859 }, sexShareFemale: 0.521993, medianAge: 34 },
|
||||
"2927309": { population: 14987, ageBands: { "0-17": 3898, "18-29": 2494, "30-59": 6295, "60+": 2300 }, sexShareFemale: 0.521652, medianAge: 35 },
|
||||
"2927408": { population: 2417678, ageBands: { "0-17": 493892, "18-29": 413081, "30-59": 1111630, "60+": 399075 }, sexShareFemale: 0.544034, medianAge: 38 },
|
||||
"2927507": { population: 20952, ageBands: { "0-17": 5218, "18-29": 4008, "30-59": 8476, "60+": 3250 }, sexShareFemale: 0.507875, medianAge: 34 },
|
||||
"2927606": { population: 14965, ageBands: { "0-17": 4060, "18-29": 2670, "30-59": 5997, "60+": 2238 }, sexShareFemale: 0.496893, medianAge: 33 },
|
||||
"2927705": { population: 29185, ageBands: { "0-17": 8322, "18-29": 5141, "30-59": 12032, "60+": 3690 }, sexShareFemale: 0.504163, medianAge: 32 },
|
||||
"2927804": { population: 4681, ageBands: { "0-17": 1200, "18-29": 747, "30-59": 1831, "60+": 903 }, sexShareFemale: 0.500748, medianAge: 36 },
|
||||
"2927903": { population: 10300, ageBands: { "0-17": 2475, "18-29": 1760, "30-59": 4157, "60+": 1908 }, sexShareFemale: 0.505243, medianAge: 36 },
|
||||
"2928000": { population: 37834, ageBands: { "0-17": 9878, "18-29": 7021, "30-59": 15192, "60+": 5743 }, sexShareFemale: 0.506238, medianAge: 33 },
|
||||
"2928059": { population: 13896, ageBands: { "0-17": 3756, "18-29": 2419, "30-59": 5710, "60+": 2011 }, sexShareFemale: 0.474669, medianAge: 33 },
|
||||
"2928109": { population: 38604, ageBands: { "0-17": 9952, "18-29": 6711, "30-59": 15757, "60+": 6184 }, sexShareFemale: 0.500699, medianAge: 34 },
|
||||
"2928208": { population: 24755, ageBands: { "0-17": 6150, "18-29": 4178, "30-59": 9756, "60+": 4671 }, sexShareFemale: 0.491699, medianAge: 36 },
|
||||
"2928307": { population: 8716, ageBands: { "0-17": 2172, "18-29": 1504, "30-59": 3463, "60+": 1577 }, sexShareFemale: 0.525585, medianAge: 35 },
|
||||
"2928406": { population: 27390, ageBands: { "0-17": 7900, "18-29": 4755, "30-59": 10291, "60+": 4444 }, sexShareFemale: 0.488682, medianAge: 32 },
|
||||
"2928505": { population: 10441, ageBands: { "0-17": 2460, "18-29": 1813, "30-59": 4253, "60+": 1915 }, sexShareFemale: 0.516521, medianAge: 36 },
|
||||
"2928604": { population: 56012, ageBands: { "0-17": 12937, "18-29": 9190, "30-59": 24240, "60+": 9645 }, sexShareFemale: 0.529101, medianAge: 37 },
|
||||
"2928703": { population: 103055, ageBands: { "0-17": 24024, "18-29": 19620, "30-59": 46527, "60+": 12884 }, sexShareFemale: 0.533404, medianAge: 34 },
|
||||
"2928802": { population: 52276, ageBands: { "0-17": 14362, "18-29": 9903, "30-59": 20635, "60+": 7376 }, sexShareFemale: 0.525710, medianAge: 32 },
|
||||
"2928901": { population: 32828, ageBands: { "0-17": 9933, "18-29": 6779, "30-59": 12659, "60+": 3457 }, sexShareFemale: 0.485957, medianAge: 29 },
|
||||
"2928950": { population: 8426, ageBands: { "0-17": 1860, "18-29": 1308, "30-59": 3544, "60+": 1714 }, sexShareFemale: 0.508782, medianAge: 39 },
|
||||
"2929008": { population: 11026, ageBands: { "0-17": 2535, "18-29": 1896, "30-59": 4763, "60+": 1832 }, sexShareFemale: 0.522492, medianAge: 37 },
|
||||
"2929057": { population: 15194, ageBands: { "0-17": 4161, "18-29": 2746, "30-59": 6156, "60+": 2131 }, sexShareFemale: 0.500856, medianAge: 33 },
|
||||
"2929107": { population: 20283, ageBands: { "0-17": 4511, "18-29": 3369, "30-59": 8968, "60+": 3435 }, sexShareFemale: 0.511956, medianAge: 37 },
|
||||
"2929206": { population: 38733, ageBands: { "0-17": 10176, "18-29": 7243, "30-59": 16858, "60+": 4456 }, sexShareFemale: 0.529987, medianAge: 33 },
|
||||
"2929255": { population: 18600, ageBands: { "0-17": 4879, "18-29": 3238, "30-59": 7572, "60+": 2911 }, sexShareFemale: 0.492097, medianAge: 34 },
|
||||
"2929305": { population: 39513, ageBands: { "0-17": 10143, "18-29": 7110, "30-59": 17096, "60+": 5164 }, sexShareFemale: 0.527953, medianAge: 34 },
|
||||
"2929354": { population: 5315, ageBands: { "0-17": 1392, "18-29": 856, "30-59": 2106, "60+": 961 }, sexShareFemale: 0.496143, medianAge: 35 },
|
||||
"2929370": { population: 10187, ageBands: { "0-17": 2662, "18-29": 1744, "30-59": 4021, "60+": 1760 }, sexShareFemale: 0.505252, medianAge: 35 },
|
||||
"2929404": { population: 10334, ageBands: { "0-17": 2343, "18-29": 1877, "30-59": 4357, "60+": 1757 }, sexShareFemale: 0.500194, medianAge: 36 },
|
||||
"2929503": { population: 40958, ageBands: { "0-17": 9638, "18-29": 6922, "30-59": 18061, "60+": 6337 }, sexShareFemale: 0.526515, medianAge: 36 },
|
||||
"2929602": { population: 17963, ageBands: { "0-17": 4220, "18-29": 3154, "30-59": 7793, "60+": 2796 }, sexShareFemale: 0.517787, medianAge: 35 },
|
||||
"2929701": { population: 16008, ageBands: { "0-17": 4337, "18-29": 3039, "30-59": 6143, "60+": 2489 }, sexShareFemale: 0.505185, medianAge: 33 },
|
||||
"2929750": { population: 11438, ageBands: { "0-17": 2680, "18-29": 1702, "30-59": 4912, "60+": 2144 }, sexShareFemale: 0.525616, medianAge: 38 },
|
||||
"2929800": { population: 10478, ageBands: { "0-17": 2676, "18-29": 1648, "30-59": 4134, "60+": 2020 }, sexShareFemale: 0.511739, medianAge: 37 },
|
||||
"2929909": { population: 46160, ageBands: { "0-17": 12172, "18-29": 9073, "30-59": 18310, "60+": 6605 }, sexShareFemale: 0.512630, medianAge: 32 },
|
||||
"2930006": { population: 9360, ageBands: { "0-17": 2288, "18-29": 1482, "30-59": 3887, "60+": 1703 }, sexShareFemale: 0.494872, medianAge: 36 },
|
||||
"2930105": { population: 74523, ageBands: { "0-17": 18886, "18-29": 13064, "30-59": 30939, "60+": 11634 }, sexShareFemale: 0.522000, medianAge: 35 },
|
||||
"2930154": { population: 34222, ageBands: { "0-17": 10616, "18-29": 6288, "30-59": 12769, "60+": 4549 }, sexShareFemale: 0.492344, medianAge: 30 },
|
||||
"2930204": { population: 38154, ageBands: { "0-17": 11650, "18-29": 7029, "30-59": 14519, "60+": 4956 }, sexShareFemale: 0.484222, medianAge: 30 },
|
||||
"2930303": { population: 17066, ageBands: { "0-17": 4213, "18-29": 2601, "30-59": 7036, "60+": 3216 }, sexShareFemale: 0.487402, medianAge: 37 },
|
||||
"2930402": { population: 17996, ageBands: { "0-17": 4098, "18-29": 3260, "30-59": 7259, "60+": 3379 }, sexShareFemale: 0.516948, medianAge: 36 },
|
||||
"2930501": { population: 80435, ageBands: { "0-17": 20421, "18-29": 14858, "30-59": 33908, "60+": 11248 }, sexShareFemale: 0.521713, medianAge: 34 },
|
||||
"2930600": { population: 13335, ageBands: { "0-17": 3263, "18-29": 2123, "30-59": 5462, "60+": 2487 }, sexShareFemale: 0.511961, medianAge: 36 },
|
||||
"2930709": { population: 114559, ageBands: { "0-17": 30150, "18-29": 21250, "30-59": 49829, "60+": 13330 }, sexShareFemale: 0.525066, medianAge: 33 },
|
||||
"2930758": { population: 13408, ageBands: { "0-17": 4262, "18-29": 2584, "30-59": 4919, "60+": 1643 }, sexShareFemale: 0.486202, medianAge: 29 },
|
||||
"2930766": { population: 14773, ageBands: { "0-17": 3313, "18-29": 2879, "30-59": 6003, "60+": 2578 }, sexShareFemale: 0.492656, medianAge: 35 },
|
||||
"2930774": { population: 25475, ageBands: { "0-17": 7335, "18-29": 4907, "30-59": 9788, "60+": 3445 }, sexShareFemale: 0.510304, medianAge: 31 },
|
||||
"2930808": { population: 17054, ageBands: { "0-17": 4591, "18-29": 3574, "30-59": 6586, "60+": 2303 }, sexShareFemale: 0.500938, medianAge: 31 },
|
||||
"2930907": { population: 11979, ageBands: { "0-17": 3077, "18-29": 2168, "30-59": 4717, "60+": 2017 }, sexShareFemale: 0.484515, medianAge: 34 },
|
||||
"2931004": { population: 21006, ageBands: { "0-17": 4843, "18-29": 3502, "30-59": 8884, "60+": 3777 }, sexShareFemale: 0.498429, medianAge: 36 },
|
||||
"2931053": { population: 17158, ageBands: { "0-17": 4318, "18-29": 3272, "30-59": 6763, "60+": 2805 }, sexShareFemale: 0.501748, medianAge: 34 },
|
||||
"2931103": { population: 7717, ageBands: { "0-17": 1790, "18-29": 1441, "30-59": 3047, "60+": 1439 }, sexShareFemale: 0.514189, medianAge: 36 },
|
||||
"2931202": { population: 18044, ageBands: { "0-17": 5161, "18-29": 3267, "30-59": 7136, "60+": 2480 }, sexShareFemale: 0.501607, medianAge: 32 },
|
||||
"2931301": { population: 15818, ageBands: { "0-17": 4528, "18-29": 3137, "30-59": 6110, "60+": 2043 }, sexShareFemale: 0.504046, medianAge: 30 },
|
||||
"2931350": { population: 145216, ageBands: { "0-17": 38258, "18-29": 27127, "30-59": 60099, "60+": 19732 }, sexShareFemale: 0.517932, medianAge: 33 },
|
||||
"2931400": { population: 7110, ageBands: { "0-17": 1549, "18-29": 1254, "30-59": 3075, "60+": 1232 }, sexShareFemale: 0.517018, medianAge: 37 },
|
||||
"2931509": { population: 21176, ageBands: { "0-17": 5630, "18-29": 3878, "30-59": 8528, "60+": 3140 }, sexShareFemale: 0.507934, medianAge: 33 },
|
||||
"2931608": { population: 15332, ageBands: { "0-17": 4259, "18-29": 3162, "30-59": 5984, "60+": 1927 }, sexShareFemale: 0.485977, medianAge: 30 },
|
||||
"2931707": { population: 10798, ageBands: { "0-17": 2564, "18-29": 1991, "30-59": 4453, "60+": 1790 }, sexShareFemale: 0.525097, medianAge: 36 },
|
||||
"2931806": { population: 16296, ageBands: { "0-17": 3577, "18-29": 2457, "30-59": 6580, "60+": 3682 }, sexShareFemale: 0.496011, medianAge: 39 },
|
||||
"2931905": { population: 48736, ageBands: { "0-17": 11712, "18-29": 8456, "30-59": 20426, "60+": 8142 }, sexShareFemale: 0.504473, medianAge: 35 },
|
||||
"2932002": { population: 24665, ageBands: { "0-17": 6078, "18-29": 3846, "30-59": 10122, "60+": 4619 }, sexShareFemale: 0.495358, medianAge: 37 },
|
||||
"2932101": { population: 18626, ageBands: { "0-17": 4584, "18-29": 2884, "30-59": 7755, "60+": 3403 }, sexShareFemale: 0.500376, medianAge: 36 },
|
||||
"2932200": { population: 17596, ageBands: { "0-17": 4817, "18-29": 2960, "30-59": 6939, "60+": 2880 }, sexShareFemale: 0.519152, medianAge: 34 },
|
||||
"2932309": { population: 16094, ageBands: { "0-17": 4226, "18-29": 2569, "30-59": 6244, "60+": 3055 }, sexShareFemale: 0.516341, medianAge: 36 },
|
||||
"2932408": { population: 13432, ageBands: { "0-17": 3027, "18-29": 2220, "30-59": 5784, "60+": 2401 }, sexShareFemale: 0.494491, medianAge: 37 },
|
||||
"2932457": { population: 13642, ageBands: { "0-17": 3899, "18-29": 2529, "30-59": 5398, "60+": 1816 }, sexShareFemale: 0.489811, medianAge: 31 },
|
||||
"2932507": { population: 18131, ageBands: { "0-17": 4663, "18-29": 2805, "30-59": 7350, "60+": 3313 }, sexShareFemale: 0.498924, medianAge: 36 },
|
||||
"2932606": { population: 15355, ageBands: { "0-17": 3538, "18-29": 2654, "30-59": 6316, "60+": 2847 }, sexShareFemale: 0.499446, medianAge: 36 },
|
||||
"2932705": { population: 21420, ageBands: { "0-17": 5858, "18-29": 3578, "30-59": 8632, "60+": 3352 }, sexShareFemale: 0.506676, medianAge: 34 },
|
||||
"2932804": { population: 16277, ageBands: { "0-17": 4420, "18-29": 3058, "30-59": 6391, "60+": 2408 }, sexShareFemale: 0.504331, medianAge: 32 },
|
||||
"2932903": { population: 85655, ageBands: { "0-17": 21598, "18-29": 15646, "30-59": 36102, "60+": 12309 }, sexShareFemale: 0.518300, medianAge: 34 },
|
||||
"2933000": { population: 24362, ageBands: { "0-17": 5751, "18-29": 4019, "30-59": 10544, "60+": 4048 }, sexShareFemale: 0.514572, medianAge: 36 },
|
||||
"2933059": { population: 13800, ageBands: { "0-17": 3216, "18-29": 2383, "30-59": 5587, "60+": 2614 }, sexShareFemale: 0.506087, medianAge: 36 },
|
||||
"2933109": { population: 8101, ageBands: { "0-17": 1821, "18-29": 1277, "30-59": 3360, "60+": 1643 }, sexShareFemale: 0.508209, medianAge: 38 },
|
||||
"2933158": { population: 13377, ageBands: { "0-17": 3639, "18-29": 2296, "30-59": 5255, "60+": 2187 }, sexShareFemale: 0.501682, medianAge: 33 },
|
||||
"2933174": { population: 9913, ageBands: { "0-17": 2288, "18-29": 1704, "30-59": 4281, "60+": 1640 }, sexShareFemale: 0.502169, medianAge: 36 },
|
||||
"2933208": { population: 42529, ageBands: { "0-17": 9661, "18-29": 6943, "30-59": 18070, "60+": 7855 }, sexShareFemale: 0.516259, medianAge: 38 },
|
||||
"2933257": { population: 6003, ageBands: { "0-17": 1473, "18-29": 948, "30-59": 2415, "60+": 1167 }, sexShareFemale: 0.480260, medianAge: 37 },
|
||||
"2933307": { population: 370879, ageBands: { "0-17": 91170, "18-29": 72665, "30-59": 155901, "60+": 51143 }, sexShareFemale: 0.521647, medianAge: 33 },
|
||||
"2933406": { population: 9503, ageBands: { "0-17": 2349, "18-29": 1844, "30-59": 3800, "60+": 1510 }, sexShareFemale: 0.507840, medianAge: 33 },
|
||||
"2933455": { population: 12968, ageBands: { "0-17": 3510, "18-29": 2406, "30-59": 5052, "60+": 2000 }, sexShareFemale: 0.481801, medianAge: 33 },
|
||||
"2933505": { population: 24474, ageBands: { "0-17": 7100, "18-29": 4356, "30-59": 9740, "60+": 3278 }, sexShareFemale: 0.492972, medianAge: 32 },
|
||||
"2933604": { population: 44757, ageBands: { "0-17": 13790, "18-29": 8312, "30-59": 16418, "60+": 6237 }, sexShareFemale: 0.511026, medianAge: 30 },
|
||||
}
|
||||
|
||||
export const demographicsForCode = (codarea: string): MunicipalityDemographics | undefined =>
|
||||
bahiaMunicipalityDemographics[codarea]
|
||||
|
||||
export const demographicsForMunicipalityName = (
|
||||
municipality: string,
|
||||
): MunicipalityDemographics | undefined => {
|
||||
const code = codeForMunicipality(municipality)
|
||||
return code ? demographicsForCode(code) : undefined
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
import type { Where } from 'payload'
|
||||
|
||||
import { tseCityCodeForMunicipality } from '@/lib/bahiaTseCityCodes'
|
||||
import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { tseZonesForCity } from '@/lib/bahiaTseZones'
|
||||
import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation'
|
||||
import { resolveNucleusTerritoryCities } from '@/utilities/nucleusTerritoryCities'
|
||||
import type { NucleusTerritoryCitiesInput } from '@/utilities/nucleusTerritoryCities'
|
||||
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
|
||||
|
||||
export type NucleusElectionGeographyInput = {
|
||||
cities: string[]
|
||||
regions: string[]
|
||||
export type NucleusElectionGeographyInput = NucleusTerritoryCitiesInput & {
|
||||
tseZones: number[]
|
||||
}
|
||||
|
||||
|
|
@ -35,9 +34,6 @@ export type NucleusElectionGeography = {
|
|||
cityZonePairs: NucleusCityZonePair[]
|
||||
}
|
||||
|
||||
const uniqueSortedCities = (values: Iterable<string>): string[] =>
|
||||
[...new Set(values)].sort((left, right) => left.localeCompare(right, 'pt-BR'))
|
||||
|
||||
/**
|
||||
* Resolve the effective city×zone geography for a nucleus baseline query.
|
||||
* Returns null when the nucleus has no usable territory (no cities and no regions),
|
||||
|
|
@ -51,16 +47,7 @@ const uniqueSortedCities = (values: Iterable<string>): string[] =>
|
|||
export const resolveNucleusElectionGeography = (
|
||||
nucleus: NucleusElectionGeographyInput,
|
||||
): NucleusElectionGeography | null => {
|
||||
const citiesFromNucleus = uniqueSortedCities(nucleus.cities.filter((city) => city.length > 0))
|
||||
const cities =
|
||||
citiesFromNucleus.length > 0
|
||||
? citiesFromNucleus
|
||||
: uniqueSortedCities(
|
||||
nucleus.regions.flatMap((region) =>
|
||||
isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : [],
|
||||
),
|
||||
)
|
||||
|
||||
const cities = resolveNucleusTerritoryCities(nucleus)
|
||||
if (cities.length === 0) return null
|
||||
|
||||
const requestedZones = sortedUniqueZoneNumbers(
|
||||
|
|
|
|||
125
src/utilities/nucleusIbgeVoterProfile.ts
Normal file
125
src/utilities/nucleusIbgeVoterProfile.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
demographicsForMunicipalityName,
|
||||
type MunicipalityAgeBandKey,
|
||||
} from '@/lib/bahiaMunicipalityDemographics'
|
||||
import {
|
||||
resolveNucleusTerritoryCities,
|
||||
type NucleusTerritoryCitiesInput,
|
||||
} from '@/utilities/nucleusTerritoryCities'
|
||||
|
||||
export const IBGE_VOTER_PROFILE_LABEL = 'Perfil médio do território'
|
||||
|
||||
export type ComputedVoterProfileViewModel = {
|
||||
label: string
|
||||
ageRange: string
|
||||
localTraits: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export type NucleusIbgeVoterProfileResult =
|
||||
| { status: 'available'; profile: ComputedVoterProfileViewModel }
|
||||
| { status: 'semPerfil' }
|
||||
|
||||
const AGE_BAND_LABELS: Record<MunicipalityAgeBandKey, string> = {
|
||||
'0-17': '0 a 17 anos',
|
||||
'18-29': '18 a 29 anos',
|
||||
'30-59': '30 a 59 anos',
|
||||
'60+': '60 anos ou mais',
|
||||
}
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('pt-BR', {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const integerFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 })
|
||||
|
||||
const formatPercent = (value: number) => percentFormatter.format(value)
|
||||
|
||||
const formatInteger = (value: number) => integerFormatter.format(value)
|
||||
|
||||
const buildSummaryLine = ({
|
||||
dominantBand,
|
||||
dominantSharePercent,
|
||||
medianAge,
|
||||
sexShareFemalePercent,
|
||||
}: {
|
||||
dominantBand: MunicipalityAgeBandKey
|
||||
dominantSharePercent: number
|
||||
medianAge: number | null
|
||||
sexShareFemalePercent: number
|
||||
}) => {
|
||||
const medianText =
|
||||
medianAge == null
|
||||
? 'idade mediana indisponível'
|
||||
: `idade mediana de ${formatInteger(medianAge)} anos`
|
||||
return `Predominam moradores na faixa de ${AGE_BAND_LABELS[dominantBand]} (${formatPercent(dominantSharePercent)}%). ${medianText.charAt(0).toUpperCase()}${medianText.slice(1)}; ${formatPercent(sexShareFemalePercent)}% mulheres.`
|
||||
}
|
||||
|
||||
export const getNucleusIbgeVoterProfile = (
|
||||
input: NucleusTerritoryCitiesInput,
|
||||
): NucleusIbgeVoterProfileResult => {
|
||||
const effectiveCities = resolveNucleusTerritoryCities(input)
|
||||
if (effectiveCities.length === 0) return { status: 'semPerfil' }
|
||||
|
||||
const ageBands: Record<MunicipalityAgeBandKey, number> = {
|
||||
'0-17': 0,
|
||||
'18-29': 0,
|
||||
'30-59': 0,
|
||||
'60+': 0,
|
||||
}
|
||||
let population = 0
|
||||
let femalePopulation = 0
|
||||
let medianAgeWeighted = 0
|
||||
let medianAgeWeight = 0
|
||||
|
||||
for (const city of effectiveCities) {
|
||||
const record = demographicsForMunicipalityName(city)
|
||||
if (!record) continue
|
||||
|
||||
population += record.population
|
||||
femalePopulation += record.population * record.sexShareFemale
|
||||
for (const band of Object.keys(ageBands) as MunicipalityAgeBandKey[]) {
|
||||
ageBands[band] += record.ageBands[band]
|
||||
}
|
||||
if (record.medianAge != null) {
|
||||
medianAgeWeighted += record.medianAge * record.population
|
||||
medianAgeWeight += record.population
|
||||
}
|
||||
}
|
||||
|
||||
if (population <= 0) return { status: 'semPerfil' }
|
||||
|
||||
const ageBandShares = {} as Record<MunicipalityAgeBandKey, number>
|
||||
for (const band of Object.keys(ageBands) as MunicipalityAgeBandKey[]) {
|
||||
ageBandShares[band] = (ageBands[band] / population) * 100
|
||||
}
|
||||
|
||||
const dominantBand = (Object.keys(ageBandShares) as MunicipalityAgeBandKey[]).reduce(
|
||||
(best, band) => (ageBandShares[band] > ageBandShares[best] ? band : best),
|
||||
'0-17' as MunicipalityAgeBandKey,
|
||||
)
|
||||
const sexShareFemalePercent = (femalePopulation / population) * 100
|
||||
const medianAge = medianAgeWeight > 0 ? medianAgeWeighted / medianAgeWeight : null
|
||||
|
||||
const ageRange = (Object.keys(AGE_BAND_LABELS) as MunicipalityAgeBandKey[])
|
||||
.map((band) => `${AGE_BAND_LABELS[band]}: ${formatPercent(ageBandShares[band])}%`)
|
||||
.join(' · ')
|
||||
|
||||
const notes = `População residente estimada: ${formatInteger(population)} habitantes (Censo 2022, média ponderada de ${effectiveCities.length} município${effectiveCities.length === 1 ? '' : 's'}).`
|
||||
|
||||
return {
|
||||
status: 'available',
|
||||
profile: {
|
||||
label: IBGE_VOTER_PROFILE_LABEL,
|
||||
ageRange,
|
||||
localTraits: buildSummaryLine({
|
||||
dominantBand,
|
||||
dominantSharePercent: ageBandShares[dominantBand],
|
||||
medianAge,
|
||||
sexShareFemalePercent,
|
||||
}),
|
||||
notes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,49 @@ import {
|
|||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
|
||||
export type VoterProfileWriteInput = {
|
||||
label: string
|
||||
ageRange?: string | null
|
||||
incomeBand?: string | null
|
||||
occupation?: string | null
|
||||
localTraits?: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export type VoterProfileWriteShape = {
|
||||
label: string
|
||||
ageRange?: string
|
||||
incomeBand?: string
|
||||
occupation?: string
|
||||
localTraits?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export const voterProfileNullsToUndefined = (
|
||||
profile: VoterProfileWriteInput,
|
||||
): VoterProfileWriteShape => ({
|
||||
label: profile.label,
|
||||
ageRange: profile.ageRange ?? undefined,
|
||||
incomeBand: profile.incomeBand ?? undefined,
|
||||
occupation: profile.occupation ?? undefined,
|
||||
localTraits: profile.localTraits ?? undefined,
|
||||
notes: profile.notes ?? undefined,
|
||||
})
|
||||
|
||||
export const normalizedVoterProfilesForWrite = (
|
||||
profiles: VoterProfileWriteInput[],
|
||||
): VoterProfileWriteShape[] =>
|
||||
profiles
|
||||
.map((profile) => ({
|
||||
label: profile.label.trim(),
|
||||
ageRange: profile.ageRange?.trim() || undefined,
|
||||
incomeBand: profile.incomeBand?.trim() || undefined,
|
||||
occupation: profile.occupation?.trim() || undefined,
|
||||
localTraits: profile.localTraits?.trim() || undefined,
|
||||
notes: profile.notes?.trim() || undefined,
|
||||
}))
|
||||
.filter(({ label }) => Boolean(label))
|
||||
|
||||
export const parseNucleusIntelligenceFormData = (formData: FormData): NucleusUpdateInput => {
|
||||
const hasTicketAlliance = ['partnerName', 'office', 'isCampaignPartner', 'allianceNotes'].some(
|
||||
(field) => formData.has(field),
|
||||
|
|
|
|||
24
src/utilities/nucleusTerritoryCities.ts
Normal file
24
src/utilities/nucleusTerritoryCities.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { citiesForTerritory, isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
|
||||
export type NucleusTerritoryCitiesInput = {
|
||||
cities: string[]
|
||||
regions: string[]
|
||||
}
|
||||
|
||||
const uniqueSortedCities = (values: Iterable<string>): string[] =>
|
||||
[...new Set(values)].sort((left, right) => left.localeCompare(right, 'pt-BR'))
|
||||
|
||||
/** Cities explicitly set on the nucleus, or all cities from selected identity territories. */
|
||||
export const resolveNucleusTerritoryCities = ({
|
||||
cities,
|
||||
regions,
|
||||
}: NucleusTerritoryCitiesInput): string[] => {
|
||||
const citiesFromNucleus = uniqueSortedCities([...cities].filter((city) => city.length > 0))
|
||||
if (citiesFromNucleus.length > 0) return citiesFromNucleus
|
||||
|
||||
return uniqueSortedCities(
|
||||
regions.flatMap((region) =>
|
||||
isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : [],
|
||||
),
|
||||
)
|
||||
}
|
||||
5010
tests/fixtures/bahia-municipality-demographics.official.json
vendored
Normal file
5010
tests/fixtures/bahia-municipality-demographics.official.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
102
tests/int/bahiaMunicipalityDemographics.int.spec.ts
Normal file
102
tests/int/bahiaMunicipalityDemographics.int.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
bahiaMunicipalityDemographics,
|
||||
demographicsForCode,
|
||||
demographicsForMunicipalityName,
|
||||
} from '@/lib/bahiaMunicipalityDemographics'
|
||||
import { bahiaMunicipalityCodes } from '@/lib/bahiaMunicipalityCodes'
|
||||
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
|
||||
|
||||
type OfficialEvidence = {
|
||||
municipalityCount: number
|
||||
assignments: Array<{
|
||||
code: string
|
||||
population: number
|
||||
ageBands: Record<'0-17' | '18-29' | '30-59' | '60+', number>
|
||||
sexShareFemale: number
|
||||
medianAge: number | null
|
||||
}>
|
||||
evidenceSha256: string
|
||||
}
|
||||
|
||||
const officialEvidence = JSON.parse(
|
||||
readFileSync(
|
||||
new URL('../fixtures/bahia-municipality-demographics.official.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
) as OfficialEvidence
|
||||
|
||||
describe('Bahia municipality demographics (IBGE Censo 2022)', () => {
|
||||
it('covers all 417 Bahia municipalities with positive population', () => {
|
||||
expect(Object.keys(bahiaMunicipalityDemographics)).toHaveLength(417)
|
||||
expect(bahiaMunicipalities).toHaveLength(417)
|
||||
expect(new Set(Object.keys(bahiaMunicipalityDemographics))).toHaveLength(417)
|
||||
|
||||
for (const city of bahiaMunicipalities) {
|
||||
const code = bahiaMunicipalityCodes[city]
|
||||
const record = bahiaMunicipalityDemographics[code]
|
||||
expect(record, city).toBeDefined()
|
||||
expect(record.population).toBeGreaterThan(0)
|
||||
expect(
|
||||
record.ageBands['0-17'] +
|
||||
record.ageBands['18-29'] +
|
||||
record.ageBands['30-59'] +
|
||||
record.ageBands['60+'],
|
||||
).toBe(record.population)
|
||||
expect(record.sexShareFemale).toBeGreaterThan(0)
|
||||
expect(record.sexShareFemale).toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the independently downloaded official evidence fixture', () => {
|
||||
expect(officialEvidence.municipalityCount).toBe(417)
|
||||
expect(
|
||||
officialEvidence.assignments.map(
|
||||
({ code, population, ageBands, sexShareFemale, medianAge }) => ({
|
||||
code,
|
||||
population,
|
||||
ageBands,
|
||||
sexShareFemale,
|
||||
medianAge,
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
Object.entries(bahiaMunicipalityDemographics)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([code, record]) => ({
|
||||
code,
|
||||
population: record.population,
|
||||
ageBands: record.ageBands,
|
||||
sexShareFemale: record.sexShareFemale,
|
||||
medianAge: record.medianAge,
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
it('matches every official assignment by a fixed evidence checksum', () => {
|
||||
const assignmentRows = Object.entries(bahiaMunicipalityDemographics)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(
|
||||
([code, record]) =>
|
||||
`D\t${code}\t${record.population}\t${record.ageBands['0-17']}\t${record.ageBands['18-29']}\t${record.ageBands['30-59']}\t${record.ageBands['60+']}\t${record.sexShareFemale}\t${record.medianAge ?? ''}\n`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
expect(createHash('sha256').update(assignmentRows).digest('hex')).toBe(
|
||||
officialEvidence.evidenceSha256,
|
||||
)
|
||||
})
|
||||
|
||||
it('supports bidirectional lookups by municipality name', () => {
|
||||
const salvador = demographicsForMunicipalityName('Salvador')
|
||||
expect(salvador).toBeDefined()
|
||||
expect(demographicsForCode('2927408')).toEqual(salvador)
|
||||
expect(demographicsForMunicipalityName('Município inexistente')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
53
tests/unit/nucleusIbgeVoterProfile.unit.spec.ts
Normal file
53
tests/unit/nucleusIbgeVoterProfile.unit.spec.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getNucleusIbgeVoterProfile,
|
||||
IBGE_VOTER_PROFILE_LABEL,
|
||||
} from '@/utilities/nucleusIbgeVoterProfile'
|
||||
|
||||
describe('getNucleusIbgeVoterProfile', () => {
|
||||
it('returns a profile for a single municipality', () => {
|
||||
const result = getNucleusIbgeVoterProfile({ cities: ['Salvador'], regions: [] })
|
||||
expect(result.status).toBe('available')
|
||||
if (result.status !== 'available') return
|
||||
|
||||
expect(result.profile.label).toBe(IBGE_VOTER_PROFILE_LABEL)
|
||||
expect(result.profile.notes).toContain('habitantes')
|
||||
expect(result.profile.ageRange).toContain('0 a 17 anos')
|
||||
expect(result.profile.localTraits).toContain('Predominam moradores')
|
||||
expect(result.profile.notes).toContain('município')
|
||||
})
|
||||
|
||||
it('weights multiple municipalities by population', () => {
|
||||
const combined = getNucleusIbgeVoterProfile({
|
||||
cities: ['Salvador', 'Abaíra'],
|
||||
regions: [],
|
||||
})
|
||||
|
||||
expect(combined.status).toBe('available')
|
||||
if (combined.status !== 'available') return
|
||||
|
||||
expect(combined.profile.notes).toContain('2 municípios')
|
||||
})
|
||||
|
||||
it('resolves cities from identity territories when cities are empty', () => {
|
||||
const result = getNucleusIbgeVoterProfile({
|
||||
cities: [],
|
||||
regions: ['Metropolitano de Salvador'],
|
||||
})
|
||||
|
||||
expect(result.status).toBe('available')
|
||||
})
|
||||
|
||||
it('returns semPerfil when geography is missing', () => {
|
||||
expect(getNucleusIbgeVoterProfile({ cities: [], regions: [] })).toEqual({
|
||||
status: 'semPerfil',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns semPerfil for unknown municipalities', () => {
|
||||
expect(
|
||||
getNucleusIbgeVoterProfile({ cities: ['Município inexistente'], regions: [] }),
|
||||
).toEqual({ status: 'semPerfil' })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue