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:
parent
d77efea253
commit
e1d847028d
32 changed files with 6484 additions and 5 deletions
1
data/pplr/coverage.json
Normal file
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
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
1
data/pplr/pg-points.json
Normal file
File diff suppressed because one or more lines are too long
1
data/pplr/pplr-geoid-family.json
Normal file
1
data/pplr/pplr-geoid-family.json
Normal file
File diff suppressed because one or more lines are too long
1
data/pplr/rop-tree.json
Normal file
1
data/pplr/rop-tree.json
Normal file
File diff suppressed because one or more lines are too long
77
data/pplr/rosetta-sample.json
Normal file
77
data/pplr/rosetta-sample.json
Normal 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"
|
||||
}
|
||||
]
|
||||
|
|
@ -1398,10 +1398,8 @@ function main() {
|
|||
['vendor', join(pubAssets, 'vendor')],
|
||||
['posters', join(pubAssets, 'posters')],
|
||||
['tables', join(pubAssets, 'tables')],
|
||||
// legacy — sandboxed pre-bundler maps rescued by the experimental roundup
|
||||
// (EXPERIMENTAL-MAPS-ROUNDUP.md): served as static assets, mounted only inside
|
||||
// wrapper-bundle iframes so the old engines cannot collide with modern bundles.
|
||||
['legacy', join(pubAssets, 'legacy')],
|
||||
// pplr — the PPLR data maps' derived datasets (plugin-designs/pplr-data-maps/scripts/build-data.mjs)
|
||||
['pplr', join(pubAssets, 'pplr')],
|
||||
]) {
|
||||
const src = join(repoData, srcName)
|
||||
if (!existsSync(src)) continue
|
||||
|
|
@ -1409,7 +1407,7 @@ function main() {
|
|||
mkdirSync(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 ──────
|
||||
// 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,
|
||||
// which no element fetches and which dropin.exclude already withholds).
|
||||
'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/tabulator/tabulator.min.js',
|
||||
'vendor/tabulator/tabulator.min.css',
|
||||
|
|
|
|||
30
plugin-designs/pplr-data-maps/index.html
Normal file
30
plugin-designs/pplr-data-maps/index.html
Normal 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>
|
||||
33
plugin-designs/pplr-data-maps/index.js
Normal file
33
plugin-designs/pplr-data-maps/index.js
Normal 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)
|
||||
5
plugin-designs/pplr-data-maps/pplr-data-maps.meta.json
Normal file
5
plugin-designs/pplr-data-maps/pplr-data-maps.meta.json
Normal 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."
|
||||
}
|
||||
83
plugin-designs/pplr-data-maps/pplr-data-maps.vue
Normal file
83
plugin-designs/pplr-data-maps/pplr-data-maps.vue
Normal 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>
|
||||
54
plugin-designs/pplr-data-maps/scripts/build-data.mjs
Normal file
54
plugin-designs/pplr-data-maps/scripts/build-data.mjs
Normal 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}`)
|
||||
|
|
@ -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>
|
||||
215
plugin-designs/pplr-data-maps/src/components/FlatLegend.vue
Normal file
215
plugin-designs/pplr-data-maps/src/components/FlatLegend.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
305
plugin-designs/pplr-data-maps/src/components/MapboxGeocoder.vue
Normal file
305
plugin-designs/pplr-data-maps/src/components/MapboxGeocoder.vue
Normal 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>
|
||||
277
plugin-designs/pplr-data-maps/src/components/PinDetailCard.vue
Normal file
277
plugin-designs/pplr-data-maps/src/components/PinDetailCard.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
49
plugin-designs/pplr-data-maps/src/utils/pplrData.js
Normal file
49
plugin-designs/pplr-data-maps/src/utils/pplrData.js
Normal 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
|
||||
}
|
||||
450
plugin-designs/pplr-data-maps/src/utils/pplrMap.js
Normal file
450
plugin-designs/pplr-data-maps/src/utils/pplrMap.js
Normal 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 <style> 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)
|
||||
}
|
||||
}
|
||||
182
plugin-designs/pplr-data-maps/src/views/pplr-coverage.vue
Normal file
182
plugin-designs/pplr-data-maps/src/views/pplr-coverage.vue
Normal 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>
|
||||
158
plugin-designs/pplr-data-maps/src/views/pplr-d3-tree.vue
Normal file
158
plugin-designs/pplr-data-maps/src/views/pplr-d3-tree.vue
Normal 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>
|
||||
98
plugin-designs/pplr-data-maps/src/views/pplr-index.vue
Normal file
98
plugin-designs/pplr-data-maps/src/views/pplr-index.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
151
plugin-designs/pplr-data-maps/src/views/pplr-pins.vue
Normal file
151
plugin-designs/pplr-data-maps/src/views/pplr-pins.vue
Normal 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>
|
||||
510
plugin-designs/pplr-data-maps/src/views/pplr-religion.vue
Normal file
510
plugin-designs/pplr-data-maps/src/views/pplr-religion.vue
Normal 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>
|
||||
700
plugin-designs/pplr-data-maps/src/views/pplr-rop-tree.vue
Normal file
700
plugin-designs/pplr-data-maps/src/views/pplr-rop-tree.vue
Normal 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>
|
||||
204
plugin-designs/pplr-data-maps/src/views/pplr-rosetta-cards.vue
Normal file
204
plugin-designs/pplr-data-maps/src/views/pplr-rosetta-cards.vue
Normal 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>
|
||||
503
plugin-designs/pplr-data-maps/src/views/pplr-semantic-trees.vue
Normal file
503
plugin-designs/pplr-data-maps/src/views/pplr-semantic-trees.vue
Normal 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>
|
||||
215
plugin-designs/pplr-data-maps/src/views/pplr-translations.vue
Normal file
215
plugin-designs/pplr-data-maps/src/views/pplr-translations.vue
Normal 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>
|
||||
Loading…
Reference in a new issue