show people group details in country map pin popup

- enrich pin popup with photo, country, people group, population,
  religion, description, and pray / full-profile links
- fetch the extra fields for pins; reuse existing translated labels
- render photos as a centred portrait (4:5) thumbnail, never landscape
This commit is contained in:
corsacca 2026-06-18 16:46:25 +01:00
parent 39494fba65
commit 94102174be

View file

@ -3,7 +3,10 @@
// outlined polygon from Mapbox's country-boundaries tileset) and plots every
// people group as a pin — the focus country's groups in a bright brand green,
// groups in surrounding countries muted — so the viewer sees both the country's
// people groups and the general area around it.
// people groups and the general area around it. Clicking a pin opens a popup
// with that people group's details (photo, country, population, religion,
// description) plus links to its full profile and prayer page, mirroring the
// research map's detail panel.
//
// Self-contained (uses the npm mapbox-gl directly, not the shared embed bundle):
// the country page is the only map on the page, so there's no second copy to
@ -30,6 +33,7 @@ const props = withDefaults(defineProps<{
const config = useRuntimeConfig()
const mapboxToken = (config.public as { mapboxToken?: string }).mapboxToken || ''
const prayBaseUrl = config.public.prayBaseUrl as string
const { t } = useI18n()
const container = ref<HTMLElement | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -54,8 +58,10 @@ function mapColors() {
}
}
const PIN_FIELDS = 'name,slug,country_code,rop1,religion,population,location_description,image_url,has_photo,latitude,longitude'
async function loadPins() {
const url = `${prayBaseUrl}/api/people-groups/list?fields=name,slug,country_code,latitude,longitude&lang=${props.languageCode}`
const url = `${prayBaseUrl}/api/people-groups/list?fields=${PIN_FIELDS}&lang=${props.languageCode}`
const res = await fetch(url)
const data = await res.json()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -66,10 +72,17 @@ async function loadPins() {
.map((p: any) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [parseFloat(p.longitude), parseFloat(p.latitude)] },
// Only JSON primitives — Mapbox feature properties can't hold nested objects.
properties: {
name: p.name,
slug: p.slug,
inCountry: p.country_code?.value === props.countryCode
inCountry: p.country_code?.value === props.countryCode,
country: p.country_code?.label ?? '',
rop1: p.rop1?.label ?? '',
religion: p.religion?.label ?? '',
population: p.population ?? 0,
description: p.location_description ?? '',
imageUrl: (p.has_photo && p.image_url) ? p.image_url : ''
}
}))
return { type: 'FeatureCollection' as const, features }
@ -80,6 +93,41 @@ function escapeHtml(s: string): string {
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
function formatPopulation(pop: number): string {
if (!pop || Number.isNaN(pop)) return ''
return Number(pop).toLocaleString(props.languageCode || 'en')
}
// Build the pin popup's inner HTML from a feature's properties. Mirrors the
// research map's detail panel: photo, name, country · people-group, the
// population/religion stats, a short description, and Pray / Full Profile links.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function popupHtml(p: any): string {
const name = escapeHtml(p.name || '')
const subtitle = [p.country, p.rop1].filter(Boolean).map(escapeHtml).join(' · ')
const pop = formatPopulation(p.population)
const profileUrl = `${props.researchUrl}${p.slug}`
const prayUrl = `${prayBaseUrl}/${p.slug}?source=doxalife`
const rows: string[] = []
if (pop) rows.push(`<div><dt>${escapeHtml(t('Population'))}</dt><dd>${pop}</dd></div>`)
if (p.religion) rows.push(`<div><dt>${escapeHtml(t('Religion'))}</dt><dd>${escapeHtml(p.religion)}</dd></div>`)
return `<div class="cm-popup">
${p.imageUrl ? `<img class="cm-popup__img" src="${escapeHtml(p.imageUrl)}" alt="${name}" loading="lazy">` : ''}
<div class="cm-popup__body">
<div class="cm-popup__name">${name}</div>
${subtitle ? `<div class="cm-popup__sub">${subtitle}</div>` : ''}
${rows.length ? `<dl class="cm-popup__stats">${rows.join('')}</dl>` : ''}
${p.description ? `<p class="cm-popup__desc">${escapeHtml(p.description)}</p>` : ''}
<div class="cm-popup__actions">
<a class="cm-popup__btn cm-popup__btn--primary" href="${prayUrl}">${escapeHtml(t('Pray'))}</a>
<a class="cm-popup__btn" href="${profileUrl}">${escapeHtml(t('Full Profile'))}</a>
</div>
</div>
</div>`
}
async function initMap() {
if (!container.value || !mapboxToken) return
@ -149,11 +197,9 @@ async function initMap() {
map.on('click', 'people-group-pins', (e: any) => {
const f = e.features?.[0]
if (!f) return
const { name, slug } = f.properties
const href = `${props.researchUrl}${slug}`
new mapboxgl.Popup({ closeButton: true, offset: 10 })
new mapboxgl.Popup({ closeButton: true, maxWidth: '264px', offset: 12 })
.setLngLat(f.geometry.coordinates)
.setHTML(`<strong>${escapeHtml(name)}</strong><br><a href="${href}">${escapeHtml(name)} &rarr;</a>`)
.setHTML(popupHtml(f.properties))
.addTo(map)
})
map.on('mouseenter', 'people-group-pins', () => { map.getCanvas().style.cursor = 'pointer' })
@ -187,3 +233,104 @@ onBeforeUnmount(() => { if (map) { map.remove(); map = null } })
}
}
</style>
<style>
/* Pin popup. Injected into the Mapbox popup as raw HTML, which scoped styles
can't reach, so these rules are global — namespaced under .cm-popup. */
.mapboxgl-popup-content:has(.cm-popup) {
padding: 12px;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
.cm-popup {
width: 240px;
}
/* Portrait thumbnail (4:5), centred — never a wide landscape crop. */
.cm-popup__img {
display: block;
width: 150px;
aspect-ratio: 4 / 5;
object-fit: cover;
border-radius: 8px;
margin: 0 auto 8px;
}
.cm-popup__body {
padding: 0;
}
.cm-popup__name {
font-weight: 700;
font-size: 15px;
line-height: 1.25;
color: var(--color-black, #1f1f1f);
}
.cm-popup__sub {
margin-top: 2px;
font-size: 12px;
color: var(--color-brand-lighter, #6d766e);
}
.cm-popup__stats {
margin: 8px 0 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.cm-popup__stats > div {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 12px;
}
.cm-popup__stats dt {
margin: 0;
color: var(--color-brand-lighter, #6d766e);
}
.cm-popup__stats dd {
margin: 0;
text-align: right;
font-weight: 600;
color: var(--color-black, #1f1f1f);
}
.cm-popup__desc {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: #555;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cm-popup__actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
.cm-popup__btn {
flex: 1;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
text-align: center;
text-decoration: none;
background: #ecf0f1;
color: var(--color-black, #1f1f1f);
}
.cm-popup__btn--primary {
background: var(--color-brand, #3b463d);
color: #fff;
}
</style>