Measured under ?lang=ru on both composites: every Latin-script string left on the
page was ours. The map above was fully translated; underneath it the grid, the
map's own side menu and filter panel, and the whole research dashboard were
English in Arabic, Chinese, Hindi, Russian and everything else.
Three surfaces, three reasons, none of them a missing translation:
the grid read labels from a host-supplied `t` object that nothing ever
filled, with an English literal at each call site as fallback
side menu +
filter panel plain string literals
dashboard labels declared as literals in dashboard.config.js — facet
rail, chart titles, KPI strip, table headers, segmented
control, search placeholder
New catalogues for all 11 locales (grid, controls, dashboard), REUSING the
catalogue's existing wording wherever the word already existed — Регион, Блок
родства, Население, Вовлечён, Графики — so two panels never name one thing two
ways, and nothing is coined that the project already has a word for.
Read through a new plugin-free tStatic() rather than useI18n, because these
components are also mounted by entries that install no i18n plugin. An unguarded
useI18n() throws and a thrown setup() removes the subtree with no build error —
the defect that had just blanked people-groups-grid everywhere (6603dc17).
★ THE CONFIG LABELS ARE GETTERS, AND A SPREAD WOULD HAVE LOOKED IDENTICAL.
dashboard.config.js is imported once at load; the locale is only known later. My
first version spread a getter object, and a spread COPIES THE RESOLVED VALUE, not
the accessor — every label would have frozen at whichever locale loaded first,
and it would have looked perfect in English. `get label() {…}` resolves on each
read.
MEASURED after, served built tree, identity checked first: Big Picture ru/ar and
Simple Research ru/zh have ZERO Latin-script chrome strings. What remains is
Mapbox's licence attribution (never translate), country and people-group names
(data), and 'UUPGs' — which every locale file deliberately keeps as the acronym.
Three gates went red on the way, two of them mine and worth recording:
check:locale-reachable slices this i18n module between two markers and EVALS
the slice; I had put tStatic between them, so the gate
hit an `export`. Moved to the end of the file.
check:library-index my import+helper above a component's docblock became
that component's DERIVED description. Moved below it.
check:kpi-unit its parser assumed single-brace entries, so getters made
it find ZERO pct KPIs — and it failed rather than
printing a green PASS about nothing, which is the gate
working. Parse now understands getters AND checks the
translated labels, so a '%' reintroduced in any of the
11 catalogues fails by name. Both controls fired.
210 lines
9.7 KiB
Vue
210 lines
9.7 KiB
Vue
<script setup>
|
|
/**
|
|
* ResearchMapFilterPanel.vue — Multi-field filter UI for the research-map.
|
|
*
|
|
* Writes to mapStore.filters. The research-map profile watches that object
|
|
* (deep) and forwards changes to useMapLayers.applyFilters() and
|
|
* useMapClustering.applyFilters() — this component never touches the map.
|
|
*
|
|
* Filter shape (mirrors what useMapLayers expects):
|
|
* {
|
|
* populationMin: number | null,
|
|
* populationMax: number | null,
|
|
* engagement: 'any' | 'engaged' | 'unengaged',
|
|
* adoption: 'any' | 'adopted' | 'unadopted',
|
|
* languages: string[] // empty = no language filter
|
|
* religions: string[] // empty = no religion filter
|
|
* }
|
|
*
|
|
* Languages + religions options come from dataStore.facets (computed by
|
|
* useMapData when normalizing). If facets aren't ready yet, we render a
|
|
* placeholder so the panel never blocks initial paint.
|
|
*/
|
|
import { tStatic } from '@map/i18n/index.js'
|
|
// See ResearchMapSideMenu for why this reads the catalogue directly rather than through useI18n.
|
|
const L = (key, en) => tStatic('controls.' + key) || en
|
|
|
|
import { inject, computed, watch, ref } from 'vue'
|
|
import { useShadowStyles } from '../composables/useShadowStyles.js'
|
|
|
|
const mapStore = inject('mapStore')
|
|
const dataStore = inject('dataStore')
|
|
|
|
// ─── Default filter state — initialized into mapStore on mount ───────────────
|
|
const DEFAULTS = {
|
|
populationMin: null,
|
|
populationMax: null,
|
|
engagement: 'any',
|
|
adoption: 'any',
|
|
languages: [],
|
|
religions: []
|
|
}
|
|
|
|
if (mapStore && !mapStore.filters) mapStore.filters = { ...DEFAULTS }
|
|
|
|
// ─── Local two-way bindings (write through to mapStore.filters) ──────────────
|
|
const popMin = computed({
|
|
get: () => mapStore?.filters?.populationMin ?? '',
|
|
set: (v) => { mapStore.filters.populationMin = v === '' ? null : Number(v) }
|
|
})
|
|
const popMax = computed({
|
|
get: () => mapStore?.filters?.populationMax ?? '',
|
|
set: (v) => { mapStore.filters.populationMax = v === '' ? null : Number(v) }
|
|
})
|
|
const engagement = computed({
|
|
get: () => mapStore?.filters?.engagement ?? 'any',
|
|
set: (v) => { mapStore.filters.engagement = v }
|
|
})
|
|
const adoption = computed({
|
|
get: () => mapStore?.filters?.adoption ?? 'any',
|
|
set: (v) => { mapStore.filters.adoption = v }
|
|
})
|
|
|
|
// ─── Multi-select chips for language + religion ──────────────────────────────
|
|
const facets = computed(() => dataStore?.facets ?? {})
|
|
const languageOptions = computed(() => facets.value?.languages ?? [])
|
|
const religionOptions = computed(() => facets.value?.religions ?? [])
|
|
|
|
function toggleLang(name) {
|
|
const arr = mapStore.filters.languages ?? []
|
|
const idx = arr.indexOf(name)
|
|
if (idx === -1) arr.push(name)
|
|
else arr.splice(idx, 1)
|
|
mapStore.filters.languages = [...arr]
|
|
}
|
|
function toggleRel(name) {
|
|
const arr = mapStore.filters.religions ?? []
|
|
const idx = arr.indexOf(name)
|
|
if (idx === -1) arr.push(name)
|
|
else arr.splice(idx, 1)
|
|
mapStore.filters.religions = [...arr]
|
|
}
|
|
|
|
const langSearch = ref('')
|
|
const relSearch = ref('')
|
|
const filteredLangs = computed(() => {
|
|
const q = langSearch.value.trim().toLowerCase()
|
|
return q ? languageOptions.value.filter(l => l.toLowerCase().includes(q)) : languageOptions.value
|
|
})
|
|
const filteredRels = computed(() => {
|
|
const q = relSearch.value.trim().toLowerCase()
|
|
return q ? religionOptions.value.filter(r => r.toLowerCase().includes(q)) : religionOptions.value
|
|
})
|
|
|
|
function isLangActive(name) { return (mapStore?.filters?.languages ?? []).includes(name) }
|
|
function isRelActive(name) { return (mapStore?.filters?.religions ?? []).includes(name) }
|
|
|
|
// ─── Reset all ───────────────────────────────────────────────────────────────
|
|
function resetAll() {
|
|
if (!mapStore) return
|
|
mapStore.filters = { ...DEFAULTS, languages: [], religions: [] }
|
|
}
|
|
|
|
// ─── Active-count summary (for footer chip) ──────────────────────────────────
|
|
const activeCount = computed(() => {
|
|
const f = mapStore?.filters ?? {}
|
|
let n = 0
|
|
if (f.populationMin != null) n++
|
|
if (f.populationMax != null) n++
|
|
if (f.engagement && f.engagement !== 'any') n++
|
|
if (f.adoption && f.adoption !== 'any') n++
|
|
if (f.languages?.length) n++
|
|
if (f.religions?.length) n++
|
|
return n
|
|
})
|
|
|
|
useShadowStyles(`
|
|
.rm-filter { display:flex;flex-direction:column;gap:14px;font:12px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#e7ebf0; }
|
|
.rm-filter .row { display:flex;align-items:center;gap:8px; }
|
|
.rm-filter label { font-size:11px;color:#a8b2bd;letter-spacing:.04em;text-transform:uppercase;font-weight:600; }
|
|
.rm-filter .field { display:flex;flex-direction:column;gap:4px; }
|
|
.rm-filter input[type=number],
|
|
.rm-filter select,
|
|
.rm-filter input[type=search] { background:#0f1216;border:1px solid #2c343f;color:#e7ebf0;padding:6px 8px;border-radius:5px;font-size:12px;min-width:0;flex:1; }
|
|
/* ALL search bars = exactly 35px tall, desktop AND mobile. These side-menu
|
|
filter search inputs are "search bar instances" too.
|
|
box-sizing:border-box pins it to exactly 35px. */
|
|
.rm-filter input[type=search] { height:35px;box-sizing:border-box; }
|
|
.rm-filter input:focus,
|
|
.rm-filter select:focus { outline:none;border-color:#7c8cf8; }
|
|
.rm-pop-row { display:grid;grid-template-columns:1fr 1fr;gap:8px; }
|
|
.rm-chips { display:flex;flex-wrap:wrap;gap:5px;max-height:140px;overflow-y:auto;padding:4px 2px;border:1px solid #232a33;border-radius:5px;background:#0f1216; }
|
|
.rm-chip { background:#232a33;border:1px solid #2c343f;color:#e7ebf0;padding:3px 8px;border-radius:11px;cursor:pointer;font-size:11px;line-height:1.4; }
|
|
.rm-chip:hover { background:#2c343f; }
|
|
.rm-chip.active { background:rgba(124,140,248,0.18);border-color:#7c8cf8;color:#a8b8ff; }
|
|
.rm-empty { padding:8px;color:#a8b2bd;font-size:11px;text-align:center; }
|
|
.rm-filter-footer { display:flex;align-items:center;justify-content:space-between;margin-top:6px; }
|
|
.rm-pill { background:rgba(124,140,248,0.12);color:#a8b8ff;padding:2px 8px;border-radius:11px;font-size:11px;font-weight:600; }
|
|
.rm-link-btn { background:none;border:none;color:#7c8cf8;cursor:pointer;font-size:11px;padding:0; }
|
|
.rm-link-btn:hover { text-decoration:underline; }
|
|
`, 'research-map-filter-panel')
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rm-filter">
|
|
<!-- Population range ──────────────────────────────────────────────── -->
|
|
<div class="field">
|
|
<label>{{ L('population', 'Population') }}</label>
|
|
<div class="rm-pop-row">
|
|
<input type="number" min="0" :placeholder="L('min', 'min')" :value="popMin" @input="popMin = $event.target.value" />
|
|
<input type="number" min="0" :placeholder="L('max', 'max')" :value="popMax" @input="popMax = $event.target.value" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Engagement / adoption status ──────────────────────────────────── -->
|
|
<div class="field">
|
|
<label>{{ L('engagement', 'Engagement') }}</label>
|
|
<select :value="engagement" @change="engagement = $event.target.value">
|
|
<option value="any">Any</option>
|
|
<option value="engaged">{{ L('engaged', 'Engaged') }}</option>
|
|
<option value="unengaged">{{ L('unengaged', 'Unengaged') }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>{{ L('adoption', 'Adoption') }}</label>
|
|
<select :value="adoption" @change="adoption = $event.target.value">
|
|
<option value="any">Any</option>
|
|
<option value="adopted">{{ L('adopted', 'Adopted') }}</option>
|
|
<option value="unadopted">{{ L('unadopted', 'Unadopted') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Language multi-select ─────────────────────────────────────────── -->
|
|
<div class="field">
|
|
<label>{{ L('language', 'Language') }}</label>
|
|
<input type="search" v-model="langSearch" :placeholder="L('search_languages', 'Search languages…')" />
|
|
<div class="rm-chips">
|
|
<span v-if="!filteredLangs.length" class="rm-empty">{{ L('no_languages', 'No languages loaded yet') }}</span>
|
|
<button
|
|
v-for="lang in filteredLangs"
|
|
:key="lang"
|
|
class="rm-chip"
|
|
:class="{ active: isLangActive(lang) }"
|
|
@click="toggleLang(lang)"
|
|
>{{ lang }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Religion multi-select ─────────────────────────────────────────── -->
|
|
<div class="field">
|
|
<label>{{ L('religion', 'Religion') }}</label>
|
|
<input type="search" v-model="relSearch" :placeholder="L('search_religions', 'Search religions…')" />
|
|
<div class="rm-chips">
|
|
<span v-if="!filteredRels.length" class="rm-empty">{{ L('no_religions', 'No religions loaded yet') }}</span>
|
|
<button
|
|
v-for="rel in filteredRels"
|
|
:key="rel"
|
|
class="rm-chip"
|
|
:class="{ active: isRelActive(rel) }"
|
|
@click="toggleRel(rel)"
|
|
>{{ rel }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer: active count + reset link ─────────────────────────────── -->
|
|
<div class="rm-filter-footer">
|
|
<span class="rm-pill">{{ activeCount }} active</span>
|
|
<button class="rm-link-btn" @click="resetAll">{{ L('reset_all', 'Reset all') }}</button>
|
|
</div>
|
|
</div>
|
|
</template>
|