Merge branch 'regions/country-map-posters' into regions/handoff-integration

# Conflicts:
#	plugin-designs/regions-dashboard/regions-list.vue
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/regions-dashboard/region-dashboards/index.html
#	public/dx-maps/regions-dashboard/regions-dashboard.js
#	public/dx-maps/regions-dashboard/regions-list/index.html
#	public/dx-maps/regions-dashboard/regions-mfe/index.html
This commit is contained in:
Doxa Maps 2026-09-29 14:53:10 +00:00
commit bf07e0eb61
2 changed files with 133 additions and 0 deletions

View file

@ -0,0 +1,51 @@
/**
* countrySheet.js — a country's map sheet, GENERATED in the browser at the click.
*
* SHARED (@map/packet/countrySheet.js). Two consumers: the regions dashboard's country-poster wall
* and the standalone <doxa-country-map> element. It was plugin-local while the regions list was its
* only caller; it moved here when the element became the second, rather than being copied.
*
* The print standard is generate, store only previews: no country sheet is shipped, and none should
* be. MEASURED before this existed: data/posters holds 28 .html sheets and all 28 are ZONES, while
* emit-countries.mjs writes only country-posters.json, whose `sheet` field is a PAPER SIZE ('3x3')
* rather than a filename. So a packet's page-1 lift for a country asked for posters/country-<iso3>.html
* and got a 404 for all 175 of them. This draws it instead.
*
* Same shape as my-upg-100-list's zone sheet (data/zone-sheet-print.js), deliberately, so the two
* surfaces cannot draw the "same" sheet two ways:
*
* const sheet = makeCountrySheet({ records, assetsBase: () => base })
* usePosterPrints({ …, sheet }) // (item, format) -> html
* <PosterWall :sheet="sheet" />
*
* The country case is simpler than the zone case: generateCountrySheet resolves the zone and its
* geometry from the country entry itself, so there is no assignment step to keep in sync.
*/
/**
* @param {object} o
* records (selector) => Promise<rows[]> the shared provider; this asks it for ONE country
* assetsBase () => string where boundaries/ is served from
* @returns {(item, format) => Promise<string>} the sheet's HTML, or '' when it cannot be drawn
*/
export function makeCountrySheet({ records, assetsBase }) {
return async function countrySheet(item, format) {
const iso3 = item && item.iso3
if (!iso3) return '' // a selection or a row without a country: no sheet
// Lazy, like the zone sheet: the generator and the boundary loader are only pulled when a
// reader actually prints, so they stay out of every wall's initial download.
const { generateCountrySheet } = await import('@map/packet/posterSheets.js')
const [{ ISO3_NAME }, rows] = await Promise.all([
import('@map/data/iso3-name.js'),
records({ type: 'country', values: [iso3] }),
])
if (!rows || !rows.length) return '' // nothing to pin: fall through rather than draw an empty country
return generateCountrySheet({
country: { id: item.id, iso3, label: item.label, count: item.count },
records: rows,
assetsBase: assetsBase(),
ISO3_NAME,
format,
})
}
}

View file

@ -33,6 +33,26 @@ import { toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js
import { peopleGroupPoints, PEOPLE_GROUP_POINT_FIELDS } from '@map/utils/peopleGroupPoints.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { makeCountrySheet } from '@map/packet/countrySheet.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import posterData from '@map/data/country-posters.json'
// ★ THE PRINT SURFACE IS OPT-IN AND LAZY, AND BOTH HALVES MATTER.
//
// OPT-IN, because other sites embed this element. A page that does not set showPosters must render
// exactly what it renders today; the surface is additive, never a change to the existing contract.
//
// LAZY, because PosterWall STATICALLY imports usePosterPrints, which pulls the whole printing
// subsystem. A plain `import PosterWall` would therefore land that weight in every embed including
// the ones that never turn the flag on, which is the opposite of opt-in. An async component keeps
// it out until a page asks for it.
//
// retryableAsyncComponent, not raw defineAsyncComponent: Vue CACHES a rejected loader promise, so
// one dropped request would disable the surface for the life of the page. Same reason
// upg-100-list.vue uses it for its detail panel.
const PosterWall = retryableAsyncComponent(() => import('@map/components/poster/PosterWall.vue'))
const props = defineProps({
profileConfig: { type: String, default: '' },
@ -81,6 +101,43 @@ const researchUrl = computed(() => cfg.value.researchUrl || 'https://doxa.life/r
const prayBaseUrl = computed(() => cfg.value.prayBaseUrl || 'https://pray.doxa.life')
const countryUrl = computed(() => cfg.value.countryUrl || 'https://doxa.life/regions/')
const languageCode = computed(() => cfg.value.languageCode || cfg.value.locale || cfg.value.lang || 'en')
// OFF unless the host says otherwise, and only true for a literal true / 'true': an absent key, a
// typo'd value or a stray string must not switch a public page's UI on by accident.
const showPosters = computed(() => cfg.value.showPosters === true || cfg.value.showPosters === 'true')
// The wall shows THIS element's country, not a region's list, so its single item is the entry for
// the first country code. An unknown code still yields a row, carrying the reason it cannot print,
// exactly as the regions list does for a country with no poster.
const POSTER_BY_ISO3 = new Map((posterData.countries || []).map((c) => [String(c.iso3 || '').toUpperCase(), c]))
const posterItems = computed(() => {
const iso3 = countryCodes.value[0]
if (!iso3) return []
const rec = POSTER_BY_ISO3.get(iso3)
if (rec) return [rec]
return [{
id: `country-${iso3.toLowerCase()}`,
iso3,
label: iso3,
count: 0,
unsupportedReason: t('No poster has been generated for this country yet.'),
}]
})
const instanceId = computed(() => cfg.value.instanceId || 'country-map')
const posterAssetsBase = computed(() => resolveAssetsBase(cfg.value))
// The same shared provider the regions wall uses, not a copy of its fetch: one cached request per
// language, and the same rows feed the sheet, so the printed map and the printed list cannot
// disagree about which groups are in this country.
const countryRecords = createPeopleGroupRecords({
apiBase: () => prayBaseUrl.value,
lang: () => languageCode.value,
profileConfig: injectedCfg,
})
const countrySheet = makeCountrySheet({
records: countryRecords,
assetsBase: () => posterAssetsBase.value,
})
const { t } = useRegionsI18n()
// ★ THE ELEMENT FETCHES ITS OWN PINS. prayBaseUrl is in the contract, and it is the API base the
@ -167,4 +224,29 @@ watch(() => [languageCode.value, countryCodes.value.join(',')], loadPoints)
"Regionsdaten" all say regions. The people-groups sentence is already in the catalogue and
already translated in all eleven, so this costs no new string. -->
<p v-if="loadFailed" class="cm-status" role="status">{{ t('People groups could not be loaded. This is a connection problem, not a filter.') }}</p>
<!-- OPT-IN. v-if, not v-show: an embed that never sets showPosters must not carry this markup at
all, and the async component behind it is never even requested. Turning it on is a change to
a live page, so no host is switched on here. -->
<PosterWall
v-if="showPosters && posterItems.length"
:items="posterItems"
:instance-id="instanceId"
:assets-base="posterAssetsBase"
:heading="''"
:intro="''"
unit="UPG"
:item-noun="t('Country')"
:item-noun-plural="t('Countries')"
item-count-key="regions.countryCount"
:records="(item) => countryRecords({ type: 'country', values: [item.iso3] })"
:sheet="countrySheet"
:card-stocks="true"
:collapsible="false"
:embedded="true"
:section-heads="false"
:columns="1"
:unsupported="(c) => c.unsupportedReason || null"
download-prefix="doxa-country"
poster-action="print"
/>
</template>