One shared print busy state, and the dashboard waits for its map before printing

Driver test round 2, #2 — the half the @media print rules could not do. The
complaint was "no loading indicator on a click": the reader presses Print, the
dialog takes a moment to rise, and nothing says anything is happening. It is
worse on this page than most, because what the dialog waits for is a MAP —
press it a moment early and the sheet carries a grey rectangle.

printInPlace.js gains printWhenReady({ waitFor, onBusy, timeoutMs }). This
pattern already exists five times in the repo (upg-100-list c54a11d5a, four of
exp's 786a83461, PeopleGroupsWall.printWall); they agree on the easy half —
set a flag, print, clear it — and each invents the hard half, which is what
"ready" means and what happens when ready never comes. The hard half is the one
that strands a reader, so it is the half that graduates:

  · THE TIMEOUT PRINTS. It does not reject and does not cancel. A reader who
    clicked Print and waited has asked for paper; a slow tile server is a reason
    to print a plainer map, not a reason to produce nothing.
  · allSettled, not all — one waiter that rejects must not cost the print.
  · onBusy clears in a finally, on every path: a rejected wait, a throwing
    print, a host with no window.print. A busy state that outlives its own
    operation makes the page look broken rather than slow.

RegionCountryMap answers when it is done, and map.loaded() is not that answer —
it reports true while tiles still stream, which is how gospel's charts printed
blank twice today. The condition is: style applied, camera stopped, tiles for
this camera in. It checks synchronously FIRST and only then subscribes to
'idle', because this component's own header already records that once('load')
measured unreliable in both directions here — an event that fired before you
listened never arrives. Always resolves, always removes its listener.

The button is quiet, sits under the title, and is hidden on paper. Busy state
carries aria-busy and :disabled, not just a changed label: the screen reader is
told, and disabled is what actually stops a second print. Both strings are in
all 11 locales; "Print" was already translated 23 times elsewhere in the tree
under other keys, so this adds the key the dashboard's own catalogue lacked.

Mutation-tested, since the failure this guards is the path nobody takes: a
waiter that never settles returns "timeout" after the ceiling and prints anyway
(201ms measured on a 200ms ceiling), a rejecting waiter still prints, and the
busy flag returns to false in all three.
This commit is contained in:
Doxa Maps 2026-09-28 20:32:38 +00:00
parent 82b2e2aa5a
commit 09adc903fc
14 changed files with 151 additions and 1 deletions

View file

@ -85,3 +85,48 @@ export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '',
setTimeout(revoke, 60000)
return true
}
/**
* Print THIS page (not a frame) once the things it draws have finished drawing, with a busy state
* the whole time — the ONE shared version of a pattern that exists five times in this repo today
* (upg-100-list.vue c54a11d5a, four in exp's plugins 786a83461, PeopleGroupsWall.printWall).
*
* ★ WHY A HELPER RATHER THAN THE FIVE COPIES: they agree on the easy half (set a flag, print, clear
* the flag) and each invents the hard half — what "ready" means, and what happens when ready never
* arrives. The hard half is the one that strands a reader.
*
* ★ THE TIMEOUT PRINTS. It does not reject and it does not cancel. A reader who clicked Print and
* waited has asked for paper; a slow tile server is a reason to print a plainer map, not a reason to
* produce nothing. The return value says which happened so a caller can say so if it wants.
*
* ★ onBusy IS CALLED IN A finally. Every path clears it: a wait that rejects, a print that throws,
* a dialog dismissed instantly, a host with no window.print at all. A busy state that can outlive
* its own operation is worse than no busy state, because the page looks broken rather than slow.
*
* @param waitFor promises to settle before printing. Rejections are swallowed on purpose: a map
* that failed to report readiness must not also cost the reader their printout.
* @param onBusy (busy: boolean) => void — drives the caller's own indicator.
* @param timeoutMs ceiling on the wait. 0 or negative prints immediately.
* @returns 'ready' | 'timeout' | 'no-print' — what actually happened.
*/
export async function printWhenReady({ waitFor = [], onBusy = null, timeoutMs = 8000 } = {}) {
const busy = (v) => { try { if (onBusy) onBusy(v) } catch { /* a caller's indicator must not break printing */ } }
busy(true)
let outcome = 'ready'
let timer = null
try {
const waits = (Array.isArray(waitFor) ? waitFor : [waitFor]).filter(Boolean)
if (waits.length && timeoutMs > 0) {
// Settled, not all: one waiter that rejects should not skip the others or the print.
const settled = Promise.allSettled(waits).then(() => 'ready')
const expired = new Promise((res) => { timer = setTimeout(() => res('timeout'), timeoutMs) })
outcome = await Promise.race([settled, expired])
}
if (typeof window === 'undefined' || typeof window.print !== 'function') return (outcome = 'no-print')
window.print()
return outcome
} finally {
if (timer) clearTimeout(timer)
busy(false)
}
}

View file

@ -65,6 +65,8 @@
"Outside this region": "خارج هذه المنطقة",
"Pray": "صلّي",
"Loading region dashboards…": "جارٍ تحميل لوحات المناطق…",
"Print": "طباعة",
"Preparing the page…": "جارٍ تحضير الصفحة…",
"No regions found.": "لم يتم العثور على مناطق.",
"Region data could not be loaded. This is a connection problem, not a filter.": "تعذّر تحميل بيانات المناطق. هذه مشكلة في الاتصال، وليست تصفية.",
"People groups could not be loaded. This is a connection problem, not a filter.": "تعذّر تحميل الشعوب. هذه مشكلة في الاتصال، وليست تصفية.",

View file

@ -65,6 +65,8 @@
"Outside this region": "Außerhalb dieser Region",
"Pray": "Ich bete",
"Loading region dashboards…": "Regions-Dashboards werden geladen…",
"Print": "Drucken",
"Preparing the page…": "Seite wird vorbereitet…",
"No regions found.": "Keine Regionen gefunden.",
"Region data could not be loaded. This is a connection problem, not a filter.": "Regionsdaten konnten nicht geladen werden. Das ist ein Verbindungsproblem, kein Filter.",
"People groups could not be loaded. This is a connection problem, not a filter.": "Volksgruppen konnten nicht geladen werden. Das ist ein Verbindungsproblem, kein Filter.",

View file

@ -65,6 +65,8 @@
"Outside this region": "Outside this region",
"Pray": "Pray",
"Loading region dashboards…": "Loading region dashboards…",
"Print": "Print",
"Preparing the page…": "Preparing the page…",
"No regions found.": "No regions found.",
"Region data could not be loaded. This is a connection problem, not a filter.": "Region data could not be loaded. This is a connection problem, not a filter.",
"People groups could not be loaded. This is a connection problem, not a filter.": "People groups could not be loaded. This is a connection problem, not a filter.",

View file

@ -65,6 +65,8 @@
"Outside this region": "Fuera de esta región",
"Pray": "Oración",
"Loading region dashboards…": "Cargando los paneles de regiones…",
"Print": "Imprimir",
"Preparing the page…": "Preparando la página…",
"No regions found.": "No se encontraron regiones.",
"Region data could not be loaded. This is a connection problem, not a filter.": "No se pudieron cargar los datos de las regiones. Es un problema de conexión, no un filtro.",
"People groups could not be loaded. This is a connection problem, not a filter.": "No se pudieron cargar los pueblos. Es un problema de conexión, no un filtro.",

View file

@ -65,6 +65,8 @@
"Outside this region": "Hors de cette région",
"Pray": "Prier",
"Loading region dashboards…": "Chargement des tableaux de bord des régions…",
"Print": "Imprimer",
"Preparing the page…": "Préparation de la page…",
"No regions found.": "Aucune région trouvée.",
"Region data could not be loaded. This is a connection problem, not a filter.": "Les données des régions n'ont pas pu être chargées. Il s'agit d'un problème de connexion, pas d'un filtre.",
"People groups could not be loaded. This is a connection problem, not a filter.": "Les peuples n'ont pas pu être chargés. Il s'agit d'un problème de connexion, pas d'un filtre.",

View file

@ -65,6 +65,8 @@
"Outside this region": "इस क्षेत्र के बाहर",
"Pray": "प्रार्थना करें",
"Loading region dashboards…": "क्षेत्र डैशबोर्ड लोड हो रहे हैं…",
"Print": "प्रिंट करें",
"Preparing the page…": "पृष्ठ तैयार किया जा रहा है…",
"No regions found.": "कोई क्षेत्र नहीं मिला।",
"Region data could not be loaded. This is a connection problem, not a filter.": "क्षेत्र डेटा लोड नहीं हो सका। यह कनेक्शन की समस्या है, फ़िल्टर की नहीं।",
"People groups could not be loaded. This is a connection problem, not a filter.": "लोकसमूह लोड नहीं हो सके। यह कनेक्शन की समस्या है, फ़िल्टर की नहीं।",

View file

@ -65,6 +65,8 @@
"Outside this region": "Fuori da questa regione",
"Pray": "Prega",
"Loading region dashboards…": "Caricamento delle dashboard delle regioni…",
"Print": "Stampa",
"Preparing the page…": "Preparazione della pagina…",
"No regions found.": "Nessuna regione trovata.",
"Region data could not be loaded. This is a connection problem, not a filter.": "Non è stato possibile caricare i dati delle regioni. È un problema di connessione, non un filtro.",
"People groups could not be loaded. This is a connection problem, not a filter.": "Non è stato possibile caricare i gruppi di popolazione. È un problema di connessione, non un filtro.",

View file

@ -65,6 +65,8 @@
"Outside this region": "Fora desta região",
"Pray": "Ore",
"Loading region dashboards…": "Carregando os painéis das regiões…",
"Print": "Imprimir",
"Preparing the page…": "Preparando a página…",
"No regions found.": "Nenhuma região encontrada.",
"Region data could not be loaded. This is a connection problem, not a filter.": "Não foi possível carregar os dados das regiões. É um problema de conexão, não um filtro.",
"People groups could not be loaded. This is a connection problem, not a filter.": "Não foi possível carregar os povos. É um problema de conexão, não um filtro.",

View file

@ -65,6 +65,8 @@
"Outside this region": "În afara acestei regiuni",
"Pray": "Roagă-te",
"Loading region dashboards…": "Se încarcă panourile regiunilor…",
"Print": "Printează",
"Preparing the page…": "Se pregătește pagina…",
"No regions found.": "Nu au fost găsite regiuni.",
"Region data could not be loaded. This is a connection problem, not a filter.": "Datele regiunilor nu au putut fi încărcate. Este o problemă de conexiune, nu un filtru.",
"People groups could not be loaded. This is a connection problem, not a filter.": "Grupurile de popoare nu au putut fi încărcate. Este o problemă de conexiune, nu un filtru.",

View file

@ -65,6 +65,8 @@
"Outside this region": "За пределами этого региона",
"Pray": "Молиться",
"Loading region dashboards…": "Загрузка панелей по регионам…",
"Print": "Печать",
"Preparing the page…": "Подготовка страницы…",
"No regions found.": "Регионы не найдены.",
"Region data could not be loaded. This is a connection problem, not a filter.": "Не удалось загрузить данные по регионам. Это проблема соединения, а не фильтра.",
"People groups could not be loaded. This is a connection problem, not a filter.": "Не удалось загрузить народы. Это проблема соединения, а не фильтра.",

View file

@ -65,6 +65,8 @@
"Outside this region": "在此区域之外",
"Pray": "祷告",
"Loading region dashboards…": "正在加载区域面板…",
"Print": "打印",
"Preparing the page…": "正在准备页面…",
"No regions found.": "未找到区域。",
"Region data could not be loaded. This is a connection problem, not a filter.": "无法加载区域数据。这是连接问题,不是筛选问题。",
"People groups could not be loaded. This is a connection problem, not a filter.": "无法加载民族群体。这是连接问题,不是筛选问题。",

View file

@ -47,6 +47,8 @@ import { PALETTE as REGION_PALETTE, WAGF_REGIONS, REGION_ORDER } from '@map/colo
// component's shadow CSS so every var() below resolves inside the shadow root.
import { DXLIFE_TOKENS } from './dxlife-tokens.js'
import RegionCountryMap from './src/RegionCountryMap.vue'
// The ONE print mechanism (five copies of this busy state exist in the repo; this is the shared one).
import { printWhenReady } from '@map/composables/printInPlace.js'
// REGLIB batch 2: the stat card, the three goal cards and their bars are library components now.
// They carry their own shadow CSS (useShadowStyles) and the card surface they share, so the rules
// for .rd-card / .rd-stat* / .rd-goal* / .rd-big* / .rd-bar* / .rd-legend are no longer below.
@ -166,6 +168,26 @@ const points = ref([])
// ours to move and may not even be the one the reader is using.
const scrollEl = ref(null)
const loading = ref(true)
// ★ PRINT (Driver test round 2, #2 — the half the @media print rules could not do). The complaint
// was "no loading indicator on a click": the reader presses Print, the dialog takes a second to
// rise, and nothing on the page says anything is happening. Worse on this page than most, because
// what the dialog is waiting for is a MAP — press it a moment early and the sheet carries a grey
// rectangle where the map should be.
const printing = ref(false)
const mapEl = ref(null)
async function printDashboard() {
if (printing.value) return // a second click during the wait must not start a second print
await printWhenReady({
// Ask the map itself. Absent (no map on this view, or it failed) the array is empty and we
// print straight away, which is correct: there is nothing to wait for.
waitFor: mapEl.value?.whenIdle ? [mapEl.value.whenIdle({ timeoutMs: 7000 })] : [],
onBusy: (v) => { printing.value = v },
// Under the map's own ceiling, so the map decides it first and this is only the backstop.
timeoutMs: 8000,
})
}
const loadFailed = ref(false)
// The current selection, as { regionSlug, countrySlug } — seeded from profile-config so a
// standalone embed can deep-link straight to one region's dashboard.
@ -809,7 +831,7 @@ useShadowStyles(
' .rd-h2{ break-after:avoid; }' +
// Controls that only do something under a pointer. The back-to-top arrow is the clearest case:
// on paper it is a circle with an arrow in it and nothing to scroll.
' .rd-chooser, .btt{ display:none !important; }' +
' .rd-chooser, .btt, .rd-printrow{ display:none !important; }' +
'}' +
'.rd-status.rd-failed{ font-weight:600; }' +
@ -821,6 +843,16 @@ useShadowStyles(
// DRIVER asked for the code to match, not just the screenshot. Converted, so the next
// margin-overriding child anywhere on this page behaves as it does on their page.
// .stack .stack--xs — --spacing-xs is 1rem at their 10px root.
'.rd-printrow{ margin:0; }' +
// Quiet by default — this is a secondary action under a title, not a call to action. The busy
// state changes the CURSOR and the opacity as well as the label, because on a fast connection
// the label may only be readable for a few hundred ms and the cursor is what a reader feels.
'.rd-print{ font:inherit; font-size:var(--font-size-sm); color:var(--dx-muted);' +
' background:transparent; border:1px solid var(--dx-border); border-radius:6px;' +
' padding:4px 12px; cursor:pointer; transition:color .2s ease, border-color .2s ease; }' +
'.rd-print:hover:not(:disabled){ color:var(--dx-text); border-color:var(--dx-accent); }' +
'.rd-print:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; }' +
'.rd-print[aria-busy="true"]{ cursor:progress; opacity:.7; }' +
'.rd-head{ display:flex; flex-direction:column; text-align:center; }' +
'.rd-head > * + *{ margin-top:10px; }' +
'.rd-backlink{ margin:0; }' +
@ -1074,6 +1106,17 @@ useShadowStyles(
<button type="button" class="rd-light-link" @click="clearSelection">&larr; {{ t('All Regions') }}</button>
</p>
<h1 class="rd-title">{{ titleParts.head ? titleParts.head + ' ' : '' }}<span class="rd-title__last">{{ titleParts.last }}</span></h1>
<!-- aria-busy, not just a changed label: a screen reader is told the control is working,
and :disabled is what actually prevents the second print. -->
<p class="rd-printrow">
<button
type="button"
class="rd-print"
:disabled="printing"
:aria-busy="printing ? 'true' : 'false'"
@click="printDashboard"
>{{ printing ? t('Preparing the page…') : t('Print') }}</button>
</p>
<p v-if="subject.kind === 'country'" class="rd-summary">
<!-- RAW, not formatNumber: CountryDetail.vue:40 is `{{ country.count }}` and that
component never defines formatNumber (RegionDetail does). Their own asymmetry. -->
@ -1131,6 +1174,7 @@ useShadowStyles(
<p v-if="mapNote" class="rd-mapnote">{{ mapNote }}</p>
<div class="rd-mapframe">
<RegionCountryMap
ref="mapEl"
:country-codes="focusCodes"
:points="points"
:bounds="mapBounds"

View file

@ -731,6 +731,45 @@ function watchResize() {
*
* One delete per line, deliberately: sr's check reads them as separate statements.
*/
/**
* Resolve once this map has nothing left to draw — what the dashboard's Print button waits on.
*
* ★ `map.loaded()` IS NOT THE QUESTION. It reports true while tiles are still streaming, which is
* how gospel's charts printed blank twice today (COMMON.md). The condition that actually means
* "the pixels are the ones the reader asked for" is: the style is applied, the camera has stopped,
* and the tiles for this camera are in.
*
* ★ 'idle' IS SUBSCRIBED TO ONLY AFTER CHECKING NOW. This component's own header records that
* once('load') was measured unreliable in BOTH directions here — whether it has already fired
* depends on how the map was constructed. An event that fired before we listened never arrives, so
* the synchronous check is the first thing, not the fallback.
*
* Always resolves, never rejects, and always removes its listener — a print must not be blocked by
* a map, and a map must not accumulate listeners because someone printed twice.
*/
function whenIdle({ timeoutMs = 8000 } = {}) {
return new Promise((resolve) => {
const m = map.value
if (!m || typeof m.isStyleLoaded !== 'function') return resolve('no-map')
const settled = () => m.isStyleLoaded()
&& !m.isMoving()
&& (typeof m.areTilesLoaded !== 'function' || m.areTilesLoaded())
let timer = null
const finish = (why) => {
if (timer) { clearTimeout(timer); timer = null }
try { m.off('idle', onIdle) } catch (_) {}
resolve(why)
}
const onIdle = () => { if (settled()) finish('idle') }
if (settled()) return finish('ready')
m.on('idle', onIdle)
timer = setTimeout(() => finish('timeout'), timeoutMs)
})
}
// The dashboard prints the page this map is on, so it has to be able to ask when the map is done.
defineExpose({ whenIdle })
onBeforeUnmount(() => {
if (ro) { try { ro.disconnect() } catch (_) {} }
if (popup) popup.remove()