208 lines
8.4 KiB
Vue
208 lines
8.4 KiB
Vue
<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 { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
|
|
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: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
|
|
center: [50, 22],
|
|
zoom: 1.8
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await initializeMap()
|
|
exposeMap(map.value)
|
|
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 (glFor(map.value)).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 ★)`
|
|
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
|
|
await whenGeojsonDrawn(map.value)
|
|
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>
|