Map errors in every language, and print buttons that show they are busy (UPG Neighbor, All People Groups, Prayer Walk Planner, Wall)

This commit is contained in:
Doxa Maps 2026-09-28 18:23:04 +00:00
parent 01b02e3781
commit 786a834610
6 changed files with 37 additions and 9 deletions

View file

@ -31,6 +31,7 @@
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
import { tStatic } from '@map/i18n/index.js'
import { useMapInstance, engineGlobal } from '@map/composables/useMapInstance.js'
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
import { useMapData } from '@map/composables/useMapData.js'
@ -354,7 +355,8 @@ onMounted(async () => {
}
// No map: if the pane is still here that is a real failure (token, container) and says so; if it
// was unmounted while initialising, useMapInstance returned quietly and so do we.
if (!map.value) { if (mapContainer.value) mapError.value = 'Map failed to initialize — check token and container'; return }
// Shown after tr('loadError'); this detail used to be English in every locale.
if (!map.value) { if (mapContainer.value) mapError.value = tStatic('messages.mapUnavailable'); return }
map.value.once('style.load', async () => {
try {
const rows = await rowsPromise

View file

@ -574,7 +574,14 @@ function onDeleteSelected() {
}
function onSelectAll() { polygons.forEach(p => { p.selected = true }); refreshMapSources() }
function onClearSelection() { clearSelectionInternal() }
function onPrintClick() { printInternal(null) }
// Printing renders one off-screen map per selected zone and waits for its tiles: seconds, not a
// click. The button shows it is busy and cannot start a second print meanwhile.
const printBusy = ref(false)
async function onPrintClick() {
if (printBusy.value) return
printBusy.value = true
try { await printInternal(null) } finally { printBusy.value = false }
}
function startRename(id) { editingTitleId.value = id }
function commitRename(id, value) {
const p = polygons.find(p => p.id === id)
@ -620,6 +627,10 @@ useShadowStyles(
'.pwp-btn.is-active{background:#92b195;border-color:#92b195;color:#fff}' +
'.pwp-btn.is-accent{background:#92b195;border-color:#92b195;color:#fff}' +
'.pwp-btn.is-accent:hover{opacity:.9}' +
'.pwp-btn.busy{cursor:progress;opacity:.75}' +
".pwp-btn.busy::after{content:'';display:inline-block;width:.8em;height:.8em;margin-inline-start:.5em;vertical-align:-.1em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:pwp-spin .8s linear infinite}" +
'@keyframes pwp-spin{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion:reduce){.pwp-btn.busy::after{animation:none}}' +
'.pwp-btn:disabled{opacity:.45;cursor:not-allowed}' +
'.pwp-btn.is-danger{color:#8a3b3b;border-color:#e0c4c4}' +
'.pwp-spacer{flex:1}' +
@ -692,7 +703,8 @@ useShadowStyles(
</div>
<div class="pwp-spacer"></div>
<span class="pwp-count">{{ tr('countSelected', { n: selectedCount, total: polygons.length }) }}</span>
<button type="button" class="pwp-btn is-accent pwp-print" :disabled="selectedCount === 0"
<button type="button" class="pwp-btn is-accent pwp-print" :class="{ busy: printBusy }"
:disabled="selectedCount === 0 || printBusy" :aria-busy="printBusy ? 'true' : 'false'"
@click="onPrintClick" :aria-label="tr('printAria')">
{{ tr('printSelected') }}
</button>

View file

@ -14,6 +14,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
import { useNeighborT } from './composables/useNeighborT.js'
import { tStatic } from '@map/i18n/index.js'
import { loadNearData, E_COLORS, UNREACHED, NAVY, fold, fmtNum, downloadCsv } from './nearData.js'
const nt = useNeighborT()
@ -124,7 +125,8 @@ onMounted(async () => {
.catch((e) => { loadError.value = nt('neighbor.mob.loadError', { message: String(e.message || e) }) })
await initializeMap()
const m = map.value
if (!m) { mapError.value = nt('messages.mapUnavailable'); return }
// A shared key, so tStatic: nt() would prefix it with neighbor. and never resolve.
if (!m) { mapError.value = tStatic('messages.mapUnavailable'); return }
whenStyleReady(m, () => {
setProjectionOn(m, 'mercator'); repaint(); frame()
m.on('click', 'cp-u', (ev) => {

View file

@ -13,6 +13,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
import { useNeighborT } from './composables/useNeighborT.js'
import { tStatic } from '@map/i18n/index.js'
import { loadNearData, E_COLORS, UNREACHED, REACHED, SELECTED, NAVY, fold, fmtNum, downloadCsv } from './nearData.js'
const nt = useNeighborT()
@ -216,7 +217,8 @@ onMounted(async () => {
.catch((e) => { loadError.value = nt('neighbor.mob.loadError', { message: String(e.message || e) }) })
await initializeMap()
const m = map.value
if (!m) { mapError.value = nt('messages.mapUnavailable'); return }
// A shared key, so tStatic: nt() would prefix it with neighbor. and never resolve.
if (!m) { mapError.value = tStatic('messages.mapUnavailable'); return }
whenStyleReady(m, () => {
setProjectionOn(m, 'mercator')
repaint(); frame()

View file

@ -7,7 +7,9 @@
<span class="btn-label">{{ isLandscape ? $t('wall.toPortrait') : $t('wall.toLandscape') }}</span>
<span class="current-mode">{{ isLandscape ? $t('wall.currentLandscape') : $t('wall.currentPortrait') }}</span>
</button>
<button @click="printWall" class="control-btn">{{ $t('wall.printPoster') }}</button>
<!-- Loading every card's HD photo can take a while: the button shows it is busy and cannot
start a second load while the first is still running. -->
<button @click="printWall" class="control-btn" :class="{ busy: printing }" :disabled="printing" :aria-busy="printing ? 'true' : 'false'">{{ $t('wall.printPoster') }}</button>
<button @click="resetZoom" class="control-btn">{{ $t('wall.resetZoom') }}</button>
<span class="group-count">{{ $t('wall.groupsLoaded', { n: totalGroups }) }}</span>
</div>
@ -42,6 +44,7 @@ export default {
inject: { profileConfig: { default: null } },
data() {
return {
printing: false, // the HD poster print is loading photos (drives the button's busy state)
svg: null,
zoom: null,
cardWidth: 50,
@ -504,7 +507,7 @@ export default {
// Reconcile which cards hold their HD photo with what the view needs now (wallLod.js).
updateLod() {
if (!this._lodGrid || !this.svg || !this._imgNodes || this._printing) return;
if (!this._lodGrid || !this.svg || !this._imgNodes || this.printing) return;
const el = this.svg.node();
const t = this._lodT || wallD3().zoomTransform(el);
const r = el.getBoundingClientRect();
@ -524,7 +527,8 @@ export default {
// THE HD POSTER PATH: load every photo, wait for them, print, then go back to level of detail.
async printWall() {
const nodes = (this._imgNodes || []).filter(Boolean);
this._printing = true;
if (this.printing) return;
this.printing = true;
await Promise.all(nodes.map((n) => new Promise((res) => {
if (n.getAttribute('href')) return res();
n.addEventListener('load', res, { once: true });
@ -533,7 +537,7 @@ export default {
n.setAttribute('href', n.getAttribute('data-hd'));
})));
try { window.print(); } finally {
this._printing = false;
this.printing = false;
for (const n of nodes) n.removeAttribute('href');
this._hd = new Set();
this.updateLod();

View file

@ -79,6 +79,12 @@ body {
transition: all 0.2s;
}
.control-btn.busy { opacity: .7; cursor: progress; }
.control-btn.busy::after { content: ''; display: inline-block; width: .8em; height: .8em; margin-inline-start: .5em;
vertical-align: -.1em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%;
animation: wall-spin .8s linear infinite; }
@keyframes wall-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .control-btn.busy::after { animation: none; } }
.control-btn:hover {
background: #0081d6;
transform: translateY(-1px);