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:
parent
395fdf69e0
commit
14f4cb2d9d
12 changed files with 175 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "(اختياري)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "(वैकल्पिक)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "(необязательно)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "(可选)"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue