doxa-maps/plugin-designs/pplr-data-maps/src/components/LoadingGlobe.vue
doxa-maps-88655c70 e1d847028d PPLR data maps (WIP): move the ten PPLR research views into plugin-designs
Not for main yet. All ten views mount on the dev server with real data and no
page errors. Left: the coverage view's status still says 'sample of 6,000',
its choropleth needs checking, and the bundle is not built or tree-verified.
2026-09-25 06:33:49 +00:00

99 lines
3.6 KiB
Vue

<script setup>
/**
* LoadingGlobe — rotating-globe SVG used as the universal loading state.
*
* Stays mounted while a profile is loading ANY stage (basemap tiles, PG pins,
* country polygons, etc.). The label updates as the profile progresses so the
* user sees "Loading basemap" → "Loading pins" → "Adding layers" rather than
* a single stuck spinner. If 30 seconds pass with no progress (`stuck` flips
* true via `stuckAfterMs`), a "Retry" button appears.
*
* Usage:
* <LoadingGlobe v-if="!isReady" :label="loadingStage" @retry="retry" />
*/
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
const props = defineProps({
label: { type: String, default: 'Loading map' },
stuckAfterMs:{ type: Number, default: 30000 },
})
const emit = defineEmits(['retry'])
const stuck = ref(false)
let stuckTimer = null
function armStuckTimer() {
if (stuckTimer) clearTimeout(stuckTimer)
stuck.value = false
stuckTimer = setTimeout(() => { stuck.value = true }, props.stuckAfterMs)
}
onMounted(armStuckTimer)
onBeforeUnmount(() => stuckTimer && clearTimeout(stuckTimer))
// Re-arm whenever the label changes — that's a sign the profile made progress.
watch(() => props.label, armStuckTimer)
function onRetry() {
stuck.value = false
armStuckTimer()
emit('retry')
}
</script>
<template>
<div class="lg-frame" role="status" aria-live="polite">
<svg class="lg-globe" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="lg-grad" cx="35%" cy="35%" r="65%">
<stop offset="0%" stop-color="#92b195" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#3b463d" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="26" fill="url(#lg-grad)" />
<circle cx="32" cy="32" r="26" fill="none" stroke="#73A17F" stroke-width="2"/>
<ellipse cx="32" cy="32" rx="26" ry="10" fill="none" stroke="#73A17F" stroke-width="1.4" opacity="0.7"/>
<ellipse cx="32" cy="32" rx="14" ry="26" fill="none" stroke="#73A17F" stroke-width="1.4" opacity="0.7"/>
<line x1="6" y1="32" x2="58" y2="32" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
<line x1="32" y1="6" x2="32" y2="58" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
</svg>
<span class="lg-text">{{ label }}</span>
<button v-if="stuck" class="lg-retry" type="button" @click="onRetry">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-3-6.7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<polyline points="21 4 21 11 14 11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Retry
</button>
</div>
</template>
<style scoped>
.lg-frame {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px;
background: #0f1216;
z-index: 1;
}
.lg-globe {
width: 64px; height: 64px;
animation: lg-spin 1.6s linear infinite;
filter: drop-shadow(0 4px 14px rgba(115,161,127,0.35));
}
@keyframes lg-spin { to { transform: rotate(360deg); } }
.lg-text {
color: #73A17F; opacity: 0.85;
font: 600 12px ui-monospace, monospace;
letter-spacing: 0.08em; text-transform: uppercase;
}
.lg-retry {
display: inline-flex; align-items: center; gap: 6px;
margin-top: 6px; padding: 6px 12px;
background: #161b22; color: #c9d1d9;
border: 1px solid #73A17F; border-radius: 999px;
font: 600 11px system-ui, sans-serif;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.lg-retry:hover { background: #73A17F; color: #0d1117; }
</style>