People-group pins say what they are: one shared note, and the groups that share a point are named

A people-group pin is a representative point, not an address, and some pins ARE the same point: on the
Doxa roster 2,107 groups sit on 2,048 coordinates, 108 of them on a point shared with another (gospel-
plugin's measurement). Every map with people-group pins must say so, in one wording.

- library/components/legends/ApproxPinNote.vue: "The dot marks where this people is found, not a town or a
  boundary." always; when the selected pin's point is shared, "It sits on the same point as <names> — <n>
  groups share this coordinate…". Never prints the raw coordinate. Wording: gospel-plugin's P5 proposal,
  in a new pins catalogue (11 locales).
- library/utils/colocation.js: the co-location index, built from the roster THE MAP draws (rows or
  GeoJSON features), never a hardcoded count.
- LegendDesktop and LegendMobile (research map, simple map, country posters, cities, UPG-100) show the
  note under the legend, and under the detail card with the shared names.
- check:approx-pin-note (headless, gates:post): the index, the two sentences, no coordinate, Arabic.
This commit is contained in:
Doxa Maps 2026-09-26 11:56:13 +00:00
parent 29feabb9a0
commit 1ac32fe3d0
17 changed files with 202 additions and 8 deletions

View file

@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* check-approx-pin-note — people-group pins say what they are, in one shared wording (APPROXPIN). Headless.
*
* ASSERTS (1) library/utils/colocation.js: groups on the same coordinate find each other (pairs, triples),
* a lone point shares with nobody, rows without a position are ignored, and both roster rows and GeoJSON
* features are read; (2) ApproxPinNote renders the first sentence always, the named second sentence only
* for a shared point, never a raw coordinate, and in Arabic from the Arabic catalogue.
* EXIT 0 pass · 1 fail
*/
import { readFileSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildColocationIndex, colocatedWith } from '../library/utils/colocation.js'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
let bad = 0
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + JSON.stringify(got)}`) }
const rows = [
{ name: 'Godda', latitude: 12.843781, longitude: 75.247906 },
{ name: 'Koraga', latitude: '12.843781', longitude: '75.247906' },
{ type: 'Feature', properties: { name: 'Maila' }, geometry: { type: 'Point', coordinates: [75.247906, 12.843781] } },
{ name: 'Alone', latitude: 10, longitude: 10 },
{ name: 'NoPos', latitude: '', longitude: '' },
]
const idx = buildColocationIndex(rows)
check('three groups on one point find each other', colocatedWith(idx, rows[0]).sort().join() === 'Koraga,Maila', colocatedWith(idx, rows[0]))
check('a GeoJSON feature is matched to rows on the same point', colocatedWith(idx, rows[2]).sort().join() === 'Godda,Koraga', colocatedWith(idx, rows[2]))
check('a lone point shares with nobody', colocatedWith(idx, rows[3]).length === 0, colocatedWith(idx, rows[3]))
check('a row without a position is ignored', colocatedWith(idx, rows[4]).length === 0, colocatedWith(idx, rows[4]))
const { createServer } = await import('vite')
const { default: vue } = await import('@vitejs/plugin-vue')
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, plugins: [vue()], resolve: { alias: { '@map': join(ROOT, 'library') } } })
try {
globalThis.__INTLIFY_JIT_COMPILATION__ = true
const Note = (await server.ssrLoadModule('/library/components/legends/ApproxPinNote.vue')).default
const { createSSRApp, h } = await import('vue')
const { createI18n } = await import('vue-i18n')
const { renderToString } = await import('vue/server-renderer')
const render = async (lc, props) => {
const pins = JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales', lc, 'pins.json'), 'utf8'))
const app = createSSRApp({ render: () => h(Note, props) })
app.use(createI18n({ legacy: false, locale: lc, messages: { [lc]: { pins } } }))
return (await renderToString(app)).replace(/<[^>]+>/g, '').replace(/<!--.*?-->/g, '').trim()
}
const one = await render('en', {})
check('first sentence always', /not a town or a boundary/.test(one) && !/same point/.test(one), one)
const two = await render('en', { shared: ['Koraga', 'Maila'] })
check('shared point names the others and counts all', /Koraga, Maila/.test(two) && /3 groups share/.test(two), two)
check('never prints a raw coordinate', !/\d+\.\d{3,}/.test(one + two), one + two)
const ar = await render('ar', { shared: ['Koraga'] })
check('Arabic renders from the Arabic catalogue', /[؀-ۿ]/.test(ar) && /Koraga/.test(ar), ar)
} finally { await server.close() }
if (bad) { console.log(`APPROX-PIN-NOTE: FAIL — ${bad} case(s).`); process.exit(1) }
console.log('APPROX-PIN-NOTE: PASS — pins say they are localities, and name the groups that share a point.')

View file

@ -0,0 +1,29 @@
<!--
ApproxPinNote.vue — the ONE wording every map with people-group pins uses (APPROXPIN, 2026-09-26).
The first sentence always: a dot is where a people is found, not a town or a boundary. The second only
when the pin's point is SHARED, naming the other groups (from library/utils/colocation.js, built from the
roster the map draws), because "approximate" alone hides that some dots are the same dot.
Never shows the raw coordinate: six decimal places beside a caveat that says "approximate" reads as a
contradiction. Wording: gospel-plugin's measured proposal (P5), in library/i18n/locales/<lc>/pins.json.
-->
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
/** Names of the OTHER groups on this pin's coordinate; empty = not shared. */
shared: { type: Array, default: () => [] },
dark: { type: Boolean, default: false },
})
const { t } = useI18n()
const second = computed(() => props.shared.length
? t('pins.sharedPoint', { names: props.shared.join(', '), n: props.shared.length + 1 })
: '')
</script>
<template>
<p class="approx-pin-note" :style="{ margin: '6px 10px 4px', font: '400 11px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif', color: dark ? 'rgba(243,243,241,0.7)' : '#5b6470' }">
{{ t('pins.approximate') }}<template v-if="second"> {{ second }}</template>
</p>
</template>

View file

@ -12,6 +12,8 @@ import { useLegendData } from '../../composables/legends/useLegendData.js'
import { retryableAsyncComponent } from '../../composables/retryableAsyncComponent.js'
import LegendRows from './LegendRows.vue'
import PeopleGroupDetail from '../PeopleGroupDetail.vue'
import ApproxPinNote from './ApproxPinNote.vue'
import { buildColocationIndex, colocatedWith } from '../../utils/colocation.js'
// Async-import the heavier branches so the boot bundle stays lean.
const LegendMobile = retryableAsyncComponent(() => import('./LegendMobile.vue'))
// Language-family tab is now handled by <SemanticTreeLegend> mounted directly
@ -253,6 +255,9 @@ const isMobile = computed(() => uiStore.isMobile)
// Legend mode (prayer progress ↔ people group detail)
const legendMode = computed(() => uiStore.legendMode)
const selectedPeopleGroup = computed(() => uiStore.selectedPeopleGroup)
// APPROXPIN: the other groups on the selected pin's coordinate, from the roster THIS map draws.
const colocation = computed(() => buildColocationIndex(peopleGroupsForFamilyTree.value))
const selectedShared = computed(() => (selectedPeopleGroup.value ? colocatedWith(colocation.value, selectedPeopleGroup.value) : []))
// Reactive data
const isCollapsed = ref(props.initiallyCollapsed)
@ -398,13 +403,15 @@ onBeforeUnmount(() => {
</button>
<div class="legend-content" :class="{ 'no-padding': legendColumns.length > 0 }">
<PeopleGroupDetail
v-if="legendMode === 'detail' && selectedPeopleGroup"
:peopleGroup="selectedPeopleGroup"
:hideHeader="true"
:dark="isDark"
:action="popupAction"
/>
<template v-if="legendMode === 'detail' && selectedPeopleGroup">
<PeopleGroupDetail
:peopleGroup="selectedPeopleGroup"
:hideHeader="true"
:dark="isDark"
:action="popupAction"
/>
<ApproxPinNote :shared="selectedShared" :dark="isDark" />
</template>
<template v-else-if="legendType === 'language-family'"></template>
<LegendRows
v-else
@ -432,6 +439,7 @@ onBeforeUnmount(() => {
</button>
</template>
</LegendRows>
<ApproxPinNote v-if="legendMode !== 'detail'" :dark="isDark" />
</div>
</div>

View file

@ -10,6 +10,8 @@ import { useLanguageFamilyLegendData } from '../../composables/legends/useLangua
import { useAffinityBlockLegendData } from '../../composables/legends/useAffinityBlockLegendData.js'
import { useWagfRegionsLegendData } from '../../composables/legends/useWagfRegionsLegendData.js'
import PeopleGroupDetail from '../PeopleGroupDetail.vue'
import ApproxPinNote from './ApproxPinNote.vue'
import { buildColocationIndex, colocatedWith } from '../../utils/colocation.js'
const { t } = useI18n()
@ -521,6 +523,13 @@ const caretRotation = computed(() => uiStore.caretRotation)
const showPullTab = computed(() => uiStore.showPullTab)
const legendMode = computed(() => uiStore.legendMode)
const selectedPeopleGroup = computed(() => uiStore.selectedPeopleGroup)
// APPROXPIN: the other groups on the selected pin's coordinate, from the roster THIS map draws.
const colocation = computed(() => {
const rows = []
for (const id in (dataStore?.sources || {})) { const src = dataStore.sources[id]; if (src?.features?.length) rows.push(...src.features) }
return buildColocationIndex(rows)
})
const selectedShared = computed(() => (selectedPeopleGroup.value ? colocatedWith(colocation.value, selectedPeopleGroup.value) : []))
// Legend title — swaps based on legend state and selection
const legendTitle = computed(() => {
@ -804,6 +813,7 @@ onBeforeUnmount(() => {
:dark="isDark"
:action="popupAction"
/>
<ApproxPinNote v-if="legendState !== 'collapsed'" :shared="selectedShared" :dark="isDark" />
</template>
<!-- Semantic-tree tabs (Languages, People Groups, WAGF Regions):
@ -865,6 +875,7 @@ onBeforeUnmount(() => {
</button>
</template>
</LegendRows>
<ApproxPinNote v-if="legendMode !== 'detail' && legendState !== 'collapsed'" :dark="isDark" />
</div>
</div>
</div><!-- /.legend-sheet-root -->

View file

@ -0,0 +1,4 @@
{
"approximate": "تشير النقطة إلى المكان الذي يوجد فيه هذا الشعب، وليست بلدة أو حدودًا.",
"sharedPoint": "تقع في النقطة نفسها مع {names} — {n} مجموعات تتشارك هذا الإحداثي، لذا يدل الموقع على المنطقة لا على مكان كل مجموعة."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "Der Punkt zeigt, wo dieses Volk lebt – nicht eine Stadt oder eine Grenze.",
"sharedPoint": "Er liegt auf demselben Punkt wie {names} — {n} Gruppen teilen sich diese Koordinate; die Position nennt also die Gegend, nicht wo welche Gruppe lebt."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "The dot marks where this people is found, not a town or a boundary.",
"sharedPoint": "It sits on the same point as {names} — {n} groups share this coordinate, so the position tells you the locality and not which group is where."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "El punto indica dónde se encuentra este pueblo, no una ciudad ni una frontera.",
"sharedPoint": "Está en el mismo punto que {names}: {n} grupos comparten esta coordenada, así que la posición indica la zona y no dónde está cada grupo."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "Le point indique où vit ce peuple, pas une ville ni une frontière.",
"sharedPoint": "Il se trouve au même point que {names} — {n} groupes partagent cette coordonnée : la position indique la région, pas où se trouve chaque groupe."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "बिंदु दिखाता है कि यह लोग कहाँ पाए जाते हैं, कोई शहर या सीमा नहीं।",
"sharedPoint": "यह {names} के साथ एक ही बिंदु पर है — {n} समूह यह निर्देशांक साझा करते हैं, इसलिए स्थान क्षेत्र बताता है, यह नहीं कि कौन-सा समूह कहाँ है।"
}

View file

@ -0,0 +1,4 @@
{
"approximate": "Il punto indica dove si trova questo popolo, non una città né un confine.",
"sharedPoint": "Si trova nello stesso punto di {names} — {n} gruppi condividono questa coordinata, quindi la posizione indica la zona e non dove si trova ciascun gruppo."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "O ponto indica onde este povo se encontra, não uma cidade nem uma fronteira.",
"sharedPoint": "Está no mesmo ponto que {names} — {n} grupos partilham esta coordenada, por isso a posição indica a região e não onde está cada grupo."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "Punctul arată unde se află acest popor, nu un oraș sau o graniță.",
"sharedPoint": "Se află în același punct cu {names} — {n} grupuri împart această coordonată, deci poziția arată zona, nu unde se află fiecare grup."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "Точка показывает, где живёт этот народ, а не город и не границу.",
"sharedPoint": "Она совпадает с точкой {names} — {n} групп делят эту координату, поэтому положение указывает местность, а не где какая группа."
}

View file

@ -0,0 +1,4 @@
{
"approximate": "此点标示该民族所在的地区,而非某个城镇或边界。",
"sharedPoint": "它与 {names} 位于同一点 — {n} 个群体共用此坐标,因此位置只表示所在地区,而非各群体的具体位置。"
}

View file

@ -0,0 +1,42 @@
/**
* colocation.js — which people groups share ONE coordinate on a map (APPROXPIN, 2026-09-26).
*
* A people-group pin is a representative point, not an address, and some pins ARE THE SAME POINT:
* measured on the Doxa roster (gospel-plugin): 2,107 groups on 2,048 distinct coordinates, 108 groups on
* a point shared with another (41 pairs, 6 triples, 2 quadruples). Saying "approximate" hides that; naming
* the co-located groups is the only version a reader can check. The index is built from THE ROSTER THE MAP
* DRAWS (every map's roster differs), never hardcoded.
*/
const KEY = (lat, lng) => `${Number(lat).toFixed(6)},${Number(lng).toFixed(6)}`
/** Read [lat, lng] from a roster row or a GeoJSON feature; null when it has no usable position. */
export function pointOf(r) {
if (!r) return null
const c = r.geometry && r.geometry.coordinates
const p = r.properties || r
const lat = c ? c[1] : (p.latitude ?? p.lat), lng = c ? c[0] : (p.longitude ?? p.lng ?? p.lon)
return Number.isFinite(Number(lat)) && Number.isFinite(Number(lng)) && lat !== '' && lng !== '' ? [Number(lat), Number(lng)] : null
}
const nameOf = (r) => { const p = (r && (r.properties || r)) || {}; return p.name || p.peopleName || p.PeopNameInCountry || '' }
/** Build once per roster: Map<coordinate key, names[]>. */
export function buildColocationIndex(rows) {
const idx = new Map()
for (const r of rows || []) {
const pt = pointOf(r); const n = nameOf(r)
if (!pt || !n) continue
const k = KEY(pt[0], pt[1])
if (!idx.has(k)) idx.set(k, [])
idx.get(k).push(n)
}
return idx
}
/** The OTHER groups on this row's point ([] when the point is not shared). */
export function colocatedWith(idx, row) {
const pt = pointOf(row); if (!idx || !pt) return []
const me = nameOf(row)
const all = idx.get(KEY(pt[0], pt[1])) || []
const others = all.filter((n) => n !== me)
return all.length > 1 ? [...new Set(others)] : []
}

View file

@ -19,6 +19,7 @@
"capability-exemplars": "node internal/generate-capability-exemplars.mjs",
"check:a11y-button-names": "node internal/check-a11y-button-names.mjs",
"check:app-start-page": "node internal/set-app-start-page.mjs --check",
"check:approx-pin-note": "node internal/check-approx-pin-note.mjs",
"check:auto-engine": "node internal/check-auto-engine.mjs",
"check:backlog-refs": "node internal/check-backlog-refs.mjs",
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
@ -207,7 +208,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -259,6 +260,7 @@
"cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
"capability-exemplars": "regenerate documentation/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).",
"check:a11y-button-names": "every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `<button>` hits, most with visible text). Needs a browser, so NOT in build:unlocked and NOT in CI. ALL=1 sweeps every profile.",
"check:approx-pin-note": "people-group pins say they are localities, not towns or boundaries, in ONE shared wording (library/components/legends/ApproxPinNote.vue): the colocation index finds groups sharing a coordinate, the note names them only for a shared point, never prints a raw coordinate, and renders from each locale's catalogue. Headless.",
"check:auto-engine": "BROWSER. The default engine is auto: on one map leaf, no token draws with MapLibre and makes zero api.mapbox.com requests, a token draws with Mapbox, and an explicit provider wins. Under the fleet browser policy it is a rega run, not part of gates:post.",
"check:backlog-refs": "a card may not depend on a card that does not exist. A dangling depends_on can never be done, so the card can never become ready and nothing says so — it reads as ordinary blocked work forever. FAILS only for depends_on on an OPEN card; a dangling parent or a stale link on a done card is REPORTED, because failing on filing debt teaches people to delete the reference rather than restore the card. Reads all three phase backlogs — references legitimately cross phases. SKIPs when _qa-cycle is absent.",
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.",