678 lines
32 KiB
JavaScript
678 lines
32 KiB
JavaScript
/**
|
|
* useSearch.js — Local geocoder search composable
|
|
*
|
|
* Supplements the Mapbox Geocoder (which only knows cities/countries)
|
|
* with results from the local people-groups dataset. Returns matches
|
|
* grouped by four categories: people groups, country groupings,
|
|
* languages, and religions.
|
|
*
|
|
* Usage:
|
|
* const { search } = useEntitySearch({ dataStore, dataSourceId })
|
|
* // Pass `search` as `localGeocoder` option to MapboxGeocoder:
|
|
* new MapboxGeocoder({ accessToken, localGeocoder: search })
|
|
*
|
|
* Ranking (higher = better):
|
|
* - name-match score 100 (people group name)
|
|
* - country-match score 50
|
|
* - religion-match score 25
|
|
* - language-match score 10
|
|
*
|
|
* Tokenization: the query is whitespace-split and ALL tokens must appear
|
|
* in a record's haystack. "India Muslim" matches records whose combined
|
|
* (name+country+language+religion) text contains both tokens.
|
|
*
|
|
* Feature shape returned follows the Carmen-GeoJSON format expected by
|
|
* mapbox-gl-geocoder:
|
|
* https://github.com/mapbox/carmen/blob/master/carmen-geojson.md
|
|
*/
|
|
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import langFamilyByLanguage from '../data/langFamilyByLanguage.json'
|
|
// Religion search must group by FAMILY (same 10 families the Religion legend shows),
|
|
// not by raw sub-type names — otherwise search surfaces 'Animism'/'Romanism' rows
|
|
// the legend can't select. Reuse the legend's exact family logic.
|
|
import { RELIGION_FAMILIES, getReligionFamily } from '../colors/religion.js'
|
|
|
|
// ── Result category ordering by active-tab context (Round 16) ────────────────
|
|
// The search bar is unified — every category is always searched — but the
|
|
// category that matches the active legend tab is surfaced FIRST so results feel
|
|
// context-aware without being cluttered. People-groups are included in EVERY
|
|
// context (coder spec: "All tabs: can still search people groups").
|
|
const ALL_GROUPS = ['blocs', 'clusters', 'peopleGroups', 'families', 'languages', 'dialects', 'people', 'places', 'regions', 'wagfBlocks', 'religions']
|
|
const ORDER_BY_CONTEXT = {
|
|
// People Groups tab → affinity bloc, cluster, people group, people group+country
|
|
'people-groups': ['blocs', 'clusters', 'peopleGroups', 'people', 'places', 'families', 'languages', 'dialects', 'regions', 'religions'],
|
|
// Language tab → language family, language, dialect
|
|
'language': ['families', 'languages', 'dialects', 'people', 'blocs', 'clusters', 'peopleGroups', 'places', 'regions', 'religions'],
|
|
// Regions tab → region, block, country
|
|
'regions': ['regions', 'wagfBlocks', 'places', 'blocs', 'clusters', 'peopleGroups', 'people', 'families', 'languages', 'dialects', 'religions'],
|
|
// Religion tab → religion
|
|
'religion': ['religions', 'people', 'blocs', 'clusters', 'peopleGroups', 'places', 'families', 'languages', 'dialects', 'regions'],
|
|
}
|
|
function groupOrder(context) {
|
|
return ORDER_BY_CONTEXT[context] || ALL_GROUPS
|
|
}
|
|
// Per-category cap for the "All DOXA Data" section so one prolific category
|
|
// (usually people-groups) can't bury the rest.
|
|
const ALLDATA_CAPS = {
|
|
blocs: 3, clusters: 3, peopleGroups: 3, families: 3, languages: 3,
|
|
dialects: 3, people: 5, places: 2, regions: 3, wagfBlocks: 3, religions: 2,
|
|
}
|
|
|
|
// Parameterized search per tab (coder R42). The key is the geocoder context
|
|
// string (TAB_SEARCH_CONTEXT in the profile .vue: 'regions'|'people-groups'|
|
|
// 'language'|'religion'); the value is the subset of grouped-result keys to keep.
|
|
// A context absent here (or '') = all categories (no regression).
|
|
//
|
|
// UNIVERSAL (every tab): People Group (peopleGroups+people), Country (places),
|
|
// Default Language (languages), Religion (religions). Each tab then ADDS its own
|
|
// domain categories:
|
|
// Regions tab + WAGF Region/Block (regions) — and NO blocs/clusters/families/dialects
|
|
// People Groups tab + Affinity Block (blocs) + Cluster (clusters)
|
|
// Languages tab + Dialect (dialects) + Language Family (families)
|
|
// Religion tab + (nothing beyond universal)
|
|
// "ONLY the Regions tab shows Regions/Blocks; showing affinity blocks or people
|
|
// clusters on the Regions tab is the reported bug." WAGF blocks (e.g. "South
|
|
// Asia") fold into the `regions` aggregate (doxaRegion||wagfRegion).
|
|
const UNIVERSAL_SEARCH = ['peopleGroups', 'people', 'places', 'languages', 'religions']
|
|
const ALLOWED_BY_CONTEXT = {
|
|
'regions': [...UNIVERSAL_SEARCH, 'regions', 'wagfBlocks'],
|
|
'people-groups': [...UNIVERSAL_SEARCH, 'blocs', 'clusters'],
|
|
'language': [...UNIVERSAL_SEARCH, 'dialects', 'families'],
|
|
'religion': [...UNIVERSAL_SEARCH],
|
|
}
|
|
|
|
// ── Score weights ────────────────────────────────────────────────────────────
|
|
const SCORE_NAME = 100
|
|
const SCORE_COUNTRY = 50
|
|
const SCORE_RELIGION = 25
|
|
const SCORE_LANGUAGE = 10
|
|
|
|
// ── Caps ─────────────────────────────────────────────────────────────────────
|
|
const MAX_PER_CATEGORY = 5
|
|
const MAX_TOTAL = 20
|
|
|
|
// ── Base-language + dialect parsing (mirrors useLanguageFamilyLegendData rules)
|
|
// Per QA building-round-1 R2 A1: each individual sign language is its own
|
|
// language; "Pakistan Sign Language" stays whole as the base. The suffix
|
|
// regex is reserved for FAMILY resolution only (see resolveFamily below).
|
|
// "Arabic, Shihhi" → base "Arabic", dialect "Shihhi"
|
|
// "Pakistan Sign Language" → base "Pakistan Sign Language", dialect null
|
|
// "Bengali" → base "Bengali", dialect null
|
|
const FAMILY_SUFFIXES = [
|
|
[/ sign language$/i, 'Sign Language'],
|
|
]
|
|
function readBaseLanguage(label) {
|
|
if (!label || typeof label !== 'string') return ''
|
|
const comma = label.indexOf(',')
|
|
if (comma > 0) return label.slice(0, comma).trim()
|
|
return label.trim()
|
|
}
|
|
function readDialectLabel(label) {
|
|
if (!label || typeof label !== 'string') return null
|
|
const comma = label.indexOf(',')
|
|
if (comma >= 0) return label.slice(comma + 1).trim() || null
|
|
return null
|
|
}
|
|
|
|
// ── Family lookup (same comma-inversion logic as useLanguageFamilyLegendData) ─
|
|
function resolveFamily(label) {
|
|
if (!label || typeof label !== 'string') return null
|
|
const parts = label.split(',').map(s => s.trim()).filter(Boolean)
|
|
if (parts.length >= 2) {
|
|
const fullReversed = [...parts].reverse().join(' ')
|
|
if (langFamilyByLanguage[fullReversed]) return langFamilyByLanguage[fullReversed]
|
|
if (parts.length >= 3) {
|
|
const twoReversed = [parts[1], parts[0]].join(' ')
|
|
if (langFamilyByLanguage[twoReversed]) return langFamilyByLanguage[twoReversed]
|
|
}
|
|
}
|
|
if (langFamilyByLanguage[label]) return langFamilyByLanguage[label]
|
|
const stripped = label.replace(/\s*\(.*?\)\s*$/, '').trim()
|
|
if (stripped !== label && langFamilyByLanguage[stripped]) return langFamilyByLanguage[stripped]
|
|
// FAMILY_SUFFIXES fallback so sign-language pins agree with the legend's
|
|
// family bucketing (e.g. "Pakistan Sign Language" → "Sign Language" family).
|
|
for (const [re, base] of FAMILY_SUFFIXES) {
|
|
if (re.test(label)) return base
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ── Section-header feature (non-clickable visual divider in suggestions) ──────
|
|
function makeSectionHeader(label, isAllData) {
|
|
return {
|
|
id: `doxa-section-header-${slugify(label)}`,
|
|
place_name: label,
|
|
text: label,
|
|
center: [0, 0],
|
|
place_type: ['doxa-section-header'],
|
|
geometry: { type: 'Point', coordinates: [0, 0] },
|
|
properties: { headerLabel: label, isHeader: true, isAllDataSection: !!isAllData },
|
|
feature: null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Slugify any text for use in a feature id ('doxa-pg-<slug>').
|
|
*/
|
|
function slugify(s) {
|
|
return String(s || '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
}
|
|
|
|
/**
|
|
* Normalize a string for case-insensitive matching.
|
|
*/
|
|
function norm(v) {
|
|
if (v == null) return ''
|
|
if (typeof v === 'string') return v.toLowerCase()
|
|
if (typeof v === 'object' && 'label' in v) return String(v.label || '').toLowerCase()
|
|
if (typeof v === 'object' && 'value' in v) return String(v.value || '').toLowerCase()
|
|
return String(v).toLowerCase()
|
|
}
|
|
|
|
/**
|
|
* Human-readable label — pulls .label / .value from {value,label} objects.
|
|
*/
|
|
function strLabel(v) {
|
|
if (v == null) return ''
|
|
if (typeof v === 'string') return v
|
|
if (typeof v === 'object') return v.label || v.value || ''
|
|
return String(v)
|
|
}
|
|
|
|
/**
|
|
* Build a precomputed search index for the people-groups dataset.
|
|
*/
|
|
function buildIndex(features) {
|
|
if (!Array.isArray(features)) return []
|
|
const index = []
|
|
for (const pg of features) {
|
|
if (!pg) continue
|
|
const lat = Number(pg.latitude)
|
|
const lng = Number(pg.longitude)
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
|
|
|
const nameHay = norm(pg.name) + ' ' + norm(pg.displayName) + ' ' + norm(pg.alternateName)
|
|
const countryHay = norm(pg.countryName) + ' ' + norm(pg.country) + ' ' + norm(pg.countryIso) + ' ' + norm(pg.countryIsoLabel)
|
|
const religionHay = norm(pg.religionName) + ' ' + norm(pg.religion) + ' ' + norm(pg.religionLabel) + ' ' + norm(pg.religionCode)
|
|
const languageHay = norm(pg.language) + ' ' + norm(pg.languageFamily) + ' ' + norm(pg.languageCode)
|
|
const regionHay = norm(pg.doxaRegion) + ' ' + norm(pg.wagfRegion) + ' ' + norm(pg.wagfBlock)
|
|
const blocHay = norm(pg.affinityBlock) + ' ' + norm(pg.affinityBlockLabel) + ' ' + norm(pg._raw?.rop1)
|
|
const clusterHay = norm(pg.cluster) + ' ' + norm(pg.clusterLabel) + ' ' + norm(pg._raw?.imb_reg_of_people_2)
|
|
const pgHay = norm(pg.peopleGroup) + ' ' + norm(pg.peopleGroupLabel) + ' ' + norm(pg._raw?.imb_reg_of_people_25)
|
|
|
|
// Pull the comma-inverted label from _raw first (the original API form,
|
|
// e.g. "Arabic, Sudanese") — pg.language can be overwritten by useMapData.
|
|
const rawLang = pg._raw?.primary_language ?? pg._raw?.PrimaryLanguage
|
|
const langLabel = (rawLang && (typeof rawLang === 'object' ? (rawLang.label || rawLang.value) : rawLang))
|
|
|| strLabel(pg.language) || strLabel(pg.primaryLanguage) || ''
|
|
const baseLang = readBaseLanguage(langLabel)
|
|
const dialectLabel = readDialectLabel(langLabel)
|
|
index.push({
|
|
feature: pg,
|
|
nameHay,
|
|
countryHay,
|
|
religionHay,
|
|
languageHay,
|
|
regionHay,
|
|
haystack: nameHay + ' ' + countryHay + ' ' + religionHay + ' ' + languageHay + ' ' + regionHay + ' ' + blocHay + ' ' + clusterHay + ' ' + pgHay,
|
|
blocHay,
|
|
clusterHay,
|
|
pgHay,
|
|
lat,
|
|
lng,
|
|
familyDerived: resolveFamily(langLabel) || null,
|
|
languageLabel: langLabel,
|
|
baseLang,
|
|
dialectLabel,
|
|
})
|
|
}
|
|
return index
|
|
}
|
|
|
|
/**
|
|
* Build aggregate lookup tables for the "places / languages / religions"
|
|
* categories.
|
|
*/
|
|
function buildAggregates(entries) {
|
|
// extraFn(bucket, entry, isNew) lets a caller stash extra per-bucket data
|
|
// (e.g. originalLabels for dialect aggregates).
|
|
const aggregate = (keyFn, labelFn, extraFn) => {
|
|
const map = new Map()
|
|
for (const e of entries) {
|
|
const key = keyFn(e)
|
|
if (!key) continue
|
|
const existing = map.get(key)
|
|
if (existing) {
|
|
existing.count += 1
|
|
existing.lngSum += e.lng
|
|
existing.latSum += e.lat
|
|
existing.memberIds.push(String(e.feature.uniqueId ?? e.feature.id ?? e.feature.slug ?? ''))
|
|
existing.minLng = Math.min(existing.minLng, e.lng)
|
|
existing.maxLng = Math.max(existing.maxLng, e.lng)
|
|
existing.minLat = Math.min(existing.minLat, e.lat)
|
|
existing.maxLat = Math.max(existing.maxLat, e.lat)
|
|
if (extraFn) extraFn(existing, e, false)
|
|
} else {
|
|
const v = {
|
|
key,
|
|
label: strLabel(labelFn(e)) || key,
|
|
count: 1,
|
|
lngSum: e.lng,
|
|
latSum: e.lat,
|
|
minLng: e.lng, maxLng: e.lng,
|
|
minLat: e.lat, maxLat: e.lat,
|
|
memberIds: [String(e.feature.uniqueId ?? e.feature.id ?? e.feature.slug ?? '')],
|
|
}
|
|
if (extraFn) extraFn(v, e, true)
|
|
map.set(key, v)
|
|
}
|
|
}
|
|
// finalize centroids
|
|
for (const v of map.values()) {
|
|
v.lng = v.lngSum / v.count
|
|
v.lat = v.latSum / v.count
|
|
}
|
|
return Array.from(map.values())
|
|
}
|
|
|
|
return {
|
|
countries: aggregate(
|
|
(e) => norm(e.feature.countryName) || norm(e.feature.country) || norm(e.feature.countryIso),
|
|
(e) => e.feature.countryName || e.feature.country || e.feature.countryIso || ''
|
|
),
|
|
// Language families — derived from primary_language via langFamilyByLanguage lookup
|
|
families: aggregate(
|
|
(e) => norm(e.familyDerived),
|
|
(e) => e.familyDerived || ''
|
|
),
|
|
// Languages — keyed by BASE language (e.g. "Arabic" not "Arabic, Sudanese")
|
|
// so a search for "Arabic" matches one row covering all Arabic dialects.
|
|
languages: aggregate(
|
|
(e) => norm(e.baseLang),
|
|
(e) => e.baseLang || ''
|
|
),
|
|
// Dialects — only entries that have a dialectLabel; keyed by base+dialect
|
|
// so "Arabic, Sudanese" and "Sign Language, Pakistan" each get their own row.
|
|
// originalLabels accumulates the raw API labels needed to filter pins exactly.
|
|
dialects: aggregate(
|
|
(e) => e.dialectLabel ? `${norm(e.baseLang)}__${norm(e.dialectLabel)}` : '',
|
|
(e) => `${e.baseLang}, ${e.dialectLabel}`,
|
|
(bucket, e, isNew) => {
|
|
if (isNew) {
|
|
bucket.originalLabels = new Set([e.languageLabel])
|
|
bucket.familyDerived = e.familyDerived || ''
|
|
bucket.baseLang = e.baseLang || ''
|
|
bucket.dialectLabel = e.dialectLabel || ''
|
|
} else {
|
|
bucket.originalLabels.add(e.languageLabel)
|
|
// Keep the first non-empty family in case some pins lack the lookup.
|
|
if (!bucket.familyDerived && e.familyDerived) bucket.familyDerived = e.familyDerived
|
|
}
|
|
}
|
|
),
|
|
// Key + label by FAMILY (Christianity, Islam, Ethnoreligion, …) using the
|
|
// SAME extraction the legend uses (getReligionFamily on the religion code),
|
|
// so every search result corresponds to a selectable Religion-legend row.
|
|
// The raw sub-type names ('Animism') still MATCH via religionHay, but collapse
|
|
// into their family row. label = family name so selection can resolve the
|
|
// family letter back (see research-map onGeocoderAggregateResult religion branch).
|
|
religions: aggregate(
|
|
(e) => getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || ''),
|
|
(e) => RELIGION_FAMILIES[getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || '')] || 'Unknown'
|
|
),
|
|
// WAGF / DOXA regions (doxaRegion) — keyed by the region value so a search
|
|
// for "Asia" collapses every pin in that region into one row. The click
|
|
// handler (onGeocoderAggregateResult) already maps kind 'region' →
|
|
// doxaRegion and dims pins to that region; the bbox auto-fits the camera.
|
|
regions: aggregate(
|
|
(e) => norm(e.feature.doxaRegion) || norm(e.feature.wagfRegion),
|
|
// DISPLAY label = the API's human/translated label (e.g. "North Asia"), NOT the
|
|
// raw slug ("north_asia"). Mirrors the legend's readRegionLabel so search rows and
|
|
// legend rows read identically. The underscore slug is only the last-ditch fallback.
|
|
// (coder 2026-06-25: underscores leaked because this labelled on the slug.)
|
|
(e) => e.feature.wagfRegionLabel || e.feature.doxaRegionLabel
|
|
|| e.feature._raw?.wagf_region?.label || e.feature.doxaRegion || e.feature.wagfRegion || ''
|
|
),
|
|
// WAGF Blocks must be their OWN aggregate, not folded into `regions` via `||` —
|
|
// a block pin (e.g. "South Asia") also has a doxaRegion, so `doxaRegion||…||wagfBlock`
|
|
// short-circuits to the region and the block never surfaces. Keyed separately so
|
|
// "South Asia" appears as its own search row. Mapped to kind 'region' downstream
|
|
// so it routes through the same region geocoder branch (which matches block: nodes).
|
|
wagfBlocks: aggregate(
|
|
(e) => norm(e.feature.wagfBlock),
|
|
// DISPLAY label = the API's human/translated label (e.g. "South East Asia"), NOT
|
|
// the raw slug ("south_east_asia"). Mirrors the legend's readBlockLabel.
|
|
(e) => e.feature.wagfBlockLabel || e.feature._raw?.wagf_block?.label || e.feature.wagfBlock || ''
|
|
),
|
|
// Affinity blocs (rop1) — keyed by bloc label so a search for "Malay" matches "Malay Peoples"
|
|
blocs: aggregate(
|
|
(e) => norm(e.feature.affinityBlockLabel) || norm(e.feature.affinityBlock),
|
|
(e) => e.feature.affinityBlockLabel || e.feature.affinityBlock || '',
|
|
(bucket, e, isNew) => {
|
|
if (isNew) bucket.blocCode = e.feature.affinityBlock || ''
|
|
}
|
|
),
|
|
// Clusters (imb_reg_of_people_2) — composite key (bloc + cluster) so two blocs with same cluster name don't collide
|
|
clusters: aggregate(
|
|
(e) => {
|
|
const blocCode = e.feature.affinityBlock || ''
|
|
const clCode = e.feature.cluster || ''
|
|
return clCode ? `${blocCode}__${clCode}` : ''
|
|
},
|
|
(e) => e.feature.clusterLabel || e.feature.cluster || '',
|
|
(bucket, e, isNew) => {
|
|
if (isNew) {
|
|
bucket.blocCode = e.feature.affinityBlock || ''
|
|
bucket.blocLabel = e.feature.affinityBlockLabel || ''
|
|
bucket.clusterCode = e.feature.cluster || ''
|
|
}
|
|
}
|
|
),
|
|
// People groups (imb_reg_of_people_25 / ROP 2.5) — keyed by pg code so multi-country
|
|
// pg's collapse into one row (e.g. "Deaf" with 123 country instances → 1 result).
|
|
peopleGroups: aggregate(
|
|
(e) => norm(e.feature.peopleGroup) || norm(e.feature.peopleGroupLabel),
|
|
(e) => e.feature.peopleGroupLabel || e.feature.peopleGroup || e.feature.name || '',
|
|
(bucket, e, isNew) => {
|
|
if (isNew) {
|
|
bucket.blocCode = e.feature.affinityBlock || ''
|
|
bucket.blocLabel = e.feature.affinityBlockLabel || ''
|
|
bucket.clusterCode = e.feature.cluster || ''
|
|
bucket.clusterLabel = e.feature.clusterLabel || ''
|
|
bucket.peopleGroupCode = e.feature.peopleGroup || ''
|
|
}
|
|
}
|
|
),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a Carmen-GeoJSON feature for a single people group.
|
|
*/
|
|
function makePeopleFeature(entry) {
|
|
const pg = entry.feature
|
|
const name = pg.name || pg.displayName || pg.slug || 'Unknown'
|
|
const country = pg.countryName || pg.country || pg.countryIso || ''
|
|
const religion = pg.religionName || pg.religion || ''
|
|
const slug = pg.slug || pg.uniqueId || slugify(name + '-' + country)
|
|
const center = [entry.lng, entry.lat]
|
|
|
|
const tail = [country, religion].filter(Boolean).join(' — ')
|
|
const label = tail ? `${name}, ${tail}` : name
|
|
|
|
return {
|
|
id: `doxa-pg-${slug}`,
|
|
// No emoji — the renderer (GeocoderComponent.renderSuggestion) prepends a
|
|
// labeled category tag. A clean place_name also keeps the selected-result
|
|
// text in the input box free of emoji (Round 16).
|
|
place_name: label,
|
|
text: name,
|
|
center,
|
|
place_type: ['people-group'],
|
|
geometry: { type: 'Point', coordinates: center },
|
|
properties: {
|
|
slug,
|
|
...pg,
|
|
},
|
|
feature: pg,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a Carmen-GeoJSON feature for a country / language / religion aggregate.
|
|
*/
|
|
function makeAggregateFeature(kind, agg) {
|
|
// No emoji — GeocoderComponent.renderSuggestion draws a labeled category tag
|
|
// from place_type (Round 16: "emojis replaced with clear labels or icons").
|
|
let placeType, idPrefix
|
|
switch (kind) {
|
|
case 'country': placeType = 'doxa-country'; idPrefix = 'doxa-country-'; break
|
|
case 'region': placeType = 'doxa-region'; idPrefix = 'doxa-region-'; break
|
|
case 'language-family': placeType = 'doxa-language-family'; idPrefix = 'doxa-language-family-'; break
|
|
case 'language': placeType = 'doxa-language'; idPrefix = 'doxa-language-'; break
|
|
case 'dialect': placeType = 'doxa-dialect'; idPrefix = 'doxa-dialect-'; break
|
|
case 'religion': placeType = 'doxa-religion'; idPrefix = 'doxa-religion-'; break
|
|
case 'affinity-bloc': placeType = 'doxa-affinity-bloc'; idPrefix = 'doxa-affinity-bloc-'; break
|
|
case 'cluster': placeType = 'doxa-cluster'; idPrefix = 'doxa-cluster-'; break
|
|
case 'people-group': placeType = 'doxa-people-group'; idPrefix = 'doxa-people-group-'; break
|
|
default: placeType = 'doxa'; idPrefix = 'doxa-'
|
|
}
|
|
const label = strLabel(agg.label) || agg.key
|
|
const slug = slugify(agg.key)
|
|
const center = [agg.lng, agg.lat]
|
|
// Tier suffix in the search-bar result label, mirroring the legend's
|
|
// (Cluster) / (PG) / (PGIC) convention so the tier is unambiguous when the
|
|
// same name appears at multiple levels (e.g. "Deaf" at bloc & cluster & PG,
|
|
// "Arab, Yemeni" at cluster & PG). Non-affinity kinds keep their plain form.
|
|
const TIER_SUFFIX = {
|
|
'affinity-bloc': 'Affinity Block',
|
|
'cluster': 'Cluster',
|
|
'people-group': 'PG',
|
|
// pgic isn't surfaced as a separate aggregate in v1, but reserved here for parity:
|
|
'pg-in-country': 'PGIC',
|
|
}
|
|
const tier = TIER_SUFFIX[kind]
|
|
const display = tier
|
|
? `${label} (${tier}) · ${agg.count}`
|
|
: `${label} (${agg.count})`
|
|
|
|
// Dialect features carry originalLabels + familyDerived so the geocoder
|
|
// result handler in research-map.vue can build a legend-matching dialect key
|
|
// and filter pins on the exact API language string.
|
|
const originalLabels = agg.originalLabels ? Array.from(agg.originalLabels) : undefined
|
|
const dialectExtra = (kind === 'dialect') ? {
|
|
familyDerived: agg.familyDerived || '',
|
|
baseLang: agg.baseLang || '',
|
|
dialectLabel: agg.dialectLabel || '',
|
|
} : null
|
|
|
|
return {
|
|
id: `${idPrefix}${slug}`,
|
|
place_name: display,
|
|
text: label,
|
|
center,
|
|
bbox: [agg.minLng, agg.minLat, agg.maxLng, agg.maxLat],
|
|
place_type: [placeType],
|
|
geometry: { type: 'Point', coordinates: center },
|
|
properties: {
|
|
slug,
|
|
doxaAggregate: kind,
|
|
count: agg.count,
|
|
label,
|
|
memberIds: agg.memberIds,
|
|
bounds: [agg.minLng, agg.minLat, agg.maxLng, agg.maxLat],
|
|
...(originalLabels ? { originalLabels } : {}),
|
|
...(dialectExtra || {}),
|
|
...(kind === 'affinity-bloc' ? { blocCode: agg.blocCode || '' } : {}),
|
|
...(kind === 'cluster' ? { blocCode: agg.blocCode || '', blocLabel: agg.blocLabel || '', clusterCode: agg.clusterCode || '' } : {}),
|
|
...(kind === 'people-group' ? { blocCode: agg.blocCode || '', blocLabel: agg.blocLabel || '', clusterCode: agg.clusterCode || '', clusterLabel: agg.clusterLabel || '', peopleGroupCode: agg.peopleGroupCode || '' } : {}),
|
|
},
|
|
feature: null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main composable.
|
|
*
|
|
* @param {object} opts
|
|
* @param {object} opts.dataStore Pinia data store (useDataStore())
|
|
* @param {string} [opts.dataSourceId] Source id to read features from.
|
|
* @returns {{ search: (query:string)=>Array, searchGrouped: (query:string)=>object }}
|
|
*/
|
|
export function useEntitySearch(opts = {}) {
|
|
const { dataStore, dataSourceId, getActiveFilter, getActiveContext } = opts
|
|
|
|
// Called during GeocoderComponent setup → useI18n() resolves the per-map
|
|
// i18n instance; the captured `t` localizes the dropdown's section headers
|
|
// ("Within …" / "All DOXA Data") per the active locale (loc-004).
|
|
const { t } = useI18n()
|
|
|
|
// ── Select the active source's features reactively ─────────────────────────
|
|
const features = computed(() => {
|
|
if (!dataStore || !dataStore.sources) return []
|
|
if (dataSourceId && dataStore.sources[dataSourceId]?.features) {
|
|
return dataStore.sources[dataSourceId].features
|
|
}
|
|
// Fallback: first source that has features
|
|
for (const id of Object.keys(dataStore.sources)) {
|
|
const src = dataStore.sources[id]
|
|
if (src?.features?.length) return src.features
|
|
}
|
|
return []
|
|
})
|
|
|
|
// ── Precomputed index + aggregates — rebuild when features change ──────────
|
|
const index = computed(() => buildIndex(features.value))
|
|
const aggregates = computed(() => buildAggregates(index.value))
|
|
|
|
/**
|
|
* Returns results grouped by category.
|
|
*/
|
|
function searchGrouped(query) {
|
|
const q = String(query || '').trim().toLowerCase()
|
|
const empty = { people: [], places: [], regions: [], wagfBlocks: [], families: [], languages: [], dialects: [], religions: [], blocs: [], clusters: [], peopleGroups: [] }
|
|
if (q.length < 2) return empty
|
|
|
|
const tokens = q.split(/[\s,;]+/).filter(Boolean)
|
|
if (!tokens.length) return empty
|
|
|
|
const idx = index.value
|
|
if (!idx.length) return empty
|
|
const aggs = aggregates.value
|
|
|
|
// ── People group matches ────────────────────────────────────────────────
|
|
const peopleMatches = []
|
|
for (const entry of idx) {
|
|
let allMatch = true
|
|
for (const tok of tokens) {
|
|
if (!entry.haystack.includes(tok)) { allMatch = false; break }
|
|
}
|
|
if (!allMatch) continue
|
|
|
|
let score = 0
|
|
if (tokens.some(t => entry.nameHay.includes(t))) score += SCORE_NAME
|
|
if (tokens.some(t => entry.countryHay.includes(t))) score += SCORE_COUNTRY
|
|
if (tokens.some(t => entry.religionHay.includes(t))) score += SCORE_RELIGION
|
|
if (tokens.some(t => entry.languageHay.includes(t))) score += SCORE_LANGUAGE
|
|
|
|
peopleMatches.push({ entry, score })
|
|
}
|
|
peopleMatches.sort((a, b) => b.score - a.score)
|
|
const peopleFeatures = peopleMatches
|
|
.slice(0, MAX_PER_CATEGORY)
|
|
.map(m => makePeopleFeature(m.entry))
|
|
|
|
// ── Aggregate matches (country / language / religion) ───────────────────
|
|
const matchAgg = (bucket) =>
|
|
bucket
|
|
.filter(a => tokens.every(t => a.key.includes(t) || norm(a.label).includes(t)))
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, MAX_PER_CATEGORY)
|
|
|
|
const places = matchAgg(aggs.countries).map(a => makeAggregateFeature('country', a))
|
|
const regions = matchAgg(aggs.regions ).map(a => makeAggregateFeature('region', a))
|
|
// WAGF blocks ride the SAME 'region' kind so the geocoder region branch handles
|
|
// them (it matches both region: and block: legend nodes). Keeps 'South Asia' a
|
|
// first-class, selectable search row.
|
|
const wagfBlocks = matchAgg(aggs.wagfBlocks).map(a => makeAggregateFeature('region', a))
|
|
const families = matchAgg(aggs.families ).map(a => makeAggregateFeature('language-family', a))
|
|
const languages = matchAgg(aggs.languages).map(a => makeAggregateFeature('language', a))
|
|
const dialects = matchAgg(aggs.dialects ).map(a => makeAggregateFeature('dialect', a))
|
|
const religions = matchAgg(aggs.religions).map(a => makeAggregateFeature('religion', a))
|
|
const blocs = matchAgg(aggs.blocs ).map(a => makeAggregateFeature('affinity-bloc', a))
|
|
let clusters = matchAgg(aggs.clusters ).map(a => makeAggregateFeature('cluster', a))
|
|
let peopleGroups = matchAgg(aggs.peopleGroups).map(a => makeAggregateFeature('people-group', a))
|
|
|
|
// Dedupe across tiers when the LABEL matches a higher-tier entity AND it's
|
|
// under the same bloc — e.g. "Deaf" exists at bloc/cluster/PG levels but
|
|
// refers to the same entity, so only keep the bloc result. Same for "Arab,
|
|
// Yemeni" if it appears as both cluster and PG within the Arab bloc.
|
|
const blocLabels = new Set(blocs.map(b => norm(b.text)))
|
|
clusters = clusters.filter(c => !blocLabels.has(norm(c.text)))
|
|
const clusterLabels = new Set(clusters.map(c => norm(c.text)))
|
|
peopleGroups = peopleGroups.filter(p => !blocLabels.has(norm(p.text)) && !clusterLabels.has(norm(p.text)))
|
|
|
|
return { people: peopleFeatures, places, regions, wagfBlocks, families, languages, dialects, religions, blocs, clusters, peopleGroups }
|
|
}
|
|
|
|
/**
|
|
* Flat array form — what MapboxGeocoder's `localGeocoder` option expects.
|
|
* When getActiveFilter is provided and returns a live selection, results are
|
|
* split into two labelled sections: "Within [selection]" and "All DOXA Data".
|
|
* Section header features (place_type: doxa-section-header) are injected as
|
|
* visual dividers; clicking them is suppressed in GeocoderComponent.vue.
|
|
* "All DOXA Data" results carry properties._allDataSection = true so the
|
|
* geocoder handler knows to deselect the legend when they are clicked.
|
|
*/
|
|
function search(query) {
|
|
const activeFilter = typeof getActiveFilter === 'function' ? getActiveFilter() : null
|
|
const activeContext = typeof getActiveContext === 'function' ? getActiveContext() : null
|
|
const g = searchGrouped(query)
|
|
// Context-aware ordering (Round 16): the active legend tab decides which
|
|
// category surfaces FIRST, while people-groups stay reachable from every
|
|
// tab. groupOrder() returns the category sequence for the active context
|
|
// (or the default order when no context is set). This replaces the fixed
|
|
// semantic-tree-first order; the same context drives the "All DOXA Data"
|
|
// section below so both halves of the dropdown agree. qa: 2026-05-02.
|
|
const order = groupOrder(activeContext)
|
|
// Context-aware FILTERING (not just ordering): each tab's search should only
|
|
// surface result types relevant to that tab's legend domain — e.g. the
|
|
// Regions tab must NOT show affinity blocks / clusters / people groups, only
|
|
// countries + WAGF regions. groupOrder() already ranks; this restricts the
|
|
// category SET. Unknown/empty context → all categories (no regression).
|
|
const allowed = ALLOWED_BY_CONTEXT[activeContext]
|
|
const gFiltered = allowed
|
|
? Object.fromEntries(allowed.filter(k => g[k]).map(k => [k, g[k]]))
|
|
: g
|
|
const allFlat = order.flatMap(k => gFiltered[k] || [])
|
|
|
|
if (!activeFilter?.key || !allFlat.length) {
|
|
return allFlat.slice(0, MAX_TOTAL)
|
|
}
|
|
|
|
// Split people-group results into "within selection" and "outside"
|
|
const withinPeople = []
|
|
const outsidePeople = []
|
|
for (const feat of g.people) {
|
|
const entry = index.value.find(e => e.feature === feat.feature)
|
|
let matches = false
|
|
if (entry) {
|
|
if (activeFilter.kind === 'family') {
|
|
matches = (entry.familyDerived || '').toLowerCase() === activeFilter.key.toLowerCase()
|
|
} else if (activeFilter.kind === 'language') {
|
|
matches = entry.languageLabel.toLowerCase() === activeFilter.key.toLowerCase()
|
|
}
|
|
}
|
|
if (matches) withinPeople.push(feat)
|
|
else outsidePeople.push(feat)
|
|
}
|
|
|
|
// Build sectioned output
|
|
const result = []
|
|
const selectionLabel = activeFilter.key
|
|
if (withinPeople.length) {
|
|
result.push(makeSectionHeader(t('search.sections.within', { selection: selectionLabel }), false))
|
|
result.push(...withinPeople.slice(0, MAX_PER_CATEGORY))
|
|
}
|
|
// "All DOXA Data" section: take a capped sampling from EACH kind so one
|
|
// prolific category (usually people-groups) can't bury the rest, walked in
|
|
// the SAME context order as the flat list so the context-relevant category
|
|
// leads here too (Round 16). Per-kind caps live in ALLDATA_CAPS.
|
|
const allTagged = order
|
|
.flatMap(k => (gFiltered[k] || []).slice(0, ALLDATA_CAPS[k] ?? 3))
|
|
.map(f => ({ ...f, properties: { ...f.properties, _allDataSection: true } }))
|
|
result.push(makeSectionHeader(t('search.sections.allData'), true))
|
|
result.push(...allTagged)
|
|
|
|
return result.slice(0, MAX_TOTAL + 8) // extra room for 2 headers + extra all-data items
|
|
}
|
|
|
|
return { search, searchGrouped, index, aggregates }
|
|
}
|
|
|
|
export default useEntitySearch
|