A long map-poster render shows its progress, an honest time, and why it failed

rega's poster-path timings: A4 300 dpi ~42 s, 24x36 in at 150 dpi 90-135 s,
24x36 in at 300 dpi ~4 min. Through all of it PosterDialog showed an
internal phase name ("hidden-map"), a bar measuring only the current phase
(so it restarted and sat still), and English. A failed render, including a
canvas too large for the device, went only to the console: the status block
vanished and the reader was told nothing.

- The bar follows the composable's overall, monotonic percent; beside it,
  the time elapsed (ticking) and, while stitching, "tile n of total".
- An honest expectation for the chosen size, from its megapixels: "up to a
  minute" from 5 MP (A4 300 dpi, 8.7 MP), "a few minutes" from 15 MP (24x36
  in 150 dpi, 19.4 MP, and up). A4 at 150 dpi (2.2 MP, 11 s) shows none.
- A failure is shown in the dialog: a specific message when the device ran
  out of memory (an oversized canvas or encode), a general one otherwise,
  nothing when the reader cancelled (AbortError).
- The Render button's labels and the "(optional)" placeholder go through
  T() too. 9 poster.* keys in 11 locales, using the dialog's own terms.
This commit is contained in:
Doxa Maps 2026-09-29 09:56:11 +00:00
parent 395fdf69e0
commit 14f4cb2d9d
12 changed files with 175 additions and 20 deletions

View file

@ -69,7 +69,7 @@
<label class="ctl">
<span>{{ T('poster.subtitle', 'Subtitle') }}</span>
<input v-model="subtitleText" type="text" placeholder="(optional)" />
<input v-model="subtitleText" type="text" :placeholder="T('poster.optional', '(optional)')" />
</label>
<fieldset class="ctl ctl--check">
@ -80,17 +80,26 @@
<label class="muted"><input type="checkbox" checked disabled /> {{ T('poster.footer_attribution', 'Footer (attribution required)') }}</label>
</fieldset>
<div class="render-status" v-if="poster.isRendering.value">
<strong>{{ poster.progress.value.phase }}</strong>
<progress :max="poster.progress.value.total || 1" :value="poster.progress.value.completed" />
<small>{{ poster.progress.value.message }}</small>
<button class="btn btn--ghost" type="button" @click="poster.cancel">{{ T('poster.cancel', 'Cancel') }}</button>
<!-- ★ A LONG RENDER SAYS SO. Large or high-dpi posters take from under a minute to several
(rega's timings: A4 300 dpi ~42 s, 24x36 in 150 dpi 90-135 s, 24x36 in 300 dpi ~4 min), and this
used to show an internal phase name and a bar that restarted with every phase. Now: the overall
percent (the composable's monotonic fraction), the time elapsed, the tile count while stitching,
an honest expectation for the chosen size, and a message if it fails. -->
<div class="render-status" v-if="poster.isRendering.value || renderError" role="status" aria-live="polite">
<template v-if="poster.isRendering.value">
<strong>{{ T('poster.rendering', 'Rendering…') }}</strong>
<progress max="100" :value="poster.progress.value.percent || 0" />
<small>{{ progressDetail }}</small>
<small v-if="sizeHint" class="render-hint">{{ sizeHint }}</small>
<button class="btn btn--ghost" type="button" @click="poster.cancel">{{ T('poster.cancel', 'Cancel') }}</button>
</template>
<p v-else class="render-error" role="alert">{{ renderError }}</p>
</div>
<div class="actions">
<button class="btn btn--ghost" type="button" @click="onClose">{{ T('buttons.close', 'Close') }}</button>
<button class="btn btn--primary" type="button" :disabled="poster.isRendering.value" @click="onRender">
{{ poster.isRendering.value ? 'Rendering…' : `Render ${format.toUpperCase()}` }}
{{ poster.isRendering.value ? T('poster.rendering', 'Rendering…') : fillIn(T('poster.render_format', 'Render {format}'), { format: format.toUpperCase() }) }}
</button>
</div>
</aside>
@ -110,9 +119,9 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, ref, watch, onBeforeUnmount } from 'vue';
import PosterPreview from './PosterPreview.vue';
import { useMapPoster } from '../../composables/poster/useMapPoster.js';
import { useMapPoster, posterPixels } from '../../composables/poster/useMapPoster.js';
import { listPosterSizes, getPosterSize, DEFAULT_POSTER_ID } from '../../constants/posterSizes.js';
import { defaultPosterSpec, posterDefaults } from '../../constants/posterDefaults.js';
// Backdrop geometry — a fixed;inset:0 backdrop covers only the content pane inside the cap-5
@ -205,7 +214,51 @@ const spec = computed(() => {
const poster = useMapPoster(props.getMap);
// {name} interpolation for the T() strings below.
const fillIn = (str, params) => String(str).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m));
// The time elapsed, ticking once a second while a render runs.
const elapsed = ref(0);
let elapsedTimer = null;
watch(() => poster.isRendering.value, (on) => {
clearInterval(elapsedTimer);
if (!on) return;
const t0 = Date.now();
elapsed.value = 0;
elapsedTimer = setInterval(() => { elapsed.value = Math.round((Date.now() - t0) / 1000); }, 1000);
});
onBeforeUnmount(() => clearInterval(elapsedTimer));
const clock = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
const progressDetail = computed(() => {
const p = poster.progress.value;
const parts = [`${p.percent || 0}%`, fillIn(T('poster.progress_elapsed', '{time} elapsed'), { time: clock(elapsed.value) })];
if (p.phase === 'stitch' && p.total) parts.push(fillIn(T('poster.progress_tiles', 'tile {n} of {total}'), { n: p.completed, total: p.total }));
return parts.join(' · ');
});
// An honest expectation from the size chosen, before and during the render. Thresholds from rega's timings
// under load: 8.7 MP (A4 300 dpi) ~42 s; 19.4 MP (24x36 in 150 dpi) 90-135 s; 77.8 MP (24x36 in 300 dpi) ~4 min.
const sizeHint = computed(() => {
let mp = 0;
try { const px = posterPixels(spec.value); mp = (px.width * px.height) / 1e6; } catch { return ''; }
if (mp >= 15) return T('poster.hint_few_minutes', 'Large posters can take a few minutes to render.');
if (mp >= 5) return T('poster.hint_up_to_minute', 'This size can take up to a minute to render.');
return '';
});
// A failed render used to vanish: the status block disappeared and only the console knew. The reader now
// gets a sentence, specific when the device ran out of memory (the large-format failure: an oversized canvas
// the browser cannot allocate or encode), and nothing when they cancelled it themselves.
const renderError = ref('');
const OUT_OF_MEMORY = /out of memory|allocation failed|array buffer|invalid array length|context lost|toBlob failed|too large|exceeds/i;
function renderFailureText(err) {
if (err?.name === 'AbortError') return '';
if (err?.name === 'RangeError' || OUT_OF_MEMORY.test(String(err?.message || err))) {
return T('poster.error_too_large', "This poster is too large for this device's memory. Choose a smaller size or a lower resolution.");
}
return T('poster.error_failed', 'The poster could not be rendered. Try again, or choose a smaller size.');
}
async function onRender() {
renderError.value = '';
try {
const blob = await poster.render(spec.value, format.value);
const ext = (format.value === 'pdf') ? 'pdf' : 'png';
@ -225,6 +278,7 @@ async function onRender() {
} catch (err) {
// eslint-disable-next-line no-console
console.error('[PosterDialog] render failed', err);
renderError.value = renderFailureText(err);
emit('error', err);
}
}
@ -295,6 +349,8 @@ function _download(blob, filename) {
.btn--ghost { background: transparent; }
.render-status { display: flex; flex-direction: column; gap: 6px; padding: 8px; background: #fff; border: 1px solid #eee; border-radius: 4px; font-size: 12px; }
.render-status progress { width: 100%; }
.render-hint { opacity: .75; }
.render-error { margin: 0; color: #b42318; font-size: 13px; line-height: 1.4; }
.poster-dlg-enter-active, .poster-dlg-leave-active { transition: opacity 0.15s ease; }
.poster-dlg-enter-from, .poster-dlg-leave-to { opacity: 0; }
/* Reduced motion: this component's styles do not go through useShadowStyles, so it carries its own guard

View file

@ -29,5 +29,14 @@
"region_americas": "الأمريكتان",
"region_europe": "أوروبا",
"region_deaf": "الصمّ",
"zone_deaf": "مجموعات الصمّ"
"zone_deaf": "مجموعات الصمّ",
"rendering": "جارٍ الإنشاء…",
"render_format": "إنشاء {format}",
"progress_elapsed": "مضى {time}",
"progress_tiles": "المربع {n} من {total}",
"hint_up_to_minute": "قد يستغرق هذا الحجم ما يصل إلى دقيقة.",
"hint_few_minutes": "قد تستغرق الملصقات الكبيرة بضع دقائق.",
"error_too_large": "هذا الملصق أكبر من ذاكرة هذا الجهاز. اختر حجمًا أصغر أو دقة أقل.",
"error_failed": "تعذّر إنشاء الملصق. حاول مرة أخرى أو اختر حجمًا أصغر.",
"optional": "(اختياري)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "Amerika",
"region_europe": "Europa",
"region_deaf": "Gehörlose",
"zone_deaf": "Gruppen gehörloser Menschen"
"zone_deaf": "Gruppen gehörloser Menschen",
"rendering": "Wird erstellt…",
"render_format": "{format} erstellen",
"progress_elapsed": "{time} vergangen",
"progress_tiles": "Kachel {n} von {total}",
"hint_up_to_minute": "Diese Größe kann bis zu einer Minute dauern.",
"hint_few_minutes": "Große Poster können einige Minuten dauern.",
"error_too_large": "Dieses Poster ist zu groß für den Speicher dieses Geräts. Wählen Sie eine kleinere Größe oder eine niedrigere Auflösung.",
"error_failed": "Das Poster konnte nicht erstellt werden. Versuchen Sie es erneut oder wählen Sie eine kleinere Größe.",
"optional": "(optional)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "Americas",
"region_europe": "Europe",
"region_deaf": "Deaf",
"zone_deaf": "Deaf people groups"
"zone_deaf": "Deaf people groups",
"rendering": "Rendering…",
"render_format": "Render {format}",
"progress_elapsed": "{time} elapsed",
"progress_tiles": "tile {n} of {total}",
"hint_up_to_minute": "This size can take up to a minute to render.",
"hint_few_minutes": "Large posters can take a few minutes to render.",
"error_too_large": "This poster is too large for this device's memory. Choose a smaller size or a lower resolution.",
"error_failed": "The poster could not be rendered. Try again, or choose a smaller size.",
"optional": "(optional)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "América",
"region_europe": "Europa",
"region_deaf": "Sordos",
"zone_deaf": "Grupos de personas sordas"
"zone_deaf": "Grupos de personas sordas",
"rendering": "Generando…",
"render_format": "Generar {format}",
"progress_elapsed": "{time} transcurridos",
"progress_tiles": "mosaico {n} de {total}",
"hint_up_to_minute": "Este tamaño puede tardar hasta un minuto en generarse.",
"hint_few_minutes": "Los pósteres grandes pueden tardar unos minutos en generarse.",
"error_too_large": "Este póster es demasiado grande para la memoria de este dispositivo. Elige un tamaño menor o una resolución más baja.",
"error_failed": "No se pudo generar el póster. Inténtalo de nuevo o elige un tamaño menor.",
"optional": "(opcional)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "Amériques",
"region_europe": "Europe",
"region_deaf": "Sourds",
"zone_deaf": "Groupes de personnes sourdes"
"zone_deaf": "Groupes de personnes sourdes",
"rendering": "Génération…",
"render_format": "Générer en {format}",
"progress_elapsed": "{time} écoulées",
"progress_tiles": "tuile {n} sur {total}",
"hint_up_to_minute": "Ce format peut prendre jusqu’à une minute.",
"hint_few_minutes": "Les grandes affiches peuvent prendre quelques minutes.",
"error_too_large": "Cette affiche est trop grande pour la mémoire de cet appareil. Choisissez un format plus petit ou une résolution plus basse.",
"error_failed": "L’affiche n’a pas pu être générée. Réessayez ou choisissez un format plus petit.",
"optional": "(facultatif)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "अमेरिका महाद्वीप",
"region_europe": "यूरोप",
"region_deaf": "बधिर",
"zone_deaf": "बधिर लोगों के समूह"
"zone_deaf": "बधिर लोगों के समूह",
"rendering": "बनाया जा रहा है…",
"render_format": "{format} बनाएँ",
"progress_elapsed": "{time} बीत गए",
"progress_tiles": "टाइल {n} / {total}",
"hint_up_to_minute": "इस आकार में एक मिनट तक लग सकता है।",
"hint_few_minutes": "बड़े पोस्टर बनने में कुछ मिनट लग सकते हैं।",
"error_too_large": "यह पोस्टर इस डिवाइस की मेमोरी के लिए बहुत बड़ा है। छोटा आकार या कम रिज़ॉल्यूशन चुनें।",
"error_failed": "पोस्टर नहीं बन सका। फिर से कोशिश करें या छोटा आकार चुनें।",
"optional": "(वैकल्पिक)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "Americhe",
"region_europe": "Europa",
"region_deaf": "Sordi",
"zone_deaf": "Gruppi di persone sorde"
"zone_deaf": "Gruppi di persone sorde",
"rendering": "Generazione…",
"render_format": "Genera {format}",
"progress_elapsed": "{time} trascorsi",
"progress_tiles": "riquadro {n} di {total}",
"hint_up_to_minute": "Questo formato può richiedere fino a un minuto.",
"hint_few_minutes": "I poster grandi possono richiedere alcuni minuti.",
"error_too_large": "Questo poster è troppo grande per la memoria di questo dispositivo. Scegli un formato più piccolo o una risoluzione più bassa.",
"error_failed": "Impossibile generare il poster. Riprova o scegli un formato più piccolo.",
"optional": "(facoltativo)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "Américas",
"region_europe": "Europa",
"region_deaf": "Surdos",
"zone_deaf": "Grupos de pessoas surdas"
"zone_deaf": "Grupos de pessoas surdas",
"rendering": "Gerando…",
"render_format": "Gerar {format}",
"progress_elapsed": "{time} decorridos",
"progress_tiles": "bloco {n} de {total}",
"hint_up_to_minute": "Este tamanho pode levar até um minuto para ser gerado.",
"hint_few_minutes": "Pôsteres grandes podem levar alguns minutos para serem gerados.",
"error_too_large": "Este pôster é grande demais para a memória deste dispositivo. Escolha um tamanho menor ou uma resolução mais baixa.",
"error_failed": "Não foi possível gerar o pôster. Tente novamente ou escolha um tamanho menor.",
"optional": "(opcional)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "Americi",
"region_europe": "Europa",
"region_deaf": "Surzi",
"zone_deaf": "Grupuri de persoane surde"
"zone_deaf": "Grupuri de persoane surde",
"rendering": "Se generează…",
"render_format": "Generează {format}",
"progress_elapsed": "{time} scurse",
"progress_tiles": "dala {n} din {total}",
"hint_up_to_minute": "Această dimensiune poate dura până la un minut.",
"hint_few_minutes": "Posterele mari pot dura câteva minute.",
"error_too_large": "Acest poster este prea mare pentru memoria acestui dispozitiv. Alegeți o dimensiune mai mică sau o rezoluție mai mică.",
"error_failed": "Posterul nu a putut fi generat. Încercați din nou sau alegeți o dimensiune mai mică.",
"optional": "(opțional)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "Америка",
"region_europe": "Европа",
"region_deaf": "Глухие",
"zone_deaf": "Группы глухих людей"
"zone_deaf": "Группы глухих людей",
"rendering": "Создание…",
"render_format": "Создать {format}",
"progress_elapsed": "прошло {time}",
"progress_tiles": "фрагмент {n} из {total}",
"hint_up_to_minute": "Для этого размера может потребоваться до минуты.",
"hint_few_minutes": "Создание больших плакатов может занять несколько минут.",
"error_too_large": "Этот плакат слишком велик для памяти устройства. Выберите меньший размер или более низкое разрешение.",
"error_failed": "Не удалось создать плакат. Попробуйте ещё раз или выберите меньший размер.",
"optional": "(необязательно)"
}

View file

@ -29,5 +29,14 @@
"region_americas": "美洲",
"region_europe": "欧洲",
"region_deaf": "聋人",
"zone_deaf": "聋人群体"
"zone_deaf": "聋人群体",
"rendering": "正在生成…",
"render_format": "生成 {format}",
"progress_elapsed": "已用时 {time}",
"progress_tiles": "第 {n}/{total} 块",
"hint_up_to_minute": "此尺寸最多可能需要一分钟。",
"hint_few_minutes": "大尺寸海报可能需要几分钟。",
"error_too_large": "此海报超出了本设备的内存。请选择较小的尺寸或较低的分辨率。",
"error_failed": "无法生成海报。请重试或选择较小的尺寸。",
"optional": "(可选)"
}