TWO FIXES THAT BOTH SHOW UP ONLY WHEN THESE PLUGINS ARE SOMEBODY ELSE'S GUEST. 1. ATTRIBUTE FALLTHROUGH. These SFCs mount AS custom elements, so a style the host puts on the tag lands on the component's own root element. MEASURED with a host style of "display:flex; position:relative; height:100%": that exact string appeared verbatim on the inner root div of people-groups-grid, stack-demo, research-map-plus-grid and dashboard-plus-grid. The gallery sets one on every tab, and the same fallthrough collapsed the Prayer Walk Planner to 76px. inheritAttrs:false on each root — a host styling the TAG is styling the box, not our internals. AFTER: the inner root carries no inline style on any of the four, and each still renders full height (2997-4010px in the harness), so the guard did not become the collapse it prevents. Worth knowing for the next probe: shadowRoot.firstElementChild is a <style> node — Vue injects styles first — so a probe that trusts it measures nothing and reports "no fallthrough, nothing to fix". Take the first non-style child. 2. THE RESIDUAL CHROME the in-gallery re-measure found: the dashboard title, Align Dashboard and its tooltip, World, Vertical Legend, groups in scope, the per-row aria templates (Filter by / Remove filter / groups), and the map drawer's aria label. Catalogued in all 11 locales and read through tStatic, reusing existing wording where the catalogue already had the word. The aria TEMPLATES matter more than their size suggests: they repeat per row, so one untranslated template is dozens of English strings in a screen-reader's ear.
146 lines
8 KiB
Vue
146 lines
8 KiB
Vue
<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="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>
|