doxa-maps/library/components/poster/PosterPreview.vue
Doxa Maps 802d5ed412
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source dee9f9c9
2026-09-26 13:26:59 +00:00

182 lines
5.5 KiB
Vue

<!--
PosterPreview.vue — scaled live preview of the poster composition.
- Captures the source map's current canvas as a low-res PNG snapshot
(re-captured whenever the spec changes substantially).
- Renders the slot SVG components on top, sized via usePosterLayout.
- Scales the entire composition to fit the available container width.
-->
<template>
<div ref="rootEl" class="poster-preview">
<div
class="poster-preview__page"
:style="pageStyle"
>
<img
v-if="snapshot"
class="poster-preview__map"
:src="snapshot"
:style="boxStyle(layout.map)"
:alt="T('poster.map_preview', 'map preview')"
/>
<div v-else class="poster-preview__map placeholder" :style="boxStyle(layout.map)">
{{ T('poster.loading_snapshot', 'Loading map snapshot…') }}
</div>
<TitleSlot
v-if="spec.slots?.title"
class="poster-preview__slot"
:style="boxStyle(layout.title)"
:title="spec.slots.title.text"
:subtitle="spec.slots.title.subtitle"
/>
<LegendSlot
v-if="spec.slots?.legend && spec.slots.legend.rows?.length"
class="poster-preview__slot"
:style="boxStyle(layout.legend)"
:rows="spec.slots.legend.rows"
:type="spec.slots.legend.type"
/>
<ScaleBarSlot
v-if="spec.slots?.scaleBar"
class="poster-preview__slot"
:style="boxStyle(layout.scaleBar)"
:map-bounds="mapBoundsRef"
:map-center-lat="centerLat"
/>
<NorthArrowSlot
v-if="spec.slots?.north"
class="poster-preview__slot"
:style="boxStyle(layout.north)"
:bearing="bearing"
/>
<FooterSlot
class="poster-preview__slot"
:style="boxStyle(layout.footer)"
:left="spec.slots?.footer?.left"
:right="spec.slots?.footer?.right"
:attribution="spec.slots?.footer?.attribution"
:metadata="metadata"
/>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { tStatic } from '../../i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in.
const T = (key, en) => tStatic(key) || en
import TitleSlot from './slots/TitleSlot.vue';
import LegendSlot from './slots/LegendSlot.vue';
import ScaleBarSlot from './slots/ScaleBarSlot.vue';
import NorthArrowSlot from './slots/NorthArrowSlot.vue';
import FooterSlot from './slots/FooterSlot.vue';
import { computeLayout } from '../../composables/poster/usePosterLayout.js';
import { posterPixels } from '../../composables/poster/useMapPoster.js';
const props = defineProps({
spec: { type: Object, required: true },
getMap: { type: Function, required: true },
legendRows: { type: Array, default: () => [] },
metadata: { type: Object, default: () => ({}) },
});
const rootEl = ref(null);
const snapshot = ref('');
const containerW = ref(800);
const bearing = ref(0);
const centerLat = ref(0);
const mapBoundsRef = ref(null);
const px = computed(() => posterPixels(props.spec));
const layout = computed(() => computeLayout(props.spec, px.value));
const scale = computed(() => {
const w = px.value.width;
if (!w) return 1;
// Cap preview at container width; never scale up past 1.
return Math.min(1, (containerW.value - 24) / w);
});
const pageStyle = computed(() => ({
width: px.value.width + 'px',
height: px.value.height + 'px',
transform: `scale(${scale.value})`,
transformOrigin: 'top left',
background: '#fff',
position: 'relative',
boxShadow: '0 6px 24px rgba(0,0,0,0.25)',
}));
function boxStyle(box) {
if (!box) return { display: 'none' };
return {
position: 'absolute',
left: box.x + 'px',
top: box.y + 'px',
width: box.w + 'px',
height: box.h + 'px',
};
}
let resizeObs = null;
onMounted(() => {
if (rootEl.value && typeof ResizeObserver !== 'undefined') {
resizeObs = new ResizeObserver(() => {
containerW.value = rootEl.value?.clientWidth || 800;
});
resizeObs.observe(rootEl.value);
containerW.value = rootEl.value.clientWidth;
}
_refreshSnapshot();
});
onUnmounted(() => {
if (resizeObs) { resizeObs.disconnect(); resizeObs = null; }
});
watch(() => [props.spec.widthIn, props.spec.heightIn, props.spec.orientation, props.spec.dpi], () => {
_refreshSnapshot();
});
async function _refreshSnapshot() {
const map = props.getMap?.();
if (!map) return;
try {
if (!map.loaded() || map.isMoving()) {
await new Promise((res) => map.once('idle', res));
}
snapshot.value = map.getCanvas().toDataURL('image/png');
bearing.value = map.getBearing?.() || 0;
const c = map.getCenter?.();
centerLat.value = c?.lat || 0;
mapBoundsRef.value = map.getBounds?.();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[PosterPreview] snapshot failed', err);
}
}
</script>
<style scoped>
.poster-preview {
width: 100%;
height: 100%;
display: flex; align-items: flex-start; justify-content: center;
padding: 0;
}
.poster-preview__page { display: block; }
.poster-preview__map { object-fit: cover; display: block; }
.poster-preview__map.placeholder {
display: flex; align-items: center; justify-content: center;
background: #ddd; color: #555; font-size: 14px;
}
.poster-preview__slot { pointer-events: none; }
</style>