Deep Research, Posterverse and the site's maps from the dx-maps output folder

- public/dx-maps: the dx-maps output folder (doxa-maps 3a6d392a1), copied unchanged.
- Home, Pray, Adopt and Research: their maps load from /dx-maps/ with the same presets. They use Mapbox with the
  site's token, as before, and the keyless MapLibre when there is none. Under the Research map, one new line
  links to /deep-research.
- /regions: each region card gets a "View country posters" toggle (app/components/PosterSlot.vue) that opens the
  region's country posters from /dx-maps/; a poster's "Show dashboard" opens the country's own page.
- /deep-research: every research map, dashboard and list in one full-page app (no site header or footer;
  "Back to homepage" in the app's menu). Hidden: in no menu, not indexed.
- /posterverse: the poster plugins in one full-page app, hidden the same way.
- Bless India Posters on the Experimental shelf of /deep-research and /posterverse.
- Each app's own language menu switches the site's language (the language is in the address); the open tab
  carries over.
- The open tab of /deep-research and /posterverse is in the address (the site's router writes it), so a shared
  link, a reload, Back and Forward keep it.
- Each page asks for a fresh copy of its dx-maps script before adding it, so a returning reader never runs a
  stale one.
- Every other file is as it was.
This commit is contained in:
Doxa Life 2026-10-03 12:23:49 +00:00
parent fc4d6228f8
commit 9dd2af08eb
1393 changed files with 811542 additions and 22 deletions

View file

@ -0,0 +1,179 @@
<script lang="ts">
// The posters' script is shared by every PosterSlot on the page: it loads once, on the first opening anywhere.
const POSTERS_TAG = 'dxm-poster-section'
const POSTERS_SCRIPT = '/dx-maps/country-posters/country-posters.js'
// FRESH, NOT STALE: a returning reader's browser may keep /dx-maps/ files for hours (no cache rule is sent for them), so
// the script is asked for once with cache 'no-cache' before it is added (a 304 when unchanged; a changed file replaces the
// stale copy), then added at its PLAIN address, so DX Maps' gallery still recognises it and never runs it twice.
function revalidate(src: string): Promise<void> {
return fetch(src, { cache: 'no-cache', credentials: 'same-origin' })
.then((r) => { if (r.status === 200 && r.body) r.body.cancel() })
.catch(() => {})
}
let postersLoad: Promise<void> | null = null
function loadPosters(prayBaseUrl: string): Promise<void> {
if (customElements.get(POSTERS_TAG)) return Promise.resolve()
postersLoad ??= revalidate(POSTERS_SCRIPT).then(() => new Promise<void>((resolve, reject) => {
// the prayer API the posters read is set before the script runs
if (prayBaseUrl) window.MAP_APP_API_URL = prayBaseUrl
const script = document.createElement('script')
script.src = POSTERS_SCRIPT
script.onload = () => customElements.get(POSTERS_TAG) ? resolve() : reject(new Error('the posters bundle did not register'))
script.onerror = () => reject(new Error('the posters bundle failed to load'))
document.head.appendChild(script)
})).catch((err) => {
postersLoad = null // a later opening tries again
throw err
})
return postersLoad
}
</script>
<script setup lang="ts">
import { countrySlug } from '~~/config/countries-meta'
/**
* PosterSlot: a layout component, like DoxaMapSlot. A "View country posters" disclosure that, when opened,
* drops down and embeds DX Maps' poster section for one region (<dxm-poster-section>, from this site's own
* /dx-maps/ copy). The posters are DX Maps'; this only places them and owns the toggle, so the element is
* asked for no header of its own (show-header="false").
*
* Its markup and look are the Regions plugin's card (DX Maps library/components/regions/RegionSectionCard.vue,
* itself taken from this site's /regions card): a second footer row under the card's dashboard link, a caret
* that turns to point down when open (the closed caret mirrors in RTL like the site's rotate-90; the open one
* does not, like rotate-180), aria-expanded and aria-controls, and the posters open underneath.
*/
interface Props {
/** The region's /regions slug, e.g. "latin-america-and-caribbean": the poster section's scope. */
scope: string
/** The poster section's type. */
type?: string
}
const props = withDefaults(defineProps<Props>(), { type: 'country' })
const { locale } = useI18n()
const localePath = useLocalePath()
const runtimeConfig = useRuntimeConfig()
// DX Maps' own words for the two states (library/i18n/locales/<lang>/regions.json), in each language the site
// has; English for any other.
const LABELS: Record<string, [string, string]> = {
en: ['View country posters', 'Hide country posters'],
ar: ['عرض ملصقات الدولة', 'إخفاء ملصقات الدولة'],
de: ['Länderposter ansehen', 'Länderposter ausblenden'],
es: ['Ver los carteles del país', 'Ocultar los carteles del país'],
fr: ['Voir les affiches du pays', 'Masquer les affiches du pays'],
hi: ['देश के पोस्टर देखें', 'देश के पोस्टर छिपाएँ'],
it: ['Vedi i poster del paese', 'Nascondi i poster del paese'],
pt: ['Ver os cartazes do país', 'Ocultar os cartazes do país'],
ro: ['Vezi posterele țării', 'Ascunde posterele țării'],
ru: ['Показать плакаты страны', 'Скрыть плакаты страны'],
zh: ['查看国家海报', '隐藏国家海报']
}
const open = ref(false)
const label = computed(() => (LABELS[locale.value] || LABELS.en)[open.value ? 1 : 0])
const disclosureId = `poster-slot-${props.scope}`
const posters = ref<HTMLElement | null>(null)
async function toggle() {
open.value = !open.value
if (!open.value || !posters.value || posters.value.firstElementChild) return
const prayBaseUrl = (runtimeConfig.public.prayBaseUrl as string) || ''
try {
await loadPosters(prayBaseUrl)
} catch (err) {
console.error('[poster slot]', err)
return
}
const section = document.createElement(POSTERS_TAG)
section.setAttribute('type', props.type)
section.setAttribute('scope', props.scope)
section.setAttribute('locale', locale.value)
section.setAttribute('show-header', 'false')
section.setAttribute('min-card-px', '150')
section.setAttribute('collapsible', 'false')
// each poster card's button reads "Show dashboard" and asks for the country's own page (the site decides the address)
section.setAttribute('card-action', 'dashboard')
section.setAttribute('assets-base', '/dx-maps/assets')
if (prayBaseUrl) section.setAttribute('api-base-url', prayBaseUrl)
posters.value.appendChild(section)
}
// "Show dashboard" on a poster card: the element sends dxm-poster-dashboard ({ iso3, name, … }, composed) and this goes
// to the country's own page, /regions/<slug>, by the same slug rule as the regions page's country links.
function onDashboard(event: Event) {
const detail = (event as CustomEvent<{ iso3?: string, name?: string }>).detail
if (!detail?.iso3) return
event.preventDefault()
navigateTo(localePath(`/regions/${countrySlug(detail.iso3, detail.name)}`))
}
onMounted(() => posters.value?.addEventListener('dxm-poster-dashboard', onDashboard))
onBeforeUnmount(() => posters.value?.removeEventListener('dxm-poster-dashboard', onDashboard))
</script>
<template>
<p class="region-card__footer region-card__posters-toggle">
<button
type="button"
class="region-card__footer-link | with-icon"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="disclosureId"
@click="toggle"
>
{{ label }}
<svg
class="icon | right"
:class="open ? 'rotate-180' : 'rotate-90'"
aria-hidden="true"
>
<use href="/assets/icons/arrow-chevron.svg#chevron-up" />
</svg>
</button>
</p>
<div
v-show="open"
:id="disclosureId"
ref="posters"
class="region-card__posters"
/>
</template>
<style scoped>
/* The region card's footer row, as /regions draws it (flex, flex-end, margin-top --spacing-lg); the toggle row
sits directly under the dashboard link's row (the Regions plugin's .region-card__posters-toggle). */
.region-card__footer {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-lg);
}
.region-card__posters-toggle {
/* a little room under the dashboard link (the Driver: "a little bit more") */
margin-top: var(--spacing-xs);
}
/* A <button> that reads as the footer's link: the site's own `a` rule (base/_globals.scss). */
.region-card__footer-link {
appearance: none;
background: none;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
font: inherit;
color: var(--color-brand-light);
font-weight: var(--font-weight-medium);
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
transition: color 0.3s ease;
}
.region-card__footer-link:hover {
color: var(--color-brand-lighter);
}
.region-card__posters {
margin-top: 13.3px;
}
</style>

View file

@ -29,11 +29,133 @@ onMounted(() => reload())
const config = useRuntimeConfig()
const mapboxToken = config.public.mapboxToken as string
const adoptMapConfig = JSON.stringify({
// THE MAP comes from this site's own copy of the dx-maps output (public/dx-maps/) and is loaded here,
// not through DoxaMapSlot, which loads the older bundle in public/js/. Mapbox with the site's token, as
// before; the keyless MapLibre when there is none. Its styles and boundaries live in the same tree, and
// the site's language is the map's.
const adoptMapConfig = computed(() => JSON.stringify({
profile: 'doxa-simple-map',
dataSource: 'pray-tools',
tk: mapboxToken,
tabs: [{ id: 'adoption', colorStrategy: 'adoption', legend: 'adoption', popup: 'adoption' }]
tabs: [{ id: 'adoption', colorStrategy: 'adoption', legend: 'adoption', popup: 'adoption' }],
provider: mapboxToken ? 'mapbox' : 'maplibre',
...(mapboxToken ? { tk: mapboxToken } : {}),
assetsBase: '/dx-maps/assets',
locale: locale.value
}))
// THE MAP'S ELEMENT, <dxm-simple-map>: DX Maps' own name for it, so it never meets the older <doxa-map> elements
// elsewhere on the site, in whatever order the pages are visited. Its bundle comes from this site's own copy of
// the dx-maps output and is added once per visit. A load in progress is shared, and a failed load takes its tag
// away again, so a later page tries afresh; the page then shows without the map.
const MAP_TAG = 'dxm-simple-map'
const MAP_SCRIPT = '/dx-maps/doxa-simple-map/doxa-simple-map.js'
// FRESH, NOT STALE: a returning reader's browser may keep /dx-maps/ files for hours (no cache rule is sent for them), so
// the script is asked for once with cache 'no-cache' before it is added (a 304 when unchanged; a changed file replaces the
// stale copy), then added at its PLAIN address, so DX Maps' gallery still recognises it and never runs it twice.
function revalidate(src: string): Promise<void> {
return fetch(src, { cache: 'no-cache', credentials: 'same-origin' })
.then((r) => { if (r.status === 200 && r.body) r.body.cancel() })
.catch(() => {})
}
function loadMapBundle(): Promise<void> {
if (customElements.get(MAP_TAG)) return Promise.resolve()
return revalidate(MAP_SCRIPT).then(() => addMapBundle())
}
function addMapBundle(): Promise<void> {
if (customElements.get(MAP_TAG)) return Promise.resolve()
const pending = document.querySelector<HTMLScriptElement>('script[data-dxl-map="' + MAP_TAG + '"]')
if (pending) {
return new Promise((resolve, reject) => {
pending.addEventListener('load', () => customElements.get(MAP_TAG) ? resolve() : reject(new Error('the map bundle did not register')), { once: true })
pending.addEventListener('error', () => reject(new Error('the map bundle failed to load')), { once: true })
})
}
return new Promise((resolve, reject) => {
const script = document.createElement('script')
const fail = (message: string) => {
script.remove()
reject(new Error(message))
}
script.src = MAP_SCRIPT
script.dataset.dxlMap = MAP_TAG
script.onload = () => customElements.get(MAP_TAG) ? resolve() : fail('the map bundle did not register')
script.onerror = () => fail('the map bundle failed to load')
document.head.appendChild(script)
})
}
// THE PHONE HEIGHT, LOCKED IN PX: 80% of the window's height when the page opens (never under 320px), so the map
// box does not grow and shrink while the browser's address bar collapses and expands during a scroll, which also
// broke a drag on the map. It is measured again only when the WIDTH changes (a rotation), never on a height-only
// resize. Wider screens keep the stylesheet's height; 80svh there is the fallback until this runs.
// A phone: a narrow screen, or a touch screen held sideways (the same test as the stylesheet's phone rules).
const PHONE_QUERY = '(max-width: 768px), (pointer: coarse) and (max-height: 600px)'
function lockPhoneHeight(slot: HTMLElement): () => void {
const PHONE = window.matchMedia(PHONE_QUERY)
let width = -1
const lock = () => {
if (window.innerWidth === width) return
width = window.innerWidth
slot.style.height = PHONE.matches ? `${Math.max(320, Math.round(window.innerHeight * 0.8))}px` : ''
}
lock()
window.addEventListener('resize', lock)
return () => window.removeEventListener('resize', lock)
}
let unlockHeight: (() => void) | null = null
onMounted(() => { if (adoptMapHost.value) unlockHeight = lockPhoneHeight(adoptMapHost.value) })
onBeforeUnmount(() => unlockHeight?.())
// THE SCROLL BAND STEPS ASIDE FOR THE MAP'S PANELS. A panel the map's controls open (Share, and every other menu)
// sits at the map's top, under the band, so its ✕ could not be tapped. DX Maps says when a panel opens (the composed
// 'dxmaps:panel-open') and when it closes ('dxmaps:panel-close'). The band counts them (another panel can still be open
// when one closes) and stays off while the count is above 0; for builds without the close event, it also comes back
// after any tap once no panel is open in the map any more.
const padOff = ref(false)
let openPanels = 0
function mapPanelOpen(): boolean {
const walk = (node: Element | ShadowRoot): boolean => {
for (const n of node.querySelectorAll('*')) {
if (n.classList.contains('mcm')) return true
if (n.shadowRoot && walk(n.shadowRoot)) return true
}
return false
}
return !!adoptMap && walk(adoptMap.shadowRoot || adoptMap)
}
const onPanelOpen = () => { openPanels++; padOff.value = true }
const onPanelClose = () => { openPanels = Math.max(0, openPanels - 1); padOff.value = openPanels > 0 }
const onAnyTap = () => {
if (padOff.value) setTimeout(() => { if (!mapPanelOpen()) { openPanels = 0; padOff.value = false } }, 50)
}
onMounted(() => {
adoptMapHost.value?.addEventListener('dxmaps:panel-open', onPanelOpen)
adoptMapHost.value?.addEventListener('dxmaps:panel-close', onPanelClose)
document.addEventListener('click', onAnyTap, true)
})
onBeforeUnmount(() => {
adoptMapHost.value?.removeEventListener('dxmaps:panel-open', onPanelOpen)
adoptMapHost.value?.removeEventListener('dxmaps:panel-close', onPanelClose)
document.removeEventListener('click', onAnyTap, true)
})
// The prayer API the map reads is set before its script runs; the element is created once the bundle is in.
const adoptMapHost = ref<HTMLElement | null>(null)
let adoptMap: HTMLElement | null = null
onMounted(async () => {
if (config.public.prayBaseUrl) window.MAP_APP_API_URL = config.public.prayBaseUrl as string
try {
await loadMapBundle()
} catch (err) {
console.error('[adopt map]', err)
return
}
if (!adoptMapHost.value) return
adoptMap = document.createElement(MAP_TAG)
adoptMap.id = 'adopt-map'
adoptMap.setAttribute('profile-config', adoptMapConfig.value)
adoptMapHost.value.prepend(adoptMap)
})
watch(adoptMapConfig, (value) => { adoptMap?.setAttribute('profile-config', value) })
onBeforeUnmount(() => {
adoptMap?.remove()
adoptMap = null
})
useTextHighlight()
@ -87,7 +209,9 @@ useTextHighlight()
data-highlight-color="primary"
>{{ t('Adoption Progress') }}</h1>
</div>
<DoxaMapSlot map-id="adopt-map" :profile-config="adoptMapConfig" class="rounded-md" />
<div ref="adoptMapHost" class="doxa-map-slot rounded-md">
<div class="doxa-map-scroll-pad doxa-map-scroll-pad--top" :class="{ 'is-off': padOff }" />
</div>
<NuxtLink
:to="localePath('/research')"
class="research-map-link"
@ -310,4 +434,60 @@ useTextHighlight()
.research-map-link:hover {
color: var(--color-brand-primary-darker, #1f2328);
}
/* The map's frame, as DoxaMapSlot draws it. */
.doxa-map-slot {
display: block;
position: relative;
width: 100%;
min-height: 780px;
aspect-ratio: 16 / 9;
overflow: hidden;
}
@media (max-width: 768px), (pointer: coarse) and (max-height: 600px) {
/* On a phone the map is 80% of the SMALL viewport height, a little shorter than the screen so the page
stays in view (svh does not jump when the browser's address bar collapses), never under 320px. */
.doxa-map-slot {
min-height: 320px;
aspect-ratio: auto;
height: 80svh;
}
}
.doxa-map-slot :deep(dxm-simple-map) {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: inherit;
overflow: hidden;
contain: paint;
}
.doxa-map-scroll-pad {
display: none;
}
@media (max-width: 768px), (pointer: coarse) and (max-height: 600px) {
.doxa-map-scroll-pad {
display: block;
position: absolute;
/* stops short of the map's control column at the inline end (32px round buttons, 3px in), so the + and
the other controls stay tappable; the rest of the band still lets a finger scroll the page */
inset-inline-start: 0;
inset-inline-end: 44px;
height: 48px;
z-index: 1500;
touch-action: pan-y;
pointer-events: auto;
}
.doxa-map-scroll-pad--top { top: 0; }
/* while a map panel is open, its ✕ and its contents get every tap */
.doxa-map-scroll-pad.is-off { display: none; }
}
.doxa-map-slot :deep(dxm-simple-map:fullscreen),
.doxa-map-slot :deep(dxm-simple-map:-webkit-full-screen) {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
}
</style>

View file

@ -0,0 +1,214 @@
<script setup lang="ts">
// Deep Research: the Research Gallery (every research map, dashboard and list in one app) as a
// full-page app. DX Life asked for the gallery under this name, on a page of the site rather than a
// subdomain, and kept off the Research page. So this page is HIDDEN: it is in no menu, and robots are
// told not to index it. It is reached by its address, or by the link under the Research page's map.
//
// The gallery is a web component served from DX-life's OWN origin (public/dx-maps/, a copy of
// the dx-maps build tree), so this site is its CDN: one script, one element, and no other host for the
// map code (the engine and base-map tiles come from public services).
// It reads its manifest and every child map from the same /dx-maps/ tree.
//
// The element is created in onMounted rather than written in the template: it is a custom element
// that only exists in the browser, so this keeps it out of SSR and out of Vue's component resolver
// without any nuxt.config change.
// A full-page app on every screen: no layout, so no site header and no footer. The way back to the site
// is the gallery's own "Back to homepage" button, directly above its Collapse button (home-href below),
// so the gallery fills the whole screen. The page itself never scrolls (the gallery's own panes scroll).
definePageMeta({ layout: false })
const { locale, locales } = useI18n()
const localePath = useLocalePath()
// The page's own words, in every language the site has (English for any other); the site's shared
// translation files stay as they are. "Back to homepage" is drawn by the app, above its Collapse button.
const HOME_LABEL: Record<string, string> = {
en: "Back to homepage",
ar: "العودة إلى الصفحة الرئيسية",
de: "Zurück zur Startseite",
es: "Volver a la página de inicio",
fr: "Retour à l'accueil",
hi: "होमपेज पर वापस जाएँ",
it: "Torna alla home page",
pt: "Voltar à página inicial",
ro: "Înapoi la pagina principală",
ru: "Вернуться на главную",
zh: "返回首页",
}
const homeLabel = () => HOME_LABEL[locale.value] || HOME_LABEL.en
// "Back to homepage" goes to the public site, doxa.life, in the reader's language (https://doxa.life/,
// https://doxa.life/ar), whichever server this page runs on.
const homeHref = () => `https://doxa.life${localePath('/')}`
// The gallery comes from this site's own /dx-maps/ copy; the script and every embed URL come from it.
const GALLERY_SCRIPT = '/dx-maps/doxa-maps-spa/doxa-maps-spa.js'
// FRESH, NOT STALE: a returning reader's browser may keep /dx-maps/ files for hours (no cache rule is sent for them), so
// the script is asked for once with cache 'no-cache' before it is added (a 304 when unchanged; a changed file replaces the
// stale copy), then added at its PLAIN address, so DX Maps' gallery still recognises it and never runs it twice.
function revalidate(src: string): Promise<void> {
return fetch(src, { cache: 'no-cache', credentials: 'same-origin' })
.then((r) => { if (r.status === 200 && r.body) r.body.cancel() })
.catch(() => {})
}
useHead({
title: 'Deep Research',
meta: [{ name: 'robots', content: 'noindex' }]
})
// With no layout, this page sets <html lang dir> itself, as the public layout does for every other
// page: in Arabic the bar and the gallery read right to left.
const dir = computed(() => {
const loc = locales.value.find(l => typeof l === 'object' && 'code' in l && l.code === locale.value)
return (typeof loc === 'object' && loc && 'dir' in loc && loc.dir) ? loc.dir : 'ltr'
})
useHead(() => ({
htmlAttrs: {
lang: locale.value,
dir: dir.value
}
}))
const host = ref<HTMLElement | null>(null)
let gallery: HTMLElement | null = null
// THE LANGUAGE MENU INSIDE THE APP (its phone bar and its rail): on a pick it fires a cancelable
// 'doxa-language-request' {detail.language}. This site owns the language (it is in the address: /ar/...),
// so the page cancels the event and switches the SITE's locale; the app then follows through `language`
// (the watch below). The tab open at that moment is carried over in ?tab=, because the localized
// address is a new page. A code the site has no route for is cancelled and ignored.
const route = useRoute()
const switchLocalePath = useSwitchLocalePath()
let currentTab = typeof route.query.tab === 'string' ? route.query.tab : ''
function onTabChange(e: Event) {
const d = (e as CustomEvent).detail
const id = Array.isArray(d) ? d[0]?.id : d?.id
if (typeof id === 'string' && id) currentTab = id
}
function onLanguageRequest(e: Event) {
e.preventDefault()
const code = (e as CustomEvent<{ language?: string }>).detail?.language
const known = locales.value.some(l => (typeof l === 'string' ? l : l.code) === code)
if (!code || !known || code === locale.value) return
const target = switchLocalePath(code as typeof locale.value)
if (!target) return
const url = new URL(target, window.location.origin)
if (currentTab) url.searchParams.set('tab', currentTab)
navigateTo(url.pathname + url.search + url.hash)
}
// THE ADDRESS (shareable pages): this site's router owns the address, so the app is told urlOwner 'host' and, when the
// open tab changes, it sends a 'dxm-address' {href, push} instead of writing history itself. A reader's tab click is a
// new history entry (push); the app's first paint replaces the current one (so a shared link or a reload keeps the
// tab). The router writes it, so Back, Forward and the language switch keep the tab; Back/Forward never send one.
const router = useRouter()
function onAddress(e: Event) {
const d = (e as CustomEvent<{ href?: string, push?: boolean }>).detail
if (!d || typeof d.href !== 'string' || !d.href.startsWith('/') || d.href.startsWith('//')) return
if (d.push) router.push(d.href).catch(() => {})
else router.replace(d.href).catch(() => {})
}
onMounted(() => {
gallery = document.createElement('doxa-maps-spa')
// Every embed URL is built at RUNTIME from the origin serving this page, so the same build works on
// any host with nothing hardcoded.
gallery.setAttribute('tree-base', `${window.location.origin}/dx-maps`)
// The site's language drives the gallery's (and every map's); it follows the language switcher.
gallery.setAttribute('language', locale.value)
// A website, not the mobile app: no phone bar of the app's own (one header on a phone); its ☰ sits in the
// corner and its language menu at the drawer's foot. Read once, when the element mounts, so it is set here.
gallery.setAttribute('shell-mode', 'web')
// The way back to the site: the gallery draws it above its Collapse button, in the site's own words.
gallery.setAttribute('home-href', homeHref())
gallery.setAttribute('home-label', homeLabel())
// Every section shows, Experimental included (the gallery's default).
const galleryConfig: Record<string, unknown> = {}
// The keyless MapLibre engine, always: the app needs no token and no setting.
galleryConfig.provider = 'maplibre'
// the site's router writes the address (onAddress, above)
galleryConfig.urlOwner = 'host'
gallery.setAttribute('provider', 'maplibre')
gallery.setAttribute('profile-config', JSON.stringify(galleryConfig))
gallery.addEventListener('tab-change', onTabChange)
gallery.addEventListener('doxa-language-request', onLanguageRequest)
gallery.addEventListener('dxm-address', onAddress)
host.value?.appendChild(gallery)
// its script, revalidated (above) and then added at its plain address, once per page load
if (!customElements.get('doxa-maps-spa') && !document.querySelector(`script[src="${GALLERY_SCRIPT}"]`)) {
revalidate(GALLERY_SCRIPT).then(() => {
if (document.querySelector(`script[src="${GALLERY_SCRIPT}"]`)) return
const script = document.createElement('script')
script.src = GALLERY_SCRIPT
document.head.appendChild(script)
})
}
})
watch(locale, (lang) => {
gallery?.setAttribute('language', lang)
gallery?.setAttribute('home-href', homeHref())
gallery?.setAttribute('home-label', homeLabel())
})
onBeforeUnmount(() => {
gallery?.remove()
gallery = null
})
</script>
<template>
<div class="research-gallery-page">
<h1 class="visually-hidden">
Deep Research
</h1>
<div
ref="host"
class="research-gallery-host"
/>
</div>
</template>
<style scoped>
.research-gallery-page {
/* 100svh: the phone's SMALL viewport, so the browser's own toolbars never cover the gallery. */
height: 100svh;
}
/* The gallery takes the whole screen, so there is no page scrollbar. */
.research-gallery-host {
display: block;
width: 100%;
height: 100%;
}
.research-gallery-host :deep(doxa-maps-spa) {
display: block;
width: 100%;
height: 100%;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>
<style>
/* Not scoped, and keyed to this page's own element, so it applies only while this page is shown and
no other page changes. With no layout, the site's stylesheet may not be loaded here: the page sets
the two things it needs itself, no body margin and no page scroll. */
html:has(.research-gallery-page),
html:has(.research-gallery-page) body {
margin: 0;
overflow: hidden;
}
/* The site keeps room for a scrollbar on every page (scrollbar-gutter: stable, base/_globals.scss).
This page never scrolls, so on a desktop that room would only be an empty strip at the right edge. */
html:has(.research-gallery-page) {
scrollbar-gutter: auto;
}
</style>

View file

@ -51,11 +51,133 @@ useHead({
: []
})
const homeMapConfig = JSON.stringify({
// THE MAP comes from this site's own copy of the dx-maps output (public/dx-maps/) and is loaded here,
// not through DoxaMapSlot, which loads the older bundle in public/js/. Mapbox with the site's token, as
// before; the keyless MapLibre when there is none. Its styles and boundaries live in the same tree, and
// the site's language is the map's.
const homeMapConfig = computed(() => JSON.stringify({
profile: 'doxa-simple-map',
dataSource: 'pray-tools',
tk: mapboxToken,
tabs: [{ id: 'engagement', colorStrategy: 'engagement', legend: 'engagement', popup: 'engagement' }]
tabs: [{ id: 'engagement', colorStrategy: 'engagement', legend: 'engagement', popup: 'engagement' }],
provider: mapboxToken ? 'mapbox' : 'maplibre',
...(mapboxToken ? { tk: mapboxToken } : {}),
assetsBase: '/dx-maps/assets',
locale: locale.value
}))
// THE MAP'S ELEMENT, <dxm-simple-map>: DX Maps' own name for it, so it never meets the older <doxa-map> elements
// elsewhere on the site, in whatever order the pages are visited. Its bundle comes from this site's own copy of
// the dx-maps output and is added once per visit. A load in progress is shared, and a failed load takes its tag
// away again, so a later page tries afresh; the page then shows without the map.
const MAP_TAG = 'dxm-simple-map'
const MAP_SCRIPT = '/dx-maps/doxa-simple-map/doxa-simple-map.js'
// FRESH, NOT STALE: a returning reader's browser may keep /dx-maps/ files for hours (no cache rule is sent for them), so
// the script is asked for once with cache 'no-cache' before it is added (a 304 when unchanged; a changed file replaces the
// stale copy), then added at its PLAIN address, so DX Maps' gallery still recognises it and never runs it twice.
function revalidate(src: string): Promise<void> {
return fetch(src, { cache: 'no-cache', credentials: 'same-origin' })
.then((r) => { if (r.status === 200 && r.body) r.body.cancel() })
.catch(() => {})
}
function loadMapBundle(): Promise<void> {
if (customElements.get(MAP_TAG)) return Promise.resolve()
return revalidate(MAP_SCRIPT).then(() => addMapBundle())
}
function addMapBundle(): Promise<void> {
if (customElements.get(MAP_TAG)) return Promise.resolve()
const pending = document.querySelector<HTMLScriptElement>('script[data-dxl-map="' + MAP_TAG + '"]')
if (pending) {
return new Promise((resolve, reject) => {
pending.addEventListener('load', () => customElements.get(MAP_TAG) ? resolve() : reject(new Error('the map bundle did not register')), { once: true })
pending.addEventListener('error', () => reject(new Error('the map bundle failed to load')), { once: true })
})
}
return new Promise((resolve, reject) => {
const script = document.createElement('script')
const fail = (message: string) => {
script.remove()
reject(new Error(message))
}
script.src = MAP_SCRIPT
script.dataset.dxlMap = MAP_TAG
script.onload = () => customElements.get(MAP_TAG) ? resolve() : fail('the map bundle did not register')
script.onerror = () => fail('the map bundle failed to load')
document.head.appendChild(script)
})
}
// THE PHONE HEIGHT, LOCKED IN PX: 80% of the window's height when the page opens (never under 320px), so the map
// box does not grow and shrink while the browser's address bar collapses and expands during a scroll, which also
// broke a drag on the map. It is measured again only when the WIDTH changes (a rotation), never on a height-only
// resize. Wider screens keep the stylesheet's height; 80svh there is the fallback until this runs.
// A phone: a narrow screen, or a touch screen held sideways (the same test as the stylesheet's phone rules).
const PHONE_QUERY = '(max-width: 768px), (pointer: coarse) and (max-height: 600px)'
function lockPhoneHeight(slot: HTMLElement): () => void {
const PHONE = window.matchMedia(PHONE_QUERY)
let width = -1
const lock = () => {
if (window.innerWidth === width) return
width = window.innerWidth
slot.style.height = PHONE.matches ? `${Math.max(320, Math.round(window.innerHeight * 0.8))}px` : ''
}
lock()
window.addEventListener('resize', lock)
return () => window.removeEventListener('resize', lock)
}
let unlockHeight: (() => void) | null = null
onMounted(() => { if (homeMapHost.value) unlockHeight = lockPhoneHeight(homeMapHost.value) })
onBeforeUnmount(() => unlockHeight?.())
// THE SCROLL BAND STEPS ASIDE FOR THE MAP'S PANELS. A panel the map's controls open (Share, and every other menu)
// sits at the map's top, under the band, so its ✕ could not be tapped. DX Maps says when a panel opens (the composed
// 'dxmaps:panel-open') and when it closes ('dxmaps:panel-close'). The band counts them (another panel can still be open
// when one closes) and stays off while the count is above 0; for builds without the close event, it also comes back
// after any tap once no panel is open in the map any more.
const padOff = ref(false)
let openPanels = 0
function mapPanelOpen(): boolean {
const walk = (node: Element | ShadowRoot): boolean => {
for (const n of node.querySelectorAll('*')) {
if (n.classList.contains('mcm')) return true
if (n.shadowRoot && walk(n.shadowRoot)) return true
}
return false
}
return !!homeMap && walk(homeMap.shadowRoot || homeMap)
}
const onPanelOpen = () => { openPanels++; padOff.value = true }
const onPanelClose = () => { openPanels = Math.max(0, openPanels - 1); padOff.value = openPanels > 0 }
const onAnyTap = () => {
if (padOff.value) setTimeout(() => { if (!mapPanelOpen()) { openPanels = 0; padOff.value = false } }, 50)
}
onMounted(() => {
homeMapHost.value?.addEventListener('dxmaps:panel-open', onPanelOpen)
homeMapHost.value?.addEventListener('dxmaps:panel-close', onPanelClose)
document.addEventListener('click', onAnyTap, true)
})
onBeforeUnmount(() => {
homeMapHost.value?.removeEventListener('dxmaps:panel-open', onPanelOpen)
homeMapHost.value?.removeEventListener('dxmaps:panel-close', onPanelClose)
document.removeEventListener('click', onAnyTap, true)
})
// The prayer API the map reads is set before its script runs; the element is created once the bundle is in.
const homeMapHost = ref<HTMLElement | null>(null)
let homeMap: HTMLElement | null = null
onMounted(async () => {
if (config.public.prayBaseUrl) window.MAP_APP_API_URL = config.public.prayBaseUrl as string
try {
await loadMapBundle()
} catch (err) {
console.error('[home map]', err)
return
}
if (!homeMapHost.value) return
homeMap = document.createElement(MAP_TAG)
homeMap.id = 'home-map'
homeMap.setAttribute('profile-config', homeMapConfig.value)
homeMapHost.value.prepend(homeMap)
})
watch(homeMapConfig, (value) => { homeMap?.setAttribute('profile-config', value) })
onBeforeUnmount(() => {
homeMap?.remove()
homeMap = null
})
useTextHighlight()
@ -114,7 +236,9 @@ useTextHighlight()
data-highlight-color="primary"
>{{ t('DOXA people groups around the world') }}</h1>
</div>
<DoxaMapSlot map-id="home-map" :profile-config="homeMapConfig" class="rounded-xlg" />
<div ref="homeMapHost" class="doxa-map-slot rounded-xlg">
<div class="doxa-map-scroll-pad doxa-map-scroll-pad--top" :class="{ 'is-off': padOff }" />
</div>
<NuxtLink
:to="localePath('/research')"
class="research-map-link"
@ -337,4 +461,65 @@ useTextHighlight()
.research-map-link:hover {
color: var(--color-brand-primary-darker, #1f2328);
}
/* The map's frame, as DoxaMapSlot draws it. */
.doxa-map-slot {
display: block;
position: relative;
width: 100%;
min-height: 780px;
aspect-ratio: 16 / 9;
overflow: hidden;
}
@media (max-width: 768px), (pointer: coarse) and (max-height: 600px) {
/* On a phone the map is 80% of the SMALL viewport height, a little shorter than the screen so the page
stays in view (svh does not jump when the browser's address bar collapses), never under 320px. */
.doxa-map-slot {
min-height: 320px;
aspect-ratio: auto;
height: 80svh;
}
}
/* The card's corners are concentric with the map's top control (a 32px round button 3px in: 3 + 16 = 19px), so the
rounded corner no longer cuts across it. */
.doxa-map-slot.rounded-xlg {
border-radius: 19px;
}
.doxa-map-slot :deep(dxm-simple-map) {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: inherit;
overflow: hidden;
contain: paint;
}
.doxa-map-scroll-pad {
display: none;
}
@media (max-width: 768px), (pointer: coarse) and (max-height: 600px) {
.doxa-map-scroll-pad {
display: block;
position: absolute;
/* stops short of the map's control column at the inline end (32px round buttons, 3px in), so the + and
the other controls stay tappable; the rest of the band still lets a finger scroll the page */
inset-inline-start: 0;
inset-inline-end: 44px;
height: 48px;
z-index: 1500;
touch-action: pan-y;
pointer-events: auto;
}
.doxa-map-scroll-pad--top { top: 0; }
/* while a map panel is open, its ✕ and its contents get every tap */
.doxa-map-scroll-pad.is-off { display: none; }
}
.doxa-map-slot :deep(dxm-simple-map:fullscreen),
.doxa-map-slot :deep(dxm-simple-map:-webkit-full-screen) {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
}
</style>

View file

@ -0,0 +1,211 @@
<script setup lang="ts">
// Posterverse: every poster plugin (UPG 100 List Posters, Country Posters, Poster Design and the poster
// shelf of experimental plugins) in one full-page app, the poster twin of Deep Research. Like Deep
// Research, this page is HIDDEN: it is in no menu and links nowhere, and robots are told not to index
// it. It is reached by its address, and ?tab=<row id> opens one plugin directly (the shell reads it).
//
// The shell is a web component served from this site's OWN /dx-maps/ tree, the same copy the Deep
// Research page uses: one script, one element, <posterverse-app>. A custom
// element's name needs a hyphen, which is why the tag is not just <posterverse>.
//
// The element is created in onMounted rather than written in the template: it is a custom element
// that only exists in the browser, so this keeps it out of SSR and out of Vue's component resolver
// without any nuxt.config change.
// A full-page app on every screen: no layout, so no site header and no footer. The way back to the site
// is the shell's own "Back to homepage" button, directly above its Collapse button (home-href below),
// so the shell fills the whole screen. The page itself never scrolls (the shell's own panes scroll).
definePageMeta({ layout: false })
const { locale, locales } = useI18n()
const localePath = useLocalePath()
// The page's own words, in every language the site has (English for any other); the site's shared
// translation files stay as they are. "Back to homepage" is drawn by the app, above its Collapse button.
const HOME_LABEL: Record<string, string> = {
en: "Back to homepage",
ar: "العودة إلى الصفحة الرئيسية",
de: "Zurück zur Startseite",
es: "Volver a la página de inicio",
fr: "Retour à l'accueil",
hi: "होमपेज पर वापस जाएँ",
it: "Torna alla home page",
pt: "Voltar à página inicial",
ro: "Înapoi la pagina principală",
ru: "Вернуться на главную",
zh: "返回首页",
}
const homeLabel = () => HOME_LABEL[locale.value] || HOME_LABEL.en
// "Back to homepage" goes to the public site, doxa.life, in the reader's language (https://doxa.life/,
// https://doxa.life/ar), whichever server this page runs on.
const homeHref = () => `https://doxa.life${localePath('/')}`
// The shell comes from this site's own /dx-maps/ copy; the script and every embed URL come from it.
const POSTERVERSE_SCRIPT = '/dx-maps/posterverse/posterverse.js'
// FRESH, NOT STALE: a returning reader's browser may keep /dx-maps/ files for hours (no cache rule is sent for them), so
// the script is asked for once with cache 'no-cache' before it is added (a 304 when unchanged; a changed file replaces the
// stale copy), then added at its PLAIN address, so DX Maps' gallery still recognises it and never runs it twice.
function revalidate(src: string): Promise<void> {
return fetch(src, { cache: 'no-cache', credentials: 'same-origin' })
.then((r) => { if (r.status === 200 && r.body) r.body.cancel() })
.catch(() => {})
}
useHead({
title: 'Posterverse',
meta: [{ name: 'robots', content: 'noindex' }]
})
// With no layout, this page sets <html lang dir> itself, as the public layout does for every other
// page: in Arabic the shell reads right to left.
const dir = computed(() => {
const loc = locales.value.find(l => typeof l === 'object' && 'code' in l && l.code === locale.value)
return (typeof loc === 'object' && loc && 'dir' in loc && loc.dir) ? loc.dir : 'ltr'
})
useHead(() => ({
htmlAttrs: {
lang: locale.value,
dir: dir.value
}
}))
const host = ref<HTMLElement | null>(null)
let shell: HTMLElement | null = null
// THE LANGUAGE MENU INSIDE THE APP (its phone bar and its rail): on a pick it fires a cancelable
// 'doxa-language-request' {detail.language}. This site owns the language (it is in the address: /ar/...),
// so the page cancels the event and switches the SITE's locale; the app then follows through `language`
// (the watch below). The tab open at that moment is carried over in ?tab=, because the localized
// address is a new page. A code the site has no route for is cancelled and ignored.
const route = useRoute()
const switchLocalePath = useSwitchLocalePath()
let currentTab = typeof route.query.tab === 'string' ? route.query.tab : ''
function onTabChange(e: Event) {
const d = (e as CustomEvent).detail
const id = Array.isArray(d) ? d[0]?.id : d?.id
if (typeof id === 'string' && id) currentTab = id
}
function onLanguageRequest(e: Event) {
e.preventDefault()
const code = (e as CustomEvent<{ language?: string }>).detail?.language
const known = locales.value.some(l => (typeof l === 'string' ? l : l.code) === code)
if (!code || !known || code === locale.value) return
const target = switchLocalePath(code as typeof locale.value)
if (!target) return
const url = new URL(target, window.location.origin)
if (currentTab) url.searchParams.set('tab', currentTab)
navigateTo(url.pathname + url.search + url.hash)
}
// THE ADDRESS (shareable pages): this site's router owns the address, so the app is told urlOwner 'host' and, when the
// open tab changes, it sends a 'dxm-address' {href, push} instead of writing history itself. A reader's tab click is a
// new history entry (push); the app's first paint replaces the current one (so a shared link or a reload keeps the
// tab). The router writes it, so Back, Forward and the language switch keep the tab; Back/Forward never send one.
const router = useRouter()
function onAddress(e: Event) {
const d = (e as CustomEvent<{ href?: string, push?: boolean }>).detail
if (!d || typeof d.href !== 'string' || !d.href.startsWith('/') || d.href.startsWith('//')) return
if (d.push) router.push(d.href).catch(() => {})
else router.replace(d.href).catch(() => {})
}
onMounted(() => {
shell = document.createElement('posterverse-app')
// Every embed URL is built at RUNTIME from the origin serving this page, so the same build works on
// any host with nothing hardcoded.
shell.setAttribute('tree-base', `${window.location.origin}/dx-maps`)
// The site's language drives the shell's (and every plugin's); it follows the language switcher.
shell.setAttribute('language', locale.value)
// A website, not the mobile app: no phone bar of the app's own (one header on a phone); its ☰ sits in the
// corner and its language menu at the drawer's foot. Read once, when the element mounts, so it is set here.
shell.setAttribute('shell-mode', 'web')
// The way back to the site: the shell draws it above its Collapse button, in the site's own words.
shell.setAttribute('home-href', homeHref())
shell.setAttribute('home-label', homeLabel())
// The keyless MapLibre engine, always: the app needs no token and no setting.
// urlOwner 'host': the site's router writes the address (onAddress, above)
const shellConfig: Record<string, unknown> = { provider: 'maplibre', urlOwner: 'host' }
shell.setAttribute('provider', 'maplibre')
shell.setAttribute('profile-config', JSON.stringify(shellConfig))
shell.addEventListener('tab-change', onTabChange)
shell.addEventListener('doxa-language-request', onLanguageRequest)
shell.addEventListener('dxm-address', onAddress)
host.value?.appendChild(shell)
// its script, revalidated (above) and then added at its plain address, once per page load
if (!customElements.get('posterverse-app') && !document.querySelector(`script[src="${POSTERVERSE_SCRIPT}"]`)) {
revalidate(POSTERVERSE_SCRIPT).then(() => {
if (document.querySelector(`script[src="${POSTERVERSE_SCRIPT}"]`)) return
const script = document.createElement('script')
script.src = POSTERVERSE_SCRIPT
document.head.appendChild(script)
})
}
})
watch(locale, (lang) => {
shell?.setAttribute('language', lang)
shell?.setAttribute('home-href', homeHref())
shell?.setAttribute('home-label', homeLabel())
})
onBeforeUnmount(() => {
shell?.remove()
shell = null
})
</script>
<template>
<div class="posterverse-page">
<h1 class="visually-hidden">
Posterverse
</h1>
<div
ref="host"
class="posterverse-host"
/>
</div>
</template>
<style scoped>
.posterverse-page {
/* 100svh: the phone's SMALL viewport, so the browser's own toolbars never cover the shell. */
height: 100svh;
}
/* The shell takes the whole screen, so there is no page scrollbar. */
.posterverse-host {
display: block;
width: 100%;
height: 100%;
}
/* 100%, not the element's own 85vh default: here the shell is the whole page. */
.posterverse-host :deep(posterverse-app) {
display: block;
width: 100%;
height: 100%;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>
<style>
/* Not scoped, and keyed to this page's own element, so it applies only while this page is shown and
no other page changes. With no layout, the site's stylesheet may not be loaded here: the page sets
the two things it needs itself, no body margin and no page scroll. */
html:has(.posterverse-page),
html:has(.posterverse-page) body {
margin: 0;
overflow: hidden;
}
/* The site keeps room for a scrollbar on every page (scrollbar-gutter: stable, base/_globals.scss).
This page never scrolls, so on a desktop that room would only be an empty strip at the right edge. */
html:has(.posterverse-page) {
scrollbar-gutter: auto;
}
</style>

View file

@ -26,11 +26,133 @@ onMounted(() => reload())
const mapboxToken = config.public.mapboxToken as string
const prayMapConfig = JSON.stringify({
// THE MAP comes from this site's own copy of the dx-maps output (public/dx-maps/) and is loaded here,
// not through DoxaMapSlot, which loads the older bundle in public/js/. Mapbox with the site's token, as
// before; the keyless MapLibre when there is none. Its styles and boundaries live in the same tree, and
// the site's language is the map's.
const prayMapConfig = computed(() => JSON.stringify({
profile: 'doxa-simple-map',
dataSource: 'pray-tools',
tk: mapboxToken,
tabs: [{ id: 'prayer', colorStrategy: 'prayer', legend: 'prayer', popup: 'prayer' }]
tabs: [{ id: 'prayer', colorStrategy: 'prayer', legend: 'prayer', popup: 'prayer' }],
provider: mapboxToken ? 'mapbox' : 'maplibre',
...(mapboxToken ? { tk: mapboxToken } : {}),
assetsBase: '/dx-maps/assets',
locale: locale.value
}))
// THE MAP'S ELEMENT, <dxm-simple-map>: DX Maps' own name for it, so it never meets the older <doxa-map> elements
// elsewhere on the site, in whatever order the pages are visited. Its bundle comes from this site's own copy of
// the dx-maps output and is added once per visit. A load in progress is shared, and a failed load takes its tag
// away again, so a later page tries afresh; the page then shows without the map.
const MAP_TAG = 'dxm-simple-map'
const MAP_SCRIPT = '/dx-maps/doxa-simple-map/doxa-simple-map.js'
// FRESH, NOT STALE: a returning reader's browser may keep /dx-maps/ files for hours (no cache rule is sent for them), so
// the script is asked for once with cache 'no-cache' before it is added (a 304 when unchanged; a changed file replaces the
// stale copy), then added at its PLAIN address, so DX Maps' gallery still recognises it and never runs it twice.
function revalidate(src: string): Promise<void> {
return fetch(src, { cache: 'no-cache', credentials: 'same-origin' })
.then((r) => { if (r.status === 200 && r.body) r.body.cancel() })
.catch(() => {})
}
function loadMapBundle(): Promise<void> {
if (customElements.get(MAP_TAG)) return Promise.resolve()
return revalidate(MAP_SCRIPT).then(() => addMapBundle())
}
function addMapBundle(): Promise<void> {
if (customElements.get(MAP_TAG)) return Promise.resolve()
const pending = document.querySelector<HTMLScriptElement>('script[data-dxl-map="' + MAP_TAG + '"]')
if (pending) {
return new Promise((resolve, reject) => {
pending.addEventListener('load', () => customElements.get(MAP_TAG) ? resolve() : reject(new Error('the map bundle did not register')), { once: true })
pending.addEventListener('error', () => reject(new Error('the map bundle failed to load')), { once: true })
})
}
return new Promise((resolve, reject) => {
const script = document.createElement('script')
const fail = (message: string) => {
script.remove()
reject(new Error(message))
}
script.src = MAP_SCRIPT
script.dataset.dxlMap = MAP_TAG
script.onload = () => customElements.get(MAP_TAG) ? resolve() : fail('the map bundle did not register')
script.onerror = () => fail('the map bundle failed to load')
document.head.appendChild(script)
})
}
// THE PHONE HEIGHT, LOCKED IN PX: 80% of the window's height when the page opens (never under 320px), so the map
// box does not grow and shrink while the browser's address bar collapses and expands during a scroll, which also
// broke a drag on the map. It is measured again only when the WIDTH changes (a rotation), never on a height-only
// resize. Wider screens keep the stylesheet's height; 80svh there is the fallback until this runs.
// A phone: a narrow screen, or a touch screen held sideways (the same test as the stylesheet's phone rules).
const PHONE_QUERY = '(max-width: 768px), (pointer: coarse) and (max-height: 600px)'
function lockPhoneHeight(slot: HTMLElement): () => void {
const PHONE = window.matchMedia(PHONE_QUERY)
let width = -1
const lock = () => {
if (window.innerWidth === width) return
width = window.innerWidth
slot.style.height = PHONE.matches ? `${Math.max(320, Math.round(window.innerHeight * 0.8))}px` : ''
}
lock()
window.addEventListener('resize', lock)
return () => window.removeEventListener('resize', lock)
}
let unlockHeight: (() => void) | null = null
onMounted(() => { if (prayMapHost.value) unlockHeight = lockPhoneHeight(prayMapHost.value) })
onBeforeUnmount(() => unlockHeight?.())
// THE SCROLL BAND STEPS ASIDE FOR THE MAP'S PANELS. A panel the map's controls open (Share, and every other menu)
// sits at the map's top, under the band, so its ✕ could not be tapped. DX Maps says when a panel opens (the composed
// 'dxmaps:panel-open') and when it closes ('dxmaps:panel-close'). The band counts them (another panel can still be open
// when one closes) and stays off while the count is above 0; for builds without the close event, it also comes back
// after any tap once no panel is open in the map any more.
const padOff = ref(false)
let openPanels = 0
function mapPanelOpen(): boolean {
const walk = (node: Element | ShadowRoot): boolean => {
for (const n of node.querySelectorAll('*')) {
if (n.classList.contains('mcm')) return true
if (n.shadowRoot && walk(n.shadowRoot)) return true
}
return false
}
return !!prayMap && walk(prayMap.shadowRoot || prayMap)
}
const onPanelOpen = () => { openPanels++; padOff.value = true }
const onPanelClose = () => { openPanels = Math.max(0, openPanels - 1); padOff.value = openPanels > 0 }
const onAnyTap = () => {
if (padOff.value) setTimeout(() => { if (!mapPanelOpen()) { openPanels = 0; padOff.value = false } }, 50)
}
onMounted(() => {
prayMapHost.value?.addEventListener('dxmaps:panel-open', onPanelOpen)
prayMapHost.value?.addEventListener('dxmaps:panel-close', onPanelClose)
document.addEventListener('click', onAnyTap, true)
})
onBeforeUnmount(() => {
prayMapHost.value?.removeEventListener('dxmaps:panel-open', onPanelOpen)
prayMapHost.value?.removeEventListener('dxmaps:panel-close', onPanelClose)
document.removeEventListener('click', onAnyTap, true)
})
// The prayer API the map reads is set before its script runs; the element is created once the bundle is in.
const prayMapHost = ref<HTMLElement | null>(null)
let prayMap: HTMLElement | null = null
onMounted(async () => {
if (config.public.prayBaseUrl) window.MAP_APP_API_URL = config.public.prayBaseUrl as string
try {
await loadMapBundle()
} catch (err) {
console.error('[pray map]', err)
return
}
if (!prayMapHost.value) return
prayMap = document.createElement(MAP_TAG)
prayMap.id = 'pray-map'
prayMap.setAttribute('profile-config', prayMapConfig.value)
prayMapHost.value.prepend(prayMap)
})
watch(prayMapConfig, (value) => { prayMap?.setAttribute('profile-config', value) })
onBeforeUnmount(() => {
prayMap?.remove()
prayMap = null
})
const DEMO_PRAYER_COVERAGE = {
@ -119,7 +241,9 @@ useTextHighlight()
<section>
<div class="container stack stack--lg">
<h2>{{ t('Prayer Progress') }}</h2>
<DoxaMapSlot map-id="pray-map" :profile-config="prayMapConfig" class="rounded-md" />
<div ref="prayMapHost" class="doxa-map-slot rounded-md">
<div class="doxa-map-scroll-pad doxa-map-scroll-pad--top" :class="{ 'is-off': padOff }" />
</div>
<NuxtLink
:to="localePath('/research')"
class="research-map-link"
@ -406,4 +530,60 @@ useTextHighlight()
.prayer-coverage-key--full {
background: var(--color-brand-primary);
}
/* The map's frame, as DoxaMapSlot draws it. */
.doxa-map-slot {
display: block;
position: relative;
width: 100%;
min-height: 780px;
aspect-ratio: 16 / 9;
overflow: hidden;
}
@media (max-width: 768px), (pointer: coarse) and (max-height: 600px) {
/* On a phone the map is 80% of the SMALL viewport height, a little shorter than the screen so the page
stays in view (svh does not jump when the browser's address bar collapses), never under 320px. */
.doxa-map-slot {
min-height: 320px;
aspect-ratio: auto;
height: 80svh;
}
}
.doxa-map-slot :deep(dxm-simple-map) {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: inherit;
overflow: hidden;
contain: paint;
}
.doxa-map-scroll-pad {
display: none;
}
@media (max-width: 768px), (pointer: coarse) and (max-height: 600px) {
.doxa-map-scroll-pad {
display: block;
position: absolute;
/* stops short of the map's control column at the inline end (32px round buttons, 3px in), so the + and
the other controls stay tappable; the rest of the band still lets a finger scroll the page */
inset-inline-start: 0;
inset-inline-end: 44px;
height: 48px;
z-index: 1500;
touch-action: pan-y;
pointer-events: auto;
}
.doxa-map-scroll-pad--top { top: 0; }
/* while a map panel is open, its ✕ and its contents get every tap */
.doxa-map-scroll-pad.is-off { display: none; }
}
.doxa-map-slot :deep(dxm-simple-map:fullscreen),
.doxa-map-slot :deep(dxm-simple-map:-webkit-full-screen) {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
}
</style>

View file

@ -107,6 +107,8 @@ useTextHighlight()
</svg>
</NuxtLink>
</p>
<!-- Country posters for this region, under the dashboard link (a PosterSlot: DX Maps' poster section). -->
<PosterSlot :scope="region.slug" />
</div>
</div>
</div>

View file

@ -23,13 +23,80 @@ const initialSearchTerm = computed(() =>
const translations = computed(() => buildUupgListTranslations(t))
// THE MAP comes from this site's own copy of the dx-maps output (public/dx-maps/) and is loaded here,
// not through DoxaMapSlot, which loads the older bundle in public/js/. Mapbox with the site's token, as
// before; the keyless MapLibre when there is none. Its styles and boundaries live in the same tree, and
// the site's language is the map's.
const runtimeConfig = useRuntimeConfig()
const mapboxToken = (runtimeConfig.public as { mapboxToken?: string }).mapboxToken || ''
const researchProfileConfig = computed(() => JSON.stringify({
profile: 'research-map',
tk: (runtimeConfig.public as { mapboxToken?: string }).mapboxToken || '',
dataSource: 'pray-tools',
instanceId: 'research-page'
instanceId: 'research-page',
provider: mapboxToken ? 'mapbox' : 'maplibre',
...(mapboxToken ? { tk: mapboxToken } : {}),
assetsBase: '/dx-maps/assets',
locale: locale.value
}))
// THE PHONE HEIGHT, LOCKED IN PX: 80% of the window's height when the page opens (never under 320px), so the map
// box does not grow and shrink while the browser's address bar collapses and expands during a scroll, which also
// broke a drag on the map. It is measured again only when the WIDTH changes (a rotation), never on a height-only
// resize. Wider screens keep the stylesheet's height; 80svh there is the fallback until this runs.
// A phone: a narrow screen, or a touch screen held sideways (the same test as the stylesheet's phone rules).
const PHONE_QUERY = '(max-width: 768px), (pointer: coarse) and (max-height: 600px)'
function lockPhoneHeight(slot: HTMLElement): () => void {
const PHONE = window.matchMedia(PHONE_QUERY)
let width = -1
const lock = () => {
if (window.innerWidth === width) return
width = window.innerWidth
slot.style.height = PHONE.matches ? `${Math.max(320, Math.round(window.innerHeight * 0.8))}px` : ''
}
lock()
window.addEventListener('resize', lock)
return () => window.removeEventListener('resize', lock)
}
const researchMapSlot = ref<HTMLElement | null>(null)
let unlockHeight: (() => void) | null = null
onMounted(() => { if (researchMapSlot.value) unlockHeight = lockPhoneHeight(researchMapSlot.value) })
onBeforeUnmount(() => unlockHeight?.())
// FRESH, NOT STALE: a returning reader's browser may keep /dx-maps/ files for hours (no cache rule is sent for them), so
// the script is asked for once with cache 'no-cache' before it is added (a 304 when unchanged; a changed file replaces the
// stale copy), then added at its PLAIN address, so DX Maps' gallery still recognises it and never runs it twice.
function revalidate(src: string): Promise<void> {
return fetch(src, { cache: 'no-cache', credentials: 'same-origin' })
.then((r) => { if (r.status === 200 && r.body) r.body.cancel() })
.catch(() => {})
}
// The prayer API the map reads is set before its script runs, and the script is added once per visit.
onMounted(async () => {
if (runtimeConfig.public.prayBaseUrl) window.MAP_APP_API_URL = runtimeConfig.public.prayBaseUrl as string
const src = '/dx-maps/doxa-research-map/doxa-research-map.js'
if (customElements.get('doxa-research-map') || document.querySelector(`script[src="${src}"]`)) return
await revalidate(src)
if (!document.querySelector(`script[src="${src}"]`)) {
const script = document.createElement('script')
script.src = src
document.head.appendChild(script)
}
})
// The line under the map, in every language the site has (English for any other); the site's shared
// translation files stay as they are.
const DEEP_RESEARCH_LINE: Record<string, { ask: string, link: string }> = {
en: { ask: "Want Deeper Research?", link: "Deep Research app" },
ar: { ask: "هل تريد بحثًا أعمق؟", link: "تطبيق Deep Research" },
de: { ask: "Möchten Sie tiefer recherchieren?", link: "Deep-Research-App" },
es: { ask: "¿Quiere investigar más a fondo?", link: "App Deep Research" },
fr: { ask: "Vous voulez aller plus loin dans vos recherches ?", link: "Application Deep Research" },
hi: { ask: "क्या आप और गहन शोध चाहते हैं?", link: "Deep Research ऐप" },
it: { ask: "Vuoi approfondire la ricerca?", link: "App Deep Research" },
pt: { ask: "Quer uma pesquisa mais aprofundada?", link: "App Deep Research" },
ro: { ask: "Vrei o cercetare mai aprofundată?", link: "Aplicația Deep Research" },
ru: { ask: "Хотите исследовать глубже?", link: "Приложение Deep Research" },
zh: { ask: "想进行更深入的研究?", link: "Deep Research 应用" },
}
const deepResearchLine = computed(() => DEEP_RESEARCH_LINE[locale.value] || DEEP_RESEARCH_LINE.en)
useTextHighlight()
</script>
@ -42,14 +109,22 @@ useTextHighlight()
</div>
<!-- Research map embed — sits directly under the H1, above the UUPG
search bar. Loads /js/doxa-research-map.js (auto-published from
the upstream module on every research-mfe build). -->
<DoxaMapSlot
map-id="research-page-map"
bundle="research-map"
:profile-config="researchProfileConfig"
class="rounded-xlg"
/>
search bar. Loads /dx-maps/doxa-research-map/doxa-research-map.js. -->
<div class="stack stack--xs">
<div ref="researchMapSlot" class="doxa-map-slot rounded-xlg">
<doxa-research-map id="research-page-map" :profile-config="researchProfileConfig" />
</div>
<!-- Under the map: the way to the full set of research maps, the hidden Deep Research page.
Small, like the homepage's "Explore our research maps" line under its map. -->
<p class="text-center deep-research-line">
{{ deepResearchLine.ask }}
<!-- A full page load: the app starts in a page of its own, with nothing of this site's maps in it. -->
<a
class="light-link"
:href="localePath('/deep-research')"
>{{ deepResearchLine.link }} &rarr;</a>
</p>
</div>
<UupgsList
:language-code="locale"
@ -60,3 +135,50 @@ useTextHighlight()
/>
</div>
</template>
<style scoped>
/* The same size as the homepage's link under its map (.research-map-link in pages/index.vue). */
.deep-research-line {
font-size: 1.2rem;
}
/* The map's frame, as DoxaMapSlot draws it. */
.doxa-map-slot {
display: block;
position: relative;
width: 100%;
min-height: 780px;
aspect-ratio: 16 / 9;
overflow: hidden;
}
@media (max-width: 768px), (pointer: coarse) and (max-height: 600px) {
/* On a phone the map is 80% of the SMALL viewport height, a little shorter than the screen so the page
stays in view (svh does not jump when the browser's address bar collapses), never under 320px. */
.doxa-map-slot {
min-height: 320px;
aspect-ratio: auto;
height: 80svh;
}
}
/* The card's corners are concentric with the map's tab row, which starts 16px in, so no tab is cut. */
.doxa-map-slot.rounded-xlg {
border-radius: 16px;
}
.doxa-map-slot doxa-research-map {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: inherit;
overflow: hidden;
contain: paint;
}
.doxa-map-slot doxa-research-map:fullscreen,
.doxa-map-slot doxa-research-map:-webkit-full-screen {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
}
</style>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>all-people-groups · all-people-groups</title>
<link rel="icon" href="data:,">
<!-- A9 hints: the map's origins, so DNS/TLS + the engine start with the bundle, not after it (engine = provider or the MapLibre default). -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://tiles.openfreemap.org" crossorigin />
<link rel="preconnect" href="https://pray.doxa.life" crossorigin />
<link rel="preload" as="script" href="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js" integrity="sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp" crossorigin="anonymous" />
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → auto), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself.
HEIGHT IN svh, NOT inset:0. A fixed box with inset:0 follows the phone's DYNAMIC viewport, so the map
(and a legend sheet sized from it) changes height every time the address bar hides or shows. 100svh is
the viewport WITH the bar shown and never changes; when the bar hides, the page background shows below
instead of the map resizing. On a desktop svh = the viewport, so nothing moves there. The 100% before it
is the fallback for a browser without svh, which would otherwise size the box to its content.
--profile-min-h:0 opts out of ProfileLoader's 100dvh floor, which would grow the element again. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, all-people-groups { position: fixed; inset: 0 auto auto 0; width: 100%; height: 100%; height: 100svh; --profile-min-h: 0; 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; }
/* ── PRINT, BOTH LEAF SHAPES ────────────────────────────────────────────────────────────
Two things every generated leaf got wrong on paper, because no generated page has ever
carried an @media print rule (measured: 0 across the tree).
The background is #0a0e15 — a dark page, printed. Browsers drop background colours by
default, but a reader who ticks "Background graphics" to get the MAP also gets the page
flooded black, and that is the reader who most wants the printout.
#status is the LOADING OVERLAY: position:fixed, inset:0, over everything. On paper it is
a full sheet reading "loading…" above the content that did in fact load. It is hidden
only while it is NOT an error — an error still prints, because a blank sheet with the
explanation suppressed is worse than a sheet that says what went wrong. */
@media print {
html, body { height: auto; background: #fff; }
#status:not(.err) { display: none; }
/* ★ RELEASE THE FIXED HOST, MEASURED (a print check on a035408e2). The leaf pins both the
slot and the element with position:fixed; inset:0. A fixed element is painted once, on the
first page, so the printed document was exactly ONE viewport: 900px tall, while the content
continued to y=1753. Every plugin whose leaf is taller than a screen has been printing its
top screenful and silently dropping the rest — the dashboard printed its stats and its map,
then blank to the bottom of the sheet.
min-height, not a released height alone: a full-bleed map leaf takes its SIZE from this rule,
so height:auto with nothing else can leave the element a zero-height box and print an empty
page — a worse failure than the one being fixed, and the discriminator is not isMap
(region-dashboards is isMap:true and is a scrolling dashboard). The floor keeps a definite
box for the map case while letting a taller document grow onto page two. */
#slot, all-people-groups { position: static; inset: auto; width: 100%; height: auto; min-height: 14cm; }
}
</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 <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-4uZxLJxLey7Zl7tiZ5qTwrQnblpRX0lxzKzjTcfUqBNEOP2Qn63Bvtra+Vo+6ecm" 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":"all-people-groups","dataSource":"pray-tools","instanceId":"all-people-groups-all-people-groups","assetsBase":"../../assets","urlOwner":"app"};
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
// entries to the partner page's own Back button (the joint session history).
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
// 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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
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 = "";
// 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.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
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("all-people-groups");
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 || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins,
// and 'auto' draws with MapLibre whether or not a token is available (the element decides that).
// This page only decides whether to FIND a token first: when one is on offer (a rung above, a
// host-set window.MAP_TOKEN, or a token endpoint) it resolves it, since place search uses it on
// either engine; with none, it mounts at once, keyless: no token prompt, no request to Mapbox.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
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.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
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. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
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>

View file

@ -0,0 +1,61 @@
BOUNDARY DATA — SOURCES AND ATTRIBUTION
=======================================
The .geojson files in this folder are third-party boundary data. Every value below is taken
VERBATIM from the upstream metadata (the geoBoundaries API, https://www.geoboundaries.org/api/current/gbOpen/<ISO3>/<LEVEL>/).
Nothing here is our own wording about a licence.
gb-IND-ADM1-full.geojson, gb-IND-ADM1-full-web.geojson
source geoBoundaries gbOpen (www.geoboundaries.org)
boundaryID IND-ADM1-1811400
licence Creative Commons Attribution 2.5 India (CC BY 2.5 IN)
licenceSource github.com/datameet/maps
year 2011
ATTRIBUTION IS REQUIRED for this file: it is Creative Commons Attribution, so a work that
redistributes or displays it must credit the source above.
gb-IND-ADM1.geojson, gb-IND-ADM1-web.geojson
The same India ADM1 boundary as above, curated to the UPG-100 list's partition.
Same source, same boundaryID, same licence, same attribution duty.
gb-CHN-ADM1.geojson
source geoBoundaries gbOpen (www.geoboundaries.org)
boundaryID CHN-ADM1-43563684
licence Public Domain
licenceSource commons.wikimedia.org/wiki/File
year 2019
No attribution duty: upstream records this boundary as Public Domain.
world-adm0.geojson, world-adm0-web.geojson
source geoBoundaries gbOpen (www.geoboundaries.org), assembled per country
features 162 countries
licence MIXED — and now ESTABLISHED per country, not merely acknowledged.
THE PER-COUNTRY TABLE IS THE AUTHORITY, and it lives in ONE place on purpose:
plugin-designs/my-upg-100-list/data/adm0/PROVENANCE.json
It records boundaryLicense, licenseSource and boundaryID for all 162, each value taken
verbatim from https://www.geoboundaries.org/api/current/gbOpen/<ISO3>/<LEVEL>/ (162 queries,
0 failures, one spot-check re-verified against the live API). This file deliberately does NOT
copy that table: one table in two places drifts.
130 OF THE 162 CARRY A DUTY. Only 32 are duty-free (28 Public Domain, 4 CC0). By class:
ATTRIBUTION-ONLY (credit the source)
44 CC BY 4.0 13 CC BY 3.0 IGO 4 CC BY 2.5 2 CC BY 3.0
2 Open Government Licence v3
SHARE-ALIKE CLASS — A STRONGER DUTY: a derivative may have to be licensed alike, which is
the class that actually constrains giving this repository away.
58 Open Data Commons ODbL 1.0
7 Creative Commons ShareAlike 2.0 / 3.0 — AGO GAB GEO GIN MMR NER TUR
ODbL IS LISTED IN THAT CLASS DELIBERATELY. Alongside attribution it requires a Derivative
Database to be offered under ODbL and carries a keep-open provision, so by volume the
share-alike question covers 65 of 162 countries, not 7. This repository has already acted on
that reading once: library/scripts/posters/data/README.md records that Natural Earth's 1:10m
ROADS were requested and deliberately NOT shipped because of an ODbL obligation.
WHETHER 'licensed alike' binds a giveaway of this repository is a legal question, not a
measurement, and it is open. It is recorded here so nobody has to rediscover it.
duty-free: 28 Public Domain, 4 CC0.
See documentation/THIRD-PARTY.md for the whole shipped tree.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,14 @@
{
"file": "gb-IND-ADM1-full.geojson",
"units": 36,
"source": "geoBoundaries gbOpen (www.geoboundaries.org)",
"boundaryID": "IND-ADM1-1811400",
"boundarySource": "DataMeet India community, Election Commission of India",
"licence": "Creative Commons Attribution 2.5 India (CC BY 2.5 IN)",
"licenceSource": "github.com/datameet/maps",
"yearRepresented": "2011",
"sourceUpdated": "Thu Jan 19 07:31:04 2023",
"fetchedFrom": "https://github.com/wmgeolab/geoBoundaries/raw/9469f09/releaseData/gbOpen/IND/ADM1/geoBoundaries-IND-ADM1_simplified.geojson",
"processing": "coordinates rounded to 3 decimals, consecutive duplicate points dropped (library/scripts/boundaries/source-full-adm1.mjs)",
"note": "Complete ADM1. The curated gb-IND-ADM1.geojson (the 100-list partition, listId-tagged) is a different file and is not replaced."
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,93 @@
Copyright © 2010 by Dharma Type.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,93 @@
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,126 @@
Oswald — SIL Open Font License, Version 1.1
Copyright 2016 The Oswald Project Authors (https://github.com/googlefonts/OswaldFont)
Full text below, and at https://openfontlicense.org/ (OFL-1.1)
WHY THIS FILE IS IN THIS REPO
DX-life sets --font-family-cyrillic to 'Oswald' and base/_globals.scss:35-44 switches every
h1/h2/h3 to it under [lang="ru-RU"], with letter-spacing -0.02em. That switch exists because
BEBAS NEUE HAS NO CYRILLIC GLYPHS AT ALL — their heading face simply cannot render Russian, so
without the switch a Russian heading falls out of the family entirely. Ours did exactly that:
we ship a `ru` locale, we had neither the rule nor the font, so every Russian heading rendered
in whatever condensed system face the browser chose.
WHICH SUBSETS, AND WHY FOUR
cyrillic and cyrillic-ext are the point. latin and latin-ext are carried too because a Russian
heading routinely contains Latin characters — a country name, a figure, an ampersand — and a
face that covers only Cyrillic drops those glyphs back to the fallback MID-WORD, which looks
worse than not switching at all. Google's own unicode-range values are carried verbatim
(css2 v57), so the browser fetches only the subset a given heading needs.
oswald-400-cyrillic.woff2 6700 B
oswald-400-cyrillic-ext.woff2 7688 B
oswald-400-latin.woff2 12248 B
oswald-400-latin-ext.woff2 11624 B
Each verified to begin with the wOF2 magic (774f4632) after download, because a fetch that
silently returns an HTML error page also "succeeds" and a font that is really a 404 page fails
at render time with no network error to find later.
Weight 400 only: their --font-weight-regular is what h1/h2/h3 use, and nothing on the regions
surfaces asks Oswald for another weight.
────────────────────────────────────────────────────────────────────────────────
Copyright 2016 The Oswald Project Authors (https://github.com/googlefonts/OswaldFont)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,93 @@
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,36 @@
worldmap.svg — provenance
=========================
WHAT IT IS
The faint world-map watermark DX-life paints behind its people-group list. Their
UupgsList sets it inline on the list root (app/components/public/UupgsList.vue:451):
background-image: url(/assets/images/worldmap.svg), background-size 60%,
background-position top, min-height 400px. Measured on their live
/regions/cameroon: HTTP 200, 62,740 bytes over the wire, computed background-size
60% / position 50% 0% / repeat no-repeat.
WHERE IT CAME FROM
Copied byte-for-byte from the DX-life repository:
doxa-life/public/assets/images/worldmap.svg
Same organisation, copied so the regions clone matches their page. 311 KB on disk;
small against this tree's other assets, which run 644 KB to 42 MB.
WHY THERE IS NO LICENCE FILE BESIDE IT
Checked, rather than assumed. A full-file scan for copyright, licence, attribution,
author, creator, <title>, <desc>, <metadata>, rdf:/dc: nodes and the usual stock-library
names (Natural Earth, simplemaps, amCharts, Freepik, Shutterstock, OSM, Mapbox) returns
NOTHING. The file is a plain vector — 394 <path> elements, three groups, no <image> and
no <text> — and its ids are design-tool export names (Group_345,
data-name="Group 345"), i.e. authored by their designer rather than downloaded with
embedded credits. DX-life keeps no licence file alongside public/assets/images/ either;
they treat that folder as their own artwork.
So this is internal reuse of a first-party asset, not redistribution of a third-party
one. Recorded here because the alternative was leaving the judgement implicit.
Contrast with the vendored typefaces, which ARE third-party and therefore carry their
licence in the folder that serves them: public/assets/fonts/bebas-neue/LICENSE and
.../poppins/LICENSE, both the SIL Open Font License 1.1, as the OFL requires.
NOTE ON THE FILENAME
Extensionless on purpose. check:public-hygiene refuses a *.txt in a served folder, and
the font LICENSE files set that precedent.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 236 KiB

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
{
"meta":{
"what": "India's 28 states and 8 union territories, each with the UPG-100 people groups located inside it (slugs). The term is \"states and union territories\", never \"provinces\".",
"regenerate": "node plugin-designs/my-upg-100-list/data/emit-india-states.mjs (after pip-stamp.mjs re-stamps admin-membership.json)",
"inputs": {
"membership": {
"path": "plugin-designs/my-upg-100-list/data/admin-membership.json",
"sha384": "sha384-0D8paDuTT3HmH+YQkuuQP0ZIjp84ysz3lIXNaFlNbZKp+OevjnpZjj4FPyi7SJUl"
},
"geometry": {
"path": "data/boundaries/gb-IND-ADM1-full.geojson",
"sha384": "sha384-0tTsrv3FE4otJ2OGq7IXCs84Y9lHF7rz3cB+Vhxv76ARuw90JtJt7RLAG91L+/9j"
}
},
"served_geometry": "boundaries/gb-IND-ADM1-full-web.geojson",
"shape_property": "shapeName",
"counts": {
"states": 28,
"union_territories": 8,
"people_groups": 855
}
},
"states":[
{"id":"india-an","iso":"IN-AN","kind":"union_territory","name":"Andaman and Nicobar Islands","shapeName":"Andaman and Nicobar Islands","bbox":[92.204,6.754,93.943,13.673],"count":9,"slugs":["andamanese","bhatu-andamani","brahmin-andamani","coorgi-andamanese","great-andamanese","jarawas","onge","sentinelese","telengi"]},
{"id":"india-ap","iso":"IN-AP","kind":"state","name":"Andhra Pradesh","shapeName":"Andhra Pradesh","bbox":[76.761,12.624,84.765,19.168],"count":81,"slugs":["accukattavandlu","aguru","aiyarakulu","andhra-vaisnawa","ansari-momin-telugu","arakala","are-bondili","are-katika","atollu","bandollu","bania-agarwal-telegu","bayita-kammara","berad","bhondari","budakukkala","cakketakare","camadollu","cundollu","dammali","dasari","gandalla","gandla","ganta-sayebulu","garadi","gavara","godagali","godari","guddi-eluguwallu","hajjam-telugu","hatgar","jakkula","jandra","jethi","kaikali","karnam","karnibhakthulu","kommu-dasari","konda-kammara","kuliya","kurakula","madiga-dasu","majjula","malis","mandula","manna-dhora","medari","mondivogula","muli","musti-chenchu","nagavamsam","nakhasa","neyyala","ontaris","padigarajulu","pagativeshagallu","paky","pamidi","pamula","pancaratra-srivaisnavas-brahmans","reddi-dora","reddika","runjalavallu","sadaru","sadhu-chetty","sakunapaksollu","samayavallu","senapathi","silavantulu","swakulasali-koshti","tamboli-telugu","thogataveera-kshatriya","tirugatigantla","tolakari","udupula","uppara","vaikhanasa-brahmans","vipravinodulu","yata","yatagiri","yatla","yite"]},
{"id":"india-ar","iso":"IN-AR","kind":"state","name":"Arunachal Pradesh","shapeName":"Arunāchal Pradesh","bbox":[91.546,26.651,97.411,29.462],"count":7,"slugs":["khamba","khamyang","khowa","momba","na","serdukpen","zakhrin"]},
{"id":"india-as","iso":"IN-AS","kind":"state","name":"Assam","shapeName":"Assam","bbox":[89.699,24.136,96.018,27.972],"count":15,"slugs":["aiton","bilaspuri","chamar-assamese","hajjam-assamese","kumhar-assamese","man","maria-muslims","matak","mukhi","nai-assamese","namasudra-assamese","sudra-das","taiphake","teli-assamese","turun"]},
{"id":"india-br","iso":"IN-BR","kind":"state","name":"Bihar","shapeName":"Bihār","bbox":[83.32,24.286,88.3,27.522],"count":18,"slugs":["banpar","camma","dhamin","gadihar","gandharv","hindu-santarash","jains-maithili","jogi-angika","kadar","mukeri","nalband","rajdhop","sahisia","shershabadia","thakurai","tikulhara","tilli","turuk-pasi"]},
{"id":"india-ch","iso":"IN-CH","kind":"union_territory","name":"Chandigarh","shapeName":"Chandīgarh","bbox":[76.705,30.666,76.849,30.795],"count":1,"slugs":["khatik-punjabi"]},
{"id":"india-ct","iso":"IN-CT","kind":"state","name":"Chhattisgarh","shapeName":"Chhattīsgarh","bbox":[80.244,17.782,84.396,24.106],"count":24,"slugs":["badhai-chhattisgarhi","bairagi-chhattisgarhi","bargah","beldar-chhattisgarhi","bhangi-chhattisgarhi","bharewa","bharia-bhumia-chhattisgarhi","bhattra-bhatri","bhunjia","chaikwa","dhakad-halbi","dom-chhattisgarhi","ghadwa","gosain-chhattisgarhi","jogi-halbi","kalal-sadri","khairwar-chhattisgarhi","khatik-chhattisgarhi","kol-chhattisgarhi","munnewar","panara","pando","sayyid-chhattisgarhi","son-koli"]},
{"id":"india-dh","iso":"IN-DH","kind":"union_territory","name":"Dadra and Nagar Haveli and Daman and Diu","shapeName":"Dādra and Nagar Haveli and Damān and Diu","bbox":[70.876,20.047,73.218,20.743],"count":1,"slugs":["sagharias"]},
{"id":"india-dl","iso":"IN-DL","kind":"union_territory","name":"Delhi","shapeName":"Delhi","bbox":[76.839,28.405,77.347,28.884],"count":1,"slugs":["khatbune"]},
{"id":"india-ga","iso":"IN-GA","kind":"state","name":"Goa","shapeName":"Goa","bbox":[73.675,14.901,74.336,15.801],"count":5,"slugs":["bania-konkani","gabit","gomantak-maratha","mhalo","mith-gauda"]},
{"id":"india-gj","iso":"IN-GJ","kind":"state","name":"Gujarat","shapeName":"Gujarāt","bbox":[68.093,20.12,74.477,24.712],"count":90,"slugs":["agaria","agri","anjana-patel-gujarati","bafan","bairagi-gujarati","baloch","bamcha","banjania","barot","barthari","bhodala","borpi","caki","champta","cunara","dafer","dangasiya","dasnami-bawa","depala","dudhwala","gadhai","galiara","garmatan","garvi","gavli","ghantiya","golarana","gosain-gujarati","hajjam-gujarati","halaypotra","hingora","hingorza","jat-muslim","juneja","kahar-gujarati","kamalia","kambodia-bhagat","kapadi","kasar-gujarati","kasbati","kathi","ker","khalpa","khaskeli","khatik-gujarati","langa","machhiyara","majothi","makrani","mandali-muslims","mer","mir","miyana","moghol-gujarati","mujavar","muslim-khatri","muslim-lakhera","muslim-patel","mutwa","narsinghpura","nayak-muslims","node","padhar","panar","paradhi","patanwadia","patni-jamat","patni-vankars","pomla","rathodia","raysipotra","royma","sagar","sandhai-muslims","saranias","sayyid-gujarati","shravan","siddi","sompura-salat","tai","targala","theba","tirgar","turi-barot","vadi","vahivanca-barot","vayati","vyapari","wagheri","wandharo"]},
{"id":"india-hp","iso":"IN-HP","kind":"state","name":"Himachal Pradesh","shapeName":"Himāchal Pradesh","bbox":[75.594,30.378,79.009,33.257],"count":20,"slugs":["bhojki","daule","dhaki","dosali","gosain-kangri","hadi","hesi","khatik-chambeali","malaneese","masku","nar","nicar","pangwal-buddhists","pangwala","rehar","sanhai","sansoi","seok","swangla","thavi"]},
{"id":"india-hr","iso":"IN-HR","kind":"state","name":"Haryana","shapeName":"Haryāna","bbox":[74.474,27.653,77.604,30.929],"count":4,"slugs":["arain-punjabi","hajjam-haryanvi","kucband","lakhera-haryanvi"]},
{"id":"india-jh","iso":"IN-JH","kind":"state","name":"Jharkhand","shapeName":"Jhārkhand","bbox":[83.33,21.97,87.962,25.349],"count":9,"slugs":["dhekaro","jadupetia","jarkhandi-hajjam","jarkhandi-julaha","jharkhandi-nai","modaks","nagbansi","puran","sukiyar"]},
{"id":"india-jk","iso":"IN-JK","kind":"union_territory","name":"Jammu and Kashmir","shapeName":"Jammu and Kashmīr","bbox":[73.39,32.276,76.78,35.118],"count":33,"slugs":["arain-dogri","bania-dogri","bhand-kashmiri","bomba","budhan","cheintz","chopan","dhobi-dogri","dhyar","domal","galwan","ganz","gardi","gorkun","hajjam-kashmiri","kawoj","kraal","lohar-dogri","manhas","manjygur-muslims","moghol-dogri","nagbansi-dogri","najjar","namdagur","naqqas","pohul","ratal","sayyid-pahari","shaksaz","shina-india","sofi","sudhan","waza"]},
{"id":"india-ka","iso":"IN-KA","kind":"state","name":"Karnataka","shapeName":"Karnātaka","bbox":[74.086,11.595,78.588,18.459],"count":81,"slugs":["adiyan","agasa","ansari-momin-kannada","arasu","ayri","bagga","baira","balasantoshi","bandi","bania-kannada","batada","bayil-kammara","bayil-pattar","bellara","bestha","chaptegar","charodi","chenna-dasar","dombi-dasa","durga-murga","galada-kokani","ganti-chores","gatti","gauli","ghadi","godda","gond-kannada","gudikara","guraw","hajjam-kannada","halleer","harkantra","hasalaru","helawa","holeya-dasari","jatigar","jaweri","jogi-telugu","jogi-tulu","khakar","kolari","komara-panta","koosa","koraga-korra","kottari","koyava","kudiya","kudubi","kummara-kumbara","kurahina-setti","ladar","maila","malaikudi","maleru","manser","meda","mogaveera","mundala","nadors","nai-kannada","nairy","natua","nawaiyat","padarthis","padiyar","pambada","pattegar","pendar","pinjara-kannada","rachavar","raddi","rajapuri","raneyar","raval","raya-rawat","sapalyas","sayyid-kannada","soligaru","sudir","thammadi","vilyagaras"]},
{"id":"india-kl","iso":"IN-KL","kind":"state","name":"Kerala","shapeName":"Kerala","bbox":[74.868,8.292,77.412,12.796],"count":72,"slugs":["allar","ambalavasi","anthur-nair","arayavati","ayyanavar","bakuda","bania-malayalam","bowis-mogeyar","cakkala","cakkyar-nambiyar","chakkiyar","chamar-muchi-malayalam","chengazhi-nambiar","cingatanmar","dheevara","erawallan","ezhavathi","ilayatu","kakkalan","kalanadies","kalladi","kallattu-kurup","karimpalan","kavara","kavuthiyan","kidaran","kocu-wellan","kollan","kootan","kumar-kshatriya","kummara-kusuvan","kunduvadian","kurukkal","malai-pandaram","malayalar","maran-nairs","marasari","mavilan","moger-malayalam","moothan","mukkari","musari","muttatu","nalkadaya","nambutiris","narsanna","nasuvan","nayadi","otans","padannan","palakad-tandans","pandithar","pathinaickans","pathiyan","pisaroti","poduval","pulisari","puspakan-unni","reddi","samanthan","thachanad-moopan","tiyattunni","urindavan","vadugan","valinjiyar","vetan","vilkurup","visavan","wainadan-cetti","wainadan-kader","wainadan-pulayan","wayanad-pathiyan"]},
{"id":"india-la","iso":"IN-LA","kind":"union_territory","name":"Ladakh","shapeName":"Ladākh","bbox":[72.529,32.338,80.327,37.077],"count":6,"slugs":["bania-kashmiri","brukpa","campa","dokhpa","mon-india","purig-pa"]},
{"id":"india-ld","iso":"IN-LD","kind":"union_territory","name":"Lakshadweep","shapeName":"Lakshadweep","bbox":[72.165,8.264,73.702,11.704],"count":1,"slugs":["laccadive"]},
{"id":"india-mh","iso":"IN-MH","kind":"state","name":"Maharashtra","shapeName":"Mahārāshtra","bbox":[72.655,15.606,80.898,22.034],"count":54,"slugs":["attar-saibulu","baharupi","barda","bari-marathi","bharwad","bhoi-marathi","chari","chitari","citrakathi","dhawad","dom-marathi","garodi","garpagari","gondhali","gopal","hardas","hatkar","jhadi","kachchi-kushwaha","kaghzi","kapewar","kasikapadi","kathar","kharvi","khatik-marathi","kokaniz","kolhati","kudimahan","ladsi","lajjhar","lodha-marathi","lonar","mana","mitna","mondiwar","muslim-kachera","nahal","naikpod","nandiwala","neeli","nilkanth","pangul","phularis","pinjara-marathi","pochammalollu","prabhu-patane","sahi","sanghar","sarodi","sutar-mistry","takari","velip","waidu","yelmar"]},
{"id":"india-ml","iso":"IN-ML","kind":"state","name":"Meghalaya","shapeName":"Meghālaya","bbox":[89.814,25.029,92.803,26.12],"count":2,"slugs":["banai","dalu-india"]},
{"id":"india-mn","iso":"IN-MN","kind":"state","name":"Manipur","shapeName":"Manipur","bbox":[92.971,23.833,94.745,25.692],"count":5,"slugs":["koiren","lois","pangans","thanga","yatibi"]},
{"id":"india-mp","iso":"IN-MP","kind":"state","name":"Madhya Pradesh","shapeName":"Madhya Pradesh","bbox":[74.03,21.07,82.81,26.869],"count":33,"slugs":["anjana","banchada","baraua","bari-hindi","basudeva","berar","bhanumati","bhurtia","dhed","gachha","hajjam-hindi","jadam","jains-hindi","kachera-bagheli","kandera","kasar-hindi","ladia","lahangir","manbhav","mankar","muskhan","muslim-gond","naita","namdeo","naoda","nat-bagheli","nema","rujjhar","sais","sikligar","sisgars","soria","zamral"]},
{"id":"india-mz","iso":"IN-MZ","kind":"state","name":"Mizoram","shapeName":"Mizoram","bbox":[92.257,21.941,93.436,24.521],"count":0,"slugs":[]},
{"id":"india-nl","iso":"IN-NL","kind":"state","name":"Nagaland","shapeName":"Nāgāland","bbox":[93.327,25.199,95.242,27.036],"count":0,"slugs":[]},
{"id":"india-or","iso":"IN-OR","kind":"state","name":"Odisha","shapeName":"Odisha","bbox":[81.388,17.813,87.486,22.568],"count":64,"slugs":["baha-i","bairagi-odia","bangti","banka","bari-odia","bauti","bavuri","bhoi-odia","bondo","brahman-jyoti","candhai-maru","chatarkheya","cherua","citrakar","daitapati","damla","darji-odia","dhera","didayi","eneti-dora","ghantarghada","ghogia","ghusuria","godra","guna","irika","janughanta","jogi-odia","kachchi-alia","kamila","kapasiya","kasar-odia","khadura","khandwal","khati-odia","kobari","koraga","kuda","kuli","kurunga","kusta","liari","maghia","mahuria","malia","markande-kanjia","mundapotta","nai-odia","naksia","noliya","oriya","panka-odia","parenga","radhi","rana","sabakhia","samasi","sanei","sayyid-odia","sinduria","suda","thanapati","tharua","tula-vina"]},
{"id":"india-pb","iso":"IN-PB","kind":"state","name":"Punjab","shapeName":"Punjab","bbox":[73.88,29.542,76.939,32.513],"count":4,"slugs":["daoli","gagra","pinjara-punjabi","racband"]},
{"id":"india-py","iso":"IN-PY","kind":"union_territory","name":"Puducherry","shapeName":"Puducherry","bbox":[75.527,10.827,82.344,16.761],"count":5,"slugs":["indo-french","indo-vietnamese","sonavar","vettalaikarans","vettiyan"]},
{"id":"india-rj","iso":"IN-RJ","kind":"state","name":"Rajasthan","shapeName":"Rājasthān","bbox":[69.484,23.061,78.272,30.198],"count":48,"slugs":["badi-marwari","baret","bargi","bhopa","bhutta","cejara","chadwa","chheepi","chobdar","chungar","cudigar","dadupanthi","damami","dhola","ghihara","gosain-rajasthani","gurru","hiranbaj","khadim","kharol","kriyagar","laskari","mangnyar-muslims","moghol-marwari","mogia","murhid","nagda","nai-mewari","nyaria","pajabgar","pakhimara","rajkumhar","rajpurohit","rath","runwalas","sain","sanchia","santia","saraogi","shikari","singiwala","sorgars","sunga","tak","usta","van-baoriya","ved","vijabargiya"]},
{"id":"india-sk","iso":"IN-SK","kind":"state","name":"Sikkim","shapeName":"Sikkim","bbox":[88.014,27.079,88.92,28.129],"count":1,"slugs":["yakha"]},
{"id":"india-tg","iso":"IN-TG","kind":"state","name":"Telangana","shapeName":"Telangāna","bbox":[77.236,15.836,81.323,19.916],"count":26,"slugs":["are-kapu","attagara","bhatrajulu","bindla","bolla","cippollu","gangireddula","gosangi","gurika","jains-telugu","kachi","katipamula","khatik-telugu","lakkamari-kapu","mala-masti","matangi","mitha-ayyalavar","moghal-telugu","muchi-chamar","pambala","pariki-muggula","patkars","rajannalu","saradagallu","sindhollu","verra-musti"]},
{"id":"india-tn","iso":"IN-TN","kind":"state","name":"Tamil Nadu","shapeName":"Tamil Nādu","bbox":[76.233,8.077,80.347,13.565],"count":64,"slugs":["alavan","ansari-momin-tamil","anuppan","aranadan","baig","bondili","cekkala","chamar-muchi-tamil","chayakkaran","dasa-palanjika","dayare","dhobi-tamil","gangawar","gudugudupandi","gurukkal","jains-tamil","jati-pillai","kambalattan","kambar","kannada-naicker","kannada-saineegar","karaiyar","karlar","karna-sale","katesar","kattiakaran","kayalar","kepmari","koliyan","kongu-vaisnawa","kongu-vellala-gounder","kota","krishnavaka","kudumi","kuluvan","kummara-kummari","kunnuva-vellalar","malai-vellala","maleyan","maniyattikkaran","mudugar","nakkala","namdeo-maratha","nanjil-mudali","pannadi","panniandi","parkavakulam","parvatarajakulam","pattanavan","perumkollar","salivahanam","savalakkarar","sayyid-tamil","semman","senaithalaivar","siviar","tambiran","tholuva-naicker","thoraiyar","tondman","uppillian","urikkar-naicker","vellaiyan-kuppam-padaiyaci","vettaikkaran"]},
{"id":"india-tr","iso":"IN-TR","kind":"state","name":"Tripura","shapeName":"Tripura","bbox":[91.15,22.939,92.332,24.528],"count":11,"slugs":["bajandar","banik-bania","barua","haluadas","kanda","mahisyadas","malakar","sabar","saha","shabdakar","tripuri-namasudra"]},
{"id":"india-up","iso":"IN-UP","kind":"state","name":"Uttar Pradesh","shapeName":"Uttar Pradesh","bbox":[77.084,23.87,84.635,30.406],"count":23,"slugs":["ahmadiya","arain-kannauji","badhik","badi","balahar","bargala","bargujar","bhuiyar","bisati","charwaha","gadhia","habura","hijra","kankali","khangar-bundeli","mujavir","pankhiya","patari","putliwale","rai-bhat-muslims","rastogi","rave","sheikhzade"]},
{"id":"india-ut","iso":"IN-UT","kind":"state","name":"Uttarakhand","shapeName":"Uttarākhand","bbox":[77.573,28.723,81.045,31.456],"count":7,"slugs":["anwal","chaudangsi","jimdar","kuthliya-bora","raji-india","sayyid-garhwali","tagewale"]},
{"id":"india-wb","iso":"IN-WB","kind":"state","name":"West Bengal","shapeName":"West Bengal","bbox":[85.82,21.484,89.874,27.221],"count":30,"slugs":["angarok","ansari-momin-bengali","bagti","baida","baisya","bhakta","bhalla","bhujel","byadh","chunari","dhal-kstriya","dhimal","ghazi","hajjam-bengali","jains-bengali","kakmara","kapuria","kasar-bengali","khairwar-bengali","khalifa","khatik-bengali","kurol","mirshikar","paharia","pinjara-dhunia-bengali","siyalgar","sunuwar","sutradhar","tamboli-bengali","tripuri-kumhar"]}
]}

View file

@ -0,0 +1 @@
{"id":"frontier-500k","label":"Project 700: frontier people groups of 500,000 or more","definition":"IMB Lostness Priority 0 and Population of at least 500,000","filter":{"lostness":[0],"minPopulation":500000},"sources":{"imb":"data/people-groups/people_groups.csv","doxa":"https://pray.doxa.life/api/people-groups/list (fetched 2026-09-25)"},"counts":{"imb":737,"doxa":71},"slugs":["agasa","aiyarakulu","akhdam","alawites-turkiye","arab-bedouin","avar-kunsakh","badhai-chhattisgarhi","bairagi-gujarati","baloch-eastern","baloch-western","bania-agarwal-telegu","bederia","bedouin-arabs-saudi-arabia","dar-hamid","deaf-burmese","deaf-congolese-democratic-republic-of-the-congo","deaf-iranians","deaf-pakistanis","digil-rahanweyn","gaaliin","gawamaa","gazan-arabs","guhayna","ha-li","hadrami-arabs","hasania","jains-hindi","jat-muslim","jula-burkina-faso","kawahia","kotokoli-togo","kummara-kumbara","kurd-northern","kuria","laki","lebanese-shia-arabs","lebanese-sunni-arabs","libyan-arabs","luwu","makhuwa-shirima","makua","meadow-mari","mishaba-laluo","moor","mwera","namasudra-assamese","northern-dong","northern-yemeni-arabs","nuer","pila","pingdi-yao","rizeiqat","rufaa","sanusi-bedouins","shiites","shina-pakistan","shirazi","sudra-das","sutradhar","tigre","tihama-yemeni-arabs","tondman","turkmen","west-bank-arabs","western-baloch-pakistan","white-moor","xiaoliangshan-nosu","yao","yazeed","yinuo-nosu","zigua"]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Some files were not shown because too many files have changed in this diff Show more