doxa-maps/library/components/ResearchMapSideMenu.vue
doxa-maps-88655c70 b4a0387495 Translate the chrome that was English in all eleven locales
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.
2026-09-25 06:10:27 +00:00

146 lines
7.9 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) || 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="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="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>