All People Groups: the map worker gets a third of the data, built once, so the map stays light
Each of the 12,379 pins carries only its row's index and one category number (1.25 MB of GeoJSON, was 3.7 MB); the popup reads the row by id, and shows the same fields (checked on 18 pins, 10 of them DOXA, and the four counts). The DOXA recolour no longer rebuilds and re-tiles all 12,379 pins: DOXA pins are a small source of their own on the top layer, and their base twins are hidden by feature-state.
This commit is contained in:
parent
2dd8888d8c
commit
63a2e09bde
1 changed files with 89 additions and 45 deletions
|
|
@ -188,8 +188,18 @@ const mapData = useMapData({
|
|||
markRaw: (v) => v,
|
||||
})
|
||||
|
||||
let featuresAll = [] // kept for legend clicks (filter + fitBounds)
|
||||
// ★ PERFORMANCE (DRV1650-APG-PERFORMANCE, 2026-09-29). The map worker gets SLIM features: an id (the
|
||||
// row's index) and one category number. Every name, country, language and religion used to ride on
|
||||
// all 12,379 features, 3.7 MB of GeoJSON built and re-tiled TWICE (first paint, then the DOXA recolour);
|
||||
// slim, it is 1.25 MB, built once. The popup reads the row by id. The DOXA recolour no longer rebuilds
|
||||
// the 12,379: DOXA pins are a small source of their own drawn on top, and their base duplicates are
|
||||
// hidden by feature-state.
|
||||
let rows = [] // the decoded rows; a feature's id is its index here
|
||||
let doxaIds = new Set() // row indexes in the DOXA set (empty until the prayer API answers)
|
||||
let doxaBySlugRop = new Map() // rop3 → { slug, name } for the pray link
|
||||
// Category numbers on the base layer, from GSEC alone (DOXA is its own layer): the paint and the
|
||||
// sort order read these.
|
||||
const GSEC_CAT = ['frontier', 'unreached', 'reached']
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
|
||||
|
|
@ -198,7 +208,7 @@ function esc(s) {
|
|||
|
||||
// Rows in one shape whichever file served them: the compact points file (column arrays + string
|
||||
// dictionaries), or — for a tree built before it existed — the old slim file's objects.
|
||||
async function loadRows() {
|
||||
async function loadRows() { // → the decoded rows
|
||||
const base = assetsBase.value
|
||||
const r = await fetch(`${base}/people-groups/people-groups-points.json`)
|
||||
if (r.ok) {
|
||||
|
|
@ -220,29 +230,36 @@ async function loadRows() {
|
|||
}))
|
||||
}
|
||||
|
||||
// Every pin by GSEC, from the file alone — the DOXA overlay (applyDoxa) comes later and never blocks.
|
||||
function buildFeatures(rows, doxaSet) {
|
||||
// A row the map can place (the file carries a few without coordinates).
|
||||
const placed = (row) => { const lat = Number(row.lat), lng = Number(row.lng); return Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0) }
|
||||
// A row's category: DOXA first, else by GSEC. The counts, the legend filter and the popup all use it.
|
||||
const catOf = (i) => (doxaIds.has(i) ? 'doxa' : categorize({ GSEC: rows[i].gsec }, new Set()))
|
||||
const slimPoint = (i, c) => ({ type: 'Feature', id: i, geometry: { type: 'Point', coordinates: [Number(rows[i].lng), Number(rows[i].lat)] }, properties: { c } })
|
||||
|
||||
// Every pin by GSEC, from the file alone: built ONCE. The DOXA overlay (applyDoxa) comes later and never blocks.
|
||||
function buildBase() {
|
||||
const feats = []
|
||||
const c = { doxa: 0, frontier: 0, unreached: 0, reached: 0 }
|
||||
for (const row of rows) {
|
||||
const lat = Number(row.lat), lng = Number(row.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) continue
|
||||
const cat = categorize({ ROP3Code: row.rop3, GSEC: row.gsec }, doxaSet)
|
||||
c[cat]++
|
||||
feats.push({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lng, lat] },
|
||||
properties: {
|
||||
name: row.name, country: row.country, population: Number(row.population) || 0,
|
||||
gsec: row.gsec, evangelical: row.evangelical, language: row.language, religion: row.religion,
|
||||
rop3: String(row.rop3 || ''), category: cat,
|
||||
},
|
||||
})
|
||||
}
|
||||
counts.value = c
|
||||
featuresAll = feats
|
||||
for (let i = 0; i < rows.length; i++) if (placed(rows[i])) feats.push(slimPoint(i, GSEC_CAT.indexOf(catOf(i))))
|
||||
recount()
|
||||
return { type: 'FeatureCollection', features: feats }
|
||||
}
|
||||
function recount() {
|
||||
const c = { doxa: 0, frontier: 0, unreached: 0, reached: 0 }
|
||||
for (let i = 0; i < rows.length; i++) if (placed(rows[i])) c[catOf(i)]++
|
||||
counts.value = c
|
||||
}
|
||||
// The DOXA set arrived: its pins go on the top layer (a ~2,100-point source) and their base duplicates
|
||||
// are hidden by feature-state. The 12,379-feature source is never rebuilt.
|
||||
function applyDoxa(doxaSet) {
|
||||
const m = map.value
|
||||
if (!m || !m.getSource('apg')) return
|
||||
const ids = new Set()
|
||||
for (let i = 0; i < rows.length; i++) if (placed(rows[i]) && doxaSet.has(String(rows[i].rop3 || ''))) ids.add(i)
|
||||
doxaIds = ids
|
||||
for (const i of ids) m.setFeatureState({ source: 'apg', id: i }, { doxa: true })
|
||||
m.getSource('apg-doxa').setData({ type: 'FeatureCollection', features: [...ids].map((i) => slimPoint(i, 0)) })
|
||||
recount()
|
||||
}
|
||||
|
||||
// The live DOXA set (rop3 = peoplegroups.org ROP3Code). Resolves to an empty set on failure.
|
||||
async function loadDoxaSet() {
|
||||
|
|
@ -263,9 +280,16 @@ async function loadDoxaSet() {
|
|||
return doxaSet
|
||||
}
|
||||
|
||||
// Pin size and ring, shared by the base and the DOXA layer.
|
||||
const PIN_RADIUS = ['interpolate', ['linear'], ['zoom'], 0, 2.2, 4, 3.4, 8, 6, 12, 9]
|
||||
const PIN_STROKE_WIDTH = ['interpolate', ['linear'], ['zoom'], 0, 0.3, 6, 0.6, 12, 1]
|
||||
// A base pin whose group is DOXA is hidden: its black twin is drawn on the layer above.
|
||||
const HIDDEN_IF_DOXA = (v) => ['case', ['boolean', ['feature-state', 'doxa'], false], 0, v]
|
||||
|
||||
function addLayer(fc) {
|
||||
const m = map.value
|
||||
m.addSource('apg', { type: 'geojson', data: fc })
|
||||
m.addSource('apg-doxa', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
m.addLayer({
|
||||
id: 'apg-pins', type: 'circle', source: 'apg',
|
||||
// ★ circle-sort-key IS A LAYOUT PROPERTY, NOT PAINT — and putting it in paint cost this
|
||||
|
|
@ -277,29 +301,43 @@ function addLayer(fc) {
|
|||
// Same failure shape as the *-emissive-strength drops fixed alongside this; the sort order
|
||||
// itself is unchanged, it just now lives where the style spec says it belongs.
|
||||
layout: {
|
||||
// DOXA above frontier above unreached above reached — the assignment stays visible.
|
||||
'circle-sort-key': ['match', ['get', 'category'], 'doxa', 4, 'frontier', 3, 'unreached', 2, 1],
|
||||
// frontier above unreached above reached (DOXA is the layer above all three).
|
||||
'circle-sort-key': ['match', ['get', 'c'], 0, 3, 1, 2, 1],
|
||||
},
|
||||
paint: {
|
||||
'circle-color': ['match', ['get', 'category'],
|
||||
'doxa', CAT_COLOR.doxa, 'frontier', CAT_COLOR.frontier,
|
||||
'unreached', CAT_COLOR.unreached, CAT_COLOR.reached],
|
||||
'circle-color': ['match', ['get', 'c'], 0, CAT_COLOR.frontier, 1, CAT_COLOR.unreached, CAT_COLOR.reached],
|
||||
// Thin high-contrast ring — visible, never clunky (the pin-visibility spec).
|
||||
// A thin DARK ring on the light basemap: separates overlapping pins and keeps the yellow and
|
||||
// green legible on near-white (a white ring was right only on the old dark map).
|
||||
'circle-stroke-color': '#1f2937',
|
||||
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 0, 0.3, 6, 0.6, 12, 1],
|
||||
'circle-stroke-opacity': 0.55,
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 2.2, 4, 3.4, 8, 6, 12, 9],
|
||||
'circle-opacity': 0.92,
|
||||
'circle-stroke-width': PIN_STROKE_WIDTH,
|
||||
'circle-stroke-opacity': HIDDEN_IF_DOXA(0.55),
|
||||
'circle-radius': PIN_RADIUS,
|
||||
'circle-opacity': HIDDEN_IF_DOXA(0.92),
|
||||
},
|
||||
})
|
||||
m.on('mouseenter', 'apg-pins', () => { m.getCanvas().style.cursor = 'pointer' })
|
||||
m.on('mouseleave', 'apg-pins', () => { m.getCanvas().style.cursor = '' })
|
||||
m.on('click', 'apg-pins', (e) => {
|
||||
const f = e.features && e.features[0]
|
||||
if (!f) return
|
||||
const p = f.properties
|
||||
// DOXA: the assignment, black, above everything else.
|
||||
m.addLayer({
|
||||
id: 'apg-doxa-pins', type: 'circle', source: 'apg-doxa',
|
||||
paint: {
|
||||
'circle-color': CAT_COLOR.doxa,
|
||||
'circle-stroke-color': '#1f2937', 'circle-stroke-width': PIN_STROKE_WIDTH, 'circle-stroke-opacity': 0.55,
|
||||
'circle-radius': PIN_RADIUS, 'circle-opacity': 0.92,
|
||||
},
|
||||
})
|
||||
for (const id of ['apg-pins', 'apg-doxa-pins']) {
|
||||
m.on('mouseenter', id, () => { m.getCanvas().style.cursor = 'pointer' })
|
||||
m.on('mouseleave', id, () => { m.getCanvas().style.cursor = '' })
|
||||
}
|
||||
// ONE click handler: a DOXA pin (top layer) wins over its hidden base twin at the same point.
|
||||
m.on('click', (e) => {
|
||||
const hits = m.queryRenderedFeatures(e.point, { layers: ['apg-doxa-pins', 'apg-pins'].filter((id) => m.getLayer(id)) })
|
||||
const f = hits.find((h) => h.layer.id === 'apg-doxa-pins' || !doxaIds.has(Number(h.id)))
|
||||
if (!f || rows[Number(f.id)] == null) return
|
||||
const i = Number(f.id), row = rows[i]
|
||||
const category = catOf(i)
|
||||
const p = { name: row.name, country: row.country, population: Number(row.population) || 0, gsec: row.gsec,
|
||||
evangelical: row.evangelical, language: row.language, religion: row.religion, rop3: String(row.rop3 || ''), category }
|
||||
const doxaRec = p.category === 'doxa' ? doxaBySlugRop.get(p.rop3) : null
|
||||
const cat = CATS.find(x => x.id === p.category)
|
||||
const pray = doxaRec && doxaRec.slug
|
||||
|
|
@ -329,16 +367,21 @@ function selectCat(id) {
|
|||
if (activeCat.value === id) { // click again = show everything
|
||||
activeCat.value = null
|
||||
m.setFilter('apg-pins', null)
|
||||
m.setLayoutProperty('apg-doxa-pins', 'visibility', 'visible')
|
||||
m.fitBounds([[-170, -55], [179, 72]], { padding: 20, duration: 900 })
|
||||
return
|
||||
}
|
||||
activeCat.value = id
|
||||
m.setFilter('apg-pins', ['==', ['get', 'category'], id])
|
||||
// DOXA lives on its own layer; the other three are the base layer's category numbers (a DOXA group's
|
||||
// base twin stays hidden by feature-state, so a GSEC filter never shows it twice).
|
||||
if (id === 'doxa') m.setFilter('apg-pins', ['==', ['get', 'c'], -1])
|
||||
else m.setFilter('apg-pins', ['==', ['get', 'c'], GSEC_CAT.indexOf(id)])
|
||||
m.setLayoutProperty('apg-doxa-pins', 'visibility', id === 'doxa' ? 'visible' : 'none')
|
||||
// Zoom OUT to see every dot of the chosen category (the visibility spec).
|
||||
let minX = 180, minY = 85, maxX = -180, maxY = -85, n = 0
|
||||
for (const f of featuresAll) {
|
||||
if (f.properties.category !== id) continue
|
||||
const [x, y] = f.geometry.coordinates
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (!placed(rows[i]) || catOf(i) !== id) continue
|
||||
const x = Number(rows[i].lng), y = Number(rows[i].lat)
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y
|
||||
n++
|
||||
|
|
@ -359,15 +402,16 @@ onMounted(async () => {
|
|||
if (!map.value) { if (mapContainer.value) mapError.value = tStatic('messages.mapUnavailable'); return }
|
||||
map.value.once('style.load', async () => {
|
||||
try {
|
||||
const rows = await rowsPromise
|
||||
if (!rows || !map.value) return
|
||||
const loaded = await rowsPromise
|
||||
if (!loaded || !map.value) return
|
||||
rows = loaded
|
||||
setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2)
|
||||
addLayer(buildFeatures(rows, new Set())) // every pin, by GSEC — the first paint
|
||||
addLayer(buildBase()) // every pin, by GSEC — the first paint
|
||||
mapStore?.registerMap?.('all-people-groups', map.value)
|
||||
appReady.value = true
|
||||
// DOXA overlay: recolour when the prayer API answers, whenever that is.
|
||||
const doxaSet = await doxaPromise
|
||||
if (doxaSet.size && map.value && map.value.getSource('apg')) map.value.getSource('apg').setData(buildFeatures(rows, doxaSet))
|
||||
if (doxaSet.size && map.value) applyDoxa(doxaSet)
|
||||
doxaReady.value = true
|
||||
} catch (err) { mapError.value = String(err?.message || err) }
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue