From 84e796ab49136e67c874e036083bd932932f0ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 23:08:02 -0300 Subject: [PATCH 1/2] =?UTF-8?q?B8=20F2:=20pol=C3=ADgonos=20das=20ZE=20de?= =?UTF-8?q?=20Salvador=20e=20re-keying=20do=20coropl=C3=A9tico=20por=20map?= =?UTF-8?q?=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvador era um polígono só carregando os votos da cidade inteira, então o coroplético não conseguia responder "qual pedaço de Salvador está fraco / é meu". As 19 ZE ganham polígono próprio, dissolvido da malha de bairros do IBGE (Censo 2022) pela lista de circunscrição da RA 02/2017 do TRE-BA, e o mapa passa a pintar por map key (slug para as zonas, ibgeCode para o resto) em vez de codarea. O polígono municipal de Salvador continua desenhado por baixo, sem dado e sem interação, para não abrir buraco no contorno do estado. Aproximação declarada na copy e no cabeçalho do script: o TRE publica a circunscrição como lista de bairros, então o bairro é o átomo geométrico — Periperi, que a resolução corta na Rua das Pedrinhas, vai inteiro para a ZE 17. competitiveRank segue por cidade (o artefato TSE só tem federalRankByIbgeCode). Sem migration, sem collection, sem action, sem Consent. --- package.json | 3 + pnpm-lock.yaml | 58 +++ .../build-municipality-zone-geometries.mjs | 407 ++++++++++++++++++ src/components/campaign/map/BahiaMap.tsx | 115 ++--- .../campaign/map/MapFeatureReadout.tsx | 5 - .../campaign/map/MunicipalityMapPanel.tsx | 67 ++- src/lib/bahiaGeometries.ts | 15 +- src/lib/bahiaGeometriesTypes.ts | 28 +- src/lib/bahiaMapStyle.ts | 26 +- src/lib/bahiaMunicipalityZoneGeometries.ts | 23 + .../bahia-municipality-zones.topo.json | 1 + src/lib/municipalityProximity.ts | 7 +- src/lib/municipalityZoneNeighborhoods.ts | 85 ++-- src/utilities/municipalityMapContract.ts | 34 +- src/utilities/municipalityMapData.ts | 52 +-- src/utilities/municipalityMapNavigation.ts | 53 ++- tests/e2e/campaignZoneMap.e2e.spec.ts | 63 +++ tests/int/bahiaGeometries.int.spec.ts | 110 ++++- tests/int/municipalityMapData.int.spec.ts | 31 +- .../municipalityZoneNeighborhoods.int.spec.ts | 9 +- tests/unit/bahiaMapStyle.unit.spec.ts | 29 ++ .../municipalityMapNavigation.unit.spec.ts | 73 ++-- 22 files changed, 1051 insertions(+), 243 deletions(-) create mode 100644 scripts/build-municipality-zone-geometries.mjs create mode 100644 src/lib/bahiaMunicipalityZoneGeometries.ts create mode 100644 src/lib/geometries/bahia-municipality-zones.topo.json create mode 100644 tests/e2e/campaignZoneMap.e2e.spec.ts diff --git a/package.json b/package.json index f5f84470..e5ded53c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs", "build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs", "build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs", + "build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-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", "devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev", @@ -108,6 +109,7 @@ "@types/pg": "^8.10.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/shpjs": "^3.4.7", "@types/topojson-client": "^3.1.5", "@types/topojson-server": "^3.0.4", "@types/topojson-simplify": "^3.0.3", @@ -129,6 +131,7 @@ "prettier": "^3.4.2", "prettier-plugin-organize-imports": "^4.3.0", "schema-dts": "^2.0.0", + "shpjs": "^6.2.0", "tailwindcss": "^4.1.18", "topojson-server": "^3.0.1", "topojson-simplify": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2912c656..f1ae38ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/shpjs': + specifier: ^3.4.7 + version: 3.4.7 '@types/topojson-client': specifier: ^3.1.5 version: 3.1.5 @@ -210,6 +213,9 @@ importers: schema-dts: specifier: ^2.0.0 version: 2.0.0(@typescript/typescript6@6.0.2) + shpjs: + specifier: ^6.2.0 + version: 6.2.0 tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -3148,6 +3154,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/shpjs@3.4.7': + resolution: {integrity: sha512-/6PjggpFsq9NFxar6ZpXsnYZ+nQJR8Cv03Gne1enIJuMZ/eFVOpu0orHxL9D7RT3ciJElzF2H6l+49US23ydUw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -3834,6 +3843,9 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + but-unzip@0.1.10: + resolution: {integrity: sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -5627,6 +5639,9 @@ packages: merge@2.1.1: resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==} + mgrs@1.0.0: + resolution: {integrity: sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -5974,6 +5989,9 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parsedbf@2.0.0: + resolution: {integrity: sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -6239,6 +6257,14 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + proj4@2.20.9: + resolution: {integrity: sha512-GLBGqXaTcdWnppre3o1sMmy4DcMGSGq/ng+9k2MTNddarRK6SveINqlqYzi3xEXuy06ljY1TTrC6H9C4f360IQ==} + peerDependencies: + geotiff: '*' + peerDependenciesMeta: + geotiff: + optional: true + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -6607,6 +6633,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shpjs@6.2.0: + resolution: {integrity: sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -7318,6 +7347,9 @@ packages: engines: {node: '>=8'} hasBin: true + wkt-parser@1.5.6: + resolution: {integrity: sha512-cqHU3lzGt/gt2OqIORP0uVy5yeOX43WABmgFkmJsmcqH3HhQo9PiJG6ftytwGKmpQUbegeX7+pQc2BuNCRfcrw==} + wmf@1.0.2: resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} engines: {node: '>=0.8'} @@ -10260,6 +10292,11 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/shpjs@3.4.7': + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 22.19.9 + '@types/statuses@2.0.6': {} '@types/topojson-client@3.1.5': @@ -10912,6 +10949,8 @@ snapshots: dependencies: streamsearch: 1.1.0 + but-unzip@0.1.10: {} + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -12826,6 +12865,8 @@ snapshots: merge@2.1.1: {} + mgrs@1.0.0: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -13344,6 +13385,8 @@ snapshots: dependencies: entities: 6.0.1 + parsedbf@2.0.0: {} + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -13633,6 +13676,11 @@ snapshots: process-warning@5.0.0: {} + proj4@2.20.9: + dependencies: + mgrs: 1.0.0 + wkt-parser: 1.5.6 + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -14209,6 +14257,14 @@ snapshots: shebang-regex@3.0.0: {} + shpjs@6.2.0: + dependencies: + but-unzip: 0.1.10 + parsedbf: 2.0.0 + proj4: 2.20.9 + transitivePeerDependencies: + - geotiff + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -14940,6 +14996,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wkt-parser@1.5.6: {} + wmf@1.0.2: {} word-wrap@1.2.5: {} diff --git a/scripts/build-municipality-zone-geometries.mjs b/scripts/build-municipality-zone-geometries.mjs new file mode 100644 index 00000000..c32a1a1c --- /dev/null +++ b/scripts/build-municipality-zone-geometries.mjs @@ -0,0 +1,407 @@ +/** + * Builds the static TopoJSON of the zone municipalities — Salvador's 19 ZE + * (roadmap B8 F2). One feature per catalog `kind: 'zona'` entry, dissolved from + * the IBGE neighborhood mesh using the official TRE circunscrição list. + * + * Provenance (do not strip): + * - IBGE Malhas de bairros, Censo 2022 (shapefile, UF BA): + * https://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_de_setores_censitarios__divisoes_intramunicipais/censo_2022/bairros/shp/UF/BA_bairros_CD2022.zip + * - Zone → neighborhood assignment: src/lib/municipalityZoneNeighborhoods.ts + * (TRE-BA Resolução Administrativa nº 2/2017, Anexo I). + * + * APPROXIMATION — the result is NOT an official TSE boundary. The TRE publishes + * the circunscrição as a list of neighborhoods, so the polygon is only as exact + * as the neighborhood mesh: where the resolution splits one neighborhood + * between two zones by a street, the polygon is atomic and goes whole to one of + * them (see SPLIT_NEIGHBORHOOD_ZONES). Every surface that draws it says so. + * + * Safety: does not touch any database — only downloads public IBGE data and + * writes a versioned artifact under src/lib/geometries/. + * + * Usage: + * pnpm build:municipality-zone-geometries + * pnpm build:municipality-zone-geometries --report # reconciliation only + * GEOMETRIES_CACHE_DIR=./data/geometries pnpm build:municipality-zone-geometries + */ + +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' + +import shp from 'shpjs' +import { merge, quantize } from 'topojson-client' +import { topology } from 'topojson-server' +import { presimplify, quantile, simplify } from 'topojson-simplify' + +const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts') +const { municipalityZoneNeighborhoods } = + await import('../src/lib/municipalityZoneNeighborhoods.ts') +const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts') + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') + +const SOURCE = { + key: 'bahia-bairros-cd2022', + url: 'https://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_de_setores_censitarios__divisoes_intramunicipais/censo_2022/bairros/shp/UF/BA_bairros_CD2022.zip', + ext: 'zip', +} + +const OUTPUT_PATH = 'src/lib/geometries/bahia-municipality-zones.topo.json' + +/** Quantile of triangle area kept after presimplify (higher = more detail). */ +const SIMPLIFY_QUANTILE = 0.35 +/** Quantization precision for the final topology. */ +const QUANTIZE_DIGITS = 1e4 + +/** + * Same neighborhood, different name in the two sources — IBGE composites + * ("Beiru / Tancredo Neves") and spellings the accent folding cannot reach. + * Identity beats the adjacency rule below: the resolution naming a + * neighborhood is the fact, adjacency is only the fallback for the ones it + * never mentions. Both sides are matched normalized and the target must exist + * in the TRE catalog, or the build fails. + */ +const IBGE_TO_TRE_NEIGHBORHOOD = { + 'BEIRU TANCREDO NEVES': 'Tancredo Neves', + 'CENTRO ADMINISTRATIVO DA BAHIA CAB': 'Centro Administrativo da Bahia', + 'NORDESTE DO AMARALINA': 'Nordeste de Amaralina', + // Islands have no shared boundary, so the adjacency rule cannot place them — + // and it does not need to: RA 02/2017 names both in ZE 4. + 'ILHA BOM JESUS DOS PASSOS': 'Ilha de Bom Jesus dos Passos', + 'ILHA DOS FRADES ILHA DE SANTO ANTONIO': 'Ilha dos Frades', + 'VILA RUY BARBOSA JARDIM CRUZEIRO': 'Vila Ruy Barbosa', +} + +/** + * TRE neighborhoods with no polygon of their own because the IBGE mesh draws + * them inside a neighbor. Only legal within one zone — the build refuses a + * merge that would move territory across zones. + */ +const TRE_NEIGHBORHOODS_MERGED_INTO = { + 'Jardim Cruzeiro': 'Vila Ruy Barbosa', +} + +/** + * Neighborhoods the resolution splits between two zones by a street. The + * polygon is atomic without street-level geodata, so it goes whole to the zone + * named here, and every build reports it. + */ +const SPLIT_NEIGHBORHOOD_ZONES = { + // RA 02/2017 cuts Periperi at Rua das Pedrinhas (north = ZE 4, south = ZE 17). + // It goes to ZE 17, whose other neighborhoods (Alto do Cabrito, Itacaranha, + // Plataforma, Praia Grande, Rio Sena, São João do Cabrito) wrap it; ZE 4 + // keeps Paripe, Coutos and the islands. + PERIPERI: 'salvador-ze-17', +} + +const REPORT_ONLY = process.argv.includes('--report') + +const die = (message) => { + console.error(`\n[build:municipality-zone-geometries] ${message}\n`) + process.exit(1) +} + +const log = (message) => console.log(`[build:municipality-zone-geometries] ${message}`) + +const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex') + +const cacheDir = () => process.env.GEOMETRIES_CACHE_DIR || join(ROOT, 'data', 'geometries') + +const ensureCachedBinary = async ({ key, url, ext }) => { + const dir = cacheDir() + await mkdir(dir, { recursive: true }) + const path = join(dir, `${key}.${ext}`) + try { + await access(path) + log(`cache hit ${path}`) + const buffer = await readFile(path) + return { url, hash: sha256(buffer), buffer } + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + log(`downloading ${url}`) + const buffer = await downloadToBuffer(url) + const hash = sha256(buffer) + await writeFile(path, buffer) + log(`saved ${path} (${buffer.length} bytes, sha256=${hash})`) + return { url, hash, buffer } +} + +const writeJson = async (relativePath, value) => { + const path = join(ROOT, relativePath) + await mkdir(dirname(path), { recursive: true }) + const body = `${JSON.stringify(value)}\n` + await writeFile(path, body) + log(`wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`) +} + +const simplifyTopology = (topo) => { + let next = presimplify(topo) + const minWeight = quantile(next, SIMPLIFY_QUANTILE) + next = simplify(next, minWeight) + return quantize(next, QUANTIZE_DIGITS) +} + +/** Accent-folded, punctuation-free key; parentheses (the TRE's split notes) drop. */ +const normalizeNeighborhood = (name) => + name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toUpperCase() + .replace(/\(.*?\)/g, ' ') + .replace(/[^A-Z0-9]+/g, ' ') + .trim() + +const polygonCount = (geometry) => + geometry?.type === 'MultiPolygon' ? geometry.coordinates.length : geometry ? 1 : 0 + +const arcIndexes = (arcs, into = new Set()) => { + for (const item of arcs) { + if (Array.isArray(item)) arcIndexes(item, into) + else into.add(item < 0 ? -1 - item : item) + } + return into +} + +const arcLength = (arc) => { + let total = 0 + for (let index = 1; index < arc.length; index += 1) { + const [previousX, previousY] = arc[index - 1] + const [x, y] = arc[index] + total += Math.hypot(x - previousX, y - previousY) + } + return total +} + +/** + * Places a polygon RA 02/2017 never names in the zone it shares the most + * boundary with. Validated against the neighborhoods the resolution DOES name + * under a different label (Beiru/Tancredo Neves, CAB, Nordeste de Amaralina, + * Vila Ruy Barbosa): the rule reproduces the official zone in all four. + */ +const rankZonesBySharedBoundary = (topo, geometry, zoneSlugByGeometry) => { + const ownArcs = arcIndexes(geometry.arcs) + const lengthByZone = new Map() + + for (const other of topo.objects.neighborhoods.geometries) { + const zone = zoneSlugByGeometry.get(other) + if (!zone || other === geometry) continue + const otherArcs = arcIndexes(other.arcs) + let shared = 0 + for (const arc of ownArcs) if (otherArcs.has(arc)) shared += arcLength(topo.arcs[arc]) + if (shared > 0) lengthByZone.set(zone, (lengthByZone.get(zone) ?? 0) + shared) + } + + return [...lengthByZone.entries()].sort((left, right) => right[1] - left[1]) +} + +const main = async () => { + const zoneEntries = municipalityCatalog.filter((entry) => entry.kind === 'zona') + if (zoneEntries.length === 0) die('No zone municipalities in the catalog.') + const zoneIbgeCodes = new Set(zoneEntries.map((entry) => entry.ibgeCode)) + + // TRE neighborhood → zone slug. A name in two zones is a documented split. + const zoneSlugByNeighborhood = new Map() + const splitNeighborhoods = new Set() + for (const record of municipalityZoneNeighborhoods) { + for (const neighborhood of record.neighborhoods) { + const key = normalizeNeighborhood(neighborhood) + const previous = zoneSlugByNeighborhood.get(key) + if (previous && previous !== record.municipalitySlug) { + const resolved = SPLIT_NEIGHBORHOOD_ZONES[key] + if (!resolved) { + die( + `"${neighborhood}" is listed in ${previous} and ${record.municipalitySlug}; add it to SPLIT_NEIGHBORHOOD_ZONES.`, + ) + } + splitNeighborhoods.add(key) + zoneSlugByNeighborhood.set(key, resolved) + continue + } + if (!previous) zoneSlugByNeighborhood.set(key, record.municipalitySlug) + } + } + + const aliasTargets = new Map() + for (const [ibgeName, treName] of Object.entries(IBGE_TO_TRE_NEIGHBORHOOD)) { + const treKey = normalizeNeighborhood(treName) + if (!zoneSlugByNeighborhood.has(treKey)) { + die(`IBGE_TO_TRE_NEIGHBORHOOD maps "${ibgeName}" to unknown TRE neighborhood "${treName}".`) + } + aliasTargets.set(normalizeNeighborhood(ibgeName), treKey) + } + + for (const [treName, hostName] of Object.entries(TRE_NEIGHBORHOODS_MERGED_INTO)) { + const treZone = zoneSlugByNeighborhood.get(normalizeNeighborhood(treName)) + const hostZone = zoneSlugByNeighborhood.get(normalizeNeighborhood(hostName)) + if (!treZone) die(`TRE_NEIGHBORHOODS_MERGED_INTO has unknown neighborhood "${treName}".`) + if (!hostZone) die(`TRE_NEIGHBORHOODS_MERGED_INTO has unknown host "${hostName}".`) + if (treZone !== hostZone) { + die( + `Merging "${treName}" (${treZone}) into "${hostName}" (${hostZone}) would move territory across zones.`, + ) + } + } + + const { hash, buffer } = await ensureCachedBinary(SOURCE) + const features = [await shp(buffer)] + .flat() + .flatMap((collection) => collection.features) + .filter((feature) => zoneIbgeCodes.has(feature.properties?.CD_MUN)) + if (features.length === 0) die('No neighborhood polygons found for the zone municipalities.') + log(`${features.length} neighborhood polygons in the zone municipalities (sha256=${hash})`) + + // Topology before anything else: the dissolve needs shared arcs to drop + // interior boundaries instead of leaving slivers, adjacency is read off those + // same arcs, and simplification then moves both sides of an arc identically. + const neighborhoodTopology = topology({ + neighborhoods: { + type: 'FeatureCollection', + features: features.map((feature) => ({ + type: 'Feature', + properties: { name: feature.properties?.NM_BAIRRO ?? '' }, + geometry: feature.geometry, + })), + }, + }) + const geometries = neighborhoodTopology.objects.neighborhoods.geometries + + // Pass 1 — the resolution's own names. + const zoneSlugByGeometry = new Map() + const unnamed = [] + for (const geometry of geometries) { + const key = normalizeNeighborhood(geometry.properties.name) + const slug = zoneSlugByNeighborhood.get(aliasTargets.get(key) ?? key) + if (slug) zoneSlugByGeometry.set(geometry, slug) + else unnamed.push(geometry) + } + + // Pass 2 — polygons RA 02/2017 never names, by shared boundary. + const orphans = [] + for (const geometry of unnamed) { + const ranked = rankZonesBySharedBoundary(neighborhoodTopology, geometry, zoneSlugByGeometry) + const [winner, runnerUp] = ranked + if (!winner) { + orphans.push(geometry.properties.name) + continue + } + zoneSlugByGeometry.set(geometry, winner[0]) + const margin = runnerUp + ? `+${(((winner[1] - runnerUp[1]) / runnerUp[1]) * 100).toFixed(0)}%` + : 'only neighbour' + log( + `not in RA 02/2017 — "${geometry.properties.name}" → ${winner[0]} by shared boundary (${margin}${ + runnerUp ? ` over ${runnerUp[0]}` : '' + })`, + ) + } + + const coveredKeys = new Set( + geometries.map((geometry) => { + const key = normalizeNeighborhood(geometry.properties.name) + return aliasTargets.get(key) ?? key + }), + ) + for (const hostName of Object.values(TRE_NEIGHBORHOODS_MERGED_INTO)) { + if (!coveredKeys.has(normalizeNeighborhood(hostName))) continue + for (const [treName, host] of Object.entries(TRE_NEIGHBORHOODS_MERGED_INTO)) { + if (host === hostName) coveredKeys.add(normalizeNeighborhood(treName)) + } + } + const neighborhoodsWithoutPolygon = municipalityZoneNeighborhoods.flatMap((record) => + record.neighborhoods + .filter((neighborhood) => !coveredKeys.has(normalizeNeighborhood(neighborhood))) + .map((neighborhood) => `${record.municipalitySlug}: ${neighborhood}`), + ) + + const geometriesByZone = new Map() + for (const [geometry, slug] of zoneSlugByGeometry) { + geometriesByZone.set(slug, [...(geometriesByZone.get(slug) ?? []), geometry]) + } + log( + `assigned ${zoneSlugByGeometry.size}/${geometries.length} polygons to ${geometriesByZone.size}/${zoneEntries.length} zones`, + ) + if (splitNeighborhoods.size > 0) { + log( + `split by RA 02/2017, kept whole: ${[...splitNeighborhoods] + .map((key) => `${key} → ${SPLIT_NEIGHBORHOOD_ZONES[key]}`) + .join(', ')}`, + ) + } + if (orphans.length > 0) { + log(`polygons with no zone (${orphans.length}):`) + for (const name of [...orphans].sort()) console.log(` - ${name}`) + } + if (neighborhoodsWithoutPolygon.length > 0) { + log(`TRE neighborhoods with no polygon (${neighborhoodsWithoutPolygon.length}):`) + for (const entry of neighborhoodsWithoutPolygon.sort()) console.log(` - ${entry}`) + } + if (orphans.length > 0 || neighborhoodsWithoutPolygon.length > 0) { + die( + 'Reconciliation is incomplete — extend IBGE_TO_TRE_NEIGHBORHOOD / TRE_NEIGHBORHOODS_MERGED_INTO until every polygon has a zone and every neighborhood has a polygon.', + ) + } + + if (REPORT_ONLY) { + log('--report: reconciliation complete, artifact not written.') + return + } + + // Simplification keeps geometry order and properties, so the zone buckets + // above still address the right shapes. + const simplified = simplifyTopology(neighborhoodTopology) + const simplifiedByName = new Map( + simplified.objects.neighborhoods.geometries.map((geometry) => [ + geometry.properties.name, + geometry, + ]), + ) + + const zoneFeatures = zoneEntries.map((entry) => { + const members = (geometriesByZone.get(entry.slug) ?? []).map((geometry) => + simplifiedByName.get(geometry.properties.name), + ) + if (members.length === 0 || members.some((member) => !member)) { + die(`Missing neighborhood geometry for ${entry.slug}.`) + } + + const geometry = merge(simplified, members) + if (!geometry || geometry.coordinates.length === 0) { + die(`Dissolve produced an empty polygon for ${entry.slug}.`) + } + + return { + type: 'Feature', + properties: { + municipalitySlug: entry.slug, + name: entry.name, + zoneNumber: entry.zoneNumber, + ibgeCode: entry.ibgeCode, + }, + geometry, + } + }) + + // Already simplified above — the final pass only quantizes, so the artifact + // stores short integers instead of the floats `merge` hands back. + await writeJson( + OUTPUT_PATH, + quantize( + topology({ municipalityZones: { type: 'FeatureCollection', features: zoneFeatures } }), + QUANTIZE_DIGITS, + ), + ) + + for (const feature of zoneFeatures) { + const pieces = polygonCount(feature.geometry) + log( + `${feature.properties.municipalitySlug}: ${ + geometriesByZone.get(feature.properties.municipalitySlug).length + } neighborhoods → ${pieces} ${pieces === 1 ? 'piece' : 'pieces'}`, + ) + } + log(`done — ${zoneFeatures.length} zone polygons.`) +} + +await main() diff --git a/src/components/campaign/map/BahiaMap.tsx b/src/components/campaign/map/BahiaMap.tsx index 22f880d3..80e77c8e 100644 --- a/src/components/campaign/map/BahiaMap.tsx +++ b/src/components/campaign/map/BahiaMap.tsx @@ -9,17 +9,16 @@ import 'leaflet/dist/leaflet.css' import { Spinner } from '@/components/ui/Spinner' import { loadMunicipalityGeometryModule, + loadMunicipalityZoneGeometryModule, loadTerritoryGeometryModule, - type MunicipalityGeometryModule, - type TerritoryGeometryModule, } from '@/lib/bahiaGeometries' +import type { BahiaGeometryFeature } from '@/lib/bahiaGeometriesTypes' import { bubbleRadius, buildHighlightSet, buildLayerStyleContext, canonicalMapKeysKey, - featureKeyFromProperties, - keyPropertyForMode, + featureMapKey, resolvePathStyle, type BahiaMapFillMode, type ChoroplethFills, @@ -64,6 +63,32 @@ const applyBahiaMinZoom = (map: L.Map) => { export type BahiaMapMode = 'municipality' | 'territory' +/** + * The municipality layer is two meshes: the 417 municípios plus the 19 Salvador + * zone polygons on top (insertion order is z-order in the SVG). The zone city's + * own polygon stays underneath as a BASE — no value, no pointer — so a sliver of + * disagreement between two independently simplified meshes cannot punch a hole + * in the state's outline. + */ +const loadLayerFeatures = async ( + mode: BahiaMapMode, +): Promise<{ features: BahiaGeometryFeature[]; baseKeys: Set }> => { + if (mode === 'territory') { + const { features } = await loadTerritoryGeometryModule() + return { features: [...features], baseKeys: new Set() } + } + + const [municipalities, zones] = await Promise.all([ + loadMunicipalityGeometryModule(), + loadMunicipalityZoneGeometryModule(), + ]) + + return { + features: [...municipalities.features, ...zones.features], + baseKeys: new Set(zones.features.map((zone) => zone.properties.ibgeCode)), + } +} + export type BahiaMapFeatureInfo = { key: string name: string @@ -106,37 +131,28 @@ type BahiaMapProps = { const getPointerType = (event: L.LeafletMouseEvent, fallback = 'mouse') => event.originalEvent instanceof PointerEvent ? event.originalEvent.pointerType : fallback +/** + * Fits from the drawn paths rather than the geometry modules: since B8 F2 a + * municipality layer is assembled from two meshes, and the paths are the one + * index that already knows which key came from which. + */ const fitMapToHighlights = ( map: L.Map, - mode: BahiaMapMode, highlightSet: Set, - geometryModule: MunicipalityGeometryModule | TerritoryGeometryModule, + pathByKey: Map, ) => { - if (highlightSet.size === 0) { + const highlightBounds = L.latLngBounds([]) + for (const key of highlightSet) { + const path = pathByKey.get(key) + if (path instanceof L.Polygon) highlightBounds.extend(path.getBounds()) + } + + if (!highlightBounds.isValid()) { map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING }) return } - const highlightedFeatures = - mode === 'municipality' - ? [...highlightSet].flatMap((key) => { - const feature = (geometryModule as MunicipalityGeometryModule).getMunicipalityFeature(key) - return feature ? [feature] : [] - }) - : [...highlightSet].flatMap((key) => { - const feature = (geometryModule as TerritoryGeometryModule).getTerritoryFeature(key) - return feature ? [feature] : [] - }) - - if (highlightedFeatures.length === 0) { - map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING }) - return - } - - const highlightBounds = L.geoJSON(highlightedFeatures).getBounds() - if (highlightBounds.isValid()) { - map.fitBounds(highlightBounds, { padding: [24, 24], maxZoom: 10 }) - } + map.fitBounds(highlightBounds, { padding: [24, 24], maxZoom: 10 }) } export const BahiaMap = ({ @@ -160,9 +176,6 @@ export const BahiaMap = ({ const containerRef = useRef(null) const mapRef = useRef(null) const layerRef = useRef(null) - const geometryModuleRef = useRef( - null, - ) const pathByKeyRef = useRef>(new Map()) const styleContextRef = useRef(null) const geometryReadyRef = useRef(false) @@ -299,7 +312,6 @@ export const BahiaMap = ({ map.remove() mapRef.current = null layerRef.current = null - geometryModuleRef.current = null pathByKeyRef.current = new Map() styleContextRef.current = null geometryReadyRef.current = false @@ -317,19 +329,13 @@ export const BahiaMap = ({ const renderLayer = async () => { try { - const geometryModule = - mode === 'municipality' - ? await loadMunicipalityGeometryModule() - : await loadTerritoryGeometryModule() + const { features, baseKeys } = await loadLayerFeatures(mode) if (cancelled) return layerRef.current?.remove() pathByKeyRef.current = new Map() - geometryModuleRef.current = geometryModule - - const keyProperty = keyPropertyForMode(mode) const syncMountStyleContext = () => { styleContextRef.current = buildLayerStyleContext({ ...stylePropsRef.current, @@ -340,21 +346,32 @@ export const BahiaMap = ({ syncMountStyleContext() const mountContext = styleContextRef.current! - const layer = L.geoJSON([...geometryModule.features], { + /** + * The key this feature answers for, or `undefined` when it is a base + * polygon: those carry no value, take no pointer, and must not answer + * for a key the data has moved off of. + */ + const paintedKey = (properties: Feature['properties'] | undefined) => { + const key = featureMapKey(properties) + return key && !baseKeys.has(key) ? key : undefined + } + + const layer = L.geoJSON(features, { style: (feature?: Feature) => { - const properties = feature?.properties as Record | undefined - const key = featureKeyFromProperties(properties, keyProperty) const context = styleContextRef.current ?? mountContext - return resolvePathStyleForFeature(context, key ?? '') + const key = paintedKey(feature?.properties) + return key + ? resolvePathStyleForFeature(context, key) + : { ...resolvePathStyle(context, ''), interactive: false } }, onEachFeature: (feature, featureLayer) => { if (!(featureLayer instanceof L.Path)) return - const properties = feature.properties as Record | undefined - const key = featureKeyFromProperties(properties, keyProperty) + const key = paintedKey(feature.properties) if (!key) return - const name = properties?.name ?? key + const name = + typeof feature.properties?.name === 'string' ? feature.properties.name : key pathByKeyRef.current.set(key, featureLayer) featureLayer.on('mouseover', (event) => { @@ -403,7 +420,7 @@ export const BahiaMap = ({ restyleAllPaths() const fitIdentity = `${mode}:${viewportKey}` - fitMapToHighlights(map, mode, buildHighlightSet(viewportKey), geometryModule) + fitMapToHighlights(map, buildHighlightSet(viewportKey), pathByKeyRef.current) lastFittedViewportKeyRef.current = fitIdentity geometryReadyRef.current = true @@ -420,7 +437,6 @@ export const BahiaMap = ({ layerRef.current?.remove() layerRef.current = null pathByKeyRef.current = new Map() - geometryModuleRef.current = null geometryReadyRef.current = false } // Geometry layer rebuilds only when `mode` changes; metric/hover updates use separate effects. @@ -434,10 +450,9 @@ export const BahiaMap = ({ if (lastFittedViewportKeyRef.current === fitIdentity) return const map = mapRef.current - const geometryModule = geometryModuleRef.current - if (!map || !geometryModule || !layerRef.current) return + if (!map || !layerRef.current) return - fitMapToHighlights(map, mode, buildHighlightSet(viewportKey), geometryModule) + fitMapToHighlights(map, buildHighlightSet(viewportKey), pathByKeyRef.current) lastFittedViewportKeyRef.current = fitIdentity }, [viewportKey, mode]) diff --git a/src/components/campaign/map/MapFeatureReadout.tsx b/src/components/campaign/map/MapFeatureReadout.tsx index dea9f8ca..ef8309ab 100644 --- a/src/components/campaign/map/MapFeatureReadout.tsx +++ b/src/components/campaign/map/MapFeatureReadout.tsx @@ -103,11 +103,6 @@ export const MapFeatureReadout = ({ {/* The bubble's own line: mixing "votes he got" and "votes at stake" into one sentence is how a reader ends up quoting the wrong one. */} {bubbleReading ?

{bubbleReading}

: null} - {navigation?.kind === 'zones' ? ( -

- Toque de novo no mapa ou role até os Municípios por zona abaixo. -

- ) : null} {navigation?.kind === 'navigate' ? ( diff --git a/src/components/campaign/map/MunicipalityMapPanel.tsx b/src/components/campaign/map/MunicipalityMapPanel.tsx index ffeb2d51..04e2cf04 100644 --- a/src/components/campaign/map/MunicipalityMapPanel.tsx +++ b/src/components/campaign/map/MunicipalityMapPanel.tsx @@ -202,20 +202,20 @@ export const MunicipalityMapPanel = ({ }, [comparisonActive, displayValues, percentScaleActive]) const scopedKeys = useMemo( - () => Object.keys(bundle.municipalitiesByIbgeCode), - [bundle.municipalitiesByIbgeCode], + () => Object.keys(bundle.municipalitiesByMapKey), + [bundle.municipalitiesByMapKey], ) /** LQ per município against his statewide standard — the same ratio E10 classes on. */ - const lqByCode = useMemo(() => { + const lqByMapKey = useMemo(() => { const statewideShare = bundle.statewideShareByYear[String(year)] ?? 0 if (statewideShare <= 0) return {} const lq: Record = {} - for (const [code, votes] of Object.entries(rawValues)) { - const validVotes = validVotesForYear[code] ?? 0 + for (const [mapKey, votes] of Object.entries(rawValues)) { + const validVotes = validVotesForYear[mapKey] ?? 0 if (votes <= 0 || validVotes <= 0) continue - lq[code] = votes / validVotes / statewideShare + lq[mapKey] = votes / validVotes / statewideShare } return lq }, [bundle.statewideShareByYear, rawValues, validVotesForYear, year]) @@ -230,12 +230,12 @@ export const MunicipalityMapPanel = ({ case 'quantile': return buildQuantileClassing(rawValues) case 'lq': - return buildLqClassing(lqByCode) + return buildLqClassing(lqByMapKey) case 'competitiveRank': return buildCompetitiveRankClassing( Object.fromEntries( - Object.entries(competitiveRankForYear).map(([code, placement]) => [ - code, + Object.entries(competitiveRankForYear).map(([mapKey, placement]) => [ + mapKey, placement.rank, ]), ), @@ -243,7 +243,7 @@ export const MunicipalityMapPanel = ({ default: return null } - }, [competitiveRankForYear, effectiveScaleMode, lqByCode, rawValues]) + }, [competitiveRankForYear, effectiveScaleMode, lqByMapKey, rawValues]) // A classing with no classes paints nothing, so the legend must fall through // to the continuous ramp — passing `{}` here would tell `BahiaMap` the map is @@ -320,16 +320,16 @@ export const MunicipalityMapPanel = ({ * * Both sides are already stable references, so there is nothing to memoize. */ - const bubbleValues = showBubbles ? bundle.projectedValidVotesByCode : undefined + const bubbleValues = showBubbles ? bundle.projectedValidVotesByMapKey : undefined const bubbleFillByKey = useMemo(() => { if (!showBubbles) return undefined const fills: ChoroplethFills = {} - for (const [code, territorialClass] of Object.entries(bundle.territorialClassByCode)) { - fills[code] = territorialClassMapFill[territorialClass] + for (const [mapKey, territorialClass] of Object.entries(bundle.territorialClassByMapKey)) { + fills[mapKey] = territorialClassMapFill[territorialClass] } return fills - }, [bundle.territorialClassByCode, showBubbles]) + }, [bundle.territorialClassByMapKey, showBubbles]) // The key's reference circles and the map's radius denominator have to be // the same number, or the key labels a size the map never draws. @@ -339,14 +339,14 @@ export const MunicipalityMapPanel = ({ (key: string): string | null => { if (!showBubbles) return null - const projected = bundle.projectedValidVotesByCode[key] ?? 0 + const projected = bundle.projectedValidVotesByMapKey[key] ?? 0 if (projected <= 0) return null - const territorialClass = bundle.territorialClassByCode[key] + const territorialClass = bundle.territorialClassByMapKey[key] const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}` }, - [bundle.projectedValidVotesByCode, bundle.territorialClassByCode, showBubbles], + [bundle.projectedValidVotesByMapKey, bundle.territorialClassByMapKey, showBubbles], ) /** @@ -366,7 +366,7 @@ export const MunicipalityMapPanel = ({ return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}` } case 'lq': { - const lq = lqByCode[key] + const lq = lqByMapKey[key] return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq) } case 'competitiveRank': { @@ -378,7 +378,7 @@ export const MunicipalityMapPanel = ({ return null } }, - [classing, competitiveRankForYear, effectiveScaleMode, lqByCode], + [classing, competitiveRankForYear, effectiveScaleMode, lqByMapKey], ) // Optimistic on the control, honest pending on the map: the comparison data @@ -582,7 +582,7 @@ export const MunicipalityMapPanel = ({ relativeReadingFor={relativeReadingFor} bubbleReadingFor={bubbleReadingFor} comparisonActive={comparisonActive} - municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode} + municipalitiesByMapKey={bundle.municipalitiesByMapKey} ariaLabel={ comparisonActive && comparison ? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município` @@ -592,11 +592,12 @@ export const MunicipalityMapPanel = ({ {/* zone breakdown below */} {bundle.zoneBreakdown.length > 0 && !comparisonActive ? ( -
+

Municípios por zona eleitoral

- Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e - detalhadas aqui. + Salvador é pintada zona por zona. O desenho vem dos bairros de cada circunscrição + (TRE-BA, RA nº 2/2017) sobre a malha do IBGE — é aproximado, não é o limite oficial do + TSE.

    {bundle.zoneBreakdown.map((zone) => ( @@ -645,7 +646,7 @@ const MunicipalityMapSelection = ({ relativeReadingFor, bubbleReadingFor, comparisonActive, - municipalitiesByIbgeCode, + municipalitiesByMapKey, ariaLabel, }: { displayValues: ChoroplethValues @@ -660,7 +661,7 @@ const MunicipalityMapSelection = ({ relativeReadingFor: (key: string) => string | null bubbleReadingFor: (key: string) => string | null comparisonActive: boolean - municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode'] + municipalitiesByMapKey: MunicipalityMapBundle['municipalitiesByMapKey'] ariaLabel: string }) => { const router = useRouter() @@ -676,20 +677,12 @@ const MunicipalityMapSelection = ({ (key: string) => { if (selectedKeyRef.current !== key) return - const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByIbgeCode) + const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByMapKey) if (navigation.kind === 'navigate') { router.push(`/campanha/municipios/${navigation.slug}`) - return - } - - if (navigation.kind === 'zones') { - document.getElementById('municipality-zone-breakdown')?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }) } }, - [municipalitiesByIbgeCode, router], + [municipalitiesByMapKey, router], ) const selectedMetricValue = @@ -703,9 +696,9 @@ const MunicipalityMapSelection = ({ const selectedNavigation = useMemo( () => selectedFeature - ? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByIbgeCode) + ? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByMapKey) : null, - [municipalitiesByIbgeCode, selectedFeature], + [municipalitiesByMapKey, selectedFeature], ) return ( diff --git a/src/lib/bahiaGeometries.ts b/src/lib/bahiaGeometries.ts index 4aa2ecc4..6fbf5e81 100644 --- a/src/lib/bahiaGeometries.ts +++ b/src/lib/bahiaGeometries.ts @@ -1,21 +1,25 @@ /** - * Lazy loaders for Bahia map geometries (B3 / B5 F1). + * Lazy loaders for Bahia map geometries (B3 / B5 F1 / B8 F2). * * TopoJSON decode runs only when a map surface mounts — not on the default `/campanha` graph. - * Submodules: `bahiaMunicipalityGeometries.ts`, `bahiaTerritoryGeometries.ts`. + * Submodules: `bahiaMunicipalityGeometries.ts`, `bahiaTerritoryGeometries.ts`, + * `bahiaMunicipalityZoneGeometries.ts`. */ export type { MunicipalityGeometryModule, + MunicipalityZoneGeometryModule, TerritoryGeometryModule, } from '@/lib/bahiaGeometriesTypes' import type { MunicipalityGeometryModule, + MunicipalityZoneGeometryModule, TerritoryGeometryModule, } from '@/lib/bahiaGeometriesTypes' let municipalityModulePromise: Promise | null = null +let municipalityZoneModulePromise: Promise | null = null let territoryModulePromise: Promise | null = null export const loadMunicipalityGeometryModule = (): Promise => { @@ -25,6 +29,13 @@ export const loadMunicipalityGeometryModule = (): Promise => { + municipalityZoneModulePromise ??= import('@/lib/bahiaMunicipalityZoneGeometries').then( + (module) => module.municipalityZoneGeometryModule, + ) + return municipalityZoneModulePromise +} + export const loadTerritoryGeometryModule = (): Promise => { territoryModulePromise ??= import('@/lib/bahiaTerritoryGeometries').then( (module) => module.territoryGeometryModule, diff --git a/src/lib/bahiaGeometriesTypes.ts b/src/lib/bahiaGeometriesTypes.ts index ec2855e5..363d877d 100644 --- a/src/lib/bahiaGeometriesTypes.ts +++ b/src/lib/bahiaGeometriesTypes.ts @@ -1,4 +1,4 @@ -import type { Feature, MultiPolygon, Polygon } from 'geojson' +import type { Feature, GeoJsonProperties, MultiPolygon, Polygon } from 'geojson' import type { GeometryCollection, Topology } from 'topojson-specification' type BahiaMunicipalityProperties = { @@ -11,10 +11,26 @@ type BahiaTerritoryProperties = { name: string } +/** + * Zone municipalities (Salvador ZE 1–19): keyed by the catalog slug, because a + * zone has no IBGE code of its own — the whole city shares `ibgeCode`. + */ +type MunicipalityZoneProperties = { + municipalitySlug: string + name: string + zoneNumber: number + ibgeCode: string +} + export type BahiaMunicipalityFeature = Feature export type BahiaTerritoryFeature = Feature +export type MunicipalityZoneFeature = Feature + +/** Any feature of the committed meshes — for helpers that only read coordinates. */ +export type BahiaGeometryFeature = Feature + export type MunicipalityTopology = Topology<{ municipalities: GeometryCollection }> @@ -23,6 +39,10 @@ export type TerritoryTopology = Topology<{ territories: GeometryCollection }> +export type MunicipalityZoneTopology = Topology<{ + municipalityZones: GeometryCollection +}> + export type MunicipalityGeometryModule = { topology: MunicipalityTopology features: readonly BahiaMunicipalityFeature[] @@ -34,3 +54,9 @@ export type TerritoryGeometryModule = { features: readonly BahiaTerritoryFeature[] getTerritoryFeature: (code: string) => BahiaTerritoryFeature | undefined } + +export type MunicipalityZoneGeometryModule = { + topology: MunicipalityZoneTopology + features: readonly MunicipalityZoneFeature[] + getMunicipalityZoneFeature: (municipalitySlug: string) => MunicipalityZoneFeature | undefined +} diff --git a/src/lib/bahiaMapStyle.ts b/src/lib/bahiaMapStyle.ts index 6a5f111a..9f72b66b 100644 --- a/src/lib/bahiaMapStyle.ts +++ b/src/lib/bahiaMapStyle.ts @@ -12,7 +12,8 @@ export type ChoroplethValues = Record export type BahiaMapFillMode = 'sequential' | 'diverging' -export type FeatureKeyProperty = 'codarea' | 'code' +/** Properties that carry a map key, most specific first (see `featureMapKey`). */ +const MAP_KEY_PROPERTIES = ['municipalitySlug', 'codarea', 'code'] as const /** * Feature key → fill, for the discrete relative scales (B13). When present it @@ -91,13 +92,22 @@ export const buildHighlightSet = (highlightKey: string): Set => export const canonicalMapKeysKey = (keys: string[]): string => keys.length > 0 ? [...keys].sort().join(',') : '' -export const keyPropertyForMode = (mode: 'municipality' | 'territory'): FeatureKeyProperty => - mode === 'municipality' ? 'codarea' : 'code' - -export const featureKeyFromProperties = ( - properties: Record | undefined, - keyProperty: FeatureKeyProperty, -): string | undefined => properties?.[keyProperty] +/** + * The key a feature is painted and addressed by, whichever mesh it came from: + * a zone municipality carries `municipalitySlug` (B8 F2 — the whole city shares + * one codarea, so the code cannot identify it), a município `codarea`, an + * identity territory `code`. The three never coexist on one feature, so no mode + * argument is needed. + */ +export const featureMapKey = ( + properties: Record | undefined | null, +): string | undefined => { + for (const property of MAP_KEY_PROPERTIES) { + const value = properties?.[property] + if (typeof value === 'string' && value.length > 0) return value + } + return undefined +} export const resolvePathStyle = (context: LayerStyleContext, key: string): PathOptions => { const classed = context.fillByKey !== undefined diff --git a/src/lib/bahiaMunicipalityZoneGeometries.ts b/src/lib/bahiaMunicipalityZoneGeometries.ts new file mode 100644 index 00000000..dc236004 --- /dev/null +++ b/src/lib/bahiaMunicipalityZoneGeometries.ts @@ -0,0 +1,23 @@ +import { feature } from 'topojson-client' + +import type { + MunicipalityZoneFeature, + MunicipalityZoneGeometryModule, + MunicipalityZoneTopology, +} from '@/lib/bahiaGeometriesTypes' +import municipalityZoneTopologyJson from '@/lib/geometries/bahia-municipality-zones.topo.json' + +const topology = municipalityZoneTopologyJson as unknown as MunicipalityZoneTopology + +const municipalityZoneFeatures = feature(topology, topology.objects.municipalityZones) + .features as MunicipalityZoneFeature[] + +const zoneBySlug = new Map( + municipalityZoneFeatures.map((entry) => [entry.properties.municipalitySlug, entry]), +) + +export const municipalityZoneGeometryModule: MunicipalityZoneGeometryModule = { + topology, + features: municipalityZoneFeatures, + getMunicipalityZoneFeature: (municipalitySlug: string) => zoneBySlug.get(municipalitySlug), +} diff --git a/src/lib/geometries/bahia-municipality-zones.topo.json b/src/lib/geometries/bahia-municipality-zones.topo.json new file mode 100644 index 00000000..de33847a --- /dev/null +++ b/src/lib/geometries/bahia-municipality-zones.topo.json @@ -0,0 +1 @@ +{"type":"Topology","bbox":[-38.65748489999993,-13.017326199999957,-38.304332699999975,-12.742395399999964],"transform":{"scale":[0.000035318751875182926,0.000027495829582957576],"translate":[-38.65748489999993,-13.017326199999957]},"objects":{"municipalityZones":{"type":"GeometryCollection","geometries":[{"type":"MultiPolygon","arcs":[[[0,1,2,3]]],"properties":{"municipalitySlug":"salvador-ze-1","name":"Salvador — ZE 1","zoneNumber":1,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[4,5,6,-3]]],"properties":{"municipalitySlug":"salvador-ze-2","name":"Salvador — ZE 2","zoneNumber":2,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[7,8,9,10]]],"properties":{"municipalitySlug":"salvador-ze-3","name":"Salvador — ZE 3","zoneNumber":3,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[11,12,13]],[[14]],[[15]],[[16]],[[17]],[[18]],[[19]]],"properties":{"municipalitySlug":"salvador-ze-4","name":"Salvador — ZE 4","zoneNumber":4,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[20,21,22,23,24,25,26,-10]]],"properties":{"municipalitySlug":"salvador-ze-5","name":"Salvador — ZE 5","zoneNumber":5,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[-2,27,28,29,-5]]],"properties":{"municipalitySlug":"salvador-ze-6","name":"Salvador — ZE 6","zoneNumber":6,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[-27,-28,-1,30,31,-11]]],"properties":{"municipalitySlug":"salvador-ze-7","name":"Salvador — ZE 7","zoneNumber":7,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[32,33,34,35,-13,36]],[[37,38]]],"properties":{"municipalitySlug":"salvador-ze-8","name":"Salvador — ZE 8","zoneNumber":8,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[39,40,41]]],"properties":{"municipalitySlug":"salvador-ze-9","name":"Salvador — ZE 9","zoneNumber":9,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[42,43,-25,44,45,46,47]]],"properties":{"municipalitySlug":"salvador-ze-10","name":"Salvador — ZE 10","zoneNumber":10,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[48,-38,49,50,51,-22,52,-34]]],"properties":{"municipalitySlug":"salvador-ze-11","name":"Salvador — ZE 11","zoneNumber":11,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[-47,53,54]]],"properties":{"municipalitySlug":"salvador-ze-12","name":"Salvador — ZE 12","zoneNumber":12,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[55,-6,-30,56,-43]]],"properties":{"municipalitySlug":"salvador-ze-13","name":"Salvador — ZE 13","zoneNumber":13,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[-23,-52,57]]],"properties":{"municipalitySlug":"salvador-ze-14","name":"Salvador — ZE 14","zoneNumber":14,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[58,-40,59,60,-35,-53,-21,-9]]],"properties":{"municipalitySlug":"salvador-ze-15","name":"Salvador — ZE 15","zoneNumber":15,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[-44,-57,-29,-26]],[[-58,-51,61,-45,-24]]],"properties":{"municipalitySlug":"salvador-ze-16","name":"Salvador — ZE 16","zoneNumber":16,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[-14,-36,-61,62]]],"properties":{"municipalitySlug":"salvador-ze-17","name":"Salvador — ZE 17","zoneNumber":17,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[-59,-8,-32,63,-41]]],"properties":{"municipalitySlug":"salvador-ze-18","name":"Salvador — ZE 18","zoneNumber":18,"ibgeCode":"2927408"}},{"type":"MultiPolygon","arcs":[[[-46,-62,-50,-39,-49,-33,64,-54]]],"properties":{"municipalitySlug":"salvador-ze-19","name":"Salvador — ZE 19","zoneNumber":19,"ibgeCode":"2927408"}}]}},"arcs":[[[3883,1288],[4,-5],[6,-9],[4,-3],[3,19],[35,57],[30,40],[10,16],[16,44],[5,16],[4,8],[8,-4],[0,-4],[3,1],[7,-11],[3,1],[5,-3],[4,2],[3,7],[4,3],[-4,11],[1,3],[17,21],[10,14],[9,24],[12,21],[13,20],[16,29],[4,5],[8,8],[3,-1],[9,13],[7,11],[5,6],[10,9],[9,7],[6,7],[3,0],[9,14],[8,5],[2,12],[5,11],[3,13],[2,1],[11,10],[-1,2],[6,8],[2,-1],[3,-5],[5,-5],[4,4],[6,10],[2,6],[4,-3],[-1,-9],[2,-4],[3,-2],[-1,-3],[6,-3],[3,4],[8,-1],[0,-3],[-7,-8],[-6,-9],[0,-8],[-6,-36],[-4,-38],[8,-51],[2,-15],[-2,-18],[-2,-7],[-4,-6],[-9,-9],[-47,-20],[-37,-15],[-9,-7],[-39,-44],[2,-17],[4,2],[1,-8],[4,0],[2,-30],[-22,-4],[3,-19],[-28,0],[0,-45],[1,-9],[8,-20],[-11,-11],[-11,-9],[-1,-11],[-3,-14],[-14,-17],[13,-20],[-11,-15],[-20,-27],[-5,-8],[-18,31],[0,-1],[-12,-27],[7,-7],[6,-9],[3,-9],[3,-15],[3,-17],[1,-22],[3,-10],[7,-10],[-13,-18],[-3,-6],[0,-9],[1,-4],[5,-6],[7,-2],[6,-6],[3,-9],[-2,-9],[-20,-24],[-8,-7],[-11,-3],[4,-17],[4,-9],[8,2],[5,3],[5,1],[3,4],[4,10],[12,16],[13,18],[4,14],[4,12],[3,5],[5,4],[8,8],[3,8],[0,10],[17,0],[16,0],[2,10],[3,6],[4,3],[6,2],[7,-1],[5,-2],[5,-4],[5,-6],[11,-25],[1,-8],[1,-13],[5,3],[7,4],[12,1],[17,-1],[14,1],[24,11],[7,5],[2,4],[3,10],[-1,21]],[[4242,1070],[6,-25],[4,-9],[9,-12],[6,-10],[6,-8],[7,-7],[4,-1]],[[4284,998],[-9,-18],[-11,-35],[-5,-19],[-2,-11],[0,-11],[2,-29],[-5,5],[-9,3],[-6,-1],[-6,-3],[-4,-4],[-6,-9],[-2,-9],[-10,-1],[-9,-2],[-10,-4],[-8,-5],[-8,-8],[-3,-5],[-12,-22],[-7,-10],[-5,-5],[-6,-4],[-10,-1],[-7,1],[-18,8],[-6,1],[-14,-1],[-22,-7],[-11,-1],[-9,1],[-6,1],[-9,3],[-7,2],[-12,5],[-10,9],[-11,9],[-3,-3],[14,-14],[4,-9],[1,-9],[-1,-9],[-5,-18],[-2,-3],[-5,-8],[-2,-6],[-20,-64],[-2,-11],[-1,-9],[3,-22],[-1,-4],[17,-4],[-2,-8],[-4,-17],[2,-6],[6,-5],[7,-1],[7,7],[3,9],[4,4],[-5,8],[8,8],[3,4],[3,14],[0,8],[3,10],[10,-3],[4,-6],[2,-6],[-1,-13],[1,0],[3,7],[3,4],[5,14],[6,3],[10,1],[5,4],[0,5],[4,23],[0,8],[9,3],[4,0],[-2,-16],[11,-2],[-3,-7],[1,-9],[5,-13],[7,-22],[0,-10],[1,-29],[1,-6],[3,-13],[4,-12],[-3,-12],[-21,-62],[1,-4],[5,-7],[-6,-7],[-13,-13],[-9,-3],[-7,0],[-7,-4],[-24,-23],[-8,-2],[-4,-6],[-2,1],[-1,-6],[0,-13],[-3,-25],[0,-3],[-7,4],[-1,2],[2,5],[-12,9],[2,9],[0,9],[-3,7],[2,5],[1,4],[-4,6],[7,1],[3,8],[4,7],[6,11],[1,6],[0,4],[-5,16],[-3,15],[-1,16],[-2,5],[-9,23],[-14,25],[-11,13],[-2,2],[1,9],[-5,6],[-5,-2],[-24,3],[-8,1],[-11,-2],[-12,-5],[-9,-4],[-8,-7],[-6,-6],[-9,-13],[-14,-17],[-29,-47],[-20,-29],[-7,-16],[-1,-4],[-1,-5],[0,-26],[5,-32],[2,0],[24,18],[10,0],[8,-1],[9,-5],[5,-5],[6,-9],[7,-15],[12,-18],[14,-24],[9,6],[6,2],[7,0],[0,-5],[2,-2],[0,-5],[-5,-8],[-3,-13],[0,-6],[3,-4],[15,-11],[3,-7],[3,-8],[0,-6],[4,-23]],[[3946,246],[-4,-1],[-3,2],[-7,-1],[-2,5],[-2,0],[-9,6],[-4,10],[-14,-2],[-6,-1],[-10,-6],[-8,-5],[-5,-1],[-6,-2],[-7,-5],[-12,-11],[-21,-8],[-14,-3],[-6,-2],[0,-3],[-5,-1],[-3,-5],[-3,0],[-8,-9],[-5,0],[1,10],[-6,-3],[-6,5],[0,3],[-5,-1],[0,9],[1,3],[-2,2],[2,12],[-1,10],[-15,8],[-20,4],[-6,3],[-12,-1],[-11,-1],[-12,2],[-19,0],[-16,-4],[-13,0],[-16,-3],[-18,-4],[-12,-1],[-9,-3],[-22,-1],[-10,-5],[-7,-9],[7,-4],[1,-4],[-5,-7],[-3,-2],[-3,-9],[-3,-4],[-5,-2],[-11,3],[-4,10],[3,4],[-3,0],[-3,-6],[-5,1],[-7,4],[6,6],[6,1],[4,3],[-1,3],[-11,-2],[-1,2],[-3,-1],[-6,3],[-4,-1],[-1,7],[7,1],[-2,5],[1,10],[8,11],[12,8],[-2,4],[4,1],[9,-1],[4,3],[0,3],[3,8],[1,6],[-1,4],[3,2],[0,6],[-2,8],[-10,1],[-5,5],[-2,5],[0,7],[-4,9],[-11,14],[-4,-1],[-3,1],[-1,2],[5,7],[2,4],[-2,11],[-4,1],[3,6],[2,1],[5,7],[7,4],[1,3],[-1,9],[-2,4],[-3,0],[-5,2],[3,13],[-3,3],[-1,-1],[-12,0],[0,4],[3,5],[5,2],[-1,6],[-8,-3],[-1,3],[-3,-1],[0,4],[-5,-1],[5,7],[0,7],[-4,2],[-3,0],[0,-6],[-3,-1],[-1,4],[3,8],[6,-1],[2,4],[-1,3],[5,1],[3,-4],[4,4],[-1,-8],[6,0],[5,5],[4,5],[4,10],[2,19],[1,14],[0,15],[-2,7],[-3,2],[0,6],[3,5],[0,4],[3,3],[-3,2],[0,3],[4,6],[-2,10],[4,8],[-3,2],[6,4],[0,6],[9,8],[5,-4],[0,-3],[4,-2],[7,1],[4,4],[8,3],[3,11],[3,3],[7,6],[6,1],[6,3],[15,15],[2,3],[7,16],[1,24],[0,11],[-3,8],[-2,4],[0,12],[0,7],[5,9],[-1,6],[-3,-1],[-1,5],[3,8],[2,12],[5,10],[9,12],[8,7],[4,7],[0,16],[1,4],[7,12],[-1,10],[1,4],[5,5],[12,12],[7,10],[5,10],[3,8],[-1,8],[1,3],[7,1],[4,3],[-3,3],[-5,1],[0,8],[5,8],[11,20],[7,10],[5,9],[8,9],[2,4],[0,13],[8,18],[4,7],[7,0],[2,6],[3,3],[2,5],[0,6],[3,9],[8,10],[2,2],[2,-4],[3,1],[0,3],[-3,5],[0,4],[3,5],[7,7],[2,11],[3,3],[5,5],[4,8],[10,10],[4,7],[4,17],[3,10],[5,11],[5,7],[5,9],[16,15],[4,3],[9,1],[4,3],[1,6],[-7,0],[0,4],[3,4],[2,7],[3,16],[6,13],[11,13],[6,7]],[[4284,998],[10,-4],[14,-4],[59,-16],[7,-2],[6,-4],[11,-9],[10,-13],[5,-11],[6,-19],[4,-11],[7,-7],[8,-5],[7,-1],[27,-1],[10,-2],[13,-9],[13,-12],[28,-30],[6,-9],[22,-26],[6,-9],[11,-11],[29,-18],[11,-5],[32,-20],[10,-7],[9,-8],[3,-4],[3,-6],[13,11],[25,11],[19,25],[22,-16],[10,-1],[1,28],[2,6],[4,2],[6,-1],[17,-7],[3,1],[4,7],[16,38],[7,-13],[1,-5],[-1,-4],[-5,-25],[-2,-3],[18,-1],[1,2],[4,2],[21,8],[8,-4],[3,-19],[21,-30],[5,-10],[1,11],[9,-7],[7,-2],[16,-11],[11,-12],[6,1],[5,-2],[4,-4],[3,2],[9,-19],[5,-14],[6,-32]],[[4976,628],[-11,-5],[-9,-6],[-6,-6],[-4,-6],[-5,-14],[-1,-9],[0,-9],[3,-37],[1,-24],[-1,-8],[-3,-7],[19,3],[2,3],[4,20],[2,7],[3,4],[10,7],[3,9],[6,3],[11,7],[19,6],[4,3],[29,28],[11,11],[11,8],[9,11],[7,4],[5,4],[0,-28],[-21,-38],[-1,-2],[-2,-56],[1,-3],[3,3],[-6,-9],[6,-16],[-1,-37],[13,-12],[5,-4],[3,-13],[3,-10],[5,-18],[1,-11],[6,-35],[6,-14],[5,-7],[9,-6],[2,-14],[-22,-2],[-7,-2],[-8,-4],[-34,-18],[-2,10],[-12,-1],[-8,3],[-9,7],[-7,9],[-13,11],[-4,5],[-9,15],[-10,10],[-8,4],[-14,0],[-6,2],[-9,5],[-3,3],[-6,7],[-4,3],[-12,45],[-9,-2],[2,-5],[-7,-4],[-5,3],[-4,0],[-27,-18],[-5,-4],[-28,3],[-4,-7],[1,-4],[15,-6],[8,-6],[3,8],[3,2],[7,-3],[13,-12],[13,-5],[8,-4],[2,-14],[-2,-13],[2,1],[2,-8],[2,-11],[4,-3],[10,-7],[6,1],[9,-9],[-2,10],[9,2],[7,7],[6,-3],[-1,-11],[4,-2],[4,-4],[3,4],[7,6],[3,-1],[0,-4],[4,-5],[3,-5],[4,-2],[4,-9],[6,-19],[0,-6],[-1,-12],[2,-3],[4,5],[7,-2],[1,-7],[-1,-5],[-19,-30],[-12,-9],[8,-3],[5,-4],[3,3],[13,4],[3,-6],[3,-1],[7,-13],[2,-8],[-2,-8],[11,-6],[6,-2],[6,0],[27,3],[24,4],[-5,40],[12,-1],[10,-7],[22,2],[8,-2],[5,7],[41,4],[5,16],[5,7],[7,3],[-1,10],[0,9],[3,3],[7,-22],[8,-10],[2,-13],[41,5],[0,16],[9,3],[26,23],[30,-35],[17,4],[-11,15],[-9,4],[-7,7],[3,4],[18,14],[6,5],[6,-10],[33,-54],[5,-2]],[[5398,193],[-10,-9],[-7,-10],[-5,-12],[-8,-12],[-5,-15],[-6,-22],[-7,-19],[-6,-5],[-24,10],[-17,3],[-8,-2],[-17,-11],[-6,4],[-10,8],[-33,12],[-14,3],[-13,-4],[-13,-3],[-12,-1],[-13,-2],[-12,0],[-9,-3],[-40,-14],[-18,-7],[-18,-8],[-12,-9],[0,-6],[-7,-7],[-18,-8],[-5,1],[-1,4],[-3,5],[-5,4],[1,4],[-3,4],[-5,4],[-10,2],[-3,-3],[-6,3],[-2,5],[-3,13],[-8,1],[-6,2],[-11,-2],[-6,-7],[-8,0],[-5,-4],[-14,-9],[-8,-7],[-5,3],[-7,-1],[-4,-3],[-3,-11],[-5,0],[-2,-2],[-1,-7],[-4,-3],[-9,4],[-2,4],[-8,2],[-4,-9],[-2,2],[-3,-11],[-2,-3],[-2,2],[-2,-1],[-2,-11],[-4,-4],[-4,1],[-4,-4],[-12,-11],[-4,-1],[-4,2],[-2,5],[-17,2],[-5,-1],[-3,-6],[-6,0],[-5,15],[-1,7],[3,5],[-3,5],[-4,1],[-1,5],[-2,19],[-1,18],[-3,4],[-4,-1],[-3,-4],[-6,1],[-2,4],[-5,5],[0,11],[0,19],[-5,3],[-10,10],[-9,8],[-6,4],[-5,-3],[-6,0],[-5,-3],[-6,-3],[-7,0],[-5,2],[-2,4],[3,8],[-7,3],[-4,-1],[-4,2],[1,11],[0,8],[6,10],[4,4],[-4,8],[-4,8],[-11,7],[-7,-1],[0,-3],[-9,0],[-4,-3],[-12,-1],[-3,-4],[-1,4],[-3,0],[-6,6],[-6,2],[-5,-4],[-4,0],[0,6],[-6,-5],[-4,0],[-6,-5],[-5,1],[-2,4],[-17,0],[-3,4],[-3,20],[-3,4],[-5,1],[-17,-8],[-1,-4],[0,-6],[2,-5],[-1,-5],[-12,-3],[-5,-1],[-13,-4],[-4,-2],[-5,0],[-11,-8],[-9,2],[-4,-2],[-1,-5],[-2,-2],[-7,-2],[-6,3],[-3,-5],[-7,-3],[-3,3],[-4,-1],[-9,8],[-1,5],[2,5],[-3,3],[-1,7],[-9,4],[-7,3],[-5,6],[-2,11],[-9,4],[-9,-1],[-11,-2],[-10,-4],[-14,-6],[-8,-5],[-14,-12],[-6,-3],[-3,2],[-5,-6],[0,-8],[-4,-6],[-6,-2],[-5,1],[-5,3],[0,7],[4,10],[-5,5],[-8,3],[-7,0],[-4,-2],[-2,-3],[-6,-7],[-11,-4],[-6,1],[-10,-7],[-8,6],[-6,8],[-9,5],[-3,6],[-7,4],[-3,6],[1,4],[-4,5],[-6,2],[-18,-1],[-14,-12],[0,-5],[-4,-8],[-4,-3],[0,-15],[1,-13],[-7,-1],[-2,-4],[-3,-1],[-1,6],[-2,4],[2,3],[-7,4],[0,4],[5,3],[1,9],[1,1],[-2,6],[-10,9],[-5,2],[-4,-2],[-4,-11],[-5,0],[-3,3],[-4,6],[0,3],[-9,-1],[-7,-4],[-3,2],[-2,8],[-4,0],[-1,-5],[-7,-5],[-3,-3],[-6,-2],[-16,10],[-3,13],[2,5],[-1,3],[-5,-3],[-5,1],[0,5],[3,6],[3,2],[-5,1],[-4,5],[-9,6],[-6,2],[-6,-1],[-4,-5],[0,-5],[-5,-3],[-4,0],[-4,4],[-1,-4],[-12,-3],[-4,3],[-9,-1],[-4,4],[-1,-1],[-2,-5],[-4,-3],[-4,0],[-6,3]],[[4583,1987],[1,4],[4,-1],[8,0],[9,5],[9,11],[2,5],[5,15],[3,2],[1,6],[5,-2],[0,-9],[-1,-14],[1,-7],[4,-10],[3,-3],[7,-4],[2,0],[17,7],[2,13],[15,-3],[5,31],[10,2],[7,19],[15,-3],[4,18],[9,-3],[15,-5],[-1,-4],[-5,-12],[-2,-3],[4,1],[1,-6],[11,12],[2,6],[7,25],[5,-3],[0,-11],[-2,-13],[-5,-7],[-16,-18],[2,-7],[19,26],[5,9],[1,7],[4,29],[1,8],[2,21],[-16,4],[5,4],[7,3],[3,-1],[1,8],[5,14],[10,20],[2,5],[-16,22],[2,2],[-16,19],[-10,7],[-4,2],[8,21],[5,-3],[14,-1],[3,-1],[7,-8],[0,-1],[14,18],[-15,10],[-9,8],[-3,4],[-2,7],[-2,3],[-1,6],[-7,1],[-9,3],[-12,-3],[-16,-3],[-13,-2],[-9,-1],[-16,1],[-24,2],[1,3],[-5,9],[-4,8],[0,13],[2,6],[1,8],[-1,15],[3,13],[19,32],[8,16],[1,4],[1,19],[-4,21],[-2,6],[23,8],[-7,28],[-5,25],[2,0],[12,7],[2,-6],[2,-22],[3,-19],[6,3],[15,-8],[11,-5],[14,-1],[22,3],[16,-3],[5,-3],[5,-1],[13,-8],[2,-1],[31,-4],[3,9],[-6,4],[-7,15],[8,7],[3,6],[2,7],[8,1],[0,8],[1,5],[0,6],[3,11],[2,-1],[4,12],[16,28],[8,10],[7,3],[4,0],[5,5],[7,4],[7,2],[-2,5],[9,4],[-7,23],[-12,44],[-11,24],[-13,28],[-4,5],[-6,5],[-11,8],[-16,6],[-7,4],[-4,5],[-5,8],[-5,8],[-4,11],[-5,19],[-5,10],[-5,3],[-7,2],[-11,-1],[-28,-5],[-13,-1],[-8,0]],[[4751,2814],[1,13],[3,11],[6,0],[10,5],[10,8],[8,7],[9,10],[4,2],[36,18],[5,3],[6,1],[24,9],[14,9],[4,5],[10,13],[18,15],[16,10],[13,14],[8,-14],[4,-10],[8,-14],[3,-4],[7,8],[2,4],[9,11],[4,-5],[10,-6],[10,-14],[11,-1],[9,3],[1,-9],[8,-19],[5,-5],[7,-2],[10,2],[7,-5],[7,-8],[3,-1],[7,1],[10,2],[22,-11],[-2,-8],[-7,-11],[0,-3],[4,-2],[8,-9],[9,-14],[3,-5],[-3,-3],[2,-5],[3,1],[5,-10],[8,3],[4,0],[12,-6],[5,2],[19,-15],[12,-6],[9,-1],[9,1],[1,-18],[5,1],[6,0],[8,-4],[8,-4],[7,-5],[-1,-1],[-11,-44],[10,-3],[-11,-9],[-7,-6],[-5,-10],[8,-5],[-2,-6],[2,-6],[-10,-3],[-5,13],[-9,-7],[-4,5],[-6,-2],[-5,3],[-14,-9],[-4,-6],[-1,-5],[3,-14],[-2,-13],[1,-1],[-2,-9],[-2,2],[-3,-5],[-8,-6],[-6,-6],[2,-10],[7,-3],[7,-11],[9,6],[0,-21],[4,-24],[0,-5],[-2,-8],[-3,1],[-2,-3],[-9,0],[-16,2],[-2,-14],[8,-6],[5,-3],[-3,-10],[5,-6],[11,-18],[-10,-9],[-16,-10],[-4,-2],[-8,-1],[-3,-2],[-6,-16],[3,-8],[-1,-8],[1,-6]],[[5138,2401],[-8,-2],[0,-3],[-1,-8],[11,-5],[11,-7],[11,-9],[0,-16],[1,-32],[0,-14],[-1,-13],[-1,-11],[-2,-8],[-15,-46],[-11,-33],[-30,-92],[-31,-96],[-3,-10],[-10,-18],[-9,-26],[-3,-14],[-5,-22],[-3,-15],[-13,-94],[-2,-19],[4,-29]],[[5028,1759],[-21,-1],[-10,1],[-33,7],[-16,3],[-45,10],[-87,19],[-16,4],[-13,6],[-54,31],[-29,17],[-8,2],[-10,2],[-18,0],[-2,14],[-4,12],[-6,-3],[-14,0],[-11,28],[-4,15],[7,5],[-3,9],[-4,0],[-4,19],[0,11],[7,-2],[4,-2],[6,-6],[-1,17],[-9,3],[-19,1],[-15,2],[-13,4]],[[5120,5863],[5,20],[5,10],[3,11],[3,4],[2,10],[4,8],[1,12],[5,19],[1,18],[0,8],[-3,9],[-2,7],[-2,21],[-2,8],[-4,6],[1,5],[-3,4],[0,16],[2,7],[2,6],[2,16],[3,12],[4,20],[4,25],[1,2],[18,-3],[15,-3],[11,-2],[2,2],[2,10],[1,6],[8,14],[5,13],[6,12],[1,6],[9,21],[2,2],[3,8],[8,14],[11,16],[-1,13],[0,10],[-1,2],[0,7],[-3,-5],[0,9],[3,7],[-3,2],[-6,-6],[-1,8],[2,2],[8,5],[-2,3],[-31,4],[-3,3],[12,-1],[3,6],[3,-2],[2,-5],[5,-1],[6,9],[2,10],[1,12],[-1,13],[-1,11],[-4,18],[-2,13],[-5,14],[-9,14],[-22,30],[-11,12],[-2,0],[-15,20],[-11,17],[-10,15],[-7,9],[-2,6],[0,3],[-6,2],[-4,3],[-10,14],[-3,7],[-8,16],[-7,10],[-6,7],[-9,12],[-7,7],[-8,12],[-13,13],[-6,7],[-16,15],[-12,10],[-9,9],[-11,9],[-26,18],[-18,12],[-9,5],[-8,4],[-11,8],[-4,1],[-6,-2],[-3,-2],[-2,-11],[-5,-6],[-6,-6],[-7,-8],[-5,-8],[-1,-3],[-3,-2],[-13,-1],[-1,-6],[-3,0],[-4,4],[-10,6],[-4,0],[-7,-7],[-8,-1],[-4,-4],[-4,0],[-6,6],[-12,1],[-6,6],[-2,6],[-5,2],[-3,5],[-1,14],[-3,5],[-3,-1],[-9,-5],[-1,4],[1,4],[-1,6],[-7,11],[-4,6],[-9,10],[-6,9],[-6,7],[-6,11],[1,7],[5,8],[-3,9],[1,3],[4,5],[7,3],[6,12],[1,4],[1,16],[-1,6],[1,9],[-1,11],[-4,13],[-1,11],[-1,2],[-7,26],[-2,16],[0,11],[1,33],[3,7],[5,-1],[6,1],[4,4],[2,7],[1,9],[4,25],[2,10],[1,10],[2,7],[5,0],[3,-2],[6,-1],[2,5],[0,5],[-2,5],[-6,4],[-7,10],[0,7],[1,7],[7,21],[6,11],[3,9],[3,12],[2,12],[3,25],[-1,15],[4,5],[-1,5],[-2,5],[1,7],[-1,3],[-5,7],[-1,9],[-5,12],[1,8],[1,7],[-1,12],[-3,10],[-6,15],[-1,5],[-6,14],[-3,12],[-10,21],[-9,15],[-9,15],[-10,14],[-5,6],[-13,13],[-8,6],[-12,6],[-11,5],[-6,6],[-6,7],[-8,7],[-16,12],[-8,5],[-20,6],[-9,2],[-11,0],[-9,-1],[-4,-2],[-6,-4],[-7,-7],[-8,-4],[-9,-6],[-8,-4],[-5,-4],[-2,5],[-7,2],[-12,0],[-4,-1],[-3,-3],[-1,1],[0,7],[-3,3],[-4,2],[-9,2],[-6,4],[-10,10],[-10,7],[-5,0],[-8,4],[-3,-2],[1,13],[-3,1],[0,-10],[-2,-1],[-4,4],[-3,13],[-3,5],[1,4],[4,0],[1,3],[-1,8],[-2,13],[0,6],[5,9],[-1,6],[-1,7],[2,9],[0,8],[-4,4],[-1,14],[-3,4],[0,7],[4,14],[-1,37],[1,21],[3,10],[2,11],[2,6],[6,27],[4,10],[5,20],[0,6],[-9,5],[-7,7],[-3,5],[-4,13],[0,8],[7,2],[5,3],[1,3],[-1,11],[0,11],[2,10],[-1,16],[0,12],[-2,9],[-4,21],[-1,13],[4,25],[-1,14],[2,9],[4,8],[2,6],[0,20],[0,17],[1,11],[0,10],[-3,13],[-4,10],[-12,25],[-2,6],[-1,18],[1,4],[7,11],[3,5],[2,9],[4,14],[-1,6],[-12,28],[-4,4],[-2,5],[-1,12],[4,16],[3,10],[10,24],[10,6],[8,1],[6,-1],[16,-7],[6,-4],[8,-7],[16,-20],[10,-6],[11,-9],[18,-8],[2,1],[11,-3],[6,-4],[2,-2],[1,-8],[1,-6],[0,-9],[7,-1],[-2,-20],[1,-2],[-7,-54],[-6,1],[1,-17],[-1,-20],[2,-3],[5,0],[7,13],[3,0],[1,-7],[2,0],[14,108],[4,0],[-14,-114],[1,-7],[39,-66],[1,-14],[-7,-5],[-27,-25],[-10,-10],[-4,-6],[3,-5],[6,3],[37,34],[6,6],[15,-8],[47,44],[46,43],[3,1],[0,2],[3,3],[-2,4],[-6,0],[-2,3],[-5,11],[1,9],[4,7],[3,2],[12,5],[9,6],[11,4],[15,1],[15,1],[5,2],[3,3],[5,9],[4,10],[3,11],[0,12],[4,10],[1,6],[-2,11],[-8,5],[4,5],[1,7],[2,2],[-1,3],[4,2],[2,5],[7,9],[8,7],[9,2],[3,2],[11,-2],[10,-3],[8,-1],[5,0],[15,6],[4,4],[9,5],[0,1],[6,4],[11,4],[12,2],[15,4],[4,5],[8,1],[13,8],[7,5],[3,0],[5,-3],[5,-1],[2,-3],[9,-5],[7,-1],[8,1],[14,6],[1,-3],[-7,-7],[-6,-4],[-4,-1],[0,-5],[-6,-8],[11,-4],[4,-1],[15,2],[6,3],[6,8],[8,29],[2,7],[1,10],[-5,15],[-3,4],[-6,3],[-5,10],[3,9],[1,6],[6,4],[10,1],[6,-2],[4,-5],[13,-32],[10,-2],[13,7],[1,2],[0,7],[1,4],[6,3],[9,-6],[3,-1],[3,-7],[4,-15],[2,-5],[4,-5],[3,-5],[5,-2],[9,5],[5,7],[7,3],[7,-2],[6,-3],[5,-10],[6,-7],[5,-18],[1,-5],[4,-7],[6,-6],[3,-7],[3,-5],[1,-11],[9,-21],[4,-5],[8,-8],[5,-2],[7,4],[9,9],[6,3],[4,0],[16,-9],[2,-9],[-3,-4],[-10,-7],[-6,-7],[-9,-11],[-6,-12],[-3,-9],[0,-11],[0,-13],[3,-8],[1,-4],[0,-5],[-1,-2],[-15,7],[-9,2],[-18,-1],[-4,1],[-6,7],[-5,4],[-2,3],[-5,12],[-2,7],[1,4],[-2,1],[0,6],[6,1],[7,3],[3,4],[0,10],[-2,3],[-7,7],[-5,4],[-10,0],[-2,1],[-7,0],[-2,-2],[-3,1],[-11,-2],[-9,6],[-3,1],[-10,1],[-2,-3],[-7,-3],[-4,2],[-7,-5],[-5,0],[-8,-4],[-6,-4],[-3,-4],[-12,-21],[-2,-7],[0,-5],[2,-5],[8,-9],[5,-9],[1,-4],[2,-11],[2,-4],[1,-8],[8,-19],[1,-10],[-2,-3],[-10,-5],[-7,-1],[-14,2],[-5,-1],[-12,-6],[-5,0],[1,-8],[2,-6],[5,-8],[11,-9],[17,-18],[9,-7],[13,-3],[14,2],[14,-1],[4,-2],[2,-3],[0,-5],[-3,-7],[1,-9],[-6,-10],[-4,-4],[-9,-5],[-2,-2],[-7,-12],[-6,-5],[-13,-9],[-7,-6],[-3,-4],[-4,-8],[-3,-10],[-6,-29],[-6,-17],[-4,-15],[-2,-18],[0,-22],[-2,-6],[1,-13],[0,-10],[6,-6],[2,-6],[5,-1],[5,-4],[3,3],[8,-1],[3,-6],[-1,-14],[-3,-12],[-1,-10],[0,-21],[-2,-11],[-3,-15],[-10,-25],[-7,-15],[-8,-14],[-3,-10],[-4,-5],[-6,-1],[-4,2],[-4,6],[-5,3],[-15,0],[-3,-2],[-1,-6],[-4,-9],[-4,-6],[-5,-1],[-3,-4],[-3,-6],[-3,-12],[-4,-10],[0,-7],[-2,-7],[-6,-10],[-9,-16],[-3,-10],[-4,-9],[-7,-8],[-8,-17],[-1,-12],[-5,-10],[-6,-4],[-6,-20],[-5,-15],[0,-10],[4,-4],[0,-10],[2,-9],[1,-12],[2,-8],[2,-13],[1,-10],[-1,-7],[-4,-9],[-5,-19],[-6,-4],[-3,-11],[2,-9],[5,-5],[5,-9],[1,-8],[2,-4],[14,-8],[6,-5],[5,4],[9,-2],[6,1],[7,8],[1,11],[3,4],[-2,4],[0,5],[4,6],[5,2],[1,4],[3,2],[7,0],[4,1],[4,5],[0,3],[3,4],[1,4],[3,2],[-1,7],[2,3],[4,15],[3,3],[6,10],[3,10],[0,10],[-2,2],[1,7],[-2,6],[-6,11],[-4,22],[1,14],[2,5],[-1,2],[2,12],[3,5],[3,1],[2,3],[4,3],[-3,8],[0,13],[3,5],[2,-1],[7,5],[8,0],[5,4],[2,5],[1,6],[2,4],[-1,3],[1,6],[4,5],[4,-2],[0,7],[1,4],[5,4],[-2,4],[3,8],[8,7],[1,5],[9,7],[3,4],[6,13],[7,9],[6,13],[8,9],[8,3],[4,-2],[5,-5],[9,-3],[12,-9],[9,-11],[4,-20],[1,-10],[0,-21],[3,-31],[0,-20],[3,-6],[-1,-4],[1,-8],[2,-3],[3,-6],[2,-10],[2,-8],[3,-15],[2,-6],[-1,-3],[6,-7],[6,-2],[5,-3],[5,-6],[6,-3],[4,1],[7,-5],[2,-3],[4,0],[8,-4],[5,1],[6,4],[5,8],[1,5],[5,8],[7,7],[10,1],[4,-2],[2,-6],[6,-9],[5,-12],[0,-9],[-2,-11],[-3,-5],[-4,-2],[-3,-6],[-5,-6],[-5,-2],[-6,0],[-14,0],[-3,-6],[8,-1],[10,-3],[2,-3],[10,-7],[12,-2],[14,0],[6,3],[4,5],[2,5],[6,0],[-1,5],[1,8],[3,7],[0,5],[1,6],[6,9],[2,7],[3,2],[5,8],[1,5],[10,13],[10,17],[2,1],[1,-5],[5,-10],[2,-12],[0,-7],[-3,-5],[-7,-8],[-7,-9],[-3,-8],[-2,-12],[1,-9],[2,-5],[4,-8],[7,-4],[1,-8],[-7,-9],[-3,-7],[2,-5],[9,-2],[8,-5],[0,-4],[-2,-4],[-10,-1],[-2,-1],[0,-8],[4,-3],[4,-5],[3,-9],[2,-5],[4,-5],[2,-4],[1,-12],[8,-14],[2,-14],[3,-6],[-1,-4],[-2,-5],[0,-3],[6,-4],[1,-4],[0,-26],[-3,-11],[0,-4],[4,-12],[-2,-9],[-2,-19],[2,-8],[4,-10],[3,-4],[11,-4],[3,-2],[11,-11],[1,-8],[-5,-21],[-1,-16],[-1,-17],[-3,-15],[-5,-15],[-5,-41],[-5,-19],[-1,-9],[2,-10],[6,-12],[1,-6],[-2,-13],[1,-5],[6,-10],[5,-1],[22,-4],[6,-3],[6,-5],[5,-9],[10,-10],[22,-9],[7,-5],[16,-7],[29,-10],[20,-6],[7,-2],[23,-13],[9,-9],[10,-13],[0,-12],[-2,-8],[-12,-30],[-10,-31],[-7,-48],[-2,-29],[1,-10],[2,-9],[6,-18],[3,-11],[11,-6],[12,-4],[15,2],[13,0],[10,-7],[5,-1],[7,1],[4,-1],[5,-6],[3,-5],[4,-8],[3,-15],[-1,-6],[-8,-11],[-11,-12],[-8,-11],[-1,-6],[0,-6],[3,-14],[5,-13],[3,-13],[5,-11],[3,-7],[6,-9],[9,-9],[7,-4],[10,-4],[3,-5],[15,-37],[4,-8],[14,-13],[12,-5],[16,-5],[9,-7],[5,-5],[5,-7],[14,-9],[7,11]],[[5872,6248],[3,-4],[9,-1],[3,-27],[-12,0],[-16,1],[-16,2],[-11,3],[-18,7],[-17,7],[-23,7],[-17,2],[-17,-1],[-23,-3],[-12,-1],[-7,2],[-5,-4],[3,-17],[2,0],[7,-33],[2,-14],[0,-13],[-1,-17],[-4,-28],[-2,-5],[-5,-33],[-5,-44],[-1,-8],[-2,-10],[-1,-6],[1,-21],[3,-21],[2,-12],[3,-9],[4,-10],[1,-6],[3,-6]],[[5703,5925],[-8,-2],[-8,-6],[-5,-11],[0,-7],[1,-4],[11,-16],[2,-7],[1,-11],[-3,-8],[0,-2],[-4,-9],[-10,-14],[-12,-13],[-5,-8],[2,-9],[2,-6],[2,-11],[-4,-12],[-1,-10],[2,-8],[4,-12],[0,-11],[-8,-27],[-2,-3],[-8,-12],[-14,-27],[-4,-10],[-14,-46],[-1,-17],[-1,-17],[-2,-12],[-7,-2],[-10,0],[-8,-5],[-5,-2],[-4,-3],[-9,0],[-8,-3],[-4,0],[-5,2],[-3,-4],[2,-2],[-1,-4],[-4,-2],[0,-10],[-8,-8],[-1,-2],[-6,-3],[-3,-4],[-10,-4],[-4,-1],[-13,-6],[-16,1],[-1,-1],[3,-6],[2,-7],[-4,-14],[-1,-1],[-3,-15],[-2,-2],[-3,-8],[-15,-14],[-3,-5],[-3,1],[-4,-4],[-6,0],[-5,5],[-3,5],[-1,4],[-10,10],[-5,2],[-6,-1],[-6,2],[-1,4],[-4,1],[-5,5],[0,5],[-4,0],[-2,14],[1,2],[-2,15],[-3,5],[2,9],[-3,8],[0,10],[-6,20],[-5,6],[-9,6],[-2,4],[0,5],[4,9],[0,4],[-2,9],[0,6],[1,4],[-14,3],[-2,10],[-6,3],[-10,1],[-7,16],[-5,5],[-12,8],[1,3],[9,14],[18,19],[6,9],[23,43],[4,6],[9,7],[12,2],[12,4],[10,25],[-34,9],[-44,8],[-19,3],[-32,2],[-27,4],[-1,15],[1,5],[-27,5],[-13,3],[-4,-10],[-3,-14],[-3,-2],[-6,1],[-19,0],[-2,-1],[-9,7],[1,18],[5,24],[2,4],[-16,-4],[-4,0],[-16,5],[-14,-1],[-9,3],[-8,4]],[[3539,8631],[4,11],[0,13],[-2,18],[-3,8],[-6,26],[-2,4],[-5,3],[1,3],[4,3],[-3,6],[0,3],[-4,-1],[-2,-4],[-6,1],[-6,4],[-2,7],[2,6],[5,3],[-2,6],[11,13],[6,12],[7,19],[1,14],[-2,17],[-2,14],[-3,10],[-4,15],[-2,5],[-17,25],[-7,12],[-12,21],[-8,10],[-5,6],[-14,13],[-13,9],[-28,18],[-16,10],[-33,22],[-43,22],[-22,13],[-24,19],[-23,20],[-18,21],[-12,18],[-5,12],[-8,26],[-14,40],[-20,35],[-25,30],[-4,8],[-2,10],[0,7],[3,12],[3,7],[10,14],[18,22],[15,18],[4,6],[4,8],[9,27],[1,8],[2,18],[1,35],[1,25],[1,9],[2,11],[5,19],[7,19],[7,12],[10,24],[6,16],[7,15],[8,15],[13,28],[4,10],[5,12],[11,19],[5,12],[4,16],[2,18],[1,13],[-2,7],[-7,9],[-1,7],[5,8],[11,4],[7,15],[3,3],[4,0],[4,-5],[5,-3],[10,-3],[7,2],[-10,0],[-8,2],[-5,5],[-1,2],[3,5],[11,14],[10,15],[5,10],[3,14],[2,23],[3,20],[5,21],[7,21],[6,10],[3,1],[4,-1],[8,-11],[7,-13],[5,-6],[8,1],[9,2],[20,12],[10,8],[23,15],[10,9],[7,6],[5,3],[9,2],[7,-1],[11,-9],[5,-3],[7,-6],[5,-6],[8,-3],[4,-13],[2,-2],[10,-8],[4,-2],[16,-6],[6,-4],[2,-9],[4,-10],[9,-18],[5,-9],[8,-6],[7,-5],[9,-4],[10,-2],[9,-5],[12,-11],[14,-14],[3,-4],[4,-15],[3,-7],[7,-8],[16,-23],[4,-8],[8,-8],[10,-9],[10,-5],[6,2],[10,-5],[9,-7],[4,-1],[6,-7],[6,-2],[4,0],[3,2],[10,10],[10,8],[1,3],[3,14],[3,-5],[0,-8],[-1,-7],[4,-5],[1,-8],[-1,-5],[7,7],[1,-1],[-1,-7],[1,-3],[8,-2],[21,22],[2,9],[7,4],[0,4],[-5,3],[-7,2],[-4,-2],[-10,-1],[-4,2],[0,4],[6,3],[6,2],[3,4],[3,2],[4,4],[6,1],[10,3],[4,0],[8,-4],[5,-6],[7,-5],[2,-4],[0,-4],[-3,-13],[-2,-10],[4,-13],[3,-1],[8,-12],[9,-15],[18,-15],[18,-19],[3,-6],[3,-11],[7,-9],[4,-9],[1,-5],[-2,-6],[-4,-9],[-1,-4],[0,-18],[0,-23],[1,-11],[0,-11],[-1,-6],[-5,-11],[-4,-2],[0,-7],[-2,-7],[1,-14],[-3,-13],[0,-6],[-2,-4],[-10,8],[-1,-2],[-5,2],[-3,-1],[-3,-4],[-1,-4],[-3,-4],[-16,-30],[-2,-8],[1,-12],[-3,-7],[2,-12],[5,-14],[1,-8],[-2,-4],[8,-13],[3,-10],[3,-9],[7,-44],[3,-10],[1,-8],[4,-9],[-3,-14],[-6,0],[-1,-6],[-4,-6],[7,-19],[0,-5],[-1,-5],[2,-11],[-3,-4],[1,-4],[-7,-10],[-2,-1],[-9,2],[-1,-1],[-2,-11],[0,-14],[1,-4],[6,-6],[4,-8],[1,-6],[1,-18],[-1,-10],[-5,-9],[1,-10],[-2,-9],[-6,-10],[-9,-9],[-3,-5],[-4,-14],[-1,-13],[1,-5],[0,-7],[11,-7],[0,-5],[-3,-2],[-1,-5],[3,-4],[2,-7],[7,-3],[1,-12],[-3,-12],[6,-10],[5,-9],[4,-15],[2,-9],[0,-8],[0,-12],[0,-19],[0,-6],[1,-3],[3,-4],[1,-20],[1,-5],[4,-12],[1,-7],[7,-14],[8,-21],[7,-13],[3,-3],[0,-3],[10,-16],[4,-9],[1,-7],[-1,-4],[3,-11],[2,-5],[-5,-14],[1,-6],[7,-7],[3,-10],[2,-1],[2,-5],[-4,-14],[1,-5],[4,-4],[3,-9],[3,-7],[0,-4],[-3,-9],[2,-5],[3,-4],[0,-2],[3,-4],[1,-10],[4,-1],[0,-6],[2,-5],[4,-15],[1,-5],[4,-3],[4,-19],[2,-5],[5,-8],[1,-14],[3,-1],[1,-6],[4,-24],[0,-5],[-2,-2],[-9,22],[-2,1],[-4,-2],[-11,-10],[-5,-5],[-8,-10],[-4,-3],[-4,-6],[-2,-7],[-6,-5],[0,-10],[4,-4],[4,-13],[3,-8],[-1,-10],[0,-24],[1,-22],[5,-14],[6,-2],[3,-5],[2,0],[2,4],[7,0],[-1,-8],[-5,-1],[-6,1],[0,3],[-3,-1],[0,5],[-4,1],[0,-2],[0,-11],[3,-5],[3,0],[2,-6],[-7,-7],[-5,-12],[0,-5],[2,-7],[0,-4],[3,-4],[1,-7],[3,-3],[1,-2],[-8,-9],[-7,-1],[1,-4],[-1,-6],[7,-6],[1,-3],[-8,-8],[-4,-1],[-3,8],[-5,5],[-1,-5],[1,-3],[-2,-1],[-3,0],[-6,5],[-4,-8],[0,3],[-5,-3],[-3,-9],[2,-3],[11,-11],[0,-3],[-3,-3],[-3,4],[-1,-2],[-3,0],[-3,-6],[-1,-8],[7,1],[4,-2],[2,-7],[-2,-6],[-3,0],[-6,11],[-4,2],[-4,-3],[2,-7],[-3,6],[-3,2],[-6,-2],[0,-6],[5,-6],[-4,-16],[7,-3],[3,-6],[3,0],[6,5],[2,-4],[-1,-7],[-4,-6],[-9,-14],[-10,-9],[-9,-4],[-12,-8],[-7,-11],[-8,-7],[-6,-9],[-13,-7],[-2,-2],[-13,-10],[-9,-13],[-4,-8],[-8,-1],[-3,-1],[-8,-9],[-1,-9],[2,-4],[-3,-8],[-6,-2],[-3,-6],[-3,-3],[-4,-8],[-7,0],[-3,-2],[-2,-8],[-4,-9],[0,-6],[3,-6],[1,-5],[-1,-5],[1,-8],[0,-4],[-5,0],[-5,11],[-2,11],[-8,2],[-1,5],[1,11],[0,3],[-4,1],[-6,-4],[-9,-3],[-5,-7],[-12,2],[-1,-1],[1,-5],[10,-4],[4,-6],[3,1],[3,4],[3,-2],[1,-5],[-3,-6],[-1,-9],[-4,-3],[-5,2],[1,8],[-2,5],[-7,4],[-2,-1],[0,-6],[-2,-6],[-2,-2],[-5,1],[-3,3],[-9,14],[-3,10],[-4,3],[-4,0],[-2,-4],[0,-10],[-3,-5],[-5,-3],[-3,-5],[-3,-1],[-2,4],[0,10],[-2,9],[-6,3],[-8,1],[-4,1],[-6,4],[-17,7],[-9,4],[-10,3],[-3,2],[-5,-3],[-4,3],[-2,-2],[-5,1],[-8,4],[-12,1],[-7,2],[-5,4],[-5,3],[-9,3],[-5,0],[-16,-2],[-3,-4],[-2,1],[1,5],[0,9],[-6,5],[-8,4],[-4,-1],[-5,-9],[-10,-7],[-8,1],[-5,-1],[-4,-5],[-6,-2],[-5,2],[-10,0],[-4,2],[-6,11],[-2,2],[-5,0],[-3,-5],[-7,-1],[-8,0],[-7,-1],[-7,1],[-11,7],[-3,4],[0,7],[0,7],[3,5],[7,10],[1,8],[-3,7],[2,9],[4,8],[5,3],[12,1],[3,4],[-2,9],[-5,3],[-2,12],[-1,2],[-5,-4],[-2,0],[-3,5],[1,6],[-3,0],[-3,-2],[-6,0],[-2,4],[0,9],[2,13],[2,7],[7,5],[7,0],[4,-2],[3,-9],[2,-1],[5,9],[1,6],[-1,7],[-3,2],[-6,2],[-10,1],[-7,3],[3,12],[2,1],[5,0],[1,2],[3,12],[6,5],[3,-2],[3,-6],[6,-1],[5,-4],[7,-1],[6,6],[5,8],[4,1],[2,5],[-2,0],[1,6],[5,4],[3,6],[10,8],[5,2],[-1,8],[-8,0],[-9,8],[-4,0],[0,6],[4,1],[1,2],[-4,4],[-2,0],[2,8],[4,2],[4,3],[1,4],[2,-1],[5,11],[-1,3],[-3,-1],[0,6],[2,2],[4,-4],[-3,5],[0,7],[-1,9],[4,0],[1,10],[-2,4],[-6,-2],[-2,5],[2,3],[-2,8],[-2,-5],[-2,7],[1,3],[-1,10],[0,9],[-8,20],[-3,4],[-4,8],[-2,-2],[-1,8],[-5,6],[0,4],[7,6],[3,-1],[3,4],[-1,7],[-9,6],[-3,7],[-2,-2],[-3,4],[3,7],[-1,4],[4,2],[3,-4],[0,5],[-3,1],[0,7],[-2,1],[-1,-7],[-3,5],[1,2],[-2,3],[1,3],[-1,3],[-3,0],[-2,14],[2,10],[4,2],[-1,4],[-1,1],[-3,10],[1,3],[-3,2],[-2,4],[-1,6],[-2,3],[2,7],[2,2],[4,6],[4,3],[1,5],[4,8],[0,9],[2,1],[-1,8],[3,5],[8,3],[5,-1],[4,0],[6,2],[5,-2],[7,6],[1,4],[6,1],[1,9],[3,7]],[[242,9213],[5,-10],[-1,-8],[3,-9],[0,-7],[1,-4],[5,-10],[2,-6],[-2,-9],[-3,-6],[-25,-22],[-7,-2],[-7,-6],[-5,1],[-2,6],[0,9],[5,9],[3,6],[0,31],[-7,18],[4,6],[-1,1],[3,8],[-5,2],[1,2],[4,-2],[8,7],[9,1],[5,0],[7,-6]],[[191,9241],[-1,-3],[-4,-2],[-8,-1],[-4,-1],[-3,-5],[-1,-4],[-6,-12],[-3,-1],[-3,-6],[0,-15],[-1,-11],[4,-5],[6,-11],[0,-15],[3,-6],[5,-3],[7,-7],[6,-2],[3,-4],[7,-3],[0,-5],[-5,3],[-5,-1],[-6,6],[-3,-1],[-5,-5],[-4,0],[-5,-3],[-9,3],[-5,0],[-1,4],[-7,0],[-6,4],[-6,3],[-8,0],[0,7],[-2,-4],[0,-4],[-7,-2],[-3,-2],[-10,0],[-2,-1],[-7,-8],[-6,-2],[-1,-9],[-9,-2],[-4,2],[3,5],[-2,4],[-4,-3],[-8,5],[3,8],[-2,6],[2,7],[4,0],[3,8],[-4,3],[-2,0],[-2,3],[-1,10],[1,5],[4,6],[7,4],[4,-1],[5,-9],[5,2],[-1,4],[3,5],[0,7],[2,12],[-4,7],[-5,3],[-4,5],[-4,9],[0,9],[7,18],[3,0],[4,-9],[6,13],[4,5],[3,5],[6,4],[3,0],[5,3],[4,12],[3,2],[4,6],[6,5],[4,19],[0,4],[-3,8],[-9,5],[0,8],[6,2],[11,1],[3,2],[13,2],[10,-2],[6,-6],[3,-5],[2,-8],[4,-6],[3,-9],[1,-6],[2,-5],[0,-17],[2,-3],[-3,-4],[0,-14],[2,-6],[6,-5],[-1,-3],[-4,-6]],[[342,8087],[-2,6],[-2,3],[-7,15],[-8,9],[-3,11],[2,7],[4,5],[6,5],[4,6],[0,8],[2,9],[10,14],[0,5],[4,5],[1,4],[-6,6],[0,22],[-2,16],[0,12],[3,2],[1,5],[-5,10],[-11,-4],[-6,3],[-4,11],[-4,15],[-3,23],[-3,10],[3,15],[-3,9],[-1,12],[-5,13],[-3,3],[-2,-2],[-4,3],[-1,4],[-10,17],[-5,5],[-3,8],[-6,6],[-2,11],[-6,7],[-3,8],[-6,7],[-5,2],[-3,-3],[-4,1],[-2,8],[1,5],[-1,12],[-2,10],[0,5],[-2,5],[-5,10],[-7,7],[-4,8],[-17,21],[-6,2],[-11,8],[-6,2],[-5,4],[-10,6],[-7,4],[-6,6],[-4,8],[-5,5],[-10,5],[-8,5],[-14,3],[-13,1],[-13,3],[-6,-2],[-4,2],[-4,-7],[-10,-7],[-8,1],[-4,5],[-2,10],[0,5],[-5,5],[-5,4],[-6,13],[-5,14],[-3,8],[-10,2],[-1,6],[6,1],[3,4],[-1,11],[-5,8],[-5,5],[-2,7],[-2,8],[2,12],[0,9],[-5,4],[-5,2],[3,7],[0,5],[2,8],[10,7],[3,5],[0,5],[-7,5],[3,3],[5,-1],[6,2],[-2,5],[1,2],[5,6],[2,-1],[1,4],[-5,5],[2,4],[5,-1],[5,2],[5,12],[9,3],[3,9],[6,5],[1,5],[6,5],[7,8],[5,12],[2,4],[1,9],[5,0],[-4,8],[8,6],[3,0],[2,4],[5,2],[7,6],[3,7],[2,-7],[-2,-14],[1,-6],[2,-5],[5,4],[-1,8],[3,16],[0,8],[9,0],[2,-4],[-3,-4],[-4,0],[0,-2],[4,-4],[-1,-7],[-3,-4],[1,-3],[3,5],[5,0],[0,3],[3,3],[4,0],[0,-4],[-3,-1],[0,-4],[7,-17],[0,-2],[5,-2],[1,4],[-4,7],[-2,7],[1,7],[7,-10],[2,2],[0,10],[1,3],[0,10],[-2,8],[1,4],[-5,17],[-6,4],[-1,3],[5,12],[1,5],[-2,13],[0,9],[4,5],[2,7],[1,9],[-2,6],[3,8],[4,3],[5,0],[1,2],[2,-4],[5,3],[1,6],[8,9],[6,2],[2,5],[5,-3],[3,-1],[5,1],[7,-4],[2,3],[2,-4],[6,3],[2,5],[5,8],[-2,20],[1,0],[3,-26],[2,-7],[1,4],[4,1],[7,17],[1,2],[3,2],[4,-3],[0,-6],[-4,2],[-4,-4],[0,-10],[-4,-11],[-9,-5],[-7,-2],[-18,-9],[-7,-13],[-5,-8],[2,-7],[2,-13],[0,-4],[2,-11],[-1,-12],[-1,-4],[3,-6],[-1,-3],[5,0],[3,3],[6,1],[3,-4],[0,-3],[9,-7],[1,-7],[-2,-2],[2,-3],[1,-4],[4,-6],[-2,-15],[0,-2],[7,8],[2,1],[5,-3],[1,6],[3,4],[6,-3],[4,5],[2,1],[2,-5],[5,2],[4,-4],[1,-3],[2,8],[0,9],[2,5],[4,4],[0,6],[4,7],[-4,14],[-3,13],[-6,6],[-3,0],[-7,5],[-2,5],[-2,6],[2,14],[4,3],[1,5],[10,7],[2,4],[6,1],[6,-1],[9,-5],[8,-1],[6,-5],[2,-3],[4,-2],[4,1],[8,-3],[7,-6],[3,-5],[-1,-9],[-2,-4],[-3,-2],[-2,-4],[3,-5],[6,0],[1,-4],[4,1],[5,-2],[1,-13],[4,4],[9,-6],[1,3],[6,-8],[6,9],[-1,5],[3,1],[1,10],[-3,5],[-1,5],[-1,12],[-1,7],[1,7],[3,2],[2,8],[-1,5],[7,4],[6,-1],[3,3],[9,-4],[5,1],[10,-1],[3,-3],[16,-4],[8,-5],[3,3],[0,6],[-3,5],[-4,0],[-6,3],[-7,9],[-2,0],[-4,6],[4,10],[3,10],[-2,3],[1,8],[-9,4],[-6,8],[0,5],[4,6],[5,13],[4,4],[-1,12],[1,7],[3,2],[9,0],[3,1],[2,-2],[6,2],[4,-5],[4,-1],[1,2],[7,2],[6,4],[6,-1],[2,4],[5,3],[1,3],[7,4],[8,2],[6,7],[2,0],[5,3],[4,-2],[1,-5],[-3,-4],[3,-17],[4,1],[1,-5],[-4,-1],[0,-9],[5,1],[3,-6],[3,-20],[-2,-11],[4,-19],[0,-7],[2,-3],[2,-8],[-3,-8],[6,-8],[3,-13],[0,-7],[4,-6],[3,-11],[4,-9],[0,-9],[2,3],[6,-10],[-4,-14],[2,-3],[3,1],[3,-5],[7,5],[2,-6],[2,-2],[-3,10],[0,3],[4,-3],[3,2],[10,-1],[-1,14],[-2,4],[2,3],[9,7],[6,4],[3,-1],[2,-3],[2,-6],[8,3],[-2,7],[0,8],[12,10],[4,-2],[1,6],[13,11],[6,10],[2,8],[4,3],[8,3],[5,6],[8,5],[4,1],[4,3],[5,5],[6,9],[3,2],[7,12],[2,8],[10,12],[5,2],[12,-3],[-1,4],[1,7],[3,5],[2,-2],[4,1],[0,5],[7,9],[2,9],[7,11],[2,6],[0,7],[2,3],[-2,8],[3,6],[2,12],[2,3],[4,1],[2,4],[-1,4],[-4,6],[-2,6],[6,10],[9,8],[4,5],[7,3],[3,10],[3,6],[6,8],[9,17],[8,13],[6,14],[5,4],[5,4],[4,9],[6,11],[4,9],[5,7],[8,22],[4,6],[5,10],[4,4],[1,3],[6,7],[3,12],[9,8],[6,-1],[0,-4],[4,3],[2,4],[6,2],[7,8],[9,3],[10,0],[2,5],[4,-1],[3,-20],[5,-3],[9,-11],[13,-9],[-1,-1],[7,-3],[18,-1],[5,2],[2,4],[-3,4],[6,1],[0,6],[1,10],[6,1],[5,-3],[3,2],[8,-3],[10,-1],[10,-5],[4,-2],[7,-5],[2,-4],[4,-11],[0,-10],[5,-9],[2,-5],[8,-8],[2,-4],[7,-6],[8,-16],[3,-12],[0,-6],[7,-13],[-1,-6],[-3,-5],[0,-8],[3,-5],[2,-10],[-5,-16],[0,-9],[2,-11],[-3,-5],[2,-5],[-4,-8],[2,-10],[5,-12],[0,-7],[3,-4],[0,-11],[-3,-1],[-1,-7],[0,-5],[-3,-10],[-1,0],[-2,-6],[0,-10],[-3,-9],[-6,-1],[-7,-12],[-5,-13],[-8,-14],[-4,-9],[-13,-22],[-4,-11],[-7,-15],[-7,-11],[-6,-17],[-1,-9],[1,-6],[-5,-18],[1,-9],[0,-8],[2,-7],[-2,-11],[2,-4],[57,31],[1,0],[-60,-34],[5,-13],[2,-1],[6,1],[1,-4],[7,-8],[5,-3],[1,1],[2,-7],[3,-4],[3,1],[-6,-14],[-3,-2],[-2,-8],[-1,-6],[-3,-14],[-5,-10],[-5,-15],[-1,-8],[-1,-2],[-5,-19],[-4,-16],[-2,-6],[0,-8],[-1,-10],[3,-12],[0,-18],[-2,-4],[1,-3],[-1,-11],[-4,-8],[-1,-16],[-8,-13],[-4,-13],[-3,-3],[-4,-5],[-1,-8],[-3,-4],[-4,-1],[0,-3],[-2,-11],[-2,-5],[-5,-7],[-5,-17],[-6,-16],[-4,-3],[0,-6],[-2,-4],[-1,-9],[-2,-7],[-1,-9],[-3,-22],[-1,-21],[-2,-7],[-1,-19],[-4,-9],[0,-6],[-3,-14],[1,-15],[4,-7],[10,-14],[3,-6],[0,-6],[3,-12],[-2,-10],[0,-7],[-11,-22],[-27,-16],[-5,-10],[-10,-42],[0,-5],[-4,-3],[-2,-8],[0,-5],[-1,-7],[-4,-3],[0,-5],[-3,-7],[-9,-7],[0,-3],[-5,-13],[-1,-6],[-3,-9],[-5,-7],[-11,-10],[-11,-12],[-4,-6],[-5,-13],[-2,-2],[-11,-7],[-4,-1],[0,-5],[-2,-13],[-5,-12],[-3,-14],[0,-10],[-3,-17],[0,-8],[2,-10],[3,-14],[4,-5],[5,6],[2,1],[2,-4],[0,-5],[-2,-4],[1,-3],[-2,-6],[2,-6],[0,-19],[-3,-14],[1,-7],[0,-8],[-3,-5],[0,-9],[-2,-13],[-5,-5],[0,-14],[-1,-5],[-3,-4],[-6,-13],[-10,-14],[-4,-7],[-3,-7],[-6,-8],[-8,-6],[-3,-6],[-4,-6],[-3,-1],[-3,-6],[-2,-8],[-10,-23],[-4,-21],[-1,-7],[-4,-8],[0,-13],[10,-14],[9,-8],[14,-4],[10,1],[5,-5],[2,-11],[-2,-1],[0,-9],[-1,-6],[-1,-12],[-4,-9],[-5,-8],[-2,-9],[-5,-4],[-2,-7],[-1,-15],[-2,-13],[-8,-13],[-7,-7],[-14,-12],[-13,-6],[-7,-7],[-18,-8],[-12,-6],[-10,-8],[-3,-5],[-6,-5],[-8,-3],[-8,-11],[-2,-4],[-5,-1],[-2,-4],[-5,-3],[-7,-9],[-5,-12],[-3,-10],[-3,-5],[-2,-8],[0,-10],[1,-3],[-2,-9],[-6,-10],[-7,-7],[-7,-3],[-2,-4],[-6,0],[-2,6],[-3,2],[-5,-3],[-4,4],[-9,-7],[1,-3],[1,-15],[0,-16],[-3,-6],[-7,-6],[-12,-2],[-2,2],[1,6],[-2,7],[-6,3],[-1,4],[-4,2],[-11,3],[-11,0],[-22,-4],[-5,-4],[-6,-1],[-9,-5],[-10,-9],[-10,-12],[-3,-6],[-20,-27],[-21,-23],[-4,-7],[-5,-4],[-3,-5],[-8,-2],[0,-4],[-3,0],[-1,4],[-13,20],[-3,4],[1,1],[15,-23],[4,2],[11,14],[3,2],[10,13],[3,1],[6,8],[5,4],[5,9],[7,8],[-7,36],[-8,40],[-5,32],[-12,23],[-6,12],[-4,9],[-3,17],[0,17],[3,16],[-3,16],[-4,11],[-10,15],[-17,16],[-24,24],[-12,9],[-30,11],[-10,1],[-6,5],[-5,7],[2,5],[0,14],[1,12],[-5,24],[-1,29],[-2,15],[-5,13],[-8,15],[0,5],[-4,-5],[-5,5],[8,10],[6,3],[3,6],[4,14],[9,23],[2,7],[0,20],[-3,10],[-1,8],[6,13],[1,9],[15,13],[10,4],[8,5],[1,9],[5,15],[12,15],[3,11],[-2,7],[0,10],[-1,6],[3,18],[-2,3],[-6,6],[4,10],[-2,7],[2,12],[-2,7]],[[344,9306],[-4,-12],[-2,-7],[-3,-5],[-4,-2],[-1,-3],[-6,-7],[3,-2],[1,-8],[0,-4],[-3,-9],[-1,-6],[-4,0],[-2,-4],[-4,0],[-1,3],[-5,2],[0,9],[3,10],[-4,10],[-11,19],[4,6],[6,-9],[3,0],[3,5],[3,0],[7,8],[2,4],[3,2],[9,0],[9,6],[-1,-6]],[[360,9359],[-4,5],[0,-4],[-4,-7],[-6,2],[-5,12],[1,-7],[0,-3],[-10,-8],[-2,-5],[-4,0],[-4,4],[-1,5],[0,7],[-1,8],[1,3],[6,-3],[6,4],[1,2],[4,1],[8,-2],[4,-2],[3,3],[5,1],[0,2],[6,5],[3,-2],[3,2],[11,1],[20,1],[9,0],[1,17],[1,3],[11,4],[0,-9],[-8,1],[-2,-17],[4,-2],[7,4],[-2,4],[4,4],[2,-3],[5,8],[3,1],[1,8],[3,5],[2,-1],[7,5],[1,3],[6,1],[0,6],[7,0],[1,-1],[6,-1],[0,-2],[6,0],[7,-7],[6,8],[5,8],[2,-1],[-1,6],[3,2],[1,5],[3,8],[-1,3],[2,11],[-6,24],[7,6],[12,-1],[0,18],[1,3],[0,-22],[7,-10],[2,-4],[2,0],[3,-9],[-1,-2],[2,-14],[0,-4],[2,-6],[-1,-2],[4,-10],[-3,-2],[5,-22],[-1,-4],[3,-4],[-1,-5],[3,0],[1,-5],[-4,0],[0,-11],[1,-1],[-2,-5],[-2,-10],[0,-8],[-4,-2],[0,-8],[-5,-2],[3,-5],[0,-3],[14,-15],[6,-4],[4,3],[4,-2],[7,4],[6,-1],[1,-2],[6,-3],[2,7],[2,3],[6,1],[3,-2],[3,-7],[2,-6],[-2,-20],[-2,-9],[-6,-11],[-1,-8],[3,-9],[-2,-5],[1,-7],[3,-5],[1,-6],[5,-16],[0,-10],[-1,-5],[-6,-16],[-4,-3],[-4,0],[-13,1],[-6,3],[-4,4],[2,5],[-7,7],[-4,0],[-10,7],[-13,6],[-7,0],[-6,4],[-2,-3],[-4,0],[-3,8],[-3,3],[3,19],[-6,-14],[-5,-1],[-3,3],[-1,8],[-3,-2],[-3,1],[-1,-3],[-3,2],[-1,5],[-5,5],[-2,-1],[3,-18],[2,-3],[-5,-3],[-1,-10],[-3,-3],[-2,6],[-4,10],[1,2],[-6,0],[-1,2],[3,6],[-3,3],[0,4],[-4,1],[-5,-2],[-3,-9],[-7,-2],[-3,-3],[-5,2],[-13,-9],[-7,-6],[-25,18],[8,14],[2,19],[11,1],[-1,20],[8,3],[-2,2],[-9,-4],[-2,5],[7,6],[-7,12],[-16,-10],[-5,-4],[-1,2],[5,3],[-1,3],[-4,-4],[-4,4],[5,4],[-1,3],[13,11],[-1,1],[-13,-11],[0,1],[13,11],[-4,10],[-7,8],[-4,-2],[-1,3],[-2,-3],[-3,3],[-3,6],[-3,0],[-4,4],[2,4]],[[5138,2401],[10,3],[8,1],[7,-3],[2,9],[3,7],[9,13],[7,11],[12,13],[3,5],[7,20],[8,31],[2,5],[11,32],[0,7],[6,8],[13,12],[9,9],[5,2],[9,9],[1,4],[3,22],[3,24],[6,-1],[5,3],[4,-5],[10,18],[1,-5],[8,2],[3,-20],[4,-12],[2,1],[6,-3],[10,0],[7,-2],[1,-2],[-7,-16],[0,-6],[18,-9],[6,-1],[11,13],[-1,5],[3,6],[4,10],[19,-11],[15,-9],[-7,-17],[-3,0],[-5,-12],[-2,-6],[-2,-11],[0,-15],[4,-6],[7,-2],[4,5],[3,7],[3,11],[4,4],[7,13],[5,-11],[5,6],[1,-4],[5,-9],[6,-5],[2,1],[9,-7],[6,-6],[2,-5],[0,-7],[5,-3],[2,-3],[2,-9],[3,-6],[3,-3],[3,-6],[0,-5],[3,-6],[0,-4],[4,-7],[2,-6],[2,-2],[22,0],[7,4],[7,8],[2,6],[2,15],[6,10],[-11,3],[-7,4],[-4,6],[-3,7],[-1,7],[2,7],[-4,19],[-3,8],[-22,23],[-6,7],[-6,13],[31,33],[-3,7],[-3,9],[0,9],[-3,15],[-1,4],[-4,21],[0,6],[-2,7],[-14,4],[-3,5],[-6,4],[-4,4],[-7,14],[-7,10],[-5,0],[-9,0],[-8,3],[-8,8],[-9,11],[-4,4],[-10,4],[-3,2],[-10,10],[-10,9],[-14,4],[-21,-1],[-4,-2],[-5,0],[-3,-4],[-3,-2],[0,-3],[-11,-6],[-2,5],[-1,6],[-8,5],[-8,9],[-4,11],[-4,6],[-1,10],[1,4],[0,8],[-2,14],[-2,9],[0,8],[3,9],[3,2],[7,2]],[[5294,2909],[6,-1],[-1,2],[16,14],[6,3],[13,1],[7,1],[7,7],[4,9],[3,14],[1,20],[3,20],[2,8],[1,4],[-4,4],[6,8],[7,5],[3,5],[2,10],[7,9],[4,3],[11,3],[4,3],[9,1],[-1,13],[3,8],[6,5],[8,6],[15,1],[9,5],[6,4],[4,-3],[2,-1],[5,6],[8,1],[15,-13],[3,-7],[3,-16],[-1,-13],[2,-9],[6,-1],[1,4],[4,0],[3,-2],[12,1],[4,-3],[4,0],[4,2],[5,5],[20,11],[6,8],[9,14],[5,13],[-1,1],[2,7],[5,11],[1,16],[-1,12],[-1,12],[8,21],[7,1],[2,8],[1,7],[3,11],[1,8],[5,19],[7,22],[-3,11],[9,28],[3,5],[6,4],[-7,3],[-7,6],[-3,4],[-6,12],[-14,24],[-7,18],[-1,6],[18,16],[5,3],[12,6],[6,2],[7,1],[18,0],[27,-3],[24,-3],[15,-2],[14,-4],[11,-12],[3,0],[25,-39],[6,5],[12,13],[10,8],[3,8],[4,4],[9,12],[7,10],[8,13],[9,19],[6,15],[7,7],[0,10],[1,2],[26,-13],[31,-17],[11,-12],[8,-13],[4,-12],[2,-11],[0,-6],[-3,-13],[-8,-21],[-4,-16],[-1,-12],[0,-7],[4,-16],[3,-10],[10,-13],[7,-6],[7,-4],[5,-1]],[[5959,3266],[4,-26],[1,-19],[-2,-8],[-4,-6],[-14,-9],[-4,-6],[-16,-23],[-2,-6],[2,-13],[3,-12],[0,-4],[-5,-13],[-7,-11],[-5,-5],[-7,-4],[-14,-14],[-14,5],[-5,0],[-16,-6],[-7,2],[-1,7],[-3,-1],[-4,-5],[-4,-3],[-10,-6],[-13,0],[-6,-7],[-2,-4],[-1,-11],[-7,-13],[-2,-9],[-5,-11],[-3,-8],[-7,-13],[-2,-1],[-7,-12],[-1,-4],[-2,-24],[1,-18],[2,-4],[3,-11],[-1,-5],[2,-5],[-1,-15],[-12,6],[-5,-9],[7,-10],[8,-19],[4,-5],[11,-22],[13,-17],[4,-7],[2,-13],[2,-14],[4,-5],[1,-4],[-13,-10],[3,-7],[7,-26],[-7,-6],[3,-10],[2,-8],[1,1],[2,-15],[-11,1],[-14,-2],[-11,-4],[-9,-7],[-5,-3],[-5,-5],[-2,-7],[-6,-14],[1,-8],[5,-10],[3,-4],[5,-4],[3,-5],[4,-9],[5,-5],[5,0],[5,-5],[14,-9],[-2,-11],[-3,-20],[3,-13],[3,-19],[-4,-3],[-2,-8],[-6,-6],[-12,-10],[-2,-6],[1,-7],[-1,-9],[-5,-8],[1,-12],[1,-7],[0,-15],[-2,-17],[-2,0],[2,-11],[-1,-8],[-2,-12],[0,-8],[-4,-10],[-5,-7],[1,-2],[0,-7],[1,-2],[9,-9],[8,-3],[5,0],[4,-1],[4,1],[10,-1],[-1,-12],[3,-9],[7,4],[0,-1],[8,-4],[2,2],[6,-1]],[[5822,2333],[0,-4],[3,-10],[-3,-1],[2,-13],[-4,-1],[-4,-8],[-1,-6],[-1,-10],[-4,0],[2,-9],[3,-5],[-2,-5],[6,-7],[6,-4],[18,21],[13,13],[1,4],[0,13],[-1,4],[0,7],[12,-2],[16,-10],[4,-5],[-1,-6],[-3,-22],[-1,-10],[1,-18],[-3,-12],[-8,-8],[-23,-19],[-4,-18],[-7,-21],[-21,-30],[-1,-1],[-10,13],[-6,-5],[0,-5],[15,-16],[12,-9],[11,-7],[7,-2],[-8,-26],[-5,-7],[0,-39],[-18,-29],[1,-2],[3,-10],[-1,-14],[2,-8],[3,-8],[6,-7],[5,-5],[10,-7],[15,-9],[10,-5],[21,-10],[13,-2],[8,0],[5,-3],[5,-4],[2,-10],[0,-16],[2,-17],[2,-7],[2,-14],[6,-14],[6,-10],[9,-8],[7,-5],[5,-1],[14,-8],[14,-5],[13,-7],[7,-1],[12,-8],[7,-6],[6,-8],[5,-11],[8,-14],[5,-6],[8,-19]],[[6061,1709],[-19,-14],[-96,-65],[-64,-42]],[[5882,1588],[-41,44],[-10,10],[-14,16],[-7,10],[-7,14],[-4,8],[-3,11],[-3,12],[-7,36],[-3,16],[-8,16],[-9,16],[-13,14],[-9,8],[-8,6],[-45,35],[-14,11],[-5,5],[-18,20],[-4,6],[-7,17],[-12,-3],[-2,8],[-12,-8],[-7,2],[0,-3],[-7,0],[-11,3],[-4,1],[-11,6],[-7,1],[-2,-1],[-7,2],[-22,1],[-5,24],[-26,15],[0,5],[-12,4],[-11,-17],[-17,-1],[-13,11],[4,-10],[-8,-6],[-15,-7],[-7,0],[-20,5],[-19,2],[-2,-2],[-22,-10],[-13,5],[-2,0],[-12,0],[-12,4],[-24,6],[-6,0],[-7,2],[-2,0],[-16,-10],[-3,-3],[-2,-6],[-3,-6],[-8,-17],[1,-18],[-2,0],[-1,-5],[-9,1],[-5,-4],[1,-7],[-3,-1],[-8,2],[-2,-2],[-13,-4],[-6,-4],[-5,-4],[-22,-23],[-11,-11],[-18,-14],[-7,-5],[-27,-16],[9,-23],[8,-21],[5,-17],[4,-16],[3,-19],[2,-13],[0,-22],[0,-11],[-2,-19],[-2,-14],[-5,-20],[-4,-12],[-8,-18],[-9,-16],[-5,-9]],[[5115,1551],[-2,8],[-2,20],[-4,26],[-7,23],[-6,16],[-10,22],[-15,25],[-7,10],[-9,14],[-20,32],[-5,12]],[[4242,1070],[-2,9],[-1,8],[0,7],[2,8],[4,9],[18,34],[9,12],[7,7],[6,4],[6,3],[5,-1],[18,-9],[9,-1],[7,1],[4,2],[9,8],[6,11],[2,15],[3,82],[0,6],[-1,16],[2,19],[3,17],[1,6],[15,27],[25,43],[5,7],[3,8],[5,13],[29,-19],[7,-6],[9,-11],[7,-11],[11,-17],[14,-10],[41,-8],[5,-2],[10,-4],[10,-7],[5,-5],[6,-8],[49,-71],[8,-7],[10,-4],[16,-5],[36,-9],[37,-20],[23,-12],[18,-2],[28,3],[8,2],[18,0],[16,-4],[21,-9],[11,-2],[13,0],[26,11],[34,28],[6,6],[8,11],[5,10],[3,10],[3,12],[8,36],[5,19],[4,12],[25,43],[10,15],[22,32],[11,15],[18,25],[38,56],[11,17]],[[5115,1551],[2,-13],[4,-16],[14,-32],[10,-12],[7,-9],[8,-6],[8,-4],[20,-8],[13,-5],[17,-5],[35,-9],[7,-1],[22,-9],[40,-18],[37,-16],[18,-10],[10,-5],[23,-16]],[[5410,1357],[10,-14],[3,-10],[1,-17],[0,-10],[-1,-10],[-8,-37],[-8,-30],[-3,-12],[-2,-13],[-3,-21],[-2,-32],[-2,-44],[-2,-10],[-6,-13],[-8,-9],[-3,-2],[-24,-14],[-38,-25],[-14,-6],[-19,-7],[-23,-5],[-13,-4],[-13,-7],[-5,-5],[-9,-10],[-53,-68],[-7,-10],[-4,-8],[-2,-11],[-1,-12],[0,-34],[1,-7],[0,-6],[-3,-15],[-6,-16],[-6,-8],[-14,-14],[-36,-33],[-21,-21],[-9,-15],[-18,-40],[-5,-11],[-7,-10],[-3,-4],[-12,-7],[-36,-12]],[[3883,1288],[10,11],[3,6],[0,5],[-2,7],[-7,1],[-79,77],[-1,7],[53,84],[2,-3],[-49,-80],[1,-5],[65,-67],[2,2],[7,12],[1,6],[13,21],[3,1],[5,0],[6,-6],[1,0],[19,30],[3,-1],[5,2],[11,6],[5,5],[5,6],[13,28],[5,13],[1,5],[-24,14],[3,9],[2,1],[6,-2],[23,35],[5,9],[-8,6],[-20,37],[-31,57],[-25,46],[-9,19],[-8,23],[-9,31],[-4,27],[-1,25],[1,-2],[1,-11],[3,-24],[2,-17],[8,-27],[11,-29],[8,-17],[39,-70],[35,-64],[47,60],[1,-5],[-3,-4],[6,-7],[6,7],[3,-1],[4,8],[-7,8],[-3,-3],[0,6],[5,7],[-2,6],[-28,35],[0,2],[79,98],[-1,3],[55,69],[8,17],[2,-1],[40,86],[53,117],[0,3],[-38,59],[-1,2],[33,116],[45,3],[2,2],[3,10],[7,9],[3,3],[6,-1],[4,9],[5,13],[1,11],[-1,1],[2,5],[1,-1],[6,9],[3,8],[4,8],[0,3],[-2,9],[-9,26],[-3,13],[-4,7],[1,6],[3,-1],[2,-2],[6,-20],[2,-3],[4,2],[-4,4],[9,2],[1,3],[-5,11],[3,3],[4,-14],[4,2],[0,20],[4,0],[2,-14],[2,-2],[6,16],[4,7],[8,20],[2,7],[2,21],[2,-1],[1,-12],[2,-5],[12,0],[4,2],[8,5],[2,10],[-2,7]],[[4413,2446],[13,0],[8,1],[-4,15],[-12,44],[1,3],[15,7],[23,11],[-2,4],[29,17],[11,9],[3,3],[5,13],[13,-10],[-4,-27],[-4,-4],[-14,-3],[7,-16],[7,-7],[3,-6],[-3,-10],[3,-2],[-7,-14],[-3,-9],[-7,-11],[-6,-10],[-5,-4],[-3,-1],[-9,-7],[-6,0],[-12,-11],[2,-11],[-2,-17],[-1,-7],[-6,-24],[-5,-27],[-3,-8],[-4,-6],[-3,-3],[-2,-4],[-1,-15],[3,-11],[0,-4],[-9,-42],[-2,-9],[-2,-9],[1,-4],[2,-2],[-1,-10],[14,-5],[9,0],[3,-3],[2,-5],[-6,-9],[-3,-7],[-4,-13],[-6,-12],[-1,-6],[-1,-7],[7,-18],[8,-5],[7,-3],[-2,-7],[17,-9],[15,-12],[6,-5],[19,-21],[7,-6],[13,-9],[14,-12],[7,-6],[9,-10],[10,-14],[12,-12],[7,-4],[0,-1]],[[7099,5230],[-13,-11],[-15,2],[-2,-2],[-5,-1],[-14,-13],[-12,-33],[-2,-18],[-4,-6],[-9,-4],[-4,-23],[1,-5],[-3,-7],[-2,-2],[-9,-3],[-13,-7],[-10,-6],[-7,-9],[-9,-6],[-4,-7],[-9,2],[-21,0],[-20,-3],[-8,-4],[-10,-10],[-5,-7],[-3,-8],[-3,-8],[0,-22],[-1,-11],[0,-4],[0,-19],[-2,-9],[-6,-16],[-13,-18],[-6,3],[-6,1],[-5,-1],[-51,-14]],[[6794,4921],[-18,8],[-25,13],[-19,16],[-22,21],[-22,17],[-18,8],[-16,3],[-23,2],[-22,0],[-52,-2],[-15,0],[-43,-8],[-22,-6],[-29,-5],[-4,2],[-14,1],[-3,-6],[-16,-15],[4,-7],[-2,-6],[-3,-18],[-5,-10],[-3,-5],[-5,10],[-4,3],[-6,2],[-5,9],[-3,2],[-14,-8],[-9,-6],[-6,4],[-1,-2],[-12,-22],[-10,-18],[-7,-12],[-18,-27],[-11,-14],[-14,-16],[-15,-15],[-26,-22],[-11,-9],[-28,-23],[-88,-70],[-40,-34],[-25,-25],[-11,-12],[-12,-14],[-16,-21],[-14,-21],[-20,-35],[-9,-17],[-14,-31],[-7,-15],[-16,-34],[-20,-39],[-20,-42],[-7,-14],[-14,-25],[-15,-25],[-28,-43],[-46,-70],[-19,-29],[-26,-41],[-28,-47],[-16,-32],[-12,-26],[-8,-22],[-22,-58],[-27,-75],[-19,-51],[-14,-30],[-9,-17],[-6,-10],[-15,-22],[-18,-25],[-34,-45],[-104,-139],[-7,-11],[-9,-17],[-4,-9],[-9,-22],[-3,-12],[-3,-14],[-2,-11],[-2,-19],[-1,-18],[0,-29],[0,-53],[0,-12],[-2,-15],[-3,-14],[-5,-15],[-6,-14],[-11,-17],[-19,-25],[-10,-14],[-6,-11],[-6,-13],[-4,2],[-4,-1],[-1,4],[2,5],[-4,-3],[-2,1]],[[5277,3147],[7,14],[5,4],[6,8],[2,3],[6,7],[-1,8],[-5,12],[-6,14],[-7,4],[-7,1],[-6,4],[-4,6],[-8,25],[-6,8],[-20,17],[-10,12],[-6,13],[-7,5],[-5,9],[-5,2],[-2,5],[-2,9],[-3,8],[2,4],[-1,3],[2,4],[0,10],[2,9],[7,12],[2,11],[7,19],[-1,2],[-4,3],[-4,5],[-2,5],[-1,7],[1,7],[-4,7],[2,9],[7,9],[4,3],[5,11],[0,7],[3,7],[2,7],[1,12],[1,1],[3,19],[14,-2],[11,5],[2,6],[-1,2],[6,22],[-4,10],[3,16],[4,8],[5,5],[5,9],[2,2],[-2,2],[-2,-3],[-8,-3],[-2,1],[-9,17],[-7,6],[-10,3]],[[5234,3644],[4,7],[-7,24],[1,17],[4,26],[0,19],[-1,23],[-3,13],[-9,22],[-5,8],[-3,10],[1,11],[7,23],[24,39],[6,11],[10,17],[-3,23],[6,16],[6,11],[4,2],[7,11],[5,5],[5,7],[6,23],[7,14],[0,3],[-2,14],[-6,18],[-4,19],[0,4],[4,11],[3,13],[5,18],[0,6],[0,15],[0,7],[-1,7],[1,4],[-6,0],[-8,5],[-11,1],[-1,-4],[-8,-4],[-11,-1],[-11,-1],[-10,2],[-7,-1],[-8,2],[6,21],[5,15],[4,14],[-4,20],[-3,1],[-1,7],[4,12],[16,18],[-1,9],[11,7],[9,8],[6,11],[5,8],[4,10],[9,1],[2,1],[12,-2],[3,1],[3,4],[0,4],[6,12],[2,8],[0,4],[4,16],[16,29],[4,6],[5,13],[6,9],[6,7],[9,7],[7,9],[0,5],[4,8],[5,5],[7,4],[11,-1],[5,-2],[7,2],[6,-1],[5,-3],[12,-14],[6,-5],[11,-7],[26,-13],[9,-2],[9,4],[2,5],[-1,14],[-6,8],[-2,5],[0,6],[-4,-8],[-5,-8],[-6,-4],[-5,0],[-13,2],[-4,3],[-4,7],[-1,11],[1,16],[-3,13],[0,11],[-2,6],[-4,5],[-7,4],[-3,3],[-8,1],[-10,0],[-4,3],[-2,6],[-1,14],[1,8],[6,24],[2,11],[0,6],[-4,7],[-21,21],[-5,9],[-4,10],[-12,38],[-4,10],[-6,7],[-8,3],[-18,6],[7,9],[3,18],[1,15],[3,24],[7,2],[4,7],[2,7],[2,3],[6,0],[4,3],[1,4],[-1,10],[1,6],[6,3],[3,3],[7,13],[8,27],[5,25],[4,15],[11,27],[6,27],[3,14],[11,45],[9,31],[7,22],[1,7],[-2,2],[3,19],[3,7],[5,17],[8,7],[18,11],[25,18],[19,12],[13,9],[2,-4],[6,2],[3,6],[10,15],[-3,5],[0,6],[12,0],[2,19],[-10,5],[3,11],[11,-2],[2,18],[-9,30],[8,34],[-2,2],[3,14],[-1,60],[16,6],[3,-2],[-4,-3],[-2,-5],[2,-6],[8,-1],[5,1],[4,-4],[6,3],[3,-2],[1,-9],[2,-12],[18,-14],[4,-8],[20,4],[7,22],[-2,44],[-3,18],[58,0],[0,4],[1,7],[5,9],[9,19],[5,17],[9,11],[1,3],[0,9],[6,15],[4,7],[5,15],[2,13],[1,19],[-1,15],[-4,15],[-4,10],[-15,19],[0,3],[5,7],[15,13],[4,10],[0,13],[-1,7],[-9,19],[-8,30],[-2,13],[0,13],[-3,19],[4,3],[1,1],[-2,8],[-2,2],[-8,18],[-8,20],[-6,12],[-14,26],[-16,31],[-5,11]],[[5872,6248],[73,106],[4,11],[0,7],[0,12],[3,6],[7,9],[9,10],[8,10],[6,3],[9,1],[11,8],[17,5],[17,0],[6,3],[9,2],[13,4],[7,4],[23,6],[14,3],[12,3],[8,2],[9,-5],[9,-12],[18,-32],[8,-9],[15,-19],[7,-5],[45,-42],[4,-3],[8,0],[7,-1],[6,-5],[11,-18],[4,-16],[4,-12],[6,-6],[3,-6],[4,-10],[2,-16],[2,-5],[7,-4],[15,2],[17,-3],[17,-11],[5,-7],[7,-14],[5,-4],[22,-11],[19,-4],[14,-1],[15,0],[7,-2],[15,-11],[3,-3],[17,-29],[5,-8],[3,-11],[2,-11],[-2,-39],[-2,-18],[-5,-19],[-9,-19],[-4,-11],[1,-17],[3,-15],[1,-18],[0,-15],[2,-9],[-1,-20],[-4,-15],[-4,-13],[-6,-15],[-2,-8],[-1,-8],[-1,-19],[-2,-20],[1,-10],[4,-12],[12,-10],[4,-1],[12,3],[29,18],[17,12],[26,11],[12,9],[13,5],[13,7],[15,12],[6,3],[13,4],[9,1],[4,-2],[7,-13],[5,-6],[18,-19],[9,-3],[11,-3],[3,-3],[2,-6],[0,-12],[-2,-20],[1,-8],[6,-16],[4,-8],[12,-20],[3,-8],[2,-7],[4,-6],[7,-2],[11,-1],[17,-11],[6,-2],[6,0],[11,-7],[6,-8],[12,-20],[5,-9],[-1,-18],[0,-11],[-6,-24],[-4,-16],[-2,-24],[5,-11],[3,-14],[2,-10],[3,-4],[9,-19],[13,-24],[6,-6],[14,-12],[7,-8],[9,-11],[12,-14],[4,-3],[12,-2],[9,1],[5,-1],[8,-4],[16,-9],[11,-5],[5,-4],[8,-8],[8,-10],[6,-6],[7,-4],[2,0],[10,-5],[14,-6],[10,-2],[4,-3],[9,-9],[7,-9],[7,-10],[1,-10],[2,-5],[6,-2],[6,-8],[8,-4],[8,-9],[8,-10],[4,-10],[4,-20],[2,-7],[3,-3]],[[6680,4159],[-5,15],[-4,18],[-4,31],[-6,21],[-5,8],[-10,13],[-6,5],[-9,4],[-14,2],[-27,0],[-19,2],[-30,4],[-12,1],[-16,4],[-27,11],[-11,7],[-26,15],[-8,3],[-7,-1],[-8,-2],[-5,-6],[-16,13],[0,2],[-7,1],[-2,3],[-4,8],[-6,10],[-1,3],[-2,10],[-2,6],[-2,19],[1,15],[10,32],[7,24],[4,8],[3,13],[2,15],[-1,4],[-5,17],[10,9],[3,9],[4,-4],[6,3],[-1,3],[-10,15],[-18,25],[-10,17],[-4,10],[6,8],[6,7],[11,5],[8,0],[26,-8],[8,0],[8,2],[4,3],[7,5],[-3,1],[-5,7],[-14,15],[-3,9],[0,14],[-1,8],[-4,13],[0,9],[-8,0],[6,3],[11,14],[6,8],[12,22],[2,7],[-4,4],[3,8],[0,8],[26,-8],[3,-3],[3,-9],[8,4],[9,3],[10,2],[11,1],[5,1],[5,6],[12,5],[1,5],[33,4],[8,6],[4,7],[7,8],[8,6],[12,2],[10,-3],[27,-14],[10,-3],[9,0],[4,1],[10,5],[5,5],[7,4],[6,2],[6,0],[7,-4],[11,-9],[26,-23],[8,-6],[8,-2],[9,1],[5,3]],[[6795,4773],[3,-4],[9,-37],[6,-10],[3,-9],[0,-18],[2,-5],[9,-12],[5,-13],[2,-11],[0,-12],[-6,-28],[-3,-5],[-5,-4],[-4,-6],[0,-3],[2,-6],[8,-2],[2,-3],[2,-11],[-2,-6],[-9,-11],[-4,-10],[-1,-6],[0,-6],[3,-8],[2,0],[8,5],[3,0],[4,-3],[5,2],[1,-3],[4,-1],[1,-3],[7,-5],[1,-4],[-1,-4],[1,-6],[-1,-3],[1,-11],[3,-6],[0,-3],[2,-5],[0,-5],[-5,-1],[-1,-6],[1,-7],[1,-8],[-1,-9],[-5,-24],[0,-3],[-2,-5],[11,-7],[6,-2],[6,0],[21,6],[9,3],[11,-1],[7,-3],[6,-5],[7,-9],[7,-11],[4,-12],[3,-11],[16,1],[4,-5],[10,-1],[10,1],[17,-3],[13,-6],[5,-5],[8,-4],[13,-3],[6,1],[10,3],[0,-9],[2,-7],[11,-24],[-2,-3],[3,-12],[-12,-4],[-8,2],[-2,-2],[-22,-8],[0,-7],[0,-5],[-4,-1],[-1,-10],[-6,-5],[-9,-6],[0,-4],[2,-12],[3,-21],[0,-9],[-2,-9],[-8,-10],[-7,-13],[-1,-8],[1,-5],[3,-7],[15,-13],[3,-7],[-1,-9],[-1,-4],[-4,-5],[0,-7],[1,-7],[4,-5],[0,-9],[-13,-7],[-3,-10],[-2,-5],[-4,-4],[-4,-2],[-1,-3],[0,-6],[-1,-3],[-7,-1],[-1,-3],[-12,1],[-8,-4],[-2,-3],[-4,-11],[-1,-9],[-2,-2],[-8,1],[-13,3],[-5,-3],[-2,-5],[-7,-8],[-16,-12],[-4,0],[-8,-8],[-6,9],[-9,9],[-7,4],[-10,9],[-26,17],[-15,8],[-17,16],[-8,7],[-13,16],[-18,21],[-18,17],[-6,7],[-14,11],[-13,13],[-14,20],[-12,8]],[[4814,3231],[10,-3],[11,-7],[1,0],[5,11],[10,24],[25,60],[28,67],[6,11],[8,8],[8,6],[23,15],[-5,-24],[-4,-11],[-7,-14],[-18,-28],[-1,-1],[-12,-23],[-7,-17],[-8,-23],[-15,-38],[-7,-15],[-9,-15],[-10,-16],[3,-3],[-2,-4],[6,-6],[3,2],[6,-4],[8,-6],[-1,-9],[-8,-18],[-9,-7],[-5,1],[-10,7],[-10,1],[-3,-3],[-9,-26],[-1,-5],[-6,-14],[-5,-13],[1,-10],[4,-8],[3,-4],[6,-2],[6,-5],[-3,-8],[7,-2],[7,1],[18,0],[0,-12],[1,-4],[-1,0],[-15,4],[-4,-3],[-2,-6],[-4,-4],[-6,-2],[-5,1],[-23,11],[-4,-1],[-4,3],[-2,-1],[-6,-16],[1,-3],[-3,-12],[-7,4],[-2,-2],[-6,-2],[-9,-16],[-11,-23],[-3,-6],[-1,1],[-8,-10],[-1,1],[-8,-11],[9,-9],[-3,-6],[-7,-4],[-13,-14],[-9,-18],[-8,-18],[-5,-14],[0,-11],[-5,-10],[12,-9],[11,-7],[7,-3],[8,-8]],[[4716,2813],[-1,-6],[-12,6],[-10,6],[-5,2],[-6,0],[-20,-13],[-21,-4],[-2,-2],[-26,-5],[9,7],[7,13],[4,12],[6,27],[5,13],[14,37],[9,18],[-2,2],[-8,4],[-10,11],[-9,8],[-11,10],[-62,-124],[-19,-34],[0,-4],[-35,19],[-8,-32],[-1,-13],[-12,-5],[11,-21],[-18,-11],[-10,12],[-4,-2],[-8,11],[-14,3],[1,7],[-30,49],[-6,10],[7,8],[-8,15],[-7,15],[-8,14],[-9,-9],[-22,36],[7,7],[4,-6],[9,3],[19,10],[3,-7],[12,9],[-8,21],[6,5],[2,-8],[6,-10],[11,-15],[10,13],[16,-17],[12,16],[19,23],[17,14],[10,9],[-15,18],[-12,-11],[-17,-18],[-8,11],[-4,15],[-7,20],[-12,22],[-9,18],[-13,25],[-7,9],[-15,21],[-9,11],[-19,27],[-19,26],[-10,13],[2,14],[8,37],[3,15],[8,44],[10,51],[4,23],[2,12],[-22,7],[-26,8],[-3,-5],[-5,-3],[-10,-3],[-6,-1],[-14,3],[0,-46],[0,-17],[2,-55],[0,-49],[0,-32],[2,-35],[1,-68],[3,-87],[3,-20],[-3,-16],[-5,15],[-3,4],[-4,2],[-6,1],[-7,-2],[1,-2],[-5,-5],[-4,-11],[0,-8],[-9,7],[-14,14],[-1,0],[-31,-30]],[[4215,2939],[-13,17],[-10,17],[-8,9],[-7,6],[-18,22],[-12,15],[-14,12],[-26,24],[-13,9],[-14,9],[-13,10],[-9,10],[-11,7],[-40,29],[-15,12],[-3,3],[-9,5],[-12,5],[-13,0],[-10,8],[-4,9],[-2,17],[-16,13],[-6,-1],[-3,-3],[-3,0],[-4,4],[-3,2],[-3,9],[1,10],[2,2],[0,6],[-4,4],[4,6],[4,-2],[3,-13],[5,-1],[3,0],[2,8],[-6,12],[1,6],[0,-5],[7,-13],[4,-2],[0,-2],[21,-9],[9,13],[1,5],[5,4],[15,23],[1,0],[12,-12],[6,-4],[1,-4],[4,0],[4,-4],[14,1],[5,1],[8,9],[8,12],[5,9],[4,11],[6,35],[-1,10],[4,25],[0,13],[3,14],[3,14],[2,12],[5,3],[2,10],[-1,6],[-2,4],[1,3],[11,0],[5,1],[5,4],[6,17],[4,6],[11,12],[9,7],[6,0],[12,-4],[7,2],[8,0],[10,-5],[17,-1],[24,-3],[7,-3],[5,0],[9,2],[31,11],[4,-4],[7,-3],[7,3],[4,0],[22,-3],[7,-3],[6,1],[6,2],[7,5],[18,15],[6,6],[11,13],[13,17],[26,40],[7,10],[3,9],[3,5],[8,7],[5,5],[13,21],[17,27],[8,19],[9,16],[3,-1],[8,16],[8,23],[4,13],[8,24],[4,13],[1,7],[3,16],[2,14],[5,22],[0,20],[0,15],[0,23],[1,12],[1,3],[4,5],[5,2],[4,0],[6,-2],[7,5],[21,7],[2,0],[6,-6],[10,6],[5,1],[5,-2],[7,0],[6,-13],[-3,-3],[0,-7],[-4,-9],[1,-16],[2,-18],[5,-3],[1,-3],[0,-10],[-3,-2],[-1,-5],[-2,-2],[9,-11],[2,3],[3,-2],[-1,-3],[5,-4],[8,-2],[11,1],[4,-3],[26,-1],[6,-3],[6,-7],[4,0],[3,-9],[-1,-8],[-2,0],[0,-4],[-4,-11],[3,2],[2,-4],[-6,-12],[-2,1],[-1,-5],[-8,-14],[-5,-6],[-6,-13],[-4,-1],[-4,-5],[-8,-15],[-6,-9],[-6,-19],[-7,-8],[6,-5],[-3,-7],[-1,-5],[7,-3],[0,-17],[3,-13],[0,-9],[0,-18],[-2,-10],[-2,-8],[-1,-11],[-3,-7],[-5,-5],[-5,-3],[-8,0],[-4,-4],[-7,-2],[-2,2],[-6,-2],[-1,-1],[-7,-3],[-5,7],[-8,-5],[-4,6],[-4,-2],[2,-5],[-2,-6],[4,-11],[-3,-2],[0,4],[-1,6],[-2,0],[-6,-6],[1,-12],[5,-5],[2,-5],[16,-24],[5,-9],[0,-7],[-9,-29],[-7,-4],[-9,-4],[-5,-4],[-2,-14],[2,-8],[-1,-10],[2,-18],[17,19],[14,10],[11,14],[8,12],[9,9],[8,0],[3,1],[10,1],[4,-4],[5,-12],[2,-7],[4,-8],[2,-10],[3,-12],[1,-7],[5,-17],[9,-14],[5,-16],[2,-11],[-3,-8],[1,-6],[1,-7],[2,-4],[6,-6],[11,-1],[22,6],[11,5],[2,2],[3,-2],[4,-5],[12,-9],[1,-6],[0,-9],[3,-3],[5,0],[2,-5],[4,0],[4,10],[5,5],[7,-3],[3,-6],[2,-14],[0,-11],[-3,-7],[-7,-8],[-5,-2],[-5,0],[-9,5],[-2,0],[-1,-4],[2,-16],[0,-8],[1,-12],[0,-6],[3,-13],[-5,-14],[-3,-11],[-5,-3],[-3,-6],[-2,-8],[-4,-6],[2,-3],[2,6],[9,11],[6,6],[8,6],[7,8],[4,11],[8,11],[1,5],[2,2],[1,4],[8,10],[3,10],[3,12],[5,5],[0,8],[4,0]],[[5979,650],[8,25],[1,13],[-1,6],[-3,5],[-4,3],[-9,-1],[-11,-6],[-3,-2],[-8,-10],[-7,-10],[-7,-5],[-13,-4],[-4,-1],[-5,1],[-6,5],[-7,9],[-16,30],[-5,10],[-9,20],[-5,13],[-9,29],[-7,16],[-1,8],[0,9],[1,4],[7,16],[10,19],[14,28],[11,24],[1,6],[0,12],[-2,17],[-2,8],[-3,15],[-1,13],[2,13],[7,27],[3,11],[5,14],[1,14],[0,16],[-1,18],[-1,5],[-8,18],[-5,8],[-4,5],[-9,9],[-4,3],[-9,9],[-7,12],[-4,22],[-1,12],[-2,10],[-4,9],[-7,11],[7,6],[4,7],[4,11],[5,14],[10,33],[4,14],[4,21],[0,14],[-3,14],[-4,7],[-5,7],[-16,15],[-10,7],[-21,17],[-33,26],[-14,11],[-20,16],[-6,8],[-6,17]],[[5736,1486],[25,18],[27,20],[38,25],[19,13],[37,26]],[[6061,1709],[63,45],[8,6],[47,37],[12,16],[35,48],[61,91],[56,84],[54,78],[14,22],[110,192],[13,21],[32,55],[37,64],[46,81],[48,82],[15,26],[23,39],[17,32],[5,9],[13,21],[10,14],[15,16],[15,13],[19,14],[21,10],[33,10],[58,12],[9,2],[23,4],[86,17],[25,5]],[[7084,2875],[138,27],[81,17],[6,1],[26,7],[18,9],[27,15],[23,16],[22,17],[51,55],[87,90],[111,118],[31,27],[14,12],[20,13],[27,13],[29,12],[21,6],[31,8],[15,4],[57,13],[76,18],[91,22],[79,18],[12,3]],[[8177,3416],[1,-20],[3,-41],[1,-14],[1,-7],[4,-7],[3,-2],[11,-7],[7,-8],[5,-18],[-2,-13],[-10,-28],[-4,-2],[-40,-39],[-5,-5],[-7,-15],[-1,-12],[2,-16],[0,-12],[-3,-11],[-4,-7],[-5,-6],[-21,-13],[-4,-4],[-1,-4],[0,-5],[1,-7],[6,-13],[7,-10],[0,-5],[-1,-11],[-3,-4],[-14,-15],[-11,-9],[-7,-2],[-18,1],[-24,21],[-6,-5],[-10,-19],[-9,-23],[-1,-6],[4,-14],[4,-5],[-2,-11],[-5,-23],[-13,-15],[-1,-6],[-5,-12],[-7,-14],[-6,-8],[-4,-8],[1,-9],[-5,-2],[-5,-9],[-7,-26],[-5,-19],[4,-10],[5,-3],[-1,-5],[4,-3],[-10,-35],[-9,-41],[-4,-15],[-4,-9],[-1,-7],[-6,-17],[-5,-15],[1,-3],[-5,-17],[0,-5],[6,-8],[8,-36],[0,-5],[19,3],[8,-4],[12,-9],[25,-20],[-8,-15],[3,-1],[10,-10],[6,-6],[3,-5],[10,-11],[8,-7],[8,-5],[19,7],[4,1],[7,-29],[-82,-30],[4,-18],[9,-39],[6,-15]],[[8016,2365],[-16,-9],[-15,-6],[-22,-12],[-12,-8],[-10,-11],[-4,-7],[-9,-7],[-7,-14],[-4,-10],[0,-9],[-2,-24],[-10,-7],[-9,-1],[-2,-7],[2,-9],[0,-11],[-7,-4],[-5,5],[-5,6],[-15,12],[-15,5],[-19,10],[-21,4],[-15,-3],[-39,-11],[-14,-6],[-10,1],[-17,-2],[-13,-7],[-15,-4],[-35,-19],[-33,-25],[-15,-10],[-10,-7],[-20,-12],[-33,-30],[-12,-14],[-15,-13],[-25,-18],[-37,-31],[-80,-54],[-30,-19],[-37,-35],[-18,-18],[-16,-16],[-17,-17],[-19,-19],[-13,-14],[-11,-16],[-8,-10],[-42,-29],[-13,-13],[-25,-21],[-15,-17],[-20,-14],[-34,-32],[-29,-21],[-31,-29],[-44,-37],[-42,-46],[-41,-31],[-22,-19],[-27,-18],[-9,-8],[-2,-2],[-42,-33],[-14,-12],[-8,-10],[-4,-1],[-2,-7],[-6,-7],[-3,2],[-5,0],[-2,-2],[-6,-1],[-4,-2],[-9,-2],[-2,2],[-9,3],[2,3],[-2,3],[-8,0],[-10,-4],[-17,-13],[-10,-10],[-16,-13],[-39,-37],[-16,-12],[-34,-31],[-40,-41],[-39,-46],[-43,-49],[-40,-41],[-31,-46],[-12,-18],[-13,-19],[-29,-34],[-15,-19],[-13,-18],[-18,-27],[-10,-17],[-23,-35],[-31,-52],[-16,-36],[-2,-4],[-18,-30],[2,-3],[0,-3],[-4,-7],[1,-8],[-1,-1],[1,-9],[-7,-2],[-6,4],[-3,-2],[5,-10],[-1,-6],[4,-6],[-5,-13],[0,-5],[5,-5],[-4,-5],[-5,0],[-7,-5],[-6,2],[-1,5],[-5,2],[-5,-1],[-4,-3],[-6,-1],[-7,-3],[-10,-7],[-5,2],[-7,-4],[-2,-5],[-4,-3],[-9,5],[-4,3],[-2,7],[-9,1],[-6,-2],[-7,-5],[-6,-6],[-7,-6],[-4,-6],[-6,-5],[-9,0],[-9,-4],[-3,1]],[[6794,4921],[4,-15],[4,-20],[1,-9],[1,-24],[0,-11],[0,-22],[2,-25],[-1,-7],[-5,-12],[-5,-3]],[[6680,4159],[8,-18],[4,-8],[7,-5],[6,-13],[7,-22],[2,-18],[0,-10],[-6,-35],[-13,-63],[-2,-10],[-6,-15],[-6,-11],[-28,-48],[-7,-18],[-12,-37],[-5,-14],[-7,-13],[-12,-16],[-9,-7],[-7,-5],[-13,-6],[-13,-4],[-9,-4],[-13,-9],[-7,-6],[-3,-3],[-15,-16],[-9,-7],[-9,-5],[-17,-7],[-18,-8],[-9,-7],[-5,-5],[-8,-10],[-8,-13]],[[6438,3663],[-4,-9],[-8,-21],[-6,-15],[-11,-29],[-9,-24],[-2,-7],[-5,-24],[-3,-12]],[[6390,3522],[-9,4],[-18,-2],[4,-14],[0,-3],[-5,-12],[-3,-5],[-4,-17],[-3,-6],[-8,-8],[-7,-4],[-9,-8],[-9,-4],[-8,16],[-7,10],[-5,2],[-10,-4],[-14,0],[-8,-3],[-7,1],[-5,3],[1,6],[-8,5],[-2,3],[-13,7],[-8,-2],[-5,-6],[-9,-8],[-11,-1],[-6,-2],[-8,-7],[-4,5],[-13,5],[-5,5],[-7,15],[-7,24],[-1,0],[-11,4],[-8,8],[-6,12],[-5,6],[-9,13],[-5,5],[-10,6],[-8,13],[-9,18],[-6,-33],[0,-13],[4,-27],[5,-7],[5,-2],[0,-9],[-1,-1],[5,-10],[-2,-10],[-9,-11],[-1,-11],[-7,-17],[-6,-9],[-10,-13],[-1,-7],[2,-8],[3,-11],[7,-17],[7,-22],[9,-25],[6,-27],[1,-15],[-8,-1],[-12,-4],[-12,-6],[-7,-5],[-17,-6],[-31,-6],[-11,-3],[-15,-2],[-9,0],[-8,2]],[[5294,2909],[3,7],[-4,7],[-11,1],[-10,3],[-10,11],[-2,11],[0,18],[2,10],[3,8],[13,32],[1,4],[-1,12],[-2,18],[-2,3],[2,10],[3,5],[-10,4],[-7,2],[-1,4],[1,7],[-1,0],[2,7],[3,15],[7,21],[1,5],[2,5],[1,8]],[[8177,3416],[16,4],[60,15],[95,21],[89,22],[46,11],[15,4],[-5,29],[8,42],[12,47],[3,15],[5,13],[7,6],[-3,5],[-5,-2],[-4,5],[-9,-4],[-8,-3],[-5,-3],[-2,-8],[-7,-5],[-3,12],[-18,20],[-16,13],[-2,8],[-5,3],[4,7],[-5,10],[-12,9],[-13,11],[-8,11],[-9,1],[-14,-2],[-8,-4],[-1,12],[7,25],[6,-2],[7,1],[5,3],[5,9],[2,8],[0,7],[-2,6],[-4,8],[-21,32],[-16,26],[-8,0],[-20,9],[-32,14],[-16,4],[-9,3],[-11,4],[-18,10],[-8,1],[-7,-1],[-9,-7],[-14,-19],[-3,-4],[-9,-6],[-19,-4],[-11,-2],[-8,-1],[-6,1],[-13,6],[-10,7],[-9,4],[-10,-1],[-11,-5],[-7,-1],[-9,1],[-7,3],[-24,11],[-14,5],[-10,6],[-21,9],[-9,5],[-9,7],[-5,6],[-8,16],[-1,11],[0,7],[3,13],[0,9],[0,5],[-6,11],[0,14],[-3,7],[-5,6],[-9,9],[-5,4],[-22,12],[-7,7],[-12,28],[-4,6],[-4,2],[-4,-1],[-2,-4],[0,-6],[3,-8],[1,-8],[-1,-5],[-3,-5],[-17,-15],[-6,-1],[-3,3],[-5,8],[-5,5],[-4,2],[-6,-1],[-6,-5],[-8,-13],[-16,-18],[-33,-35],[-9,-7],[-32,-16],[-7,-4],[-7,-9],[-5,-9],[-6,-14],[-4,-5],[-5,-1],[-8,2],[-6,4],[-7,7],[0,15],[-2,11],[0,7],[2,6],[0,1],[5,1],[5,10],[3,4],[7,4],[2,8],[6,7],[7,16],[0,3],[4,2],[3,17],[3,3],[0,2],[4,3],[2,6],[3,0],[8,7],[11,5],[11,4],[27,14],[17,6],[15,9],[12,8],[18,18],[3,5],[-1,11],[0,9],[1,0],[4,11],[11,9],[4,8],[0,11],[1,4],[6,-3],[13,-4],[4,0],[5,-2],[2,-11],[3,-5],[11,-1],[5,-4],[10,-3],[10,1],[7,0],[5,-3],[5,-7],[6,-3],[15,-4],[32,-15],[7,-2],[7,-1],[7,1],[30,6],[14,5],[6,3],[8,6],[5,1],[7,0],[8,6],[4,0],[12,-2],[18,-8],[9,-6],[9,0],[10,-8],[7,-2],[14,-5],[14,-8],[15,-4],[25,-11],[20,-7],[14,-8],[37,-15],[9,-3],[7,0],[40,-9],[22,2],[8,-1],[5,0],[6,-3],[2,0],[18,1],[48,4],[28,2],[0,-42],[9,3],[41,13],[4,3],[9,11],[-42,34],[-35,29],[-7,5],[-22,12],[-36,14],[-40,9],[-17,5],[-14,6],[-9,6],[-10,8],[-8,9],[-13,16],[-6,10],[-5,9],[-9,25],[-5,25],[-2,47],[0,19],[-2,114],[-1,63],[-1,62],[-2,93],[-1,63],[0,30],[-1,88],[-1,40],[-1,69],[4,176],[3,116],[0,3],[1,48],[1,5],[1,38],[-1,6],[3,92],[2,52],[2,77],[1,43],[2,71],[2,72],[1,12],[2,19],[3,15],[3,10],[17,47],[30,85],[4,13],[3,20],[-1,18],[-3,68],[-5,120],[-4,80],[-3,57],[-2,18],[-4,20],[-6,25],[-16,31],[-6,9],[57,71],[69,117],[45,75]],[[8507,6699],[65,-90],[36,-129],[3,-43],[25,-226],[8,-230],[-63,-301],[-5,-158],[-5,-190],[-9,-331],[2,-145],[6,-370],[-2,-25],[57,-45],[67,-54],[16,-12],[6,-5],[28,-23],[89,-70],[22,-26],[22,-37],[18,-40],[3,-10],[-7,-7],[6,-14],[5,-13],[37,-48],[13,-25],[-1,-3],[5,-7],[8,-6],[43,34],[19,93],[15,1],[5,1],[10,1],[0,-2],[37,-7],[2,0],[24,9],[32,-29],[31,21],[34,6],[7,35],[8,19],[22,27],[24,38],[22,24],[26,20],[26,40],[16,-12],[56,-99],[61,23],[73,0],[40,-64],[19,-14],[41,-29],[9,-76],[183,47],[12,-7],[9,-9],[77,20],[4,-3],[16,-95],[-3,-4],[-43,-12],[8,-16],[19,-43],[32,-49],[21,-33],[-9,-13],[-2,-7],[-13,-19],[-3,-7],[-6,-8],[-11,-11],[-10,-13],[-8,-12],[-8,-17],[-8,-10],[-9,-21],[-7,-10],[-5,-9],[-11,-22],[-5,-7],[-4,-7],[-11,-12],[-5,-4],[-7,-9],[-1,-5],[-8,-13],[-4,-5],[1,-2],[-4,-5],[-2,-7],[-3,-4],[-4,-9],[-7,-16],[-1,-4],[-4,-5],[-1,-6],[-5,-5],[-1,-6],[-4,0],[0,-6],[-4,-4],[-2,-5],[-3,-4],[-2,-5],[-3,0],[-1,-4],[-2,-1],[-1,-5],[-7,-8],[-9,-19],[-16,-29],[-2,-4],[-7,-12],[-7,-9],[-1,-3],[-7,-11],[-3,-4],[-3,-5],[-4,-6],[-14,-31],[-4,-17],[-4,-6],[-1,-5],[-4,-2],[0,-6],[-4,-7],[-7,-7],[-3,-4],[-13,-33],[-3,-1],[-7,-8],[-5,-11],[-4,-3],[-6,-8],[-5,-10],[-6,-10],[-3,-3],[-7,-11],[-2,-2],[0,-4],[-4,-1],[-8,-11],[-7,-7],[-8,-7],[-15,-15],[-6,-10],[-5,-5],[-10,-6],[-6,-7],[-9,-12],[-5,-8],[-6,-7],[-1,-4],[-14,-13],[-4,-2],[-3,-5],[-13,-11],[-4,-2],[-2,-3],[-4,-2],[-2,-4],[-4,-1],[-2,-5],[-4,-5],[-1,-3],[-9,-8],[-2,-4],[-13,-15],[-8,-15],[-8,-9],[-2,-4],[-6,-5],[-1,-3],[-9,-9],[-19,-26],[-6,-4],[-7,-10],[-5,-11],[-6,-10],[-2,-7],[-7,-11],[-9,-11],[-11,-10],[-6,-5],[-9,-9],[-4,-7],[-3,-10],[-1,-8],[-4,-7],[-3,-3],[-6,-5],[-12,-6],[-10,-13],[-14,-20],[-6,-7],[-10,-13],[-9,-10],[-6,-5],[-3,-6],[-4,-4],[-8,-18],[-9,-9],[-5,-10],[-3,-3],[-8,-6],[-5,-5],[0,-2],[-10,-19],[-9,-11],[-2,-6],[-6,-3],[-8,-7],[-4,-2],[-5,-10],[-6,-12],[-3,-1],[-7,-6],[-7,1],[-6,-3],[-2,3],[-5,1],[-9,0],[-5,-2],[-6,-7],[-7,-2],[-4,-3],[-11,-11],[-8,-1],[0,-4],[-4,0],[-1,-6],[-3,-1],[-7,-10],[-11,-11],[-9,-5],[-2,-4],[-6,-4],[-7,-6],[-12,-14],[-10,-15],[-5,-5],[-6,-7],[-8,-9],[-3,-4],[-3,-3],[-3,-6],[-2,-1],[-2,-5],[-2,-2],[-9,-13],[-3,-6],[-7,-9],[-2,-4],[-6,-6],[-4,-10],[-9,-8],[-2,-4],[-4,-3],[-3,-4],[-6,1],[-3,4],[-3,-3],[-12,-6],[-7,-6],[-12,-14],[-5,-2],[-4,-3],[-4,-7],[1,-7],[-2,1],[-4,-4],[-4,0],[-7,-4],[-6,0],[-3,-4],[-8,-1],[-2,-4],[-1,-9],[-3,0],[-4,3],[-11,-1],[-13,-11],[-4,-5],[-11,-15],[-8,-4],[-5,-5],[-2,-5],[-7,-5],[-6,1],[-3,-2],[-13,0],[-4,-1],[-8,-8],[-4,-7],[-1,-3],[-7,-7],[-1,-6],[-3,-1],[-4,-6],[-2,2],[-6,-2],[-8,-9],[3,-2],[-2,-4],[-7,-2],[-7,2],[-4,0],[-2,-4],[-5,2],[-1,-5],[-4,-3],[-5,-1],[-4,-5],[-6,-9],[-3,-10],[-3,-3],[-2,-7],[-5,-3],[-5,-4],[-10,-2],[-5,-3],[-13,0],[-2,-3],[0,4],[-10,0],[-6,4],[-5,0],[-6,-2],[-5,4],[-3,5],[-1,4],[-3,2],[-16,1],[-8,-6],[-9,1],[-7,4],[-7,0],[-11,6],[-13,3],[-24,2],[-5,2],[-13,20],[-14,22],[-20,24],[-22,16],[-6,-2],[-5,6],[0,6],[7,2],[0,5],[-3,7],[-3,-1],[-4,9],[-8,3],[-5,-1],[-1,-8],[-3,1],[-6,4],[1,4],[-4,12],[-3,4],[-5,14],[1,7],[0,4],[-3,5],[-4,1],[-6,0],[-3,7],[-1,9],[-3,2],[-3,-1],[-4,3],[-1,5],[-3,5],[-7,6],[-8,1],[-8,-2],[-8,-6],[-8,-10],[-11,0],[-11,4],[-23,-7],[-10,-4],[-10,-1],[-1,1],[-16,5],[-15,1],[-11,-2],[-13,-4],[-8,-2],[-22,-10],[-26,-8],[-25,-7],[-13,-10],[-2,-6],[-4,-1],[-2,-3],[-6,-1],[-7,6],[1,5],[2,0],[-4,5],[-5,1],[-8,-3]],[[5979,650],[-2,0],[-3,5],[-6,1],[-19,-27],[-11,-14],[-11,-17],[-14,-18],[-27,-37],[-2,-4],[4,-6],[0,-4],[-5,-3],[2,-2],[-6,-5],[-9,2],[-5,-4],[-2,-5],[3,-9],[0,-4],[-3,-5],[-5,-2],[-3,0],[-7,-5],[-4,-7],[-10,-4],[-5,-1],[-5,3],[-5,7],[-5,3],[-5,-2],[-5,-9],[-3,-8],[-6,-10],[-5,-10],[-10,-7],[-10,-7],[-6,-8],[-14,-27],[-9,-9],[-10,-5],[-17,3],[-9,1],[-6,-4],[-10,3],[-12,0],[-15,-2],[-11,-4],[-11,-2],[-11,-5],[-7,-5],[-15,-14],[-3,-9],[-7,-6],[-15,-20],[-9,-10],[-9,-14],[-9,-2],[-15,-8],[-8,-6],[-11,-4],[-27,-16],[-13,-10],[-6,-6],[-12,-11],[-17,-12],[-19,-12],[-13,-5],[-16,-13]],[[5410,1357],[8,13],[8,5],[20,5],[5,3],[49,44],[0,2],[20,19],[9,9],[8,12],[18,34],[6,8],[7,7],[6,6],[15,7],[18,0],[37,-5],[15,-3],[21,-3],[22,-5],[22,-7],[3,-3],[4,-6],[5,-13]],[[6390,3522],[-4,-18],[-1,-5],[-3,-14],[-6,-13],[-7,-12],[-10,-14],[-9,-15],[-8,-17],[-2,-7],[-4,-20],[-2,-24],[-1,-30],[-1,-19],[-1,-35],[1,-13],[2,-9],[6,-25],[27,9],[1,-8],[2,-4],[6,-4],[6,1],[18,6],[11,-28],[-23,-5],[8,-16],[-26,-15],[-23,-42],[-10,-23],[-6,-39],[-11,9],[-11,6],[-39,19],[-11,-18],[-1,-9],[-7,-23],[-6,-15],[-4,-12],[-4,-10],[-5,-10],[-6,-9],[-4,-1],[-4,7],[-3,-2],[-10,-2],[-5,-5],[-6,-13],[-5,-12],[5,-4],[9,-7],[15,-18],[12,-12],[10,-12],[15,-27],[4,-15],[4,-6],[22,-23],[7,-6],[6,-5],[9,-4],[12,-2],[9,1],[27,7],[13,1],[10,0],[6,-2],[11,-3],[-7,-19],[19,-28],[4,-8],[1,-5],[0,-9],[-2,-8],[-6,-8],[-4,-3],[-4,-5],[-6,2],[-10,0],[-10,-3],[-8,-1],[-8,-4],[-6,-7],[-7,-2],[-6,-6],[4,-10],[-22,-14],[-8,-2],[-5,-4],[0,-7],[-5,-5],[-2,-4],[2,-8],[-3,-7],[-5,6],[0,-6],[-4,-3],[-5,-13],[-3,-3],[-10,-8],[-1,-5],[-3,-6],[-4,-16],[-3,-7],[-13,-11],[-6,-6],[-4,-7],[-8,-9],[-8,-6],[-6,1],[-3,-1],[-2,-7],[0,-8],[1,-3],[-1,-6],[-3,-5],[-3,-9],[2,-11],[15,-9],[13,-6],[4,-5],[9,5],[5,10],[4,-22],[-7,-42],[0,-7],[-3,-16],[-1,-8],[-2,-13],[-4,-5],[-4,-4],[-2,-4],[0,-10],[-14,4],[-12,1],[-7,2],[-1,-9],[-8,-2],[-10,-1],[-9,1],[-10,4],[-6,1],[-8,6],[-12,3],[-3,0],[-6,-2],[-4,0],[-3,7],[-4,4],[-2,7],[-1,8],[-3,14],[-2,14],[3,4],[-2,2],[-11,-10],[-15,-20],[-2,-6],[-34,-43],[-14,-17],[-5,-6],[-14,-13],[-17,-23],[-27,18],[-20,10],[1,8],[-2,5],[-4,4],[4,6],[-4,5],[-8,10],[-9,12],[-5,7],[-3,4],[-2,14],[-5,3],[-4,-11],[-3,1],[-5,-4],[0,-13],[8,-7],[-4,-3],[-2,1],[-7,7],[-4,1],[-14,-1],[-1,3],[-5,1],[-6,-3],[-8,-7],[-8,-6],[-4,-1],[3,-7],[-4,-4],[-8,-12],[-3,-7],[-10,2]],[[4751,2814],[-8,0],[-7,3],[-8,1],[-6,0],[-6,-5]],[[4814,3231],[-5,4],[-2,6],[3,2],[2,5],[-2,8],[1,6],[-3,4],[-1,7],[0,10],[-5,7],[-5,0],[-11,-9],[-3,-2],[-8,-1],[-8,-2],[-11,6],[-5,5],[-5,8],[0,8],[4,7],[8,5],[8,10],[3,6],[-1,9],[-1,4],[-4,3],[-2,4],[0,-4],[-7,4],[-10,-2],[-8,1],[-8,4],[-4,6],[-5,17],[-6,10],[-7,14],[-9,7],[-3,1],[-11,7],[-9,4],[-3,4],[-1,8],[8,11],[8,9],[-2,1],[-4,-2],[-7,-7],[0,5],[6,7],[11,6],[-2,4],[-3,2],[6,1],[8,6],[-9,-3],[-1,2],[5,3],[10,9],[9,5],[2,3],[-3,5],[10,8],[-1,5],[34,29],[7,6],[5,-2],[3,-5],[10,-1],[4,2],[4,0],[24,-2],[10,-4],[39,-26],[6,-3],[8,2],[12,7],[13,10],[11,14],[2,3],[10,7],[4,4],[-5,2],[3,8],[6,3],[1,8],[3,2],[1,7],[-5,3],[-1,2],[3,13],[1,8],[-2,19],[-2,6],[-1,11],[3,8],[1,6],[-2,11],[0,13],[-2,10],[1,9],[-4,6],[1,13],[6,21],[1,5],[1,19],[4,9],[4,6],[9,11],[6,12],[2,11],[1,10],[3,10],[13,12],[3,6],[1,9],[0,10],[-3,9],[-4,10],[-3,7],[-1,17],[-3,10],[-7,10],[-18,15],[1,4],[3,0],[5,-4],[9,-2],[6,-3],[9,1],[4,3],[13,14],[8,12],[7,14],[8,17]],[[5015,4006],[18,-9],[33,-12],[8,-3],[-6,-25],[-21,-89],[-9,-38],[-4,-17],[-9,-38],[-3,-12],[-6,-26],[-7,-31],[-2,-26],[0,-21],[16,-3],[14,5],[4,2],[7,9],[6,3],[15,4],[4,-2],[4,-5],[1,-9],[-2,-19],[3,-7],[9,1],[14,0],[11,-3],[15,7],[9,4],[9,-2],[7,-1],[12,-10],[11,-1],[6,1],[13,9],[11,11],[3,2],[8,0],[4,-2],[8,-6],[2,-4],[3,1]],[[6438,3663],[12,-7],[8,-1],[7,0],[11,5],[13,6],[10,2],[4,-16],[61,-9],[14,-10],[1,-2],[24,-68],[-1,-5],[5,-5],[27,-8],[17,0],[26,-15],[10,-1],[5,3],[10,2],[11,-1],[8,-1],[11,5],[7,3],[11,3],[9,-1],[8,-5],[9,-9],[12,-12],[8,-10],[6,-10],[14,-29],[6,-12],[13,-26],[6,-13],[2,-8],[3,-17],[3,-7],[15,-10],[15,-8],[13,-10],[9,-8],[33,-42],[12,-14],[11,-16],[9,-13],[21,-20],[10,-8],[11,-18],[5,-12],[3,-11],[1,-10],[0,-13],[-2,-18],[-1,-16],[-3,-25],[-3,-28],[-3,-13],[-2,-15],[2,-8],[2,-7],[4,-7],[4,-3],[4,-3],[18,-5],[5,-2],[8,-8],[3,-11],[4,-7],[5,-6],[5,-4],[17,-6],[-6,-19],[-2,-15],[-1,-4],[-1,-10],[10,-56]],[[5015,4006],[0,2],[3,10],[2,4],[4,10],[6,5],[2,-4],[3,5],[4,2],[8,0],[1,-2],[5,1],[4,-4],[5,4],[4,10],[4,7],[-7,0],[3,3],[-13,20],[1,4],[-3,0],[-3,5],[-1,6],[-1,12],[2,18],[-2,1],[0,10],[0,13],[2,14],[4,5],[1,9],[3,3],[5,11],[8,13],[1,3],[-3,6],[1,3],[6,-2],[5,-8],[7,-7],[5,-5],[2,-9],[14,-10],[4,-2],[10,-1],[0,2],[-16,5],[-9,9],[-5,13],[3,20],[2,9],[-2,11],[-6,6],[-5,-2],[-1,-3],[-3,-2],[-5,3],[-6,11],[2,9],[6,10],[-5,3],[-22,-6],[-10,4],[-3,1],[-7,4],[-6,1],[-2,3],[-4,2],[-7,-2],[-10,-10],[-6,-10],[-5,-2],[0,-3],[-5,-5],[-9,-13],[-1,-4],[-2,-1],[-4,-10],[0,-4],[3,-6],[2,-3],[0,-7],[0,-11],[3,-8],[4,-1],[5,-8],[4,-14],[3,-2],[1,-4],[-2,-6],[-4,-8],[-3,-6],[-4,1],[0,-3],[-3,-5],[-5,-4],[-4,0],[-7,2],[-9,-2],[-9,-8],[-8,-4],[1,-2],[6,1],[0,-1],[-7,-11],[-7,-6],[-4,1],[-5,-1],[-5,-4],[-3,3],[-10,5],[-6,0],[-10,-3],[-15,-9],[-6,-2],[-5,0],[-1,-3],[-21,-7],[-8,-7],[-2,-3],[0,-7],[-2,-8],[0,-7],[8,0],[4,-7],[1,-7],[-3,-5],[-7,2],[-11,10],[-7,5],[-5,5],[-11,7],[-7,5],[-8,9],[-16,11],[-4,5],[-5,6],[-10,18],[-5,11],[-14,61],[-12,-3],[-3,15],[5,2],[-1,1],[-7,28],[0,2],[6,3],[-3,17],[-7,21],[0,8],[-4,13],[-4,20],[0,14],[-1,2],[2,15],[0,15],[1,8],[-3,5],[1,5],[3,5],[2,2],[4,5],[6,19],[0,9],[4,8],[2,11],[2,5],[4,17],[5,6],[5,19],[2,2],[6,16],[0,7],[3,9],[3,5],[1,8],[4,-1],[1,2],[3,9],[4,6],[5,15],[1,10],[4,15],[1,6],[4,12],[4,9],[3,13],[13,29],[11,21],[-2,2],[13,26],[1,4],[3,12],[2,11],[5,11],[3,12],[3,0],[5,36],[3,31],[2,27],[3,18],[4,7],[3,2],[4,-1],[7,1],[4,2],[7,8],[16,15],[11,12],[2,12],[2,8],[6,12],[4,11],[1,6],[4,9],[11,7],[9,10],[8,0],[9,4],[8,2],[13,6],[5,4],[11,8],[7,5],[14,20],[3,8],[6,11],[6,13],[6,20],[5,8],[3,9],[7,15],[4,12],[5,19],[4,24],[2,21],[1,15],[-1,30],[0,26],[-3,8],[1,9],[1,3],[1,11],[1,25],[0,4],[-3,6],[-1,5],[3,6],[3,8],[2,8],[0,11],[-1,19],[-3,21],[-5,13],[-4,4],[0,6],[3,7],[0,6],[-4,13],[-2,9],[-6,3],[-1,6],[0,6],[3,4],[-1,12],[-4,26],[0,7],[1,9],[1,12],[-2,11],[-4,23],[-4,32],[-10,38],[-5,32],[-3,12],[0,7],[1,16],[-3,2],[-5,-2],[-4,1],[-2,3],[-1,9],[-3,3],[-3,-7],[-1,1],[11,20],[3,8],[0,4],[-3,3],[3,4],[5,2],[2,-4],[3,2],[5,3],[12,-4],[11,1],[6,4],[9,14],[11,12],[2,4],[2,6],[6,2],[14,18],[9,15],[5,11],[4,11]],[[4413,2446],[-1,2],[-23,-1],[-5,2],[-9,1],[0,3],[4,1],[2,3],[-1,6],[-2,36],[-1,18],[-1,9],[0,7],[0,7],[0,8],[4,2],[3,-2],[-2,-4],[0,-8],[5,-4],[9,14],[-1,13],[-5,7],[2,12],[-7,27],[-9,29],[-13,42],[-17,42],[-14,37],[-13,23],[-16,25],[-21,36],[-6,9],[-10,18],[-3,12],[-3,6],[-18,20],[-11,14],[-10,15],[-5,6]],[[7099,5230],[5,0],[8,8],[4,5],[8,8],[2,3],[6,2],[8,3],[3,-2],[2,-7],[7,3],[5,-3],[10,0],[8,1],[33,11],[12,2],[3,7],[4,2],[-2,17],[1,11],[6,20],[3,6],[6,1],[8,-3],[2,2],[1,7],[-3,11],[-5,35],[-6,20],[0,13],[-1,5],[-4,5],[-6,1],[-1,3],[1,5],[3,3],[6,4],[12,11],[14,14],[12,17],[4,4],[10,6],[4,1],[8,6],[6,9],[3,8],[1,7],[-1,15],[-2,9],[-3,10],[-5,19],[-11,22],[-10,18],[-5,18],[-3,14],[1,7],[2,8],[9,11],[1,4],[7,9],[4,7],[2,8],[4,18],[2,3],[12,0],[8,-2],[12,0],[5,5],[5,1],[16,2],[5,2],[7,3],[8,5],[18,19],[22,35],[16,28],[12,14],[16,25],[18,26],[15,4],[16,-9],[18,-3],[16,2],[13,13],[10,27],[5,37],[-2,48],[-5,40],[-6,26],[-12,14],[-15,16],[-13,10],[-6,17],[-6,25],[-6,19],[-9,19],[-1,20],[2,29],[-3,23],[-2,23],[4,18],[12,16],[15,11],[53,62],[12,15],[8,18],[0,24],[-4,23],[8,31],[8,14],[5,20],[2,25],[1,29],[8,37],[11,21],[16,13],[15,8],[20,11],[21,16],[11,11],[6,17],[16,19],[23,17],[19,15],[13,19],[8,24],[0,39],[1,21],[7,19],[15,7],[14,6],[17,9],[10,14],[7,18],[7,30],[7,37],[0,18],[1,46],[11,-3],[14,1],[8,4],[11,-1],[11,-6],[18,-12],[14,-15],[42,-28],[10,-3],[25,-12],[26,-24],[8,-9],[7,-9],[2,-9],[4,-8],[6,-10],[12,-8],[8,-11],[7,-2],[16,6],[16,5],[21,10],[18,8],[16,3],[7,-3],[9,-8],[10,-12],[9,-15],[6,-6],[6,-5],[8,-4],[18,-13],[10,-11],[20,-19],[22,-16],[10,-8],[26,-31],[78,-42],[49,-58]]]} diff --git a/src/lib/municipalityProximity.ts b/src/lib/municipalityProximity.ts index dcbc396a..81b77bef 100644 --- a/src/lib/municipalityProximity.ts +++ b/src/lib/municipalityProximity.ts @@ -1,6 +1,7 @@ import type { Position } from 'geojson' import type { + BahiaGeometryFeature, BahiaMunicipalityFeature, MunicipalityGeometryModule, } from '@/lib/bahiaGeometriesTypes' @@ -105,7 +106,7 @@ const isPointInRing = (point: GeoPoint, ring: readonly Position[]): boolean => { return inside } -const polygonRingsOf = (feature: BahiaMunicipalityFeature): readonly Position[][][] => +const polygonRingsOf = (feature: BahiaGeometryFeature): readonly Position[][][] => feature.geometry.type === 'Polygon' ? [feature.geometry.coordinates] : feature.geometry.coordinates @@ -114,7 +115,7 @@ const polygonRingsOf = (feature: BahiaMunicipalityFeature): readonly Position[][ * Holes and MultiPolygons do not occur in the committed mesh (417 plain * Polygons), but a rebuild at finer quality would introduce them. */ -export const featureContainsPoint = (feature: BahiaMunicipalityFeature, point: GeoPoint): boolean => +export const featureContainsPoint = (feature: BahiaGeometryFeature, point: GeoPoint): boolean => polygonRingsOf(feature).some(([outerRing, ...holes]) => { if (!outerRing || !isPointInRing(point, outerRing)) return false return !holes.some((hole) => isPointInRing(point, hole)) @@ -125,7 +126,7 @@ export const featureContainsPoint = (feature: BahiaMunicipalityFeature, point: G * at Bahia's latitudes is far below the precision this feeds). Holes are * ignored: they move the centre by less than the rounding of the label. */ -export const featureCentroid = (feature: BahiaMunicipalityFeature): GeoPoint => { +export const featureCentroid = (feature: BahiaGeometryFeature): GeoPoint => { let weightedLng = 0 let weightedLat = 0 let totalArea = 0 diff --git a/src/lib/municipalityZoneNeighborhoods.ts b/src/lib/municipalityZoneNeighborhoods.ts index 9f078b76..d40f02ba 100644 --- a/src/lib/municipalityZoneNeighborhoods.ts +++ b/src/lib/municipalityZoneNeighborhoods.ts @@ -6,6 +6,10 @@ * https://www.tre-ba.jus.br/legislacao/compilada/resolucao/2017/resolucao-administrativa-no-2-de-10-de-maio-de-2017 * (accessed 2026-07-21) * + * Rows author the slug and the neighborhood list only: `city` and `zoneNumber` + * are hydrated from `municipalityCatalog`, which is also what makes an unknown + * or non-zona slug fail at import instead of drifting silently. + * * Coverage is independently checked by tests/fixtures/municipality-zone-neighborhoods.official.json. */ @@ -13,11 +17,9 @@ import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' export type MunicipalityZoneNeighborhoodSource = 'tre-ra-02-2017' -type MunicipalityZoneNeighborhoodCity = 'Salvador' - export type MunicipalityZoneNeighborhoodEntry = { readonly municipalitySlug: string - readonly city: MunicipalityZoneNeighborhoodCity + readonly city: string readonly zoneNumber: number readonly source: MunicipalityZoneNeighborhoodSource readonly neighborhoods: readonly string[] @@ -28,20 +30,25 @@ const sortNeighborhoods = (names: readonly string[]): readonly string[] => const entry = ( municipalitySlug: string, - city: MunicipalityZoneNeighborhoodCity, - zoneNumber: number, source: MunicipalityZoneNeighborhoodSource, neighborhoods: readonly string[], -): MunicipalityZoneNeighborhoodEntry => ({ - municipalitySlug, - city, - zoneNumber, - source, - neighborhoods: sortNeighborhoods(neighborhoods), -}) +): MunicipalityZoneNeighborhoodEntry => { + const catalogEntry = getMunicipalityCatalogEntry(municipalitySlug) + if (!catalogEntry || catalogEntry.kind !== 'zona' || catalogEntry.zoneNumber === undefined) { + throw new Error(`Missing zona municipality catalog entry for slug: ${municipalitySlug}`) + } + + return { + municipalitySlug, + city: catalogEntry.city, + zoneNumber: catalogEntry.zoneNumber, + source, + neighborhoods: sortNeighborhoods(neighborhoods), + } +} export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoodEntry[] = [ - entry('salvador-ze-1', 'Salvador', 1, 'tre-ra-02-2017', [ + entry('salvador-ze-1', 'tre-ra-02-2017', [ 'Alto das Pombas', 'Barra', 'Calabar', @@ -52,7 +59,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Graça', 'Vitória', ]), - entry('salvador-ze-2', 'Salvador', 2, 'tre-ra-02-2017', [ + entry('salvador-ze-2', 'tre-ra-02-2017', [ 'Amaralina', 'Chapada do Rio Vermelho', 'Engenho Velho da Federação', @@ -60,7 +67,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Ondina', 'Rio Vermelho', ]), - entry('salvador-ze-3', 'Salvador', 3, 'tre-ra-02-2017', [ + entry('salvador-ze-3', 'tre-ra-02-2017', [ 'Baixa de Quintas', 'Cidade Nova', 'Fazenda Grande do Retiro', @@ -69,7 +76,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Pero Vaz', 'Santa Mônica', ]), - entry('salvador-ze-4', 'Salvador', 4, 'tre-ra-02-2017', [ + entry('salvador-ze-4', 'tre-ra-02-2017', [ 'Coutos', 'Fazenda Coutos', 'Ilha de Bom Jesus dos Passos', @@ -80,7 +87,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Periperi (parte ao norte da Rua das Pedrinhas)', 'São Tomé', ]), - entry('salvador-ze-5', 'Salvador', 5, 'tre-ra-02-2017', [ + entry('salvador-ze-5', 'tre-ra-02-2017', [ 'Barreiras', 'Cabula', 'Engomadeira', @@ -89,14 +96,14 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Retiro', 'São Gonçalo', ]), - entry('salvador-ze-6', 'Salvador', 6, 'tre-ra-02-2017', [ + entry('salvador-ze-6', 'tre-ra-02-2017', [ 'Acupe', 'Boa Vista de Brotas', 'Brotas', 'Candeal', 'Engenho Velho de Brotas', ]), - entry('salvador-ze-7', 'Salvador', 7, 'tre-ra-02-2017', [ + entry('salvador-ze-7', 'tre-ra-02-2017', [ 'Barbalho', 'Barris', 'Comércio', @@ -111,7 +118,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Tororó', 'Vila Laura', ]), - entry('salvador-ze-8', 'Salvador', 8, 'tre-ra-02-2017', [ + entry('salvador-ze-8', 'tre-ra-02-2017', [ 'Cajazeiras II', 'Cajazeiras IV', 'Cajazeiras V', @@ -125,7 +132,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Pirajá', 'Valéria', ]), - entry('salvador-ze-9', 'Salvador', 9, 'tre-ra-02-2017', [ + entry('salvador-ze-9', 'tre-ra-02-2017', [ 'Boa Viagem', 'Bonfim', 'Jardim Cruzeiro', @@ -137,7 +144,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Uruguai', 'Vila Ruy Barbosa', ]), - entry('salvador-ze-10', 'Salvador', 10, 'tre-ra-02-2017', [ + entry('salvador-ze-10', 'tre-ra-02-2017', [ 'Bairro da Paz', 'Boca do Rio', 'Costa Azul', @@ -148,7 +155,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Pituaçu', 'STIEP', ]), - entry('salvador-ze-11', 'Salvador', 11, 'tre-ra-02-2017', [ + entry('salvador-ze-11', 'tre-ra-02-2017', [ 'Águas Claras', 'Calabetão', 'Castelo Branco', @@ -161,7 +168,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Sete de Abril', 'Vila Canária', ]), - entry('salvador-ze-12', 'Salvador', 12, 'tre-ra-02-2017', [ + entry('salvador-ze-12', 'tre-ra-02-2017', [ 'Aeroporto', 'Alto do Coqueirinho', 'Areia Branca', @@ -171,7 +178,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'São Cristóvão', 'Stella Maris', ]), - entry('salvador-ze-13', 'Salvador', 13, 'tre-ra-02-2017', [ + entry('salvador-ze-13', 'tre-ra-02-2017', [ 'Caminho das Árvores', 'Itaigara', 'Nordeste de Amaralina', @@ -179,14 +186,14 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Santa Cruz', 'Vale das Pedrinhas', ]), - entry('salvador-ze-14', 'Salvador', 14, 'tre-ra-02-2017', [ + entry('salvador-ze-14', 'tre-ra-02-2017', [ 'Arenoso', 'Novo Horizonte', 'São Marcos', 'Sussuarana', 'Tancredo Neves', ]), - entry('salvador-ze-15', 'Salvador', 15, 'tre-ra-02-2017', [ + entry('salvador-ze-15', 'tre-ra-02-2017', [ 'Arraial do Retiro', 'Boa Vista de São Caetano', 'Bom Juá', @@ -194,7 +201,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Lobato', 'São Caetano', ]), - entry('salvador-ze-16', 'Salvador', 16, 'tre-ra-02-2017', [ + entry('salvador-ze-16', 'tre-ra-02-2017', [ 'Cabula VI', 'Canabrava', 'Centro Administrativo da Bahia', @@ -207,7 +214,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Saramandaia', 'Vale dos Lagos', ]), - entry('salvador-ze-17', 'Salvador', 17, 'tre-ra-02-2017', [ + entry('salvador-ze-17', 'tre-ra-02-2017', [ 'Alto da Terezinha', 'Alto do Cabrito', 'Itacaranha', @@ -217,7 +224,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Rio Sena', 'São João do Cabrito', ]), - entry('salvador-ze-18', 'Salvador', 18, 'tre-ra-02-2017', [ + entry('salvador-ze-18', 'tre-ra-02-2017', [ "Caixa D'água", 'Calçada', 'Caminho de Areia', @@ -227,7 +234,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo 'Mares', 'Roma', ]), - entry('salvador-ze-19', 'Salvador', 19, 'tre-ra-02-2017', [ + entry('salvador-ze-19', 'tre-ra-02-2017', [ 'Boca da Mata', 'Cajazeiras X', 'Cajazeiras XI', @@ -264,19 +271,3 @@ export const municipalityZoneNeighborhoodSourceLabel = ( return source satisfies never } } - -const assertMunicipalityZoneNeighborhoodCatalogIntegrity = (): void => { - for (const record of municipalityZoneNeighborhoods) { - const catalogEntry = getMunicipalityCatalogEntry(record.municipalitySlug) - if (!catalogEntry || catalogEntry.kind !== 'zona') { - throw new Error( - `Missing zona municipality catalog entry for slug: ${record.municipalitySlug}`, - ) - } - if (catalogEntry.city !== record.city || catalogEntry.zoneNumber !== record.zoneNumber) { - throw new Error(`Catalog mismatch for slug: ${record.municipalitySlug}`) - } - } -} - -assertMunicipalityZoneNeighborhoodCatalogIntegrity() diff --git a/src/utilities/municipalityMapContract.ts b/src/utilities/municipalityMapContract.ts index 53543958..ef009450 100644 --- a/src/utilities/municipalityMapContract.ts +++ b/src/utilities/municipalityMapContract.ts @@ -1,7 +1,7 @@ import type { FederalCompetitiveRank } from '@/lib/bahiaElectionAggregates' import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults' import type { VoteEstimateScenario } from '@/lib/voteEstimate' -import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation' +import type { MunicipalitiesByMapKey } from '@/utilities/municipalityMapNavigation' import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass' /** @@ -89,16 +89,22 @@ type MunicipalityZoneBreakdownRow = { export type MunicipalityMapComparison = { candidateNumber: number candidateName: string - /** TSE year (as string) → codarea → (sollaVotes − otherVotes). */ + /** TSE year (as string) → map key → (sollaVotes − otherVotes). */ diffByYear: Record> } +/** + * Every record below is keyed by MAP KEY, not by IBGE codarea: since B8 F2 the + * map paints one polygon per catalog unit, so Salvador's 19 zones are addressed + * by slug (`salvador-ze-3`) and every other município by its codarea. See + * `mapKeyForMunicipality`. + */ export type MunicipalityMapBundle = { - /** year (as string) → codarea → value. 2026 = cenário médio (central). */ + /** year (as string) → map key → value. 2026 = cenário médio (central). */ valuesByYear: Record> - /** 2026 totals per estimate scenario (codarea → votes). */ + /** 2026 totals per estimate scenario (map key → votes). */ values2026ByScenario: Record> - /** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */ + /** year (as string) → map key → votosValidos (federal T1). 2026 reuses 2022. */ validVotesByYear: Record> /** * year (as string) → his own STATEWIDE share of valid votes: the "1×" the LQ @@ -107,14 +113,18 @@ export type MunicipalityMapBundle = { * the same standard. 2026 reuses the 2022 standard, like `validVotesByYear`. */ statewideShareByYear: Record - /** codarea → E10 territorial class, aggregated over the slugs in scope. */ - territorialClassByCode: Record - /** codarea → his placement among the federal-deputy candidates voted there. */ + /** map key → E10 territorial class of that unit. */ + territorialClassByMapKey: Record + /** + * map key → his placement among the federal-deputy candidates voted there. The + * TSE artifact ranks by codarea only, so Salvador's zones all carry the city's + * position (the legend says so). + */ competitiveRankByYear: Record> - /** codarea → projected 2026 valid votes (E8) — the size of the proportional symbol. */ - projectedValidVotesByCode: Record - /** IBGE codarea → accessible municipality slugs for map click navigation. */ - municipalitiesByIbgeCode: MunicipalitiesByIbgeCode + /** map key → projected 2026 valid votes (E8) — the size of the proportional symbol. */ + projectedValidVotesByMapKey: Record + /** map key → the municipality it opens on click. */ + municipalitiesByMapKey: MunicipalitiesByMapKey /** Zone municipalities in scope (Salvador) with per-year values. */ zoneBreakdown: MunicipalityZoneBreakdownRow[] candidateName: string diff --git a/src/utilities/municipalityMapData.ts b/src/utilities/municipalityMapData.ts index ea533cfe..3ac9ccb1 100644 --- a/src/utilities/municipalityMapData.ts +++ b/src/utilities/municipalityMapData.ts @@ -36,10 +36,13 @@ import type { MunicipalityMapBundle, MunicipalityMapComparison, } from '@/utilities/municipalityMapContract' -import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation' +import { + buildMunicipalitiesByMapKey, + mapKeyForMunicipality, +} from '@/utilities/municipalityMapNavigation' import { projectedValidVotes } from '@/utilities/municipalityPotential' import { - computeAggregateTerritorialClass, + computeMunicipalityTerritorialClass, type MunicipalityTerritorialClass, } from '@/utilities/municipalityTerritorialClass' import { @@ -104,9 +107,9 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( const statewideShareByYear: Record = {} const competitiveRankByYear: Record> = {} const zoneVotesBySlug = new Map>() - // One polygon per IBGE code, N catalog slugs behind it (Salvador is 19) — - // the same index the map uses to navigate, reused here for the rollups. - const municipalitiesByIbgeCode = buildMunicipalitiesByIbgeCode(municipalities) + // One polygon per catalog unit since B8 F2, so this index — the same one the + // map navigates by — is also the key of every rollup below. + const municipalitiesByMapKey = buildMunicipalitiesByMapKey(municipalities) // Historical years come from the committed artifact (immutable TSE data, // pre-aggregated per municipality) — zero database work. @@ -114,11 +117,11 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( const values: Record = {} const validValues: Record = {} for (const municipality of municipalities) { + const mapKey = mapKeyForMunicipality(municipality) const baseline = getMunicipalityFederalBaseline(municipality.slug) const votes = baseline.votesByYear[String(year)] ?? 0 - values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + votes - const valid = baseline.validVotesByYear[String(year)] ?? 0 - validValues[municipality.ibgeCode] = (validValues[municipality.ibgeCode] ?? 0) + valid + values[mapKey] = votes + validValues[mapKey] = baseline.validVotesByYear[String(year)] ?? 0 if (municipality.kind === 'zona') { const bySlug = zoneVotesBySlug.get(municipality.slug) ?? {} bySlug[String(year)] = votes @@ -135,10 +138,12 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( statewideShareByYear[String(year)] = statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0 + // Rank exists per city only, so every zone of Salvador carries the city's + // position — stated in the legend rather than faked per zone. const ranks: Record = {} - for (const ibgeCode of Object.keys(municipalitiesByIbgeCode)) { - const rank = getFederalCompetitiveRank(ibgeCode, year) - if (rank) ranks[ibgeCode] = rank + for (const municipality of municipalities) { + const rank = getFederalCompetitiveRank(municipality.ibgeCode, year) + if (rank) ranks[mapKeyForMunicipality(municipality)] = rank } competitiveRankByYear[String(year)] = ranks } @@ -148,14 +153,12 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( // substitution `validVotesByYear` already makes. statewideShareByYear['2026'] = statewideShareByYear['2022'] ?? 0 - const territorialClassByCode: Record = {} - const projectedValidVotesByCode: Record = {} - for (const [ibgeCode, entries] of Object.entries(municipalitiesByIbgeCode)) { - const slugs = entries.map((entry) => entry.slug) - territorialClassByCode[ibgeCode] = computeAggregateTerritorialClass(slugs).class - projectedValidVotesByCode[ibgeCode] = slugs.reduce( - (total, slug) => total + projectedValidVotes(getMunicipalityFederalBaseline(slug)), - 0, + const territorialClassByMapKey: Record = {} + const projectedValidVotesByMapKey: Record = {} + for (const [mapKey, entry] of Object.entries(municipalitiesByMapKey)) { + territorialClassByMapKey[mapKey] = computeMunicipalityTerritorialClass(entry.slug).class + projectedValidVotesByMapKey[mapKey] = projectedValidVotes( + getMunicipalityFederalBaseline(entry.slug), ) } @@ -178,8 +181,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( scenario, ) if (votes > 0) { - pledgeValuesByScenario[scenario][municipality.ibgeCode] = - (pledgeValuesByScenario[scenario][municipality.ibgeCode] ?? 0) + votes + pledgeValuesByScenario[scenario][mapKeyForMunicipality(municipality)] = votes } if (municipality.kind === 'zona') { const byScenario = zoneVotes2026BySlug.get(municipality.slug) ?? emptyZoneScenarioVotes() @@ -239,7 +241,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( const solla = getMunicipalityFederalBaseline(municipality.slug).votesByYear[String(year)] ?? 0 const other = sumVotesForGeography(otherVotes, municipality.geography) - values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + (solla - other) + values[mapKeyForMunicipality(municipality)] = solla - other } diffByYear[String(year)] = values }), @@ -258,10 +260,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( values2026ByScenario: pledgeValuesByScenario, validVotesByYear, statewideShareByYear, - territorialClassByCode, + territorialClassByMapKey, competitiveRankByYear, - projectedValidVotesByCode, - municipalitiesByIbgeCode, + projectedValidVotesByMapKey, + municipalitiesByMapKey, zoneBreakdown, candidateName: BASELINE_TICKET_2022.candidate.name, comparison, diff --git a/src/utilities/municipalityMapNavigation.ts b/src/utilities/municipalityMapNavigation.ts index caa33974..53dad921 100644 --- a/src/utilities/municipalityMapNavigation.ts +++ b/src/utilities/municipalityMapNavigation.ts @@ -5,12 +5,28 @@ type MunicipalityMapSlugEntry = { name: string } +/** IBGE codarea → the accessible catalog units inside it (Salvador has 19). */ export type MunicipalitiesByIbgeCode = Record -export type MunicipalityMapNavigation = - | { kind: 'none' } - | { kind: 'navigate'; slug: string } - | { kind: 'zones' } +/** + * Map key → the single unit it paints. Since B8 F2 the map draws one polygon per + * catalog unit — zone municipalities by slug, everything else by IBGE codarea — + * so this index is 1:1 where `MunicipalitiesByIbgeCode` is 1:N. + */ +export type MunicipalitiesByMapKey = Record + +export type MunicipalityMapNavigation = { kind: 'none' } | { kind: 'navigate'; slug: string } + +/** + * What the choropleth paints a unit by: a zone municipality has no code of its + * own (the whole city shares one), so it is keyed by its immutable catalog slug, + * which is also the polygon's key in `bahia-municipality-zones.topo.json`. + */ +export const mapKeyForMunicipality = (municipality: { + kind: 'municipio' | 'zona' + slug: string + ibgeCode: string +}): string => (municipality.kind === 'zona' ? municipality.slug : municipality.ibgeCode) export const buildMunicipalitiesByIbgeCode = ( municipalities: readonly AccessibleMunicipality[], @@ -30,12 +46,25 @@ export const buildMunicipalitiesByIbgeCode = ( return byIbge } -export const resolveMunicipalityMapNavigation = ( - ibgeCode: string, - municipalitiesByIbgeCode: MunicipalitiesByIbgeCode, -): MunicipalityMapNavigation => { - const entries = municipalitiesByIbgeCode[ibgeCode] - if (!entries || entries.length === 0) return { kind: 'none' } - if (entries.length === 1) return { kind: 'navigate', slug: entries[0].slug } - return { kind: 'zones' } +export const buildMunicipalitiesByMapKey = ( + municipalities: ReadonlyArray, +): MunicipalitiesByMapKey => { + const byMapKey: MunicipalitiesByMapKey = {} + + for (const municipality of municipalities) { + byMapKey[mapKeyForMunicipality(municipality)] = { + slug: municipality.slug, + name: municipality.name, + } + } + + return byMapKey +} + +export const resolveMunicipalityMapNavigation = ( + mapKey: string, + municipalitiesByMapKey: MunicipalitiesByMapKey, +): MunicipalityMapNavigation => { + const entry = municipalitiesByMapKey[mapKey] + return entry ? { kind: 'navigate', slug: entry.slug } : { kind: 'none' } } diff --git a/tests/e2e/campaignZoneMap.e2e.spec.ts b/tests/e2e/campaignZoneMap.e2e.spec.ts new file mode 100644 index 00000000..b8787391 --- /dev/null +++ b/tests/e2e/campaignZoneMap.e2e.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from './fixtures/campaignE2EFixtures.js' + +/** + * B8 F2 — Salvador used to be one polygon carrying the whole city's votes, so + * the choropleth could not answer "which piece of Salvador is weak / is mine". + * This pins the two halves of that fix: the zone mesh is drawn (one path per + * ZE, the city's own polygon left underneath as a non-interactive base) and a + * zone is addressable on its own — the readout names it and opens it. + */ +test('the dashboard map paints Salvador zone by zone and opens one', async ({ campaign, page }) => { + const { fixtures } = campaign + const password = fixtures.value('senha') + const coordinator = await campaign.payload.create({ + collection: 'campaignUser', + data: { + name: fixtures.value('Coordenadora Mapa'), + email: `${fixtures.value('map-coordinator')}@example.com`, + password, + role: 'coordinator', + }, + depth: 0, + }) + + await campaign.login(page, coordinator.email!, password) + + await expect(page.getByRole('heading', { name: 'Mapa dos Municípios' })).toBeVisible({ + timeout: 60_000, + }) + await page.waitForSelector('path.leaflet-interactive', { timeout: 60_000 }) + + // 417 municípios + 19 Salvador zones; Salvador's own polygon is the one path + // that stays out of the pointer's way. + await expect + .poll( + () => + page.evaluate(() => { + const paths = Array.from(document.querySelectorAll('.leaflet-overlay-pane path')) + return { + total: paths.length, + interactive: paths.filter((path) => path.classList.contains('leaflet-interactive')) + .length, + } + }), + { timeout: 30_000 }, + ) + .toEqual({ total: 436, interactive: 435 }) + + // Insertion order is z-order, so the zone mesh is the tail of the pane. + await page.evaluate(() => { + const paths = Array.from(document.querySelectorAll('.leaflet-overlay-pane path')) + paths.at(-1)?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + }) + + const readout = page.locator('div[aria-live="polite"]').first() + await expect(readout).toContainText(/Salvador — ZE \d+/) + + await readout.getByRole('link', { name: 'Abrir município' }).click() + // The slug is the assertion: the detail page's own

    reads the municipality + // ROW, which other specs rename, while the polygon's name comes from the + // static catalog. + await expect(page).toHaveURL(/\/campanha\/municipios\/salvador-ze-\d+$/) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() +}) diff --git a/tests/int/bahiaGeometries.int.spec.ts b/tests/int/bahiaGeometries.int.spec.ts index 3c5579bb..87375554 100644 --- a/tests/int/bahiaGeometries.int.spec.ts +++ b/tests/int/bahiaGeometries.int.spec.ts @@ -4,14 +4,22 @@ import { statSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import type { Position } from 'geojson' import { describe, expect, it } from 'vitest' -import { loadMunicipalityGeometryModule, loadTerritoryGeometryModule } from '@/lib/bahiaGeometries' +import type { BahiaGeometryFeature } from '@/lib/bahiaGeometriesTypes' + +import { + loadMunicipalityGeometryModule, + loadMunicipalityZoneGeometryModule, + loadTerritoryGeometryModule, +} from '@/lib/bahiaGeometries' import { bahiaMunicipalityCodes } from '@/lib/bahiaMunicipalityCodes' import { bahiaIdentityTerritoryRecords } from '@/lib/bahiaTerritories' import { municipalityCatalog } from '@/lib/municipalityCatalog' import { featureCentroid, + featureContainsPoint, findContainingMunicipality, resolveNearbyMunicipality, } from '@/lib/municipalityProximity' @@ -21,6 +29,9 @@ import { featureBounds } from '../helpers/featureBounds' /** Soft ceiling against accidental unsimplified meshes (plan target ≤ ~600 KB). */ const MAX_TOPO_BYTES = 600 * 1024 +/** Square degrees → km² at Salvador's latitude — good to a few percent, which is all this needs. */ +const SQUARE_DEGREE_KM2 = 110.57 * 108.5 + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..') const hasNonEmptyGeometry = ( @@ -31,6 +42,32 @@ const hasNonEmptyGeometry = ( return true } +/** Planar shoelace in square degrees — only ever compared against itself. */ +const featureArea = (feature: BahiaGeometryFeature): number => { + const polygons = + feature.geometry.type === 'Polygon' + ? [feature.geometry.coordinates] + : feature.geometry.coordinates + + return polygons.reduce((total, [outerRing, ...holes]) => { + const ringArea = (ring: readonly Position[]): number => { + let sum = 0 + for ( + let current = 0, previous = ring.length - 1; + current < ring.length; + previous = current++ + ) { + sum += ring[previous][0] * ring[current][1] - ring[current][0] * ring[previous][1] + } + return Math.abs(sum) / 2 + } + + return ( + total + ringArea(outerRing) - holes.reduce((holeTotal, hole) => holeTotal + ringArea(hole), 0) + ) + }, 0) +} + describe('Bahia static geometries', () => { it('keeps committed TopoJSON under the size budget', () => { const municipalityBytes = statSync( @@ -39,11 +76,16 @@ describe('Bahia static geometries', () => { const territoryBytes = statSync( join(repoRoot, 'src/lib/geometries/bahia-identity-territories.topo.json'), ).size + const zoneBytes = statSync( + join(repoRoot, 'src/lib/geometries/bahia-municipality-zones.topo.json'), + ).size expect(municipalityBytes).toBeGreaterThan(10_000) expect(territoryBytes).toBeGreaterThan(1_000) + expect(zoneBytes).toBeGreaterThan(10_000) expect(municipalityBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES) expect(territoryBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES) + expect(zoneBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES) }) it('exposes 417 municipality features whose codarea set matches the code table', async () => { @@ -87,6 +129,72 @@ describe('Bahia static geometries', () => { expect(getTerritoryFeature('99')).toBeUndefined() }) + /** + * B8 F2 — the 19 Salvador zones are dissolved from the IBGE neighborhood mesh, + * a different source than the municipal mesh above, so what is pinned here is + * the contract the map depends on: one feature per catalog zona, keyed by slug, + * geometrically inside the city it decomposes. + */ + describe('zone municipality geometries (B8 F2)', () => { + const zoneCatalog = municipalityCatalog.filter((entry) => entry.kind === 'zona') + + it('exposes one feature per zona municipality, keyed by catalog slug', async () => { + const { topology, features, getMunicipalityZoneFeature } = + await loadMunicipalityZoneGeometryModule() + + expect(Object.keys(topology.objects)).toEqual(['municipalityZones']) + expect(topology.objects.municipalityZones.geometries).toHaveLength(zoneCatalog.length) + expect(features).toHaveLength(zoneCatalog.length) + + for (const entry of zoneCatalog) { + const zone = getMunicipalityZoneFeature(entry.slug) + expect(zone, entry.slug).toBeDefined() + expect(zone?.properties.name).toBe(entry.name) + expect(zone?.properties.zoneNumber).toBe(entry.zoneNumber) + expect(zone?.properties.ibgeCode).toBe(entry.ibgeCode) + expect(hasNonEmptyGeometry(zone?.geometry)).toBe(true) + } + + expect(getMunicipalityZoneFeature('feira-de-santana')).toBeUndefined() + }) + + it('keeps every zone inside the municipality it decomposes', async () => { + const [{ getMunicipalityFeature }, { features }] = await Promise.all([ + loadMunicipalityGeometryModule(), + loadMunicipalityZoneGeometryModule(), + ]) + + for (const zone of features) { + const city = getMunicipalityFeature(zone.properties.ibgeCode) + expect(city, zone.properties.municipalitySlug).toBeDefined() + expect( + featureContainsPoint(city!, featureCentroid(zone)), + zone.properties.municipalitySlug, + ).toBe(true) + } + }) + + /** + * Salvador's land is ~324 km². It is NOT compared against the municipal + * polygon: that one measures ~690 km², matching the official IBGE area, + * because the municipal limits enclose the bay water around the islands — + * water the neighborhood mesh does not draw. + * + * Since each neighborhood polygon is assigned to exactly one zone, the sum is + * also the overlap check: a neighborhood counted twice, or dropped, moves this + * by its own area. + */ + it('sums to Salvador’s land area, so no neighborhood is dropped or counted twice', async () => { + const { features } = await loadMunicipalityZoneGeometryModule() + + const zoneKm2 = + features.reduce((total, zone) => total + featureArea(zone), 0) * SQUARE_DEGREE_KM2 + + expect(zoneKm2).toBeGreaterThan(280) + expect(zoneKm2).toBeLessThan(360) + }) + }) + /** * B14 resolves "onde estou" against this same mesh, so the containment used in * the field is pinned here with real coordinates — the synthetic squares in diff --git a/tests/int/municipalityMapData.int.spec.ts b/tests/int/municipalityMapData.int.spec.ts index 65805014..417d6496 100644 --- a/tests/int/municipalityMapData.int.spec.ts +++ b/tests/int/municipalityMapData.int.spec.ts @@ -10,10 +10,10 @@ import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData' import { installCampaignFixtures } from '../helpers/campaignFixtures' -const ZONE_MUNICIPALITY_COUNT = municipalityCatalog.filter((entry) => entry.kind === 'zona').length -const ZONE_MUNICIPALITY_CODES = new Set( - municipalityCatalog.filter((entry) => entry.kind === 'zona').map((entry) => entry.ibgeCode), -) +const zoneMunicipalities = municipalityCatalog.filter((entry) => entry.kind === 'zona') +const ZONE_MUNICIPALITY_COUNT = zoneMunicipalities.length +const ZONE_MUNICIPALITY_SLUGS = new Set(zoneMunicipalities.map((entry) => entry.slug)) +const ZONE_MUNICIPALITY_CODES = new Set(zoneMunicipalities.map((entry) => entry.ibgeCode)) let payload: Payload const campaignFixtures = installCampaignFixtures({ @@ -28,7 +28,7 @@ describe('loadMunicipalityMapBundle — list URL filters', () => { payload = await getPayload({ config: await config }) }) - it('scopes the map to zone municipalities when kind=zona is in the URL', async () => { + it('paints each zone under its own map key, never pooled under the city code', async () => { const coordinator = await campaignFixtures().createCampaignUser('coordinator') const bundle = await loadMunicipalityMapBundle(payload, coordinator, { kind: 'zona' }) @@ -36,11 +36,18 @@ describe('loadMunicipalityMapBundle — list URL filters', () => { expect(bundle).not.toBeNull() expect(bundle!.zoneBreakdown).toHaveLength(ZONE_MUNICIPALITY_COUNT) - const ibgeCodes = Object.keys(bundle!.valuesByYear['2022'] ?? {}) - expect(ibgeCodes.length).toBeGreaterThan(0) - for (const code of ibgeCodes) { - expect(ZONE_MUNICIPALITY_CODES.has(code)).toBe(true) + const mapKeys = Object.keys(bundle!.valuesByYear['2022'] ?? {}) + expect(mapKeys.length).toBeGreaterThan(0) + for (const key of mapKeys) { + expect(ZONE_MUNICIPALITY_SLUGS.has(key), key).toBe(true) + expect(ZONE_MUNICIPALITY_CODES.has(key), key).toBe(false) } + expect(Object.keys(bundle!.municipalitiesByMapKey).sort()).toEqual([...mapKeys].sort()) + + // The zones share the city's TSE rank — the artifact ranks by codarea only. + const ranks = bundle!.competitiveRankByYear['2022'] ?? {} + const positions = new Set(mapKeys.map((key) => ranks[key]?.rank)) + expect(positions.size).toBe(1) }) it('returns null when the filtered municipality set is empty', async () => { @@ -62,9 +69,9 @@ describe('loadMunicipalityMapBundle — list URL filters', () => { expect(included!.zoneBreakdown.some((row) => row.slug === administered.slug)).toBe( administered.kind === 'zona', ) - if (administered.kind !== 'zona') { - expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([administered.ibgeCode]) - } + expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([ + administered.kind === 'zona' ? administered.slug : administered.ibgeCode, + ]) const excluded = administered.kind === 'zona' diff --git a/tests/int/municipalityZoneNeighborhoods.int.spec.ts b/tests/int/municipalityZoneNeighborhoods.int.spec.ts index 4b1d174c..f52506e4 100644 --- a/tests/int/municipalityZoneNeighborhoods.int.spec.ts +++ b/tests/int/municipalityZoneNeighborhoods.int.spec.ts @@ -16,7 +16,8 @@ type OfficialEvidence = { zoneCount: number assignments: Array<{ municipalitySlug: string - city: 'Salvador' | 'Camaçari' + /** Hydrated from the catalog since Fase 0 of B8 F2, so the fixture no longer narrows it. */ + city: string zoneNumber: number source: 'tre-ra-02-2017' | 'tre-voting-locations-curated' neighborhoods: string[] @@ -60,6 +61,12 @@ describe('Municipality zone neighborhood catalog (Salvador only)', () => { } }) + it('lists zones in catalog order', () => { + expect(municipalityZoneNeighborhoods.map((entry) => entry.municipalitySlug)).toEqual( + zoneMunicipalities.map((entry) => entry.slug), + ) + }) + it('keeps Salvador neighborhoods exclusive across zones', () => { const seen = new Map() diff --git a/tests/unit/bahiaMapStyle.unit.spec.ts b/tests/unit/bahiaMapStyle.unit.spec.ts index cb493a50..e7cd68af 100644 --- a/tests/unit/bahiaMapStyle.unit.spec.ts +++ b/tests/unit/bahiaMapStyle.unit.spec.ts @@ -4,6 +4,7 @@ import { buildLayerStyleContext, canonicalMapKeysKey, computeChoroplethMax, + featureMapKey, resolvePathStyle, } from '@/lib/bahiaMapStyle' @@ -29,6 +30,34 @@ describe('bahiaMapStyle', () => { }) }) + describe('featureMapKey (B8 F2)', () => { + it('keys a zone municipality by its catalog slug', () => { + expect( + featureMapKey({ municipalitySlug: 'salvador-ze-7', ibgeCode: '2927408', zoneNumber: 7 }), + ).toBe('salvador-ze-7') + }) + + it('keys a município by codarea and an identity territory by code', () => { + expect(featureMapKey({ codarea: '2905701', name: 'Barreiras' })).toBe('2905701') + expect(featureMapKey({ code: 'chapada-diamantina', name: 'Chapada Diamantina' })).toBe( + 'chapada-diamantina', + ) + }) + + it('prefers the slug when the zone mesh also carries the city code', () => { + expect(featureMapKey({ municipalitySlug: 'salvador-ze-1', codarea: '2927408' })).toBe( + 'salvador-ze-1', + ) + }) + + it('returns undefined when no key property is usable', () => { + expect(featureMapKey(undefined)).toBeUndefined() + expect(featureMapKey({ name: 'Sem chave' })).toBeUndefined() + expect(featureMapKey({ codarea: '' })).toBeUndefined() + expect(featureMapKey({ codarea: 2905701 })).toBeUndefined() + }) + }) + describe('canonicalMapKeysKey', () => { it('sorts keys for stable identity', () => { expect(canonicalMapKeysKey(['2927408', '2905701'])).toBe('2905701,2927408') diff --git a/tests/unit/municipalityMapNavigation.unit.spec.ts b/tests/unit/municipalityMapNavigation.unit.spec.ts index 34446c72..a3e84cac 100644 --- a/tests/unit/municipalityMapNavigation.unit.spec.ts +++ b/tests/unit/municipalityMapNavigation.unit.spec.ts @@ -2,50 +2,69 @@ import { describe, expect, it } from 'vitest' import { buildMunicipalitiesByIbgeCode, + buildMunicipalitiesByMapKey, + mapKeyForMunicipality, resolveMunicipalityMapNavigation, } from '@/utilities/municipalityMapNavigation' +const abaira = { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108', kind: 'municipio' } as const +const salvadorZe1 = { + slug: 'salvador-ze-1', + name: 'Salvador — ZE 1', + ibgeCode: '2927408', + kind: 'zona', +} as const +const salvadorZe2 = { + slug: 'salvador-ze-2', + name: 'Salvador — ZE 2', + ibgeCode: '2927408', + kind: 'zona', +} as const + describe('municipalityMapNavigation', () => { it('buildMunicipalitiesByIbgeCode groups municipalities by ibgeCode and sorts by name', () => { - const result = buildMunicipalitiesByIbgeCode([ - { slug: 'salvador-ze-2', name: 'Salvador ZE 2', ibgeCode: '2927408' }, - { slug: 'salvador-ze-1', name: 'Salvador ZE 1', ibgeCode: '2927408' }, - { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' }, - ]) + const result = buildMunicipalitiesByIbgeCode([salvadorZe2, salvadorZe1, abaira]) expect(result['2900108']).toEqual([{ slug: 'abaira', name: 'Abaíra' }]) expect(result['2927408']).toEqual([ - { slug: 'salvador-ze-1', name: 'Salvador ZE 1' }, - { slug: 'salvador-ze-2', name: 'Salvador ZE 2' }, + { slug: 'salvador-ze-1', name: 'Salvador — ZE 1' }, + { slug: 'salvador-ze-2', name: 'Salvador — ZE 2' }, ]) }) - it('resolveMunicipalityMapNavigation returns none when ibgeCode is missing or empty', () => { - const byIbge = buildMunicipalitiesByIbgeCode([ - { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' }, - ]) - - expect(resolveMunicipalityMapNavigation('9999999', byIbge)).toEqual({ kind: 'none' }) - expect(resolveMunicipalityMapNavigation('2900108', {})).toEqual({ kind: 'none' }) + it('keys zone municipalities by slug and everything else by codarea', () => { + expect(mapKeyForMunicipality(abaira)).toBe('2900108') + expect(mapKeyForMunicipality(salvadorZe1)).toBe('salvador-ze-1') }) - it('resolveMunicipalityMapNavigation returns navigate for a single municipality', () => { - const byIbge = buildMunicipalitiesByIbgeCode([ - { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' }, - ]) + it('gives every zone its own map key instead of pooling them under the city', () => { + const byMapKey = buildMunicipalitiesByMapKey([salvadorZe1, salvadorZe2, abaira]) - expect(resolveMunicipalityMapNavigation('2900108', byIbge)).toEqual({ - kind: 'navigate', - slug: 'abaira', + expect(Object.keys(byMapKey).sort()).toEqual(['2900108', 'salvador-ze-1', 'salvador-ze-2']) + expect(byMapKey['2927408']).toBeUndefined() + expect(byMapKey['salvador-ze-2']).toEqual({ + slug: 'salvador-ze-2', + name: 'Salvador — ZE 2', }) }) - it('resolveMunicipalityMapNavigation returns zones for multiple municipalities on the same ibgeCode', () => { - const byIbge = buildMunicipalitiesByIbgeCode([ - { slug: 'salvador-ze-1', name: 'Salvador ZE 1', ibgeCode: '2927408' }, - { slug: 'salvador-ze-2', name: 'Salvador ZE 2', ibgeCode: '2927408' }, - ]) + it('resolveMunicipalityMapNavigation returns none for an unknown or empty key', () => { + const byMapKey = buildMunicipalitiesByMapKey([abaira]) - expect(resolveMunicipalityMapNavigation('2927408', byIbge)).toEqual({ kind: 'zones' }) + expect(resolveMunicipalityMapNavigation('9999999', byMapKey)).toEqual({ kind: 'none' }) + expect(resolveMunicipalityMapNavigation('2900108', {})).toEqual({ kind: 'none' }) + }) + + it('resolveMunicipalityMapNavigation opens the unit behind the key, zone included', () => { + const byMapKey = buildMunicipalitiesByMapKey([abaira, salvadorZe1]) + + expect(resolveMunicipalityMapNavigation('2900108', byMapKey)).toEqual({ + kind: 'navigate', + slug: 'abaira', + }) + expect(resolveMunicipalityMapNavigation('salvador-ze-1', byMapKey)).toEqual({ + kind: 'navigate', + slug: 'salvador-ze-1', + }) }) }) From 7b70322118a27dbc45ef3665d634098a5c08184f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 23:25:42 -0300 Subject: [PATCH 2/2] Document B8 F2 and apply the simplify pass findings. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the Salvador zone delivery where the next agent will look for it — roadmap, plan as-built, notebook and the geometry/map sections of AGENTS.md — including the two measurements that are easy to re-derive wrongly (the bay water in Salvador's municipal polygon, and ZE 16 wrapping ZE 14, which is why neither an area ratio nor a centroid check is a valid overlap test). Simplify fixes: the build script re-indexes simplified geometries by a unique id instead of neighbourhood name, so a repeated name in a future mesh cannot silently collapse two polygons inside a committed artifact; a stale comment claiming zone polygons do not exist; and two int assertions that the Fase 0 catalog hydration turned tautological. Ledgers the Aikido outage: the scanner returns Opengrep exit 2 for any input, so the mandatory per-change scan was skipped, not passed. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 7 +-- AGENTS.md | 13 +++-- docs/TECH-DEBT.md | 1 + docs/plans/poligonos-pracas-zona.md | 53 +++++++++++++++---- docs/roadmap.md | 20 +++---- .../build-municipality-zone-geometries.mjs | 13 +++-- src/lib/municipalityProximity.ts | 11 ++-- .../municipalityZoneNeighborhoods.int.spec.ts | 5 +- 8 files changed, 84 insertions(+), 39 deletions(-) diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 946c6dee..8f2afdd1 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B8 F2 entregue em código (2026-07-26):** as 19 ZE de Salvador ganharam polígono próprio e o coroplético deixou de ser keyed por `codarea`. Antes, Salvador era **um** polígono carregando os votos da cidade inteira, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu" — a única visão por zona era a lista textual do `zoneBreakdown`. **Fase 0 (prep S1+S3):** `municipalityZoneNeighborhoods` passou a autorar só `{ municipalitySlug, source, neighborhoods }` e hidrata `city`/`zoneNumber` de `getMunicipalityCatalogEntry` no `entry()` (slug desconhecido ou não-`zona` falha no import); int novo de ordem de slugs vs catálogo. S2 (gerar TS do JSON) segue cortado — a curadoria ficou no script, não no catálogo. **Fase 1–2 (artefato):** `scripts/build-municipality-zone-geometries.mjs` (`pnpm build:municipality-zone-geometries`, `--report` só reconcilia) baixa/cacheia `BA_bairros_CD2022.zip` do geoftp do IBGE, parseia com **`shpjs`** (devDependency nova — reprojeta o SIRGAS 2000, o IBGE não publica GeoJSON de bairro), filtra `CD_MUN === '2927408'` (**170 polígonos**) e dissolve por zona pela RA 02/2017 do TRE-BA → `src/lib/geometries/bahia-municipality-zones.topo.json` (**72.310 bytes**, ~20 KB gzip, 19 features keyed por `municipalitySlug`), com submódulo `bahiaMunicipalityZoneGeometries.ts` + `loadMunicipalityZoneGeometryModule()` no padrão memoizado de B5 F1. **Reconciliação = 3 tabelas curadas no cabeçalho do script, não regex mais esperta:** `IBGE_TO_TRE_NEIGHBORHOOD` (7 aliases — compostos "Beiru/Tancredo Neves", CAB, ilhas), `TRE_NEIGHBORHOODS_MERGED_INTO` (Jardim Cruzeiro dentro de Vila Ruy Barbosa; o build **recusa** um merge que atravesse zonas) e `SPLIT_NEIGHBORHOOD_ZONES` (Periperi, que a resolução corta na Rua das Pedrinhas, vai **inteiro** para a ZE 17 — o bairro é o átomo geométrico). Atribuição em dois passes: nome da resolução, depois **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi) — a regra foi **validada** contra os 4 que a resolução nomeia sob outro rótulo e reproduz a zona oficial nos 4. O build morre se sobrar polígono órfão ou bairro TRE sem polígono (hoje 170/170 → 19/19). **Fase 3 (re-keying):** map key = `kind === 'zona' ? slug : ibgeCode` (`mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` em `municipalityMapNavigation.ts`); o bundle inteiro re-keyed (`territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`, `valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`); os rollups pararam de acumular com `+=` porque cada map key agora é 1:1; `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass` (um slug por chave); `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com o scroll-anchor e a copy "toque de novo" do readout. **`competitiveRank` continua por cidade** (o artefato TSE só tem `federalRankByIbgeCode`): as 19 zonas dividem a posição da cidade, o que a legenda já afirmava — int novo pina que as 19 têm **uma** posição só. Per-ZE segue fora (gatilho: produto pedir posição dentro da zona). `buildMunicipalitiesByIbgeCode` sobrevive intacto — o href de lista do B14 ✓ é por cidade. **Fase 4 (mapa):** `BahiaMap` monta a camada de município a partir de **duas** malhas via `loadLayerFeatures`, e o polígono municipal de Salvador fica desenhado **por baixo** como base sem dado e sem pointer (`baseKeys`), para que um sliver de desacordo entre duas malhas simplificadas independentemente não abra buraco no contorno do estado; `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo sumiu); `fitMapToHighlights` passou a medir os **paths desenhados** (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado. Copy do painel: a linha morta "Zonas não têm polígono oficial — Salvador e **Camaçari** aparecem agregadas" (Camaçari saiu do modelo de zona na M1) virou o disclaimer de aproximação. **Dois fatos medidos, para ninguém re-derivar:** o polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²** — a diferença é a água da baía que os limites municipais encerram e a malha de bairros não desenha, então o int de área compara contra a área de **terra** (280–360 km²) e não contra o município; e centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui, porque a ZE 16 é concava e envolve a ZE 14 (o centróide da 16 cai na 14) — a garantia de não-sobreposição é estrutural (cada bairro é atribuído a exatamente uma zona) mais a soma de áreas. Testes: int de contrato (19 features, slugs == catálogo, budget 600 KB, centróide de cada zona dentro do polígono municipal), unit de `featureMapKey`, e2e `campaignZoneMap.e2e.spec.ts` (436 paths / 435 interativos — o único não-interativo é a base de Salvador — readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`). **`/simplify`:** comentário de `NearbyMunicipalityResolution.zoneCity` que dizia "sem polígonos de zona (B8 F2)" corrigido (os polígonos passaram a existir nesta entrega; o card **deliberadamente** continua na lista filtrada — débito aberto, não efeito colateral); duas asserções do int de bairros que viraram tautologia com a hidratação da Fase 0 (comparavam `city`/`zoneNumber` com o catálogo de onde foram copiados) removidas, apontando para o teste de fixture que checa contra a transcrição independente; e o script parou de re-indexar geometrias simplificadas por **nome** de bairro (o irmão `build-bahia-geometries.mjs` usa `codarea`, único por construção) — hoje são 170 nomes distintos em 170 polígonos, mas um nome repetido em malha futura colapsaria dois polígonos silenciosamente num artefato commitado; passou a haver `id` próprio, e o rebuild saiu **byte-idêntico** (sha256 `1dd0d629…`). Sem migration, sem collection, sem action, sem Consent. **Gate:** tsc/lint/format/knip/`check:cycles`/unit+int/e2e/build rodados; **Aikido não fechou** — o scanner MCP responde Opengrep exit code 2 em **qualquer** entrada (inclusive um arquivo-probe trivial de uma linha), então os "4 findings" que ele reportou são artefato da execução quebrada e não achados atribuíveis; o feed do Aikido não tem nada deste repo. SCA do `shpjs` também pendente. Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). - **B39–B42 registrados (2026-07-26, lote roadmap-items):** sessão "Lembrar de mim" (**B39**, ~0,5d, não cortável — default hoje 2 h); login biométrico WebAuthn (**B40**, soft B39, cortável); scroll horizontal + sticky coluna Município (**B41**, espelha TerritoryList/B21); polimento mobile lista municípios (**B42** — Drawer Tendência/Assessores no padrão B26 + card compacto). Planos: `lembrar-de-mim-sessao-campanha.md`, `login-biometrico-campanha.md`, `scroll-horizontal-lista-municipios.md`, `polimento-mobile-lista-municipios.md`. Dedup do lote: drawers + card mobile mesclados em B42; auth fatiado B39/B40. - **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md). - **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md). @@ -18,7 +19,7 @@ alwaysApply: false - **B24 entregue em código (2026-07-26):** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios`. Tirou o botão "Salvar" (anti-goal do princípio 4) e o `trendNote` hidden; `MunicipalityListTrendControl` agora espelha a máquina de `MunicipalityListExpectedVotesControl` — draft/display/committed + AbortController + geração, status ~150 ms / nota 600 ms (timer cancelável), flush no blur e no fechamento do Popover, Badge otimista, `Textarea` "Justificativa" (`maxLength` 2000), `Alert` + live region `sr-only`, tooltip B23 lendo a nota exibida. Endpoint `POST /campanha/municipios/political-trend` (mesmo shell CSRF/JSON do B27: `isSameOriginRequest` + `campaignJsonMutationRoute`) chama `setMunicipalityPoliticalTrend` **sem** `revalidatePath` — faceta/`?trend=` reconciliam na próxima navegação. Lista/page deixam de passar `trendFormAction`; `/editar` mantém o form action multi-campo. Sem migration. Gate verde (tsc/lint/format/knip/check:cycles/479 unit/393 int/build); e2e B24 + prewarm da rota; Aikido 0 findings. Plano: [`autosave-tendencia-lista-municipios.md`](../../../docs/plans/autosave-tendencia-lista-municipios.md). - **B14 entregue em código (2026-07-26):** card "Onde estou" no Início staff. **A auditoria do plano derrubou sua decisão central antes de codar:** o plano de 24/07 tratava point-in-polygon como rabbit hole ("explode bundle") e escolhia um artefato commitado de centroides — mas `MunicipalityMapPanel` monta sempre em `mode="municipality"` e `BahiaMap` chama `loadMunicipalityGeometryModule()` no mount, então os 417 polígonos (132 KB crus, **41 KB gzip**, chunk próprio memoizado) já estão no browser de quem abre `/campanha`. Decidido com o usuário: usar polígonos. Saíram de escopo `pnpm build:municipality-centroids`, `src/lib/municipalityCentroids/` e o snapshot test — e o card passou a responder "o município **onde estou**" em vez de "o mais próximo". Centroide sobrevive só para ordenar o fallback. - **Decisão de produto do usuário nesta sessão:** assessor fisicamente fora da carteira é informado de onde está **e** recebe o município mais próximo que pode abrir com a distância; acima de 150 km a sugestão vira ruído e o card diz que não há nada por perto. Salvador continua sem chute de ZE (gatilho segue **B8 F2**): leva à lista filtrada, com a contagem de zonas limitada ao que o ator enxerga — um assessor com três zonas não é informado de dezenove. + **Decisão de produto do usuário nesta sessão:** assessor fisicamente fora da carteira é informado de onde está **e** recebe o município mais próximo que pode abrir com a distância; acima de 150 km a sugestão vira ruído e o card diz que não há nada por perto. Salvador continua sem chute de ZE: leva à lista filtrada, com a contagem de zonas limitada ao que o ator enxerga — um assessor com três zonas não é informado de dezenove. _(B8 F2 ✓ 2026-07-26 desenhou os polígonos das ZE, então resolver a zona exata passou a ser possível; o card **não** mudou — segue como débito com gatilho "pedido de campo".)_ **A medição pegou um custo invisível.** A primeira versão levou o First Load JS de `/campanha` de **265 → 286 kB**: o card chamava `buildMunicipalityListHref` para o link das zonas, e o serializador canônico arrasta `bahiaTerritories` + `municipalityCatalog` (que puxa códigos IBGE e zonas TSE) — **21 kB por um único link**. Corrigido serializando o href no servidor (`zoneCityHrefs`, chaveado por IBGE, uma entrada só quando o ator vê mais de uma zona da cidade): ficou em **268 kB**. O `/simplify` levou esse cálculo do loader `server-only` para `CampaignDashboard` (o RSC que já monta hrefs de lista, então o bundle não muda) e trocou a contagem manual pelo índice `buildMunicipalitiesByIbgeCode` que o painel do mapa já usa; `StaffDashboardView` deixou de carregar URL. A regra "client component não importa módulo de dado do servidor para obter valores" vale também para **serializadores de URL**, e a medição de baseline exigiu `git stash` — o número do B13 (265 kB) não servia como referência por si só. @@ -74,7 +75,7 @@ alwaysApply: false - **Discovery "Inteligência de campanha" aprovado (2026-07-21):** ciclo literatura→persona→entrevista sintética concluído e colaborado; embasamento canônico em [`docs/research/`](../../../docs/research/) (compêndio ~67 fontes, persona, relatório com kernel Rumelt + playbook de 25 padrões dado→decisão P/T/I/J/K + anti-goals). Programa de produto registrado no roadmap como **E8–E16 + B13 + C12** (+ adjacentes A11/E17 desde 2026-07-24; [plano-mestre `inteligencia-campanha.md`](../../../docs/plans/inteligencia-campanha.md)) — conta da cadeira, fila de alocação, registro-fundação (trajetória de pledge/sinais/decisões), classificação relativa, escala relativa no mapa, níveis N0–N4, motor de sugestões (humano no loop), camada TI, planejador de giros, backtest. Decisões de produto travadas: % estadual absoluto é anti-métrica; denominadores usam válidos projetados (nunca aptos); sugestão nunca decide sozinha; nível de envolvimento não vaza para fora do staff. - **B12 entregue em código (2026-07-21; deploy pendente com remodelagem):** `fitToKeys` + `interactiveKeys` no Mapa das Praças (`municipalitiesByIbgeCode`); `canonicalMapKeysKey`; hover stroke-only (fill inalterado); clear síncrono no mouseout; fit ao footprint filtrado sem re-zoom em Ano/Escala. Plano: [`aproximar-mapa-pracas.md`](../../../docs/plans/aproximar-mapa-pracas.md). - **B6 entregue em código (2026-07-21; deploy pendente com remodelagem):** `BahiaMap` setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers puros em `bahiaMapStyle.ts` + unit `bahiaMapStyle.unit.spec.ts`. Plano: [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md). Fecha débitos de perf de **B10** e **B11**. -- **B8 F1 entregue em código (2026-07-21; deploy pendente com remodelagem):** catálogo estático `municipalityZoneNeighborhoods` (21 Praças-zona SSA/CMS; Salvador = TRE-BA RA 02/2017; Camaçari = locais de votação + curadoria) + fixture `municipality-zone-neighborhoods.official.json` + int `municipalityZoneNeighborhoods.int.spec.ts`; card **Bairros desta Praça** na aba Visão geral de `/campanha/municipios/[slug]` quando `kind === 'zona'` (staff e líder). Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). **F2 pendente** (dissolve IBGE → TopoJSON + mapa). +- **B8 F1 entregue em código (2026-07-21; deploy pendente com remodelagem):** catálogo estático `municipalityZoneNeighborhoods` (21 Praças-zona SSA/CMS; Salvador = TRE-BA RA 02/2017; Camaçari = locais de votação + curadoria) + fixture `municipality-zone-neighborhoods.official.json` + int `municipalityZoneNeighborhoods.int.spec.ts`; card **Bairros desta Praça** na aba Visão geral de `/campanha/municipios/[slug]` quando `kind === 'zona'` (staff e líder). Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). **F2 entregue em 2026-07-26** (ver bullet no topo do Status). - **C8 F4 entregue em código (2026-07-21; deploy pendente com remodelagem):** DRY municipality staff form actions — `municipalityStaffFormActions.ts` (votos estimados, tendência, assessores) compartilhado entre lista e `/editar`; `editar/formActions.ts` mantém só `updateMunicipalityStrategy`. Plano: [`escala-dry-pos-c6.md`](../../../docs/plans/escala-dry-pos-c6.md). - **A9+ entregue em código (2026-07-21; deploy pendente com remodelagem):** loader compartilhado da lista — `loadMunicipalityListPageBundle` (1× `aggregatePledgesByMunicipality`), `buildMunicipalityMapBundleFromMunicipalities`, `municipalityRevalidation.ts` + hidden `municipalitySlug`; int `municipalityPageData.int.spec.ts`. Plano: [`escala-dry-pos-a9.md`](../../../docs/plans/escala-dry-pos-a9.md). - **B11 entregue em código (2026-07-21; deploy pendente com remodelagem):** escala **% dos válidos** no Mapa das Praças — seletor `Total (votos)` / `% dos válidos` (default %); domínio 0–100% fixo (`scaleMax` em `BahiaMap`); `validVotesByYear` no bundle; 2026 usa válidos 2022; compare desliga %; readout em % + votos absolutos; `Promise.all` nos loads TSE. Plano: [`escala-percentual-mapa-pracas.md`](../../../docs/plans/escala-percentual-mapa-pracas.md). @@ -217,4 +218,4 @@ alwaysApply: false - Backlog consolidado do produto (admin RBAC, mapa, eventos, demandas, apoiadores, listas globais, site, white-label): [`docs/roadmap.md`](../../../docs/roadmap.md). - **B7 entregue (2026-07-21):** mapa em `/campanha/municipios` usa `buildMunicipalityListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**. - **A9 / B6 / B9 / B7 / B10 / B11 / B8 F1 / filtros-auto entregues (2026-07-21; em produção desde 2026-07-23):** ver bullets no topo do Status. Próximos abertos: ver [`docs/roadmap.md`](../../../docs/roadmap.md) (compile 2026-07-24) — Janela 1: smoke pós-deploy + **R6** (+ **E8** em paralelo); Janela 3: **B8 F2** (polígonos das zonas de Salvador). -- **B8 registrado (2026-07-21); F1 entregue (2026-07-21):** polígonos das Praças-zona Salvador/Camaçari. F1: catálogo `municipalityZoneNeighborhoods` + card na Praça. F2 pendente: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). +- **B8 registrado (2026-07-21); F1 entregue (2026-07-21); F2 entregue (2026-07-26):** polígonos das Praças-zona Salvador/Camaçari (Camaçari saiu do escopo na M1). F1: catálogo `municipalityZoneNeighborhoods` + card na Praça. F2: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). diff --git a/AGENTS.md b/AGENTS.md index 9cb57f25..97b0226c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,12 +70,14 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e. +**Recently resolved (2026-07-26):** **B8 F2** Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be **one** polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual `zoneBreakdown`. Two halves shipped. **(1) The mesh:** a new committed artifact `src/lib/geometries/bahia-municipality-zones.topo.json` (72,310 bytes, ~20 KB gzip, 19 features keyed by `municipalitySlug`) built by `pnpm build:municipality-zone-geometries` — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that **the curation lives in the script, declared line by line with its evidence, and the build fails closed**: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes **whole** to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by **longest shared boundary**, and that rule was _validated_ against the 4 it names under another label before being trusted. **(2) The key:** the choropleth is no longer keyed by `codarea` but by a **map key** (`mapKeyForMunicipality`: slug for `kind === 'zona'`, `ibgeCode` otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with `+=` and `computeAggregateTerritorialClass` gave way to `computeMunicipalityTerritorialClass`; `resolveMunicipalityMapNavigation` became 1:1, and with it the `kind: 'zones'` branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. `buildMunicipalitiesByIbgeCode` survives untouched: B14's list href is per city on purpose. **Two measurements, so nobody re-derives them:** Salvador's municipal polygon is **~690 km²** against **~324 km²** for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a **land**-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is **not** a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e `campaignZoneMap.e2e.spec.ts` pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens `/campanha/municipios/salvador-ze-N`. No migration, no collection, no server action, no `Consent`. **Open debts with triggers (in the plan, not silent):** the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: [`docs/plans/poligonos-pracas-zona.md`](docs/plans/poligonos-pracas-zona.md). + **Recently resolved (2026-07-26):** **E12** Camada TI — extends the E17/B21 rollup (`territoryOverview.ts` + `loadTerritoryOverview.ts`) with E8 goal coverage (`central`), MAUP-safe regional capture (aggregate ratio Σ own votes ÷ Σ field ceiling — never the mean of municipal ratios; median, min–max, named critical municipality and beacon), and aggregated territorial class via `computeAggregateTerritorialClass` (same rule as B13 for TI groups). Staff surface: three new columns on `/campanha/territorios` (Cobertura, Captura, Classe) with B22 header descriptions and sorts `cobertura`/`captura`/`classe`; municipality detail gets intra-TI benchmark T4 on `MunicipalityGoalAccountCard` (`territoryIntraCaptureBenchmark.ts` — Salvador zone slugs compare among the 19 zones, other Metropolitano peers among RMS municipalities). Two E18 concepts (`captura-regional`, `benchmark-intra-ti`). v1 cuts kept: no `?group=territorio` on the municipality list, no TI map mode. No migration. Plano: [`docs/plans/camada-territorios-identidade.md`](docs/plans/camada-territorios-identidade.md). **Recently resolved (2026-07-26):** **B24** Auto-save + justificativa no popover de Tendência — `MunicipalityListTrendControl` grava sozinho via `POST /campanha/municipios/political-trend` (status ~150 ms, nota 600 ms, flush blur/fechamento), `Textarea` de justificativa no Popover, sem `revalidatePath` da lista; `/editar` mantém submit explícito. Plano: [`docs/plans/autosave-tendencia-lista-municipios.md`](docs/plans/autosave-tendencia-lista-municipios.md). -**Recently resolved (2026-07-26):** **B14** Município mais próximo (atalho geo no Início staff) — a new client island `NearestMunicipalityCard` ("Onde estou") answers "open the município I am standing in", and the answer is **point-in-polygon**, not a nearest-centroid guess. The source plan had rejected polygons as a bundle rabbit hole; the premise was false by the time it shipped, because `MunicipalityMapPanel` always mounts in `mode="municipality"` and `BahiaMap` calls `loadMunicipalityGeometryModule()` on mount, so the 417 polygons (132 KB raw, **41 KB gzip**, own memoized chunk) are already in the browser of anyone on `/campanha` — the card reuses that promise and adds no geometry. So **no centroid artifact, no build script, no migration, no collection, no server action and no `Consent` key**: the position is read by `navigator.geolocation` and matched in the browser, and never leaves the device. Pure math in `src/lib/municipalityProximity.ts` (haversine, ray casting with holes, area-weighted centroid, discriminated union `inScope | zoneCity | outOfScope | outsideBahia`; **measured, so nobody re-derives it:** the mesh decodes to 417 plain `Polygon`s with zero holes and zero MultiPolygons, a full containment scan costs **0.106 ms** and the whole fallback **0.32 ms** — 81% of it centroids — so there is no hot path here and a centroid artifact would still not pay for itself; building a fresh `Map` per call measured _slower_, and the module's existing `getMunicipalityFeature` index is passed in only because the map already built it); browser plumbing in `src/utilities/campaignGeolocation.ts` (sibling of `recentVisits.ts`: session flag `teqo:campaign:geo-prompted-session` so the automatic prompt fires at most once per tab, guarded permission read where Safari's missing descriptor legitimately answers `unknown`, typed failures). Centroid distance survives only to rank the fallback: an advisor standing outside his portfolio is told where he is **and** offered the closest município he can open, up to `NEAREST_IN_SCOPE_MAX_KM` = 150 km. Salvador still gets no guessed ZE (trigger stays **B8 F2**) — it links to the filtered list, with the zone count limited to what the actor can read. `StaffDashboardView` gained `accessibleMunicipalities` (~6.6 KB gzip of RSC payload for all 435), and the zone-list href reaches the island as a `zoneCityHrefs` prop serialized by `CampaignDashboard` (the RSC that already builds list hrefs) rather than by the Payload loader: the first version imported `buildMunicipalityListHref` into the island and cost **21 kB** of First Load JS for a single link, because the canonical serializer drags `bahiaTerritories` + `municipalityCatalog` in — the "client components must not import server data modules for values" rule applies to **URL serializers** too, so the href is serialized on the server. `/campanha` First Load JS: 265 → **268 kB** (measured against a stashed baseline, not against the number in a previous note). A coarse fix (accuracy > 10 km, typical of a desk) renders the result **with** an explicit "localização aproximada" caveat rather than silently naming the wrong município. The shortcuts row hides itself in **CSS** (`hidden … has-[>*]:grid`, written inline in `CampaignDashboard`): having the children report visibility upward cost two state slots, a callback prop, an effect and a layout shift on every load, because the server rendered the row hidden and the effect only unhid it after first paint — and with that state gone, the `DashboardShortcuts` wrapper was a named `div`, so it is gone too. `loadMunicipalityGeometryModule` memoizes the dynamic import's **rejection**, so the card catches it and falls into an actionable failure state — otherwise a ChunkLoadError left a spinner with a retry button that could never recover. Plano: [`docs/plans/municipio-mais-proximo.md`](docs/plans/municipio-mais-proximo.md). -**Recently resolved (2026-07-26):** **B13** Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales **relative to the candidate himself**, all as **discrete classes**: **quantis** (the new default), **padrão próprio (LQ)** and **posição no município**, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in `src/lib/mapScaleClasses.ts` (new file — `bahiaMapStyle.ts` already owned path styling); `resolvePathStyle` gained a `fillByKey` path that **replaces** the continuous ramp when present, where a missing key is "no data", which is not a value of zero. **LQ is 5 named classes cut on the E10 anchors, not diverging** — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. `TERRITORIAL_CLASS_ANCHORS` / `AT_STANDARD_LQ` moved to `src/lib/territorialClassAnchors.ts` because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (**E15**) stays a one-line diff in one file. Quantiles are computed over the **user's access scope** (435 for coordinator/candidate, the portfolio for an advisor) and **degrade to 3 classes below 10 municípios**, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the **committed artifact (v3)** rather than a request-time query — see "Election baseline data" above. New in E10: `computeAggregateTerritorialClass(slugs)` classes a GROUP as one territory by summing inputs and running the **same** `classifyMunicipalityTerritory`, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — **E12's TI rollup must inherit this helper instead of growing a second one**. The map loader fills `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` and `projectedValidVotesByCode`, all pure over the artifact: **no new query per request**. UI: discrete `MapScaleLegend` with real ranges tied to the selector via `aria-describedby`, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, `interactive: false` so the bubble does not stack a second hover target on its own município) — 417 markers were **measured** before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in `/campanha/conceitos`. No migration, no collection, no server action; `/campanha`'s First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own `ssr: false` chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by `competitiveRankByYear`. **Known flake (pre-existing, reproduced on a clean tree):** `campaignMunicipalities.e2e.spec.ts` fails intermittently with 2 workers — the `LeaderContactsPanel` consent checkbox clicked before hydration, and a `getByRole` regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: [`docs/plans/escala-relativa-mapa.md`](docs/plans/escala-relativa-mapa.md). +**Recently resolved (2026-07-26):** **B14** Município mais próximo (atalho geo no Início staff) — a new client island `NearestMunicipalityCard` ("Onde estou") answers "open the município I am standing in", and the answer is **point-in-polygon**, not a nearest-centroid guess. The source plan had rejected polygons as a bundle rabbit hole; the premise was false by the time it shipped, because `MunicipalityMapPanel` always mounts in `mode="municipality"` and `BahiaMap` calls `loadMunicipalityGeometryModule()` on mount, so the 417 polygons (132 KB raw, **41 KB gzip**, own memoized chunk) are already in the browser of anyone on `/campanha` — the card reuses that promise and adds no geometry. So **no centroid artifact, no build script, no migration, no collection, no server action and no `Consent` key**: the position is read by `navigator.geolocation` and matched in the browser, and never leaves the device. Pure math in `src/lib/municipalityProximity.ts` (haversine, ray casting with holes, area-weighted centroid, discriminated union `inScope | zoneCity | outOfScope | outsideBahia`; **measured, so nobody re-derives it:** the mesh decodes to 417 plain `Polygon`s with zero holes and zero MultiPolygons, a full containment scan costs **0.106 ms** and the whole fallback **0.32 ms** — 81% of it centroids — so there is no hot path here and a centroid artifact would still not pay for itself; building a fresh `Map` per call measured _slower_, and the module's existing `getMunicipalityFeature` index is passed in only because the map already built it); browser plumbing in `src/utilities/campaignGeolocation.ts` (sibling of `recentVisits.ts`: session flag `teqo:campaign:geo-prompted-session` so the automatic prompt fires at most once per tab, guarded permission read where Safari's missing descriptor legitimately answers `unknown`, typed failures). Centroid distance survives only to rank the fallback: an advisor standing outside his portfolio is told where he is **and** offered the closest município he can open, up to `NEAREST_IN_SCOPE_MAX_KM` = 150 km. Salvador still gets no guessed ZE — it links to the filtered list, with the zone count limited to what the actor can read. `StaffDashboardView` gained `accessibleMunicipalities` (~6.6 KB gzip of RSC payload for all 435), and the zone-list href reaches the island as a `zoneCityHrefs` prop serialized by `CampaignDashboard` (the RSC that already builds list hrefs) rather than by the Payload loader: the first version imported `buildMunicipalityListHref` into the island and cost **21 kB** of First Load JS for a single link, because the canonical serializer drags `bahiaTerritories` + `municipalityCatalog` in — the "client components must not import server data modules for values" rule applies to **URL serializers** too, so the href is serialized on the server. `/campanha` First Load JS: 265 → **268 kB** (measured against a stashed baseline, not against the number in a previous note). A coarse fix (accuracy > 10 km, typical of a desk) renders the result **with** an explicit "localização aproximada" caveat rather than silently naming the wrong município. The shortcuts row hides itself in **CSS** (`hidden … has-[>*]:grid`, written inline in `CampaignDashboard`): having the children report visibility upward cost two state slots, a callback prop, an effect and a layout shift on every load, because the server rendered the row hidden and the effect only unhid it after first paint — and with that state gone, the `DashboardShortcuts` wrapper was a named `div`, so it is gone too. `loadMunicipalityGeometryModule` memoizes the dynamic import's **rejection**, so the card catches it and falls into an actionable failure state — otherwise a ChunkLoadError left a spinner with a retry button that could never recover. Plano: [`docs/plans/municipio-mais-proximo.md`](docs/plans/municipio-mais-proximo.md). +**Recently resolved (2026-07-26):** **B13** Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales **relative to the candidate himself**, all as **discrete classes**: **quantis** (the new default), **padrão próprio (LQ)** and **posição no município**, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in `src/lib/mapScaleClasses.ts` (new file — `bahiaMapStyle.ts` already owned path styling); `resolvePathStyle` gained a `fillByKey` path that **replaces** the continuous ramp when present, where a missing key is "no data", which is not a value of zero. **LQ is 5 named classes cut on the E10 anchors, not diverging** — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. `TERRITORIAL_CLASS_ANCHORS` / `AT_STANDARD_LQ` moved to `src/lib/territorialClassAnchors.ts` because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (**E15**) stays a one-line diff in one file. Quantiles are computed over the **user's access scope** (435 for coordinator/candidate, the portfolio for an advisor) and **degrade to 3 classes below 10 municípios**, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the **committed artifact (v3)** rather than a request-time query — see "Election baseline data" above. New in E10: `computeAggregateTerritorialClass(slugs)` classes a GROUP as one territory by summing inputs and running the **same** `classifyMunicipalityTerritory`, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — **E12's TI rollup must inherit this helper instead of growing a second one**. The map loader fills `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` and `projectedValidVotesByCode`, all pure over the artifact: **no new query per request**. _(B8 F2 later re-keyed the last two to `territorialClassByMapKey` / `projectedValidVotesByMapKey`.)_ UI: discrete `MapScaleLegend` with real ranges tied to the selector via `aria-describedby`, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, `interactive: false` so the bubble does not stack a second hover target on its own município) — 417 markers were **measured** before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in `/campanha/conceitos`. No migration, no collection, no server action; `/campanha`'s First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own `ssr: false` chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by `competitiveRankByYear`. **Known flake (pre-existing, reproduced on a clean tree):** `campaignMunicipalities.e2e.spec.ts` fails intermittently with 2 workers — the `LeaderContactsPanel` consent checkbox clicked before hydration, and a `getByRole` regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: [`docs/plans/escala-relativa-mapa.md`](docs/plans/escala-relativa-mapa.md). **Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md). @@ -155,7 +157,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k - **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`). - **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate. - **Dobradinhas:** `stateDeputy` entity + `municipality.stateDeputies` / `leadership.stateDeputies` hasMany; UI at `/campanha/dobradinhas`. -- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only. Scales (B13): **quantis** (default), **padrão próprio (LQ)**, **posição no município**, **% dos válidos** (B11) and **Total (votos)**. The three relative ones are **discrete classes, never a continuous ramp** — the pure math lives in `src/lib/mapScaleClasses.ts` (quantiles degrade to 3 classes below 10 municípios and the legend says so; LQ is cut on the E10 anchors in `src/lib/territorialClassAnchors.ts`). Diverging red×blue stays exclusive to the compare mode, where it means "Solla × adversário" — do not reuse it for LQ. Quantiles are computed over the **user's access scope**, so an advisor sees his portfolio classed against itself. Rank is unavailable in 2026 (TSE data) and LQ in 2026 reuses the 2022 statewide standard, the same precedent `percentValid` set. An optional proportional-symbol layer sizes bubbles by projected valid votes (area ∝ value) and colours them by the E10 class. A class label never renders alone: the readout always spells out the reason. +- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only. **Keyed by "map key", not `ibgeCode`** (B8 F2, 2026-07-26): `mapKeyForMunicipality` returns the `municipalitySlug` for `kind === 'zona'` and the `ibgeCode` otherwise, so each of Salvador's 19 ZE is painted, hovered and opened on its own from `bahia-municipality-zones.topo.json`. Every bundle field follows that key (`municipalitiesByMapKey`, `territorialClassByMapKey`, `projectedValidVotesByMapKey`, `valuesByYear`, …) and each key maps to exactly one catalog entry, so rollups assign instead of accumulating. Salvador's **municipal** polygon is still drawn, underneath, as a non-interactive base with no data (`baseKeys` in `BahiaMap`) — two independently simplified meshes do not close on the same coastline, and deleting it opened a gap in the state outline; being out of the key index, it cannot double-count or steal a hover. Feature→key resolution is `featureMapKey` (`municipalitySlug ?? codarea ?? code`). **`competitiveRank` is still per city:** the TSE artifact only carries `federalRankByIbgeCode`, so all 19 zones share the city's placement — which is what the legend already claims, and an int test pins it. Scales (B13): **quantis** (default), **padrão próprio (LQ)**, **posição no município**, **% dos válidos** (B11) and **Total (votos)**. The three relative ones are **discrete classes, never a continuous ramp** — the pure math lives in `src/lib/mapScaleClasses.ts` (quantiles degrade to 3 classes below 10 municípios and the legend says so; LQ is cut on the E10 anchors in `src/lib/territorialClassAnchors.ts`). Diverging red×blue stays exclusive to the compare mode, where it means "Solla × adversário" — do not reuse it for LQ. Quantiles are computed over the **user's access scope**, so an advisor sees his portfolio classed against itself. Rank is unavailable in 2026 (TSE data) and LQ in 2026 reuses the 2022 statewide standard, the same precedent `percentValid` set. An optional proportional-symbol layer sizes bubbles by projected valid votes (area ∝ value) and colours them by the E10 class. A class label never renders alone: the readout always spells out the reason. - **URLs:** `/campanha/municipios[/slug]`, `/campanha/dobradinhas`, `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/atividades[/slug]`, `/campanha/apoiadores`, `/campanha/assessores` (coordinator/candidate), `/campanha/conceitos` (staff glossary), `/campanha/perfil`. ## Campaign supporters (C2) @@ -182,10 +184,11 @@ Mobilization events / agenda backing the campaign's field operations (roadmap it Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3). No migration, collection, Consent, or server action — same static-data pattern as `bahiaTerritories` / `bahiaTseZones`. -- **Artifacts (committed):** `src/lib/geometries/bahia-municipalities.topo.json` (~132 KB; Topology object `municipalities`, 417 features, `properties: { codarea, name }`) and `src/lib/geometries/bahia-identity-territories.topo.json` (~15 KB; object `territories`, 27 features, `properties: { code, name }`). Soft size budget in tests: ≤ ~600 KB each. +- **Artifacts (committed):** `src/lib/geometries/bahia-municipalities.topo.json` (~132 KB; Topology object `municipalities`, 417 features, `properties: { codarea, name }`), `src/lib/geometries/bahia-identity-territories.topo.json` (~15 KB; object `territories`, 27 features, `properties: { code, name }`) and — since B8 F2 (2026-07-26) — `src/lib/geometries/bahia-municipality-zones.topo.json` (~72 KB; object `municipalityZones`, **19** features, `properties: { municipalitySlug, name, zoneNumber, ibgeCode }`), the Salvador ZE mesh. Soft size budget in tests: ≤ ~600 KB each. - **Code table:** `src/lib/bahiaMunicipalityCodes.ts` — canonical municipality name → IBGE 7-digit `codarea`, plus `codeForMunicipality` / `municipalityForCode`. Fixture `tests/fixtures/bahia-municipality-codes.official.json` + int tests for bijection/coverage. -- **Runtime helpers:** `src/lib/bahiaGeometries.ts` — `getMunicipalityFeature` / `getTerritoryFeature`, typed topologies, feature arrays. TopoJSON→GeoJSON decode is LAZY (dynamic import via `loadMunicipalityGeometryModule`; B5 F1 done) — the default `/campanha` server graph never pays it. +- **Runtime helpers:** `src/lib/bahiaGeometries.ts` — `getMunicipalityFeature` / `getTerritoryFeature` / `getMunicipalityZoneFeature`, typed topologies, feature arrays. TopoJSON→GeoJSON decode is LAZY (dynamic import via `loadMunicipalityGeometryModule` / `loadMunicipalityZoneGeometryModule`; B5 F1 done) — the default `/campanha` server graph never pays it. Shared feature types live in `bahiaGeometriesTypes.ts`; geometry math that must accept any of the three meshes (point-in-polygon, centroid) takes `BahiaGeometryFeature`. - **Territory polygons:** dissolved from IBGE municipality meshes via `bahiaIdentityTerritoryRecords` (`topojson` `merge`), not the IDE Bahia shapefile (IDE remains provenance/validation reference only). +- **Rebuild (Salvador zones):** `pnpm build:municipality-zone-geometries` (`scripts/build-municipality-zone-geometries.mjs`; `--report` reconciles without writing) downloads IBGE `BA_bairros_CD2022.zip` from geoftp, parses the shapefile with **`shpjs`** (devDependency — that cut is SHP-only, SIRGAS 2000), keeps `CD_MUN === '2927408'` (170 polygons) and dissolves neighbourhood→zone against **TRE-BA RA 02/2017** (`municipalityZoneNeighborhoods.ts`). Reconciliation is **curated data in the script header**, not cleverer matching: an alias table, a table for TRE neighbourhoods with no polygon of their own (the build refuses a merge that would cross zones), and a table for the one neighbourhood the resolution splits mid-street (it goes whole to one ZE — the neighbourhood is the atom). The 5 polygons the resolution never names are assigned by **longest shared boundary**, a rule validated against the 4 it names under another label. The build fails on an orphan polygon or a TRE neighbourhood with no polygon, so 170/170 → 19/19 is an invariant, not a hope. Same cache dir and the same "**does not touch the database**" guarantee as the sibling below. - **Rebuild:** `pnpm build:geometries` (`scripts/build-bahia-geometries.mjs`) downloads IBGE Malhas (qualidade intermediaria) + Localidades, reconciles names with `canonicalizeMunicipalityName`, simplifies/quantizes, emits the artifacts above. Cache under `data/geometries/` (gitignored; override with `GEOMETRIES_CACHE_DIR`). **Does not touch the database** — no `assertLocalDatabase` / no Neon risk. Not part of `pnpm build` or `pnpm dev`. Details: `docs/plans/mapa-bahia-geometrias.md`. Scale/DRY follow-ups from `/simplify`: `docs/plans/escala-dry-pos-b2.md` (B5). ## Election baseline data (TSE 2022) diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index 865d6b47..9638ba93 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -8,6 +8,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | | 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b | | knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | | +| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | open 2026-07-26 (found during B8 F2) | | | Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | | | `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 | | Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | | diff --git a/docs/plans/poligonos-pracas-zona.md b/docs/plans/poligonos-pracas-zona.md index f9360fd0..597a0348 100644 --- a/docs/plans/poligonos-pracas-zona.md +++ b/docs/plans/poligonos-pracas-zona.md @@ -1,7 +1,8 @@ # B8 — Polígonos dos Municípios-zona de Salvador (ZE 1–19) -Status: F1 entregue em código (2026-07-21; em produção desde 2026-07-23); F2 pendente -Atualizado em: 2026-07-24 +Status: F1 entregue em código (2026-07-21; em produção desde 2026-07-23); **F2 entregue em código (2026-07-26)** — deploy pendente +Atualizado em: 2026-07-26 +Revisão 2026-07-26 (F2 entregue): as duas questões em aberto foram fechadas na implementação (malha **IBGE 2022**, sem pivô para GeoSalvador; chave = **híbrido A+B** — `municipalitySlug` nas zonas, `ibgeCode` no resto, com o polígono municipal de Salvador **mantido como base sem interação** em vez de removido). As-built completo na seção "F2 as-built" abaixo; débitos novos em "Adiado com gatilho". Revisão 2026-07-24 (remodelagem Municípios M1): **Camaçari saiu do escopo** — virou município inteiro (ZE 170/171 agregadas), e as entradas de Camaçari foram removidas do catálogo (`MunicipalityZoneNeighborhoodCity = 'Salvador'`; fonte única `tre-ra-02-2017`). B8 agora cobre **só as 19 zonas de Salvador**. Identificadores renomeados `plaza*` → `municipality*` na M1. As menções a Camaçari abaixo foram mantidas apenas como histórico de pesquisa. Revisão 2026-07-21: F1 implementada — `municipalityZoneNeighborhoods.ts`, fixture+int, `MunicipalityZoneNeighborhoodsCard` no overview do Município-zona; mapa inalterado (F2). Revisão 2026-07-21 (pós-`/simplify` + capture-review-debts): débitos S1–S3 absorvidos como **F2 prep** (~½ dia, dentro do appetite F2); S4–S10 em Explicitamente fora / Adiado; JR1–JR7 em Já resolvido (não reabrir no simplify). @@ -58,10 +59,10 @@ _Histórico Camaçari (fora de escopo desde M1):_ não havia lista oficial zona - **Polígonos = dissolve de bairros (IBGE e/ou malha municipal), não geocodificar seções.** Mesmo padrão de dissolução já usado em TIs (B2). Gaps: polígono manual leve ou omitir pedaço com nota. **Rejeitado:** geocodificar milhares de seções → casco; pedir shapefile ao TRE neste ciclo; PostGIS. - **i18n e naming** (AGENTS.md): identificadores `municipalityZoneNeighborhoods` (existente), `build-municipality-zone-geometries.mjs`, `bahia-municipality-zones.topo.json`, `getMunicipalityZoneFeature(slug)`; strings UI em pt-BR. -## Questões em aberto +## Questões em aberto — fechadas na F2 (2026-07-26) -- **Malha de bairros: IBGE 2022 vs GeoSalvador?** **Opções:** A) só IBGE BA | B) GeoSalvador. **Recomendação:** A na F2 com reconciliação de nomes TRE↔`NM_BAIRRO`; se cobertura de Salvador for insuficiente no spike de ½ dia, pivotar B. Validar no spike antes de lock de artefato. -- **Chave do coroplético no mapa: `municipalitySlug` ou continuar `ibgeCode` + overlay?** **Opções:** A) mode/`values` por `municipalitySlug` para Municípios-zona + município para o resto | B) split Salvador em features de zona substituindo o polígono municipal. **Recomendação:** B no footprint de Salvador (um polígono por Município-zona; o polígono municipal some lá) e A/`ibgeCode` no restante da Bahia — evita dupla contagem visual. _(assumido — validar no craft)_ +- **Malha de bairros: IBGE 2022 vs GeoSalvador?** → **A (IBGE `BA_bairros_CD2022`)**, sem pivô. O spike deu **170/170** polígonos de Salvador atribuídos e **19/19** zonas cobertas; os unmatched críticos que disparariam GeoSalvador (>10% dos bairros TRE sem polígono) não apareceram. Custo: `shpjs` como devDependency, porque o IBGE só publica SHP (SIRGAS 2000) nesse recorte. +- **Chave do coroplético: `municipalitySlug` ou `ibgeCode` + overlay?** → **híbrido A+B**, com um ajuste sobre a recomendação: o polígono municipal de Salvador **não sumiu**, ficou desenhado por baixo como **base sem dado e sem pointer**. Duas malhas simplificadas de forma independente não fecham na mesma linha de costa, e apagar o polígono municipal abria sliver de fundo no contorno do estado. A base não entra no índice de chaves, então não há dupla contagem visual nem alvo duplo de hover — o risco que a recomendação original queria evitar. ## Abordagem proposta @@ -88,11 +89,11 @@ Componentes: ### Fases -| Fase | Entrega | Appetite | -| ------- | ------------------------------------------------------------------------------ | ------------- | -| F1 ✓ | Catálogo + testes + bloco no município (entregue; Salvador-only desde M1) | ~1 dia | -| F2 prep | Hidratação via `municipalityCatalog` + canônico JSON→TS + teste ordem de slugs | ~½ dia | -| F2 | Spike cobertura malha → script → TopoJSON → mapa | ~1,5–2,5 dias | +| Fase | Entrega | Appetite | +| --------- | ------------------------------------------------------------------------------------------- | ------------- | +| F1 ✓ | Catálogo + testes + bloco no município (entregue; Salvador-only desde M1) | ~1 dia | +| F2 prep ✓ | Hidratação via `municipalityCatalog` + teste ordem de slugs (S1+S3; **S2 cortado**, abaixo) | ~½ dia | +| F2 ✓ | Spike cobertura malha → script → TopoJSON → mapa (entregue 2026-07-26) | ~1,5–2,5 dias | **F2 prep** (débitos pós-`/simplify`, capture-review-debts 2026-07-21 — não item paralelo no roadmap): @@ -122,6 +123,30 @@ Componentes: - **Desenhar 19 polígonos à mão no QGIS sem catálogo.** Irrepetível e sem teste. **Mitigação:** F1 obrigatória (feita); manual só para unmatched. - **Tratar polígono derivado como limite oficial TSE.** Risco jurídico/comunicação. **Mitigação:** copy “aproximação a partir de bairros; não é limite oficial”. +## F2 as-built (2026-07-26) + +**Artefato.** `src/lib/geometries/bahia-municipality-zones.topo.json` — **72.310 bytes** (~20 KB gzip; budget de 600 KB do padrão B2 sobrou inteiro), objeto `municipalityZones`, 19 features com `properties: { municipalitySlug, name, zoneNumber, ibgeCode }`. Gerado por `pnpm build:municipality-zone-geometries` (`scripts/build-municipality-zone-geometries.mjs`, flag `--report` para só reconciliar sem escrever), que baixa/cacheia `BA_bairros_CD2022.zip` em `data/geometries/` (gitignored), parseia com `shpjs`, filtra `CD_MUN === '2927408'`, simplifica no quantil 0.35, dissolve por zona com `topojson.merge` e quantiza em 1e4 — mesma ordem do irmão `build-bahia-geometries.mjs`, e como ele **não toca o banco**. Consumo lazy por `loadMunicipalityZoneGeometryModule()` (`bahiaMunicipalityZoneGeometries.ts`), no padrão memoizado de B5 F1: o grafo default do `/campanha` não paga o decode. + +**Reconciliação (a parte que não é código).** Três tabelas curadas no cabeçalho do script, cada linha com a evidência: + +- `IBGE_TO_TRE_NEIGHBORHOOD` — 7 aliases de grafia/composição (`Beiru/Tancredo Neves`, CAB, ilhas da baía). +- `TRE_NEIGHBORHOODS_MERGED_INTO` — bairro da resolução sem polígono próprio no IBGE (Jardim Cruzeiro dentro de Vila Ruy Barbosa). O build **recusa** um merge cujos dois lados caiam em zonas diferentes. +- `SPLIT_NEIGHBORHOOD_ZONES` — Periperi, que a RA 02/2017 corta na Rua das Pedrinhas entre duas zonas, vai **inteiro** para a ZE 17: o bairro é o átomo geométrico disponível, e partir polígono por descrição de rua era o rabbit hole que este plano já recusava. + +Atribuição em dois passes: (1) nome citado pela resolução; (2) **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi). O passe 2 foi **validado**, não assumido: rodado sobre os 4 bairros que a resolução nomeia sob outro rótulo, ele reproduz a zona oficial nos 4. O build falha se sobrar polígono órfão ou bairro TRE sem polígono — hoje 170/170 → 19/19. + +**Re-keying (o que mudou fora do mapa).** Map key = `kind === 'zona' ? municipalitySlug : ibgeCode`, via `mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` (`municipalityMapNavigation.ts`). O bundle inteiro passou a ser keyed por ela (`valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`, `territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`); os rollups pararam de acumular com `+=` porque cada chave é agora 1:1, e por isso `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass`. `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com a copy "toque de novo para ver as zonas" e o scroll-anchor do painel. `buildMunicipalitiesByIbgeCode` sobrevive intacto: o href de lista do **B14 ✓** é por cidade, de propósito. + +**Rank competitivo continua por cidade.** O artefato TSE (v3) só tem `federalRankByIbgeCode`, então as 19 zonas herdam a posição da cidade inteira — exatamente o que a legenda da escala "posição no município" já afirma. Int novo pina que as 19 compartilham **uma** posição, para que a limitação seja um invariante testado e não uma surpresa. Rank por ZE exigiria uma dimensão nova no artefato (gatilho abaixo). + +**Mapa.** `BahiaMap` monta a camada de município a partir de duas malhas (`loadLayerFeatures`), com o polígono municipal de Salvador desenhado por baixo como base sem dado e sem pointer (`baseKeys`). `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo era supérfluo); `fitMapToHighlights` passou a medir os paths desenhados (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado. + +**Dois fatos medidos (não re-derivar).** (1) O polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²**: a diferença é a água da baía que o limite municipal encerra e a malha de bairros não desenha. Por isso o int de área compara contra a área de **terra** (280–360 km²) e não contra o município — a primeira versão do teste, que exigia ≥95% de razão, estava medindo terra contra terra+água. (2) Centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui: a ZE 16 é concava e envolve a ZE 14, então o centróide da 16 cai legitimamente na 14. A não-sobreposição é garantida estruturalmente (cada bairro entra em exatamente uma zona, verificado no build) mais a soma de áreas. + +**Testes.** Int de contrato do artefato (19 features, slugs == catálogo `kind === 'zona'`, budget, centróide de cada zona dentro do polígono municipal de Salvador, área somada); int de rank compartilhado; unit de `featureMapKey` e de `mapKeyForMunicipality`/`buildMunicipalitiesByMapKey`; e2e `campaignZoneMap.e2e.spec.ts` — 436 paths / 435 interativos (o único não-interativo é a base de Salvador), readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`. + +**Gate.** tsc, lint, format, knip (P3 pré-existente em `payload.config.ts`), `check:cycles`, unit+int, e2e e build rodados. **Aikido não fechou:** o scanner responde Opengrep exit code 2 em qualquer entrada, inclusive num arquivo-probe trivial de uma linha, então os achados que ele reportou são artefato da execução quebrada e não são atribuíveis; o feed do Aikido não lista nada deste repo. SCA do `shpjs` (devDependency nova) também pendente — ambos registrados como débito abaixo. + ## Já resolvido no simplify/critique (não reabrir) - **JR1** — `MunicipalityZoneNeighborhoodsCard`: um lookup `municipalityZoneNeighborhoodEntryForSlug` (sem duplo hit no `entryBySlug`). @@ -132,6 +157,9 @@ Componentes: - **JR6** — cobertura usa `zoneMunicipalities.length` em vez de constante fixa. - **JR7** — _(histórico)_ exclusividade de Camaçari sem teste — obsoleto: Camaçari saiu do catálogo na M1. - Impeccable compacto na F1: sem snapshot `.impeccable/critique/` nem P0–P3 abertos nesta superfície; polish visual amplo → **R6**. +- **JR8** _(F2)_ — comentário de `NearbyMunicipalityResolution.zoneCity` que afirmava não existirem polígonos de zona: corrigido para dizer que existem e que o card **deliberadamente** não os usa. +- **JR9** _(F2)_ — duas asserções do int de bairros que a hidratação da Fase 0 tornou tautológicas (comparavam `city`/`zoneNumber` com o catálogo de onde acabaram de ser copiados) removidas; a verificação real segue no `deepEqual` contra o fixture transcrito à mão. +- **JR10** _(F2)_ — o script re-indexava geometrias simplificadas por **nome** de bairro; passou a usar `id` próprio (o irmão usa `codarea`, único por construção). Hoje são 170 nomes distintos, mas um nome repetido em malha futura colapsaria dois polígonos em silêncio dentro de um artefato commitado. Rebuild saiu byte-idêntico. ## Explicitamente fora (skips `/simplify` + descartes do triage) @@ -144,7 +172,10 @@ Componentes: ## Adiado com gatilho -- **S6 — CSS compartilhado lista zona** (`MunicipalityMapPanel` zone breakdown ↔ `MunicipalityZoneNeighborhoodsCard`). **Gatilho:** F2 alterar `MunicipalityMapPanel`/`BahiaMap` ou R6 citar duplicação visual em ≥2 superfícies de zona. +- **S6 — CSS compartilhado lista zona** (`MunicipalityMapPanel` zone breakdown ↔ `MunicipalityZoneNeighborhoodsCard`). **Gatilho disparou** na F2 (o painel foi alterado) e foi reavaliado: as duas listas continuam pequenas e com estrutura diferente, então extrair peça compartilhada agora seria abstração por coincidência visual. Segue adiado, agora com gatilho único: **R6** citar a duplicação em ≥2 superfícies de zona. +- **F2-D1 — card "Onde estou" (B14 ✓) resolver a ZE exata de Salvador.** Os polígonos passaram a existir nesta entrega, e `featureContainsPoint` já é genérico o suficiente para recebê-los; o card continua levando à lista filtrada por decisão. **Gatilho:** pedido de campo ("estou em Salvador e quero abrir minha zona"). Custo real é uma segunda malha no chunk do card, não matemática nova. Aresta já registrada no grafo do roadmap. +- **F2-D2 — rank competitivo por ZE.** Exige dimensão nova no artefato TSE (hoje `federalRankByIbgeCode`), o que mexe no budget de 700 KB. **Gatilho:** E11 pedir o eixo de competição dentro de Salvador, ou o dossiê de um Município-zona exibir posição própria. Hoje as 19 zonas herdam a posição da cidade, com int pinando o fato. +- **S2 — fonte única TS ↔ fixture do catálogo de bairros** (gerar `municipalityZoneNeighborhoods.ts` a partir do JSON). **Cortado na F2 prep:** a curadoria que a F2 exigia foi para o **script** de geometrias, não para o catálogo, então o gerador não teria a quem servir; o `deepEqual` fixture↔código já pega drift. **Gatilho:** um terceiro consumidor precisar do catálogo em runtime não-TS. - **Malha GeoSalvador em vez de IBGE.** Revisitar quando: spike F2 mostrar unmatched críticos em Salvador (>10% bairros TRE sem polígono). - **Camada ZE estadual (B4 antigo).** Revisitar quando: produto pedir mapa de ZE fora de Salvador (improvável neste ciclo). diff --git a/docs/roadmap.md b/docs/roadmap.md index 727e6334..ba728fd6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)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). +Atualizado em: 2026-07-26 (**B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)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). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -58,6 +58,8 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( - **B38 (2026-07-26)** — sidebar recolhido em tablet: `CampaignSidebar` com `collapsible="offcanvas"`, `SidebarTrigger` no inset `md+`, `defaultOpen` a partir do cookie `sidebar_state`, colapso padrão 768–1023 via `CampaignSidebarViewportDefault` quando não há cookie; mobile Sheet inalterado. Plano: [sidebar-recolhido-tablet.md](plans/sidebar-recolhido-tablet.md). - **B13 (2026-07-26)** — escala relativa no mapa do Início: o coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71%. Três escalas relativas em classes discretas — **quantis** (novo default, degradando para 3 classes com escopo < 10 municípios e declarando isso na legenda), **padrão próprio (LQ)** nas mesmas âncoras do E10 e **posição no município** — mais uma camada opcional de bolhas por válidos projetados, coloridas pela classe do E10. "Posição no município" não existia em lugar nenhum: entrou no **artefato TSE commitado (v3)** como `federalRankByIbgeCode` (colocação entre todos os candidatos com votos na geografia, 2014/2018/2022; 534 → 623 KB, orçamento 700 KB), não em query de request. `computeAggregateTerritorialClass` classifica um grupo de slugs como um território só (o mapa é por `ibgeCode` e Salvador são 19 slugs) somando insumos e rodando o **mesmo** classificador — o E12 herda o helper. As âncoras do E10 saíram para `src/lib/territorialClassAnchors.ts` para não arrastar o artefato ao bundle do cliente; a matemática das escalas vive em `src/lib/mapScaleClasses.ts`. Legenda discreta com faixas reais, readout que nunca mostra a classe sem o porquê, dois conceitos novos em `/campanha/conceitos`. Rank indisponível em 2026 (dado TSE); sem migration; bundle client de `/campanha` inalterado (o payload RSC cresceu ~63,6 KB — **B13+ F4**). Plano: [escala-relativa-mapa.md](plans/escala-relativa-mapa.md); débitos do `/simplify`: [escala-dry-pos-b13.md](plans/escala-dry-pos-b13.md). +- **B8 F2 (2026-07-26)** — polígonos das 19 ZE de Salvador e re-keying do coroplético. Salvador era **um** polígono carregando os votos da cidade, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu". Novo artefato commitado `bahia-municipality-zones.topo.json` (72 KB, ~20 KB gzip, 19 features) gerado por `pnpm build:municipality-zone-geometries` a partir do `BA_bairros_CD2022` do IBGE (`shpjs` como devDependency — o recorte só existe em SHP), dissolvendo bairro→zona pela **RA 02/2017 do TRE-BA**: 170/170 polígonos atribuídos, 19/19 zonas, curadoria em 3 tabelas explícitas no script (aliases, bairro sem polígono próprio, bairro que a resolução parte) mais um passe de **maior fronteira compartilhada** para os 5 que a resolução nunca nomeia — regra validada contra os 4 que ela nomeia sob outro rótulo. A chave do mapa virou **map key** (`slug` na zona, `ibgeCode` no resto): bundle, rollups, navegação e classe territorial re-keyed, e o ramo `kind: 'zones'` do readout morreu. O polígono municipal de Salvador ficou desenhado por baixo como **base sem dado e sem pointer**, porque duas malhas simplificadas independentemente não fecham na mesma costa. `competitiveRank` continua por cidade (o artefato só tem `federalRankByIbgeCode`, o que a legenda já dizia — int pina que as 19 dividem uma posição). Sem migration, sem collection, sem action. Plano: [poligonos-pracas-zona.md](plans/poligonos-pracas-zona.md). + ## Próximos — Campanha (`/campanha`) ### Programa Inteligência de campanha (E8–E16, B13, C12 · adjacentes A11/E17/E18) @@ -84,7 +86,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**C13** renomear "Plano de Ação" → "Atividade" (entidade, código, banco e rotas)~~ — **entregue 2026-07-25** (collection `activity`, 4 tabelas + 3 enums + 2 colunas + 39 índices + 14 FKs + 2 sequences renomeados por migration data-preserving escrita à mão `20260725_213000_rename_action_plan_to_activity`, que também alinhou os 8 índices fósseis `%plaza%` da remodelagem; rota `/campanha/atividades`, sidebar "Atividades", rótulos de campo "Tipo/Origem/Resultado da atividade"; guard de vocabulário table-driven em `codebaseConventions.unit.spec.ts` varrendo `src`+`tests`+`scripts`) · [plano](plans/renomear-plano-acao-para-atividade.md) - **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md) - **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md) -- **B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19; Camaçari saiu do escopo na M1 — município inteiro): F1 catálogo zona→bairros entregue (hoje Salvador-only); falta prep do catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável · [plano](plans/poligonos-pracas-zona.md) +- ~~**B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19)~~ — **entregue 2026-07-26** (artefato `bahia-municipality-zones.topo.json` por dissolve IBGE↔TRE RA 02/2017; coroplético keyed por map key `slug`|`ibgeCode`; polígono municipal de Salvador mantido como base sem interação; rank competitivo segue por cidade com int pinando o fato) · débitos com gatilho no plano: ZE exata no card "Onde estou" (B14 ✓) e rank por ZE · [plano](plans/poligonos-pracas-zona.md) - **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais - **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md) - **Programa WhatsApp interno (D3–D5)** · baixa/média prioridade · 1:1 staff↔lideranças via bridge **não oficial** · **número pessoal** de cada assessor/CG (QR no app, parece chat pessoal) · não Business API / não massa / não chip institucional · fecha Little Hire · gatilho honesto (2026-07-24): "ZAP é o campo" é fato confirmado — D3 entra se os deltas do ZAP **não** estiverem sendo absorvidos pelo fluxo sede-digita (C12) após uso real · plano-mestre [whatsapp-interno-campanha.md](plans/whatsapp-interno-campanha.md) @@ -93,7 +95,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **D5** inbox da própria sessão → rascunhos (`municipalityUpdate`/demanda) com humano no loop · depende de D3 · [plano](plans/whatsapp-sugestao-atualizacoes.md) - **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · absorve os débitos de produto/UX remanescentes de FD2 ([field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md)): glossário inline (O3 — hipótese ainda sem evidência), triagem em lote, empty states de coordenador · gatilho: antes de 16/08 - ~~**B38** sidebar recolhido em tablet~~ — **entregue 2026-07-26** (`collapsible="offcanvas"` no `CampaignSidebar`; `SidebarTrigger` no inset `md+`; `defaultOpen` lê cookie `sidebar_state`; `CampaignSidebarViewportDefault` colapsa uma vez em 768–1023 sem cookie; mobile Sheet inalterado; sem migration) · [plano](plans/sidebar-recolhido-tablet.md) -- ~~**B14**~~ ✓ município mais próximo (geolocalização → card "Onde estou" no Início staff, entregue 2026-07-26) · pede permissão **1× por sessão** se ainda não concedida; matching client-side **por point-in-polygon** sobre o chunk de geometria do mapa (centroides só ordenam o fallback de 150 km na carteira); Salvador multi-zona → lista filtrada até B8 F2 · [plano](plans/municipio-mais-proximo.md) +- ~~**B14**~~ ✓ município mais próximo (geolocalização → card "Onde estou" no Início staff, entregue 2026-07-26) · pede permissão **1× por sessão** se ainda não concedida; matching client-side **por point-in-polygon** sobre o chunk de geometria do mapa (centroides só ordenam o fallback de 150 km na carteira); Salvador multi-zona → lista filtrada; **B8 F2 ✓ (2026-07-26)** desenhou os polígonos das ZE, então resolver a zona exata passou a ser possível, mas o card não mudou — segue como débito com gatilho "pedido de campo" no [plano do B8](plans/poligonos-pracas-zona.md) · [plano](plans/municipio-mais-proximo.md) - ~~**B20** Municípios prioritários no Início~~ — **entregue 2026-07-25** (`pickDashboardPriorityMunicipalities` + amostra 8 por déficit `central`; Badge com total; “Ver todas” → `?priority=alta`; `list-none` na strip) · [plano](plans/municipios-prioritarios-inicio.md) - ~~**B21** página própria dos Territórios de Identidade~~ — **entregue 2026-07-25** (`/campanha/territorios` staff-only; 27 TIs + decomposição do Metropolitano; sort/filtros/busca canônicos na URL; drill para municípios; chrome extraído em `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`; painel de TI removido do Início por decisão da sessão) · sem migration/collection/action · [plano](plans/pagina-territorios-identidade.md) - **Downstream atualizado:** B25 teve a dependência dura atendida; B29 e B33 não pagam mais a extração do chrome compartilhado e agora implementam apenas política/URL/filtros dos próprios domínios. @@ -163,7 +165,7 @@ flowchart TD D3 --> D4["D4 WA envio 1:1"] D3 --> D5["D5 WA → rascunhos"] D4 -.thread.-> D5 - B8F2["B8 F2 Polígonos
    zonas de Salvador"] + B8F2["B8 F2 ✓ Polígonos
    zonas de Salvador"] B14n["B14 ✓ Município mais próximo"] B38n["B38 ✓ Sidebar recolhido
    em tablet"] B39n["B39 Lembrar de mim
    / sessão longa"] @@ -320,10 +322,10 @@ flowchart TD C13n -.vocabulário e caminhos da vertical.-> E13i C2prod -.dados reais.-> E11i E11i -.decisões acumuladas.-> E15i - B8F2 -.ZE Salvador.-> B14n + B8F2 -.ZE Salvador = débito aberto.-> B14n ``` -Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), ~~**B14**~~ ✓ (atalho geo no Início entregue 2026-07-26; B8 F2 segue como gatilho só para desambiguar as ZE de Salvador), ~~**B38**~~ ✓ (sidebar recolhido em tablet entregue 2026-07-26), **B39** (lembrar de mim / sessão longa; sem dep dura — cookie/JWT já existem; soft B40 só pela ordem no mesmo `LoginForm`), **B40** (login biométrico; soft B39 pelo TTL/copy — pode começar depois do B39), **B41** (scroll horizontal + sticky na lista de municípios; soft B16 ✓/B38 ✓ — não bloqueia), **B42** (polimento mobile da mesma lista; soft B26 ✓/B24 ✓/B27 ✓ — paralelo a B41), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro — **entregue 2026-07-26**), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). +Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), ~~**B14**~~ ✓ (atalho geo no Início entregue 2026-07-26; ~~B8 F2~~ ✓ entregou os polígonos das ZE em 2026-07-26, então desambiguar Salvador virou débito com gatilho "pedido de campo", não dependência), ~~**B38**~~ ✓ (sidebar recolhido em tablet entregue 2026-07-26), **B39** (lembrar de mim / sessão longa; sem dep dura — cookie/JWT já existem; soft B40 só pela ordem no mesmo `LoginForm`), **B40** (login biométrico; soft B39 pelo TTL/copy — pode começar depois do B39), **B41** (scroll horizontal + sticky na lista de municípios; soft B16 ✓/B38 ✓ — não bloqueia), **B42** (polimento mobile da mesma lista; soft B26 ✓/B24 ✓/B27 ✓ — paralelo a B41), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro — **entregue 2026-07-26**), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). ### Sequência por janela (só pendentes) @@ -331,7 +333,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. -**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); ~~**E10** classificação relativa~~ (entregue 2026-07-25) → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; **B8 F2** polígonos das zonas de Salvador; débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**. +**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); ~~**E10** classificação relativa~~ (entregue 2026-07-25) → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; ~~**B8 F2** polígonos das zonas de Salvador~~ (entregue 2026-07-26); débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**. **Janela 4 — set → 04/10:** C5 GOTV _(validar)_, congelamento ~20/09 (só bugfix/dados). **D4** envio 1:1 e **D5** inbox→rascunhos só se D3 estiver estável **antes** do congelamento (senão adiar pós-04/10). **E15** backtest pós-eleição (após 04/10). @@ -339,7 +341,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun **Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). -**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); **B40** login biométrico (senha + B39 continuam — cortar custa só o atalho de digital/Face ID; o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); **B41** scroll horizontal + sticky (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. +**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); **B40** login biométrico (senha + B39 continuam — cortar custa só o atalho de digital/Face ID; o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); **B41** scroll horizontal + sticky (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. ## Bloqueadores atuais @@ -379,7 +381,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun ## Fora de escopo (por enquanto) - Serviço Rust separado; self-host/Coolify enquanto Vercel atender; doações in-app -- Geocodificação de seções eleitorais / unidade = seção (polígonos **aproximados** dos 19 Municípios-zona de Salvador = **B8 F2**, sem seções) +- Geocodificação de seções eleitorais / unidade = seção (os polígonos **aproximados** dos 19 Municípios-zona de Salvador saíram no **B8 F2 ✓ 2026-07-26**, por dissolve de bairros — seções continuam fora) - PWA do site/`/admin`; PostGIS sem query espacial real - WhatsApp Business API / disparo em massa / blast a apoiadores ou eleitores (Res. TSE 23.610 art. 33; Meta veda WABA político). **Exceto** o programa **D3–D5**: bridge não oficial **1:1 interno** staff↔lideranças já no CRM (ver [whatsapp-interno-campanha.md](plans/whatsapp-interno-campanha.md)) — risco ToS/operacional explícito nos planos; não é substituto de WABA - Previsão estatística de votos neste ciclo diff --git a/scripts/build-municipality-zone-geometries.mjs b/scripts/build-municipality-zone-geometries.mjs index c32a1a1c..3710ca0b 100644 --- a/scripts/build-municipality-zone-geometries.mjs +++ b/scripts/build-municipality-zone-geometries.mjs @@ -258,9 +258,12 @@ const main = async () => { const neighborhoodTopology = topology({ neighborhoods: { type: 'FeatureCollection', - features: features.map((feature) => ({ + // `id` is what the simplified topology is re-indexed by further down. + // Neighborhood names are unique in today's mesh but nothing guarantees it, + // and keying by name would silently collapse two polygons into one. + features: features.map((feature, index) => ({ type: 'Feature', - properties: { name: feature.properties?.NM_BAIRRO ?? '' }, + properties: { id: index, name: feature.properties?.NM_BAIRRO ?? '' }, geometry: feature.geometry, })), }, @@ -351,16 +354,16 @@ const main = async () => { // Simplification keeps geometry order and properties, so the zone buckets // above still address the right shapes. const simplified = simplifyTopology(neighborhoodTopology) - const simplifiedByName = new Map( + const simplifiedById = new Map( simplified.objects.neighborhoods.geometries.map((geometry) => [ - geometry.properties.name, + geometry.properties.id, geometry, ]), ) const zoneFeatures = zoneEntries.map((entry) => { const members = (geometriesByZone.get(entry.slug) ?? []).map((geometry) => - simplifiedByName.get(geometry.properties.name), + simplifiedById.get(geometry.properties.id), ) if (members.length === 0 || members.some((member) => !member)) { die(`Missing neighborhood geometry for ${entry.slug}.`) diff --git a/src/lib/municipalityProximity.ts b/src/lib/municipalityProximity.ts index 81b77bef..12bad87c 100644 --- a/src/lib/municipalityProximity.ts +++ b/src/lib/municipalityProximity.ts @@ -52,10 +52,13 @@ export type NearbyMunicipalityResolution = | { kind: 'inScope'; municipality: AccessibleMunicipality } /** * The point falls inside a city modeled as several zone municipalities - * (Salvador). Without zone polygons (B8 F2) the honest answer is the filtered - * list, never a guessed ZE. `zoneCount` is what the actor can open, so an - * advisor with three zones is not told there are nineteen. `ibgeCode` is how - * the caller finds the filtered-list href the server serialized for it. + * (Salvador). The answer is the filtered list, never a guessed ZE. B8 F2 has + * since drawn the zone polygons, so resolving the exact ZE became possible — + * this resolver deliberately still does not, because that is a separate + * decision (an open debt) rather than a side effect of the map delivery. + * `zoneCount` is what the actor can open, so an advisor with three zones is + * not told there are nineteen. `ibgeCode` is how the caller finds the + * filtered-list href the server serialized for it. */ | { kind: 'zoneCity'; city: string; ibgeCode: string; zoneCount: number } /** Inside Bahia, but in a município outside the actor's portfolio. */ diff --git a/tests/int/municipalityZoneNeighborhoods.int.spec.ts b/tests/int/municipalityZoneNeighborhoods.int.spec.ts index f52506e4..270d980b 100644 --- a/tests/int/municipalityZoneNeighborhoods.int.spec.ts +++ b/tests/int/municipalityZoneNeighborhoods.int.spec.ts @@ -55,8 +55,9 @@ describe('Municipality zone neighborhood catalog (Salvador only)', () => { for (const municipality of zoneMunicipalities) { const record = municipalityZoneNeighborhoodEntryForSlug(municipality.slug) expect(record, municipality.slug).toBeDefined() - expect(record?.city).toBe(municipality.city) - expect(record?.zoneNumber).toBe(municipality.zoneNumber) + // `city`/`zoneNumber` are copied from this same catalog, so comparing them + // here could not fail. The fixture test below is what actually checks them, + // against an independent transcription of the resolution. expect(record?.neighborhoods.length, municipality.slug).toBeGreaterThan(0) } })