Take main
# Conflicts: # public/doxa-maps/index.html
This commit is contained in:
commit
e941d642ce
58 changed files with 8241 additions and 117 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"
|
||||
}
|
||||
]
|
||||
93
internal/check-prayer-walk-planner.mjs
Normal file
93
internal/check-prayer-walk-planner.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-prayer-walk-planner.mjs — the Prayer Walk Planner must be a full-page tool that prints a
|
||||
* real walking map.
|
||||
*
|
||||
* WHAT THIS PROTECTS (the Driver: "the prayer walk planner is still a very tiny map, not expanding
|
||||
* the full page", and "the printed map is a ~10px sliver"):
|
||||
* 1. SIZE — the map canvas fills the page below the toolbar, standalone (its leaf) AND inside the
|
||||
* Research Gallery, at 1280x800 and 390x844. Measured before the fix: 747x76 in the gallery on
|
||||
* desktop and 390x0 on a phone, while the leaf filled — the gallery's inline host style fell
|
||||
* through onto the component's root and turned its flex column into a block.
|
||||
* 2. PRINT — a real zone in a real city (Balat, Istanbul) renders a print-resolution map (>=2000px
|
||||
* each way) that is not blank: many distinct colours, mostly non-white. The image is saved so a
|
||||
* human can READ it: streets, building footprints, the zone outline on top.
|
||||
* 3. LOCALE — ?lang=es gives a Spanish toolbar. 4. 0 page errors on the print page.
|
||||
*
|
||||
* Starts its own servers on OS-assigned ports; needs only a built tree. Run it inside the shared
|
||||
* browser locks, like every browser gate. OUT=<dir> chooses where the print PNG is written.
|
||||
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no playwright) · 75 NOT MEASURED (server identity).
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
if (!existsSync(resolve(ROOT, 'public/doxa-maps/prayer-walk-planner/prayer-walk-planner.js'))) { console.log('SKIP check-prayer-walk-planner: no built planner'); process.exit(2) }
|
||||
let chromium
|
||||
try { ({ chromium } = await import('playwright')) } catch { console.log('SKIP check-prayer-walk-planner: playwright not installed'); process.exit(2) }
|
||||
const OUT = process.env.OUT || '/tmp'
|
||||
const free = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
|
||||
const TP = await free(), HP = await free(), TREE = `http://127.0.0.1:${TP}/doxa-maps`
|
||||
const srv = spawn('node', ['internal/serve-strict.mjs', '--port', String(TP)], { cwd: ROOT, stdio: 'ignore' })
|
||||
const host = createServer((q, r) => { const l = new URL(q.url, 'http://x').searchParams.get('l') || 'en'; r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><html lang="${l}"><body style="margin:0"><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="prayer-walk-planner" style="height:100vh"></doxa-maps-spa></body></html>`) }).listen(HP, 'localhost')
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
|
||||
// ★ SERVER IDENTITY BY CONTENT, before a single measurement. A readiness check that only waits for
|
||||
// HTTP 200 can adopt a STALE server: if the spawn above died (EADDRINUSE; its output is ignored) and
|
||||
// something else answers on the port, every result would describe someone else's tree. So the served
|
||||
// manifest must be byte-identical to THIS checkout's. A mismatch is NOT MEASURED (exit 75), never a
|
||||
// pass or a fail — the same code the shared browser locks use for "busy, re-run later".
|
||||
{
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const disk = readFileSync(resolve(ROOT, 'public/doxa-maps/manifest.json'), 'utf8')
|
||||
let served = ''
|
||||
for (let i = 0; i < 20 && !served; i++) {
|
||||
try { const r = await fetch(`http://127.0.0.1:${TP}/doxa-maps/manifest.json`); if (r.ok) served = await r.text() } catch { /* not up yet */ }
|
||||
if (!served) await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
if (served !== disk) {
|
||||
console.log(`NOT MEASURED: the server on :${TP} is not serving this checkout's tree (manifest ${served ? 'differs' : 'unreachable'}). Re-run.`)
|
||||
try { srv.kill() } catch {} try { host.close() } catch {} process.exit(75)
|
||||
}
|
||||
}
|
||||
const b = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const findEl = () => { const f = (root) => { const e = root.querySelector('prayer-walk-planner'); if (e) return e; for (const n of root.querySelectorAll('*')) if (n.shadowRoot) { const x = f(n.shadowRoot); if (x) return x } return null }; return f(document) }
|
||||
const results = []
|
||||
try {
|
||||
// 1. SIZE, 4 cells
|
||||
for (const [W, H] of [[1280, 800], [390, 844]]) for (const where of ['leaf', 'gallery']) {
|
||||
const p = await b.newPage({ viewport: { width: W, height: H } }); const errs = []
|
||||
p.on('pageerror', e => errs.push(String(e).slice(0, 140)))
|
||||
await p.goto(where === 'leaf' ? `${TREE}/prayer-walk-planner/prayer-walk-planner/index.html` : `http://localhost:${HP}/`)
|
||||
await p.waitForTimeout(9000)
|
||||
const m = await p.evaluate(`(() => { const el = (${findEl.toString()})(); if (!el) return null; const sr = el.shadowRoot, r = n => n ? Math.round(n.getBoundingClientRect().height) : 0; const pw = sr.querySelector('.pwp'); return { host: r(el), pwp: r(pw), toolbar: r(sr.querySelector('.pwp-toolbar')), canvas: r(sr.querySelector('canvas')), pwpDisplay: pw && getComputedStyle(pw).display, pwpDir: pw && getComputedStyle(pw).flexDirection } })()`)
|
||||
const ok = m && m.canvas >= (m.host - m.toolbar - 60) && m.canvas > 300
|
||||
results.push({ check: `size ${where} ${W}x${H}`, ok, m, errs })
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} size ${where} ${W}x${H}: ${JSON.stringify(m)} ${errs.length ? 'ERR ' + errs.join(' | ') : '0 errors'}`)
|
||||
await p.close()
|
||||
}
|
||||
// 2. PRINT MAP of a real zone (Balat, Istanbul: dense houses on small streets) + 4. LOCALE (es)
|
||||
const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); const errs = []
|
||||
p.on('pageerror', e => errs.push(String(e).slice(0, 140)))
|
||||
await p.goto(`${TREE}/prayer-walk-planner/prayer-walk-planner/index.html?lang=es`)
|
||||
await p.waitForTimeout(8000)
|
||||
const ui = await p.evaluate(`(() => { const el = (${findEl.toString()})(); return [...el.shadowRoot.querySelectorAll('.pwp-btn')].map(b => b.textContent.trim()).slice(0, 4) })()`)
|
||||
const lok = ui.some(t => /Dibujar|Seleccionar/.test(t))
|
||||
console.log(`${lok ? 'PASS' : 'FAIL'} locale es toolbar: ${JSON.stringify(ui)}`); results.push({ check: 'locale es', ok: lok })
|
||||
const zone = [[28.9468, 41.0307], [28.9502, 41.0318], [28.9519, 41.0297], [28.9487, 41.0283]]
|
||||
const shot = await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const id = el.addPolygon(${JSON.stringify(zone)}, 'Balat'); const url = await el.renderZoneMap(id); return url })()`)
|
||||
if (shot) {
|
||||
writeFileSync(`${OUT}/pwp-print-balat.png`, Buffer.from(shot.split(',')[1], 'base64'))
|
||||
const st = await p.evaluate(async (url) => { const img = new Image(); img.src = url; await img.decode(); const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; const x = c.getContext('2d'); x.drawImage(img, 0, 0); const d = x.getImageData(0, 0, c.width, c.height).data; const cols = new Set(); let nonBg = 0; for (let i = 0; i < d.length; i += 4 * 97) { cols.add((d[i] >> 4) + ',' + (d[i + 1] >> 4) + ',' + (d[i + 2] >> 4)); if (!(d[i] > 240 && d[i + 1] > 240 && d[i + 2] > 240)) nonBg++ } return { w: img.naturalWidth, h: img.naturalHeight, distinctColours: cols.size, nonWhiteShare: +(nonBg / (d.length / 4 / 97)).toFixed(2) } }, shot)
|
||||
const pok = st.w >= 2000 && st.h >= 2000 && st.distinctColours > 40 && st.nonWhiteShare > 0.3
|
||||
console.log(`${pok ? 'PASS' : 'FAIL'} print map: ${JSON.stringify(st)} -> ${OUT}/pwp-print-balat.png`); results.push({ check: 'print map', ok: pok, st })
|
||||
} else { console.log('FAIL print map: renderZoneMap returned null'); results.push({ check: 'print map', ok: false }) }
|
||||
console.log(`${errs.length ? 'FAIL' : 'PASS'} page errors on print page: ${errs.length} ${errs.join(' | ')}`); results.push({ check: 'errors', ok: !errs.length })
|
||||
await p.close()
|
||||
} finally { await b.close(); host.close(); srv.kill() }
|
||||
const bad = results.filter(r => !r.ok).length
|
||||
console.log(bad ? `check-prayer-walk-planner: FAIL (${bad})` : 'check-prayer-walk-planner: PASS — the map fills the page in the leaf and the gallery at desktop and phone, and prints a street-level map')
|
||||
process.exit(bad ? 1 : 0)
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
*
|
||||
* PRECONDITION: a built tree in public/doxa-maps (this starts its own servers; no dev server).
|
||||
* Usage: node internal/check-research-gallery-embed.mjs [--mode js|iframe] [--only id,id] [--provider maplibre]
|
||||
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no browser)
|
||||
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no browser) · 75 NOT MEASURED (server identity)
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
|
@ -123,6 +123,25 @@ const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/h
|
|||
const cleanup = () => { try { tree.kill() } catch {} try { host.close() } catch {} }
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
|
||||
// ★ SERVER IDENTITY BY CONTENT, before a single measurement. A readiness check that only waits for
|
||||
// HTTP 200 can adopt a STALE server: if the spawn above died (EADDRINUSE; its output is ignored) and
|
||||
// something else answers on the port, every result would describe someone else's tree. So the served
|
||||
// manifest must be byte-identical to THIS checkout's. A mismatch is NOT MEASURED (exit 75), never a
|
||||
// pass or a fail — the same code the shared browser locks use for "busy, re-run later".
|
||||
{
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const disk = readFileSync(resolve(ROOT, 'public/doxa-maps/manifest.json'), 'utf8')
|
||||
let served = ''
|
||||
for (let i = 0; i < 20 && !served; i++) {
|
||||
try { const r = await fetch(`http://127.0.0.1:${TREE_PORT}/doxa-maps/manifest.json`); if (r.ok) served = await r.text() } catch { /* not up yet */ }
|
||||
if (!served) await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
if (served !== disk) {
|
||||
console.log(`NOT MEASURED: the server on :${TREE_PORT} is not serving this checkout's tree (manifest ${served ? 'differs' : 'unreachable'}). Re-run.`)
|
||||
cleanup(); process.exit(75)
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
|
||||
const fails = []
|
||||
const checked = {} // mode -> tabs actually driven; printed, and 0 is a FAIL (see the summary)
|
||||
|
|
@ -277,8 +296,13 @@ try {
|
|||
const before = await count.count()
|
||||
const facet = pane().locator('[data-facet="wagf_region"][data-value]:not(.dash-only)').first()
|
||||
const val = await facet.getAttribute('data-value', { timeout: 20000 })
|
||||
// The SUBJECT carries its own expected answer: the facet row prints how many groups it holds
|
||||
// (e.g. "Asia 1,309"). Asserting the grid shows EXACTLY that — not merely "Showing <digits>" —
|
||||
// fails on "Showing 0" (a subject with no data, or a broken link) AND on an unfiltered grid.
|
||||
const rowNums = ((await facet.innerText()).match(/\d[\d,]*/g) || []).map(x => Number(x.replace(/,/g, '')))
|
||||
const expect = rowNums.length ? rowNums[rowNums.length - 1] : 0
|
||||
await facet.click()
|
||||
return poll(async () => { const n = await count.count(); const txt = n ? await count.first().innerText() : ''; return { ok: before === 0 && /Showing\s+[\d,]+/.test(txt), why: `facet wagf_region=${val} → grid count line ${n ? '"' + txt.trim().slice(0, 40) + '"' : 'still absent'}` } })
|
||||
return poll(async () => { const n = await count.count(); const txt = n ? await count.first().innerText() : ''; const got = Number(((txt.match(/Showing\s+([\d,]+)/) || [])[1] || '0').replace(/,/g, '')); return { ok: before === 0 && expect > 0 && got === expect, why: `facet wagf_region=${val} (row says ${expect}) → grid count line ${n ? '"' + txt.trim().slice(0, 40) + '"' : 'still absent'}` } })
|
||||
},
|
||||
'research-map-plus-grid': async () => {
|
||||
const total = pane().locator('.repel').first()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1440,6 +1438,14 @@ function main() {
|
|||
'people-groups/people-groups-slim.json',
|
||||
// …and its compact cut (build-points.mjs): what all-people-groups actually fetches, 0.74 MB.
|
||||
'people-groups/people-groups-points.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',
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@
|
|||
"check:gallery-embed:js": "node internal/check-research-gallery-embed.mjs --mode js",
|
||||
"check:gallery-embed:iframe": "node internal/check-research-gallery-embed.mjs --mode iframe",
|
||||
"check:gallery-embed:maplibre": "node internal/check-research-gallery-embed.mjs --provider maplibre",
|
||||
"check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs",
|
||||
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs",
|
||||
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
|
||||
"check:backlog-refs": "node internal/check-backlog-refs.mjs",
|
||||
|
|
@ -286,6 +287,7 @@
|
|||
"check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.",
|
||||
"check:map-unmount-race": "browser-free, sub-second: runs the shipped ensureEngineCSS with a null element (what an unmounted pane hands it) against a copy with its guard stripped; the guarded arm must resolve and the stripped arm must throw \"reading getRootNode\". By gospel-plugin, adopted by studio.",
|
||||
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged.",
|
||||
"check:prayer-walk-planner": "the Prayer Walk Planner is a full-page tool that prints a real walking map: the map canvas fills the page below the toolbar in its own leaf AND inside the Research Gallery at 1280x800 and 390x844 (the gallery once collapsed it to 76px / 0px); a real zone (Balat, Istanbul) renders a >=2000px print map that is not blank, saved to OUT=<dir> so a human can read it; ?lang=es gives a Spanish toolbar; 0 page errors. Run inside the shared browser locks. Exit 2 = SKIP.",
|
||||
"check:composed-filters": "CELL C — the gospel map composed above its own tables. Asserts the composite keeps the reader INSIDE it: \"Every resource in <language>\" must be a <button> that filters in place, not an <a> that navigates away and throws out the map the reader just positioned. Checks the tag, that the URL did not change, that the table NARROWED (row count down but not to zero), that the pin count survived, and that nothing 4xx'd. Picks a tier-2 pin from the live source — the first version opened pin 0, a group with nothing found, and failed a working composite on an empty table. Browser gate: run under both rendezvous locks, product lock first.",
|
||||
"check:vision-card-numbers": "the DOXA Vision Card must show the numbers the DATA gives: independently recomputes all four funnel steps (and DOXA-in-frontier) from people_groups.csv + the saved DOXA list, compares with vision-funnel.json, and with --url also with the numbers painted in the card. A typed-in number, a stale funnel file or a rendering bug fails it.",
|
||||
"check:map-embed-cells": "PRODUCTION-MATRIX cells B (iframe) and C (script embed) for the seven Research Gallery maps on a genuinely foreign origin (tree on 127.0.0.1, partner page on localhost). C passes the HOST a distinct token and fails if any Mapbox request uses another. --only bundle/profile,… --cell B|C."
|
||||
|
|
|
|||
|
|
@ -725,6 +725,13 @@ function whenTagDefined(tag, ms) {
|
|||
|
||||
export default defineComponent({
|
||||
name: 'DoxaMapsSpa',
|
||||
// THE HOST'S ATTRIBUTES STAY ON THE HOST. This component is the custom-element root, so Vue would
|
||||
// otherwise copy every non-prop attribute of <doxa-maps-spa> — a host's style="…", class, data-* —
|
||||
// onto VerticalTabsLayout's .vtl. An inline display there beats the layout's own rules; that is
|
||||
// exactly how the Prayer Walk Planner's map collapsed to 76px when this gallery styled its host
|
||||
// (measured 2026-09-25). .vtl survives today only because it declares display:grid !important.
|
||||
// Everything the layout needs (dir, lang, labels, …) is passed explicitly below.
|
||||
inheritAttrs: false,
|
||||
components: { VerticalTabsLayout },
|
||||
// profile-config props the standalone leaf page + gallery hand every element. treeBase lets a
|
||||
// host pin the tree root explicitly; the rest are accepted so the element is a well-behaved
|
||||
|
|
|
|||
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
|
||||
}
|
||||
464
plugin-designs/pplr-data-maps/src/utils/pplrMap.js
Normal file
464
plugin-designs/pplr-data-maps/src/utils/pplrMap.js
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* 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']
|
||||
// PPLR renamed these fields upstream (measured 2026-09-25: GENCalpha3 is now GENCAlpha3, CtryNm is
|
||||
// Name, ctryNmDisp is DisplayName). The query asks for the NEW names and each feature is given the
|
||||
// OLD ones back, so every view that reads f.properties.GENCalpha3 keeps working unchanged.
|
||||
const COUNTRY_FS_RENAMED = { GENCalpha3: 'GENCAlpha3', ISOalpha2: 'ISOAlpha2', CtryNm: 'Name', ctryNmDisp: 'DisplayName' }
|
||||
|
||||
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: Object.values(COUNTRY_FS_RENAMED).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()
|
||||
// ArcGIS reports a bad query as HTTP 200 with { error }. Read as "no features", that
|
||||
// silently removed every country polygon from every view the day the schema changed.
|
||||
if (data.error) throw new Error(`PPLR aCountry: ${(data.error.details || []).join('; ') || data.error.message || data.error.code}`)
|
||||
for (const f of data.features || []) {
|
||||
const p = f.properties || (f.properties = {})
|
||||
for (const [oldName, newName] of Object.entries(COUNTRY_FS_RENAMED)) if (p[oldName] == null) p[oldName] = p[newName]
|
||||
features.push(f)
|
||||
}
|
||||
// f=geojson reports the flag under data.properties, not at the top level, so the old
|
||||
// check stopped after the first page: 200 of 251 countries. A full page means there may be more.
|
||||
exceeded = data.exceededTransferLimit === true || data.properties?.exceededTransferLimit === true
|
||||
|| (data.features?.length || 0) >= 200
|
||||
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)
|
||||
}
|
||||
}
|
||||
190
plugin-designs/pplr-data-maps/src/views/pplr-coverage.vue
Normal file
190
plugin-designs/pplr-data-maps/src/views/pplr-coverage.vue
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<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)
|
||||
// Verification handle (the gospel-resources __grState contract): how many country
|
||||
// polygons found a coverage row. A key mismatch paints every country the "no data" grey.
|
||||
if (typeof window !== 'undefined') window.__pplrCoverage = () => ({
|
||||
polygons: fc.features.length,
|
||||
matched: fc.features.filter(f => f.properties.avgFilled != null).length,
|
||||
sampleUnmatched: fc.features.filter(f => f.properties.avgFilled == null).slice(0, 5).map(f => f.properties.GENCalpha3),
|
||||
rendered: map.value?.getLayer('choropleth') ? map.value.queryRenderedFeatures({ layers: ['choropleth'] }).length : 0,
|
||||
})
|
||||
|
||||
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 (all ${[...countryStats.values()].reduce((n, c) => n + c.pgCount, 0).toLocaleString('en-US')} PG-in-country rows)`
|
||||
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>
|
||||
|
|
@ -51,12 +51,55 @@ import { useMapInstance } from '@map/composables/useMapInstance.js'
|
|||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { openPrintFrame } from '@map/composables/printInPlace.js'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
import { resolveNamedStyle } from '@map/styles/registry.js'
|
||||
import { translate, LOCALES } from './src/strings.js'
|
||||
|
||||
// ─── The host's attributes stay on the host ───────────────────────────────────
|
||||
// ★ THE "VERY TINY MAP" (Driver; measured 2026-09-25: 747x76 in the Research Gallery at 1280x800,
|
||||
// and 390x0 on a phone, while the standalone leaf filled 1003x739). This SFC is mounted AS the
|
||||
// custom-element root (index.js — that is what puts the client API on the element), so Vue's
|
||||
// attribute fallthrough copied every non-prop attribute of <prayer-walk-planner> onto the root
|
||||
// .pwp — including the gallery's inline style="display:block;position:absolute;inset:0;…". That
|
||||
// inline display:block beat .pwp{display:flex}: the column stopped being a flex container, the body
|
||||
// fell back to the height of the zone list's text, and the map got whatever was left. The leaf never
|
||||
// showed it because its page styles the element through a stylesheet, not a style attribute.
|
||||
// Host attributes (style, tree-base, profile-config) describe the HOST box; they must not restyle
|
||||
// the component inside it. profile-config still reaches us — it is a declared prop.
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
instanceId: { type: String, default: '' }
|
||||
instanceId: { type: String, default: '' },
|
||||
// A host (the Research Gallery, a partner page) hands the language in profile-config, the same
|
||||
// contract every other plugin reads; `language` is the plain-attribute form.
|
||||
profileConfig: { type: [String, Object], default: '' },
|
||||
language: { type: String, default: '' }
|
||||
})
|
||||
|
||||
// ─── Language ─────────────────────────────────────────────────────────────────
|
||||
// Ladder, most authoritative first: `language` attribute → profile-config locale/lang (what the
|
||||
// gallery forwards) → ?lang= / ?locale= → <html lang> → English. Never the browser's language:
|
||||
// the page the planner sits on decides, as for every map in this repo.
|
||||
const cfg = computed(() => {
|
||||
let pc = props.profileConfig
|
||||
if (typeof pc === 'string') { try { pc = pc ? JSON.parse(pc) : {} } catch { pc = {} } }
|
||||
return pc && typeof pc === 'object' ? pc : {}
|
||||
})
|
||||
function hostLocale() {
|
||||
try {
|
||||
const q = new URLSearchParams(location.search)
|
||||
const u = (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
|
||||
if (LOCALES.includes(u)) return u
|
||||
} catch (_) { /* no window */ }
|
||||
try { return (document.documentElement.lang || '').split('-')[0].toLowerCase() } catch (_) { return '' }
|
||||
}
|
||||
const uiLocale = computed(() => {
|
||||
const want = String(props.language || cfg.value.locale || cfg.value.lang || hostLocale() || 'en').split('-')[0].toLowerCase()
|
||||
return LOCALES.includes(want) ? want : 'en'
|
||||
})
|
||||
const uiDir = computed(() => (uiLocale.value === 'ar' ? 'rtl' : 'ltr'))
|
||||
const tr = (key, params) => translate(uiLocale.value, key, params)
|
||||
|
||||
const INSTANCE_ID = props.instanceId || ('pwp-' + Math.random().toString(36).slice(2, 10))
|
||||
const STORAGE_KEY = 'prayer-walk-planner:v1:' + INSTANCE_ID
|
||||
|
||||
|
|
@ -80,7 +123,7 @@ const SHARE_EMBED_PATH = 'prayer-walk-planner/prayer-walk-planner/index.html'
|
|||
// link and discovers otherwise only after sending it. One line in the status strip that
|
||||
// already exists, rather than a new surface.
|
||||
function noteShareScope() {
|
||||
const msg = 'This link shares the planner. Your drawn zones stay on this device.'
|
||||
const msg = tr('shareScope')
|
||||
statusMsg.value = msg
|
||||
setTimeout(() => { if (statusMsg.value === msg) statusMsg.value = '' }, 6000)
|
||||
}
|
||||
|
|
@ -229,7 +272,7 @@ function startDrawing() {
|
|||
if (drawing.value) return
|
||||
drawing.value = true
|
||||
drawPoints.value = []
|
||||
statusMsg.value = 'Click to place points. Double-click (or Finish) to close the polygon.'
|
||||
statusMsg.value = tr('clickToPlace')
|
||||
if (map.value) map.value.getCanvas().style.cursor = 'crosshair'
|
||||
}
|
||||
function stopDrawing() {
|
||||
|
|
@ -241,17 +284,18 @@ function stopDrawing() {
|
|||
}
|
||||
function finishDrawing() {
|
||||
if (drawPoints.value.length < 3) {
|
||||
statusMsg.value = 'Need at least 3 points to make a polygon.'
|
||||
statusMsg.value = tr('need3')
|
||||
return
|
||||
}
|
||||
const coords = ringClosed(drawPoints.value)
|
||||
const title = 'Zone ' + (polygons.length + 1)
|
||||
const title = tr('zone', { n: polygons.length + 1 })
|
||||
addPolygonInternal(coords, title)
|
||||
drawing.value = false
|
||||
drawPoints.value = []
|
||||
if (map.value) map.value.getCanvas().style.cursor = ''
|
||||
statusMsg.value = 'Polygon added.'
|
||||
setTimeout(() => { if (statusMsg.value === 'Polygon added.') statusMsg.value = '' }, 2000)
|
||||
const added = tr('added')
|
||||
statusMsg.value = added
|
||||
setTimeout(() => { if (statusMsg.value === added) statusMsg.value = '' }, 2000)
|
||||
}
|
||||
|
||||
// ─── Polygon CRUD — the shared core both the UI buttons and the client-side API call ──
|
||||
|
|
@ -262,7 +306,7 @@ function addPolygonInternal(coords, title) {
|
|||
const id = 'zone-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7)
|
||||
polygons.push({
|
||||
id,
|
||||
title: title || ('Zone ' + (polygons.length + 1)),
|
||||
title: title || tr('zone', { n: polygons.length + 1 }),
|
||||
coords: ringClosed(coords),
|
||||
createdAt: new Date().toISOString(),
|
||||
selected: false
|
||||
|
|
@ -320,99 +364,170 @@ function staticSnapshotUrl(coords) {
|
|||
return null
|
||||
}
|
||||
|
||||
// The print map's page shape and resolution. 1200x1344 CSS px at pixelRatio 2 = a 2400x2688
|
||||
// image: about 320 dpi across the 7.5in x 8.4in letter-portrait map frame, so house footprints
|
||||
// and minor streets stay crisp on paper. The aspect IS the frame's (.snapshot on the sheet); 8.4in
|
||||
// is what fills the page under the title band and above the charge/corners/credit lines.
|
||||
const PRINT_W = 1200, PRINT_H = 1344, PRINT_PIXEL_RATIO = 2
|
||||
|
||||
// Street and place labels in the reader's language where OSM has it, else the local name.
|
||||
// OpenFreeMap's styles label with name:latin (+ name:nonlatin); `name:<lang>` is the OSM tag for
|
||||
// a translated name. English keeps the style's own expression untouched.
|
||||
function localizeBasemapLabels(m, lang) {
|
||||
if (!m || !lang || lang === 'en') return
|
||||
try {
|
||||
for (const layer of m.getStyle().layers || []) {
|
||||
if (layer.type !== 'symbol' || !layer.layout || !layer.layout['text-field']) continue
|
||||
m.setLayoutProperty(layer.id, 'text-field', ['coalesce', ['get', 'name:' + lang], ['get', 'name:latin'], ['get', 'name']])
|
||||
}
|
||||
} catch (_) { /* labels are a nicety; the map is still correct in the style's own language */ }
|
||||
}
|
||||
|
||||
// Render ONE zone as a print-resolution PNG data URL in a temporary, off-screen MapLibre map.
|
||||
// Returns null when there is no engine (no WebGL) — the sheet then prints without a map.
|
||||
// ★ ARABIC (and every RTL script) NEEDS THE RTL TEXT PLUGIN, and MapLibre never had it here.
|
||||
// library/maps/rtl-text.js installs it for MAPBOX only (window.mapboxgl, from api.mapbox.com), so a
|
||||
// MapLibre map drew Arabic place names as disconnected, reversed glyphs — measured on the printed
|
||||
// Arabic sheet, 2026-09-25. This installs the same plugin for MapLibre from a keyless CDN and, for
|
||||
// the print map, WAITS for it: a lazily-loaded plugin can still be loading when the frame is taken.
|
||||
const RTL_PLUGIN_URL = 'https://cdn.jsdelivr.net/npm/@mapbox/mapbox-gl-rtl-text@0.3.0/dist/mapbox-gl-rtl-text.js'
|
||||
async function ensureRtlText(gl) {
|
||||
try {
|
||||
const status = gl.getRTLTextPluginStatus ? gl.getRTLTextPluginStatus() : 'unavailable'
|
||||
if (status === 'unavailable') await gl.setRTLTextPlugin(RTL_PLUGIN_URL, false)
|
||||
for (let i = 0; i < 50 && gl.getRTLTextPluginStatus && gl.getRTLTextPluginStatus() !== 'loaded'; i++) {
|
||||
if (gl.getRTLTextPluginStatus() === 'error') break
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
} catch (_) { /* shaping is a nicety; the map still renders */ }
|
||||
}
|
||||
|
||||
async function renderPrintMap(zone) {
|
||||
const gl = typeof window !== 'undefined' && window.maplibregl
|
||||
if (!gl) return null
|
||||
await ensureRtlText(gl)
|
||||
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity
|
||||
for (const [lng, lat] of zone.coords) {
|
||||
if (lng < minLng) minLng = lng; if (lng > maxLng) maxLng = lng
|
||||
if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat
|
||||
}
|
||||
// Off-screen but LAID OUT: a display:none container has no size and renders nothing.
|
||||
const box = document.createElement('div')
|
||||
box.setAttribute('aria-hidden', 'true')
|
||||
box.style.cssText = `position:fixed;left:-${PRINT_W * 3}px;top:0;width:${PRINT_W}px;height:${PRINT_H}px;pointer-events:none;`
|
||||
document.body.appendChild(box)
|
||||
let pm = null
|
||||
try {
|
||||
pm = new gl.Map({
|
||||
container: box,
|
||||
style: resolveNamedStyle('daylight', 'maplibre').style, // OpenFreeMap liberty: roads + buildings, keyless
|
||||
bounds: [[minLng, minLat], [maxLng, maxLat]],
|
||||
fitBoundsOptions: { padding: 90, maxZoom: 18 },
|
||||
pixelRatio: PRINT_PIXEL_RATIO,
|
||||
interactive: false,
|
||||
attributionControl: false, // the credit is printed as text on the sheet instead
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: { preserveDrawingBuffer: true },
|
||||
preserveDrawingBuffer: true // pre-v5 spelling of the same option; harmless on v5
|
||||
})
|
||||
await new Promise((res, rej) => { pm.once('load', res); pm.once('error', (e) => rej(e && e.error || e)); setTimeout(() => rej(new Error('style load timeout')), 30000) })
|
||||
localizeBasemapLabels(pm, uiLocale.value)
|
||||
// The zone on top: a light wash so the streets inside stay readable, a white casing and a
|
||||
// strong outline so the boundary survives a grey-scale office printer.
|
||||
pm.addSource('pwp-print-zone', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [ringClosed(zone.coords)] } } })
|
||||
pm.addLayer({ id: 'pwp-print-fill', type: 'fill', source: 'pwp-print-zone', paint: { 'fill-color': '#92b195', 'fill-opacity': 0.12 } })
|
||||
pm.addLayer({ id: 'pwp-print-casing', type: 'line', source: 'pwp-print-zone', paint: { 'line-color': '#ffffff', 'line-width': 9 } })
|
||||
pm.addLayer({ id: 'pwp-print-line', type: 'line', source: 'pwp-print-zone', paint: { 'line-color': '#1f5f3a', 'line-width': 5 } })
|
||||
// Wait until every tile for this view has loaded AND painted.
|
||||
await new Promise((res) => { const done = () => { if (pm.loaded() && pm.areTilesLoaded()) res(); else pm.once('idle', done) }; pm.once('idle', done); setTimeout(res, 25000) })
|
||||
return await new Promise((res) => {
|
||||
pm.once('render', () => { try { res(pm.getCanvas().toDataURL('image/png')) } catch (_) { res(null) } })
|
||||
pm.triggerRepaint()
|
||||
})
|
||||
} finally {
|
||||
try { pm && pm.remove() } catch (_) { /* release the GL context either way */ }
|
||||
box.remove()
|
||||
}
|
||||
}
|
||||
|
||||
async function printInternal(ids) {
|
||||
const targets = ids && ids.length
|
||||
? polygons.filter(p => ids.includes(p.id))
|
||||
: polygons.filter(p => p.selected)
|
||||
if (targets.length === 0) {
|
||||
statusMsg.value = 'Select at least one polygon to print.'
|
||||
statusMsg.value = tr('selectOne')
|
||||
return { opened: false, count: 0 }
|
||||
}
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]))
|
||||
|
||||
// ── PER-ZONE POSTER SNAPSHOT (card BUILD-PWP-POSTERS-FROM-THE-SHARED-POSTER-LIBRARY) ──
|
||||
// Driver: "use the same poster generation software that the UPG 100 list used ... for
|
||||
// generating prayer walking maps." The UPG generator (library/scripts/posters/generate.mjs)
|
||||
// is BUILD-TIME over 28 fixed zones; walk zones are drawn at RUNTIME, so this is its runtime
|
||||
// sibling: same design language (portrait sheet, title band, framed map, honest credit --
|
||||
// the 839b0c4 poster rulings), map image sourced from the live keyless MapLibre canvas.
|
||||
// For EACH zone the camera fits that zone's bounds and waits for idle, so the snapshot is a
|
||||
// real WALKING-SCALE street map with the zone polygon painted on it -- not the whole-world
|
||||
// viewport the old single shared snapshot cropped from. The camera is restored afterwards.
|
||||
// ── PER-ZONE PRINT MAP: a PAGE-SHAPED, PRINT-RESOLUTION render of each zone ──────────────
|
||||
// The Driver: "the printed map is a ~10px sliver". Cause, measured 2026-09-25: print used to
|
||||
// fitBounds the LIVE map and snapshot its canvas, so the printed image was exactly the size
|
||||
// and shape of whatever the live map happened to be on screen: 747x76 inside the Research
|
||||
// Gallery, which is the sliver. Even full-page it was screen-resolution and screen-shaped.
|
||||
// Now each zone renders in its OWN temporary MapLibre instance: page-shaped, pixelRatio 2,
|
||||
// keyless OpenFreeMap vector tiles with roads and building footprints, the zone outline on top.
|
||||
// It never touches the reader's live map, and it is destroyed as soon as its pixels are taken,
|
||||
// so it costs one WebGL context for about a second.
|
||||
statusMsg.value = tr('preparing')
|
||||
const shots = {}
|
||||
if (map.value) {
|
||||
const m = map.value
|
||||
const prevCenter = m.getCenter(), prevZoom = m.getZoom()
|
||||
const idle = () => new Promise((res) => { m.once('idle', res); setTimeout(res, 4000) })
|
||||
for (const p of targets) {
|
||||
try {
|
||||
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity
|
||||
for (const [lng, lat] of p.coords) {
|
||||
if (lng < minLng) minLng = lng; if (lng > maxLng) maxLng = lng
|
||||
if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat
|
||||
}
|
||||
m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 72, duration: 0, maxZoom: 17 })
|
||||
await idle()
|
||||
// ★ CAPTURE INSIDE A RENDER FRAME. MapLibre does not preserve the WebGL drawing buffer
|
||||
// (preserveDrawingBuffer:false), so toDataURL() outside a frame returns a BLANK white
|
||||
// image that still reads as a valid data URL -- measured: the first poster shipped a
|
||||
// white rectangle while hasSnapshot said true. triggerRepaint + once('render') reads
|
||||
// the pixels while the buffer is live; the standard MapLibre snapshot pattern.
|
||||
shots[p.id] = await new Promise((res) => {
|
||||
m.once('render', () => { try { res(m.getCanvas().toDataURL('image/png')) } catch (_) { res(null) } })
|
||||
m.triggerRepaint()
|
||||
})
|
||||
if (!shots[p.id]) delete shots[p.id]
|
||||
} catch (_) { /* tainted/unavailable canvas -- the sheet still prints without the map */ }
|
||||
for (const p of targets) {
|
||||
try { shots[p.id] = await renderPrintMap(p) } catch (e) {
|
||||
console.error('[prayer-walk-planner] print map failed for', p.title, e)
|
||||
}
|
||||
try { m.jumpTo({ center: prevCenter, zoom: prevZoom }) } catch (_) { /* restore is best-effort */ }
|
||||
if (!shots[p.id]) delete shots[p.id]
|
||||
}
|
||||
if (statusMsg.value === tr('preparing')) statusMsg.value = ''
|
||||
|
||||
const dateStr = esc(new Date().toLocaleDateString(uiLocale.value))
|
||||
// Coordinates are Latin digits and N/S/E/W inside possibly-RTL prose: isolate each one LTR, or the
|
||||
// bidi algorithm reorders it (measured on the Arabic sheet: "°41.03024E28.94888° ,N").
|
||||
const ltr = (t) => '<bdi dir="ltr">' + esc(t) + '</bdi>'
|
||||
const pages = targets.map((p) => {
|
||||
const [clng, clat] = polygonCentroid(p.coords)
|
||||
const pointList = p.coords.slice(0, -1).map(([lng, lat]) => formatCoord(lng, lat)).join(' · ')
|
||||
const pointList = p.coords.slice(0, -1).map(([lng, lat]) => ltr(formatCoord(lng, lat))).join(' · ')
|
||||
const img = shots[p.id]
|
||||
return (
|
||||
'<section class="zone-page">' +
|
||||
'<header class="band"><h1>' + esc(p.title) + '</h1>' +
|
||||
'<p class="kicker">Prayer walking map · centre ' + formatCoord(clng, clat) + '</p></header>' +
|
||||
(img ? '<figure class="mapframe"><img class="snapshot" src="' + img + '" alt="Walking map of ' + esc(p.title) + '"></figure>' : '') +
|
||||
'<p class="charge">Walk the streets of this zone and pray for the people who live, work and worship along them.</p>' +
|
||||
'<h2>Zone corners</h2>' +
|
||||
'<p class="points">' + pointList + '</p>' +
|
||||
'<p class="footer">Prayer Walk Planner · generated ' + esc(new Date().toLocaleDateString()) +
|
||||
' · Map © OpenFreeMap · Data © OpenStreetMap contributors</p>' +
|
||||
'<p class="kicker">' + esc(tr('kicker', { c: '\u0000' })).replace('\u0000', ltr(formatCoord(clng, clat))) + '</p></header>' +
|
||||
(img ? '<figure class="mapframe"><img class="snapshot" src="' + img + '" alt="' + esc(tr('mapAlt', { title: p.title })) + '"></figure>' : '') +
|
||||
'<p class="charge">' + esc(tr('charge')) + '</p>' +
|
||||
'<p class="points"><b>' + esc(tr('corners')) + ':</b> ' + pointList + '</p>' +
|
||||
'<p class="footer">' + esc(tr('footer', { date: '\u0000' })).replace('\u0000', dateStr) + '</p>' +
|
||||
'</section>'
|
||||
)
|
||||
}).join('')
|
||||
|
||||
const title = 'Prayer Walking Plan — ' + targets.length + (targets.length === 1 ? ' zone' : ' zones')
|
||||
const title = targets.length === 1 ? tr('docTitleOne') : tr('docTitleMany', { n: targets.length })
|
||||
const w = openPrintFrame(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
|
||||
'<!DOCTYPE html><html lang="' + uiLocale.value + '" dir="' + uiDir.value + '"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
|
||||
'<style>' +
|
||||
// letter portrait explicitly -- size:auto lets the browser pick landscape (the same
|
||||
// defect the poster path fixed in 839b0c4); a walking sheet is a portrait document.
|
||||
'@page{size:letter portrait;margin:12mm}' +
|
||||
'html,body{margin:0;background:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#3b463d}' +
|
||||
'.zone-page{page-break-after:always;max-width:720px;margin:0 auto;padding:10px 8px}' +
|
||||
// ONE ZONE = ONE PAGE a person carries on the walk: the section never splits, and the map
|
||||
// takes the page. 8.4in tall at the full 7.5in printable width, the same 1200:1344 shape
|
||||
// the print map is rendered at (PRINT_W/PRINT_H), so nothing is cropped or letterboxed.
|
||||
'.zone-page{page-break-after:always;break-inside:avoid;margin:0 auto}' +
|
||||
'.zone-page:last-child{page-break-after:auto}' +
|
||||
// The poster design language: a dark-green title band like the UPG poster header,
|
||||
// the map in a framed figure, quiet uppercase kickers -- material light palette only.
|
||||
'.band{background:#3b463d;color:#fff;border-radius:8px;padding:14px 18px;margin:0 0 12px}' +
|
||||
'h1{font-size:26px;margin:0;letter-spacing:-0.01em}' +
|
||||
'.kicker{margin:4px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#d1d4cd}' +
|
||||
'.mapframe{margin:0;border:1px solid #d1d4cd;border-radius:6px;overflow:hidden}' +
|
||||
'.snapshot{display:block;width:100%;max-height:520px;object-fit:cover}' +
|
||||
'.charge{font-size:14px;line-height:1.6;color:#3b463d;margin:12px 0 0}' +
|
||||
'h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6d766e;margin:16px 0 4px}' +
|
||||
'.points{font-size:12px;line-height:1.7;word-spacing:2px;color:#6d766e}' +
|
||||
'.footer{margin-top:18px;font-size:10px;color:#6d766e;border-top:1px solid #d1d4cd;padding-top:8px}' +
|
||||
'.band{background:#3b463d;color:#fff;border-radius:6px;padding:8px 14px;margin:0 0 6px;-webkit-print-color-adjust:exact;print-color-adjust:exact}' +
|
||||
'h1{font-size:20px;margin:0;letter-spacing:-0.01em}' +
|
||||
'.kicker{margin:2px 0 0;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#d1d4cd}' +
|
||||
'.mapframe{margin:0;border:1px solid #d1d4cd;border-radius:4px;overflow:hidden}' +
|
||||
'.snapshot{display:block;width:100%;height:8.4in;object-fit:cover}' +
|
||||
'.charge{font-size:12px;line-height:1.45;color:#3b463d;margin:6px 0 0}' +
|
||||
'.points{font-size:9.5px;line-height:1.5;color:#6d766e;margin:4px 0 0}' +
|
||||
'.footer{margin-top:4px;font-size:9px;color:#6d766e;border-top:1px solid #d1d4cd;padding-top:4px}' +
|
||||
'</style></head><body>' + pages + '</body></html>',
|
||||
{ autoprint: true }
|
||||
)
|
||||
if (!w) {
|
||||
statusMsg.value = 'Print frame unavailable in this host.'
|
||||
statusMsg.value = tr('printUnavailable')
|
||||
return { opened: false, count: 0 }
|
||||
}
|
||||
return { opened: true, count: targets.length }
|
||||
|
|
@ -426,10 +541,13 @@ function clearSelection() { clearSelectionInternal() }
|
|||
function listPolygons() { return polygons.map(p => ({ ...p })) }
|
||||
function getPolygon(id) { const p = polygons.find(p => p.id === id); return p ? { ...p } : null }
|
||||
function print(ids = null) { return printInternal(ids) } // async: resolves once the dialog is raised
|
||||
// The print-resolution PNG (data URL) for one zone, WITHOUT opening the print dialog: for an agent
|
||||
// that wants to save or send the map, and for the gate that proves the print shows streets.
|
||||
async function renderZoneMap(id) { const p = polygons.find(p => p.id === id); return p ? renderPrintMap(p) : null }
|
||||
|
||||
defineExpose({
|
||||
addPolygon, deletePolygon, selectPolygon, clearSelection,
|
||||
listPolygons, getPolygon, print,
|
||||
listPolygons, getPolygon, print, renderZoneMap,
|
||||
startDrawing, stopDrawing,
|
||||
instanceId: INSTANCE_ID
|
||||
})
|
||||
|
|
@ -439,7 +557,7 @@ function registerGlobal() {
|
|||
if (typeof window === 'undefined') return
|
||||
const api = {
|
||||
addPolygon, deletePolygon, selectPolygon, clearSelection,
|
||||
listPolygons, getPolygon, print, startDrawing, stopDrawing,
|
||||
listPolygons, getPolygon, print, renderZoneMap, startDrawing, stopDrawing,
|
||||
instanceId: INSTANCE_ID
|
||||
}
|
||||
window.PrayerWalkPlanner = window.PrayerWalkPlanner || { instances: {} }
|
||||
|
|
@ -485,11 +603,12 @@ onMounted(async () => {
|
|||
try {
|
||||
await initializeMap()
|
||||
if (map.value) {
|
||||
map.value.on('load', () => { setupMapLayers(); refreshMapSources() })
|
||||
if (window.maplibregl) ensureRtlText(window.maplibregl)
|
||||
map.value.on('load', () => { localizeBasemapLabels(map.value, uiLocale.value); setupMapLayers(); refreshMapSources() })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[prayer-walk-planner] map failed to initialize — polygon list, localStorage and print still work:', e)
|
||||
statusMsg.value = 'Map failed to load (no WebGL?) — drawing needs the map, but your saved zones and Print still work.'
|
||||
statusMsg.value = tr('mapFailed')
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
|
|
@ -532,31 +651,31 @@ useShadowStyles(
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pwp">
|
||||
<div class="pwp" :dir="uiDir" :lang="uiLocale">
|
||||
<div class="pwp-toolbar">
|
||||
<button type="button" class="pwp-btn" :class="{ 'is-active': drawing }"
|
||||
:aria-pressed="drawing" @click="onDrawClick">
|
||||
{{ drawing ? 'Drawing… (click map)' : 'Draw polygon' }}
|
||||
{{ drawing ? tr('drawing') : tr('draw') }}
|
||||
</button>
|
||||
<button v-if="drawing" type="button" class="pwp-btn is-accent"
|
||||
:disabled="drawPoints.length < 3" @click="onFinishClick">
|
||||
Finish polygon ({{ drawPoints.length }} pts)
|
||||
{{ tr('finish', { n: drawPoints.length }) }}
|
||||
</button>
|
||||
<button type="button" class="pwp-btn is-danger" :disabled="selectedCount === 0"
|
||||
@click="onDeleteSelected" aria-label="Delete selected polygons">
|
||||
Delete selected
|
||||
@click="onDeleteSelected" :aria-label="tr('deleteSelectedAria')">
|
||||
{{ tr('deleteSelected') }}
|
||||
</button>
|
||||
<button type="button" class="pwp-btn" :disabled="polygons.length === 0" @click="onSelectAll">
|
||||
Select all
|
||||
{{ tr('selectAll') }}
|
||||
</button>
|
||||
<button type="button" class="pwp-btn" :disabled="selectedCount === 0" @click="onClearSelection">
|
||||
Clear selection
|
||||
{{ tr('clearSelection') }}
|
||||
</button>
|
||||
<div class="pwp-spacer"></div>
|
||||
<span class="pwp-count">{{ selectedCount }} / {{ polygons.length }} selected</span>
|
||||
<span class="pwp-count">{{ tr('countSelected', { n: selectedCount, total: polygons.length }) }}</span>
|
||||
<button type="button" class="pwp-btn is-accent" :disabled="selectedCount === 0"
|
||||
@click="onPrintClick" aria-label="Print selected prayer-walking zones">
|
||||
Print selected
|
||||
@click="onPrintClick" :aria-label="tr('printAria')">
|
||||
{{ tr('printSelected') }}
|
||||
</button>
|
||||
<!-- SHARE THE TOOL, NOT THE ROUTE — and that distinction is the whole reason this
|
||||
button took a second look rather than being added with the others.
|
||||
|
|
@ -581,7 +700,7 @@ useShadowStyles(
|
|||
</div>
|
||||
<div class="pwp-list">
|
||||
<div v-if="polygons.length === 0" class="pwp-list-empty">
|
||||
No zones yet — draw a polygon on the map to start a prayer-walking plan.
|
||||
{{ tr('emptyList') }}
|
||||
</div>
|
||||
<div v-for="p in polygons" :key="p.id" class="pwp-item"
|
||||
:class="{ 'is-selected': p.selected }" @click="togglePolygonSelection(p.id)">
|
||||
|
|
@ -593,8 +712,8 @@ useShadowStyles(
|
|||
{{ p.title }}
|
||||
</span>
|
||||
<button type="button" class="pwp-item-del" @click.stop="deletePolygonInternal(p.id)"
|
||||
:aria-label="'Delete ' + p.title">
|
||||
Delete
|
||||
:aria-label="tr('deleteAria', { title: p.title })">
|
||||
{{ tr('delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
231
plugin-designs/prayer-walk-planner/src/strings.js
Normal file
231
plugin-designs/prayer-walk-planner/src/strings.js
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* strings.js — the Prayer Walk Planner's own words, in the 11 shipped locales.
|
||||
*
|
||||
* PRIVATE TO THIS PLUGIN (same rule as the Research Gallery's src/strings.js): the planner's
|
||||
* toolbar, list, status lines and printed sheet belong to it alone, so they live here rather than
|
||||
* in library/i18n, whose engine inlines every namespace into every bundle.
|
||||
*
|
||||
* `en` is the source and the fallback: a key missing in a locale renders in English, never as a
|
||||
* key name. Placeholders: {n} {total} {title} {c} {date}.
|
||||
*/
|
||||
export const STRINGS = {
|
||||
en: {
|
||||
draw: 'Draw polygon', drawing: 'Drawing… (click map)', finish: 'Finish polygon ({n} pts)',
|
||||
deleteSelected: 'Delete selected', deleteSelectedAria: 'Delete selected polygons',
|
||||
selectAll: 'Select all', clearSelection: 'Clear selection', countSelected: '{n} / {total} selected',
|
||||
printSelected: 'Print selected', printAria: 'Print selected prayer-walking zones',
|
||||
emptyList: 'No zones yet — draw a polygon on the map to start a prayer-walking plan.',
|
||||
delete: 'Delete', deleteAria: 'Delete {title}', zone: 'Zone {n}',
|
||||
clickToPlace: 'Click to place points. Double-click (or Finish) to close the polygon.',
|
||||
need3: 'Need at least 3 points to make a polygon.', added: 'Polygon added.',
|
||||
selectOne: 'Select at least one polygon to print.', printUnavailable: 'Print frame unavailable in this host.',
|
||||
preparing: 'Preparing the print map…',
|
||||
shareScope: 'This link shares the planner. Your drawn zones stay on this device.',
|
||||
mapFailed: 'Map failed to load (no WebGL?) — drawing needs the map, but your saved zones and Print still work.',
|
||||
kicker: 'Prayer walking map · centre {c}',
|
||||
charge: 'Walk the streets of this zone and pray for the people who live, work and worship along them.',
|
||||
corners: 'Zone corners', mapAlt: 'Walking map of {title}',
|
||||
footer: 'Prayer Walk Planner · generated {date} · Map © OpenFreeMap · Data © OpenStreetMap contributors',
|
||||
docTitleOne: 'Prayer Walking Plan — 1 zone', docTitleMany: 'Prayer Walking Plan — {n} zones'
|
||||
},
|
||||
es: {
|
||||
draw: 'Dibujar polígono', drawing: 'Dibujando… (haz clic en el mapa)', finish: 'Terminar polígono ({n} puntos)',
|
||||
deleteSelected: 'Eliminar selección', deleteSelectedAria: 'Eliminar los polígonos seleccionados',
|
||||
selectAll: 'Seleccionar todo', clearSelection: 'Quitar selección', countSelected: '{n} / {total} seleccionadas',
|
||||
printSelected: 'Imprimir selección', printAria: 'Imprimir las zonas de caminata de oración seleccionadas',
|
||||
emptyList: 'Aún no hay zonas: dibuja un polígono en el mapa para empezar un plan de caminata de oración.',
|
||||
delete: 'Eliminar', deleteAria: 'Eliminar {title}', zone: 'Zona {n}',
|
||||
clickToPlace: 'Haz clic para colocar puntos. Doble clic (o Terminar) para cerrar el polígono.',
|
||||
need3: 'Se necesitan al menos 3 puntos para formar un polígono.', added: 'Polígono añadido.',
|
||||
selectOne: 'Selecciona al menos un polígono para imprimir.', printUnavailable: 'La impresión no está disponible en esta página.',
|
||||
preparing: 'Preparando el mapa para imprimir…',
|
||||
shareScope: 'Este enlace comparte el planificador. Tus zonas dibujadas se quedan en este dispositivo.',
|
||||
mapFailed: 'No se pudo cargar el mapa (¿sin WebGL?). Para dibujar hace falta el mapa, pero tus zonas guardadas y la impresión siguen funcionando.',
|
||||
kicker: 'Mapa de caminata de oración · centro {c}',
|
||||
charge: 'Recorre las calles de esta zona y ora por las personas que viven, trabajan y adoran en ellas.',
|
||||
corners: 'Esquinas de la zona', mapAlt: 'Mapa para caminar de {title}',
|
||||
footer: 'Planificador de caminatas de oración · generado el {date} · Mapa © OpenFreeMap · Datos © colaboradores de OpenStreetMap',
|
||||
docTitleOne: 'Plan de caminata de oración — 1 zona', docTitleMany: 'Plan de caminata de oración — {n} zonas'
|
||||
},
|
||||
fr: {
|
||||
draw: 'Dessiner un polygone', drawing: 'Dessin en cours… (cliquez sur la carte)', finish: 'Terminer le polygone ({n} points)',
|
||||
deleteSelected: 'Supprimer la sélection', deleteSelectedAria: 'Supprimer les polygones sélectionnés',
|
||||
selectAll: 'Tout sélectionner', clearSelection: 'Effacer la sélection', countSelected: '{n} / {total} sélectionnées',
|
||||
printSelected: 'Imprimer la sélection', printAria: 'Imprimer les zones de marche de prière sélectionnées',
|
||||
emptyList: 'Aucune zone pour l’instant — dessinez un polygone sur la carte pour commencer un plan de marche de prière.',
|
||||
delete: 'Supprimer', deleteAria: 'Supprimer {title}', zone: 'Zone {n}',
|
||||
clickToPlace: 'Cliquez pour placer des points. Double-cliquez (ou Terminer) pour fermer le polygone.',
|
||||
need3: 'Il faut au moins 3 points pour former un polygone.', added: 'Polygone ajouté.',
|
||||
selectOne: 'Sélectionnez au moins un polygone à imprimer.', printUnavailable: 'L’impression n’est pas disponible sur cette page.',
|
||||
preparing: 'Préparation de la carte à imprimer…',
|
||||
shareScope: 'Ce lien partage le planificateur. Vos zones dessinées restent sur cet appareil.',
|
||||
mapFailed: 'La carte n’a pas pu se charger (pas de WebGL ?). Le dessin nécessite la carte, mais vos zones enregistrées et l’impression fonctionnent toujours.',
|
||||
kicker: 'Carte de marche de prière · centre {c}',
|
||||
charge: 'Parcourez les rues de cette zone et priez pour les personnes qui y vivent, y travaillent et y adorent.',
|
||||
corners: 'Coins de la zone', mapAlt: 'Carte de marche de {title}',
|
||||
footer: 'Planificateur de marches de prière · généré le {date} · Carte © OpenFreeMap · Données © contributeurs OpenStreetMap',
|
||||
docTitleOne: 'Plan de marche de prière — 1 zone', docTitleMany: 'Plan de marche de prière — {n} zones'
|
||||
},
|
||||
de: {
|
||||
draw: 'Polygon zeichnen', drawing: 'Zeichnen… (auf die Karte klicken)', finish: 'Polygon abschließen ({n} Punkte)',
|
||||
deleteSelected: 'Auswahl löschen', deleteSelectedAria: 'Ausgewählte Polygone löschen',
|
||||
selectAll: 'Alle auswählen', clearSelection: 'Auswahl aufheben', countSelected: '{n} / {total} ausgewählt',
|
||||
printSelected: 'Auswahl drucken', printAria: 'Ausgewählte Gebetsspaziergang-Zonen drucken',
|
||||
emptyList: 'Noch keine Zonen — zeichnen Sie ein Polygon auf der Karte, um einen Gebetsspaziergang zu planen.',
|
||||
delete: 'Löschen', deleteAria: '{title} löschen', zone: 'Zone {n}',
|
||||
clickToPlace: 'Klicken, um Punkte zu setzen. Doppelklick (oder Abschließen) schließt das Polygon.',
|
||||
need3: 'Für ein Polygon sind mindestens 3 Punkte nötig.', added: 'Polygon hinzugefügt.',
|
||||
selectOne: 'Wählen Sie mindestens ein Polygon zum Drucken aus.', printUnavailable: 'Drucken ist auf dieser Seite nicht verfügbar.',
|
||||
preparing: 'Druckkarte wird vorbereitet…',
|
||||
shareScope: 'Dieser Link teilt den Planer. Ihre gezeichneten Zonen bleiben auf diesem Gerät.',
|
||||
mapFailed: 'Die Karte konnte nicht geladen werden (kein WebGL?). Zum Zeichnen wird die Karte benötigt, aber gespeicherte Zonen und Drucken funktionieren weiterhin.',
|
||||
kicker: 'Karte für den Gebetsspaziergang · Mitte {c}',
|
||||
charge: 'Gehen Sie durch die Straßen dieser Zone und beten Sie für die Menschen, die hier leben, arbeiten und Gott anbeten.',
|
||||
corners: 'Eckpunkte der Zone', mapAlt: 'Spaziergangskarte von {title}',
|
||||
footer: 'Gebetsspaziergang-Planer · erstellt am {date} · Karte © OpenFreeMap · Daten © OpenStreetMap-Mitwirkende',
|
||||
docTitleOne: 'Gebetsspaziergang-Plan — 1 Zone', docTitleMany: 'Gebetsspaziergang-Plan — {n} Zonen'
|
||||
},
|
||||
pt: {
|
||||
draw: 'Desenhar polígono', drawing: 'Desenhando… (clique no mapa)', finish: 'Concluir polígono ({n} pontos)',
|
||||
deleteSelected: 'Excluir seleção', deleteSelectedAria: 'Excluir os polígonos selecionados',
|
||||
selectAll: 'Selecionar tudo', clearSelection: 'Limpar seleção', countSelected: '{n} / {total} selecionadas',
|
||||
printSelected: 'Imprimir seleção', printAria: 'Imprimir as zonas de caminhada de oração selecionadas',
|
||||
emptyList: 'Ainda não há zonas — desenhe um polígono no mapa para começar um plano de caminhada de oração.',
|
||||
delete: 'Excluir', deleteAria: 'Excluir {title}', zone: 'Zona {n}',
|
||||
clickToPlace: 'Clique para marcar pontos. Clique duas vezes (ou Concluir) para fechar o polígono.',
|
||||
need3: 'São necessários pelo menos 3 pontos para formar um polígono.', added: 'Polígono adicionado.',
|
||||
selectOne: 'Selecione pelo menos um polígono para imprimir.', printUnavailable: 'A impressão não está disponível nesta página.',
|
||||
preparing: 'Preparando o mapa para impressão…',
|
||||
shareScope: 'Este link compartilha o planejador. Suas zonas desenhadas ficam neste dispositivo.',
|
||||
mapFailed: 'O mapa não carregou (sem WebGL?). Desenhar exige o mapa, mas suas zonas salvas e a impressão continuam funcionando.',
|
||||
kicker: 'Mapa de caminhada de oração · centro {c}',
|
||||
charge: 'Caminhe pelas ruas desta zona e ore pelas pessoas que vivem, trabalham e adoram nelas.',
|
||||
corners: 'Cantos da zona', mapAlt: 'Mapa de caminhada de {title}',
|
||||
footer: 'Planejador de caminhadas de oração · gerado em {date} · Mapa © OpenFreeMap · Dados © colaboradores do OpenStreetMap',
|
||||
docTitleOne: 'Plano de caminhada de oração — 1 zona', docTitleMany: 'Plano de caminhada de oração — {n} zonas'
|
||||
},
|
||||
it: {
|
||||
draw: 'Disegna poligono', drawing: 'Disegno in corso… (clicca sulla mappa)', finish: 'Completa poligono ({n} punti)',
|
||||
deleteSelected: 'Elimina selezionati', deleteSelectedAria: 'Elimina i poligoni selezionati',
|
||||
selectAll: 'Seleziona tutto', clearSelection: 'Deseleziona', countSelected: '{n} / {total} selezionate',
|
||||
printSelected: 'Stampa selezionate', printAria: 'Stampa le zone di passeggiata di preghiera selezionate',
|
||||
emptyList: 'Ancora nessuna zona — disegna un poligono sulla mappa per iniziare un piano di passeggiata di preghiera.',
|
||||
delete: 'Elimina', deleteAria: 'Elimina {title}', zone: 'Zona {n}',
|
||||
clickToPlace: 'Clicca per posizionare i punti. Doppio clic (o Completa) per chiudere il poligono.',
|
||||
need3: 'Servono almeno 3 punti per formare un poligono.', added: 'Poligono aggiunto.',
|
||||
selectOne: 'Seleziona almeno un poligono da stampare.', printUnavailable: 'La stampa non è disponibile in questa pagina.',
|
||||
preparing: 'Preparazione della mappa di stampa…',
|
||||
shareScope: 'Questo link condivide il pianificatore. Le zone che hai disegnato restano su questo dispositivo.',
|
||||
mapFailed: 'Impossibile caricare la mappa (niente WebGL?). Per disegnare serve la mappa, ma le zone salvate e la stampa funzionano ancora.',
|
||||
kicker: 'Mappa della passeggiata di preghiera · centro {c}',
|
||||
charge: 'Percorri le strade di questa zona e prega per le persone che vi abitano, vi lavorano e vi adorano.',
|
||||
corners: 'Vertici della zona', mapAlt: 'Mappa della passeggiata di {title}',
|
||||
footer: 'Pianificatore di passeggiate di preghiera · generato il {date} · Mappa © OpenFreeMap · Dati © contributori OpenStreetMap',
|
||||
docTitleOne: 'Piano di passeggiata di preghiera — 1 zona', docTitleMany: 'Piano di passeggiata di preghiera — {n} zone'
|
||||
},
|
||||
ro: {
|
||||
draw: 'Desenează poligon', drawing: 'Se desenează… (dă clic pe hartă)', finish: 'Finalizează poligonul ({n} puncte)',
|
||||
deleteSelected: 'Șterge selecția', deleteSelectedAria: 'Șterge poligoanele selectate',
|
||||
selectAll: 'Selectează tot', clearSelection: 'Anulează selecția', countSelected: '{n} / {total} selectate',
|
||||
printSelected: 'Tipărește selecția', printAria: 'Tipărește zonele de plimbare de rugăciune selectate',
|
||||
emptyList: 'Încă nu există zone — desenează un poligon pe hartă pentru a începe un plan de plimbare de rugăciune.',
|
||||
delete: 'Șterge', deleteAria: 'Șterge {title}', zone: 'Zona {n}',
|
||||
clickToPlace: 'Dă clic pentru a plasa puncte. Dublu clic (sau Finalizează) pentru a închide poligonul.',
|
||||
need3: 'Sunt necesare cel puțin 3 puncte pentru un poligon.', added: 'Poligon adăugat.',
|
||||
selectOne: 'Selectează cel puțin un poligon de tipărit.', printUnavailable: 'Tipărirea nu este disponibilă pe această pagină.',
|
||||
preparing: 'Se pregătește harta de tipărit…',
|
||||
shareScope: 'Acest link partajează planificatorul. Zonele desenate rămân pe acest dispozitiv.',
|
||||
mapFailed: 'Harta nu s-a încărcat (fără WebGL?). Desenarea are nevoie de hartă, dar zonele salvate și tipărirea funcționează în continuare.',
|
||||
kicker: 'Hartă pentru plimbarea de rugăciune · centru {c}',
|
||||
charge: 'Plimbă-te pe străzile acestei zone și roagă-te pentru oamenii care locuiesc, lucrează și se închină aici.',
|
||||
corners: 'Colțurile zonei', mapAlt: 'Harta de plimbare pentru {title}',
|
||||
footer: 'Planificator de plimbări de rugăciune · generat pe {date} · Hartă © OpenFreeMap · Date © contribuitorii OpenStreetMap',
|
||||
docTitleOne: 'Plan de plimbare de rugăciune — 1 zonă', docTitleMany: 'Plan de plimbare de rugăciune — {n} zone'
|
||||
},
|
||||
ru: {
|
||||
draw: 'Нарисовать область', drawing: 'Рисование… (щёлкайте по карте)', finish: 'Завершить область (точек: {n})',
|
||||
deleteSelected: 'Удалить выбранные', deleteSelectedAria: 'Удалить выбранные области',
|
||||
selectAll: 'Выбрать все', clearSelection: 'Снять выбор', countSelected: 'Выбрано: {n} из {total}',
|
||||
printSelected: 'Печать выбранных', printAria: 'Печать выбранных зон молитвенной прогулки',
|
||||
emptyList: 'Зон пока нет — нарисуйте область на карте, чтобы начать план молитвенной прогулки.',
|
||||
delete: 'Удалить', deleteAria: 'Удалить: {title}', zone: 'Зона {n}',
|
||||
clickToPlace: 'Щёлкайте, чтобы ставить точки. Двойной щелчок (или «Завершить») замыкает область.',
|
||||
need3: 'Для области нужно не менее 3 точек.', added: 'Область добавлена.',
|
||||
selectOne: 'Выберите хотя бы одну область для печати.', printUnavailable: 'Печать недоступна на этой странице.',
|
||||
preparing: 'Подготовка карты для печати…',
|
||||
shareScope: 'Эта ссылка открывает планировщик. Нарисованные зоны остаются на этом устройстве.',
|
||||
mapFailed: 'Не удалось загрузить карту (нет WebGL?). Для рисования нужна карта, но сохранённые зоны и печать по-прежнему работают.',
|
||||
kicker: 'Карта молитвенной прогулки · центр {c}',
|
||||
charge: 'Пройдите по улицам этой зоны и помолитесь о людях, которые здесь живут, работают и поклоняются.',
|
||||
corners: 'Углы зоны', mapAlt: 'Карта прогулки: {title}',
|
||||
footer: 'Планировщик молитвенных прогулок · создано {date} · Карта © OpenFreeMap · Данные © участники OpenStreetMap',
|
||||
docTitleOne: 'План молитвенной прогулки — 1 зона', docTitleMany: 'План молитвенной прогулки — зон: {n}'
|
||||
},
|
||||
ar: {
|
||||
draw: 'ارسم مضلّعًا', drawing: 'جارٍ الرسم… (انقر على الخريطة)', finish: 'إنهاء المضلّع ({n} نقاط)',
|
||||
deleteSelected: 'حذف المحدد', deleteSelectedAria: 'حذف المضلّعات المحددة',
|
||||
selectAll: 'تحديد الكل', clearSelection: 'إلغاء التحديد', countSelected: '{n} / {total} محدد',
|
||||
printSelected: 'طباعة المحدد', printAria: 'طباعة مناطق جولات الصلاة المحددة',
|
||||
emptyList: 'لا توجد مناطق بعد — ارسم مضلّعًا على الخريطة لتبدأ خطة جولة صلاة.',
|
||||
delete: 'حذف', deleteAria: 'حذف {title}', zone: 'المنطقة {n}',
|
||||
clickToPlace: 'انقر لوضع النقاط. انقر نقرًا مزدوجًا (أو «إنهاء») لإغلاق المضلّع.',
|
||||
need3: 'يلزم 3 نقاط على الأقل لتكوين مضلّع.', added: 'أُضيف المضلّع.',
|
||||
selectOne: 'حدّد مضلّعًا واحدًا على الأقل للطباعة.', printUnavailable: 'الطباعة غير متاحة في هذه الصفحة.',
|
||||
preparing: 'جارٍ تجهيز خريطة الطباعة…',
|
||||
shareScope: 'يشارك هذا الرابط المخطِّط. تبقى المناطق التي رسمتها على هذا الجهاز.',
|
||||
mapFailed: 'تعذّر تحميل الخريطة (لا يوجد WebGL؟). الرسم يحتاج إلى الخريطة، لكن مناطقك المحفوظة والطباعة ما زالت تعمل.',
|
||||
kicker: 'خريطة جولة الصلاة · المركز {c}',
|
||||
charge: 'امشِ في شوارع هذه المنطقة وصلِّ من أجل الناس الذين يسكنون ويعملون ويعبدون فيها.',
|
||||
corners: 'زوايا المنطقة', mapAlt: 'خريطة جولة {title}',
|
||||
footer: 'مخطِّط جولات الصلاة · أُنشئت في {date} · الخريطة © OpenFreeMap · البيانات © مساهمو OpenStreetMap',
|
||||
docTitleOne: 'خطة جولة الصلاة — منطقة واحدة', docTitleMany: 'خطة جولة الصلاة — عدد المناطق: {n}'
|
||||
},
|
||||
hi: {
|
||||
draw: 'बहुभुज बनाएँ', drawing: 'बनाया जा रहा है… (मानचित्र पर क्लिक करें)', finish: 'बहुभुज पूरा करें ({n} बिंदु)',
|
||||
deleteSelected: 'चयनित हटाएँ', deleteSelectedAria: 'चयनित बहुभुज हटाएँ',
|
||||
selectAll: 'सभी चुनें', clearSelection: 'चयन हटाएँ', countSelected: '{n} / {total} चयनित',
|
||||
printSelected: 'चयनित प्रिंट करें', printAria: 'चयनित प्रार्थना पदयात्रा क्षेत्र प्रिंट करें',
|
||||
emptyList: 'अभी कोई क्षेत्र नहीं — प्रार्थना पदयात्रा की योजना शुरू करने के लिए मानचित्र पर बहुभुज बनाएँ।',
|
||||
delete: 'हटाएँ', deleteAria: '{title} हटाएँ', zone: 'क्षेत्र {n}',
|
||||
clickToPlace: 'बिंदु लगाने के लिए क्लिक करें। बहुभुज बंद करने के लिए डबल-क्लिक करें (या पूरा करें)।',
|
||||
need3: 'बहुभुज बनाने के लिए कम से कम 3 बिंदु चाहिए।', added: 'बहुभुज जोड़ा गया।',
|
||||
selectOne: 'प्रिंट करने के लिए कम से कम एक बहुभुज चुनें।', printUnavailable: 'इस पेज पर प्रिंट उपलब्ध नहीं है।',
|
||||
preparing: 'प्रिंट मानचित्र तैयार हो रहा है…',
|
||||
shareScope: 'यह लिंक योजनाकार साझा करता है। आपके बनाए क्षेत्र इसी डिवाइस पर रहते हैं।',
|
||||
mapFailed: 'मानचित्र लोड नहीं हुआ (WebGL नहीं?)। बनाने के लिए मानचित्र चाहिए, पर आपके सहेजे क्षेत्र और प्रिंट अब भी काम करते हैं।',
|
||||
kicker: 'प्रार्थना पदयात्रा मानचित्र · केंद्र {c}',
|
||||
charge: 'इस क्षेत्र की गलियों में चलें और यहाँ रहने, काम करने और आराधना करने वाले लोगों के लिए प्रार्थना करें।',
|
||||
corners: 'क्षेत्र के कोने', mapAlt: '{title} का पदयात्रा मानचित्र',
|
||||
footer: 'प्रार्थना पदयात्रा योजनाकार · {date} को बनाया गया · मानचित्र © OpenFreeMap · डेटा © OpenStreetMap योगदानकर्ता',
|
||||
docTitleOne: 'प्रार्थना पदयात्रा योजना — 1 क्षेत्र', docTitleMany: 'प्रार्थना पदयात्रा योजना — {n} क्षेत्र'
|
||||
},
|
||||
zh: {
|
||||
draw: '绘制多边形', drawing: '正在绘制…(点击地图)', finish: '完成多边形({n} 个点)',
|
||||
deleteSelected: '删除所选', deleteSelectedAria: '删除所选多边形',
|
||||
selectAll: '全选', clearSelection: '清除选择', countSelected: '已选 {n} / {total}',
|
||||
printSelected: '打印所选', printAria: '打印所选的祷告行走区域',
|
||||
emptyList: '还没有区域——在地图上绘制一个多边形,开始制定祷告行走计划。',
|
||||
delete: '删除', deleteAria: '删除{title}', zone: '区域 {n}',
|
||||
clickToPlace: '点击放置顶点。双击(或点“完成”)闭合多边形。',
|
||||
need3: '多边形至少需要 3 个点。', added: '已添加多边形。',
|
||||
selectOne: '请至少选择一个要打印的多边形。', printUnavailable: '此页面无法打印。',
|
||||
preparing: '正在准备打印地图…',
|
||||
shareScope: '此链接分享的是规划工具。您绘制的区域保留在本设备上。',
|
||||
mapFailed: '地图加载失败(没有 WebGL?)。绘制需要地图,但已保存的区域和打印仍可使用。',
|
||||
kicker: '祷告行走地图 · 中心 {c}',
|
||||
charge: '走遍这个区域的街道,为在这里生活、工作和敬拜的人祷告。',
|
||||
corners: '区域顶点', mapAlt: '{title}的行走地图',
|
||||
footer: '祷告行走规划 · 生成于 {date} · 地图 © OpenFreeMap · 数据 © OpenStreetMap 贡献者',
|
||||
docTitleOne: '祷告行走计划 — 1 个区域', docTitleMany: '祷告行走计划 — {n} 个区域'
|
||||
}
|
||||
}
|
||||
|
||||
export const LOCALES = Object.keys(STRINGS)
|
||||
|
||||
/** Translate `key` into `locale`, falling back to English, then to the key itself. */
|
||||
export function translate(locale, key, params) {
|
||||
const table = STRINGS[locale] || STRINGS.en
|
||||
let s = table[key] ?? STRINGS.en[key] ?? key
|
||||
if (params) for (const k in params) s = s.split('{' + k + '}').join(String(params[k]))
|
||||
return s
|
||||
}
|
||||
2
public/.gitignore
vendored
2
public/.gitignore
vendored
|
|
@ -34,5 +34,5 @@ token.local.js
|
|||
/assets/posters/
|
||||
/assets/people-groups/
|
||||
/assets/tables/
|
||||
/assets/legacy/
|
||||
/assets/pplr/
|
||||
/assets/vendor/
|
||||
|
|
|
|||
75
public/assets/legacy/upg-neighbor/index.html
vendored
Normal file
75
public/assets/legacy/upg-neighbor/index.html
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>legacy map host</title>
|
||||
<style>html,body{margin:0;height:100%;background:#f3f3f1}.host{position:fixed;inset:0}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="host" id="host"></div>
|
||||
<script>
|
||||
// Sandboxed host for the LEGACY upg-neighbor IIFE (pre-bundler architecture). This page runs
|
||||
// inside an IFRAME so the old engine and its dependencies cannot collide with the modern
|
||||
// bundles -- the isolation pattern the roundup mandates ("add without breaking doxa-maps").
|
||||
var q = new URLSearchParams(location.search);
|
||||
var el = (q.get('el') || 'send-map').replace(/[^a-z0-9-]/g, '');
|
||||
var tk = q.get('tk') || '';
|
||||
// Token globals the legacy build may read.
|
||||
window.MAPBOX_TOKEN = tk; window.MAP_TOKEN = tk; window.UPG_TOKEN = tk;
|
||||
// The legacy code fetches relative "/api/people-groups/..." which only resolved on its old
|
||||
// origin. Shim fetch so those calls reach the live API from ANY host (static tree, iframe).
|
||||
var API = 'https://pray.doxa.life';
|
||||
// The cockpit profile fails closed without a configured API base and names this global in
|
||||
// its own error banner (measured: "No API base configured (apiBase / window.MAP_APP_API_URL
|
||||
// / VITE_API_BASE_URL) -- failing closed").
|
||||
window.MAP_APP_API_URL = API;
|
||||
var origFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
try {
|
||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
if (url.indexOf('/api/') === 0) return origFetch(API + url, init);
|
||||
} catch (e) { /* fall through */ }
|
||||
return origFetch(input, init);
|
||||
};
|
||||
// ── KEYLESS SHIM ──────────────────────────────────────────────────────────
|
||||
// The legacy bundle expects a mapboxgl GLOBAL and requests mapbox://styles/mapbox/light-v11.
|
||||
// MapLibre is API-compatible with the mapbox-gl v1 surface the bundle uses, and OpenFreeMap
|
||||
// Positron is the keyless twin of light-v11 -- so the shim below makes every legacy map run
|
||||
// WITHOUT a Mapbox token: load our self-hosted MapLibre, alias it as mapboxgl, and rewrite
|
||||
// any mapbox:// style to the keyless URL inside a patched Map constructor.
|
||||
var KEYLESS_STYLE = 'https://tiles.openfreemap.org/styles/positron';
|
||||
var lib = document.createElement('link'); lib.rel='stylesheet'; lib.href='./maplibre-gl.css';
|
||||
document.head.appendChild(lib);
|
||||
var ml = document.createElement('script');
|
||||
ml.src = './maplibre-gl.js';
|
||||
ml.onload = function () {
|
||||
var Real = window.maplibregl.Map;
|
||||
function PatchedMap (opts) {
|
||||
if (opts && typeof opts.style === 'string' && opts.style.indexOf('mapbox://') === 0) opts.style = KEYLESS_STYLE;
|
||||
return new Real(opts);
|
||||
}
|
||||
PatchedMap.prototype = Real.prototype;
|
||||
window.mapboxgl = Object.assign({}, window.maplibregl, { Map: PatchedMap, accessToken: '' });
|
||||
loadLegacy();
|
||||
};
|
||||
ml.onerror = function () { loadLegacy(); };
|
||||
document.head.appendChild(ml);
|
||||
|
||||
function loadLegacy () {
|
||||
var s = document.createElement('script');
|
||||
s.src = './upg-neighbor.iife.js';
|
||||
s.onload = function () {
|
||||
var node = document.createElement(el);
|
||||
node.style.cssText = 'display:block;width:100%;height:100%';
|
||||
// The legacy elements use the SAME profile-config attribute contract as the modern
|
||||
// bundles (measured: without it the element renders its own usage banner).
|
||||
node.setAttribute('profile-config', JSON.stringify({ profile: el, tk: tk }));
|
||||
document.getElementById('host').appendChild(node);
|
||||
};
|
||||
s.onerror = function () { document.body.innerHTML = '<p style="font:14px system-ui;padding:20px">legacy bundle failed to load</p>'; };
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
public/assets/legacy/upg-neighbor/maplibre-gl.css
vendored
Normal file
1
public/assets/legacy/upg-neighbor/maplibre-gl.css
vendored
Normal file
File diff suppressed because one or more lines are too long
59
public/assets/legacy/upg-neighbor/maplibre-gl.js
vendored
Normal file
59
public/assets/legacy/upg-neighbor/maplibre-gl.js
vendored
Normal file
File diff suppressed because one or more lines are too long
548
public/assets/legacy/upg-neighbor/upg-neighbor.iife.js
vendored
Normal file
548
public/assets/legacy/upg-neighbor/upg-neighbor.iife.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/doxa-maps/assets/pplr/coverage.json
generated
Normal file
1
public/doxa-maps/assets/pplr/coverage.json
generated
Normal file
File diff suppressed because one or more lines are too long
1
public/doxa-maps/assets/pplr/pg-names.json
generated
Normal file
1
public/doxa-maps/assets/pplr/pg-names.json
generated
Normal file
File diff suppressed because one or more lines are too long
1
public/doxa-maps/assets/pplr/pg-points.json
generated
Normal file
1
public/doxa-maps/assets/pplr/pg-points.json
generated
Normal file
File diff suppressed because one or more lines are too long
1
public/doxa-maps/assets/pplr/pplr-geoid-family.json
generated
Normal file
1
public/doxa-maps/assets/pplr/pplr-geoid-family.json
generated
Normal file
File diff suppressed because one or more lines are too long
1
public/doxa-maps/assets/pplr/rop-tree.json
generated
Normal file
1
public/doxa-maps/assets/pplr/rop-tree.json
generated
Normal file
File diff suppressed because one or more lines are too long
77
public/doxa-maps/assets/pplr/rosetta-sample.json
generated
Normal file
77
public/doxa-maps/assets/pplr/rosetta-sample.json
generated
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"
|
||||
}
|
||||
]
|
||||
2
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
2
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-Q8rOxEI1qbhEU4RKp1N3wmH990IPGT+AVCkNoSplZOxD+gqvBP1a4gD4TX+7xR40" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-0x9kTbgn/VFIqzPuAhwj4FHZE4+SrcZu798Cyxc1o/8VvS9qBFQaaZW/Oonj5K7s" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-Q8rOxEI1qbhEU4RKp1N3wmH990IPGT+AVCkNoSplZOxD+gqvBP1a4gD4TX+7xR40" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-0x9kTbgn/VFIqzPuAhwj4FHZE4+SrcZu798Cyxc1o/8VvS9qBFQaaZW/Oonj5K7s" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
38
public/doxa-maps/manifest.json
generated
38
public/doxa-maps/manifest.json
generated
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 21,
|
||||
"profileCount": 33,
|
||||
"bundleCount": 22,
|
||||
"profileCount": 34,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life",
|
||||
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
||||
|
|
@ -302,7 +302,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-Q8rOxEI1qbhEU4RKp1N3wmH990IPGT+AVCkNoSplZOxD+gqvBP1a4gD4TX+7xR40",
|
||||
"integrity": "sha384-0x9kTbgn/VFIqzPuAhwj4FHZE4+SrcZu798Cyxc1o/8VvS9qBFQaaZW/Oonj5K7s",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -327,7 +327,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-Q8rOxEI1qbhEU4RKp1N3wmH990IPGT+AVCkNoSplZOxD+gqvBP1a4gD4TX+7xR40\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-0x9kTbgn/VFIqzPuAhwj4FHZE4+SrcZu798Cyxc1o/8VvS9qBFQaaZW/Oonj5K7s\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -833,11 +833,37 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "pplr-data-maps",
|
||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
"output": "doxa-maps/pplr-data-maps/pplr-data-maps.js",
|
||||
"integrity": "sha384-u04GVRZqNXyBqfkyvCtm9IT6/RWbJTNTxTpv6ScsnE/5za+UIlTjAoZKIn9k2Zp3",
|
||||
"elements": [
|
||||
"pplr-data-maps"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "pplr-data-maps",
|
||||
"file": "pplr-data-maps.vue",
|
||||
"description": "Ten views that let a researcher check the PPLR people-group data by eye",
|
||||
"element": "pplr-data-maps",
|
||||
"type": "monolithic",
|
||||
"isMap": true,
|
||||
"iframeUrl": "./pplr-data-maps/pplr-data-maps/index.html",
|
||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-u04GVRZqNXyBqfkyvCtm9IT6/RWbJTNTxTpv6ScsnE/5za+UIlTjAoZKIn9k2Zp3\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||
},
|
||||
"bundle": "pplr-data-maps"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "prayer-walk-planner",
|
||||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
|
||||
"integrity": "sha384-g8RKmjylw3TQzKJqlBQiVI5DXeUtPVPvWr6pzFDgOzn0GIYTytYB/vMKl3AjJbae",
|
||||
"integrity": "sha384-WPlrs8XIyGgif3VVs+wjcBzVny+9aSArtOKZBGhFOpiND8bbKZ+ijcM8nJh8kDhY",
|
||||
"elements": [
|
||||
"prayer-walk-planner"
|
||||
],
|
||||
|
|
@ -853,7 +879,7 @@
|
|||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-g8RKmjylw3TQzKJqlBQiVI5DXeUtPVPvWr6pzFDgOzn0GIYTytYB/vMKl3AjJbae\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-WPlrs8XIyGgif3VVs+wjcBzVny+9aSArtOKZBGhFOpiND8bbKZ+ijcM8nJh8kDhY\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
161
public/doxa-maps/pplr-data-maps/pplr-data-maps.js
generated
Normal file
161
public/doxa-maps/pplr-data-maps/pplr-data-maps.js
generated
Normal file
File diff suppressed because one or more lines are too long
195
public/doxa-maps/pplr-data-maps/pplr-data-maps/index.html
generated
Normal file
195
public/doxa-maps/pplr-data-maps/pplr-data-maps/index.html
generated
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<!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 · pplr-data-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
|
||||
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
|
||||
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, pplr-data-maps { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading token…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <pplr-data-maps> custom element. -->
|
||||
<script src="../pplr-data-maps.js" integrity="sha384-u04GVRZqNXyBqfkyvCtm9IT6/RWbJTNTxTpv6ScsnE/5za+UIlTjAoZKIn9k2Zp3" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"pplr-data-maps","dataSource":"pray-tools","instanceId":"pplr-data-maps-pplr-data-maps","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = false;
|
||||
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
|
||||
// rather than this page's profile-config. With no baked token (the public copy), it must get
|
||||
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
|
||||
var NESTS_MAPS = false;
|
||||
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||
mounted element does not cover shows it through, because every layer above body is
|
||||
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
|
||||
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
|
||||
function done() {
|
||||
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||
document.documentElement.style.background = '#f3f3f1';
|
||||
document.body.style.background = '#f3f3f1';
|
||||
}
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
|
||||
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("pplr-data-maps");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||
if (TOKEN_FREE) {
|
||||
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
|
||||
mount('');
|
||||
// A token-free composite with no token anywhere: its content renders, but its nested maps
|
||||
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
|
||||
if (NESTS_MAPS && !directToken) {
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'status';
|
||||
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
|
||||
document.body.appendChild(bar);
|
||||
status = bar;
|
||||
askToken();
|
||||
var goBtn = document.getElementById('tkGo');
|
||||
if (goBtn) goBtn.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (v) { location.reload(); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. -->
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-g8RKmjylw3TQzKJqlBQiVI5DXeUtPVPvWr6pzFDgOzn0GIYTytYB/vMKl3AjJbae" crossorigin="anonymous"></script>
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-WPlrs8XIyGgif3VVs+wjcBzVny+9aSArtOKZBGhFOpiND8bbKZ+ijcM8nJh8kDhY" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Loading…
Reference in a new issue