The Country Posters map's legend Packet export works, and the card deck rolls out behind one switch

- The legend's packet submenu emits 'packet:full' | 'packet:lightweight', and the country map compared
  against a bare 'packet', so its legend Packet export did nothing at all. It splits the kind first, as
  the UPG-100 map's handler does (the research map carries the same three lines; reported to its owner).
- The selection's rows come from the print standard's records provider by slug (full print fields,
  the reader's language); the rows mapped by hand put the country's NAME in country_code and carried no
  language, so every profile description ran short. They remain as the fallback. The live map's raster
  goes in through the mapImage seam, so page 1 is the map full page.
- The legend gains an OPT-IN Card Deck export (exportCards; provide('legendExportCards') for the mobile
  sheet), so a map that does not handle it never shows a dead item.
- library/print/experimentalExports.js is the ONE switch for rolling the standard's exports out on
  maps that are not production print surfaces (production: the UPG 100 list, the Regions dashboard's
  country posters, the Country Posters wall). This map's card deck sits behind it; off — per page, per
  visit (?exports=off), per site or everywhere — restores the menu it had before.
- The records provider returns coordinates (a generated map's pins), exposes the unselected set
  (recordsFor.all) and sorts in the reader's collation.
This commit is contained in:
Doxa Maps 2026-09-29 04:44:24 +00:00
parent 88f747cf5c
commit 8890377993
5 changed files with 100 additions and 16 deletions

View file

@ -430,6 +430,8 @@ const onSemanticTreeSelect = inject('onSemanticTreeSelect', () => {})
// handler, so the three profiles that do not provide one are untouched.
const onLegendExport = inject('onLegendExport', null)
const mobileExportEnabled = computed(() => typeof onLegendExport === 'function')
// The card deck is opt-in the same way: a profile that handles 'cards' provides legendExportCards = true.
const mobileExportCards = inject('legendExportCards', false)
// ── Data-driven legend (universal for all legend types) ─────────────────────
const legendTypeRef = toRef(props, 'legendType')
@ -899,6 +901,7 @@ onBeforeUnmount(() => {
:columnLabel="treeColumnLabelForMobile"
:columns="columns || ['count', 'pop']"
:export-enabled="mobileExportEnabled"
:export-cards="mobileExportEnabled && mobileExportCards === true"
@select="onSemanticTreeSelect"
@export="(kind) => onLegendExport && onLegendExport(kind)"
>

View file

@ -111,6 +111,9 @@ const props = defineProps({
// unchanged; a profile opts in by passing :export-enabled. Gates the
// top-right Export affordance (UX scaffold only — no export yet).
exportEnabled: { type: Boolean, default: false },
// Which prayer resources the host can print for the selection. 'cards' (the card deck) is OPT-IN: a
// host that does not handle it must not show a menu item that does nothing. Poster and packet stay on.
exportCards: { type: Boolean, default: false },
// Prayer progress payload for the header bar — { covered, total, segments } from
// useLegendData(...).progress. null → no bar (every non-prayer legend). Falls back
// to inject('legendProgress') so the mobile sheet's embedded legend (LegendMobile →
@ -1290,6 +1293,15 @@ useShadowStyles(`
</svg>
<span class="stl-export-item-label">{{ t('legend.export.poster') }}</span>
</button>
<!-- The CARD DECK for the selection: one prayer card per people group (the same deck the
poster walls print). Opt-in per host, see exportCards. -->
<button v-if="exportCards" type="button" class="stl-export-item" role="menuitem" @click.stop="emitExport('cards')">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<rect x="1.5" y="2.5" width="6" height="8" rx="1" stroke="currentColor" stroke-width="1.2"/>
<rect x="4.5" y="1.5" width="6" height="8" rx="1" stroke="currentColor" stroke-width="1.2"/>
</svg>
<span class="stl-export-item-label">{{ t('buttons.cardDeck') }}</span>
</button>
<!-- ★ THE PACKET ITEM IS A DISCLOSURE, NOT A PRINT. Clicking it no longer prints
immediately, because there are now two packets it could mean. It EXPLAINS what a
UPG 100 list is — the item is the only place a reader meets the idea — and reveals

View file

@ -24,7 +24,7 @@ import { applyPeopleGroupList } from './peopleGroupList.js'
// What the prints read: the card's face and language, the profile's description (imb_gsec feeds its
// reachedness sentence), the sets page's joins (slug), and the country for the page-1 map and captions.
export const PRINT_FIELDS = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion,'
+ 'image_url,primary_language,imb_gsec'
+ 'image_url,primary_language,imb_gsec,latitude,longitude' // coordinates: a generated map's pins
// Fields the API sends as { value, label } dicts. Normalised to the value, with the label kept beside
// it as <field>_label — the shape card-render and describeGroup read. Same rule as the UPG-100
@ -54,7 +54,7 @@ export function extractRecords(payload) {
}
/** The records a selector names, sorted by name. `evaluate` handles selector types this module does not. */
export function selectRecords(records, selector, evaluate = null) {
export function selectRecords(records, selector, evaluate = null, locale = undefined) {
const rows = Array.isArray(records) ? records : []
const s = selector || {}
const vals = new Set((s.values || []).map(String))
@ -72,7 +72,8 @@ export function selectRecords(records, selector, evaluate = null) {
} else {
picked = []
}
return picked.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
// Sorted in the READER's collation (the list prints in their language, so its order should read as theirs).
return picked.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), locale))
}
/**
@ -105,5 +106,8 @@ export function createPeopleGroupRecords({ apiBase, lang = () => 'en', profileCo
}
return cache.get(lc)
}
return async (selector) => selectRecords(await all(), selector, evaluate)
const recordsFor = async (selector) => selectRecords(await all(), selector, evaluate, (typeof lang === 'function' ? lang() : lang) || undefined)
// Every record, unselected — what a generated map sheet needs to assign pins across all its zones.
recordsFor.all = all
return recordsFor
}

View file

@ -0,0 +1,29 @@
/**
* experimentalExports.js — the ONE switch for the print standard's EXPERIMENTAL rollout on map legends.
*
* PRODUCTION (no flag, always on): the UPG 100 list and the Regions dashboard's country posters, and the
* Country Posters wall — those print through the standard unconditionally.
* EXPERIMENTAL (this flag): every OTHER map whose legend already has an Export menu gets the same
* reusable exports (the card deck; the prayer packet, full or lightweight) only while this is on, so the
* rollout can be tried in more places and switched off in one move if it misbehaves.
*
* Off, in order of reach:
* one page profile-config `experimentalExports: false`
* one visit ?exports=off in the URL
* one site window.DOXA_EXPERIMENTAL_EXPORTS = false before the maps load
* everywhere EXPERIMENTAL_EXPORTS_DEFAULT below
* A legend given `false` shows exactly the Export menu it had before the rollout.
*/
export const EXPERIMENTAL_EXPORTS_DEFAULT = true
export function experimentalExportsOn(profileConfig = null) {
const cfg = profileConfig && typeof profileConfig === 'object' && 'value' in profileConfig ? profileConfig.value : profileConfig
if (cfg && typeof cfg.experimentalExports === 'boolean') return cfg.experimentalExports
try {
const q = new URLSearchParams(globalThis.location?.search || '').get('exports')
if (q === 'off' || q === '0' || q === 'false') return false
if (q === 'on' || q === '1' || q === 'true') return true
} catch { /* no location (Node, a sandboxed frame): fall through */ }
if (typeof globalThis.DOXA_EXPERIMENTAL_EXPORTS === 'boolean') return globalThis.DOXA_EXPERIMENTAL_EXPORTS
return EXPERIMENTAL_EXPORTS_DEFAULT
}

View file

@ -66,6 +66,9 @@ import { useCountryOutline, resolveCountryIso } from '@map/composables/useCountr
import { FULL_PRAYER_THRESHOLD } from '@map/colors/prayer-progress.js'
import { useI18n } from 'vue-i18n'
import { SUPPORTED_LOCALES } from '@map/i18n/index.js'
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { experimentalExportsOn } from '@map/print/experimentalExports.js'
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
// Shared data pipeline — lives in the library (@map/composables/useMapData.js).
import { useMapData } from '@map/composables/useMapData.js'
@ -985,6 +988,12 @@ provide('onSemanticTreeSelect', (node) => onSemanticTreeSelect(node))
// so providing unconditionally would give the phone an affordance the desktop deliberately
// hides whenever that flag is off.
provide('onLegendExport', EXPORT_FEATURE_ENABLED ? onExport : null)
// ★ EXPERIMENTAL ROLLOUT of the print standard on this map's legend (library/print/experimentalExports.js):
// the card deck for a selection shows only while the shared switch is on. This map pane is not one of the
// production print surfaces (those are the UPG 100 list, the Regions dashboard's country posters and the
// Country Posters wall), so the new export is flagged, and off restores the menu it had before.
const EXPERIMENTAL_EXPORTS = EXPORT_FEATURE_ENABLED && experimentalExportsOn(profileConfig)
provide('legendExportCards', EXPERIMENTAL_EXPORTS)
// Resolves the active tab's strategy → color for a given feature, used by
// useSelectedPin.js so the selection-pin matches the underlying dot color
// (qa: 2026-05-06 user feedback). research-mfe tab strategyKeys already
@ -1248,15 +1257,25 @@ async function openPoster() {
// ─── Export menu router (SemanticTreeLegend @export) ─────────────────────────
// EXPERIMENTAL — dormant behind the shared kill-switch (SHOW_EXPORT_BUTTON).
// The legend's Export menu emits 'export' with 'poster' | 'packet'.
// ⚠ THE KIND CARRIES ITS VARIANT ('packet:full' | 'packet:lightweight'), as the legend's packet submenu
// emits it. This compared against a bare 'packet', so the legend's Packet export on this map did nothing
// at all. Split first, exactly as upg-100-list's onLegendExport does.
function onExport(kind) {
if (kind === 'poster') return openPoster()
if (kind === 'packet') return openPacket()
const [base, variant] = String(kind).split(':')
if (base === 'poster') return openPoster()
if (base === 'packet') return openPacket(variant === 'lightweight' ? 'lightweight' : 'full')
if (base === 'cards' && EXPERIMENTAL_EXPORTS) return openPacket('cards')
}
// ─── Packet (printed through the shared renderer, like Simple Research's) ─────
// Defensive throughout: never throw uncaught into the UI; guard against double-run.
// The print standard's records provider for a selection on this map (one fetch per language).
const selectionRecords = createPeopleGroupRecords({
apiBase: () => getApiBaseUrl(),
lang: () => String(uiLocale.value || 'en').split('-')[0],
})
let _packetBusy = false
async function openPacket() {
async function openPacket(variant = 'full') {
if (_packetBusy) return
_packetBusy = true
try {
@ -1310,7 +1329,17 @@ async function openPacket() {
// The renderer reads slug / name / population / religion / country_code, and usePosterPrints reads
// image_url; this map's normalized rows carry country and imageUrl in camelCase. Mapped once, here,
// where the difference is visible, rather than renaming either side's vocabulary.
const records = groups.map((g) => ({
// ★ THE SELECTION'S RECORDS FROM THE PRINT STANDARD'S PROVIDER (lists/peopleGroupRecords.js), by
// slug: every field the prints read (the country as ISO3 with its name beside it, the language and
// reachedness the profile sentence is built from), in the reader's language. The rows mapped by hand
// below put the country's NAME in country_code and carried no language, so every description ran
// short; they remain only as the fallback when the API cannot be reached.
let records = null
try {
const slugs = groups.map((g) => String(g.slug || '')).filter(Boolean)
if (slugs.length) records = await selectionRecords({ type: 'pg_slugs', values: slugs })
} catch (e) { console.warn('[country-posters] selection records unavailable; printing from the map rows:', e?.message || e) }
if (!records || !records.length) records = groups.map((g) => ({
slug: String(g.slug || g.uniqueId || g.id || ''),
name: g.name,
population: g.population,
@ -1322,14 +1351,15 @@ async function openPacket() {
// name, and the field name country_code invites exactly that mistake on the country line.
// Checked against the normalizer rather than guessed: library/api/DataSourceManager.js:357-358.
religion: g.religionName || g.religionLabel || g.religion || '',
country_code: g.countryName || g.countryIsoLabel || g.country || g.countryIso || '',
country_code: g.countryIso || g.country_code || '',
country_code_label: g.countryName || g.countryIsoLabel || g.country || '',
image_url: g.imageUrl || g.image_url || '',
}))
// Page 1 keeps the LIVE map rather than a fetched poster: this half is the one that knows what is
// currently on screen. The shared renderer interpolates `mapSvg` as raw markup, so the PNG this map
// already knows how to produce rides in as an <img> — a data URL, because the print frame is a
// separate document and an object URL created here would be revoked out from under it.
// currently on screen. The PNG this map already knows how to produce goes in as a data URL (the
// print frame is a separate document; an object URL created here would be revoked out from under it)
// through the composable's mapImage seam, which places it as the whole page.
const spec = defaultPosterSpec({ widthIn: 8.27, heightIn: 11.69, dpi: 150, orientation: 'portrait' })
spec.slots.title = { ...spec.slots.title, text: legendTitle.value || 'Doxa Map' }
// ★ useLegendData returns `items`, not `rows`: `legend.rows` was undefined, so `|| []` handed the
@ -1340,8 +1370,11 @@ async function openPacket() {
.map((r) => ({ label: r.label, color: r.color }))
.filter((r) => r.label)
spec.slots.legend = { ...spec.slots.legend, rows: legendRows }
let mapSvg = ''
try {
// The live map, rasterised NOW (generated, not stored), becomes page 1 through the print standard's
// mapImage seam, which places it as the whole page. It was handed over as a bare <img>, which the
// page-1 layout no longer draws full page. The card deck has no map page, so it skips the render.
let mapUrl = ''
if (variant !== 'cards') try {
const mapBlob = await useMapPoster(() => map.value).render(spec, 'png')
const dataUrl = await new Promise((resolve, reject) => {
const r = new FileReader()
@ -1349,7 +1382,7 @@ async function openPacket() {
r.onerror = reject
r.readAsDataURL(mapBlob)
})
mapSvg = `<img src="${dataUrl}" alt="" style="width:100%;height:100%;object-fit:contain">`
mapUrl = dataUrl
} catch (e) {
// A cover without the map beats no packet — the same call the shared composable documents.
console.warn('[country-posters] packet map page skipped:', e?.message || e)
@ -1364,8 +1397,10 @@ async function openPacket() {
records: () => records,
lang: () => String(uiLocale.value || 'en').split('-')[0],
label: () => zone.label,
mapImage: () => mapUrl || null,
})
await prints.printPacket(zone, { mapSvg })
if (variant === 'cards') await prints.printCardDeck(zone)
else await prints.printPacket(zone, { variant })
} catch (err) {
console.error('[country-posters] packet export failed:', err)
} finally {
@ -2367,6 +2402,7 @@ onBeforeUnmount(() => {
:columnLabel="legendColumnLabel"
:open="_stlOpen"
:export-enabled="EXPORT_FEATURE_ENABLED"
:export-cards="EXPERIMENTAL_EXPORTS"
@select="onSemanticTreeSelect"
@export="onExport"
@update:open="_onStlOpenChange"