PPLR data maps (WIP): move the ten PPLR research views into plugin-designs

Not for main yet. All ten views mount on the dev server with real data and no
page errors. Left: the coverage view's status still says 'sample of 6,000',
its choropleth needs checking, and the bundle is not built or tree-verified.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 04:04:12 +00:00
parent d77efea253
commit e1d847028d
32 changed files with 6484 additions and 5 deletions

1
data/pplr/coverage.json Normal file

File diff suppressed because one or more lines are too long

1
data/pplr/pg-names.json Normal file

File diff suppressed because one or more lines are too long

1
data/pplr/pg-points.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
data/pplr/rop-tree.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,77 @@
[
{
"geoid": 221743,
"name": "Mandarin-speaking Han Chinese",
"rop3_code": "103686",
"rop25_code": "301734",
"imb_people_code": "PG006802",
"joshua_project_code": "12051CH",
"world_christian_database_code": "22870",
"peid": 6802,
"pgid": "PG006802",
"country_code": "CHN",
"iso_alpha3": "CHN",
"religion_code": "NAT",
"language_code": "cmn"
},
{
"geoid": 232868,
"name": "Yoruba",
"rop3_code": "111095",
"rop25_code": "309038",
"imb_people_code": "PG014193",
"joshua_project_code": "16057NI",
"world_christian_database_code": "82430",
"peid": 14193,
"pgid": "PG014193",
"country_code": "NGA",
"iso_alpha3": "NGA",
"religion_code": "C",
"language_code": "yor"
},
{
"geoid": 245083,
"name": "Bengali Sunni Muslims",
"rop3_code": "111658",
"rop25_code": "301073",
"imb_people_code": "PG001156",
"joshua_project_code": "22492BG",
"world_christian_database_code": "5490",
"peid": 1156,
"pgid": "PG001156",
"country_code": "BGD",
"iso_alpha3": "BGD",
"religion_code": "MSN",
"language_code": "ben"
},
{
"geoid": 236976,
"name": "Northern Pashtun",
"rop3_code": "107909",
"rop25_code": "306585",
"imb_people_code": "PG043767",
"joshua_project_code": "14256AF",
"world_christian_database_code": "400",
"peid": 43767,
"pgid": "PG043767",
"country_code": "AFG",
"iso_alpha3": "AFG",
"religion_code": "MSN",
"language_code": "pbu"
},
{
"geoid": 220329,
"name": "White Americans",
"rop3_code": "110448",
"rop25_code": "300276",
"imb_people_code": "PG043515",
"joshua_project_code": "15741US",
"world_christian_database_code": "*",
"peid": 43515,
"pgid": "PG043515",
"country_code": "USA",
"iso_alpha3": "USA",
"religion_code": "C",
"language_code": "eng"
}
]

View file

@ -1398,10 +1398,8 @@ function main() {
['vendor', join(pubAssets, 'vendor')], ['vendor', join(pubAssets, 'vendor')],
['posters', join(pubAssets, 'posters')], ['posters', join(pubAssets, 'posters')],
['tables', join(pubAssets, 'tables')], ['tables', join(pubAssets, 'tables')],
// legacy — sandboxed pre-bundler maps rescued by the experimental roundup // pplr — the PPLR data maps' derived datasets (plugin-designs/pplr-data-maps/scripts/build-data.mjs)
// (EXPERIMENTAL-MAPS-ROUNDUP.md): served as static assets, mounted only inside ['pplr', join(pubAssets, 'pplr')],
// wrapper-bundle iframes so the old engines cannot collide with modern bundles.
['legacy', join(pubAssets, 'legacy')],
]) { ]) {
const src = join(repoData, srcName) const src = join(repoData, srcName)
if (!existsSync(src)) continue if (!existsSync(src)) continue
@ -1409,7 +1407,7 @@ function main() {
mkdirSync(destRel, { recursive: true }) mkdirSync(destRel, { recursive: true })
cpSync(src, destRel, { recursive: true }) cpSync(src, destRel, { recursive: true })
} }
console.log('[generate-tree] copied data/ sources → web-root/assets (people-groups, vendor, posters, tables)') console.log('[generate-tree] copied data/ sources → web-root/assets (people-groups, vendor, posters, tables, pplr)')
// ── THE TREE IS THE SHIPPING UNIT: stage the fetched assets INSIDE it ────── // ── THE TREE IS THE SHIPPING UNIT: stage the fetched assets INSIDE it ──────
// BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE (2026-09-20). The web-root copy // BUG-DATA-ASSETS-NOT-SHIPPED-TREE-NOT-PORTABLE (2026-09-20). The web-root copy
@ -1438,6 +1436,14 @@ function main() {
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV, // people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
// which no element fetches and which dropin.exclude already withholds). // which no element fetches and which dropin.exclude already withholds).
'people-groups/people-groups-slim.json', 'people-groups/people-groups-slim.json',
// pplr-data-maps — fetched through dataUrl() (src/utils/pplrData.js). pg-names.json loads
// only when a pin's detail card opens.
'pplr/pg-points.json',
'pplr/pg-names.json',
'pplr/coverage.json',
'pplr/rop-tree.json',
'pplr/pplr-geoid-family.json',
'pplr/rosetta-sample.json',
// vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime. // vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime.
'vendor/tabulator/tabulator.min.js', 'vendor/tabulator/tabulator.min.js',
'vendor/tabulator/tabulator.min.css', 'vendor/tabulator/tabulator.min.css',

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pplr-data-maps (dev)</title>
<link rel="icon" href="data:,">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#f3f3f1}
pplr-data-maps{position:fixed;inset:0;display:block}
</style>
</head>
<body>
<!-- ?tk=<token> and ?view=<lens> (pplr-religion · pplr-rop-tree · … · pplr-index) -->
<script>
(function () {
var q = new URLSearchParams(location.search)
var el = document.createElement('pplr-data-maps')
el.setAttribute('profile-config', JSON.stringify({
profile: 'pplr-data-maps', view: q.get('view') || 'pplr-religion', assetsBase: '/assets', tk: q.get('tk') || window.MAPBOX_TOKEN || ''
}))
document.body.appendChild(el)
})()
</script>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,33 @@
/**
* pplr-data-maps / index.js — bundle entry (emits pplr-data-maps.js), defines <pplr-data-maps>.
*
* The standard ProfileLoader shape (see doxa-simple-map/index.js): the glob captures this
* folder's one profile, pplr-data-maps.vue; its ten views live in src/views/. The Mapbox RTL
* text plugin is registered once here: many PG names are Arabic, Urdu or Hebrew.
*/
import { defineCustomElement } from 'vue'
import { createPinia } from 'pinia'
import ProfileLoader from '@map/ProfileLoader.vue'
import { createAppI18n } from '@map/i18n/index.js'
const profileModules = import.meta.glob('./*.vue')
if (typeof window !== 'undefined' && window.mapboxgl?.setRTLTextPlugin) {
try {
window.mapboxgl.setRTLTextPlugin(
'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js',
null, true
)
} catch (_) { /* already set this session */ }
}
const El = defineCustomElement(ProfileLoader, {
configureApp(app) {
app.use(createPinia())
const i18n = createAppI18n()
app.use(i18n)
app.provide('appI18n', i18n)
app.provide('profileModules', profileModules)
}
})
if (typeof customElements !== 'undefined' && !customElements.get('pplr-data-maps')) customElements.define('pplr-data-maps', El)

View file

@ -0,0 +1,5 @@
{
"type": "monolithic",
"label": "PPLR Data Maps",
"note": "Monolithic composite (capability 3): ONE root .vue nests ten internal views of the PPLR people-group data (religion, ROP tree, language families, semantic trees, PG name locales, ROP sunburst, all pins, cross-system ID rosetta, translation coverage, index) behind a view-switcher. index.js registers exactly ONE tag (pplr-data-maps); no view is independently embeddable."
}

View file

@ -0,0 +1,83 @@
<script setup>
/**
* pplr-data-maps.vue — PPLR data maps: ten views of the PPLR people-group data in one profile.
*
* @description Ten views that let a researcher check the PPLR people-group data by eye
* @profile pplr-data-maps
* @bundle pplr-data-maps
* @element pplr-data-maps
*
* MOVED 2026-09-25 from the PPLR-validate-data MFE (far-fast-tools, read-only) into this
* plugin-designs folder. The ten views are its original app-profiles, now in src/views/, with its
* own components, instance store and map helpers in src/. This hub replaces the original showcase
* page's tab bar. The original registered <doxa-map>, which collided with doxa-simple-map; this
* bundle registers <pplr-data-maps>.
*
* Data comes from data/pplr/ (built by scripts/build-data.mjs) through the tree's shared assets
* base, so the bundle works embedded on any host. d3 loads on first need, for the sunburst.
*
* profile-config: { "profile":"pplr-data-maps", "tk":"pk...", "view":"pplr-religion" }
*/
import { inject, ref, computed, defineAsyncComponent, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { setDataBase, ensureD3 } from './src/utils/pplrData.js'
const profileConfig = inject('profileConfig')
const cfg = computed(() => profileConfig?.value || {})
setDataBase(resolveAssetsBase(cfg.value))
// Order and labels follow the original showcase page's tab bar.
const VIEWS = [
{ key: 'pplr-religion', label: 'Religion', load: () => import('./src/views/pplr-religion.vue') },
{ key: 'pplr-rop-tree', label: 'ROP Tree', load: () => import('./src/views/pplr-rop-tree.vue') },
{ key: 'pplr-language-families', label: 'Language Families', load: () => import('./src/views/pplr-language-families.vue') },
{ key: 'pplr-semantic-trees', label: 'Semantic Trees', load: () => import('./src/views/pplr-semantic-trees.vue') },
{ key: 'pplr-translations', label: 'PG Name Locales', load: () => import('./src/views/pplr-translations.vue') },
{ key: 'pplr-d3-tree', label: 'ROP Sunburst', load: () => ensureD3().then(() => import('./src/views/pplr-d3-tree.vue')) },
{ key: 'pplr-pins', label: 'All PG-in-country pins', load: () => import('./src/views/pplr-pins.vue') },
{ key: 'pplr-rosetta-cards', label: 'Cross-system ID Rosetta', load: () => import('./src/views/pplr-rosetta-cards.vue') },
{ key: 'pplr-coverage', label: 'Translation coverage', load: () => import('./src/views/pplr-coverage.vue') },
{ key: 'pplr-index', label: 'Index', load: () => import('./src/views/pplr-index.vue') },
]
const COMPONENTS = Object.fromEntries(VIEWS.map((v) => [v.key, defineAsyncComponent(v.load)]))
const VALID = new Set(VIEWS.map((v) => v.key))
// Chosen at setup so a non-default view never mounts the default first (see upg-neighbor.vue).
const view = ref(VALID.has(cfg.value.view) ? cfg.value.view : 'pplr-religion')
function setView(k) { if (VALID.has(k)) view.value = k }
// The Index view's tiles dispatch pplr-select-profile on window (the original host page listened).
function onSelect(e) { setView(e?.detail?.profile || e?.detail?.id || e?.detail) }
onMounted(() => window.addEventListener('pplr-select-profile', onSelect))
onBeforeUnmount(() => window.removeEventListener('pplr-select-profile', onSelect))
useShadowStyles(
'.pdm-root{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;background:#f3f3f1;' +
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;isolation:isolate}' +
'.pdm-bar{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:8px 12px;background:#fff;' +
'border-bottom:1px solid #d1d4cd;overflow-x:auto;z-index:50}' +
'.pdm-brand{font:700 12px/1 ui-monospace,monospace;letter-spacing:.06em;text-transform:uppercase;color:#3b463d;white-space:nowrap}' +
'.pdm-seg{display:inline-flex;gap:4px;flex-wrap:nowrap}' +
'.pdm-seg button{border:1px solid #d1d4cd;background:#fff;color:#3b463d;cursor:pointer;white-space:nowrap;' +
'font:600 12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:7px 11px;border-radius:999px}' +
'.pdm-seg button.on{background:#92b195;border-color:#92b195;color:#fff}' +
'.pdm-seg button:hover:not(.on){border-color:#92b195}' +
'.pdm-stage{flex:1 1 0;position:relative;min-height:0}',
'pplr-data-maps')
</script>
<template>
<div class="pdm-root">
<div class="pdm-bar">
<span class="pdm-brand">PPLR data maps</span>
<div class="pdm-seg" role="tablist" aria-label="Choose a view">
<button v-for="v in VIEWS" :key="v.key" role="tab" :aria-selected="view === v.key"
:class="{ on: view === v.key }" @click="setView(v.key)">{{ v.label }}</button>
</div>
</div>
<div class="pdm-stage">
<component :is="COMPONENTS[view]" :key="view" />
</div>
</div>
</template>

View file

@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* build-data.mjs — derives data/pplr/* from the PPLR-validate-data source's assets/data/.
*
* The source ships 78 MB of data (pg-points.geojson 23 MB, pg-names.jsonl 56 MB). The maps need
* far less, so this writes what they read:
*
* pg-points.json every PG-in-country pin, WITHOUT the 17 n_* locale names, coords at 4 dp
* pg-names.json { code: [17 locale names in LOCALES order] }, fetched only when a pin opens
* coverage.json { iso3: { pgCount, localeSum } } over ALL aROP3ByCountry rows (the original
* streamed a 6,000-row sample of pg-names.jsonl in the browser)
* rop-tree.json, pplr-geoid-family.json, rosetta-sample.json copied as-is
*
* Usage: PPLR_SRC=<path to PPLR-validate-data-mfe> node plugin-designs/pplr-data-maps/scripts/build-data.mjs
*/
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const LOCALES = ['en','hi','bn','ar','zh_TW','fr','ru','ne','pt','es','ko','tl','zh_CN','id','gu','am','de']
const SRC = process.env.PPLR_SRC
if (!SRC) { console.error('set PPLR_SRC to the PPLR-validate-data-mfe folder'); process.exit(2) }
const IN = join(SRC, 'assets', 'data')
const OUT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'data', 'pplr')
mkdirSync(OUT, { recursive: true })
const round = (n) => Math.round(n * 1e4) / 1e4
const pts = JSON.parse(readFileSync(join(IN, 'pg-points.geojson'), 'utf8'))
const names = {}
const slim = pts.features.map((f) => {
const props = {}
for (const [k, v] of Object.entries(f.properties)) if (!k.startsWith('n_')) props[k] = v
names[f.properties.code] = LOCALES.map((l) => f.properties['n_' + l] || '')
return { type: 'Feature', geometry: { type: 'Point', coordinates: f.geometry.coordinates.map(round) }, properties: props }
})
writeFileSync(join(OUT, 'pg-points.json'), JSON.stringify({ type: 'FeatureCollection', features: slim }))
writeFileSync(join(OUT, 'pg-names.json'), JSON.stringify({ locales: LOCALES, names }))
const coverage = {}
const rl = createInterface({ input: createReadStream(join(IN, 'pg-names.jsonl')), crlfDelay: Infinity })
for await (const line of rl) {
if (!line.trim()) continue
let o
try { o = JSON.parse(line) } catch { continue }
if (o.source !== 'aROP3ByCountry' || !o.country) continue
const s = coverage[o.country] || (coverage[o.country] = { pgCount: 0, localeSum: 0 })
s.pgCount++
for (const l of LOCALES) if (o['name_' + l]) s.localeSum++
}
writeFileSync(join(OUT, 'coverage.json'), JSON.stringify(coverage))
for (const f of ['rop-tree.json', 'pplr-geoid-family.json', 'rosetta-sample.json']) copyFileSync(join(IN, f), join(OUT, f))
console.log(`[pplr] ${slim.length} pins, ${Object.keys(names).length} name rows, ${Object.keys(coverage).length} countries → ${OUT}`)

View file

@ -0,0 +1,83 @@
<script setup>
/**
* DataSourcesDropdown — gear-icon button that opens a panel listing the
* data sources powering the current map (URL + queried fields per source).
*
* Used by every map profile that exposes data-source provenance, factored
* out 2026-05-02 to stop copy-pasting ~50 lines of toolbar+panel CSS per
* profile. Each profile passes `:sources="DATA_SOURCES"` and slots this
* component into its toolbar.
*/
import { ref } from 'vue'
defineProps({
/** Array of { label, url, fields[] } */
sources: { type: Array, default: () => [] },
})
const open = ref(false)
function toggle() { open.value = !open.value }
</script>
<template>
<div class="dsd-wrap">
<button class="toolbar-btn round" type="button" title="Data sources" @click="toggle">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="open" class="dsd-panel">
<div class="dsd-header">Data sources</div>
<div v-for="src in sources" :key="src.url" class="dsd-src">
<div class="dsd-src-label">{{ src.label }}</div>
<div class="dsd-src-url">{{ src.url }}</div>
<div v-if="src.fields?.length" class="dsd-fields">
<span v-for="f in src.fields" :key="f" class="dsd-field">{{ f }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dsd-wrap { position: relative; }
.toolbar-btn.round {
width: 36px; height: 36px; padding: 0;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: #161b22; color: #8b949e;
border: 1px solid #30363d; cursor: pointer;
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
}
.toolbar-btn.round:hover { background: #1c2333; color: #c9d1d9; border-color: #73A17F; }
.dsd-panel {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); color: #c9d1d9;
border: 1px solid #30363d; border-radius: 8px;
font: 12px system-ui; padding: 12px 16px;
min-width: 320px; max-width: 420px; max-height: 60vh; overflow-y: auto;
z-index: 10;
}
.dsd-header {
font: 600 11px ui-monospace, monospace; color: #c9d1d9;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 0 4px 8px; margin-bottom: 6px;
border-bottom: 1px solid #21262d;
}
.dsd-src { margin-bottom: 8px; }
.dsd-src:last-child { margin-bottom: 0; }
.dsd-src-label {
font: 600 9px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 2px;
}
.dsd-src-url {
font: 10px ui-monospace, monospace; color: #6e7681; word-break: break-all;
background: #161b22; border: 1px solid #21262d; border-radius: 3px;
padding: 3px 5px; margin-bottom: 3px;
}
.dsd-fields { display: flex; flex-wrap: wrap; gap: 2px; }
.dsd-field {
font: 10px ui-monospace, monospace; background: #21262d;
color: #c9d1d9; border-radius: 3px; padding: 1px 4px;
}
</style>

View file

@ -0,0 +1,215 @@
<script setup>
/**
* FlatLegend — non-tree, single-tab companion to SemanticTreeLegend.
*
* For datasets with a flat color taxonomy (e.g. religion groups, country
* clusters, simple binary states) — anything that doesn't need the tabbed
* generation drill-down. Same chrome (rounded corners, dark/light theme,
* collapse-to-chip under the search bar) as SemanticTreeLegend so the two
* read as siblings, not separate widgets.
*
* Item shape:
* { key: string, label: string, color: string, count?: number, filter?: any }
*
* Selection emits `@select` with the item or null. The parent map is
* responsible for `setFilter` — this component does not touch the map.
*
* Mediator store hookup: optional. If `usePplrInstance.useInstance()` returns
* a store, FlatLegend reads `instance.theme.value` for [data-theme] styling
* and writes selection back to `instance.selection.value`.
*/
import { ref, computed } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
items: { type: Array, default: () => [] },
title: { type: String, default: 'Legend' },
showCount:{ type: Boolean, default: true },
})
const emit = defineEmits(['select'])
const instance = useInstance()
const collapsed = ref(false)
const theme = computed(() => instance?.theme.value || 'dark')
const selectedKey = computed(() => {
const sel = instance?.selection.value
if (!sel) return null
// Selection from this legend uses the item.key as its id; from the geocoder it
// may use a 'rel:<key>' prefix. Tolerate both.
if (typeof sel.id === 'string' && sel.id.startsWith('rel:')) return sel.id.slice(4)
return sel.id || sel.key || null
})
function pick(item) {
const isSame = selectedKey.value === item.key
if (isSame) {
if (instance) instance.selection.value = null
emit('select', null)
return
}
const node = {
id: `rel:${item.key}`,
label: item.label,
color: item.color,
filter: item.filter,
kind: 'religion',
}
if (instance) instance.selection.value = node
emit('select', node)
}
function clearSel() {
if (instance) instance.selection.value = null
emit('select', null)
}
</script>
<template>
<div class="fl-panel" :class="{ collapsed }" :data-theme="theme" v-show="!collapsed">
<div class="fl-titlebar">
<button class="fl-collapse-btn" :title="collapsed ? 'Expand legend' : 'Collapse legend'"
@click="collapsed = true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<polyline points="14,18 8,12 14,6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="fl-title">{{ title }}</div>
<button v-if="selectedKey" class="fl-clear" title="Clear selection (Esc)" @click="clearSel">×</button>
</div>
<div class="fl-rows" :class="{ 'has-sel': !!selectedKey }">
<button
v-for="item in items"
:key="item.key"
class="fl-row"
:class="{ selected: selectedKey === item.key }"
:data-row-id="item.key"
:style="{ '--row-color': item.color }"
@click="pick(item)"
>
<span class="fl-dot" :style="{ background: item.color }" />
<span class="fl-label">{{ item.label }}</span>
<span v-if="showCount && item.count != null" class="fl-count">{{ item.count.toLocaleString() }}</span>
</button>
</div>
</div>
<button v-if="collapsed"
class="fl-reopen"
:data-theme="theme"
title="Open legend"
@click="collapsed = false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<polyline points="9,18 15,12 9,6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Legend</span>
</button>
</template>
<style scoped>
.fl-panel {
position: absolute; top: 60px; left: 8px; width: 320px;
background: rgba(13,17,23,0.96); color: #c9d1d9;
border: 1px solid #30363d; border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
z-index: 4; font: 12px system-ui, sans-serif;
max-height: calc(100% - 80px);
display: flex; flex-direction: column;
overflow: hidden;
}
.fl-panel[data-theme="light"] {
background: rgba(255,255,255,0.97); color: #1f2328;
border-color: #d0d7de;
box-shadow: 0 8px 24px rgba(31,35,40,0.18);
}
/* Title bar */
.fl-titlebar {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid #21262d;
}
.fl-panel[data-theme="light"] .fl-titlebar { border-bottom-color: #d8dee4; }
.fl-collapse-btn {
background: transparent; border: none; cursor: pointer;
color: #8b949e; padding: 2px 4px; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
}
.fl-collapse-btn:hover { color: #c9d1d9; background: rgba(115,161,127,0.12); }
.fl-panel[data-theme="light"] .fl-collapse-btn { color: #57606a; }
.fl-panel[data-theme="light"] .fl-collapse-btn:hover { color: #1f2328; background: #f6f8fa; }
.fl-title {
flex: 1;
font: 600 11px ui-monospace, monospace;
text-transform: uppercase; letter-spacing: 0.06em;
color: #73A17F;
}
.fl-panel[data-theme="light"] .fl-title { color: #3b463d; }
.fl-clear {
background: transparent; border: none; cursor: pointer;
color: #8b949e; font-size: 16px; line-height: 1; padding: 2px 6px;
border-radius: 4px;
}
.fl-clear:hover { background: #ef4444; color: #fff; }
.fl-panel[data-theme="light"] .fl-clear { color: #57606a; }
/* Rows */
.fl-rows { overflow-y: auto; padding: 4px; }
.fl-row {
width: 100%;
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: transparent; border: none; cursor: pointer;
color: inherit; text-align: left;
font: 12px system-ui, sans-serif;
border-radius: 6px;
transition: opacity 0.12s, background 0.12s;
}
.fl-row:hover { background: rgba(115,161,127,0.08); }
.fl-panel[data-theme="light"] .fl-row:hover { background: #f6f8fa; }
.fl-rows.has-sel .fl-row { opacity: 0.3; }
.fl-rows.has-sel .fl-row.selected {
opacity: 1;
background: rgba(255,255,255,0.05);
box-shadow:
inset 3px 0 0 0 var(--row-color, #73A17F),
inset 0 0 0 1px rgba(255,255,255,0.05);
}
.fl-panel[data-theme="light"] .fl-rows.has-sel .fl-row.selected {
background: rgba(0,0,0,0.04);
box-shadow:
inset 3px 0 0 0 var(--row-color, #3b463d),
inset 0 0 0 1px rgba(0,0,0,0.05);
}
.fl-dot { width: 11px; height: 11px; border-radius: 50%; }
.fl-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fl-count {
font: 11px ui-monospace, monospace;
color: #6e7681;
}
.fl-panel[data-theme="light"] .fl-count { color: #57606a; }
/* Collapsed chip — sibling of the panel, sits where the search-bar's bottom edge is */
.fl-reopen {
position: absolute; top: 60px; left: 8px; z-index: 3;
height: 36px; border-radius: 999px;
background: rgba(13,17,23,0.96); color: #c9d1d9;
border: 1px solid #30363d;
padding: 0 14px 0 10px;
display: inline-flex; align-items: center; gap: 6px;
cursor: pointer;
font: 600 11px ui-monospace, monospace;
text-transform: uppercase; letter-spacing: 0.05em;
box-shadow: 0 4px 14px rgba(0,0,0,0.35);
}
.fl-reopen[data-theme="light"] {
background: rgba(255,255,255,0.97); color: #1f2328; border-color: #d0d7de;
box-shadow: 0 4px 14px rgba(31,35,40,0.15);
}
.fl-reopen:hover { border-color: #73A17F; }
</style>

View file

@ -0,0 +1,99 @@
<script setup>
/**
* LoadingGlobe — rotating-globe SVG used as the universal loading state.
*
* Stays mounted while a profile is loading ANY stage (basemap tiles, PG pins,
* country polygons, etc.). The label updates as the profile progresses so the
* user sees "Loading basemap" → "Loading pins" → "Adding layers" rather than
* a single stuck spinner. If 30 seconds pass with no progress (`stuck` flips
* true via `stuckAfterMs`), a "Retry" button appears.
*
* Usage:
* <LoadingGlobe v-if="!isReady" :label="loadingStage" @retry="retry" />
*/
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
const props = defineProps({
label: { type: String, default: 'Loading map' },
stuckAfterMs:{ type: Number, default: 30000 },
})
const emit = defineEmits(['retry'])
const stuck = ref(false)
let stuckTimer = null
function armStuckTimer() {
if (stuckTimer) clearTimeout(stuckTimer)
stuck.value = false
stuckTimer = setTimeout(() => { stuck.value = true }, props.stuckAfterMs)
}
onMounted(armStuckTimer)
onBeforeUnmount(() => stuckTimer && clearTimeout(stuckTimer))
// Re-arm whenever the label changes — that's a sign the profile made progress.
watch(() => props.label, armStuckTimer)
function onRetry() {
stuck.value = false
armStuckTimer()
emit('retry')
}
</script>
<template>
<div class="lg-frame" role="status" aria-live="polite">
<svg class="lg-globe" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="lg-grad" cx="35%" cy="35%" r="65%">
<stop offset="0%" stop-color="#92b195" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#3b463d" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="26" fill="url(#lg-grad)" />
<circle cx="32" cy="32" r="26" fill="none" stroke="#73A17F" stroke-width="2"/>
<ellipse cx="32" cy="32" rx="26" ry="10" fill="none" stroke="#73A17F" stroke-width="1.4" opacity="0.7"/>
<ellipse cx="32" cy="32" rx="14" ry="26" fill="none" stroke="#73A17F" stroke-width="1.4" opacity="0.7"/>
<line x1="6" y1="32" x2="58" y2="32" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
<line x1="32" y1="6" x2="32" y2="58" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
</svg>
<span class="lg-text">{{ label }}</span>
<button v-if="stuck" class="lg-retry" type="button" @click="onRetry">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-3-6.7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<polyline points="21 4 21 11 14 11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Retry
</button>
</div>
</template>
<style scoped>
.lg-frame {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px;
background: #0f1216;
z-index: 1;
}
.lg-globe {
width: 64px; height: 64px;
animation: lg-spin 1.6s linear infinite;
filter: drop-shadow(0 4px 14px rgba(115,161,127,0.35));
}
@keyframes lg-spin { to { transform: rotate(360deg); } }
.lg-text {
color: #73A17F; opacity: 0.85;
font: 600 12px ui-monospace, monospace;
letter-spacing: 0.08em; text-transform: uppercase;
}
.lg-retry {
display: inline-flex; align-items: center; gap: 6px;
margin-top: 6px; padding: 6px 12px;
background: #161b22; color: #c9d1d9;
border: 1px solid #73A17F; border-radius: 999px;
font: 600 11px system-ui, sans-serif;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.lg-retry:hover { background: #73A17F; color: #0d1117; }
</style>

View file

@ -0,0 +1,305 @@
<script setup>
/**
* MapboxGeocoder.vue — wraps mapbox-gl-geocoder npm plugin (loaded via CDN).
*
* DOXA's pattern, ported per user request 2026-05-02 ("your competitor is
* beating you in that area. Their geocoder's directly mapped to language-
* family legend"). Replaces the bespoke PplrGeocoder.vue dropdown with
* Mapbox's vetted UI/UX (keyboard nav, debouncing, ARIA, mobile-friendly)
* while keeping local-data search via the localGeocoder callback.
*
* Search results funnel into either:
* - emit('select', node) — when the user picks a local item from props.localIndex
* - emit('place', place) — when the user picks a Mapbox geographic place
*
* Both events forward to the parent profile, which already wires `select`
* into instance.selection (legend highlight + map filter via setFilter) and
* `place` into a flyTo / fitBounds.
*
* The `/` keyboard shortcut from the original PplrGeocoder is preserved.
*/
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue'
import { ensureGeocoder } from '../utils/pplrData.js'
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
/** Pre-flattened searchable items: [{id, label, kind, depth, filter, color?, count?, pop?, ...}] */
localIndex: { type: Array, default: () => [] },
/** Placeholder text — parameterized per map */
placeholder: { type: String, default: 'Search…' },
/** Mapbox access token (required for the geographic Places search) */
mapboxToken: { type: String, default: '' },
/** Min query length before search fires */
minChars: { type: Number, default: 2 },
/** Kind labels for hint text — { family: 'Lang Family', bloc: 'Affinity Bloc', ... } */
kindLabels: { type: Object, default: () => ({}) },
/** Max local results in the dropdown */
localMax: { type: Number, default: 8 },
})
const emit = defineEmits(['select', 'place'])
const instance = useInstance()
const wrapEl = ref(null)
const slotEl = ref(null)
let geocoder = null
// Build the localGeocoder callback. Mapbox-gl-geocoder calls this on every
// keystroke; we return Carmen-GeoJSON-shaped features for matching local
// items. The `__pplr_local` property carries the original node so onResult
// can route it back to emit('select').
function buildLocalGeocoder() {
return (q) => {
if (!q || q.length < props.minChars) return []
const ql = q.toLowerCase()
const out = []
for (const item of props.localIndex) {
const label = (item.label || '').toLowerCase()
if (!label.includes(ql)) continue
out.push({
type: 'Feature',
// Carmen-GeoJSON shape required by mapbox-gl-geocoder
place_name: item.label,
place_type: ['poi'],
text: item.label,
properties: {
// Carry a hint of what KIND of result this is (shows in dropdown)
__pplr_kind: props.kindLabels[item.kind] || item.kind || '',
__pplr_local: item,
},
// Geometry is required by Carmen-GeoJSON schema. Use a sentinel
// [0,0] centroid; the parent ignores geometry for local results.
geometry: { type: 'Point', coordinates: [0, 0] },
center: [0, 0],
})
if (out.length >= props.localMax) break
}
// Sort priority: BROADER scope first (lower depth = bigger bucket), then
// prefix matches, then population/count desc. Per user 2026-05-02:
// searching "Arabic" was selecting the narrow "Arabic" language node
// (depth 1) instead of the family "Arab (Arabic)" (depth 0) — only some
// Arabic pins showed. Depth-first means picking the top result now
// filters the broadest reasonable bucket (whole family).
out.sort((a, b) => {
const ai = a.properties.__pplr_local
const bi = b.properties.__pplr_local
const ad = ai?.depth ?? 99
const bd = bi?.depth ?? 99
if (ad !== bd) return ad - bd
const ap = a.place_name.toLowerCase().startsWith(ql) ? 0 : 1
const bp = b.place_name.toLowerCase().startsWith(ql) ? 0 : 1
if (ap !== bp) return ap - bp
return ((bi?.pop || 0) - (ai?.pop || 0)) || ((bi?.count || 0) - (ai?.count || 0))
})
return out
}
}
// Custom suggestion renderer — shows the local kind chip beside each row
function renderSuggestion(item) {
const kind = item?.properties?.__pplr_kind || ''
const local = item?.properties?.__pplr_local
const dotColor = local?.color || ''
const dot = dotColor
? `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${dotColor};margin-right:8px;vertical-align:middle"></span>`
: ''
const tag = kind
? `<span style="font:600 9px ui-monospace,monospace;color:#73A17F;text-transform:uppercase;letter-spacing:0.06em;margin-left:auto">${kind}</span>`
: ''
return `<div class="mapboxgl-ctrl-geocoder--suggestion-title" style="display:flex;align-items:center;gap:4px;width:100%">
${dot}<span style="flex:1">${item.place_name}</span>${tag}
</div>`
}
// mapbox-gl-geocoder CSS lives in the regular document's <head> via index.html.
// Shadow DOM is sealed from regular-document styles, so we must inject the same
// stylesheet into each shadow root that hosts a geocoder. Idempotent — checks
// if already present.
const GEOCODER_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css'
function ensureGeocoderCss(root) {
if (!root || root === document) return
if (root.querySelector('link[data-pplr-geocoder-css]')) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = GEOCODER_CSS_URL
link.setAttribute('data-pplr-geocoder-css', '1')
root.prepend(link)
}
function setup() {
// Wrap entire setup in try/catch — a geocoder failure must NEVER bubble
// out of onMounted, since Vue's "Unhandled error during execution of
// mounted hook" cascades and can prevent sibling components (the map!)
// from mounting. Per user 2026-05-02: map went completely blank when
// the geocoder threw on shadow-DOM addTo().
try {
if (!window.MapboxGeocoder || !window.mapboxgl) {
console.warn('[MapboxGeocoder] mapbox-gl-geocoder or mapbox-gl not on window — check CDN <script> tags')
return
}
if (!props.mapboxToken) {
console.warn('[MapboxGeocoder] mapboxToken prop is empty — geographic Places search disabled')
}
geocoder = new window.MapboxGeocoder({
accessToken: props.mapboxToken,
mapboxgl: window.mapboxgl,
placeholder: props.placeholder,
localGeocoder: buildLocalGeocoder(),
localGeocoderOnly: false,
minLength: props.minChars,
types: 'country,region,district,place',
limit: 5,
render: renderSuggestion,
})
geocoder.on('result', (ev) => {
const f = ev.result
const local = f?.properties?.__pplr_local
if (local) {
if (instance) {
instance.selection.value = local
if (typeof local.depth === 'number') instance.activeTab.value = local.depth
}
emit('select', local)
} else {
emit('place', {
id: f.id,
label: f.place_name,
place: f.text,
kind: 'place',
bbox: f.bbox,
center: f.center,
})
}
})
geocoder.on('clear', () => {
if (instance) instance.selection.value = null
emit('select', null)
})
// mapbox-gl-geocoder v5's addTo() does `document.body.contains(el)`
// which returns false for shadow-DOM elements (our case — each profile
// mounts inside <doxa-map>'s shadow root). Workaround: stage in body,
// then move the rendered element into our shadow-DOM slot. References
// inside the geocoder are by element reference so the move is safe.
if (!slotEl.value) return
// Inject geocoder CSS into the shadow root so the rendered geocoder is
// actually styled (regular-document <head> styles don't cross the shadow).
ensureGeocoderCss(slotEl.value.getRootNode())
const stage = document.createElement('div')
stage.style.cssText = 'position:absolute;left:-9999px;top:-9999px;visibility:hidden;pointer-events:none;'
document.body.appendChild(stage)
try {
geocoder.addTo(stage)
const rendered = stage.firstElementChild
if (rendered) slotEl.value.appendChild(rendered)
} finally {
if (stage.parentNode) stage.parentNode.removeChild(stage)
}
} catch (e) {
console.warn('[MapboxGeocoder] setup failed — geocoder disabled:', e)
geocoder = null
}
}
function teardown() {
try { geocoder?.onRemove() } catch {}
geocoder = null
if (slotEl.value) slotEl.value.innerHTML = ''
}
// Rebuild localGeocoder callback when localIndex changes (the closure captures
// props.localIndex by reference via getter; Mapbox calls our function fresh
// each query so this is automatic — no rebuild needed).
// Global "/" shortcut to focus geocoder (preserved from PplrGeocoder)
function onGlobalKey(e) {
const tgt = document.activeElement
const inField = tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)
const isSlash = e.key === '/' && !inField
const isCmdK = (e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')
if (isSlash || isCmdK) {
e.preventDefault()
const input = slotEl.value?.querySelector('input.mapboxgl-ctrl-geocoder--input')
input?.focus()
input?.select?.()
}
}
onMounted(() => {
// setup() swallows its own errors; a failed script load leaves the map working without search.
ensureGeocoder().then(setup, (e) => console.warn('[MapboxGeocoder]', e.message))
document.addEventListener('keydown', onGlobalKey)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onGlobalKey)
teardown()
})
</script>
<template>
<div ref="wrapEl" class="mgc-wrap" :data-theme="instance?.theme.value || 'dark'">
<div ref="slotEl" class="mgc-slot" />
</div>
</template>
<style scoped>
.mgc-wrap { width: 100%; position: relative; }
/* mapbox-gl-geocoder injects its own root .mapboxgl-ctrl-geocoder; we just
need a slot element to host it. Style overrides target the deep selectors
from the plugin's stylesheet (loaded via index.html CDN). */
.mgc-slot :deep(.mapboxgl-ctrl-geocoder) {
width: 100%; max-width: 100%;
background: rgba(13,17,23,0.96);
border: 1px solid #30363d;
border-radius: 999px;
box-shadow: 0 4px 14px rgba(0,0,0,0.25);
font: 13px system-ui, sans-serif;
min-width: 0;
}
.mgc-slot :deep(.mapboxgl-ctrl-geocoder--input) {
color: #c9d1d9;
height: 36px;
padding: 6px 36px 6px 38px;
font: 13px system-ui, sans-serif;
}
.mgc-slot :deep(.mapboxgl-ctrl-geocoder--input::placeholder) { color: #6e7681; }
.mgc-slot :deep(.mapboxgl-ctrl-geocoder--icon-search) {
fill: #6e7681; top: 8px; left: 9px;
}
.mgc-slot :deep(.mapboxgl-ctrl-geocoder--button) {
background: transparent; top: 6px;
}
.mgc-slot :deep(.suggestions) {
background: rgba(13,17,23,0.97);
border: 1px solid #30363d; border-radius: 8px;
margin-top: 4px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
}
.mgc-slot :deep(.suggestions > li > a) {
color: #c9d1d9; padding: 8px 12px;
}
.mgc-slot :deep(.suggestions > .active > a),
.mgc-slot :deep(.suggestions > li > a:hover) {
background: rgba(115,161,127,0.12);
color: #c9d1d9;
}
/* Light theme overrides */
.mgc-wrap[data-theme="light"] :deep(.mapboxgl-ctrl-geocoder) {
background: rgba(255,255,255,0.97); border-color: #d0d7de;
box-shadow: 0 4px 14px rgba(31,35,40,0.15);
}
.mgc-wrap[data-theme="light"] :deep(.mapboxgl-ctrl-geocoder--input) { color: #1f2328; }
.mgc-wrap[data-theme="light"] :deep(.mapboxgl-ctrl-geocoder--input::placeholder) { color: #57606a; }
.mgc-wrap[data-theme="light"] :deep(.suggestions) {
background: #ffffff; border-color: #d0d7de; color: #1f2328;
}
.mgc-wrap[data-theme="light"] :deep(.suggestions > li > a) { color: #1f2328; }
.mgc-wrap[data-theme="light"] :deep(.suggestions > .active > a),
.mgc-wrap[data-theme="light"] :deep(.suggestions > li > a:hover) {
background: #f6f8fa; color: #1f2328;
}
</style>

View file

@ -0,0 +1,277 @@
<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 '../composables/usePplrInstance.js'
import { LOCALES, LOCALE_NAMES } from '../utils/pplrMap.js'
import { loadNames } from '../utils/pplrData.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 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: 'Country', value: dash(p.country), mono: false },
{ label: 'Religion', value: dash(p.religion), mono: false },
{ label: 'Language', value: dash(p.language), mono: false },
{ label: 'ISO-3 Country', value: dash(p.iso3), mono: true, hidden: !p.iso3 },
{ label: 'Affinity Bloc', value: dash(p.rop1), mono: true, hidden: !p.rop1 },
{ label: 'People Cluster', 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">People Group</span>
<button class="pdc-close" title="Close (Esc)" @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>Name in 17 locales</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>
Fly to pin
</button>
<button class="pdc-btn" @click="close">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>

View file

@ -0,0 +1,215 @@
<script setup>
/**
* SemanticSunburst — D3 sunburst with mouse-wheel zoom + drag pan.
*
* Per user 2026-05-02: replaced the original click-to-expand-arc behavior
* with map-style scroll-wheel zoom + click-and-drag pan. Click-to-expand was
* "kind of slow" and "I don't like it"; users want the same gesture they get
* on a Mapbox map.
*
* Labels are now visibility-tuned: only render labels for arcs that occupy
* at least `LABEL_MIN_RADIANS` of arc, and font scales up at higher zoom
* (label visibility re-evaluated on each zoom event).
*
* D3 is loaded from CDN globally — see index.html `<script src="d3.min.js">`.
*
* Props:
* tree — { name, code?, children?: Tree[], value?: number }
* colorMap — { [code]: hex } for depth-1 nodes; deeper depths lighten
* centerLabel — string shown in the donut hole
* depthLabels — string[] — e.g. ['', 'Affinity Bloc', 'Cluster', 'PG']
* maxDepth — number — render up to this many rings (default 3)
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({
tree: { type: Object, required: true },
colorMap: { type: Object, default: () => ({}) },
centerLabel: { type: String, default: '' },
depthLabels: { type: Array, default: () => [] },
maxDepth: { type: Number, default: 3 },
})
const container = ref(null)
const status = ref('')
let svgEl = null
let resizeObs = null
function arcColorOf(node) {
// Walk up to depth-1 ancestor for the base color
let n = node
while (n.depth > 1) n = n.parent
const base = n.depth === 1 ? (props.colorMap[n.data.code] || '#888') : '#334155'
// Lighten deeper rings by blending toward white
const light = node.depth === 1 ? 0 : node.depth === 2 ? 0.18 : 0.34
if (light === 0) return base
const hex = base.replace('#', '')
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
const lr = Math.round(r + (255 - r) * light)
const lg = Math.round(g + (255 - g) * light)
const lb = Math.round(b + (255 - b) * light)
return `rgb(${lr},${lg},${lb})`
}
// Threshold (radians) — labels only render for arcs at least this wide.
// At higher zoom scales we lower the threshold so more labels become visible.
const LABEL_MIN_RADIANS = 0.10
function build() {
const d3 = window.d3
if (!d3) { status.value = 'D3 not loaded — refresh page'; return }
const el = container.value
if (!el || !props.tree) return
// Tear down any prior render so we don't stack svgs on resize/data-swap.
el.innerHTML = ''
const W = el.clientWidth || 720
const H = el.clientHeight || 600
const R = Math.min(W, H) / 2 - 10
const hierarchy = d3.hierarchy(props.tree)
.sum(d => d.value || 0)
.sort((a, b) => b.value - a.value)
d3.partition().size([2 * Math.PI, R])(hierarchy)
const arc = d3.arc()
.startAngle(d => d.x0)
.endAngle(d => d.x1)
.padAngle(d => Math.min((d.x1 - d.x0) / 2, 0.004))
.padRadius(R / 2)
.innerRadius(d => d.y0)
.outerRadius(d => Math.max(d.y0, d.y1 - 1.5))
const svg = d3.select(el).append('svg')
.attr('viewBox', [-W / 2, -H / 2, W, H])
.attr('width', W).attr('height', H)
.style('font', '10px system-ui')
.style('background', 'transparent')
.style('cursor', 'grab')
svgEl = svg.node()
// Single transform group — wheel zoom + drag pan apply via d3.zoom().
const root = svg.append('g')
const centerLabelEl = root.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('fill', '#c9d1d9').style('font-size', '12px').style('font-weight', '600')
.style('pointer-events', 'none')
.text(props.centerLabel)
const tooltip = d3.select(el).append('div')
.style('position', 'absolute').style('pointer-events', 'none').style('display', 'none')
.style('background', 'rgba(13,17,23,0.95)').style('color', '#c9d1d9')
.style('border', '1px solid #30363d').style('border-radius', '6px')
.style('padding', '8px 12px').style('font', '12px system-ui')
.style('z-index', '10').style('max-width', '260px')
// Render arcs with paint-order so larger inner rings draw first.
const visible = hierarchy.descendants().filter(d => d.depth > 0 && d.depth <= props.maxDepth)
const path = root.append('g').selectAll('path')
.data(visible)
.join('path')
.attr('fill', d => arcColorOf(d))
.attr('fill-opacity', d => d.depth === 1 ? 0.92 : 0.7)
.attr('stroke', '#0d1117').attr('stroke-width', 0.5)
.attr('d', arc).style('cursor', 'pointer')
// Inner-ring labels — bigger font + better contrast (white text), and we
// reposition them on zoom so they stay legible when zoomed in.
const labelData = visible.filter(d => d.depth === 1 && (d.x1 - d.x0) > LABEL_MIN_RADIANS)
const labelGroup = root.append('g').attr('pointer-events', 'none')
const label = labelGroup.selectAll('text')
.data(labelData)
.join('text')
.attr('transform', d => {
const x = (d.x0 + d.x1) / 2 * 180 / Math.PI - 90
const y = (d.y0 + d.y1) / 2
return `rotate(${x}) translate(${y},0) rotate(${x < 90 || x > 270 ? 0 : 180})`
})
.attr('dy', '0.35em').attr('text-anchor', 'middle')
.style('fill', '#ffffff').style('font-size', '11px').style('font-weight', '700')
.style('paint-order', 'stroke').style('stroke', 'rgba(13,17,23,0.65)').style('stroke-width', '2.5px')
.text(d => (d.data.name || '').replace(' Peoples', '').replace(' People', ''))
// Hover tooltip only — arc clicks no longer trigger zoom (per user request:
// wheel-zoom + drag-pan instead of click-to-expand).
path.on('mousemove', (event, d) => {
const depth = props.depthLabels[d.depth] || ''
tooltip.style('display', 'block')
.style('left', (event.offsetX + 14) + 'px')
.style('top', (event.offsetY - 10) + 'px')
.html(`<b>${d.data.name}</b><br><span style="color:#6b7280">${depth} · ${d.value.toLocaleString()} entries</span>`)
})
.on('mouseleave', () => tooltip.style('display', 'none'))
// ── Wheel zoom + drag pan (Mapbox-style gesture, per user 2026-05-02) ──
// wheelDelta: tweak default for smoother small-step zooming.
const zoomBehavior = d3.zoom()
.scaleExtent([0.5, 12])
.wheelDelta(event => -event.deltaY * (event.deltaMode ? 120 : 1) / 500)
.on('zoom', (event) => {
root.attr('transform', event.transform)
// Hide labels that become too small when zoomed out, scale font down
// when zoomed in (so they don't get artificially huge).
const k = event.transform.k
label.style('font-size', `${Math.max(9, 11 / k * 1.1)}px`)
.style('display', d => (d.x1 - d.x0) * k > LABEL_MIN_RADIANS ? null : 'none')
})
// Cursor toggle for drag affordance
svg.call(zoomBehavior)
.on('mousedown.cursor', () => svg.style('cursor', 'grabbing'))
.on('mouseup.cursor', () => svg.style('cursor', 'grab'))
.on('dblclick.zoom', null) // disable dblclick-zoom — keep gesture purely wheel/drag
const total = hierarchy.value
status.value = `${total.toLocaleString()} entries · scroll to zoom · drag to pan`
}
onMounted(() => {
build()
// Re-render on container resize (window resize, gem-slot collapse, etc.)
if ('ResizeObserver' in window && container.value) {
let scheduled = false
resizeObs = new ResizeObserver(() => {
if (scheduled) return
scheduled = true
requestAnimationFrame(() => { scheduled = false; build() })
})
resizeObs.observe(container.value)
}
})
onBeforeUnmount(() => {
resizeObs?.disconnect()
if (svgEl) svgEl.remove()
})
// Re-build when tree data swaps (e.g. switching profile context)
watch(() => props.tree, () => build(), { deep: false })
</script>
<template>
<div class="ssb-frame">
<div ref="container" class="ssb-canvas" />
<div v-if="status" class="ssb-status">{{ status }}</div>
</div>
</template>
<style scoped>
.ssb-frame {
width: 100%; height: 100%;
position: relative;
background: #0d1117;
}
.ssb-canvas { width: 100%; height: 100%; position: relative; }
.ssb-status {
position: absolute; bottom: 8px; left: 12px;
background: rgba(13,17,23,0.92); color: #86efac; border: 1px solid #30363d;
font: 11px ui-monospace, monospace; padding: 5px 10px; border-radius: 4px;
z-index: 2;
}
</style>

View file

@ -0,0 +1,945 @@
<script setup>
/**
* SemanticTreeLegend — reusable hierarchical legend for any one-to-many semantic tree.
*
* Decoupled from map and geocoder; communicates via the shared per-instance store
* (Mediator Pattern) when one is provided. Falls back to local state when standalone.
*
* Tree node shape:
* { id, label, color?, count?, pop?, filter?, info?, children?: TreeNode[] }
*
* Per QA architecture decisions:
* - Tab N = Generation N (1-indexed in user-facing labels). Tab 1 = root.
* - Click-through deselect: clicking a different row replaces selection.
* - Inline X chip on the selected row (slot reserved on every row → columns stay aligned).
* - 30% dim on non-selected rows EXCEPT descendants of the selected node (so the user
* can drill down into a selected family without losing visibility).
* - Breadcrumb: title row swaps to "{ParentTabName}: {ParentLabel}" when a child is
* selected (e.g. "Lang Family: Afro-Asiatic" when Arabic is selected on Languages tab).
* - Sortable UPGs / Pop column headers — click to toggle, click again to flip direction.
* - No in-legend search bar — search lives in the parent's PplrGeocoder above this panel
* (one search per map cluster, per QA "unified geocoder" decision).
*/
import { ref, computed, watch, nextTick } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
nodes: { type: Array, default: () => [] },
tabs: { type: Array, default: null },
columns: { type: Array, default: () => ['count', 'pop'] },
title: { type: String, default: 'Legend' },
})
const emit = defineEmits(['select'])
const instance = useInstance()
// ── Tree traversal ─────────────────────────────────────────────────────────────
function treeMaxDepth(nodes, d = 0) {
if (!nodes?.length) return d
return Math.max(...nodes.map(n => treeMaxDepth(n.children ?? [], d + 1)))
}
function collectAtDepth(nodes, target, curr = 0) {
if (curr === target) return nodes.slice()
return nodes.flatMap(n => collectAtDepth(n.children ?? [], target, curr + 1))
}
function findAncestorChain(nodes, targetId, chain = []) {
for (const node of nodes) {
if (node.id === targetId) return chain
if (node.children?.length) {
const found = findAncestorChain(node.children, targetId, [...chain, node])
if (found) return found
}
}
return null
}
function collectAllDescendantIds(node, out = new Set()) {
if (!node?.children?.length) return out
for (const c of node.children) {
out.add(c.id)
collectAllDescendantIds(c, out)
}
return out
}
// ── Derived ────────────────────────────────────────────────────────────────────
const treeDepth = computed(() => treeMaxDepth(props.nodes))
const tabList = computed(() => {
const src = props.tabs ?? []
const len = src.length || (treeDepth.value + 1)
return Array.from({ length: len }, (_, i) =>
src[i] ?? { id: `gen${i+1}`, label: `Generation ${i + 1}` }
)
})
// ── State ─────────────────────────────────────────────────────────────────────
const localActiveTab = ref(0)
const expandedIds = ref(new Set())
const localSelected = ref(null)
const panelOpen = ref(true)
const showInfoForTab = ref(null)
const sortBy = ref(null) // null = default (pop desc), 'count' | 'pop'
const sortDir = ref('desc') // 'desc' | 'asc'
const activeTab = computed({
get: () => instance ? instance.activeTab.value : localActiveTab.value,
set: v => {
if (instance) instance.activeTab.value = v
else localActiveTab.value = v
},
})
const selectedNode = computed({
get: () => instance ? instance.selection.value : localSelected.value,
set: v => {
if (instance) instance.selection.value = v
else localSelected.value = v
},
})
const selectedId = computed(() => selectedNode.value?.id || null)
watch(() => props.nodes, () => {
activeTab.value = 0
expandedIds.value = new Set()
selectedNode.value = null
emit('select', null)
}, { deep: false })
let _lastInternalId = null
if (instance) {
watch(() => instance.selection.value, sel => {
if (sel?.id === _lastInternalId) return
if (!sel) { _lastInternalId = null; return }
_lastInternalId = sel.id
const chain = findAncestorChain(props.nodes, sel.id) || []
if (typeof sel.depth === 'number') {
activeTab.value = Math.min(sel.depth, tabList.value.length - 1)
}
const tabIdx = activeTab.value
// Clear OTHER expansions; only expand the ancestor chain so the selection
// is actually visible in the rows (per QA: don't leave Indo-European open
// when user picked an Afro-Asiatic-equivalent result).
const newExp = new Set()
chain.slice(tabIdx).forEach(p => newExp.add(p.id))
expandedIds.value = newExp
emit('select', sel)
scrollSelectedIntoView()
})
}
// Scroll the currently-selected row into view inside the legend's scroll
// container — useful after geocoder picks where the row may be far down.
//
// Shadow-DOM caveat (per user 2026-05-02 — geocoder picks "selected" the row
// but the highlight never showed because the row was off-screen + the old
// document.querySelector couldn't see into the shadow root, so the
// scroll-into-view silently failed). Walk via panelRoot to query inside the
// shadow root, fall back to document for non-shadow hosts.
function scrollSelectedIntoView() {
nextTick(() => {
const id = selectedNode.value?.id
if (!id) return
const sel = `[data-row-id="${CSS.escape(id)}"]`
const root = panelRoot.value?.getRootNode?.()
const el = (root && root !== document && root.querySelector?.(sel))
|| panelRoot.value?.querySelector?.(sel)
|| document.querySelector(sel)
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
}
const panelRoot = ref(null)
// ── Sort ───────────────────────────────────────────────────────────────────────
function defaultSort(a, b) {
const pd = (b.pop ?? 0) - (a.pop ?? 0)
return pd !== 0 ? pd : (b.count ?? 0) - (a.count ?? 0)
}
const byActiveSort = computed(() => {
if (!sortBy.value) return defaultSort
const key = sortBy.value
const dir = sortDir.value === 'asc' ? 1 : -1
return (a, b) => (((a[key] ?? 0) - (b[key] ?? 0)) * dir)
})
function clickSortHeader(key) {
if (sortBy.value === key) {
// toggle direction; third click clears back to default
if (sortDir.value === 'desc') sortDir.value = 'asc'
else { sortBy.value = null; sortDir.value = 'desc' }
} else {
sortBy.value = key
sortDir.value = 'desc'
}
}
// ── Visible rows ───────────────────────────────────────────────────────────────
// Deferred per-tab rendering: tab switches happen INSTANTLY (activeTab updates
// synchronously, "Loading…" placeholder shows). The expensive walk + sort runs
// in setTimeout(0) so the click handler returns immediately. Cache prevents
// repeated work when re-visiting a tab.
//
// Why: clicking Tab 4 (PGIC, ~21k leaves) used to block the click handler for
// ~50ms while collectAtDepth walked 34k nodes. Deferred render makes the tab
// switch feel instant; rows fill in on the next frame.
const tabResults = ref([]) // tabIdx → sorted Array of nodes
const tabLoading = ref(false) // true while async work for current tab is in flight
let _pendingTab = -1
function ensureTabComputed(tab) {
if (tabResults.value[tab]) { tabLoading.value = false; return }
tabLoading.value = true
_pendingTab = tab
setTimeout(() => {
if (_pendingTab !== tab) return // user moved on; skip stale work
const result = [...collectAtDepth(props.nodes, tab)].sort(byActiveSort.value)
const next = [...tabResults.value]
next[tab] = result
tabResults.value = next
tabLoading.value = false
}, 0)
}
watch(activeTab, t => ensureTabComputed(t), { immediate: true })
watch(() => [props.nodes, sortBy.value, sortDir.value], () => {
// Underlying data or sort changed → invalidate every cached tab and recompute
// the current one.
tabResults.value = []
ensureTabComputed(activeTab.value)
}, { deep: false })
const topNodesAll = computed(() => tabResults.value[activeTab.value] || [])
const topNodes = computed(() => {
const limit = tabList.value[activeTab.value]?.topLimit
return limit ? topNodesAll.value.slice(0, limit) : topNodesAll.value
})
const topNodesOverflow = computed(() => {
const limit = tabList.value[activeTab.value]?.topLimit
if (!limit) return 0
return Math.max(0, topNodesAll.value.length - limit)
})
function buildRows(out, nodes, relDepth, parent, ancestors) {
for (const node of nodes) {
out.push({ node, relDepth, parent, ancestors })
if (expandedIds.value.has(node.id) && node.children?.length) {
buildRows(out, [...node.children].sort(byActiveSort.value), relDepth + 1, node, [...ancestors, node])
}
}
}
const visibleRows = computed(() => {
const out = []
buildRows(out, topNodes.value, 0, null, [])
// Annotate each row with hairline-trunk segments so we can render
// pipe / tee / elbow / blank lines per indent column. This makes the legend
// look like an ASCII tree (└── / ├──) — sibling-aware so the trunk only
// continues past a row if there are more siblings below at that ancestor level.
for (let i = 0; i < out.length; i++) {
const row = out[i]
const segs = []
for (let L = 0; L < row.relDepth; L++) {
// Identity of ancestor at level L for THIS row
const ancAtL = row.ancestors[L]
// Look forward to find: any later row whose ancestor at L is the same?
let trunkContinues = false
for (let j = i + 1; j < out.length; j++) {
const next = out[j]
if (next.relDepth <= L) break // walked out of this branch
if (next.relDepth === L + 1 && next.ancestors[L] === ancAtL) {
trunkContinues = true; break
}
if (next.relDepth > L + 1 && next.ancestors[L] === ancAtL) {
// descendants of an EARLIER child of ancestor — keep looking
continue
}
}
if (L === row.relDepth - 1) {
// Connector to MY parent — does my parent have more children below me?
let hasLaterSibling = false
for (let j = i + 1; j < out.length; j++) {
const next = out[j]
if (next.relDepth < row.relDepth) break
if (next.relDepth === row.relDepth && next.parent === row.parent) {
hasLaterSibling = true; break
}
}
segs.push(hasLaterSibling ? 'tee' : 'elbow')
} else {
segs.push(trunkContinues ? 'pipe' : 'blank')
}
}
row.trunks = segs
}
return out
})
const hasSel = computed(() => selectedId.value !== null)
const hasCount = computed(() => props.columns.includes('count'))
const hasPop = computed(() => props.columns.includes('pop'))
// Descendants of the selected node — kept full-opacity so the user can drill in
const selectedDescendants = computed(() => {
if (!selectedNode.value) return new Set()
return collectAllDescendantIds(selectedNode.value)
})
// ── Parent breadcrumb (uses parent's TAB name, not generic "Parent:") ────────
const parentBreadcrumb = computed(() => {
const sel = selectedNode.value
if (!sel) return null
const chain = findAncestorChain(props.nodes, sel.id)
if (!chain || !chain.length) return null
const parent = chain[chain.length - 1]
const parentDepth = chain.length - 1
const tabName = tabList.value[parentDepth]?.label || 'Parent'
return { node: parent, tabName }
})
// ── Actions ────────────────────────────────────────────────────────────────────
function selectNode(node) {
if (selectedId.value === node.id) {
_lastInternalId = null
selectedNode.value = null
emit('select', null)
} else {
_lastInternalId = node.id
selectedNode.value = node
emit('select', node)
scrollSelectedIntoView()
}
}
function toggleExpand(id, e) {
e?.stopPropagation()
const s = new Set(expandedIds.value)
s.has(id) ? s.delete(id) : s.add(id)
expandedIds.value = s
}
function switchTab(idx) {
if (activeTab.value === idx) {
showInfoForTab.value = showInfoForTab.value === idx ? null : idx
return
}
activeTab.value = idx
expandedIds.value = new Set()
_lastInternalId = null
selectedNode.value = null
showInfoForTab.value = null
emit('select', null)
}
function deselect(e) {
e?.stopPropagation()
_lastInternalId = null
selectedNode.value = null
emit('select', null)
}
function showInfo(idx, e) {
e?.stopPropagation()
showInfoForTab.value = showInfoForTab.value === idx ? null : idx
}
// ── Formatters ─────────────────────────────────────────────────────────────────
function fmtPop(n) {
if (!n) return '—'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return Math.round(n / 1e3) + 'k'
return n.toLocaleString()
}
function fmtCount(n) { return n != null ? n.toLocaleString() : '—' }
// Per-row class helper — descendants of selected stay full-opacity
function rowClasses(row) {
return {
selected: selectedId.value === row.node.id,
descendant: hasSel.value && selectedDescendants.value.has(row.node.id),
child: row.relDepth > 0,
leaf: !row.node.children?.length,
}
}
</script>
<template>
<!-- Two roots so the collapsed-state chip isn't dragged off-screen by the
panel's translateX(-100%). The chip is a sibling, not a child. -->
<div ref="panelRoot" class="stl-panel" :class="{ closed: !panelOpen }" :data-theme="instance?.theme.value || 'dark'">
<div class="stl-inner">
<!-- Title bar — swaps to "{Tab name}: {Parent label}" when a child is selected.
Corner caret on the LEFT collapses the panel (replaces the old right-edge ribbon). -->
<div class="stl-titlebar">
<button
class="stl-collapse-btn"
:class="{ closed: !panelOpen }"
:title="panelOpen ? 'Collapse legend' : 'Expand legend'"
@click="panelOpen = !panelOpen">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<template v-if="parentBreadcrumb && selectedNode">
<span class="stl-tb-eyebrow">{{ parentBreadcrumb.tabName }}</span>
<span v-if="parentBreadcrumb.node.color" class="stl-tb-dot"
:style="{ background: parentBreadcrumb.node.color }"></span>
<span class="stl-tb-text" :title="parentBreadcrumb.node.label">{{ parentBreadcrumb.node.label }}</span>
</template>
<template v-else>
<span class="stl-tb-title">{{ title }}</span>
</template>
</div>
<!-- Tabs (one per generation) — single row -->
<div class="stl-tabs-wrap">
<div class="stl-tabs">
<div v-for="(tab, idx) in tabList" :key="tab.id"
class="stl-tab-chip" :class="{ active: activeTab === idx }">
<button class="stl-tab" @click="switchTab(idx)">{{ tab.label }}</button>
<button v-if="tab.info" class="stl-tab-info" :title="tab.info"
@click="showInfo(idx, $event)">i</button>
</div>
</div>
<div v-if="showInfoForTab !== null && tabList[showInfoForTab]?.info" class="stl-info-pop">
{{ tabList[showInfoForTab].info }}
</div>
</div>
<!-- Column headers — sortable -->
<div class="stl-col-hdr">
<span class="stl-caret-sp"></span>
<span class="stl-dot-sp"></span>
<span class="stl-hdr-name">{{ tabList[activeTab]?.label || 'Name' }}</span>
<button v-if="hasCount" class="stl-hdr-num"
:class="{ active: sortBy === 'count' }"
title="Sort by UPGs"
@click="clickSortHeader('count')">
UPGs
<span v-if="sortBy === 'count'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
</button>
<button v-if="hasPop" class="stl-hdr-num"
:class="{ active: sortBy === 'pop' }"
title="Sort by Population"
@click="clickSortHeader('pop')">
Pop
<span v-if="sortBy === 'pop'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
</button>
<span class="stl-x-slot"></span>
</div>
<!-- Rows -->
<div class="stl-rows" :class="{ 'has-sel': hasSel }">
<div v-for="row in visibleRows" :key="row.node.id"
class="stl-row" :class="rowClasses(row)"
:data-row-id="row.node.id"
:style="{
'--trunk-color': (row.parent?.color || '#6e7681'),
'--row-color': (row.node.color || '#73A17F'),
}"
@click="selectNode(row.node)">
<!-- Hairline tree connectors (one per indent level) — sibling-aware -->
<span v-for="(seg, i) in row.trunks" :key="`tr${i}`"
class="stl-trunk" :class="`stl-trunk-${seg}`"></span>
<button v-if="row.node.children?.length"
class="stl-caret" :class="{ open: expandedIds.has(row.node.id) }"
@click.stop="toggleExpand(row.node.id, $event)">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<span v-else class="stl-caret-sp"></span>
<span v-if="row.node.color" class="stl-dot" :style="{ background: row.node.color }"></span>
<span v-else class="stl-dot-sp"></span>
<span class="stl-name">{{ row.node.label }}</span>
<span v-if="hasCount" class="stl-num">{{ fmtCount(row.node.count) }}</span>
<span v-if="hasPop" class="stl-num">{{ fmtPop(row.node.pop) }}</span>
<span class="stl-x-slot">
<button v-if="selectedId === row.node.id"
class="stl-x-chip" title="Clear selection"
@click.stop="deselect($event)">
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</span>
</div>
<div v-if="tabLoading && !visibleRows.length" class="stl-empty stl-loading">
<span class="stl-spinner"></span>
<span>Loading {{ tabList[activeTab]?.label || 'rows' }}…</span>
</div>
<div v-else-if="!visibleRows.length && nodes.length === 0" class="stl-empty">Loading data…</div>
<div v-else-if="!visibleRows.length" class="stl-empty">No data at this level</div>
<div v-if="topNodesOverflow > 0" class="stl-overflow">
Showing top {{ tabList[activeTab].topLimit.toLocaleString() }} of
{{ topNodesAll.length.toLocaleString() }} —
<span class="stl-overflow-hint">use the geocoder above to find the rest</span>
</div>
</div>
</div>
</div>
<!-- Collapsed-state pill — sibling of .stl-panel so the panel's translateX
doesn't take it off-screen. Sits where the panel used to be. -->
<button v-if="!panelOpen"
class="stl-reopen"
:data-theme="instance?.theme.value || 'dark'"
title="Expand legend"
@click="panelOpen = true">
<svg class="stl-reopen-icon" width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M2 4h8M2 6h8M2 8h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
<span class="stl-reopen-label">
<template v-if="selectedNode">
<span v-if="selectedNode.color" class="stl-reopen-dot"
:style="{ background: selectedNode.color }"></span>
{{ selectedNode.label }}
</template>
<template v-else>{{ title }}</template>
</span>
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" class="stl-reopen-caret">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</template>
<style scoped>
/* ── Single source of truth for column widths ─────────────────────────────────── */
.stl-inner {
--stl-pad-x: 10px;
--stl-gap: 6px;
--stl-caret-w: 14px;
--stl-dot-w: 8px;
--stl-num-w: 44px;
--stl-x-w: 22px;
flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column;
background: #161b22; border: 1px solid #30363d;
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.45);
overflow: hidden;
}
/* ── Panel wrapper — wider sidebar (no more right-edge ribbon eating space) ─── */
.stl-panel {
position: absolute; left: 8px; top: 60px; bottom: 12px; width: 380px; z-index: 4;
display: flex; flex-direction: column;
transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s;
pointer-events: auto;
}
.stl-panel.closed {
transform: translateX(-100%);
opacity: 0; pointer-events: none;
}
/* ── Title bar (~32px) ────────────────────────────────────────────────────────── */
.stl-titlebar {
display: flex; align-items: center; gap: 6px;
padding: 8px 12px; flex-shrink: 0;
border-bottom: 1px solid #21262d;
min-height: 32px;
}
.stl-tb-title,
.stl-tb-eyebrow {
font: 600 10px ui-monospace, monospace;
color: #73A17F; text-transform: uppercase; letter-spacing: 0.07em;
flex-shrink: 0;
}
.stl-tb-eyebrow { color: #6e7681; font-size: 9px; letter-spacing: 0.08em; }
.stl-tb-dot {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 0 0 3px rgba(115,161,127,0.15);
}
.stl-tb-text {
flex: 1; min-width: 0;
font: 600 12px system-ui; color: #e6edf3;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ── Tabs (single row) ────────────────────────────────────────────────────────── */
.stl-tabs-wrap { flex-shrink: 0; }
.stl-tabs {
display: flex; flex-wrap: nowrap; gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid #21262d;
overflow-x: auto;
scrollbar-width: none;
}
.stl-tabs::-webkit-scrollbar { display: none; }
.stl-tab-chip {
display: inline-flex; align-items: stretch; flex-shrink: 0;
background: #1c2333; border: 1px solid #30363d; border-radius: 6px;
overflow: hidden; transition: border-color 0.12s, box-shadow 0.12s;
}
.stl-tab-chip:hover { border-color: #484f58; }
.stl-tab-chip.active {
background: #3b463d; border-color: #73A17F;
box-shadow: 0 0 0 2px rgba(115,161,127,0.22), 0 1px 4px rgba(59,70,61,0.4);
}
.stl-tab {
display: inline-flex; align-items: center;
background: transparent; border: none;
color: #8b949e; font: 600 10px ui-monospace, monospace;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px; cursor: pointer; white-space: nowrap;
}
.stl-tab:hover { color: #c9d1d9; }
.stl-tab-chip.active .stl-tab { color: #fff; }
.stl-tab-info {
background: rgba(110,118,129,0.15); border: none; border-left: 1px solid #30363d;
color: #6e7681; cursor: pointer;
width: 18px; padding: 0; font: 700 9px ui-monospace, monospace;
}
.stl-tab-info:hover { background: rgba(59,70,61,0.2); color: #73A17F; }
.stl-tab-chip.active .stl-tab-info {
border-left-color: rgba(0,0,0,0.3); color: #fff;
background: rgba(0,0,0,0.2);
}
.stl-info-pop {
margin: 2px 8px 6px; padding: 7px 10px;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
font: 11px system-ui; color: #c9d1d9; line-height: 1.45;
}
/* ── Shared row layout (header + data rows) ───────────────────────────────────── */
.stl-col-hdr,
.stl-row {
display: flex; align-items: center; gap: var(--stl-gap);
padding: 0 var(--stl-pad-x);
}
.stl-col-hdr {
height: 22px; flex-shrink: 0;
background: #0d1117; border-bottom: 1px solid #21262d;
}
.stl-hdr-name {
flex: 1; min-width: 0;
font: 600 9px ui-monospace, monospace;
color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em;
}
.stl-hdr-num {
width: var(--stl-num-w); text-align: right; flex-shrink: 0;
font: 600 9px ui-monospace, monospace;
color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em;
background: transparent; border: none; cursor: pointer;
padding: 0; transition: color 0.1s;
display: inline-flex; align-items: center; justify-content: flex-end; gap: 3px;
}
.stl-hdr-num:hover { color: #c9d1d9; }
.stl-hdr-num.active { color: #73A17F; }
.stl-sort-arrow { font-size: 8px; }
/* ── Rows container ───────────────────────────────────────────────────────────── */
.stl-rows { flex: 1; overflow-y: auto; padding-bottom: 6px; }
.stl-rows::-webkit-scrollbar { width: 6px; }
.stl-rows::-webkit-scrollbar-track { background: transparent; }
.stl-rows::-webkit-scrollbar-thumb { background: #21262d; border-radius: 3px; }
.stl-rows::-webkit-scrollbar-thumb:hover { background: #30363d; }
.stl-empty {
padding: 20px 12px; text-align: center;
font: 11px system-ui; color: #484f58;
}
.stl-loading {
display: inline-flex; align-items: center; gap: 8px;
width: 100%; justify-content: center;
color: #73A17F;
}
.stl-spinner {
width: 12px; height: 12px; border-radius: 50%;
border: 2px solid rgba(115,161,127,0.25);
border-top-color: #73A17F;
animation: stl-spin 0.7s linear infinite;
}
@keyframes stl-spin { to { transform: rotate(360deg); } }
.stl-overflow {
padding: 8px 10px; margin: 4px 0 0;
background: #0d1117; border-top: 1px dashed #30363d;
text-align: center; font: 10px system-ui; color: #6e7681;
}
.stl-overflow-hint { color: #73A17F; }
/* ── Data rows ────────────────────────────────────────────────────────────────── */
.stl-row {
min-height: 27px;
cursor: pointer; transition: opacity 0.12s, background 0.1s;
border-bottom: 1px solid rgba(33,38,45,0.45);
}
.stl-row:last-child { border-bottom: none; }
.stl-row:hover { background: rgba(255,255,255,0.04); }
.stl-row.child .stl-name { color: #adbac7; }
.stl-row.leaf .stl-name { font-style: italic; }
/* Dim non-selected rows EXCEPT descendants of the selected node (so the user can
drill down into a selected family without the children being grayed out). */
.stl-rows.has-sel .stl-row:not(.selected):not(.descendant) { opacity: 0.30; }
.stl-rows.has-sel .stl-row:not(.selected):not(.descendant):hover { opacity: 0.6; }
.stl-rows.has-sel .stl-row.descendant { opacity: 1; }
.stl-rows.has-sel .stl-row.selected {
opacity: 1;
/* Subtle neutral background tint + soft outer glow in the row's own color so
the selection pops without competing with the family color of the row. */
background: rgba(255,255,255,0.05);
box-shadow:
inset 3px 0 0 0 var(--row-color, #73A17F),
inset 0 0 0 1px rgba(255,255,255,0.05),
0 0 14px -2px var(--row-color, rgba(115,161,127,0.4));
}
.stl-panel[data-theme="light"] .stl-rows.has-sel .stl-row.selected {
background: rgba(255,255,255,0.7);
box-shadow:
inset 3px 0 0 0 var(--row-color, #3b463d),
inset 0 0 0 1px rgba(0,0,0,0.04),
0 0 14px -2px var(--row-color, rgba(59,70,61,0.35));
}
/* ── Reserved slots ───────────────────────────────────────────────────────────── */
.stl-caret-sp { width: var(--stl-caret-w); flex-shrink: 0; }
.stl-dot-sp { width: var(--stl-dot-w); flex-shrink: 0; }
.stl-x-slot {
width: var(--stl-x-w); flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
/* ── ASCII-tree hairline trunks ───────────────────────────────────────────────
One span per indent level. Each span is 12px wide. Pseudo-elements draw
thin lines tinted by the row's parent color (--trunk-color):
- pipe = vertical line through full row height (ancestor at this level
still has more siblings below)
- tee = vertical line + right stub at row middle (this row has later siblings)
- elbow = vertical line stops at row middle + right stub (this row is the last child)
- blank = no line (ancestor's chain ended above this row)
*/
.stl-trunk {
position: relative; flex-shrink: 0;
width: 12px; align-self: stretch;
}
.stl-trunk-pipe::before {
content: ''; position: absolute;
left: 5px; top: 0; bottom: 0; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-tee::before {
content: ''; position: absolute;
left: 5px; top: 0; bottom: 0; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-tee::after {
content: ''; position: absolute;
left: 5px; top: 50%; height: 1px; width: 7px;
background: var(--trunk-color, #6e7681);
opacity: 0.55;
}
.stl-trunk-elbow::before {
content: ''; position: absolute;
left: 5px; top: 0; height: 50%; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-elbow::after {
content: ''; position: absolute;
left: 5px; top: calc(50% - 0.5px); height: 1px; width: 7px;
background: var(--trunk-color, #6e7681);
opacity: 0.55;
}
/* selected/descendant rows: brighter trunks */
.stl-row.selected .stl-trunk-pipe::before,
.stl-row.selected .stl-trunk-tee::before,
.stl-row.selected .stl-trunk-tee::after,
.stl-row.selected .stl-trunk-elbow::before,
.stl-row.selected .stl-trunk-elbow::after,
.stl-row.descendant .stl-trunk-pipe::before,
.stl-row.descendant .stl-trunk-tee::before,
.stl-row.descendant .stl-trunk-tee::after,
.stl-row.descendant .stl-trunk-elbow::before,
.stl-row.descendant .stl-trunk-elbow::after {
opacity: 0.85;
}
/* ── Caret button ─────────────────────────────────────────────────────────────── */
.stl-caret {
background: none; border: none; padding: 0; cursor: pointer;
color: #6e7681; width: var(--stl-caret-w); height: var(--stl-caret-w); flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
border-radius: 3px;
}
.stl-caret svg { transition: transform 0.15s; }
.stl-caret.open svg { transform: rotate(90deg); }
.stl-caret:hover { color: #c9d1d9; background: rgba(255,255,255,0.06); }
/* ── Color dot ────────────────────────────────────────────────────────────────── */
.stl-dot {
width: var(--stl-dot-w); height: var(--stl-dot-w); border-radius: 50%; flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
}
.stl-row.selected .stl-dot {
box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 0 0 3px rgba(115,161,127,0.25);
}
/* ── Label ────────────────────────────────────────────────────────────────────── */
.stl-name {
flex: 1; min-width: 0;
font: 11.5px system-ui, sans-serif; color: #e6edf3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── Numeric columns ──────────────────────────────────────────────────────────── */
.stl-num {
width: var(--stl-num-w); text-align: right; flex-shrink: 0;
font: 10.5px ui-monospace, monospace; color: #8b949e;
font-variant-numeric: tabular-nums;
}
.stl-row.selected .stl-num { color: #e6edf3; }
/* ── Inline X chip — clearer "delete" affordance, no overlap with badges ─────── */
.stl-x-chip {
background: #ef4444; border: none; border-radius: 50%;
color: #fff; cursor: pointer;
width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 0 2px rgba(239,68,68,0.18);
transition: transform 0.12s, box-shadow 0.12s;
}
.stl-x-chip:hover { transform: scale(1.08); box-shadow: 0 0 0 3px rgba(239,68,68,0.3); }
/* ── Corner caret (in titlebar) — primary collapse affordance ────────────────── */
.stl-collapse-btn {
background: rgba(110,118,129,0.15); border: 1px solid #30363d; border-radius: 5px;
color: #8b949e; cursor: pointer;
width: 22px; height: 20px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: color 0.1s, background 0.1s, transform 0.18s;
}
.stl-collapse-btn:hover { color: #c9d1d9; background: rgba(59,70,61,0.18); border-color: #73A17F; }
.stl-collapse-btn svg { transition: transform 0.18s; transform: rotate(180deg); }
/* ── Collapsed-state pill — rounded "Legend" chip — matches the search-bar
height/radius/left-edge so the chrome reads as a vertical stack of pills.
z-index intentionally LOWER than the geocoder slot (5) so the search
dropdown can extend over the chip when the user is searching. */
.stl-reopen {
position: absolute; left: 8px; top: 60px; z-index: 3;
display: inline-flex; align-items: center; gap: 8px;
background: #0d1117; border: 1px solid #30363d;
border-radius: 999px;
color: #c9d1d9; cursor: pointer;
height: 36px; /* exact match to search-bar height */
padding: 0 14px 0 12px;
box-shadow: 0 4px 14px rgba(0,0,0,0.25);
font: 600 11px ui-monospace, monospace;
text-transform: uppercase; letter-spacing: 0.06em;
max-width: 320px;
transition: color 0.12s, background 0.12s, border-color 0.12s, transform 0.12s,
box-shadow 0.12s;
}
.stl-reopen:hover {
color: #fff; background: #1c2333; border-color: #73A17F;
box-shadow: 0 6px 20px rgba(59,70,61,0.25);
transform: translateY(-1px);
}
.stl-reopen-icon { color: #73A17F; flex-shrink: 0; }
.stl-reopen-label {
display: inline-flex; align-items: center; gap: 6px;
min-width: 0; max-width: 200px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-transform: none; letter-spacing: 0;
font: 600 12px system-ui, sans-serif;
}
.stl-reopen-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
}
.stl-reopen-caret { color: #6e7681; flex-shrink: 0; }
.stl-reopen:hover .stl-reopen-caret { color: #c9d1d9; }
@media (max-width: 640px) {
.stl-panel { width: 92vw; max-width: 380px; top: 64px; bottom: 8px; }
.stl-reopen { top: 64px; }
}
/* ── Light-mode palette ──────────────────────────────────────────────────────
Overrides triggered by [data-theme="light"] on the panel root. Mirrors the
underlying Mapbox light-v11 basemap so chrome and map read as one surface. */
.stl-panel[data-theme="light"] .stl-inner {
background: #ffffff;
border-color: #d0d7de;
box-shadow: 0 6px 24px rgba(31, 35, 40, 0.12);
}
.stl-panel[data-theme="light"] .stl-titlebar { border-color: #d8dee4; }
.stl-panel[data-theme="light"] .stl-tb-title,
.stl-panel[data-theme="light"] .stl-tb-eyebrow { color: #3b463d; }
.stl-panel[data-theme="light"] .stl-tb-eyebrow { color: #57606a; }
.stl-panel[data-theme="light"] .stl-tb-text { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-collapse-btn {
background: rgba(208,215,222,0.4); border-color: #d0d7de; color: #57606a;
}
.stl-panel[data-theme="light"] .stl-collapse-btn:hover {
background: rgba(59,70,61,0.12); border-color: #3b463d; color: #3b463d;
}
.stl-panel[data-theme="light"] .stl-tabs { border-color: #d8dee4; }
.stl-panel[data-theme="light"] .stl-tab-chip {
background: #f6f8fa; border-color: #d0d7de;
}
.stl-panel[data-theme="light"] .stl-tab { color: #57606a; }
.stl-panel[data-theme="light"] .stl-tab:hover { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-tab-chip.active {
background: #3b463d; border-color: #3b463d;
box-shadow: 0 0 0 2px rgba(59,70,61,0.18);
}
.stl-panel[data-theme="light"] .stl-tab-chip.active .stl-tab { color: #fff; }
.stl-panel[data-theme="light"] .stl-tab-info {
background: rgba(208,215,222,0.4); border-color: #d0d7de; color: #57606a;
}
.stl-panel[data-theme="light"] .stl-info-pop {
background: #f6f8fa; border-color: #d0d7de; color: #1f2328;
}
.stl-panel[data-theme="light"] .stl-col-hdr {
background: #f6f8fa; border-color: #d8dee4;
}
.stl-panel[data-theme="light"] .stl-hdr-name,
.stl-panel[data-theme="light"] .stl-hdr-num { color: #57606a; }
.stl-panel[data-theme="light"] .stl-hdr-num:hover { color: #3b463d; }
.stl-panel[data-theme="light"] .stl-hdr-num.active { color: #3b463d; }
.stl-panel[data-theme="light"] .stl-rows::-webkit-scrollbar-thumb { background: #d0d7de; }
.stl-panel[data-theme="light"] .stl-rows::-webkit-scrollbar-thumb:hover { background: #afb8c1; }
.stl-panel[data-theme="light"] .stl-empty,
.stl-panel[data-theme="light"] .stl-overflow {
color: #57606a; background: #f6f8fa; border-color: #d8dee4;
}
.stl-panel[data-theme="light"] .stl-overflow-hint { color: #3b463d; }
.stl-panel[data-theme="light"] .stl-row {
border-color: rgba(208,215,222,0.5);
}
.stl-panel[data-theme="light"] .stl-row:hover { background: rgba(59,70,61,0.04); }
.stl-panel[data-theme="light"] .stl-row.child .stl-name { color: #57606a; }
.stl-panel[data-theme="light"] .stl-rows.has-sel .stl-row.selected {
background: rgba(59,70,61,0.12); box-shadow: inset 3px 0 0 0 #3b463d, 0 0 12px rgba(59,70,61,0.08);
}
.stl-panel[data-theme="light"] .stl-name { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-num { color: #57606a; }
.stl-panel[data-theme="light"] .stl-row.selected .stl-num { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-caret { color: #57606a; }
.stl-panel[data-theme="light"] .stl-caret:hover { background: rgba(59,70,61,0.08); color: #3b463d; }
/* Light-mode reopen pill */
.stl-reopen[data-theme="light"] {
background: #ffffff; border-color: #d0d7de; color: #1f2328;
box-shadow: 0 4px 16px rgba(31,35,40,0.18);
}
.stl-reopen[data-theme="light"]:hover {
background: #f6f8fa; border-color: #3b463d; color: #3b463d;
box-shadow: 0 6px 20px rgba(59,70,61,0.2);
}
.stl-reopen[data-theme="light"] .stl-reopen-icon { color: #3b463d; }
.stl-reopen[data-theme="light"] .stl-reopen-caret { color: #57606a; }
</style>

View file

@ -0,0 +1,93 @@
/**
* usePplrInstance.js — per-map "instance store" for PPLR profiles.
*
* Implements the Mediator Pattern (per QA-feedback Round 8 architecture closeout):
* each map cluster (one map + one geocoder + one legend) gets its own scoped store.
* Components never call each other directly — they read/write the store and watch its fields.
*
* Bidirectional sync between geocoder and legend lives here:
* - Selecting a row in the legend → store.selection set → geocoder watches and clears its text
* - Picking a result in the geocoder → store.selection set + store.activeTab synced → legend reacts
* - X on either side → store.selection cleared → both clear
*
* Each profile creates one instance via createPplrInstance() and provides it.
* Children (legend, geocoder) consume it via useInstance().
*/
import { ref, provide, inject } from 'vue'
const KEY = 'pplrInstance'
/**
* Create a new instance store for one map.
* @param {string} instanceId Stable id used for debugging/keying.
* @returns {object} reactive store
*/
export function createPplrInstance(instanceId) {
return {
instanceId,
// ── Selection (drives map filter + legend highlight) ────────────────────
/** Currently selected tree node, or null. Shape: { id, label, kind, depth, filter, color, ...extras } */
selection: ref(null),
/** Active legend tab index (0-based — but Tab 1 = Generation 1 in user-facing labels) */
activeTab: ref(0),
/** Search-mode flag: true when geocoder text ≥ 2 chars */
isSearching: ref(false),
// ── Geocoder text (read by legend so it can dim itself, etc.) ────────────
geocoderText: ref(''),
// ── Pin-detail panel (clicked pin replaces legend with detail) ──────────
/** Currently clicked pin properties, or null */
pinDetail: ref(null),
// ── Chrome theme — driven by the active basemap (light-v11 → 'light',
// anything else → 'dark'). Legend/geocoder/detail-card read this and
// apply [data-theme] CSS overrides so chrome matches the map underneath.
theme: ref('dark'),
}
}
/**
* Provide the instance to descendants.
* Call this in the profile (parent) component.
*/
export function provideInstance(instance) {
provide(KEY, instance)
}
/**
* Inject the instance in a child component.
* Returns null if no instance was provided (component used standalone).
*/
export function useInstance() {
return inject(KEY, null)
}
/**
* Convenience: select a node, sync legend tab to its depth.
*/
export function selectNode(instance, node) {
if (!instance) return
if (!node) {
instance.selection.value = null
return
}
instance.selection.value = node
if (typeof node.depth === 'number') {
instance.activeTab.value = node.depth
}
}
/**
* Convenience: clear all (selection + geocoder text + pin detail).
*/
export function clearAll(instance) {
if (!instance) return
instance.selection.value = null
instance.geocoderText.value = ''
instance.pinDetail.value = null
}

View file

@ -0,0 +1,49 @@
/**
* pplrData.js — where this bundle's data files are served from.
*
* The maps were written against a page that served assets/data/ at the site root. Embedded, that
* relative path resolves against the HOST page and 404s, so every fetch goes through dataUrl(),
* which joins the tree's shared assets base (set once by the hub from resolveAssetsBase) with
* pplr/<file>. The files are derived by ../scripts/build-data.mjs into data/pplr/.
*/
let base = './assets'
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
export function dataUrl(file) { return base + '/pplr/' + file }
// d3 v7 is a page global here (as in the original build). Load it once, on first need.
let d3Promise = null
export function ensureD3() {
if (typeof window === 'undefined' || window.d3) return Promise.resolve()
if (!d3Promise) d3Promise = new Promise((res, rej) => {
const s = document.createElement('script')
s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'
s.onload = () => res(); s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load')) }
document.head.appendChild(s)
})
return d3Promise
}
// The 17 locale names per PG, split out of pg-points so the first paint stays small. Loaded on
// the first pin that opens a detail card.
let namesPromise = null
export function loadNames() {
if (!namesPromise) namesPromise = fetch(dataUrl('pg-names.json'))
.then((r) => { if (!r.ok) throw new Error('pg-names.json ' + r.status); return r.json() })
.catch((e) => { namesPromise = null; throw e })
return namesPromise
}
// mapbox-gl-geocoder is a page global too (the original page carried a <script> tag). Load it
// once, on first need, so the search box works wherever the bundle is embedded.
const GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'
let geocoderPromise = null
export function ensureGeocoder() {
if (typeof window === 'undefined' || window.MapboxGeocoder) return Promise.resolve()
if (!geocoderPromise) geocoderPromise = new Promise((res, rej) => {
const s = document.createElement('script')
s.src = GEOCODER_JS
s.onload = () => res(); s.onerror = () => { geocoderPromise = null; rej(new Error('geocoder failed to load')) }
document.head.appendChild(s)
})
return geocoderPromise
}

View file

@ -0,0 +1,450 @@
/**
* pplrMap.js — shared helpers for PPLR validation profiles.
*
* Provides:
* - LOCALES, LOCALE_NAMES — the 17 locale keys and display names
* - COUNTRY_FS_URL, COUNTRY_FS_FIELDS — PPLR FeatureServer country polygon endpoint
* - loadOnce(path) — cached fetch returning JSON
* - addCountryPolygons(map) — fetches country polygons from PPLR FeatureServer (paginated)
* - addPGPoints(map, opts) — adds the PG-in-country pin layer (individual pins, no clustering)
* - popupForPG(props) — generic popup HTML for a PG feature
*
* Local data files (served from /assets/data/):
* pg-points.geojson 21,419 PG-in-country pins — code, name, rop1-3, rop25, iso3,
* country, imb, jp, wcd, religion, rg, n_en…n_de (17 locales)
* rop-tree.json ROP hierarchy: 17 blocs → 292 clusters → 13,065 PGs w/ entries[]
* rosetta-sample.json 5 sample PGs with all cross-system IDs
* pg-names-summary.json per-source × locale fill-rate matrix
*
* Remote data (live — NOT bundled):
* PPLR FeatureServer aCountry → country polygons, paginated
*/
export const LOCALES = ['en','hi','bn','ar','zh_TW','fr','ru','ne','pt','es','ko','tl','zh_CN','id','gu','am','de']
export const LOCALE_NAMES = {
en: 'English', hi: 'Hindi', bn: 'Bengali', ar: 'Arabic', zh_TW: 'Chinese (Trad)',
fr: 'French', ru: 'Russian', ne: 'Nepali', pt: 'Portuguese', es: 'Spanish',
ko: 'Korean', tl: 'Tagalog', zh_CN: 'Chinese (Simp)', id: 'Indonesian',
gu: 'Gujarati', am: 'Amharic', de: 'German'
}
// ── PPLR FeatureServer — country polygons (live, not bundled) ────────────────
import { dataUrl } from './pplrData.js'
export const COUNTRY_FS_URL = 'https://services2.arcgis.com/S4ydGgujXcif36k3/arcgis/rest/services/aCountry/FeatureServer/0/query'
export const COUNTRY_FS_FIELDS = ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp']
const _cache = new Map()
export async function loadOnce(path) {
if (_cache.has(path)) return _cache.get(path)
const p = (async () => {
// bare file names are this bundle's data files; anything with a scheme or slash is fetched as given
const r = await fetch(/^(https?:)?\/|\//.test(path) ? path : dataUrl(path))
if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`)
return r.json()
})()
_cache.set(path, p)
try { return await p } catch (e) { _cache.delete(path); throw e }
}
/**
* Drop a single cached path or the entire cache. Call from a profile's
* onBeforeUnmount when you want the next mount to re-fetch (frees the
* 21k-feature pg-points GeoJSON ~10–15 MB; otherwise it persists for the
* lifetime of the host page even when no profile holds it).
*/
export function clearLoadOnceCache(path) {
if (path) _cache.delete(path)
else _cache.clear()
}
function fetchCountryPolygons() {
const CACHE_KEY = '__pplr_country_polygons__'
if (_cache.has(CACHE_KEY)) return _cache.get(CACHE_KEY)
const p = (async () => {
const features = []
let offset = 0
let exceeded = true
while (exceeded) {
const params = new URLSearchParams({
where: '1=1',
returnGeometry: 'true',
f: 'geojson',
outFields: COUNTRY_FS_FIELDS.join(','),
resultRecordCount: '200',
resultOffset: String(offset),
maxAllowableOffset: '0.05' // simplify geometry server-side; fine for world-scale choropleth
})
const resp = await fetch(`${COUNTRY_FS_URL}?${params}`)
if (!resp.ok) throw new Error(`PPLR aCountry: HTTP ${resp.status}`)
const data = await resp.json()
features.push(...(data.features || []))
exceeded = data.exceededTransferLimit === true
offset += data.features?.length || 0
if (!data.features?.length) break
}
return { type: 'FeatureCollection', features }
})()
_cache.set(CACHE_KEY, p)
p.catch(() => _cache.delete(CACHE_KEY))
return p
}
/**
* Add country polygons from PPLR FeatureServer.
*
* Non-blocking by default: adds empty source+layers immediately so pin layers
* added by the caller render on top. Data fills in via setData() when the
* paginated fetch completes (typically 1-2 seconds).
*
* Pass `blocking: true` when the caller needs the full fc before proceeding
* (e.g. pplr-coverage choropleth that must annotate feature properties).
*
* Returns { sourceId, dataReady: Promise<{ fc, sourceId }> }.
* For blocking callers, await the returned promise directly.
*/
// Theme-aware paint presets. Light mode renders polygons fully invisible
// (per user spec 2026-05-02: "I prefer on light mode not to even show the
// Polygon blue borders"). Dark mode keeps the dark-gray fill + blue line.
export const POLYGON_PAINT = {
dark: { fillColor: '#1f2937', fillOpacity: 0.35, lineColor: '#3b82f6', lineOpacity: 0.5 },
light: { fillColor: 'rgba(0,0,0,0)', fillOpacity: 0, lineColor: 'rgba(0,0,0,0)', lineOpacity: 0 },
}
export function addCountryPolygons(map, {
sourceId = 'pplr-countries',
fillColor = '#1f2937',
fillOpacity = 0.35,
lineColor = '#3b82f6',
lineOpacity = 0.5,
blocking = false,
theme = null, // 'dark' | 'light' — if set, overrides the four colors above
} = {}) {
if (theme && POLYGON_PAINT[theme]) {
({ fillColor, fillOpacity, lineColor, lineOpacity } = POLYGON_PAINT[theme])
}
const emptyFc = { type: 'FeatureCollection', features: [] }
if (!map.getSource(sourceId)) {
map.addSource(sourceId, { type: 'geojson', data: emptyFc, generateId: true })
}
if (!map.getLayer(`${sourceId}-fill`)) {
map.addLayer({
id: `${sourceId}-fill`,
type: 'fill',
source: sourceId,
paint: {
'fill-color': fillColor,
'fill-opacity': ['case', ['boolean', ['feature-state', 'hover'], false], fillOpacity + 0.2, fillOpacity]
}
})
}
if (!map.getLayer(`${sourceId}-line`)) {
map.addLayer({
id: `${sourceId}-line`,
type: 'line',
source: sourceId,
paint: { 'line-color': lineColor, 'line-width': 0.6, 'line-opacity': lineOpacity }
})
}
// Hover highlight (feature-state)
let hoverId = null
map.on('mousemove', `${sourceId}-fill`, e => {
if (!e.features.length) return
if (hoverId !== null) map.setFeatureState({ source: sourceId, id: hoverId }, { hover: false })
hoverId = e.features[0].id
map.setFeatureState({ source: sourceId, id: hoverId }, { hover: true })
map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', `${sourceId}-fill`, () => {
if (hoverId !== null) map.setFeatureState({ source: sourceId, id: hoverId }, { hover: false })
hoverId = null
map.getCanvas().style.cursor = ''
})
// Fetch data; populate source when ready
const dataReady = fetchCountryPolygons().then(fc => {
if (map.getSource(sourceId)) map.getSource(sourceId).setData(fc)
return { fc, sourceId }
})
if (blocking) return dataReady // caller awaits full data
dataReady.catch(e => console.warn('Country polygons failed to load:', e))
return Promise.resolve({ sourceId, dataReady }) // caller gets control immediately
}
/**
* Re-paint the country polygon layers for a given theme. Call after a
* theme/basemap toggle that does NOT involve setStyle (when setStyle fires,
* use the `theme` option on addCountryPolygons in your re-init instead).
*/
export function setCountryPolygonTheme(map, theme, sourceId = 'pplr-countries') {
const p = POLYGON_PAINT[theme]
if (!map || !p) return
const fillId = `${sourceId}-fill`
const lineId = `${sourceId}-line`
if (map.getLayer(fillId)) {
try {
map.setPaintProperty(fillId, 'fill-color', p.fillColor)
map.setPaintProperty(fillId, 'fill-opacity',
['case', ['boolean', ['feature-state', 'hover'], false], p.fillOpacity + 0.2, p.fillOpacity])
} catch {}
}
if (map.getLayer(lineId)) {
try {
map.setPaintProperty(lineId, 'line-color', p.lineColor)
map.setPaintProperty(lineId, 'line-opacity', p.lineOpacity)
} catch {}
}
}
/**
* Bind a "fuzzy" click handler on a circle layer — Mapbox's default click
* detection only hits the visible pixels of a 3-7px circle, which is hard on
* touch + crowded zooms. We instead listen on the whole map and use
* queryRenderedFeatures with a small bbox around the click point so the
* effective hit-target is `tolerance × 2` pixels wide. No visual change to
* the pin itself (per user 2026-05-02: don't restyle base pins).
*
* Cursor change still uses the direct mouseenter/mouseleave on the layer so
* the cursor only goes pointer when the user is actually over a pin —
* keeping the "fuzzy area" invisible.
*/
export function bindFuzzyPinClick(map, layerId, handler, { tolerance = 8 } = {}) {
if (!map) return
map.on('click', e => {
const t = tolerance
const bbox = [
[e.point.x - t, e.point.y - t],
[e.point.x + t, e.point.y + t],
]
const features = map.queryRenderedFeatures(bbox, { layers: [layerId] })
if (features.length) handler({ features, lngLat: e.lngLat })
})
map.on('mouseenter', layerId, () => map.getCanvas().style.cursor = 'pointer')
map.on('mouseleave', layerId, () => map.getCanvas().style.cursor = '')
}
/**
* Selected-pin indicator: a Mapbox marker with an SVG pin icon hovering over
* the clicked point. Mirrors the pattern DOXA's research-mfe uses ("the go
* indicator that shows which pin you actually selected" — user feedback
* 2026-05-02). Returns a controller object the profile holds onto.
*
* Usage:
* const indicator = makePinIndicator()
* map.on('click', PIN_LAYER, e => {
* const { lng, lat } = e.lngLat
* indicator.show(map, [lng, lat], { color: '#73A17F' })
* })
* // and on close:
* indicator.hide()
*/
export function makePinIndicator() {
let marker = null
let el = null
function ensureEl(color) {
if (!el) {
el = document.createElement('div')
el.className = 'pplr-pin-indicator'
el.style.cssText = 'pointer-events:none;width:30px;height:38px;transform:translateY(-12px);'
}
// Inline SVG so the icon ships with the bundle (no CDN). Color is the
// selected row's color, falling back to brand green.
el.innerHTML = `<svg viewBox="0 0 24 32" width="30" height="38" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.4 0 0 5.4 0 12c0 8 12 20 12 20s12-12 12-20C24 5.4 18.6 0 12 0z"
fill="${color || '#73A17F'}" stroke="#ffffff" stroke-width="1.6"
style="filter:drop-shadow(0 2px 4px rgba(0,0,0,0.45))"/>
<circle cx="12" cy="12" r="4.2" fill="#ffffff"/>
</svg>`
}
return {
show(map, lngLat, { color } = {}) {
if (!map || !lngLat) return
ensureEl(color)
if (!marker) {
marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
}
marker.setLngLat(lngLat).addTo(map)
},
hide() { marker?.remove() },
destroy() { marker?.remove(); marker = null; el = null },
}
}
/**
* Add PG points as individual pins — no clustering.
* Every pin is always visible regardless of zoom level.
*/
export async function addPGPoints(map, {
path = 'pg-points.json',
sourceId = 'pplr-pgs',
circleColor = '#fbbf24'
} = {}) {
const fc = await loadOnce(path)
if (!map.getSource(sourceId)) {
map.addSource(sourceId, { type: 'geojson', data: fc, cluster: false })
}
if (!map.getLayer(`${sourceId}-pin`)) {
map.addLayer({
id: `${sourceId}-pin`,
type: 'circle',
source: sourceId,
paint: {
'circle-color': typeof circleColor === 'string' ? circleColor : '#fbbf24',
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2, 4, 3.5, 8, 5.5, 14, 9],
'circle-stroke-color': '#0d1117',
'circle-stroke-width': 0.8,
'circle-opacity': 0.88
}
})
}
map.on('mouseenter', `${sourceId}-pin`, () => map.getCanvas().style.cursor = 'pointer')
map.on('mouseleave', `${sourceId}-pin`, () => map.getCanvas().style.cursor = '')
return { fc, sourceId, pinLayerId: `${sourceId}-pin` }
}
/** Standard popup HTML for a PG feature (PG-in-country grain). */
export function popupForPG(p, extraRows = '') {
const dash = v => (v == null || v === 'None' || v === '' ? '<span class="m">—</span>' : v)
return `<div class="pplr-popup">
<h3>${p.name || '(no name)'}</h3>
<table>
<tr><td>Country</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
<tr><td>ROP3</td><td><code>${dash(p.rop3)}</code></td></tr>
<tr><td>ROP25 (Ethne)</td><td><code>${dash(p.rop25)}</code></td></tr>
<tr><td>JoshuaProject</td><td><code>${dash(p.jp)}</code></td></tr>
<tr><td>IMB People</td><td><code>${dash(p.imb)}</code></td></tr>
<tr><td>WCD</td><td><code>${dash(p.wcd)}</code></td></tr>
<tr><td>Religion</td><td>${dash(p.religion)}</td></tr>
<tr><td>Language</td><td>${dash(p.language)}</td></tr>
${extraRows}
</table>
</div>`
}
/** Shared popup CSS — inject once per profile via &lt;style&gt; tag. */
export const POPUP_CSS = `
.pplr-popup { font-family: system-ui, sans-serif; color: #0d1117; min-width: 240px; max-width: 320px; }
.pplr-popup h3 { font-size: 13px; font-weight: 600; margin: 0 0 8px; color: #1f2937; }
.pplr-popup table { width: 100%; border-collapse: collapse; font-size: 11px; }
.pplr-popup td { padding: 3px 6px; vertical-align: top; }
.pplr-popup td:first-child { color: #6b7280; font-weight: 500; width: 38%; }
.pplr-popup code { font: 11px ui-monospace, monospace; color: #1d4ed8; background: #eff6ff; padding: 1px 4px; border-radius: 3px; }
.pplr-popup .m { color: #9ca3af; font-style: italic; }
.mapboxgl-popup-content { padding: 12px 14px !important; border-radius: 6px !important; box-shadow: 0 4px 16px rgba(0,0,0,0.5) !important; }
/* ── Mapbox built-in controls (NavigationControl / FullscreenControl) — themed ── */
.mapboxgl-ctrl-group {
background: rgba(13,17,23,0.92) !important;
border: 1px solid #30363d !important;
border-radius: 8px !important;
box-shadow: 0 4px 14px rgba(0,0,0,0.35) !important;
overflow: hidden !important;
}
.mapboxgl-ctrl-group button {
background: transparent !important;
width: 32px !important;
height: 32px !important;
border: none !important;
border-bottom: 1px solid rgba(48,54,61,0.6) !important;
}
.mapboxgl-ctrl-group button:last-child { border-bottom: none !important; }
.mapboxgl-ctrl-group button:hover {
background: rgba(115,161,127,0.18) !important;
}
.mapboxgl-ctrl-group button .mapboxgl-ctrl-icon {
/* Pure white — was invert(0.85) which read too gray against the dark chip. */
filter: invert(1) !important;
}
.mapboxgl-ctrl-attrib { background: rgba(13,17,23,0.7) !important; }
.mapboxgl-ctrl-attrib a, .mapboxgl-ctrl-attrib { color: #adbac7 !important; }
/* Light-mode overrides — when host profile sets [data-theme="light"], Mapbox
controls invert. We can't scope by theme since controls live outside our
component tree, so we use a class on the map container set by the profile. */
.profile[data-theme="light"] .mapboxgl-ctrl-group {
background: #ffffff !important;
border-color: #d0d7de !important;
box-shadow: 0 4px 14px rgba(31,35,40,0.15) !important;
}
.profile[data-theme="light"] .mapboxgl-ctrl-group button {
border-bottom-color: rgba(208,215,222,0.6) !important;
}
.profile[data-theme="light"] .mapboxgl-ctrl-group button:hover {
background: rgba(59,70,61,0.08) !important;
}
.profile[data-theme="light"] .mapboxgl-ctrl-group button .mapboxgl-ctrl-icon {
filter: none !important;
}
`
/**
* Custom Mapbox IControl that toggles fullscreen on a caller-supplied
* container. Replaces mapboxgl.FullscreenControl, which has a known issue
* where the icon does not flip back to "exit" when a non-map container is
* fullscreened — the second click then re-requests fullscreen instead of
* exiting.
*
* Usage:
* map.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
*
* Behaviour: identical SVG to Mapbox's built-in (uses the same CSS classes
* `mapboxgl-ctrl-fullscreen` / `mapboxgl-ctrl-shrink`), but the toggle
* decision is driven purely by document.fullscreenElement so it stays in
* sync regardless of how the user enters/exits fullscreen (button, ESC, F11).
*/
export class FullscreenToggleControl {
constructor(container) { this._container = container }
// Truthy-based check — works inside shadow DOM where strict equality with
// document.fullscreenElement fails (the browser returns the shadow HOST,
// not our inner element). Since the app only fullscreens one element, any
// active fullscreen is unambiguously ours.
_isFs() {
if (document.fullscreenElement) return true
const root = this._container?.getRootNode?.()
return !!(root && root !== document && root.fullscreenElement)
}
onAdd(map) {
this._map = map
this._wrap = document.createElement('div')
this._wrap.className = 'mapboxgl-ctrl mapboxgl-ctrl-group'
this._btn = document.createElement('button')
this._btn.type = 'button'
this._btn.className = 'mapboxgl-ctrl-fullscreen'
this._btn.setAttribute('aria-label', 'Toggle fullscreen')
this._btn.title = 'Toggle fullscreen'
this._btn.innerHTML = '<span class="mapboxgl-ctrl-icon" aria-hidden="true"></span>'
this._btn.addEventListener('click', () => this._toggle())
this._sync = () => {
this._btn.className = this._isFs() ? 'mapboxgl-ctrl-shrink' : 'mapboxgl-ctrl-fullscreen'
}
// Listen on both document and shadow root (whichever applies)
document.addEventListener('fullscreenchange', this._sync)
const root = this._container?.getRootNode?.()
if (root && root !== document) root.addEventListener('fullscreenchange', this._sync)
this._rootForCleanup = root
this._wrap.appendChild(this._btn)
return this._wrap
}
_toggle() {
if (this._isFs()) {
document.exitFullscreen?.()
} else {
this._container?.requestFullscreen?.()
}
}
onRemove() {
document.removeEventListener('fullscreenchange', this._sync)
if (this._rootForCleanup && this._rootForCleanup !== document) {
this._rootForCleanup.removeEventListener('fullscreenchange', this._sync)
}
this._wrap.parentNode?.removeChild(this._wrap)
}
}

View file

@ -0,0 +1,182 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
/**
* pplr-coverage.vue — Country choropleth: avg # of locales filled per PG in that country.
*
* Uses pg-points.geojson grouped by ISO3 to compute per-country PG count + avg
* translation-completeness (using DisplayName_<loc> nullity check from pg-names.jsonl).
*
* For perf, computes coverage from the JSONL streamed first ~6000 records (covers
* most countries, may underestimate sparser ones — flagged in the popup).
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, addPGPoints, loadOnce, LOCALES, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import DataSourcesDropdown from '../components/DataSourcesDropdown.vue'
const DATA_SOURCES = [
{ label: 'PG pins (PG-in-country)', url: 'pg-points.json',
fields: ['code', 'iso3', 'country', 'n_en…n_de (17 locale name fields)'] },
{ label: 'Country polygons (live)', url: 'PPLR FeatureServer · aCountry',
fields: ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp'] },
]
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const status = ref('Loading…')
const dataReady = ref(false)
function reload() { window.location.reload() }
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
center: [25, 18],
zoom: 1.6
})
// Compute per-country: pg_count + avg locales filled
const countryStats = new Map() // iso3 → { pgCount, localeSum, sampleN }
// Precomputed by scripts/build-data.mjs over ALL aROP3ByCountry rows of pg-names.jsonl (the
// original streamed a 6,000-row sample of that 56 MB file in the browser).
async function streamCoverage() {
const r = await fetch(dataUrl('coverage.json'))
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const all = await r.json()
for (const [iso, s] of Object.entries(all)) countryStats.set(iso, { pgCount: s.pgCount, localeSum: s.localeSum, sampleN: s.pgCount })
}
onMounted(async () => {
await initializeMap()
map.value.on('load', async () => {
try {
status.value = 'Loading per-country coverage…'
await streamCoverage()
status.value = 'Loading country polygons…'
const { fc, sourceId } = await addCountryPolygons(map.value, { fillOpacity: 0, blocking: true })
// Add a choropleth fill layer above the base
fc.features.forEach(f => {
const iso = f.properties.GENCalpha3
const s = iso ? countryStats.get(iso) : null
f.properties.avgFilled = s ? s.localeSum / s.sampleN : null
f.properties.pgCount = s ? s.pgCount : 0
})
// Replace the source data so feature properties carry coverage
map.value.getSource(sourceId).setData(fc)
map.value.addLayer({
id: 'choropleth',
type: 'fill',
source: sourceId,
paint: {
'fill-color': [
'case', ['==', ['get', 'avgFilled'], null], 'rgba(31,41,55,0.4)',
['interpolate', ['linear'], ['get', 'avgFilled'],
0, '#7f1d1d', // dark red — 0 locales
4, '#dc2626', // red — 4
8, '#f59e0b', // amber — 8
12, '#84cc16', // lime — 12
17, '#22c55e' // green — 17/17
]
],
'fill-opacity': 0.7
}
}, `${sourceId}-line`) // place under the line layer
// Country popup
map.value.on('click', 'choropleth', e => {
const p = e.features[0].properties
const html = `<div class="pplr-popup">
<h3>${p.CtryNm || p.ctryNmDisp || '—'}<small>${p.GENCalpha3 || p.ISOalpha2 || '?'}</small></h3>
<table>
<tr><td>PG count</td><td><b>${p.pgCount || 0}</b></td></tr>
<tr><td>Avg locales filled</td><td>
<b>${p.avgFilled != null ? p.avgFilled.toFixed(1) : '—'}</b> / 17</td></tr>
</table>
</div>`
new mapboxgl.Popup({ maxWidth: '300px' })
.setLngLat(e.lngLat).setHTML(html).addTo(map.value)
})
status.value = 'Adding PG pins on top…'
const { pinLayerId } = await addPGPoints(map.value, { circleColor: '#fef3c7' })
map.value.on('click', pinLayerId, e => {
const f = e.features[0]
new mapboxgl.Popup({ maxWidth: '320px' })
.setLngLat(f.geometry.coordinates).setHTML(popupForPG(f.properties)).addTo(map.value)
})
status.value = `Ready · ${countryStats.size} countries colored by avg translation locales / 17 (sample of 6,000 PGs)`
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`
}
})
})
onBeforeUnmount(() => destroy())
const SCALE = [
{ stop: '0', color: '#7f1d1d', label: '0 locales' },
{ stop: '4', color: '#dc2626', label: '' },
{ stop: '8', color: '#f59e0b', label: '8' },
{ stop: '12', color: '#84cc16', label: '' },
{ stop: '17', color: '#22c55e', label: '17/17' },
]
const COV_POPUP_CSS = POPUP_CSS + `
.pplr-popup h3 small { font-weight: 400; color: #6b7280; margin-left: 6px; font-size: 11px; font-family: ui-monospace, monospace; }
`
</script>
<template>
<div class="profile">
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Translation coverage · choropleth = avg locales filled per country · click PG pin or country</div>
<div class="legend">
<div class="title">Avg locales / 17</div>
<div class="bar">
<div v-for="s in SCALE" :key="s.stop" class="seg" :style="{ background: s.color }">
<span v-if="s.label">{{ s.label }}</span>
</div>
</div>
</div>
<div class="map-toolbar">
<DataSourcesDropdown :sources="DATA_SOURCES" />
</div>
<component is="style">{{ COV_POPUP_CSS }}</component>
</div>
</template>
<style scoped>
.profile { width: 100%; height: 100%; position: relative; }
.map { width: 100%; height: 100%; }
.loading { position: absolute; inset: 0; display:flex; align-items:center; justify-content:center;
background: #0f1216; color: #94a3b8; font: 13px sans-serif; z-index: 1; }
.status {
position: absolute; bottom: 12px; left: 12px;
background: rgba(13,17,23,0.92); color: #86efac; border: 1px solid #30363d;
font: 11px ui-monospace, monospace; padding: 6px 12px; border-radius: 4px; z-index: 2;
}
.header-pill {
position: absolute; top: 12px; left: 12px;
background: rgba(13,17,23,0.92); color: #c9d1d9; border: 1px solid #30363d;
font: 12px system-ui; padding: 8px 14px; border-radius: 999px; z-index: 2;
}
/* Legend shifted down to clear the new top-right toolbar (data sources) */
.legend {
position: absolute; top: 56px; right: 12px; z-index: 2;
background: rgba(13,17,23,0.94); color: #c9d1d9; border: 1px solid #30363d;
border-radius: 6px; padding: 8px 12px; min-width: 200px;
}
.map-toolbar { position: absolute; top: 12px; right: 12px; z-index: 5; }
.legend .title { font: 600 10px system-ui; color: #86efac; margin-bottom: 6px;
text-transform: uppercase; letter-spacing: 0.04em; }
.legend .bar { display: flex; height: 22px; border-radius: 4px; overflow: hidden;
border: 1px solid #30363d; }
.legend .seg { flex: 1; display: flex; align-items: center; justify-content: center;
font: 10px ui-monospace, monospace; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
</style>

View file

@ -0,0 +1,158 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
/**
* pplr-d3-tree.vue — ROP People-Affinity-Cluster sunburst.
*
* Per user 2026-05-02: capped at depth-2 (Affinity Bloc + Cluster) instead
* of including the 13k+ People Group leaves at depth 3. SVG sunbursts top
* out around 1k arcs before scroll-zoom gets unresponsive; depth 2 is ~309
* arcs, which keeps wheel-zoom snappy. The PG-level data is still surfaced
* elsewhere (ROP Tree map + Semantic Trees explorer).
*
* Uses the shared SemanticSunburst component (same render path as future
* sunbursts; the prior bespoke implementation in this file was rewritten on
* 2026-05-02 to use SemanticSunburst's wheel-zoom + drag-pan + perf-tuned
* label visibility).
*/
import { onMounted, ref } from 'vue'
import SemanticSunburst from '../components/SemanticSunburst.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
const tree = ref(null)
const status = ref('Loading ROP hierarchy…')
const showSettings = ref(false)
function reload() { window.location.reload() }
const BLOC_COLORS = {
A001: '#f59e0b', A017: '#94a3b8', A002: '#ef4444', A003: '#8b5cf6',
A019: '#06b6d4', A004: '#f97316', A006: '#3b82f6', A007: '#10b981',
A008: '#14b8a6', A009: '#6366f1', A010: '#22c55e', A005: '#ec4899',
A012: '#a78bfa', A011: '#84cc16', A013: '#fb923c', A018: '#e11d48',
A015: '#0ea5e9',
}
const DATA_SOURCES = [
{ label: 'ROP hierarchy', url: 'rop-tree.json',
fields: ['code', 'name', 'clusters[].code', 'clusters[].name', 'clusters[].groups[].entries[]'] },
]
onMounted(async () => {
try {
const resp = await fetch(dataUrl('rop-tree.json'))
const flat = await resp.json()
// d3.hierarchy shape — leaves carry the value (PG count) so cluster arcs
// get sized correctly even though the leaves themselves aren't drawn at
// maxDepth=2.
tree.value = {
name: 'PPLR', code: 'root',
children: flat.map(bloc => ({
name: bloc.name, code: bloc.code,
children: bloc.clusters.map(cl => ({
name: cl.name, code: cl.code,
children: cl.groups.map(g => ({ name: g.name, code: g.code, value: g.entries.length })),
})),
})),
}
const total = flat.reduce((a, b) => a + b.clusters.reduce((c, d) => c + d.groups.reduce((e, f) => e + f.entries.length, 0), 0), 0)
status.value = `${total.toLocaleString()} entries · scroll to zoom · drag to pan`
} catch (e) {
status.value = `Error: ${e.message}`
}
})
</script>
<template>
<div class="profile">
<LoadingGlobe v-if="!tree" label="Loading ROP hierarchy" @retry="reload" />
<template v-else>
<SemanticSunburst
:tree="tree"
:color-map="BLOC_COLORS"
center-label="PPLR"
:depth-labels="['','Affinity Bloc','People Cluster','PG (not rendered)']"
:max-depth="2"
/>
<div class="header-pill">ROP Hierarchy · Affinity Bloc → People Cluster · scroll to zoom</div>
<div class="status">{{ status }}</div>
<div class="map-toolbar">
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>
<div class="settings-fields">
<span v-for="f in src.fields" :key="f" class="settings-field">{{ f }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.profile {
width: 100%; height: 100%; position: relative;
background: #0d1117; color: #c9d1d9;
font-family: system-ui, -apple-system, sans-serif;
}
.header-pill {
position: absolute; top: 12px; left: 12px;
background: rgba(13,17,23,0.92); color: #c9d1d9; border: 1px solid #30363d;
font: 11px system-ui; padding: 6px 12px; border-radius: 999px; z-index: 2;
}
.status {
position: absolute; bottom: 12px; left: 12px;
background: rgba(13,17,23,0.92); color: #86efac; border: 1px solid #30363d;
font: 11px ui-monospace, monospace; padding: 6px 12px; border-radius: 4px; z-index: 2;
}
.map-toolbar {
position: absolute; top: 12px; right: 12px; z-index: 5;
display: flex; flex-direction: column; gap: 4px;
}
.toolbar-btn.round {
width: 36px; height: 36px; padding: 0;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: #161b22; color: #8b949e;
border: 1px solid #30363d; cursor: pointer;
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
}
.toolbar-btn.round:hover { background: #1c2333; color: #c9d1d9; border-color: #73A17F; }
.settings-wrap { position: relative; }
.settings-panel {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); color: #c9d1d9;
border: 1px solid #30363d; border-radius: 8px;
font: 12px system-ui; padding: 12px 16px;
min-width: 280px; max-width: 380px; z-index: 10;
}
.settings-header {
font: 600 11px ui-monospace, monospace; color: #c9d1d9;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 0 4px 8px; margin-bottom: 6px;
border-bottom: 1px solid #21262d;
}
.settings-src { margin-bottom: 8px; }
.settings-src-label {
font: 600 9px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 2px;
}
.settings-src-url {
font: 10px ui-monospace, monospace; color: #6e7681; word-break: break-all;
background: #161b22; border: 1px solid #21262d; border-radius: 3px;
padding: 3px 5px; margin-bottom: 3px;
}
.settings-fields { display: flex; flex-wrap: wrap; gap: 2px; }
.settings-field {
font: 10px ui-monospace, monospace; background: #21262d;
color: #c9d1d9; border-radius: 3px; padding: 1px 4px;
}
</style>

View file

@ -0,0 +1,98 @@
<script setup>
/**
* pplr-index.vue — Profile: PPLR Validation Index
*
* Tile menu listing every PPLR validation profile in this MFE.
* Each tile dispatches a `pplr-select-profile` CustomEvent on click —
* the host page (index.html) listens for it and switches the topbar tab.
*/
const PROFILES = [
{ id: 'pplr-religion', title: 'Religion distribution map',
desc: 'All 21k PG-in-country pins colored by primary religion group (9 groups: Christianity, Islam, Hinduism, Ethnoreligion, Buddhism, Judaism, Sikhism, Unaffiliated, Other). Country polygons underneath. Click any pin for detail.' },
{ id: 'pplr-rop-tree', title: 'ROP hierarchy tree explorer',
desc: 'Split map + sidebar. Left panel: collapsible tree of 17 Affinity Blocs → 292 Clusters → 13,065 People Groups → PG-in-Country (21k entries). Click any node to filter map pins and fly the viewport. Pins colored by Affinity Bloc.' },
{ id: 'pplr-language-families', title: 'Language families map',
desc: '21k PG-in-country pins colored by PPLR ethnolinguistic family (71 families, ~100% coverage via GeoID join to aROP3byCountry). Top 25 families (85% of pins) get distinct colors; remainder shown grey.' },
{ id: 'pplr-semantic-trees', title: 'Semantic tree reference',
desc: 'Left-to-right reference tables for the three PPLR classification trees (Lang, ROP, Admin). Shows real API field names per level per system (PPLR / Joshua Project / IMB / WCD).' },
{ id: 'pplr-d3-tree', title: 'ROP sunburst (D3)',
desc: 'D3 zoomable sunburst of the full ROP hierarchy. Inner ring: 17 Affinity Blocs; middle ring: 292 Clusters; outer ring: 13k People Groups. Click any arc to zoom in; click center to zoom out. Colors match the tree explorer.' },
{ id: 'pplr-pins', title: 'All PG-in-country pins',
desc: 'World Mapbox map with country polygons + every PG-in-country pin. Click any pin for full Rosetta popup. Validates lat/long completeness and global PG distribution.' },
{ id: 'pplr-translations', title: 'Translations on the map (17 locales)',
desc: 'Same map; click any pin to see the PG name in English + 17 IMB locales side-by-side. Surfaces machine-translation quality issues (e.g. Asas → "basics" in 17 languages).' },
{ id: 'pplr-rosetta-cards', title: 'Cross-system IDs colored on the map',
desc: 'Pin color encodes which cross-system IDs exist (gold = JP+IMB+WCD, blue = JP+IMB, etc.). The 5 Rosetta sample PGs show as gold ★. Click any pin for the full Rosetta table.' },
{ id: 'pplr-coverage', title: 'Country choropleth — translation coverage',
desc: 'Country polygons shaded by avg locales filled per PG (red 0/17 → green 17/17). PG pins overlaid on top. Click a country for stats; click a pin for the PG record.' },
]
function selectProfile(id) {
// Dispatch a composed event so it crosses the shadow-DOM boundary into the host
window.dispatchEvent(new CustomEvent('pplr-select-profile', {
detail: { profile: id },
bubbles: true, composed: true
}))
}
</script>
<template>
<div class="pplr-index">
<header>
<h1>PPLR data validation</h1>
<p>Many small displays — each answers one validation question about the PPLR people-group data.
Source: <a href="https://services2.arcgis.com/S4ydGgujXcif36k3" target="_blank">PPLR ArcGIS FeatureServer</a>
· 6 parquets · 48,174 PG records · 17 locales.</p>
</header>
<section class="grid">
<button v-for="p in PROFILES" :key="p.id" @click="selectProfile(p.id)" class="tile">
<h2>{{ p.title }}</h2>
<p>{{ p.desc }}</p>
<code>{{ p.id }}</code>
</button>
</section>
<footer>
<p>Click a tile, or use the topbar tabs above to switch displays.
Click <b>Overview</b> top-right to see every slot at once.</p>
</footer>
</div>
</template>
<style scoped>
.pplr-index {
width: 100%; height: 100%; overflow: auto;
background: #0d1117; color: #c9d1d9;
font-family: system-ui, -apple-system, sans-serif;
padding: 32px 40px;
box-sizing: border-box;
}
header h1 { font-size: 22px; font-weight: 500; color: #58a6ff; margin: 0 0 8px; }
header p { font-size: 13px; color: #8b949e; line-height: 1.6; max-width: 720px; }
header a { color: #79c0ff; }
.grid {
margin-top: 28px;
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.tile {
display: block; text-align: left; cursor: pointer;
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 16px 18px; color: inherit; font: inherit;
transition: border-color 0.15s, background 0.15s;
}
.tile:hover { border-color: #58a6ff; background: #1c2128; }
.tile h2 { font-size: 14px; font-weight: 600; color: #79c0ff; margin: 0 0 6px; }
.tile p { font-size: 12px; color: #8b949e; line-height: 1.55; margin: 0 0 10px; }
.tile code { font-family: ui-monospace, monospace; font-size: 11px; color: #6e7681; }
footer {
margin-top: 28px; padding-top: 14px;
border-top: 1px solid #21262d;
font-size: 11px; color: #6e7681;
}
footer b { color: #c9d1d9; }
</style>

View file

@ -0,0 +1,734 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
/**
* pplr-language-families.vue — PG pins colored by PPLR ethnolinguistic family.
*
* Language hierarchy (parse rule):
* colon = dialect separator; comma = variant separator (NOT dialect).
* "Punjabi, Western" → root "Punjabi", variant "Punjabi, Western"
* "Arabic, Egyptian: Cairene" → root "Arabic", variant "Arabic, Egyptian", dialect full string
*
* The full tree (family → language → variant → dialect) drives SemanticTreeLegend.
* Each depth level = one tab. Filter expressions are pre-built into each tree node.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import { createPplrInstance, provideInstance } from '../composables/usePplrInstance.js'
const pplrInstance = createPplrInstance('language-families')
provideInstance(pplrInstance)
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens .profile so chrome stays visible
const status = ref('Loading…')
const PIN_LAYER = 'lf-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — no globe→mercator reproject flash
})
// ── Language families ─────────────────────────────────────────────────────────
const FAMILIES = [
{ key: 'North Indian', color: '#f97316' },
{ key: 'Bantu', color: '#8b5cf6' },
{ key: 'Tibeto-Burmese', color: '#3b82f6' },
{ key: 'Indo-Malay', color: '#06b6d4' },
{ key: 'Papuan', color: '#84cc16' },
{ key: 'Dravidian', color: '#f59e0b' },
{ key: 'Southern Amerindian', color: '#a78bfa' },
{ key: 'Bantoid', color: '#10b981' },
{ key: 'Germanic', color: '#94a3b8' },
{ key: 'Iranian', color: '#ec4899' },
{ key: 'Arab (Arabic)', color: '#fbbf24' },
{ key: 'Melanesian', color: '#0ea5e9' },
{ key: 'Sudanic', color: '#6366f1' },
{ key: 'Latin (Romance)', color: '#ef4444' },
{ key: 'Chinese', color: '#f43f5e' },
{ key: 'Slav', color: '#67e8f9' },
{ key: 'Central Amerindian', color: '#22c55e' },
{ key: 'Altaic', color: '#fb923c' },
{ key: 'Guinean (Kwa)', color: '#c084fc' },
{ key: 'Hausa-Chadic', color: '#14b8a6' },
{ key: 'Mon-Khmer', color: '#a3e635' },
{ key: 'Northern Amerindian', color: '#fde047' },
{ key: 'Jewish', color: '#60a5fa' },
{ key: 'Tai', color: '#4ade80' },
{ key: 'Nilotic (Para-Nilotic)', color: '#f87171' },
{ key: 'Other', color: '#374151' },
]
const familyColorMap = new Map(FAMILIES.map(f => [f.key, f.color]))
function familyColor(fam) { return familyColorMap.get(fam) || familyColorMap.get('Other') }
// LoadingGlobe stays mounted until pins are added to the map. Flips true after
// setupLayers + family aggregation completes. Per user 2026-05-02: don't drop
// the loader before pins populate.
const dataReady = ref(false)
function reload() { window.location.reload() }
// ── Settings (data sources) ──────────────────────────────────────────────────
const showSettings = ref(false)
const DATA_SOURCES = [
{ label: 'PG pins (PG-in-country)', url: 'pg-points.json',
fields: ['code', 'name', 'rop1', 'rop2', 'rop3', 'rop25', 'iso3', 'country', 'language', 'fam (joined)'] },
{ label: 'Language family lookup (joined)', url: 'pplr-geoid-family.json',
fields: ['<rop25-code>: <Family Name>'] },
{ label: 'Country polygons (live)', url: 'PPLR FeatureServer · aCountry',
fields: ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp'] },
]
function buildColorExpr() {
return ['match', ['get', 'fam'],
...FAMILIES.slice(0, 25).flatMap(f => [f.key, f.color]),
'#374151'
]
}
// ── Projection + basemap ──────────────────────────────────────────────────────
const projection = ref('mercator') // default mercator per user spec
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
]
const pgData = ref(null)
// ── Data state ────────────────────────────────────────────────────────────────
const familyCounts = ref({}) // famKey → PGIC count
const familyPops = ref({}) // famKey → total population
const hasPop = ref(false)
const famLangGroups = ref({}) // famKey → { count, pop, langs: LangGroup[] }
const LANG_TABS = [
{ id: 'family', label: 'Lang Family',
info: 'PPLR\'s ethnolinguistic family classification (per assets/data/pplr-geoid-family.json). Note: PPLR groups Arabic under "Arab (Arabic)" rather than the broader linguistic family "Afro-Asiatic", and splits other Afro-Asiatic peoples into "Hausa-Chadic", "Cushitic", "Sudanic", etc.' },
{ id: 'language', label: 'Language',
info: 'A distinct language (e.g. Arabic, Hindi, Punjabi). Identified by 3-character ROL code (ISO 639-3).' },
{ id: 'variant', label: 'Variant / Dialect',
info: 'A regional or social variety of a language (e.g. "Punjabi, Western", "Arabic, Egyptian"). Comma-separated names group as variants under a root language.' },
]
const LANG_KIND_LABELS = { family: 'Lang Family', language: 'Language', variant: 'Variant', dialect: 'Dialect' }
// ── Sort helper ───────────────────────────────────────────────────────────────
function byPopThenCount(a, b) {
const pd = (b.pop || 0) - (a.pop || 0)
return pd !== 0 ? pd : (b.count || 0) - (a.count || 0)
}
// ── Language parse: colon = dialect, comma = variant (NOT dialect) ────────────
function parseLang(lang) {
if (lang.includes(':')) {
const colonIdx = lang.indexOf(':')
const before = lang.substring(0, colonIdx).trim()
if (before.includes(',')) {
const commaIdx = before.indexOf(',')
return { root: before.substring(0, commaIdx).trim(), variant: before, dialectFull: lang }
}
return { root: before, variant: null, dialectFull: lang }
}
if (lang.includes(',')) {
const commaIdx = lang.indexOf(',')
return { root: lang.substring(0, commaIdx).trim(), variant: lang, dialectFull: null }
}
return { root: lang, variant: null, dialectFull: null }
}
function accumulateLang(lmap, lang, pop) {
if (!lang) return
const { root, variant, dialectFull } = parseLang(lang)
if (!lmap[root]) lmap[root] = { base: root, count: 0, pop: 0, variants: {}, dialects: [] }
lmap[root].count++
lmap[root].pop += pop
if (variant) {
if (!lmap[root].variants[variant]) lmap[root].variants[variant] = { name: variant, count: 0, pop: 0, dialects: [] }
lmap[root].variants[variant].count++
lmap[root].variants[variant].pop += pop
if (dialectFull) {
const vd = lmap[root].variants[variant].dialects
let d = vd.find(x => x.name === dialectFull)
if (!d) { d = { name: dialectFull, count: 0, pop: 0 }; vd.push(d) }
d.count++; d.pop += pop
}
} else if (dialectFull) {
let d = lmap[root].dialects.find(x => x.name === dialectFull)
if (!d) { d = { name: dialectFull, count: 0, pop: 0 }; lmap[root].dialects.push(d) }
d.count++; d.pop += pop
}
}
function finalizeLmap(lmap) {
return Object.values(lmap)
.map(l => ({
...l,
variants: Object.values(l.variants)
.map(v => ({ ...v, dialects: [...v.dialects].sort(byPopThenCount) }))
.sort(byPopThenCount),
dialects: [...l.dialects].sort(byPopThenCount),
}))
.sort(byPopThenCount)
}
// ── Tree builders — convert lang data into SemanticTreeLegend node format ─────
function langFilter(langs) {
return langs.length === 1
? ['==', ['get', 'language'], langs[0]]
: ['in', ['get', 'language'], ['literal', langs]]
}
function buildDialectLeaf(d, famKey) {
return {
id: `d:${famKey}:${d.name}`,
label: d.name,
color: familyColor(famKey),
count: d.count,
pop: d.pop,
filter: ['==', ['get', 'language'], d.name],
children: [],
}
}
function buildVariantNode(vt, famKey) {
const langs = [vt.name, ...vt.dialects.map(d => d.name)]
return {
id: `v:${famKey}:${vt.name}`,
label: vt.name,
color: familyColor(famKey),
count: vt.count,
pop: vt.pop,
filter: langFilter(langs),
children: vt.dialects.map(d => buildDialectLeaf(d, famKey)),
}
}
function buildLangNode(lg, famKey) {
const allLangs = [
lg.base,
...lg.variants.map(v => v.name),
...lg.variants.flatMap(v => v.dialects.map(d => d.name)),
...lg.dialects.map(d => d.name),
]
return {
id: `l:${famKey}:${lg.base}`,
label: lg.base,
color: familyColor(famKey),
count: lg.count,
pop: lg.pop,
filter: langFilter(allLangs),
children: [
...lg.variants.map(vt => buildVariantNode(vt, famKey)),
...lg.dialects.map(d => buildDialectLeaf(d, famKey)),
],
}
}
// ── langTree: the single family-rooted tree that drives all 4 legend tabs ─────
const sortedFamilies = computed(() =>
[...FAMILIES].sort((a, b) => {
const pd = (familyPops.value[b.key] || 0) - (familyPops.value[a.key] || 0)
return pd !== 0 ? pd : (familyCounts.value[b.key] || 0) - (familyCounts.value[a.key] || 0)
})
)
const KNOWN_FAMS = FAMILIES.slice(0, 25).map(f => f.key)
const langTree = computed(() => {
if (Object.keys(famLangGroups.value).length === 0) return []
return sortedFamilies.value
.filter(f => (familyCounts.value[f.key] || 0) > 0)
.map(f => ({
id: `fam:${f.key}`,
label: f.key,
color: f.color,
count: familyCounts.value[f.key] || 0,
pop: familyPops.value[f.key] || 0,
filter: f.key === 'Other'
? ['!', ['in', ['get', 'fam'], ['literal', KNOWN_FAMS]]]
: ['==', ['get', 'fam'], f.key],
children: (famLangGroups.value[f.key]?.langs || []).map(lg => buildLangNode(lg, f.key)),
}))
})
// ── Pin-click indicator ─────────────────────────────────────────────────────
const pinIndicator = makePinIndicator()
watch(() => pplrInstance.pinDetail.value, p => { if (!p) pinIndicator.hide() })
// ── Geocoder → map sync ────────────────────────────────────────────────────
// Geocoder writes instance.selection but doesn't emit an event the map
// listens for. Watch the store so geocoder picks actually filter the map.
watch(() => pplrInstance.selection.value, sel => {
if (!map.value) return
onLegendSelect(sel || null)
})
// Selection only filters which pins are visible. Pin appearance stays
// identical to the base-map style — no halos, no size change, no pulse.
function onLegendSelect(node) {
if (!node) {
map.value?.setFilter(PIN_LAYER, null)
} else if (node.filter) {
map.value?.setFilter(PIN_LAYER, node.filter)
flyToFilter(node.filter)
}
}
// Compute bounds of pins matching a filter expression and fly there.
// Handles the two filter shapes the legend builds: ['==', ['get', f], v]
// and ['in', ['get', f], ['literal', [v...]]]. Anything else is ignored.
function flyToFilter(filter) {
if (!map.value || !pgData.value || !Array.isArray(filter)) return
let field, values
if (filter[0] === '==' && Array.isArray(filter[1]) && filter[1][0] === 'get') {
field = filter[1][1]; values = [filter[2]]
} else if (filter[0] === 'in' && Array.isArray(filter[1]) && filter[1][0] === 'get'
&& Array.isArray(filter[2]) && filter[2][0] === 'literal') {
field = filter[1][1]; values = filter[2][1]
} else return
const set = new Set(values)
const lngs = [], lats = []
for (const f of pgData.value.features) {
if (set.has(f.properties[field])) {
lngs.push(f.geometry.coordinates[0])
lats.push(f.geometry.coordinates[1])
}
}
if (!lngs.length) return
map.value.stop()
if (lngs.length === 1) {
map.value.flyTo({ center: [lngs[0], lats[0]], zoom: 6.5, duration: 1200 })
} else {
map.value.fitBounds(
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
{ padding: 60, maxZoom: 6, duration: 1200 }
)
}
}
// ── Local search index for the geocoder: flatten the tree with kind tags ─────
const langLocalIndex = computed(() => {
const out = []
function walk(nodes, depth, kind) {
for (const n of nodes) {
out.push({ ...n, kind, depth })
if (n.children?.length) {
const childKind = depth === 0 ? 'language' : (depth === 1 ? 'variant' : 'dialect')
walk(n.children, depth + 1, childKind)
}
}
}
walk(langTree.value, 0, 'family')
return out
})
function onGeocoderPlace(p) {
if (!map.value) return
map.value.stop() // cancel in-flight animation so back-to-back picks don't stack
if (p.bbox) {
map.value.fitBounds([[p.bbox[0], p.bbox[1]], [p.bbox[2], p.bbox[3]]], { padding: 60, maxZoom: 7, duration: 1200 })
} else if (p.center) {
map.value.flyTo({ center: p.center, zoom: 5, duration: 1200 })
}
}
function onPinLocate(p) {
if (!map.value || p?.lng == null || p?.lat == null) return
map.value.stop()
map.value.flyTo({ center: [p.lng, p.lat], zoom: 6, duration: 1200 })
}
const detailColor = computed(() => {
const p = pplrInstance.pinDetail.value
return p ? familyColor(p.fam || 'Other') : '#73A17F'
})
// ── Projection / basemap ──────────────────────────────────────────────────────
function toggleProjection() {
projection.value = projection.value === 'globe' ? 'mercator' : 'globe'
if (map.value) map.value.setProjection(projection.value)
}
function setBasemap(key) {
if (key === currentBasemap.value) { showBasemap.value = false; return }
currentBasemap.value = key
pplrInstance.theme.value = key === 'light' ? 'light' : 'dark'
showBasemap.value = false
const style = BASEMAPS.find(b => b.key === key)?.url
if (!style || !map.value) return
map.value.once('style.load', () => { setupLayers(); map.value.setProjection(projection.value) })
// diff:false suppresses Mapbox's "Unimplemented: setSprite" warning between
// dark-v11 and light-v11. We re-add layers in setupLayers anyway.
map.value.setStyle(style, { diff: false })
}
function toggleTheme() {
setBasemap(currentBasemap.value === 'dark' ? 'light' : 'dark')
}
// ── Map layers ────────────────────────────────────────────────────────────────
function setupLayers() {
if (!pgData.value || !map.value) return
addCountryPolygons(map.value, { theme: pplrInstance.theme.value })
if (!map.value.getSource('lf-pgs')) {
map.value.addSource('lf-pgs', { type: 'geojson', data: pgData.value, cluster: false })
map.value.addLayer({
id: PIN_LAYER, type: 'circle', source: 'lf-pgs',
paint: {
'circle-color': buildColorExpr(),
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2, 6, 4, 12, 7],
'circle-opacity': 0.85,
'circle-stroke-color': '#0d1117',
'circle-stroke-width': 0.5
}
})
bindFuzzyPinClick(map.value, PIN_LAYER, ({ features }) => {
const f = features[0]
const lng = f.geometry.coordinates[0]
const lat = f.geometry.coordinates[1]
pplrInstance.pinDetail.value = { ...f.properties, lng, lat }
pinIndicator.show(map.value, [lng, lat], { color: familyColor(f.properties.fam || 'Other') })
})
}
}
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
const color = familyColor(p.fam || 'Other')
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>Family</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>
<tr><td>Affinity Bloc</td><td>${d(p.rop1)}</td></tr>
<tr><td>Religion</td><td>${d(p.religion)}</td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
</table>
</div>`
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
await initializeMap()
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {
try {
// Shift logical center right of the 380px legend so the globe / pin
// density isn't hidden behind the column.
map.value.setPadding({ left: 380, top: 0, right: 0, bottom: 0 })
addCountryPolygons(map.value, { theme: pplrInstance.theme.value })
status.value = 'Loading language data…'
const [pgResp, lfResp] = await Promise.all([
fetch(dataUrl('pg-points.json')),
fetch(dataUrl('pplr-geoid-family.json'))
])
const fc = await pgResp.json()
const geoIdFamily = await lfResp.json()
let matched = 0, inTop25 = 0, totalPopSum = 0
const COLORED = new Set(KNOWN_FAMS)
for (const feat of fc.features) {
const code = String(feat.properties.code)
const fam = geoIdFamily[code] || null
feat.properties.fam = fam
if (fam) { matched++; if (COLORED.has(fam)) inTop25++ }
totalPopSum += feat.properties.pop || feat.properties.population || 0
}
hasPop.value = totalPopSum > 0
pgData.value = fc
setupLayers()
// Family counts + pops
const fcounts = {}, fpops = {}
for (const feat of fc.features) {
const f = feat.properties.fam || 'Other'
const p = feat.properties.pop || feat.properties.population || 0
fcounts[f] = (fcounts[f] || 0) + 1
fpops[f] = (fpops[f] || 0) + p
}
familyCounts.value = fcounts
familyPops.value = fpops
// Family → language groups (builds the tree)
const fLangMap = {}
for (const feat of fc.features) {
const fam = feat.properties.fam || 'Other'
const lang = feat.properties.language || ''
const pop = feat.properties.pop || feat.properties.population || 0
if (!fLangMap[fam]) fLangMap[fam] = { count: 0, pop: 0, lmap: {} }
fLangMap[fam].count++
fLangMap[fam].pop += pop
if (lang) accumulateLang(fLangMap[fam].lmap, lang, pop)
}
const result = {}
for (const fam of Object.keys(fLangMap)) {
result[fam] = { count: fLangMap[fam].count, pop: fLangMap[fam].pop, langs: finalizeLmap(fLangMap[fam].lmap) }
}
famLangGroups.value = result
const coverage = Math.round(100 * matched / fc.features.length)
status.value = `Ready · ${fc.features.length.toLocaleString()} pins · ${coverage}% family coverage · top-25 = ${Math.round(100 * inTop25 / fc.features.length)}%`
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`
}
})
})
onBeforeUnmount(() => { pinIndicator.destroy(); destroy() })
const LF_CSS = POPUP_CSS + `
.pplr-popup h3 small { display: block; font-weight: 400; color: #6b7280; font-size: 11px; margin-top: 2px; }
`
</script>
<template>
<div ref="profileRoot" class="profile" :data-theme="pplrInstance.theme.value">
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<!-- Decoupled geocoder above the legend, sharing the per-map instance store -->
<div class="geocoder-slot">
<MapboxGeocoder
:local-index="langLocalIndex"
:kind-labels="LANG_KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
@place="onGeocoderPlace"
/>
</div>
<!-- Pin-detail card swaps in for the legend when a pin is clicked -->
<PinDetailCard
v-if="pplrInstance.pinDetail.value"
:color="detailColor"
@locate="onPinLocate"
/>
<!-- Reusable semantic tree legend — decoupled from map, connected via @select -->
<SemanticTreeLegend
v-else
:nodes="langTree"
:tabs="LANG_TABS"
title="Language Families"
@select="onLegendSelect"
/>
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Ethnolinguistic families (PPLR) · click pin for detail</div>
<!-- Toolbar — rounded pill controls -->
<div class="map-toolbar">
<button class="toolbar-btn round" :title="`Theme: ${currentBasemap === 'dark' ? 'Dark' : 'Light'} (click to toggle)`"
@click="toggleTheme">
<svg v-if="currentBasemap === 'dark'" width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
<button class="toolbar-btn round" :title="projection === 'globe' ? 'Switch to Mercator' : 'Switch to Globe'"
@click="toggleProjection">
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map" @click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt"
:class="{ active: currentBasemap === b.key }"
@click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>
<div class="settings-fields">
<span v-for="f in src.fields" :key="f" class="settings-field">{{ f }}</span>
</div>
</div>
</div>
</div>
</div>
<component is="style">{{ LF_CSS }}</component>
</div>
</template>
<style scoped>
.profile { width: 100%; height: 100%; position: relative; }
.map { width: 100%; height: 100%; }
.loading {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: #0f1216; color: #94a3b8; font: 13px sans-serif; z-index: 1;
}
/* Pills sized to fit under the 380px legend column without sticking out
to its right, and lifted above the Mapbox attribution at the bottom. */
.status {
position: absolute; bottom: 32px; left: 12px;
background: rgba(13,17,23,0.92); color: #86efac; border: 1px solid #30363d;
font: 11px ui-monospace, monospace; padding: 6px 12px; border-radius: 4px; z-index: 2;
max-width: 340px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.header-pill {
position: absolute; bottom: 64px; left: 12px;
background: rgba(13,17,23,0.92); color: #c9d1d9; border: 1px solid #30363d;
font: 11px system-ui; padding: 6px 12px; border-radius: 999px; z-index: 2;
max-width: 340px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── Geocoder slot — sits above the legend, matches its 380px width ─────────── */
.geocoder-slot {
position: absolute; top: 12px; left: 8px; width: 380px; z-index: 5;
}
@media (max-width: 640px) {
.geocoder-slot { width: 92vw; max-width: 380px; left: 8px; }
}
/* ── Light-mode chrome overrides ─────────────────────────────────────────── */
.profile[data-theme="light"] .toolbar-btn {
background: rgba(255,255,255,0.96); border-color: #d0d7de; color: #57606a;
box-shadow: 0 3px 10px rgba(31,35,40,0.12);
}
.profile[data-theme="light"] .toolbar-btn:hover {
background: #f6f8fa; color: #3b463d; border-color: #3b463d;
}
.profile[data-theme="light"] .toolbar-btn.round { background: #ffffff; }
.profile[data-theme="light"] .toolbar-btn.round:hover {
background: #f6f8fa; box-shadow: 0 5px 14px rgba(59,70,61,0.2);
}
.profile[data-theme="light"] .basemap-panel {
background: rgba(255,255,255,0.98); border-color: #d0d7de;
box-shadow: 0 6px 20px rgba(31,35,40,0.15);
}
.profile[data-theme="light"] .basemap-title { color: #3b463d; border-color: #d8dee4; }
.profile[data-theme="light"] .basemap-opt { color: #57606a; }
.profile[data-theme="light"] .basemap-opt:hover { background: #f6f8fa; color: #1f2328; }
.profile[data-theme="light"] .basemap-opt.active { border-color: #3b463d; color: #3b463d; }
.profile[data-theme="light"] .status {
background: rgba(255,255,255,0.95); border-color: #d0d7de; color: #1a7f37;
}
.profile[data-theme="light"] .header-pill {
background: rgba(255,255,255,0.95); border-color: #d0d7de; color: #1f2328;
}
.profile[data-theme="light"] .loading { background: #f6f8fa; color: #57606a; }
.map-toolbar {
position: absolute; top: 12px; right: 12px; z-index: 5;
display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
}
.toolbar-btn {
display: flex; align-items: center; gap: 5px;
background: rgba(13,17,23,0.92); color: #8b949e;
border: 1px solid #30363d; border-radius: 6px;
font: 11px ui-monospace, monospace; padding: 5px 10px;
cursor: pointer; white-space: nowrap;
transition: color 0.12s, border-color 0.12s, background 0.12s, transform 0.12s,
box-shadow 0.12s;
}
.toolbar-btn:hover { color: #c9d1d9; border-color: #73A17F; }
.toolbar-btn.round {
width: 36px; height: 36px; padding: 0;
border-radius: 50%; justify-content: center;
background: #161b22;
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
}
.toolbar-btn.round:hover {
background: #1c2333; transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(59,70,61,0.25);
}
.toolbar-icon { font-size: 13px; line-height: 1; }
.toolbar-label { font-size: 10px; letter-spacing: 0.04em; }
.basemap-wrap { position: relative; }
.basemap-panel {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); border: 1px solid #30363d; border-radius: 6px;
padding: 8px; min-width: 130px; z-index: 10;
display: flex; flex-direction: column; gap: 3px;
}
.basemap-title {
font: 600 10px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em;
padding: 0 4px 4px; border-bottom: 1px solid #21262d; margin-bottom: 2px;
}
.basemap-opt {
background: transparent; border: 1px solid transparent; border-radius: 4px;
color: #8b949e; font: 11px system-ui; padding: 4px 8px; cursor: pointer; text-align: left;
}
.basemap-opt:hover { background: #161b22; color: #c9d1d9; }
.basemap-opt.active { border-color: #73A17F; color: #73A17F; }
/* ── Settings (data sources) — toolbar-attached panel ────────────────────── */
.settings-wrap { position: relative; }
.settings-panel {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); color: #c9d1d9;
border: 1px solid #30363d; border-radius: 8px;
font: 12px system-ui; padding: 12px 16px;
min-width: 320px; max-width: 420px; max-height: 50vh; overflow-y: auto;
z-index: 10;
}
.settings-header {
font: 600 11px ui-monospace, monospace; color: #c9d1d9;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 0 4px 8px; margin-bottom: 6px;
border-bottom: 1px solid #21262d;
}
.settings-src { margin-bottom: 8px; }
.settings-src:last-child { margin-bottom: 0; }
.settings-src-label {
font: 600 9px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 2px;
}
.settings-src-url {
font: 10px ui-monospace, monospace; color: #6e7681; word-break: break-all;
background: #161b22; border: 1px solid #21262d; border-radius: 3px;
padding: 3px 5px; margin-bottom: 3px;
}
.settings-fields { display: flex; flex-wrap: wrap; gap: 2px; }
.settings-field {
font: 10px ui-monospace, monospace; background: #21262d;
color: #c9d1d9; border-radius: 3px; padding: 1px 4px;
}
.profile[data-theme="light"] .settings-panel {
background: rgba(255,255,255,0.98); border-color: #d0d7de;
box-shadow: 0 6px 20px rgba(31,35,40,0.15); color: #1f2328;
}
.profile[data-theme="light"] .settings-header { color: #1f2328; border-bottom-color: #d8dee4; }
.profile[data-theme="light"] .settings-src-label { color: #3b463d; }
.profile[data-theme="light"] .settings-src-url { background: #f6f8fa; border-color: #d0d7de; color: #57606a; }
.profile[data-theme="light"] .settings-field { background: #eaeef2; color: #1f2328; }
</style>

View file

@ -0,0 +1,151 @@
<script setup>
/**
* pplr-pins.vue — All 26,349 PG-in-country pins on Mapbox + country polygons.
* Validates lat/long completeness and global PG distribution.
* Individual pins at all zoom levels; click pin for full Rosetta popup; hover country for highlight.
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, addPGPoints, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const status = ref('Loading…')
const dataReady = ref(false)
function reload() { window.location.reload() }
const showSettings = ref(false)
const DATA_SOURCES = [
{ label: 'PG pins (PG-in-country)', url: 'pg-points.json',
fields: ['code', 'name', 'country', 'iso3', 'rop1–3', 'rop25', 'imb', 'jp', 'wcd', 'religion', 'language', 'pop'] },
{ label: 'Country polygons (live)', url: 'PPLR FeatureServer · aCountry',
fields: ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp'] },
]
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
center: [25, 18],
zoom: 1.6
})
onMounted(async () => {
await initializeMap()
map.value.on('load', async () => {
try {
status.value = 'Adding country polygons…'
await addCountryPolygons(map.value)
status.value = 'Adding PG pins…'
const { fc, pinLayerId } = await addPGPoints(map.value)
map.value.on('click', pinLayerId, e => {
const f = e.features[0]
new mapboxgl.Popup({ maxWidth: '340px' })
.setLngLat(f.geometry.coordinates)
.setHTML(popupForPG(f.properties))
.addTo(map.value)
})
status.value = `Ready · ${fc.features.length.toLocaleString('en-US')} PG-in-country pins · click any pin`
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`
}
})
})
onBeforeUnmount(() => destroy())
</script>
<template>
<div class="profile">
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">All PG-in-country pins · click for cross-system IDs</div>
<div class="map-toolbar">
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>
<div class="settings-fields">
<span v-for="f in src.fields" :key="f" class="settings-field">{{ f }}</span>
</div>
</div>
</div>
</div>
</div>
<component is="style">{{ POPUP_CSS }}</component>
</div>
</template>
<style scoped>
.profile { width: 100%; height: 100%; position: relative; }
.map { width: 100%; height: 100%; }
.loading { position: absolute; inset: 0; display:flex; align-items:center; justify-content:center;
background: #0f1216; color: #94a3b8; font: 13px sans-serif; z-index: 1; }
.status {
position: absolute; bottom: 12px; left: 12px;
background: rgba(13,17,23,0.92); color: #79c0ff; border: 1px solid #30363d;
font: 11px ui-monospace, monospace;
padding: 6px 12px; border-radius: 4px; z-index: 2;
}
.header-pill {
position: absolute; top: 12px; left: 12px;
background: rgba(13,17,23,0.92); color: #c9d1d9; border: 1px solid #30363d;
font: 12px system-ui; padding: 8px 14px; border-radius: 999px; z-index: 2;
}
/* ── Toolbar (data sources only) ────────────────────────────────────────── */
.map-toolbar {
position: absolute; top: 12px; right: 12px; z-index: 5;
display: flex; flex-direction: column; gap: 4px;
}
.toolbar-btn.round {
width: 36px; height: 36px; padding: 0;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: #161b22; color: #8b949e;
border: 1px solid #30363d; cursor: pointer;
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
}
.toolbar-btn.round:hover { background: #1c2333; color: #c9d1d9; border-color: #73A17F; }
.settings-wrap { position: relative; }
.settings-panel {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); color: #c9d1d9;
border: 1px solid #30363d; border-radius: 8px;
font: 12px system-ui; padding: 12px 16px;
min-width: 320px; max-width: 420px; z-index: 10;
}
.settings-header {
font: 600 11px ui-monospace, monospace; color: #c9d1d9;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 0 4px 8px; margin-bottom: 6px;
border-bottom: 1px solid #21262d;
}
.settings-src { margin-bottom: 8px; }
.settings-src-label {
font: 600 9px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 2px;
}
.settings-src-url {
font: 10px ui-monospace, monospace; color: #6e7681; word-break: break-all;
background: #161b22; border: 1px solid #21262d; border-radius: 3px;
padding: 3px 5px; margin-bottom: 3px;
}
.settings-fields { display: flex; flex-wrap: wrap; gap: 2px; }
.settings-field {
font: 10px ui-monospace, monospace; background: #21262d;
color: #c9d1d9; border-radius: 3px; padding: 1px 4px;
}
</style>

View file

@ -0,0 +1,510 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
/**
* pplr-religion.vue — PG pins colored by primary religion.
*
* 9 religion groups (christian, islam, hindu, ethnic, buddhist, jewish, sikh, none, other).
* Country polygons underneath. Click a pin for full popup. Legend top-right.
* Uses the `rg` field pre-computed in pg-points.geojson.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
import FlatLegend from '../components/FlatLegend.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import { createPplrInstance, provideInstance } from '../composables/usePplrInstance.js'
const pplrInstance = createPplrInstance('religion')
provideInstance(pplrInstance)
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens .profile so chrome stays visible
const status = ref('Loading…')
const PIN_LAYER = 'rel-pins'
const showSettings = ref(false)
const projection = ref('mercator')
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
]
const pgData = ref(null)
const dataReady = ref(false)
function reload() { window.location.reload() }
function toggleProjection() {
projection.value = projection.value === 'globe' ? 'mercator' : 'globe'
if (map.value) map.value.setProjection(projection.value)
}
function toggleTheme() {
setBasemap(currentBasemap.value === 'dark' ? 'light' : 'dark')
}
function setBasemap(key) {
if (key === currentBasemap.value) { showBasemap.value = false; return }
currentBasemap.value = key
pplrInstance.theme.value = key === 'light' ? 'light' : 'dark'
showBasemap.value = false
const style = BASEMAPS.find(b => b.key === key)?.url
if (!style || !map.value) return
map.value.once('style.load', () => { setupLayers(); map.value.setProjection(projection.value) })
// diff:false suppresses Mapbox's "Unimplemented: setSprite" warning between
// dark-v11 and light-v11. We re-add layers in setupLayers anyway.
map.value.setStyle(style, { diff: false })
}
function setupLayers() {
if (!pgData.value || !map.value) return
// Religion profile: polygons hidden on BOTH themes (per user 2026-05-02 "I
// kind of just want you on the religion map to remove the blue borders on
// dark mode... remove the polygons"). Only pins + basemap show.
addCountryPolygons(map.value, {
fillColor: 'rgba(0,0,0,0)', fillOpacity: 0,
lineColor: 'rgba(0,0,0,0)', lineOpacity: 0,
})
if (!map.value.getSource('rel-pgs')) {
map.value.addSource('rel-pgs', { type: 'geojson', data: pgData.value, cluster: false })
map.value.addLayer({
id: PIN_LAYER, type: 'circle', source: 'rel-pgs',
paint: {
'circle-color': COLOR_EXPR,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2, 6, 4, 12, 7],
'circle-opacity': 0.85,
'circle-stroke-color': '#0d1117',
'circle-stroke-width': 0.5
}
})
bindFuzzyPinClick(map.value, PIN_LAYER, ({ features }) => {
const f = features[0]
const lng = f.geometry.coordinates[0]
const lat = f.geometry.coordinates[1]
pplrInstance.pinDetail.value = { ...f.properties, lng, lat }
const groupColor = RELIGION_GROUPS.find(g => g.key === f.properties.rg)?.color || '#73A17F'
pinIndicator.show(map.value, [lng, lat], { color: groupColor })
})
}
}
const DATA_SOURCES = [
{
label: 'PG-in-country pins',
url: 'pg-points.json',
fields: ['name', 'country', 'rg', 'religion', 'language', 'imb', 'jp']
},
{
label: 'Country polygons — PPLR FeatureServer aCountry',
url: COUNTRY_FS_URL,
fields: COUNTRY_FS_FIELDS
}
]
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — no globe→mercator reproject flash
})
// Color palette per user spec 2026-05-02:
// - Christianity is the ONLY blue group (navy-ish)
// - Ethnoreligion → pink (was purple)
// - Judaism → purple (was cyan-blue; cannot share Christianity's hue)
// - Buddhism stays gold (visually distinct from Islam's amber + Judaism's purple)
const RELIGION_GROUPS = [
{ key: 'christian', label: 'Christianity', color: '#0ea5e9' }, // sky blue (only blue group)
{ key: 'islam', label: 'Islam', color: '#f59e0b' }, // amber
{ key: 'hindu', label: 'Hinduism', color: '#f97316' }, // orange
{ key: 'ethnic', label: 'Ethnoreligion', color: '#ec4899' }, // pink (was purple)
{ key: 'buddhist', label: 'Buddhism', color: '#fcd34d' }, // gold
{ key: 'jewish', label: 'Judaism', color: '#a855f7' }, // purple (was cyan-blue)
{ key: 'sikh', label: 'Sikhism', color: '#22c55e' }, // green
{ key: 'none', label: 'Unaffiliated', color: '#94a3b8' }, // light gray
{ key: 'other', label: 'Other / Unknown', color: '#4b5563' }, // dark gray
]
const COLOR_EXPR = [
'match', ['get', 'rg'],
...RELIGION_GROUPS.flatMap(g => [g.key, g.color]),
'#4b5563'
]
// Per-group pin counts → fed to FlatLegend so users can see distribution at a glance
const religionCounts = ref({})
const religionLegendItems = computed(() => RELIGION_GROUPS.map(g => ({
key: g.key,
label: g.label,
color: g.color,
count: religionCounts.value[g.key] || 0,
filter: ['==', ['get', 'rg'], g.key],
})))
function onLegendSelect(node) {
if (!map.value) return
if (!node) {
map.value.setFilter(PIN_LAYER, null)
} else if (node.filter) {
map.value.setFilter(PIN_LAYER, node.filter)
}
}
// ── Local search index — religions + countries (for the geocoder) ────────────
const religionLocalIndex = computed(() => {
const out = RELIGION_GROUPS.map((g, i) => ({
id: `rel:${g.key}`,
label: g.label,
color: g.color,
kind: 'religion',
depth: 0,
filter: ['==', ['get', 'rg'], g.key],
}))
if (pgData.value) {
const seen = new Set()
for (const f of pgData.value.features) {
const c = f.properties.country
if (c && !seen.has(c)) {
seen.add(c)
out.push({
id: `country:${f.properties.iso3 || c}`,
label: c,
kind: 'country',
depth: 0,
filter: ['==', ['get', 'iso3'], f.properties.iso3],
})
}
}
}
return out
})
const REL_KIND_LABELS = { religion: 'Religion', country: 'Country' }
// ── Pin-click indicator ─────────────────────────────────────────────────────
const pinIndicator = makePinIndicator()
watch(() => pplrInstance.pinDetail.value, p => { if (!p) pinIndicator.hide() })
// Geocoder selection only filters pin visibility — pin appearance stays the
// same as the base map (no halo, no pulse, no size change).
watch(() => pplrInstance.selection.value, sel => {
if (!map.value) return
if (!sel) {
map.value.setFilter(PIN_LAYER, null)
} else if (sel.filter) {
map.value.setFilter(PIN_LAYER, sel.filter)
}
})
function onGeocoderPlace(p) {
if (!map.value) return
map.value.stop() // cancel in-flight animation so back-to-back picks don't stack
if (p.bbox) {
map.value.fitBounds([[p.bbox[0], p.bbox[1]], [p.bbox[2], p.bbox[3]]], { padding: 60, maxZoom: 7, duration: 1200 })
} else if (p.center) {
map.value.flyTo({ center: p.center, zoom: 5, duration: 1200 })
}
}
function onPinLocate(p) {
if (!map.value || p?.lng == null || p?.lat == null) return
map.value.stop()
map.value.flyTo({ center: [p.lng, p.lat], zoom: 6, duration: 1200 })
}
const detailColor = computed(() => {
const p = pplrInstance.pinDetail.value
if (!p) return '#73A17F'
return RELIGION_GROUPS.find(g => g.key === p.rg)?.color || '#4b5563'
})
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
const grp = RELIGION_GROUPS.find(g => g.key === p.rg)
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Religion</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
</table>
</div>`
}
onMounted(async () => {
await initializeMap()
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {
try {
// Legend now sits top-left under the search bar (~320px wide). Shift the
// logical center right of that column so the world / globe isn't drawn
// under the legend column.
map.value.setPadding({ left: 320, top: 0, right: 0, bottom: 0 })
status.value = 'Loading country polygons…'
addCountryPolygons(map.value, { theme: pplrInstance.theme.value })
status.value = 'Loading PG pins…'
const resp = await fetch(dataUrl('pg-points.json'))
pgData.value = await resp.json()
setupLayers()
const counts = {}
pgData.value.features.forEach(f => { const g = f.properties.rg; counts[g] = (counts[g] || 0) + 1 })
religionCounts.value = counts
const dominant = RELIGION_GROUPS.reduce((a, b) => (counts[a.key] || 0) > (counts[b.key] || 0) ? a : b)
// Per user 2026-05-02: drop the "Ready" prefix — it was unclear and chewed up width.
status.value = `${pgData.value.features.length.toLocaleString()} pins · ${dominant.label} most common (${(counts[dominant.key]||0).toLocaleString()})`
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`
}
})
})
onBeforeUnmount(() => { pinIndicator.destroy(); destroy() })
const REL_CSS = POPUP_CSS + `
.pplr-popup h3 small { display: block; font-weight: 400; color: #6b7280; font-size: 11px; margin-top: 2px; }
`
</script>
<template>
<div ref="profileRoot" class="profile" :data-theme="pplrInstance.theme.value">
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Religion distribution · click pin for detail</div>
<!-- Decoupled geocoder — searches religions + countries + places -->
<div class="geocoder-slot">
<MapboxGeocoder
:local-index="religionLocalIndex"
:kind-labels="REL_KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
@place="onGeocoderPlace"
/>
</div>
<!-- Pin-detail card swaps in for the legend on pin click -->
<PinDetailCard
v-if="pplrInstance.pinDetail.value"
:color="detailColor"
@locate="onPinLocate"
/>
<!-- FlatLegend (non-tree) — sits under the search bar like SemanticTreeLegend does on the other profiles -->
<FlatLegend
v-else
:items="religionLegendItems"
title="Primary Religion"
@select="onLegendSelect"
/>
<!-- Toolbar — rounded pill controls -->
<div class="map-toolbar">
<button class="toolbar-btn round" :title="`Theme: ${currentBasemap === 'dark' ? 'Dark' : 'Light'} (click to toggle)`"
@click="toggleTheme">
<svg v-if="currentBasemap === 'dark'" width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
<button class="toolbar-btn round" :title="projection === 'globe' ? 'Switch to Mercator' : 'Switch to Globe'"
@click="toggleProjection">
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map" @click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt" :class="{ active: currentBasemap === b.key }" @click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<!-- Data sources — moved into the toolbar to match ROP-tree (per user 2026-05-02) -->
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel toolbar-attached">
<div class="settings-header">Data sources</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>
<div class="settings-fields">
<span v-for="f in src.fields" :key="f" class="settings-field">{{ f }}</span>
</div>
</div>
</div>
</div>
</div>
<component is="style">{{ REL_CSS }}</component>
</div>
</template>
<style scoped>
.profile { width: 100%; height: 100%; position: relative; }
.map { width: 100%; height: 100%; }
.loading { position: absolute; inset: 0; display:flex; align-items:center; justify-content:center;
background: #0f1216; color: #94a3b8; font: 13px sans-serif; z-index: 1; }
/* Lifted above Mapbox attribution; capped narrow so they don't dominate. */
.status {
position: absolute; bottom: 32px; left: 12px;
background: rgba(13,17,23,0.92); color: #86efac; border: 1px solid #30363d;
font: 11px ui-monospace, monospace; padding: 6px 12px; border-radius: 4px; z-index: 2;
max-width: 380px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.header-pill {
position: absolute; bottom: 64px; left: 12px;
background: rgba(13,17,23,0.92); color: #c9d1d9; border: 1px solid #30363d;
font: 11px system-ui; padding: 6px 12px; border-radius: 999px; z-index: 2;
max-width: 380px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── Geocoder slot — narrowed to match the FlatLegend below it ────────────── */
.geocoder-slot {
position: absolute; top: 12px; left: 8px; width: 320px; z-index: 5;
}
@media (max-width: 640px) {
.geocoder-slot { width: calc(100vw - 16px); max-width: 320px; }
}
/* ── Light-mode chrome overrides ─────────────────────────────────────────── */
.profile[data-theme="light"] .toolbar-btn {
background: rgba(255,255,255,0.96); border-color: #d0d7de; color: #57606a;
box-shadow: 0 3px 10px rgba(31,35,40,0.12);
}
.profile[data-theme="light"] .toolbar-btn:hover {
background: #f6f8fa; color: #3b463d; border-color: #3b463d;
}
.profile[data-theme="light"] .toolbar-btn.round { background: #ffffff; }
.profile[data-theme="light"] .toolbar-btn.round:hover {
background: #f6f8fa; box-shadow: 0 5px 14px rgba(59,70,61,0.2);
}
.profile[data-theme="light"] .basemap-panel,
.profile[data-theme="light"] .settings-panel {
background: rgba(255,255,255,0.98); border-color: #d0d7de;
box-shadow: 0 6px 20px rgba(31,35,40,0.15);
}
.profile[data-theme="light"] .basemap-title,
.profile[data-theme="light"] .settings-src-label {
color: #3b463d; border-color: #d8dee4;
}
.profile[data-theme="light"] .basemap-opt { color: #57606a; }
.profile[data-theme="light"] .basemap-opt:hover { background: #f6f8fa; color: #1f2328; }
.profile[data-theme="light"] .basemap-opt.active { border-color: #3b463d; color: #3b463d; }
.profile[data-theme="light"] .status {
background: rgba(255,255,255,0.95); border-color: #d0d7de; color: #1a7f37;
}
.profile[data-theme="light"] .header-pill {
background: rgba(255,255,255,0.95); border-color: #d0d7de; color: #1f2328;
}
.profile[data-theme="light"] .loading { background: #f6f8fa; color: #57606a; }
.profile[data-theme="light"] .settings-panel.toolbar-attached {
background: rgba(255,255,255,0.98); border-color: #d0d7de;
box-shadow: 0 6px 20px rgba(31,35,40,0.15); color: #1f2328;
}
.profile[data-theme="light"] .settings-src-url {
background: #f6f8fa; border-color: #d0d7de; color: #57606a;
}
.profile[data-theme="light"] .settings-field { background: #eaeef2; color: #1f2328; }
.map-toolbar {
position: absolute; top: 12px; right: 12px; z-index: 3;
display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
}
.toolbar-btn {
display: flex; align-items: center; gap: 5px;
background: rgba(13,17,23,0.92); color: #8b949e;
border: 1px solid #30363d; border-radius: 6px;
font: 11px ui-monospace, monospace; padding: 5px 10px;
cursor: pointer; white-space: nowrap;
transition: color 0.12s, border-color 0.12s, background 0.12s, transform 0.12s,
box-shadow 0.12s;
}
.toolbar-btn:hover { color: #c9d1d9; border-color: #73A17F; }
.toolbar-btn.round {
width: 36px; height: 36px; padding: 0;
border-radius: 50%; justify-content: center;
background: #161b22;
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
}
.toolbar-btn.round:hover {
background: #1c2333; transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(59,70,61,0.25);
}
.toolbar-icon { font-size: 13px; line-height: 1; }
.toolbar-label { font-size: 10px; letter-spacing: 0.04em; }
.basemap-wrap { position: relative; }
.basemap-panel {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); border: 1px solid #30363d; border-radius: 6px;
padding: 8px; min-width: 130px; z-index: 10;
display: flex; flex-direction: column; gap: 3px;
}
.basemap-title {
font: 600 10px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em;
padding: 0 4px 4px; border-bottom: 1px solid #21262d; margin-bottom: 2px;
}
.basemap-opt {
background: transparent; border: 1px solid transparent; border-radius: 4px;
color: #8b949e; font: 11px system-ui; padding: 4px 8px;
cursor: pointer; text-align: left;
}
.basemap-opt:hover { background: #161b22; color: #c9d1d9; }
.basemap-opt.active { border-color: #73A17F; color: #73A17F; }
/* ── Settings (data sources) — now a toolbar button, panel drops below ── */
.settings-wrap { position: relative; }
.settings-panel.toolbar-attached {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); color: #c9d1d9;
border: 1px solid #30363d; border-radius: 8px;
font: 12px system-ui; padding: 12px 16px;
min-width: 320px; max-width: 420px; max-height: 50vh; overflow-y: auto;
z-index: 10;
}
.settings-header {
font: 600 11px ui-monospace, monospace; color: #c9d1d9;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 0 4px 8px; margin-bottom: 6px;
border-bottom: 1px solid #21262d;
}
.profile[data-theme="light"] .settings-header { color: #1f2328; border-bottom-color: #d8dee4; }
.settings-src { margin-bottom: 10px; }
.settings-src:last-child { margin-bottom: 0; }
.settings-src-label {
font: 600 10px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 3px;
}
.settings-src-url {
font: 10px ui-monospace, monospace; color: #6e7681; word-break: break-all;
background: #161b22; border: 1px solid #21262d; border-radius: 4px;
padding: 4px 6px; margin-bottom: 4px;
}
.settings-fields { display: flex; flex-wrap: wrap; gap: 3px; }
.settings-field {
font: 10px ui-monospace, monospace; background: #21262d;
color: #c9d1d9; border-radius: 3px; padding: 1px 5px;
}
</style>

View file

@ -0,0 +1,700 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
/**
* pplr-rop-tree.vue — ROP hierarchy: Affinity Bloc → Cluster → People Group → PGIC.
*
* SemanticTreeLegend drives the first 3 levels (bloc/cluster/PG).
* PGIC is a separate floating panel (21k items, search-based or selected-PG-based).
* Pins are colored by Affinity Bloc. Each level sorts by pin count desc.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, shallowRef, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, setCountryPolygonTheme, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import { createPplrInstance, provideInstance } from '../composables/usePplrInstance.js'
// ── Per-map instance store (Mediator pattern: ties legend, geocoder, map together) ──
const pplrInstance = createPplrInstance('rop-tree')
provideInstance(pplrInstance)
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens the .profile so chrome stays visible
const status = ref('Loading…')
const PIN_LAYER = 'rop-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — see useMapInstance projection arg
})
// ── Affinity Bloc colors ──────────────────────────────────────────────────────
const BLOC_COLORS = {
A001: '#f59e0b', A017: '#94a3b8', A002: '#ef4444', A003: '#8b5cf6',
A019: '#06b6d4', A004: '#f43f5e', A006: '#3b82f6', A007: '#10b981',
A008: '#14b8a6', A009: '#6366f1', A010: '#22c55e', A005: '#ec4899',
A012: '#a78bfa', A011: '#84cc16', A013: '#facc15', A018: '#e11d48',
A015: '#0ea5e9',
}
function blocColor(code) { return BLOC_COLORS[code] || '#6b7280' }
// ── Settings ──────────────────────────────────────────────────────────────────
const showSettings = ref(false)
const DATA_SOURCES = [
{ label: 'ROP tree', url: 'rop-tree.json',
fields: ['code', 'name', 'clusters[].code', 'clusters[].name', 'clusters[].groups[].code', 'clusters[].groups[].entries[]'] },
{ label: 'PG pins', url: 'pg-points.json',
fields: ['code', 'name', 'rop1', 'rop2', 'rop3', 'rop25', 'iso3', 'country', 'imb', 'jp', 'religion', 'language'] },
{ label: 'Country polygons', url: COUNTRY_FS_URL, fields: COUNTRY_FS_FIELDS },
]
// ── Data ──────────────────────────────────────────────────────────────────────
const treeData = shallowRef([])
const pgData = shallowRef(null)
const mapLoaded = ref(false)
const ropTreeBuilt = shallowRef([]) // Frozen tree, built ONCE in onMounted to avoid 21k recompute on every dep change
// Pin counts per ROP level (from pg-points)
const blocCounts = ref({})
const clusterCounts = ref({})
// Population per ROP level (from pg-points pop field, if present)
const blocPops = ref({})
const clusterPops = ref({})
const pgPops = ref({})
const hasPop = ref(false)
// PGIC lookup: rop25 → { name, country, pop }
const pgicLookup = ref({})
// ── Projection + basemap ──────────────────────────────────────────────────────
const projection = ref('mercator') // default mercator per user spec
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
]
// ── ROP tree (4 generations: Affinity Bloc → People Cluster → ROP25 → ROP3byCountry)
// Tab N = Generation N (1-indexed in user-facing numbering — see QA defining-constrained-problem1).
// SemanticTreeLegend renders the first 3 generations inline; the 4th (PGIC / 21k items)
// is handled by a separate floating panel (too many nodes for the inline tree).
// Short tab labels keep all 4 chips on a single row. The "i" tooltip carries the full meaning.
const ROP_TABS = [
{ id: 'affinity', label: 'Bloc',
info: 'Affinity Bloc — top-level grouping of related people groups (e.g. Arab Peoples). 17 blocs total.' },
{ id: 'cluster', label: 'Cluster',
info: 'People Cluster — subdivision within an Affinity Bloc (e.g. Maghrebi Arab inside Arab Peoples). 292 clusters.' },
{ id: 'rop25', label: 'ROP25',
info: 'People Group level — a distinct ethnolinguistic identity (e.g. Algerian Arab). ~13k groups.' },
{ id: 'rop3xc', label: 'PGIC',
topLimit: 200,
info: 'ROP3 × Country (PGIC) — same ethnolinguistic group in a specific country (e.g. Algerian Arab in France). 21k records. Showing top 200 by population — use the geocoder to find the rest.' },
]
// Build the ropTree ONCE from the now-loaded data (called from onMounted after pg-points
// + pgicLookup are ready). Avoids the previous behavior where the computed re-allocated
// 13k PG nodes × ~50 PGIC children each on every reactive dep change → tab-switch lag.
function buildRopTreeOnce() {
if (!treeData.value.length) { ropTreeBuilt.value = []; return }
const lookup = pgicLookup.value
ropTreeBuilt.value = [...treeData.value]
.sort((a, b) => (blocCounts.value[b.code] || 0) - (blocCounts.value[a.code] || 0))
.map(bloc => ({
id: `bloc:${bloc.code}`,
label: bloc.name || bloc.code,
color: blocColor(bloc.code),
count: blocCounts.value[bloc.code] || 0,
pop: blocPops.value[bloc.code] || 0,
filter: ['==', ['get', 'rop1'], bloc.code],
children: [...(bloc.clusters || [])]
.sort((a, b) => (clusterCounts.value[b.code] || 0) - (clusterCounts.value[a.code] || 0))
.map(cl => ({
id: `cl:${cl.code}`,
label: cl.name || cl.code,
color: blocColor(bloc.code),
count: clusterCounts.value[cl.code] || 0,
pop: clusterPops.value[cl.code] || 0,
filter: ['==', ['get', 'rop2'], cl.code],
children: [...(cl.groups || [])]
.sort((a, b) => b.entries.length - a.entries.length)
.map(g => ({
id: `pg:${g.code}`,
label: g.name || g.code,
color: blocColor(bloc.code),
count: g.entries.length,
pop: pgPops.value[g.code] || 0,
filter: ['==', ['get', 'rop3'], g.code],
pgicIds: g.entries,
// Tab 4: PGIC children — built from pgicLookup (rop25 → name + country)
children: g.entries.map(geoId => {
// Some rop-tree.json entries don't have matching rows in
// pg-points.geojson — show them as "Unmapped · <code>" so the
// user sees the rop25 ID with context, not a bare number
// (per user 2026-05-02: "currently it's returning like these
// numbers which is weird").
const info = lookup[geoId]
const label = info
? (info.country ? `${info.name} · ${info.country}` : info.name)
: `Unmapped · ${geoId}`
return {
id: `pgic:${geoId}`,
label,
color: blocColor(bloc.code),
count: 1,
pop: info?.pop || 0,
filter: ['==', ['get', 'rop25'], geoId],
}
}),
})),
})),
}))
}
// Read-only accessor for template + child components
const ropTree = computed(() => ropTreeBuilt.value)
// ── Local search index for the geocoder (flattened tree + kind tags) ──────────
const KIND_LABELS = {
bloc: 'Affinity Bloc',
cluster: 'People Cluster',
pg: 'ROP25',
pgic: 'ROP3 × Country',
country: 'Country',
}
const localIndex = computed(() => {
const out = []
// Blocs / clusters / PGs
for (const bloc of ropTree.value) {
out.push({ ...bloc, kind: 'bloc', depth: 0 })
for (const cl of bloc.children) {
out.push({ ...cl, kind: 'cluster', depth: 1 })
for (const pg of cl.children) {
// Strip pgic children from the searchable PG entry (keep index light)
out.push({ id: pg.id, label: pg.label, color: pg.color, count: pg.count, pop: pg.pop,
filter: pg.filter, kind: 'pg', depth: 2 })
}
}
}
// Top PGICs by population (cap at 2k for snappy search)
const pgicEntries = Object.entries(pgicLookup.value)
.sort((a, b) => (b[1].pop || 0) - (a[1].pop || 0))
.slice(0, 2000)
for (const [geoId, info] of pgicEntries) {
out.push({
id: `pgic:${geoId}`,
label: info.country ? `${info.name} · ${info.country}` : info.name,
kind: 'pgic',
depth: 3,
filter: ['==', ['get', 'rop25'], geoId],
pop: info.pop || 0,
})
}
// Distinct countries
if (pgData.value) {
const seen = new Set()
for (const f of pgData.value.features) {
const c = f.properties.country, iso = f.properties.iso3
if (c && iso && !seen.has(iso)) {
seen.add(iso)
out.push({
id: `country:${iso}`,
label: c,
kind: 'country',
depth: 0,
filter: ['==', ['get', 'iso3'], iso],
})
}
}
}
return out
})
// ── Legend selection handler ───────────────────────────────────────────────────
// Selecting a row filters which pins are visible (and flies to them). It does
// NOT change pin visual style — the user explicitly asked us to keep base
// pin appearance on selection (no halos, no size change, no pulse).
function onRopLegendSelect(node) {
if (!node) {
map.value?.setFilter(PIN_LAYER, null)
return
}
if (node.filter) {
map.value?.setFilter(PIN_LAYER, node.filter)
}
flyToNode(node)
}
// ── Detail-card "Fly to pin" handler ──────────────────────────────────────────
function onPinLocate(p) {
if (!map.value || p?.lng == null || p?.lat == null) return
map.value.stop()
map.value.flyTo({ center: [p.lng, p.lat], zoom: 6.5, duration: 1200 })
}
// Resolve detail-card accent color from the pinned PG's affinity bloc
const detailColor = computed(() => {
const p = pplrInstance.pinDetail.value
return p ? blocColor(p.rop1) : '#73A17F'
})
// ── Geocoder place-pick: fly to the place geometry ────────────────────────────
function onGeocoderPlace(p) {
if (!map.value) return
map.value.stop()
if (p.bbox) {
map.value.fitBounds([[p.bbox[0], p.bbox[1]], [p.bbox[2], p.bbox[3]]], { padding: 60, maxZoom: 7, duration: 1200 })
} else if (p.center) {
map.value.flyTo({ center: p.center, zoom: 6.5, duration: 1200 })
}
}
// ── Map interaction ───────────────────────────────────────────────────────────
function flyToNode(node) {
if (!pgData.value || !mapLoaded.value || !map.value) return
const id = node.id
let field, value
if (id.startsWith('bloc:')) { field = 'rop1'; value = id.slice(5) }
else if (id.startsWith('cl:')) { field = 'rop2'; value = id.slice(3) }
else if (id.startsWith('pg:')) { field = 'rop3'; value = id.slice(3) }
else if (id.startsWith('pgic:')) { field = 'rop25'; value = id.slice(5) }
else if (id.startsWith('country:')) { field = 'iso3'; value = id.slice(8) }
else return
const lngs = [], lats = []
for (const f of pgData.value.features) {
if (f.properties[field] === value) {
lngs.push(f.geometry.coordinates[0])
lats.push(f.geometry.coordinates[1])
}
}
if (!lngs.length) return
// Cancel any in-progress camera animation BEFORE starting a new one. Without
// this, fast row-switching stacks animations and the camera looks like it's
// mid-zoom when pins for the new selection finally render.
map.value.stop()
if (lngs.length === 1) {
map.value.flyTo({ center: [lngs[0], lats[0]], zoom: 6.5, duration: 1200 })
return
}
map.value.fitBounds([
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)]
], { padding: 60, maxZoom: 6, duration: 1200 })
}
// ── Projection / basemap ──────────────────────────────────────────────────────
function toggleProjection() {
projection.value = projection.value === 'globe' ? 'mercator' : 'globe'
if (map.value) map.value.setProjection(projection.value)
}
function setBasemap(key) {
if (key === currentBasemap.value) { showBasemap.value = false; return }
currentBasemap.value = key
pplrInstance.theme.value = key === 'light' ? 'light' : 'dark' // sync chrome theme
showBasemap.value = false
const style = BASEMAPS.find(b => b.key === key)?.url
if (!style || !map.value) return
map.value.once('style.load', () => { setupLayers(); map.value.setProjection(projection.value) })
// diff:false skips Mapbox's "try-to-diff-the-style" path which logs an
// "Unimplemented: setSprite" warning whenever sprite sheets differ between
// dark-v11 and light-v11. We re-add all our layers in setupLayers anyway,
// so a full rebuild is correct and avoids the noisy warning.
map.value.setStyle(style, { diff: false })
}
// Quick light/dark toggle — flips between dark-v11 and light-v11 directly
function toggleTheme() {
setBasemap(currentBasemap.value === 'dark' ? 'light' : 'dark')
}
// Retry: full page reload — last resort if loading is genuinely stuck.
function reload() { window.location.reload() }
// ── Pin-click indicator (a marker icon hovering over the selected pin) ──────
const pinIndicator = makePinIndicator()
// Hide indicator whenever the pin-detail card closes (X button, Esc, or
// programmatic clear) — the legend is back, no pinned pin to point at.
watch(() => pplrInstance.pinDetail.value, p => { if (!p) pinIndicator.hide() })
// ── Geocoder → map sync ────────────────────────────────────────────────────
// The legend writes to instance.selection on row click AND fires @select; the
// geocoder ONLY writes to instance.selection (no event). Without this watch,
// geocoder picks set the store but never reach setFilter / flyTo. Watching
// the store closes that loop. setFilter is idempotent so the legend's @select
// path overlapping is harmless.
watch(() => pplrInstance.selection.value, sel => {
if (!map.value) return
if (sel?.filter) onRopLegendSelect(sel)
else if (!sel) onRopLegendSelect(null)
})
// ── Map layers ────────────────────────────────────────────────────────────────
function setupLayers() {
if (!pgData.value || !map.value) return
addCountryPolygons(map.value, { theme: pplrInstance.theme.value })
if (!map.value.getSource('rop-pgs')) {
map.value.addSource('rop-pgs', { type: 'geojson', data: pgData.value, cluster: false })
const matchExpr = ['match', ['get', 'rop1'],
...Object.entries(BLOC_COLORS).flatMap(([k, v]) => [k, v]),
'#6b7280'
]
map.value.addLayer({
id: PIN_LAYER, type: 'circle', source: 'rop-pgs',
paint: {
'circle-color': matchExpr,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2, 6, 4, 12, 7],
'circle-opacity': 0.85,
'circle-stroke-color': '#0d1117',
'circle-stroke-width': 0.5,
}
})
// Fuzzy click — 8px hit-radius around the pin so they're easier to tap
bindFuzzyPinClick(map.value, PIN_LAYER, ({ features }) => {
const f = features[0]
const lng = f.geometry.coordinates[0]
const lat = f.geometry.coordinates[1]
pplrInstance.pinDetail.value = { ...f.properties, lng, lat }
pinIndicator.show(map.value, [lng, lat], { color: blocColor(f.properties.rop1) })
})
}
}
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Affinity Bloc</td><td><b>${d(p.rop1)}</b></td></tr>
<tr><td>Cluster</td><td><b>${d(p.rop2)}</b></td></tr>
<tr><td>People Group</td><td><b>${d(p.rop3)}</b></td></tr>
<tr><td>PEID</td><td><code>${d(p.peid)}</code></td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
<tr><td>Religion</td><td>${d(p.religion)}</td></tr>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
</table>
</div>`
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
await initializeMap()
// Navigation: zoom +/- (Mapbox built-in) + custom fullscreen toggle.
// See FullscreenToggleControl in pplrMap.js for why we don't use
// mapboxgl.FullscreenControl with a non-map container.
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
// Load tree structure immediately (small file)
const treeResp = await fetch(dataUrl('rop-tree.json'))
treeData.value = await treeResp.json()
map.value.on('load', async () => {
try {
// Globe centering: shift the map's logical center right of the legend
// so the globe doesn't render beneath the 380px legend column.
// Padding is also respected by flyTo / fitBounds for selection animations.
map.value.setPadding({ left: 380, top: 0, right: 0, bottom: 0 })
addCountryPolygons(map.value, { theme: pplrInstance.theme.value })
status.value = 'Loading PG pins…'
const resp = await fetch(dataUrl('pg-points.json'))
const fc = await resp.json()
pgData.value = fc
// Aggregate counts + population per ROP level
const bc = {}, cc = {}, bp = {}, cp = {}, pp = {}
let totalPop = 0
for (const feat of fc.features) {
const r1 = feat.properties.rop1, r2 = feat.properties.rop2, r3 = feat.properties.rop3
const p = feat.properties.pop || feat.properties.population || 0
if (r1) { bc[r1] = (bc[r1] || 0) + 1; bp[r1] = (bp[r1] || 0) + p }
if (r2) { cc[r2] = (cc[r2] || 0) + 1; cp[r2] = (cp[r2] || 0) + p }
if (r3) { pp[r3] = (pp[r3] || 0) + p }
totalPop += p
}
blocCounts.value = bc
clusterCounts.value = cc
blocPops.value = bp
clusterPops.value = cp
pgPops.value = pp
hasPop.value = totalPop > 0
// PGIC lookup (rop25 → name, country, pop)
const lkp = {}
for (const feat of fc.features) {
const id = feat.properties.rop25
if (id && !lkp[id]) {
lkp[id] = {
name: feat.properties.name,
country: feat.properties.country,
pop: feat.properties.pop || feat.properties.population || 0,
}
}
}
pgicLookup.value = lkp
// Build the tree ONCE here, not via reactive computed (avoids 21k recompute lag)
buildRopTreeOnce()
setupLayers()
const peidCount = fc.features.filter(f => f.properties.peid != null).length
mapLoaded.value = true
status.value = `Ready · ${fc.features.length.toLocaleString()} pins · ${peidCount.toLocaleString()} with PEID`
} catch (err) {
status.value = `Error: ${err.message}`
}
})
})
onBeforeUnmount(() => { pinIndicator.destroy(); destroy() })
const TREE_POPUP_CSS = POPUP_CSS + `
.pplr-popup h3 small { display: block; color: #6b7280; font-weight: 400; font-size: 11px; margin-top: 2px; }
`
</script>
<template>
<div ref="profileRoot" class="profile" :data-theme="pplrInstance.theme.value">
<!-- Stays mounted until BOTH the basemap canvas AND the PG pin layer are
ready (per user 2026-05-02: don't disappear before pins populate). -->
<LoadingGlobe v-if="!isMapReady || !mapLoaded" :label="status" @retry="reload" />
<!-- Decoupled geocoder, top-left, sits above the legend; shares instance store -->
<div class="geocoder-slot">
<MapboxGeocoder
:local-index="localIndex"
:kind-labels="KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
@place="onGeocoderPlace"
/>
</div>
<!-- Pin-detail card replaces legend when a pin is clicked -->
<PinDetailCard
v-if="pplrInstance.pinDetail.value"
:color="detailColor"
@locate="onPinLocate"
/>
<!-- Reusable legend: Affinity Bloc → People Cluster → ROP25 → ROP3 × Country -->
<SemanticTreeLegend
v-else
:nodes="ropTree"
:tabs="ROP_TABS"
title="ROP Hierarchy"
@select="onRopLegendSelect"
/>
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">ROP Hierarchy · Affinity Bloc → Cluster → People Group → PGIC</div>
<!-- Toolbar — rounded pill controls -->
<div class="map-toolbar">
<button class="toolbar-btn round" :title="`Theme: ${currentBasemap === 'dark' ? 'Dark' : 'Light'} (click to toggle)`"
@click="toggleTheme">
<svg v-if="currentBasemap === 'dark'" width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
<button class="toolbar-btn round" :title="projection === 'globe' ? 'Switch to Mercator' : 'Switch to Globe'"
@click="toggleProjection">
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map"
@click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt"
:class="{ active: currentBasemap === b.key }"
@click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>
<div class="settings-fields">
<span v-for="f in src.fields" :key="f" class="settings-field">{{ f }}</span>
</div>
</div>
</div>
</div>
</div>
<component is="style">{{ TREE_POPUP_CSS }}</component>
</div>
</template>
<style scoped>
.profile { width: 100%; height: 100%; position: relative; }
.map { width: 100%; height: 100%; }
.loading {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: #0f1216; color: #94a3b8; font: 13px sans-serif; z-index: 1;
}
/* Status + header pills: positioned to underlap the legend (380px wide) and
clear the Mapbox attribution at the very bottom. Cap width below legend
width so the right edge never sticks out from behind the legend. */
.status {
position: absolute; bottom: 32px; left: 12px;
background: rgba(13,17,23,0.92); color: #86efac; border: 1px solid #30363d;
font: 11px ui-monospace, monospace; padding: 6px 12px; border-radius: 4px; z-index: 2;
max-width: 340px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.header-pill {
position: absolute; bottom: 64px; left: 12px;
background: rgba(13,17,23,0.92); color: #c9d1d9; border: 1px solid #30363d;
font: 11px system-ui; padding: 6px 12px; border-radius: 999px; z-index: 2;
max-width: 340px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── Geocoder slot — sits above the legend, matches its 380px width ─────────── */
.geocoder-slot {
position: absolute; top: 12px; left: 8px; width: 380px; z-index: 5;
}
@media (max-width: 640px) {
.geocoder-slot { width: 92vw; max-width: 380px; left: 8px; }
}
/* ── Light-mode chrome overrides — applied when basemap is light-v11 ─────── */
.profile[data-theme="light"] .toolbar-btn {
background: rgba(255,255,255,0.96); border-color: #d0d7de; color: #57606a;
box-shadow: 0 3px 10px rgba(31,35,40,0.12);
}
.profile[data-theme="light"] .toolbar-btn:hover {
background: #f6f8fa; color: #3b463d; border-color: #3b463d;
}
.profile[data-theme="light"] .toolbar-btn.round { background: #ffffff; }
.profile[data-theme="light"] .toolbar-btn.round:hover {
background: #f6f8fa; box-shadow: 0 5px 14px rgba(59,70,61,0.2);
}
.profile[data-theme="light"] .basemap-panel,
.profile[data-theme="light"] .settings-panel {
background: rgba(255,255,255,0.98); border-color: #d0d7de;
box-shadow: 0 6px 20px rgba(31,35,40,0.15);
}
.profile[data-theme="light"] .basemap-title,
.profile[data-theme="light"] .settings-src-label {
color: #3b463d; border-color: #d8dee4;
}
.profile[data-theme="light"] .basemap-opt { color: #57606a; }
.profile[data-theme="light"] .basemap-opt:hover { background: #f6f8fa; color: #1f2328; }
.profile[data-theme="light"] .basemap-opt.active { border-color: #3b463d; color: #3b463d; }
.profile[data-theme="light"] .status {
background: rgba(255,255,255,0.95); border-color: #d0d7de; color: #1a7f37;
}
.profile[data-theme="light"] .header-pill {
background: rgba(255,255,255,0.95); border-color: #d0d7de; color: #1f2328;
}
.profile[data-theme="light"] .loading { background: #f6f8fa; color: #57606a; }
.profile[data-theme="light"] .settings-src-url {
background: #f6f8fa; border-color: #d0d7de; color: #57606a;
}
.profile[data-theme="light"] .settings-field { background: #eaeef2; color: #1f2328; }
/* ── Toolbar ─────────────────────────────────────────────────────────────────── */
.map-toolbar {
position: absolute; top: 12px; right: 12px; z-index: 5;
display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
}
.toolbar-btn {
display: flex; align-items: center; gap: 5px;
background: rgba(13,17,23,0.92); color: #8b949e;
border: 1px solid #30363d; border-radius: 6px;
font: 11px ui-monospace, monospace; padding: 5px 10px;
cursor: pointer; white-space: nowrap;
transition: color 0.12s, border-color 0.12s, background 0.12s, transform 0.12s,
box-shadow 0.12s;
}
.toolbar-btn:hover { color: #c9d1d9; border-color: #73A17F; }
/* Rounded variant — DOXA-style 36px circular chips */
.toolbar-btn.round {
width: 36px; height: 36px; padding: 0;
border-radius: 50%;
justify-content: center;
background: #161b22;
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
}
.toolbar-btn.round:hover {
background: #1c2333; transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(59,70,61,0.25);
}
.toolbar-icon { font-size: 13px; line-height: 1; }
.toolbar-label { font-size: 10px; letter-spacing: 0.04em; }
.basemap-wrap, .settings-wrap { position: relative; }
.basemap-panel, .settings-panel {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); border: 1px solid #30363d; border-radius: 6px;
padding: 8px; min-width: 130px; z-index: 10;
display: flex; flex-direction: column; gap: 3px;
}
.basemap-title {
font: 600 10px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em;
padding: 0 4px 4px; border-bottom: 1px solid #21262d; margin-bottom: 2px;
}
.basemap-opt {
background: transparent; border: 1px solid transparent; border-radius: 4px;
color: #8b949e; font: 11px system-ui; padding: 4px 8px; cursor: pointer; text-align: left;
}
.basemap-opt:hover { background: #161b22; color: #c9d1d9; }
.basemap-opt.active { border-color: #73A17F; color: #73A17F; }
.settings-panel { min-width: 300px; max-width: 420px; max-height: 50vh; overflow-y: auto; }
.settings-header {
font: 600 11px ui-monospace, monospace; color: #c9d1d9;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 4px 4px 8px; margin-bottom: 6px;
border-bottom: 1px solid #21262d;
}
.profile[data-theme="light"] .settings-header { color: #1f2328; border-bottom-color: #d8dee4; }
.settings-src { margin-bottom: 8px; }
.settings-src-label {
font: 600 9px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 2px;
}
.settings-src-url {
font: 10px ui-monospace, monospace; color: #6e7681; word-break: break-all;
background: #161b22; border: 1px solid #21262d; border-radius: 3px; padding: 3px 5px; margin-bottom: 3px;
}
.settings-fields { display: flex; flex-wrap: wrap; gap: 2px; }
.settings-field {
font: 10px ui-monospace, monospace; background: #21262d; color: #c9d1d9; border-radius: 3px; padding: 1px 4px;
}
</style>

View file

@ -0,0 +1,204 @@
<script setup>
/**
* pplr-rosetta-cards.vue — Mapbox map of PGs with cross-system-ID popups.
*
* Color pins by which IDs are present:
* gold = JP + IMB + WCD all present
* blue = JP + IMB
* purple = IMB only
* pink = JP only
* gray = neither
*
* The 5 sample PGs from rosetta-sample.json are highlighted with larger pins.
* Click any pin → popup with full Rosetta table.
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import DataSourcesDropdown from '../components/DataSourcesDropdown.vue'
const DATA_SOURCES = [
{ label: 'PG pins (PG-in-country)', url: 'pg-points.json',
fields: ['code', 'name', 'country', 'iso3', 'rop25', 'imb', 'jp', 'wcd', 'rosetta'] },
{ label: 'Rosetta sample (5 PGs)', url: 'rosetta-sample.json',
fields: ['joshuaProjectCode', 'imb', 'wcd', 'name', 'country', 'language'] },
]
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const status = ref('Loading…')
const dataReady = ref(false)
function reload() { window.location.reload() }
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
center: [50, 22],
zoom: 1.8
})
onMounted(async () => {
await initializeMap()
map.value.on('load', async () => {
try {
status.value = 'Loading PG data…'
await addCountryPolygons(map.value)
status.value = 'Loading PG pins + Rosetta sample…'
const [pgFc, rosetta] = await Promise.all([
loadOnce('pg-points.json'),
loadOnce('rosetta-sample.json')
])
// Mark Rosetta sample PGs (match by joshuaProjectCode)
const rosKeys = new Set(rosetta.map(r => String(r.joshuaProjectCode || '')))
pgFc.features.forEach(f => {
f.properties.rosetta = rosKeys.has(String(f.properties.jp || '')) ? 1 : 0
const has = (v) => v != null && v !== 'None' && v !== '' && v !== '*'
const hasJP = has(f.properties.jp), hasIMB = has(f.properties.imb), hasWCD = has(f.properties.wcd)
f.properties.idTier = hasJP && hasIMB && hasWCD ? 'all' :
hasJP && hasIMB ? 'jp_imb' :
hasIMB ? 'imb' :
hasJP ? 'jp' : 'none'
})
map.value.addSource('pgs', { type: 'geojson', data: pgFc })
// All PG pins (small, color by ID-tier)
map.value.addLayer({
id: 'pgs-pin',
type: 'circle',
source: 'pgs',
filter: ['==', ['get', 'rosetta'], 0],
paint: {
'circle-color': ['match', ['get', 'idTier'],
'all', '#fbbf24',
'jp_imb', '#3b82f6',
'imb', '#a78bfa',
'jp', '#ec4899',
'#6b7280'],
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2, 6, 5, 12, 8],
'circle-stroke-color': '#0d1117',
'circle-stroke-width': 0.8,
'circle-opacity': 0.85
}
})
// Rosetta sample (5 stars — slightly larger than base pins so they
// stand out, but zoom-interpolated so they don't dominate the whole
// map at low zoom levels (per user 2026-05-02: "yellow pens are way
// bigger than all the other pins... when you zoom out it makes the
// whole map just yellow").
map.value.addLayer({
id: 'rosetta-pin',
type: 'circle',
source: 'pgs',
filter: ['==', ['get', 'rosetta'], 1],
paint: {
'circle-color': '#fbbf24',
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 4, 6, 7, 12, 11],
'circle-stroke-color': '#fbbf24',
'circle-stroke-width': 1.5,
'circle-stroke-opacity': 0.5,
'circle-opacity': 0.95
}
})
const handleClick = e => {
const f = e.features[0]
const p = f.properties
const has = v => v != null && v !== 'None' && v !== '' && v !== '*'
const sample = rosetta.find(r => String(r.joshuaProjectCode || '') === String(p.jp || ''))
const missingIds = [
!has(p.jp) && 'JoshuaProject',
!has(p.imb) && 'IMB People',
!has(p.wcd) && 'WCD',
].filter(Boolean)
const rosettaRow = sample
? `<tr><td>Rosetta ★</td><td><b style="color:#d97706">★ sample PG</b></td></tr>`
: ''
const missingNote = missingIds.length
? `<tr><td colspan="2" style="padding-top:6px;font-size:10px;color:#6b7280;font-style:italic">
${missingIds.join(', ')} ID${missingIds.length > 1 ? 's' : ''} not yet cross-mapped in PPLR — empty rows are a data gap, not an error.
</td></tr>`
: ''
new mapboxgl.Popup({ maxWidth: '360px' })
.setLngLat(f.geometry.coordinates)
.setHTML(popupForPG(p, rosettaRow + missingNote))
.addTo(map.value)
}
map.value.on('click', 'pgs-pin', handleClick)
map.value.on('click', 'rosetta-pin', handleClick)
map.value.on('mouseenter', 'pgs-pin', () => map.value.getCanvas().style.cursor = 'pointer')
map.value.on('mouseleave', 'pgs-pin', () => map.value.getCanvas().style.cursor = '')
map.value.on('mouseenter', 'rosetta-pin', () => map.value.getCanvas().style.cursor = 'pointer')
map.value.on('mouseleave', 'rosetta-pin', () => map.value.getCanvas().style.cursor = '')
status.value = `Ready · ${pgFc.features.length} pins · ${rosKeys.size} Rosetta samples (gold ★)`
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`
}
})
})
onBeforeUnmount(() => destroy())
const LEGEND = [
{ color: '#fbbf24', label: 'JP + IMB + WCD all' },
{ color: '#3b82f6', label: 'JP + IMB' },
{ color: '#a78bfa', label: 'IMB only' },
{ color: '#ec4899', label: 'JP only' },
{ color: '#6b7280', label: 'neither' },
]
</script>
<template>
<div class="profile">
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Cross-system ID Rosetta · color = which IDs exist · ★ = sample row</div>
<div class="legend">
<div class="title">ID coverage</div>
<div v-for="l in LEGEND" :key="l.label" class="row">
<span class="dot" :style="{ background: l.color }" />{{ l.label }}
</div>
</div>
<div class="map-toolbar">
<DataSourcesDropdown :sources="DATA_SOURCES" />
</div>
<component is="style">{{ POPUP_CSS }}</component>
</div>
</template>
<style scoped>
.profile { width: 100%; height: 100%; position: relative; }
.map { width: 100%; height: 100%; }
.loading { position: absolute; inset: 0; display:flex; align-items:center; justify-content:center;
background: #0f1216; color: #94a3b8; font: 13px sans-serif; z-index: 1; }
.status {
position: absolute; bottom: 12px; left: 12px;
background: rgba(13,17,23,0.92); color: #fcd34d; border: 1px solid #30363d;
font: 11px ui-monospace, monospace; padding: 6px 12px; border-radius: 4px; z-index: 2;
}
.header-pill {
position: absolute; top: 12px; left: 12px;
background: rgba(13,17,23,0.92); color: #c9d1d9; border: 1px solid #30363d;
font: 12px system-ui; padding: 8px 14px; border-radius: 999px; z-index: 2;
}
/* Legend shifted down to clear the new top-right toolbar (data sources gear) */
.legend {
position: absolute; top: 56px; right: 12px; z-index: 2;
background: rgba(13,17,23,0.94); color: #c9d1d9; border: 1px solid #30363d;
border-radius: 6px; padding: 10px 14px; min-width: 160px;
}
.legend .title { font: 600 11px system-ui; color: #fcd34d; margin-bottom: 6px;
text-transform: uppercase; letter-spacing: 0.04em; }
.legend .row { display: flex; align-items: center; gap: 8px; font: 11px system-ui; padding: 2px 0; }
.legend .dot { width: 12px; height: 12px; border-radius: 50%; }
.map-toolbar { position: absolute; top: 12px; right: 12px; z-index: 5; }
</style>

View file

@ -0,0 +1,503 @@
<script setup>
import { inject, ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
const profileConfig = inject('profileConfig', null)
const treeType = computed(() => profileConfig?.value?.treeType || null)
// One tree per category (per user spec). Each parent has exactly 2 children to
// keep the visual pattern obvious. Lang tree intentionally shows one language
// WITH dialects and one WITHOUT (to convey "not every language has dialects").
const TREES = [
{
id: 'rop',
title: 'ROP Tree',
color: '#a78bfa',
levels: ['Affinity Bloc', 'People Cluster', 'ROP25', 'ROP3 × Country'],
// 1 bloc → 2 clusters → 2 ROP25 each → 2 PGICs each (2·2·2 fan-out, 8 rows)
examples: [
['Arab Peoples', 'Maghrebi Arab', 'Algerian Arab', 'Algerian Arab in Algeria'],
['', '', '', 'Algerian Arab in France' ],
['', '', 'Moroccan Arab', 'Moroccan Arab in Morocco' ],
['', '', '', 'Moroccan Arab in Spain' ],
['', 'Levantine Arab', 'Lebanese Arab', 'Lebanese Arab in Lebanon' ],
['', '', '', 'Lebanese Arab in USA' ],
['', '', 'Syrian Arab', 'Syrian Arab in Syria' ],
['', '', '', 'Syrian Arab in Germany' ],
],
systems: [
{ label: 'PPLR', color: '#58a6ff', fields: ['ROP1Code · ROP1Name', 'ROP2Code · ROP2Name', 'ROP25Code · ROP25Name', 'GeoID · Code · Name'] },
{ label: 'Joshua Project', color: '#f59e0b', fields: ['—', '—', 'JoshuaProjectCode (PGID)', 'PGID · JoshuaProjectName'] },
{ label: 'IMB', color: '#f43f5e', fields: ['—', '—', 'IMBPeopleCode', 'PEID · IMBPeopleName'] },
{ label: 'WCD', color: '#10b981', fields: ['—', '—', 'WorldChristianDatabaseCode', 'WCDPRN · wcdName'] },
],
},
{
id: 'lang',
title: 'Lang Tree',
color: '#06b6d4',
levels: ['Lang Family', 'Language', 'Dialect / Variety'],
// 1 family → 2 languages — first has 2 dialects, second has 0 (✕ marker)
examples: [
['Arab (Arabic)', 'Egyptian Arabic', 'Egyptian: Cairene' ],
['', '', 'Egyptian: Saidi' ],
['', 'Standard Arabic', '✕ No dialects' ],
],
systems: [
{ label: 'PPLR', color: '#58a6ff', fields: ['Family · aROP3byCountry', 'langCd (3-char ROL) · aLanguage', 'langCd (ROL-GRN, 9-char) · aLanguage'] },
{ label: 'aLanguage scale', color: '#14b8a6', fields: ['—', '7,270 language records', '534 dialect records'] },
{ label: 'Joshua Project', color: '#f59e0b', fields: ['—', 'PrimaryLanguageName · ROL3', 'LanguageClass'] },
{ label: 'IMB', color: '#f43f5e', fields: ['—', '—', '—'] },
],
},
{
id: 'admin',
title: 'Admin Tree',
color: '#22c55e',
levels: ['Admin 1', 'Admin 2', 'Admin 3', 'Admin 4'],
// 1 country → 2 provinces → 2 parishes each → 2 villages each
// Generic, non-US example: a Pacific-island parish/village hierarchy
examples: [
['Vanuatu', 'Tafea Province', 'Tanna Parish', 'Whitesands village'],
['', '', '', 'Lenakel village' ],
['', '', 'Aniwa Parish', 'Itonga village' ],
['', '', '', 'Imanaka village' ],
['', 'Penama Province', 'Pentecost Parish', 'Loltong village' ],
['', '', '', 'Melsisi village' ],
['', '', 'Ambae Parish', 'Lolovenue village' ],
['', '', '', 'Lolopuepue village'],
],
systems: [
{ label: 'GADM / OSM', color: '#22c55e', fields: ['NAME_1 / admin_level=4', 'NAME_2 / admin_level=6', 'NAME_3 / admin_level=8', 'NAME_4 / admin_level=10'] },
{ label: 'PPLR', color: '#58a6ff', fields: ['ISOAlpha3 / ROG', '—', '—', 'Lat/Lon → spatial join'] },
{ label: 'WHOSIS', color: '#f59e0b', fields: ['ISO3', 'Subdivision', '—', '—'] },
],
},
]
const visibleTrees = computed(() => TREES)
// Title cell spans: header row + example rows + separator row only.
// System rows reuse col 0 for their own system-name label.
function titleRowspan(tree) {
return 2 + tree.examples.length // 1 header + N examples + 1 separator
}
function rowBg(ri) {
return ri % 2 === 0 ? '#0d1117' : '#0a0e14'
}
// ── SVG cubic-bezier tree-line overlay ───────────────────────────────────────
const wrapRefs = {}
const treePaths = ref({})
const svgSizes = ref({})
function setWrapRef(el, id) { if (el) wrapRefs[id] = el }
function buildPaths(tree) {
const wrap = wrapRefs[tree.id]
if (!wrap) return
const wRect = wrap.getBoundingClientRect()
if (!wRect.width) return
svgSizes.value[tree.id] = { w: Math.ceil(wRect.width), h: Math.ceil(wrap.scrollHeight) }
const exRows = wrap.querySelectorAll('tr.ex-row')
const cRects = []
exRows.forEach(row => {
const cells = row.querySelectorAll('td.ex-cell')
const rowRects = []
cells.forEach(cell => {
const r = cell.getBoundingClientRect()
rowRects.push({
cx: (r.left + r.right) / 2 - wRect.left,
cy: (r.top + r.bottom) / 2 - wRect.top,
})
})
cRects.push(rowRects)
})
const paths = []
const nRows = tree.examples.length
const nCols = tree.levels.length
for (let c = 1; c < nCols; c++) {
let parentRow = null
let siblings = []
const flush = () => {
if (parentRow === null || !siblings.length) return
const pR = cRects[parentRow]?.[c - 1]
if (!pR) return
for (const sr of siblings) {
const sR = cRects[sr]?.[c]
if (!sR) continue
const dx = (sR.cx - pR.cx) * 0.45
paths.push(`M ${pR.cx} ${pR.cy} C ${pR.cx + dx} ${pR.cy} ${sR.cx - dx} ${sR.cy} ${sR.cx} ${sR.cy}`)
}
parentRow = null
siblings = []
}
for (let r = 0; r < nRows; r++) {
if (tree.examples[r][c - 1] !== '') { flush(); parentRow = r }
if (tree.examples[r][c] !== '' && parentRow !== null) siblings.push(r)
}
flush()
}
treePaths.value[tree.id] = paths
}
let resizeObs = null
onMounted(async () => {
await nextTick()
for (const tree of visibleTrees.value) buildPaths(tree)
resizeObs = new ResizeObserver(() => {
for (const tree of visibleTrees.value) buildPaths(tree)
})
for (const id of Object.keys(wrapRefs)) resizeObs.observe(wrapRefs[id])
})
onBeforeUnmount(() => resizeObs?.disconnect())
</script>
<template>
<div class="page">
<header class="page-header">
<h1>Semantic tree reference</h1>
<p>Classification hierarchies used in PPLR. Each column = one depth level. Dots mark node presence. System rows show API field names per external system.</p>
</header>
<!-- 2×2 grid: ROP · Lang · Admin · placeholder -->
<div class="trees-grid">
<div v-for="tree in visibleTrees" :key="tree.id" class="tree-cell">
<div class="tree-table-wrap" :ref="el => setWrapRef(el, tree.id)">
<!-- SVG cubic-bezier tree-line overlay -->
<svg
v-if="treePaths[tree.id]?.length"
class="tree-lines-svg"
:width="svgSizes[tree.id]?.w"
:height="svgSizes[tree.id]?.h"
:viewBox="`0 0 ${svgSizes[tree.id]?.w} ${svgSizes[tree.id]?.h}`"
xmlns="http://www.w3.org/2000/svg"
>
<path
v-for="(d, i) in treePaths[tree.id]"
:key="i"
:d="d"
:stroke="tree.color"
stroke-width="1.5"
stroke-opacity="0.55"
fill="none"
stroke-linecap="round"
/>
</svg>
<table>
<colgroup>
<!-- col 0: title/system-name column — fixed narrow width -->
<col class="col-label" />
<!-- level columns: equal share of remaining space -->
<col v-for="_ in tree.levels" :key="_" class="col-level" />
</colgroup>
<tbody>
<!-- ── Header row: level titles ── -->
<tr>
<!--
Title cell spans only header + examples + separator.
System rows reuse this column for their system-name label.
-->
<td
class="tree-title-cell"
:rowspan="titleRowspan(tree)"
:style="{ color: tree.color, borderRight: '2px solid ' + tree.color }"
>{{ tree.title }}</td>
<th
v-for="lvl in tree.levels"
:key="lvl"
class="level-hdr"
:style="{ color: tree.color }"
>{{ lvl }}</th>
</tr>
<!-- ── Example rows: concrete labels (not just dots) ── -->
<tr
v-for="(row, ri) in tree.examples"
:key="'ex' + ri"
class="ex-row"
:style="{ background: rowBg(ri) }"
>
<td
v-for="(cell, ci) in row"
:key="ci"
class="ex-cell example-cell"
:class="{
'example-cell--filled': cell !== '',
'example-cell--nodialect': cell.startsWith('✕')
}"
:title="cell"
>{{ cell }}</td>
</tr>
<!-- ── Separator: "API field names" section ── -->
<tr class="sys-sep-row">
<td :colspan="tree.levels.length" class="sys-sep-cell">API field names per external system</td>
</tr>
<!-- ── System rows: system name (col 0) + field names (cols 1+) ── -->
<tr
v-for="sys in tree.systems"
:key="sys.label"
class="system-row"
>
<td class="system-name-cell" :style="{ color: sys.color }">{{ sys.label }}</td>
<td
v-for="(field, fi) in sys.fields"
:key="fi"
class="system-field-cell"
:class="{ 'system-field-cell--dash': field === '—' }"
>{{ field }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 4th cell: invitation to discover more hidden trees in the data.
The 3 trees on the left are what we've found so far. The "?" is the
open question — what other one-to-many semantic structures hide in
PPLR? Each new tree found gets promoted into a real cell. -->
<div class="tree-cell tree-discover">
<div class="discover-mark" aria-hidden="true">?</div>
<h2 class="discover-title">What other semantic trees<br>are hiding in the data?</h2>
<p class="discover-body">
The three trees here are what we've mapped so far. The goal is to find
all of them. If you spot a one-to-many hierarchy in PPLR, JP, IMB or
WCD that isn't represented yet, add it as the 4th cell.
</p>
<ul class="discover-hints">
<li><span>Migration corridors?</span> origin → diaspora pathways</li>
<li><span>Religious sub-streams?</span> denomination → tradition → school</li>
<li><span>Engagement status tiers?</span> unreached → unengaged → unevangelized</li>
<li><span>Translation lineage?</span> source language → script → orthography</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
/* Per user 2026-05-02: all 4 cells must fit in the viewport without scroll.
.page is now a flex column that fills the profile; .trees-grid takes the
remaining height with two equal rows. Each cell hides overflow so a
text-heavy cell (the discovery "?" cell) cannot make its row taller than
the trees on the other side. */
.page {
width: 100%;
height: 100%;
overflow: hidden;
background: #161b22;
color: #e6edf3;
font-family: system-ui, -apple-system, sans-serif;
padding: 10px 14px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.page-header { flex-shrink: 0; }
.page-header h1 {
font-size: 13px; font-weight: 600; color: #79c0ff; margin: 0 0 2px;
letter-spacing: 0.01em;
}
.page-header p {
font-size: 10.5px; color: #adbac7; max-width: 720px; line-height: 1.4; margin: 0 0 8px;
}
/* ── 2×2 grid — fills the leftover viewport height ── */
.trees-grid {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.tree-cell {
min-width: 0; min-height: 0;
background: #1c2128;
border: 1px solid #373e47;
border-radius: 8px;
padding: 6px 8px;
box-shadow: 0 3px 10px rgba(0,0,0,0.22);
/* Per user 2026-05-02: cells were hiding field columns. Allow per-cell
scrolling instead — full content stays visible inside its tile. */
overflow: auto;
display: flex; flex-direction: column;
}
.tree-cell::-webkit-scrollbar { width: 6px; height: 6px; }
.tree-cell::-webkit-scrollbar-thumb { background: #484f58; border-radius: 3px; }
/* ── Table+SVG container: scrolls horizontally if columns don't fit (per user
2026-05-02: don't hide field columns just to keep cells slim) ── */
.tree-table-wrap {
position: relative;
overflow-x: auto;
overflow-y: visible;
}
.tree-table-wrap::-webkit-scrollbar { height: 6px; }
.tree-table-wrap::-webkit-scrollbar-thumb { background: #484f58; border-radius: 3px; }
/* ── SVG overlay ── */
.tree-lines-svg {
position: absolute; top: 0; left: 0;
pointer-events: none; z-index: 2; overflow: visible;
}
/* ── Table: fixed layout so columns share space evenly within the grid cell ── */
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
position: relative;
z-index: 1;
}
/* Label column: wide enough for longest label ("ALANGUAGE SCALE" ≈ 15 chars at 9px mono) */
col.col-label { width: 72px; }
/* Level columns: share remaining space equally */
col.col-level { width: auto; }
/* ── Tree title cell: spans header + examples + separator (slim) ── */
.tree-title-cell {
background: #22272e;
writing-mode: vertical-rl;
transform: rotate(180deg);
text-align: center;
font-weight: 700;
font-size: 10px;
padding: 6px 0;
border: 1px solid #373e47;
vertical-align: middle;
}
/* ── Level headers (slim) ── */
.level-hdr {
background: #22272e;
text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.05em;
font-weight: 700;
color: #cdd9e5;
padding: 5px 4px;
text-align: center;
border: 1px solid #373e47;
white-space: normal;
word-break: break-word;
vertical-align: bottom;
}
/* ── Example cells: now show concrete labels (slim) ── */
.example-cell {
font: 10px system-ui, sans-serif;
padding: 3px 5px;
border: 1px solid #2d333b;
background: #1c2128;
color: #6e7681;
text-align: left;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.example-cell--filled {
color: #cdd9e5;
font-weight: 500;
}
.example-cell--nodialect {
color: #ef4444; font-style: italic;
}
/* ── Separator row (slim) ── */
.sys-sep-row { background: #22272e; }
.sys-sep-cell {
font: 700 8.5px ui-monospace, monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #79c0ff;
padding: 4px 6px;
border: 1px solid #373e47;
border-top: 2px solid #4d586a;
white-space: normal;
}
/* ── System rows (slim) ── */
.system-row { background: #1c2128; }
.system-row:nth-child(even) { background: #22272e; }
.system-name-cell {
font: 700 9px ui-monospace, monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #adbac7;
padding: 4px 5px;
border: 1px solid #373e47;
white-space: normal;
word-break: break-word;
vertical-align: top;
}
.system-field-cell {
font: 9.5px ui-monospace, monospace;
color: #cdd9e5;
padding: 4px 6px;
border: 1px solid #373e47;
text-align: center;
white-space: normal;
word-break: break-word;
overflow-wrap: break-word;
vertical-align: top;
}
.system-field-cell--dash { color: #545d68; }
/* ── Discovery cell (the 4th cell) — sized to match the others, NOT taller ── */
.tree-discover {
justify-content: center; align-items: center;
gap: 6px; padding: 10px 12px;
text-align: center;
overflow: hidden;
}
.discover-mark {
font: 700 56px system-ui, -apple-system, sans-serif;
color: #73A17F; line-height: 0.85;
text-shadow: 0 4px 14px rgba(115,161,127,0.3);
margin: 0;
}
.discover-title {
margin: 2px 0 0;
font: 600 12px system-ui, sans-serif;
color: #e6edf3; line-height: 1.25;
}
.discover-body {
margin: 2px 0 0;
font: 10.5px system-ui, sans-serif;
color: #adbac7; line-height: 1.4;
max-width: 38ch;
}
.discover-hints {
list-style: none; margin: 4px 0 0; padding: 0;
display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
font: 9.5px system-ui, sans-serif;
color: #8b949e; text-align: left;
width: 100%;
}
.discover-hints li {
padding: 3px 6px;
border-left: 2px solid #30363d;
border-radius: 0 4px 4px 0;
background: rgba(115,161,127,0.04);
line-height: 1.3;
}
.discover-hints li span {
display: block;
font-weight: 600; color: #cdd9e5;
}
</style>

View file

@ -0,0 +1,215 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
/**
* pplr-translations.vue — PG name in all 17 locales.
*
* Pin click → PinDetailCard sidebar (scrollable) — same pattern as ROP-tree
* and Language-Families profiles. Replaced the old mapboxgl.Popup which
* couldn't fit all 17 locales without a scrollbar inside the popup.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import PinDetailCard from '../components/PinDetailCard.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import { createPplrInstance, provideInstance } from '../composables/usePplrInstance.js'
const pplrInstance = createPplrInstance('translations')
provideInstance(pplrInstance)
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null)
const status = ref('Loading…')
const PIN_LAYER = 'trans-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
center: [70, 22],
zoom: 2,
projection: 'mercator',
})
// ── Pin-click indicator ─────────────────────────────────────────────────────
const pinIndicator = makePinIndicator()
watch(() => pplrInstance.pinDetail.value, p => { if (!p) pinIndicator.hide() })
function onPinLocate(p) {
if (!map.value || p?.lng == null || p?.lat == null) return
map.value.stop()
map.value.flyTo({ center: [p.lng, p.lat], zoom: 6, duration: 1200 })
}
const detailColor = computed(() => '#a78bfa') // tab's accent purple
const dataReady = ref(false)
function reload() { window.location.reload() }
const showSettings = ref(false)
const DATA_SOURCES = [
{ label: 'PG pins (PG-in-country)', url: 'pg-points.json',
fields: ['code', 'name', 'country', 'iso3', 'rop25', 'language', 'n_en…n_de (17 locales)'] },
{ label: 'Country polygons (live)', url: 'PPLR FeatureServer · aCountry',
fields: ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp'] },
]
onMounted(async () => {
await initializeMap()
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {
try {
map.value.setPadding({ left: 380, top: 0, right: 0, bottom: 0 })
status.value = 'Loading PG data…'
addCountryPolygons(map.value, { theme: pplrInstance.theme.value })
status.value = 'Loading PG pins…'
const resp = await fetch(dataUrl('pg-points.json'))
const fc = await resp.json()
map.value.addSource('trans-pgs', { type: 'geojson', data: fc, cluster: false })
map.value.addLayer({
id: PIN_LAYER, type: 'circle', source: 'trans-pgs',
paint: {
'circle-color': '#a78bfa',
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2, 6, 4, 12, 7],
'circle-opacity': 0.85,
'circle-stroke-color': '#0d1117',
'circle-stroke-width': 0.6,
},
})
bindFuzzyPinClick(map.value, PIN_LAYER, ({ features }) => {
const f = features[0]
const lng = f.geometry.coordinates[0]
const lat = f.geometry.coordinates[1]
pplrInstance.pinDetail.value = { ...f.properties, lng, lat }
pinIndicator.show(map.value, [lng, lat], { color: '#a78bfa' })
})
status.value = `${fc.features.length.toLocaleString()} pins · click any pin to see all 17 locale names`
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`
}
})
})
onBeforeUnmount(() => { pinIndicator.destroy(); destroy() })
</script>
<template>
<div ref="profileRoot" class="profile" :data-theme="pplrInstance.theme.value">
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<!-- Pin-detail card with all 17 locales scrollable -->
<PinDetailCard
v-if="pplrInstance.pinDetail.value"
:color="detailColor"
@locate="onPinLocate"
/>
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">PG name · 17 locales · click any pin</div>
<!-- Toolbar — single-button (data sources) for now; this profile doesn't
expose theme / projection / basemap toggles since it's locale-focused. -->
<div class="map-toolbar">
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>
<div class="settings-fields">
<span v-for="f in src.fields" :key="f" class="settings-field">{{ f }}</span>
</div>
</div>
</div>
</div>
</div>
<component is="style">{{ POPUP_CSS }}</component>
</div>
</template>
<style scoped>
.profile { width: 100%; height: 100%; position: relative; }
.map { width: 100%; height: 100%; }
.loading {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: #0f1216; color: #94a3b8; font: 13px sans-serif; z-index: 1;
}
.status {
position: absolute; bottom: 32px; left: 12px;
background: rgba(13,17,23,0.92); color: #c4b5fd; border: 1px solid #30363d;
font: 11px ui-monospace, monospace; padding: 6px 12px; border-radius: 999px; z-index: 2;
max-width: 340px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.header-pill {
position: absolute; bottom: 64px; left: 12px;
background: rgba(13,17,23,0.92); color: #c9d1d9; border: 1px solid #30363d;
font: 11px system-ui; padding: 6px 12px; border-radius: 999px; z-index: 2;
max-width: 340px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── Toolbar (data sources only on this profile) ─────────────────────────── */
.map-toolbar {
position: absolute; top: 12px; right: 12px; z-index: 5;
display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
}
.toolbar-btn.round {
width: 36px; height: 36px; padding: 0;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: #161b22; color: #8b949e;
border: 1px solid #30363d; cursor: pointer;
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
transition: color 0.12s, background 0.12s, transform 0.12s, box-shadow 0.12s;
}
.toolbar-btn.round:hover {
background: #1c2333; color: #c9d1d9; border-color: #73A17F;
transform: translateY(-1px); box-shadow: 0 5px 14px rgba(59,70,61,0.25);
}
.settings-wrap { position: relative; }
.settings-panel {
position: absolute; top: calc(100% + 4px); right: 0;
background: rgba(13,17,23,0.97); color: #c9d1d9;
border: 1px solid #30363d; border-radius: 8px;
font: 12px system-ui; padding: 12px 16px;
min-width: 320px; max-width: 420px; max-height: 50vh; overflow-y: auto;
z-index: 10;
}
.settings-header {
font: 600 11px ui-monospace, monospace; color: #c9d1d9;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 0 4px 8px; margin-bottom: 6px;
border-bottom: 1px solid #21262d;
}
.settings-src { margin-bottom: 8px; }
.settings-src:last-child { margin-bottom: 0; }
.settings-src-label {
font: 600 9px ui-monospace, monospace; color: #73A17F;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 2px;
}
.settings-src-url {
font: 10px ui-monospace, monospace; color: #6e7681; word-break: break-all;
background: #161b22; border: 1px solid #21262d; border-radius: 3px;
padding: 3px 5px; margin-bottom: 3px;
}
.settings-fields { display: flex; flex-wrap: wrap; gap: 2px; }
.settings-field {
font: 10px ui-monospace, monospace; background: #21262d;
color: #c9d1d9; border-radius: 3px; padding: 1px 4px;
}
</style>