doxa-maps/plugin-designs/pplr-data-maps/src/components/PinDetailCard.vue
Doxa Maps 5d67a3969d PPLR maps: the legend and the instance store are the library's, not drifted copies
pplr carried private copies of SemanticTreeLegend and usePplrInstance that had drifted.
The instance copy lacked activeTabTouched, so the legend tab snapped back to its
default after a pick; the legend copy spoke English accessibility labels in every
locale. Both are deleted and the views import the library versions.
2026-09-28 03:58:06 +00:00

279 lines
11 KiB
Vue

<script setup>
/**
* PinDetailCard.vue — replaces the SemanticTreeLegend when a pin is clicked.
*
* Per QA defining-constrained-problem1.md: "when someone clicks on a people group,
* it converts the legend into a display section for the people groups, reusing the
* same component slot." We don't reuse SemanticTreeLegend's component but we DO
* reuse its CSS aesthetic (rounded card, dark theme, slide-collapse) so the swap
* feels seamless.
*
* Reads `instance.pinDetail` (set by the profile when a pin click fires) and
* shows: name, country, religion + color dot, language, all cross-system IDs
* (IMB / JP / WCD / PEID / ROP fields), plus "Locate on map" and "Close" actions.
*
* Architecture: Same instance store as legend + geocoder. Closing this card
* (X button or Esc) clears instance.pinDetail and the legend reappears.
*/
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useInstance } from '@map/composables/usePplrInstance.js'
import { LOCALES, LOCALE_NAMES } from '../utils/pplrMap.js'
import { loadNames } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
const props = defineProps({
/** Optional override — usually read from instance.pinDetail */
pin: { type: Object, default: null },
/** Optional color resolver: kind→color (e.g., bloc color, religion color) */
color: { type: String, default: '#73A17F' },
})
const emit = defineEmits(['close', 'locate'])
const instance = useInstance()
const tr = usePplrT() // not `t`: the names table below is already called t
const pinData = computed(() => props.pin || instance?.pinDetail.value || null)
// All 17 locale names for this PG (n_en, n_hi, n_bn, …). Per user 2026-05-02:
// the popup-style display couldn't show all 17, so we render them as their own
// scrollable section in the sidebar with "—" placeholders for missing locales.
// The 17 names no longer ride on every pin (they were two-thirds of the 23 MB points file); they
// load from pg-names.json the first time a card opens, keyed by PG code in LOCALES order.
const namesTable = ref(null)
watch(pinData, (p) => {
if (p && !namesTable.value) loadNames().then((t) => { namesTable.value = t }).catch(() => {})
}, { immediate: true })
const locales = computed(() => {
if (!pinData.value) return []
const p = pinData.value
const t = namesTable.value
const row = t && t.names[p.code]
return LOCALES.map((code, i) => ({
code,
name: LOCALE_NAMES[code] || code,
value: p[`n_${code}`] || (row && row[t.locales.indexOf(code)]) || null,
}))
})
const fields = computed(() => {
if (!pinData.value) return []
const p = pinData.value
const dash = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
// Curated field order — most identifying first, registry IDs last
return [
{ label: tr('pplr.popup.country'), value: dash(p.country), mono: false },
{ label: tr('pplr.popup.religion'), value: dash(p.religion), mono: false },
{ label: tr('pplr.popup.language'), value: dash(p.language), mono: false },
{ label: tr('pplr.popup.iso3Country'), value: dash(p.iso3), mono: true, hidden: !p.iso3 },
{ label: tr('pplr.popup.affinityBloc'), value: dash(p.rop1), mono: true, hidden: !p.rop1 },
{ label: tr('pplr.popup.peopleCluster'), value: dash(p.rop2), mono: true, hidden: !p.rop2 },
{ label: 'ROP25', value: dash(p.rop3), mono: true, hidden: !p.rop3 },
{ label: 'PGIC GeoID', value: dash(p.rop25), mono: true, hidden: !p.rop25 },
{ label: 'PEID', value: dash(p.peid), mono: true, hidden: !p.peid },
{ label: 'IMB PGID', value: dash(p.imb), mono: true, hidden: !p.imb },
{ label: 'Joshua Project', value: dash(p.jp), mono: true, hidden: !p.jp },
{ label: 'WCD', value: dash(p.wcd), mono: true, hidden: !p.wcd },
].filter(f => !f.hidden)
})
function close() {
if (instance) instance.pinDetail.value = null
emit('close')
}
function locate() {
emit('locate', pinData.value)
}
function onKey(e) {
if (e.key === 'Escape' && pinData.value) close()
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<div v-if="pinData" class="pdc-panel" :data-theme="instance?.theme.value || 'dark'">
<!-- Accent header bar (uses pin's color — bloc, religion, etc.) -->
<div class="pdc-accent" :style="{ background: color }"></div>
<!-- Title + close -->
<div class="pdc-header">
<span class="pdc-eyebrow">{{ tr('pplr.popup.peopleGroup') }}</span>
<button class="pdc-close" :title="tr('pplr.ui.closeEsc')" @click="close">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</button>
</div>
<h2 class="pdc-name">{{ pinData.name || '—' }}</h2>
<!-- Field grid + locale-names section. Single scrollable column so the
user can see all 17 locales without clipping. -->
<div class="pdc-fields">
<div v-for="f in fields" :key="f.label" class="pdc-field">
<span class="pdc-field-label">{{ f.label }}</span>
<span class="pdc-field-value" :class="{ mono: f.mono }">{{ f.value }}</span>
</div>
<div class="pdc-section-divider">
<span>{{ tr('pplr.card.nameInLocales', { n: locales.length }) }}</span>
</div>
<div v-for="loc in locales" :key="loc.code" class="pdc-field pdc-locale">
<span class="pdc-field-label">{{ loc.name }} <code>{{ loc.code }}</code></span>
<span class="pdc-field-value" :class="{ 'is-missing': !loc.value }">
{{ loc.value || '—' }}
</span>
</div>
</div>
<!-- Footer actions -->
<div class="pdc-actions">
<button class="pdc-btn pdc-btn-primary" @click="locate">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<path d="M12 21s7-7 7-12a7 7 0 1 0-14 0c0 5 7 12 7 12z" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="9" r="2.5" stroke="currentColor" stroke-width="2"/>
</svg>
{{ tr('pplr.ui.flyToPin') }}
</button>
<button class="pdc-btn" @click="close">{{ tr('pplr.ui.close') }}</button>
</div>
</div>
</template>
<style scoped>
.pdc-panel {
position: absolute; left: 8px; top: 60px; bottom: 12px; width: 380px; z-index: 6;
background: #161b22; border: 1px solid #30363d;
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.45);
display: flex; flex-direction: column; overflow: hidden;
}
.pdc-accent { height: 4px; flex-shrink: 0; }
.pdc-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 14px 0;
}
.pdc-eyebrow {
font: 600 9px ui-monospace, monospace;
color: #73A17F; text-transform: uppercase; letter-spacing: 0.08em;
}
.pdc-close {
background: rgba(110,118,129,0.2); border: none; border-radius: 50%;
color: #c9d1d9; cursor: pointer;
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
transition: background 0.1s;
}
.pdc-close:hover { background: #ef4444; color: #fff; }
.pdc-name {
margin: 4px 14px 12px;
font: 600 17px system-ui, -apple-system, sans-serif;
color: #e6edf3; line-height: 1.3;
}
.pdc-fields {
flex: 1; overflow-y: auto;
padding: 0 14px;
display: flex; flex-direction: column; gap: 1px;
}
.pdc-fields::-webkit-scrollbar { width: 6px; }
.pdc-fields::-webkit-scrollbar-thumb { background: #21262d; border-radius: 3px; }
.pdc-field {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 6px 0;
border-bottom: 1px solid rgba(33,38,45,0.6);
min-height: 28px;
}
.pdc-field:last-child { border-bottom: none; }
.pdc-field-label {
font: 11px ui-monospace, monospace; color: #6e7681;
flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.04em;
font-size: 9.5px;
}
.pdc-field-value {
font: 12px system-ui, sans-serif; color: #c9d1d9;
text-align: right; max-width: 65%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pdc-locale .pdc-field-value {
/* Locales can be long (Devanagari, Amharic, etc.) — let them wrap. */
white-space: normal; overflow: visible; text-overflow: clip;
word-break: break-word; line-height: 1.3;
}
.pdc-locale .pdc-field-label code {
font: 9px ui-monospace, monospace; color: #6e7681;
margin-left: 4px;
}
.pdc-field-value.is-missing { color: #6e7681; font-style: italic; }
.pdc-section-divider {
margin: 12px 0 4px;
font: 600 9px ui-monospace, monospace;
color: #73A17F;
text-transform: uppercase; letter-spacing: 0.08em;
padding-bottom: 4px;
border-bottom: 1px solid #21262d;
}
.pdc-panel[data-theme="light"] .pdc-section-divider {
color: #3b463d; border-bottom-color: #d8dee4;
}
.pdc-panel[data-theme="light"] .pdc-locale .pdc-field-label code { color: #57606a; }
.pdc-panel[data-theme="light"] .pdc-field-value.is-missing { color: #57606a; }
.pdc-field-value.mono {
font: 11px ui-monospace, monospace;
background: #0d1117; border: 1px solid #21262d; border-radius: 3px;
padding: 1px 6px;
}
.pdc-actions {
display: flex; gap: 6px; padding: 10px 14px;
border-top: 1px solid #21262d; flex-shrink: 0;
background: rgba(13,17,23,0.6);
}
.pdc-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 5px;
background: #21262d; border: 1px solid #30363d; border-radius: 6px;
color: #c9d1d9; font: 600 11px system-ui; padding: 6px 10px;
cursor: pointer; transition: background 0.1s, border-color 0.1s;
}
.pdc-btn:hover { background: #2d333b; border-color: #484f58; }
.pdc-btn-primary {
background: #3b463d; border-color: #92b195; color: #fff;
}
.pdc-btn-primary:hover { background: #92b195; }
@media (max-width: 640px) {
.pdc-panel { width: 92vw; max-width: 380px; top: 64px; bottom: 8px; }
}
/* ── Light-mode palette ──────────────────────────────────────────────────── */
.pdc-panel[data-theme="light"] {
background: #ffffff; border-color: #d0d7de;
box-shadow: 0 6px 24px rgba(31,35,40,0.15);
}
.pdc-panel[data-theme="light"] .pdc-eyebrow { color: #3b463d; }
.pdc-panel[data-theme="light"] .pdc-close { background: rgba(208,215,222,0.5); color: #1f2328; }
.pdc-panel[data-theme="light"] .pdc-name { color: #1f2328; }
.pdc-panel[data-theme="light"] .pdc-field { border-color: rgba(208,215,222,0.6); }
.pdc-panel[data-theme="light"] .pdc-field-label { color: #57606a; }
.pdc-panel[data-theme="light"] .pdc-field-value { color: #1f2328; }
.pdc-panel[data-theme="light"] .pdc-field-value.mono {
background: #f6f8fa; border-color: #d0d7de; color: #24292f;
}
.pdc-panel[data-theme="light"] .pdc-actions {
border-color: #d8dee4; background: rgba(246,248,250,0.5);
}
.pdc-panel[data-theme="light"] .pdc-btn {
background: #f6f8fa; border-color: #d0d7de; color: #1f2328;
}
.pdc-panel[data-theme="light"] .pdc-btn:hover { background: #eaeef2; border-color: #afb8c1; }
.pdc-panel[data-theme="light"] .pdc-btn-primary {
background: #3b463d; border-color: #3b463d; color: #fff;
}
.pdc-panel[data-theme="light"] .pdc-btn-primary:hover { background: #3b463d; }
</style>