diff --git a/library/composables/printInPlace.js b/library/composables/printInPlace.js index 4383f9c78..f5cf75e1e 100644 --- a/library/composables/printInPlace.js +++ b/library/composables/printInPlace.js @@ -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) + } +} diff --git a/library/i18n/locales/ar/regions.json b/library/i18n/locales/ar/regions.json index b1dcfc04f..cc7217b54 100644 --- a/library/i18n/locales/ar/regions.json +++ b/library/i18n/locales/ar/regions.json @@ -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.": "تعذّر تحميل الشعوب. هذه مشكلة في الاتصال، وليست تصفية.", diff --git a/library/i18n/locales/de/regions.json b/library/i18n/locales/de/regions.json index d9a6cfdc6..480ffaaff 100644 --- a/library/i18n/locales/de/regions.json +++ b/library/i18n/locales/de/regions.json @@ -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.", diff --git a/library/i18n/locales/en/regions.json b/library/i18n/locales/en/regions.json index 801862558..d6afac5fc 100644 --- a/library/i18n/locales/en/regions.json +++ b/library/i18n/locales/en/regions.json @@ -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.", diff --git a/library/i18n/locales/es/regions.json b/library/i18n/locales/es/regions.json index 16f457604..f0f5cac10 100644 --- a/library/i18n/locales/es/regions.json +++ b/library/i18n/locales/es/regions.json @@ -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.", diff --git a/library/i18n/locales/fr/regions.json b/library/i18n/locales/fr/regions.json index 5698e0fb1..7c3b99a6a 100644 --- a/library/i18n/locales/fr/regions.json +++ b/library/i18n/locales/fr/regions.json @@ -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.", diff --git a/library/i18n/locales/hi/regions.json b/library/i18n/locales/hi/regions.json index 4a8fbd3f6..fc6adb12a 100644 --- a/library/i18n/locales/hi/regions.json +++ b/library/i18n/locales/hi/regions.json @@ -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.": "लोकसमूह लोड नहीं हो सके। यह कनेक्शन की समस्या है, फ़िल्टर की नहीं।", diff --git a/library/i18n/locales/it/regions.json b/library/i18n/locales/it/regions.json index 7c2ec899f..8b36668d1 100644 --- a/library/i18n/locales/it/regions.json +++ b/library/i18n/locales/it/regions.json @@ -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.", diff --git a/library/i18n/locales/pt/regions.json b/library/i18n/locales/pt/regions.json index d7ca8df73..867b3b89d 100644 --- a/library/i18n/locales/pt/regions.json +++ b/library/i18n/locales/pt/regions.json @@ -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.", diff --git a/library/i18n/locales/ro/regions.json b/library/i18n/locales/ro/regions.json index a48ce0673..3885826a0 100644 --- a/library/i18n/locales/ro/regions.json +++ b/library/i18n/locales/ro/regions.json @@ -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.", diff --git a/library/i18n/locales/ru/regions.json b/library/i18n/locales/ru/regions.json index 987a51fe8..a06f5d139 100644 --- a/library/i18n/locales/ru/regions.json +++ b/library/i18n/locales/ru/regions.json @@ -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.": "Не удалось загрузить народы. Это проблема соединения, а не фильтра.", diff --git a/library/i18n/locales/zh/regions.json b/library/i18n/locales/zh/regions.json index 141fb0daa..c1c11730b 100644 --- a/library/i18n/locales/zh/regions.json +++ b/library/i18n/locales/zh/regions.json @@ -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.": "无法加载民族群体。这是连接问题,不是筛选问题。", diff --git a/plugin-designs/regions-dashboard/region-dashboards.vue b/plugin-designs/regions-dashboard/region-dashboards.vue index 7a1bec9de..79fbc0d81 100644 --- a/plugin-designs/regions-dashboard/region-dashboards.vue +++ b/plugin-designs/regions-dashboard/region-dashboards.vue @@ -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(
+ +
@@ -1131,6 +1174,7 @@ useShadowStyles(
{{ mapNote }}