Measured all seven production maps at 390x844 with touch emulation. 195 controls sat below WCAG 2.5.8's AA minimum of 24x24 CSS px, spread across five maps -- but they were not five owners' bugs. Grouped by class, 195 failures were ten classes, and 119 of them lived in library/: the same PosterWall, PinHint, LegendMobile, SemanticTreeLegend and ResearchMapFilterPanel controls repeated down a list and across four maps. Routing the per-map counts would have filed hundreds of defects against owners who do not own the geometry. Raised the shared controls, scoped to (pointer:coarse). 2.5.8 is a touch ergonomics rule, and these components sit on maps this change does not own: a finger gets the bigger target, the mouse keeps the density those owners designed. The poster row is the reason this matters -- its height was tuned by eye against the card's blue outline, and growing it for a pointer that needs no help would have spent the artwork clearance to fix nothing. min-height states the 24 instead of deriving it. Growing the padding first MEASURED 23.7px and 23.0px -- under by a third of a pixel and by one, because the height is padding plus a line box the renderer computes from a 10.5px font. The declared minimum also survives a font swap or a locale with taller glyphs. The header tape now self-centres like the footer. It hung on top:-11px, half of its then-fixed 22px height, which silently becomes wrong the moment the height changes -- as it just did. top:0 with translate(-50%,-50%) sits on the border at any height, the mechanism the footer already used, and one less number to keep in sync. Two exceptions in 2.5.8 are now encoded in the probe rather than remembered. Inline: 48 .uupg__country-link "failures" are an <a> inside a paragraph, computed display:inline, sized by the line-height of the text around it -- the spec exempts them and the probe now skips display:inline, counting them separately. Equivalent: a .lpb-seg progress segment is 10px tall inside a track that clips overflow, so no hit-area extender can reach 24px; it resolves to a tree node and calls the same selectNode a full-width legend row calls. 195 failures on five maps -> 4 on two. UPG-100 alone went 137 -> 0. The poster tape gate and per-row persistence still pass.
212 lines
9.5 KiB
Vue
212 lines
9.5 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 { 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; }
|
|
/* WCAG 2.5.8 Target Size (Minimum), AA = 24x24 CSS px. Scoped to (pointer:coarse):
|
|
2.5.8 governs touch ergonomics, and these are SHARED components sitting on maps this
|
|
change does not own -- a finger gets the bigger target, the mouse keeps the density
|
|
those owners designed. min-height states the 24 rather than deriving it from a font. */
|
|
@media (pointer:coarse) { .rm-link-btn { min-height: 24px; } }
|
|
`, 'research-map-filter-panel')
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rm-filter">
|
|
<!-- Population range ──────────────────────────────────────────────── -->
|
|
<div class="field">
|
|
<label>Population</label>
|
|
<div class="rm-pop-row">
|
|
<input type="number" min="0" placeholder="min" :value="popMin" @input="popMin = $event.target.value" />
|
|
<input type="number" min="0" placeholder="max" :value="popMax" @input="popMax = $event.target.value" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Engagement / adoption status ──────────────────────────────────── -->
|
|
<div class="field">
|
|
<label>Engagement</label>
|
|
<select :value="engagement" @change="engagement = $event.target.value">
|
|
<option value="any">Any</option>
|
|
<option value="engaged">Engaged</option>
|
|
<option value="unengaged">Unengaged</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Adoption</label>
|
|
<select :value="adoption" @change="adoption = $event.target.value">
|
|
<option value="any">Any</option>
|
|
<option value="adopted">Adopted</option>
|
|
<option value="unadopted">Unadopted</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Language multi-select ─────────────────────────────────────────── -->
|
|
<div class="field">
|
|
<label>Language</label>
|
|
<input type="search" v-model="langSearch" placeholder="Search languages…" />
|
|
<div class="rm-chips">
|
|
<span v-if="!filteredLangs.length" class="rm-empty">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>Religion</label>
|
|
<input type="search" v-model="relSearch" placeholder="Search religions…" />
|
|
<div class="rm-chips">
|
|
<span v-if="!filteredRels.length" class="rm-empty">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">Reset all</button>
|
|
</div>
|
|
</div>
|
|
</template>
|