doxa-maps/library/components/ResearchMapSideMenu.vue
doxa-maps-88655c70 471bd05cea Stop showing English where the catalogue already holds the translation
A reader on an Arabic page saw a handful of English words among the Arabic:
the table's "People Groups" title, its count line, the Engagement column, the
Gospel Access chart title, and the side drawer's close button. Every one of them
had a correct Arabic entry sitting in the catalogue; nothing looked it up.

THE CHECK CAME FIRST, AND IT REPLACES A SWEEP THAT PASSED A BROKEN TREE.
The old method sorted every rendered string into third-party / data / chrome and
counted only chrome. That split is a guess, and it guessed wrong exactly where
the defect lived: single words like "Region", "Prayer" and "Country" are as
plausible as values as they are as labels, so they scored as data and the sweep
reported zero on a page that was visibly leaking.

check:composite-locale-leaks asks a question with an answer instead:

    the English catalogue holds this exact string as a value, AND
    this locale holds a DIFFERENT value for that key, AND
    the page still renders the English one.

No judgement about what kind of word it is — if the translation exists and the
reader is not getting it, that is the defect. The two surfaces a lookup cannot
judge (vendor map chrome, and table/facet cells carrying API values) are muted
by NAME with a stated reason, never by word, so muting one can never hide the
next real leak that happens to reuse it. The gate failed on this tree naming 9
strings before any of them were fixed.

Also threaded here: both composites now accept `lang` / `locale` in
profile-config and pass it to the children they mount, alongside `tk` and
`provider`. A partner writing {"lang":"ar"} reached nothing before — the knob
was documented and read by nobody.

The prayer facet's label in DashboardResearchFilters is now a catalogue getter
too. Measured before touching it: dashboard.config.js does define prayer_level
and FACETS spreads the config last, so that literal never rendered for this
config — it is a default for a config that omits the facet, and it stays, with
the merge order written down so the next reader need not re-derive it.

Four strings remain English with no catalogue entry at all — "Top countries",
"Select all {n}", the table dock's title, and the singular "group". They need
wording in ten languages that cannot be composed from existing entries, so they
are a DECIDE card for the Driver rather than translations invented here.
2026-09-25 12:16:59 +00:00

146 lines
8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* ResearchMapSideMenu.vue — Side drawer specific to the research-map profile.
*
* Hosts:
* - Cluster mode toggle (off / mapbox / mst / network)
* - Pin-opacity dim slider (visible only when clustering is on)
* - Slot for the multi-field filter panel (parent passes <ResearchMapFilterPanel />)
* - Poster export button (emits 'open-poster' — parent opens PosterDialog)
* - Reload-data button
*
* Reads/writes mapStore.clusteringMode and mapStore.clusterPinOpacity.
* Does NOT touch the map directly — the useMapClustering composable in the
* profile subscribes to those store fields.
*
* Props:
* isOpen — drawer visible flag (parent owns)
* Emits:
* close — backdrop or X clicked
* open-poster — poster button clicked
* reload-map — reload-data button clicked
*
* Slot (default): filter UI (kept as a slot so other profiles can pass their
* own filter component without forking this drawer).
*/
import { inject, computed } from 'vue'
import { useShadowStyles } from '../composables/useShadowStyles.js'
defineProps({
isOpen: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'open-poster', 'reload-map'])
import { tStatic } from '@map/i18n/index.js'
// ★ THIS PANEL WAS ENGLISH IN ALL ELEVEN LOCALES. Measured on the composite under ?lang=ru: every
// Latin-script string left on the page belonged to this menu and its filter sibling — the map
// around them was fully translated. Read through tStatic rather than useI18n: these components are
// reachable from mounts that install no i18n plugin, and an unguarded useI18n() throws and takes
// the subtree with it (the defect that blanked people-groups-grid, fixed in 6603dc17). English
// stays as the last argument, so a missing key degrades to today's text rather than to a key name.
const L = (key, en) => tStatic('controls.' + key) || tStatic('dashboard.' + key) || en
const mapStore = inject('mapStore')
const uiStore = inject('uiStore')
// ─── Cluster controls ────────────────────────────────────────────────────────
const CLUSTER_MODES = [
{ id: 'off', label: L('off', 'Off'), desc: L('off_desc', 'Show every pin individually') },
{ id: 'mapbox', label: L('native', 'Native'), desc: L('native_desc', 'Mapbox circle clusters (fastest)') },
{ id: 'mst', label: L('mst', 'MST'), desc: L('mst_desc', 'Minimum-spanning-tree affinity webs') },
{ id: 'network', label: L('network', 'Network'), desc: L('network_desc', 'Nearest-neighbor connection lines') }
]
const clusterMode = computed({
get: () => mapStore?.clusteringMode ?? 'off',
set: (v) => { if (mapStore) mapStore.clusteringMode = v }
})
const pinOpacity = computed({
get: () => mapStore?.clusterPinOpacity ?? 0.6,
set: (v) => { if (mapStore) mapStore.clusterPinOpacity = Number(v) }
})
useShadowStyles(`
.rm-side-backdrop { position:absolute;inset:0;background:rgba(0,0,0,0.45);z-index:1500;opacity:0;pointer-events:none;transition:opacity .18s; }
.rm-side-backdrop.open { opacity:1;pointer-events:auto; }
/* box-shadow lives on .open ONLY: when closed the drawer is translateX(100%) off
the right edge, but a left-pointing shadow (-4px) bled back onto the map's right
edge — the mystery "shadow on the right side" (coder staging QA 2026-06-25). */
.rm-side-drawer { position:absolute;top:0;right:0;bottom:0;width:340px;max-width:100vw;background:#171c23;color:#e7ebf0;border-left:1px solid #232a33;z-index:1600;transform:translateX(100%);transition:transform .22s ease;display:flex;flex-direction:column;font:13px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.rm-side-drawer.open { transform:translateX(0); box-shadow:-4px 0 16px rgba(0,0,0,0.4); }
.rm-side-header { display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid #232a33;flex:0 0 auto; }
.rm-side-title { font-size:14px;font-weight:600;color:#e7ebf0;margin:0; }
.rm-side-close { background:none;border:none;color:#a8b2bd;font-size:22px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:4px; }
.rm-side-close:hover { color:#e7ebf0;background:rgba(255,255,255,0.06); }
.rm-side-body { flex:1 1 auto;overflow-y:auto;padding:14px 18px; }
.rm-section { margin-bottom:22px; }
.rm-section h4 { margin:0 0 8px;font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#a8b2bd; }
.rm-cluster-grid { display:grid;grid-template-columns:1fr 1fr;gap:6px; }
.rm-cluster-btn { background:#232a33;border:1px solid #2c343f;color:#e7ebf0;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:12px;text-align:left;line-height:1.3; }
.rm-cluster-btn:hover { background:#2c343f;border-color:#3a4250; }
.rm-cluster-btn.active { background:rgba(124,140,248,0.16);border-color:#7c8cf8;color:#a8b8ff; }
.rm-cluster-btn .label { display:block;font-weight:600;font-size:12px;margin-bottom:2px; }
.rm-cluster-btn .desc { display:block;font-size:10.5px;color:#a8b2bd; }
.rm-slider-row { display:flex;align-items:center;gap:10px;margin-top:8px; }
.rm-slider-row input[type=range] { flex:1; }
.rm-slider-row .val { width:36px;text-align:right;font-variant-numeric:tabular-nums;color:#a8b2bd;font-size:11px; }
.rm-side-footer { flex:0 0 auto;padding:12px 18px;border-top:1px solid #232a33;display:flex;flex-direction:column;gap:8px; }
.rm-btn { display:flex;align-items:center;justify-content:center;gap:8px;background:#7c8cf8;border:none;color:#0f1216;padding:10px 14px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600; }
.rm-btn:hover { background:#9aa6ff; }
.rm-btn.ghost { background:transparent;color:#a8b2bd;border:1px solid #2c343f; }
.rm-btn.ghost:hover { background:#232a33;color:#e7ebf0; }
`, 'research-map-side-menu')
</script>
<template>
<div class="rm-side-backdrop" :class="{ open: isOpen }" @click="emit('close')" />
<aside class="rm-side-drawer" :class="{ open: isOpen }" role="dialog" :aria-label="L('map_controls', 'Research map controls')">
<header class="rm-side-header">
<h3 class="rm-side-title">{{ L('research_controls', 'Research controls') }}</h3>
<button class="rm-side-close" :aria-label="tStatic('buttons.close') || 'Close'" @click="emit('close')">×</button>
</header>
<div class="rm-side-body">
<!-- Clustering ─────────────────────────────────────────────────── -->
<div class="rm-section">
<h4>{{ L('clustering', 'Clustering') }}</h4>
<div class="rm-cluster-grid">
<button
v-for="m in CLUSTER_MODES"
:key="m.id"
class="rm-cluster-btn"
:class="{ active: clusterMode === m.id }"
@click="clusterMode = m.id"
>
<span class="label">{{ m.label }}</span>
<span class="desc">{{ m.desc }}</span>
</button>
</div>
<div v-if="clusterMode !== 'off'" class="rm-slider-row">
<label for="rm-pin-op">{{ L('pin_dim', 'Pin dim') }}</label>
<input
id="rm-pin-op"
type="range"
min="0.05"
max="1"
step="0.05"
:value="pinOpacity"
@input="pinOpacity = $event.target.value"
/>
<span class="val">{{ Math.round(pinOpacity * 100) }}%</span>
</div>
</div>
<!-- Filter panel slot ──────────────────────────────────────────── -->
<div class="rm-section">
<h4>{{ L('filters', 'Filters') }}</h4>
<slot />
</div>
</div>
<footer class="rm-side-footer">
<button class="rm-btn" @click="emit('open-poster')">{{ L('export_poster', 'Export poster…') }}</button>
<button class="rm-btn ghost" @click="emit('reload-map')">{{ L('reload_data', 'Reload data') }}</button>
</footer>
</aside>
</template>