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:
parent
88f747cf5c
commit
8890377993
5 changed files with 100 additions and 16 deletions
|
|
@ -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)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
29
library/print/experimentalExports.js
Normal file
29
library/print/experimentalExports.js
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue