From 4f2ec80ad49fe0ae47e280e4d5ef5b3673faabd2 Mon Sep 17 00:00:00 2001 From: rafapolo Date: Wed, 30 Sep 2026 19:17:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20estado=20e=20cidade=20do=20filtro=20e?= =?UTF-8?q?=20da=20=C3=A1rvore=20tamb=C3=A9m=20a=20partir=20de=20coverage?= =?UTF-8?q?=20e=20outras=20grafias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/wall.html | 123 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 19 deletions(-) diff --git a/web/wall.html b/web/wall.html index a3bbac8..d0212ab 100644 --- a/web/wall.html +++ b/web/wall.html @@ -1448,36 +1448,106 @@ const UF = { AC: 'Acre', AL: 'Alagoas', AP: 'Amapá', AM: 'Amazonas', BA: 'Bahia RS: 'Rio Grande do Sul', RO: 'Rondônia', RR: 'Roraima', SC: 'Santa Catarina', SP: 'São Paulo', SE: 'Sergipe', TO: 'Tocantins' }; const STATE_BY_KEY = new Map(Object.values(UF).map(n => [deburr(n), n])); +// ── places, from subject AND coverage, in the many ways the catalogue writes them: +// "Porto Velho (RO)" · "Recife, PE" · "Rio de Janeiro - RJ" · "Recife, Pernambuco, Brasil" +// "Bahia (Estado)" · "RJ" · "Centro (Rio de Janeiro, RJ)" · bare "Recife" (learned below) +const UF_CODE = /^[A-Z]{2}$/; +const CITY_UF = new Map(); +const CANON = new Map(); // deburred city → its most common spelling ("Rio de janeiro" → "Rio de Janeiro") +const HOOD = new Map(); // deburred bairro → {state, city}, learned from "Bairro (Cidade, UF)" +const FEATURE = /^(?:ba[ií]a|morro|ilha|serra|lago|lagoa|floresta|estrada|avenida|rua|pra[cç]a|largo|parque|jardim|campo|cais|porto|rio|igreja|esta[cç][aã]o|forte|palacio|palácio|centro)\b/i; // deburred city → its UF, learned from the explicit forms +const stateOf = x => UF_CODE.test(x) ? UF[x] : STATE_BY_KEY.get(deburr(x)); +function parsePlaceExplicit(raw) { + const t = raw.trim().replace(/\.$/, '').replace(/,\s*(?:brasil|brazil)$/i, '').replace(/^\d{4}(?:-\d{4})?\s*:\s*/, ''); + let m; + if ((m = t.match(/^(.+?)\s*\((?:estado|brazil\s*:\s*state)\)$/i)) && stateOf(m[1].trim())) return { state: stateOf(m[1].trim()) }; + if (UF_CODE.test(t) && UF[t]) return { state: UF[t] }; + if (STATE_BY_KEY.has(deburr(t))) return { state: STATE_BY_KEY.get(deburr(t)), city: /^(rio de janeiro|sao paulo)$/.test(deburr(t)) ? t : null }; + // "Bairro (Cidade, UF)" + if ((m = t.match(/^(.+?)\s*\(([^(),]+),\s*([A-Za-z]{2})\)$/)) && UF[m[3].toUpperCase()] && !/\s-\s/.test(m[1])) + return { state: UF[m[3].toUpperCase()], city: m[2].replace(/^\d{4}(?:-\d{4})?\s*:\s*/, '').trim(), sub: m[1].trim() }; + if (/\s-\s/.test(t.replace(/\s+-\s+(?=[A-Z]{2}$)/, ''))) return null; // a topic path, handled by the caller + // "Cidade (UF)" · "Cidade, UF" · "Cidade - UF" · "Cidade(UF)" + if ((m = t.match(/^(.+?)\s*(?:\(|,\s*|\s-\s*)([A-Z]{2})\)?$/)) && UF[m[2]]) return { state: UF[m[2]], city: m[1].trim() }; + // "Cidade (Estado por extenso)" · "Cidade, Estado por extenso" + if ((m = t.match(/^(.+?)\s*(?:\(|,\s*)([^(),]+)\)?$/)) && STATE_BY_KEY.has(deburr(m[2]))) return { state: STATE_BY_KEY.get(deburr(m[2])), city: m[1].trim() }; + return null; +} +function parsePlace(raw) { + const p = parsePlaceExplicit(raw); + if (p) { + if (p.city) p.city = CANON.get(deburr(p.city)) || p.city; + // "Ceará, CE": the state written twice is no city (Rio de Janeiro and São Paulo are both) + if (p.city && deburr(p.city) === deburr(p.state) && !/^(rio de janeiro|sao paulo)$/.test(deburr(p.state))) p.city = null; + return p; + } + const k = deburr(raw.trim().replace(/\.$/, '')); + const uf = CITY_UF.get(k); + return uf ? { state: UF[uf], city: raw.trim().replace(/\.$/, '') } : null; +} +const NO_PLACE = /^\[?s\.?\s?l\.?\]?$|^\[?s\.?\s?n\.?\]?$/i; + +// every place-bearing tag of a piece, PT side only +function geoTags(meta) { + const seen = new Set(), out = []; + const add = t => { + const k = subjectKey(t); + if (!k || seen.has(k) || NO_PLACE.test(String(t).trim())) return; + seen.add(k); out.push(String(t).trim()); + }; + subjectsOf(meta).forEach(add); + const cov = [].concat((meta || {}).coverage || []).map(t => String(t).trim()).filter(t => !EN_WORD.test(t)); + cov.forEach(add); + return out; +} + function subjectTree(meta) { const root = { kids: new Map() }; const put = path => path.reduce((n, label) => { if (!n.kids.has(label)) n.kids.set(label, { kids: new Map(), place: false }); return n.kids.get(label); }, root); + const place = path => path.forEach((_, i) => { put(path.slice(0, i + 1)).place = true; }); const others = []; let hasPlace = false; - subjectsOf(meta).forEach(tag => { - const key = deburr(tag).replace(/\s*\(estado\)$/, ''); - if (key === 'brasil') { put(['Brasil']).place = true; hasPlace = true; return; } - if (STATE_BY_KEY.has(key)) { - const st = STATE_BY_KEY.get(key); - put(['Brasil']).place = true; put(['Brasil', st]).place = true; hasPlace = true; return; + const tags = geoTags(meta); + // a bairro / feature ("Centro", "Baía de Guanabara") is not a city: it hangs under the + // piece's real city, or under the city it was learned to belong to + const cities = new Set(); + tags.forEach(tag => { const p = parsePlace(tag); if (p && p.city && !FEATURE.test(p.city) && !HOOD.has(deburr(p.city))) cities.add(p.state + '|' + p.city); }); + tags.forEach(tag => { + const key = deburr(tag).replace(/\.$/, ''); + if (key === 'brasil' || key === 'brazil') { place(['Brasil']); hasPlace = true; return; } + let p = parsePlace(tag); + if (p && p.city && !p.sub && (FEATURE.test(p.city) || HOOD.has(deburr(p.city)))) { + const h = HOOD.get(deburr(p.city)); + const real = h ? { state: h.state, city: h.city } : [...cities].map(c => c.split('|')).filter(c => c[0] === p.state).map(c => ({ state: c[0], city: c[1] }))[0]; + p = real ? { state: real.state, city: real.city, sub: p.city } : { state: p.state, city: null, sub: p.city }; } - const parts = tag.split(/\s+-\s+/); - // "Espírito Santo (Estado) - Colônias": a state carrying a topic - const sm = parts[0].match(/^(.+?)\s*\(Estado\)$/i); - if (sm && STATE_BY_KEY.has(deburr(sm[1]))) { - const path = ['Brasil', STATE_BY_KEY.get(deburr(sm[1]))]; - path.forEach((_, i) => { put(path.slice(0, i + 1)).place = true; }); - parts.slice(1).forEach((_, i) => put(path.concat(parts.slice(1, i + 2)))); + if (p) { + const path = ['Brasil', p.state].concat(p.city ? [p.city] : []); + place(path); + if (p.sub) put(path.concat(p.sub)).place = true; hasPlace = true; return; } - const m = parts[parts.length - 1].match(/^(.+?)\s*\(([A-Z]{2})\)$/); - if (!m || !UF[m[2]]) { others.push(tag); return; } - const path = ['Brasil', UF[m[2]], m[1]]; - path.slice(0, 3).forEach((_, i) => { put(path.slice(0, i + 1)).place = true; }); - parts.slice(0, -1).forEach((_, i) => { put(path.concat(parts.slice(0, i + 1))); }); - hasPlace = true; + // "Espírito Santo (Estado) - Colônias": a state carrying a topic + const parts = tag.split(/\s+-\s+/); + const sm = parts[0].match(/^(.+?)\s*\(Estado\)$/i); + if (parts.length > 1 && sm && STATE_BY_KEY.has(deburr(sm[1]))) { + const path = ['Brasil', STATE_BY_KEY.get(deburr(sm[1]))]; + place(path); hasPlace = true; + parts.slice(1).forEach((_, i) => put(path.concat(parts.slice(1, i + 2)))); + return; + } + // "Tópico - Cidade (UF)": the last segment carries the place + const lp = parts.length > 1 ? parsePlace(parts[parts.length - 1]) : null; + if (lp && lp.city) { + const path = ['Brasil', lp.state, lp.city]; + place(path); hasPlace = true; + parts.slice(0, -1).forEach((_, i) => put(path.concat(parts.slice(0, i + 1)))); + return; + } + others.push(tag); }); // a lone "Brasil" is no tree — it reads better as a chip with the rest if (hasPlace && root.kids.get('Brasil').kids.size === 0) { root.kids.clear(); others.push('Brasil'); } @@ -1532,8 +1602,23 @@ function subjectFacets(meta) { return { st, ct, tp }; } +function learnCities(entries) { + const votes = new Map(), spell = new Map(); + entries.forEach(e => geoTags(e.meta).forEach(t => { + const p = parsePlaceExplicit(t); + if (!p || !p.city) return; + { const k = deburr(p.city), v = spell.get(k) || new Map(); v.set(p.city, (v.get(p.city) || 0) + 1); spell.set(k, v); } + if (p.sub) HOOD.set(deburr(p.sub), { state: p.state, city: p.city }); + const uf = Object.keys(UF).find(k => UF[k] === p.state), k = deburr(p.city); + const v = votes.get(k) || new Map(); v.set(uf, (v.get(uf) || 0) + 1); votes.set(k, v); + })); + spell.forEach((v, k) => CANON.set(k, [...v].sort((a, b) => b[1] - a[1])[0][0])); + votes.forEach((v, k) => { const [uf, n] = [...v].sort((a, b) => b[1] - a[1])[0]; if (n >= 2 && !STATE_BY_KEY.has(k)) CITY_UF.set(k, uf); }); +} + function setupFilters(mapData) { const entries = Object.values(mapData.map); + learnCities(entries); const instCounts = new Map(), typeCounts = new Map(), authorGroups = new Map(); let minYear = Infinity, maxYear = -Infinity;