doxa-maps/plugin-designs/posterverse/posterverse.vue
Doxa Maps 8c674c0447 Posterverse: don't overwrite a link to a tab that arrives with the manifest
The first-paint write is skipped when the address names a tab the shell
doesn't know yet (a row that only resolves once the manifest is in). Writing
tab 0 there would replace the link before applyManifest could honour it;
applyManifest's own first-paint replace writes the honoured tab.
2026-10-03 05:26:59 +00:00

453 lines
25 KiB
Vue

<!--
posterverse.vue — the second capability-5 app shell (nested-mfe-spa): every poster plugin in one place.
* @description Every poster plugin in one app: a vertical-tab rail of real, embeddable poster plugins.
Tabs: UPG100 Posters, Country Posters, Poster Design, and an Experimental folder of the
poster-related experimental plugins. The same pattern as the Research Gallery (doxa-maps-spa),
reused rather than copied: the shared VerticalTabsLayout, bundle+profile identities resolved from
the tree's manifest, the library's loadScriptOnce/whenTagDefined, and the same profile-config
contract for every child.
WHAT IS DIFFERENT FROM THE GALLERY, on purpose:
- The rail is an ALLOW-LIST (src/tabs.js), Experimental included. The gallery's Experimental is
"every bundle nobody placed", which here would expose the gated key-cities.
- No MapInfo row, no hosted "?" and no shell-level phone notice. Panes do NOT carry
data-doxa-gallery or data-doxa-help, so each child keeps its OWN info popup and its own phone
notice (ProfileLoader / useScreenGate) instead of deferring to a host that has none.
ONE tag, <posterverse-app>, is defined by this bundle (index.js). It never imports a child's
components and never registers a child's tag: each child's own script does that.
-->
<template>
<VerticalTabsLayout
:dir="uiDir"
:lang="uiLocale"
:labels="layoutLabels"
:title="headingText"
:nav-label="tr('sec.posters')"
:header="false"
:home-href="homeHref || cfg.homeHref || defaultHomeHref"
:home-label="homeLabel || cfg.homeLabel || ''"
:groups="navGroups"
:active="activeKey"
@update:active="onSelect">
<!-- Every tab mounts its plugin's real custom element into an EMPTY, Vue-unmanaged slot, after that
bundle's script is injected once. The hint is a separate sibling, so clearing the slot can never
remove a Vue-managed node.
LIGHT rows (v-show) stay mounted once loaded, so switching back is instant. HEAVY rows (anything
that owns or nests a WebGL map) are rendered with v-if and DESTROYED on deactivate, which runs the
element's own teardown (map.remove()) and keeps the document to about one live map context. -->
<section
v-for="(t, i) in tabs" :key="t.id" class="pv-pane"
:ref="el => { if (el) paneEls[i] = el; else delete paneEls[i] }"
v-show="i === activeIndex" :hidden="i !== activeIndex">
<template v-if="!t.heavy || i === activeIndex">
<div class="pv-slot" :ref="el => setSlot(i, el)"></div>
<div v-if="t.state === 'loading'" class="pv-hint">{{ tr('loading', { label: tabLabel(t) }) }}</div>
<div v-else-if="t.state === 'error'" class="pv-hint err">
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br><small>{{ t.error }}</small>
</div>
<!-- No manifest means no scriptUrl, so the row can never mount: say so rather than show an empty pane. -->
<div v-else-if="t.state === 'idle' && !t.scriptUrl && manifestError" class="pv-hint err">
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br>
<small>{{ manifestError }}</small><br>
<small>{{ tr('manifestHint') }}</small>
</div>
</template>
</section>
<!-- The language menu: the end of the phone bar, and the bottom of the desktop rail. A pick asks the
host first (doxa-language-request); only an uncancelled pick reaches setLocale. -->
<template #phone-end><ShellLanguageMenu :current="uiLocale" @switch="setLocale" /></template>
<template #rail-end="{ collapsed }"><ShellLanguageMenu :current="uiLocale" :collapsed="collapsed" @switch="setLocale" /></template>
<!-- The pane is the scroller (overflow-y:auto), so back-to-top follows the active pane. -->
<BackToTopButton :target="activePaneEl" :labels="{ backToTop: tr('backToTop') }" />
</VerticalTabsLayout>
</template>
<script>
import { defineComponent, inject } from 'vue'
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import BackToTopButton from '@map/components/BackToTopButton.vue'
import ShellLanguageMenu from '@map/components/ShellLanguageMenu.vue'
import { saveLocale } from '@map/layouts/app-wrapper-material/languages.js'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf } from '@map/composables/useViewAddress.js'
import { translate, LOCALES } from './src/strings.js'
import { SECTIONS, TABS, resolveTabs } from './src/tabs.js'
// The host page's language by the ladder every nested map uses: ?lang= / ?locale=, then <html lang>.
// '' when the page says nothing. Never navigator.language: a browser's language must not override the
// language of the page the shell is embedded in.
function detectHostLocale() {
try {
const q = new URLSearchParams(location.search)
const fromUrl = (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
if (LOCALES.includes(fromUrl)) return fromUrl
} catch { /* no window */ }
try {
const fromHtml = (document.documentElement.lang || '').split('-')[0].toLowerCase()
if (LOCALES.includes(fromHtml)) return fromHtml
} catch { /* no document */ }
return ''
}
const freshTab = (d) => ({ ...d, tag: d.tag || '', scriptUrl: d.scriptUrl || '', heavy: !!d.heavy, mounted: false, state: 'idle', error: '' })
export default defineComponent({
name: 'Posterverse',
// The host's attributes stay on the host: an inline style copied onto the layout root beats the
// layout's own rules (a styled host once collapsed a nested map to 76px).
inheritAttrs: false,
components: { VerticalTabsLayout, BackToTopButton, ShellLanguageMenu },
props: {
treeBase: { type: String, default: '' },
profileConfig: { type: [String, Object], default: '' },
// Not `title`: that is a native HTMLElement property, and defineCustomElement maps props to attributes.
heading: { type: String, default: 'Posterverse' },
tokenUrl: { type: String, default: '' },
tk: { type: String, default: '' },
// The language, inherited from the host (canonical prop). profile-config.locale / lang also work.
language: { type: String, default: '' },
apiBaseUrl: { type: String, default: '' },
// The engine for every nested map: 'maplibre' or 'mapbox'. Unset = each map's own ladder.
provider: { type: String, default: '' },
// Which tab opens first, by row id (src/tabs.js). Also ?tab= on the page URL.
tab: { type: String, default: '' },
// The host's "Back to homepage" link, drawn by VerticalTabsLayout directly above Collapse (the top of
// the drawer on a phone). Also accepted as profile-config.homeHref / homeLabel. With no URL from the host,
// the link goes to the DOXA site in the shell's language (defaultHomeHref).
homeHref: { type: String, default: '' },
homeLabel: { type: String, default: '' },
},
emits: ['tab-change'],
setup() {
return { injectedBase: inject('pvTreeBase', '') }
},
data() {
return {
paneEls: {},
activeIndex: 0,
manifestError: '',
// The directory the manifest answered from (trailing slash). Tree-relative scriptUrls resolve
// against THIS, which differs from the document under the dev server.
manifestBase: '',
// The manifest's `runtime` block (the build's apiBaseUrl + public token), for foreign-page embeds.
runtime: {},
hostLang: detectHostLocale(),
// A language picked in THIS session through the menu, and the host's language value it was picked
// over. The pick wins only while that value is unchanged; the saved choice (loadLocale) is never
// read, since it falls back to the browser's language and would override the host page.
localPick: '',
pickBase: null,
// Until the manifest lands, the rail shows the rows that are expected to exist; provisional rows
// appear only once the manifest proves their bundle is in this tree.
tabs: TABS.filter((d) => !d.provisional).map(freshTab),
}
},
computed: {
cfg() {
let pc = this.profileConfig
if (typeof pc === 'string') { try { pc = pc ? JSON.parse(pc) : {} } catch { pc = {} } }
return pc && typeof pc === 'object' ? pc : {}
},
// The tree root with a trailing slash: host override, then the base index.js derived from its own
// <script> URL, then '' (relative: sibling bundle folders are one level up).
base() {
const raw = String(this.treeBase || this.cfg.treeBase || this.injectedBase || '').trim()
return raw ? raw.replace(/\/+$/, '') + '/' : ''
},
// The tree's asset pool, ABSOLUTE, for every child. Without it a nested element climbs to a sibling
// pool the publisher does not ship.
assetsBase() {
const v = String(this.cfg.assetsBase || '').trim()
if (/^https?:\/\//i.test(v) || v.startsWith('/')) return v.replace(/\/+$/, '')
return this.manifestBase ? this.manifestBase.replace(/\/+$/, '') + '/assets' : ''
},
headingText() { return String(this.cfg.heading || this.heading || '').trim() || 'Posterverse' },
// The language the host passed: the `language` prop, else profile-config language / locale / lang.
hostLanguage() { return String(this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim() },
// Language precedence: this session's menu pick (while the host's value is the one it was made over),
// then what the host says, then the host page's own language, then English.
effectiveLocale() {
if (this.localPick && this.hostLanguage === this.pickBase) return this.localPick
return (this.hostLanguage || this.hostLang || 'en').split('-')[0].toLowerCase()
},
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
// "Back to homepage" with no host to name one (a standalone page, the published tree): the DOXA site, in the
// shell's language. A host's home-href, or profile-config.homeHref, always wins.
defaultHomeHref() { return 'https://doxa.life' + (this.uiLocale && this.uiLocale !== 'en' ? '/' + this.uiLocale : '') },
uiDir() { return this.uiLocale === 'ar' ? 'rtl' : 'ltr' },
layoutLabels() {
const out = {}
for (const k of ['toggleList', 'openViews', 'expandList', 'collapseList', 'collapse', 'home']) out[k] = this.tr(k)
return out
},
effectiveProvider() {
const p = String(this.provider || this.cfg.provider || '').trim().toLowerCase()
return (p === 'maplibre' || p === 'mapbox') ? p : ''
},
activeKey() { const t = this.tabs[this.activeIndex]; return t ? t.id : '' },
activePaneEl() { return this.paneEls[this.activeIndex] || null },
// The grouped rail: SECTIONS projected over the rows that exist. An empty section is omitted.
navGroups() {
return SECTIONS.map((g) => ({
key: g.key,
label: this.tr(g.i18n),
icon: g.icon,
folder: !!g.folder,
open: g.open,
rows: this.tabs.filter((t) => t.section === g.key).map((t) => ({
key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: t.icon,
badge: g.folder ? this.tr('lab') : undefined,
})),
})).filter((g) => g.rows.length)
},
},
watch: {
// A language change REMOUNTS: not every child re-reads its config live, but a fresh element reads
// the new locale at mount. Hidden mounted panes are reset so their next open is fresh too.
effectiveLocale() {
const reset = (i, t) => {
try { const slot = this._slots && this._slots[i]; if (slot) slot.innerHTML = '' } catch { /* reset anyway */ }
t.mounted = false; t.state = 'idle'; t.error = ''
}
this.tabs.forEach((t, i) => { if (t.mounted && i !== this.activeIndex) reset(i, t) })
const t = this.tabs[this.activeIndex]
if (!t || !t.mounted) return
reset(this.activeIndex, t)
this.mountElement(this.activeIndex)
},
tab(id) { if (id) this.onSelect(id) },
// A host that sets a new language takes over again: the session pick is dropped.
hostLanguage(v) { if (v !== this.pickBase) { this.localPick = ''; this.pickBase = null } },
},
mounted() {
this.watchHostLang()
// Paint the rail at once; the manifest completes the rows in the background and re-drives the mount.
// First paint is a REPLACE, so a reload or a shared link keeps the tab without adding a Back step. If the
// address names a tab that only arrives with the manifest (a provisional row), write nothing yet: writing
// tab 0 here would overwrite the link, and applyManifest's own first-paint replace writes the honoured tab.
const want = this.requestedTab()
const known = !want || this.tabs.some((t) => t.id === want)
this.select(this.initialIndex(), { firstPaint: true, fromHistory: !known })
// Back / Forward re-apply the tab from the address and never write (writing would lose Forward).
this._offAddress = onAddressChange(({ tab }) => {
const i = this.tabs.findIndex((t) => t.id === tab)
this.select(i >= 0 ? i : 0, { fromHistory: true })
})
this.loadManifest()
},
beforeUnmount() {
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
if (this._offAddress) { this._offAddress(); this._offAddress = null }
},
methods: {
tr(key, params) { return translate(this.uiLocale, key, params) },
// The menu's 'switch': no host cancelled the request, so the shell switches itself.
setLocale(code) {
this.pickBase = this.hostLanguage
this.localPick = code
saveLocale(code)
},
tabLabel(t) { return this.tr('tab.' + t.id) },
tabSub(t) { return t.section === 'experimental' ? this.tr('experimentalSub') : this.tr('sub.' + t.id) },
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
watchHostLang() {
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
this._langObs = new MutationObserver(() => { this.hostLang = detectHostLocale() })
this._langObs.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
},
requestedTab() {
let q = ''
try { q = readViewKeys(location.search).tab } catch { /* non-browser */ }
// On a page whose address this shell owns, the URL (a reload, a shared link) beats a baked cfg.tab, or the
// first-paint replace would overwrite the link with the default. Elsewhere the host's config leads.
const order = urlOwnerOf(this.cfg) ? [this.tab, q, this.cfg.tab] : [this.tab, this.cfg.tab, q]
return String(order.find((v) => v) || '').trim()
},
initialIndex() {
const i = this.tabs.findIndex((t) => t.id === this.requestedTab())
return i >= 0 ? i : 0
},
// Read the manifest once. Candidates, in order:
// 1. <tree>/manifest.json (same origin);
// 2. <tree>/doxa-maps-spa/manifest.json, the byte-identical copy generate-tree writes for the
// Research Gallery. The live static layer sends CORS headers only inside a tree folder, not
// on the root manifest, so a partner page's script embed reads this copy FIRST;
// 3. /public/<tree>/manifest.json, where the dev server keeps the built manifest.
// Script URLs always resolve against the tree ROOT, never the copy's folder.
loadManifest() {
const dirOf = (u) => { try { return new URL(u, location.href).href.replace(/[^/]*$/, '') } catch { return '' } }
const rootUrl = this.resolve('manifest.json')
const root = { url: rootUrl, root: dirOf(rootUrl) }
const copy = { url: this.resolve('doxa-maps-spa/manifest.json'), root: root.root }
let crossOrigin = false
try { crossOrigin = new URL(rootUrl, location.href).origin !== location.origin } catch { /* relative = same */ }
const candidates = crossOrigin ? [copy, root] : [root, copy]
const devFallback = '/public/' + __DOXA_TREE_NAME__ + '/manifest.json'
if (!candidates.some((c) => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
const tried = []
const attempt = (i) => {
if (i >= candidates.length) {
this.manifestError = 'manifest.json not found (tried ' + tried.join(', ') + ')'
console.error('[posterverse] ' + this.manifestError + ' — every tab is composed from it.')
return
}
const { url, root: treeRoot } = candidates[i]
tried.push(url)
return fetch(url, { credentials: 'same-origin' })
.then((r) => { if (!r || !r.ok) throw new Error(url + ' → HTTP ' + (r ? r.status : '?')); return r.json() })
.then((m) => {
if (!m) throw new Error(url + ' → empty')
this.manifestError = ''
this.manifestBase = treeRoot || ''
this.applyManifest(m)
})
.catch(() => attempt(i + 1))
}
try { attempt(0) } catch { /* no fetch (non-browser): fail soft */ }
},
// Replace the provisional rail with the rows this tree actually ships, keep the active row by id,
// and re-drive the mount that could not run before its scriptUrl existed.
applyManifest(m) {
this.runtime = (m && m.runtime && typeof m.runtime === 'object') ? m.runtime : {}
this.applyPageGlobals()
const activeId = this.activeKey
this.tabs = resolveTabs(m, (msg) => console.warn(msg)).map(freshTab)
const want = this.requestedTab()
const w = !this._honouredTab && want ? this.tabs.findIndex((t) => t.id === want) : -1
const i = this.tabs.findIndex((t) => t.id === activeId)
this._honouredTab = true
this.select(w >= 0 ? w : (i >= 0 ? i : 0), { firstPaint: true })
},
onSelect(key) {
const i = this.tabs.findIndex((t) => t.id === key)
if (i !== -1) this.select(i)
},
// A tree-relative ref resolves against the manifest's own directory (then the tree base, then '../').
// An absolute or rooted ref is returned untouched: prefixing a rooted dev ref makes a path that exists nowhere.
resolve(ref) {
const raw = String(ref)
if (/^(https?:)?\/\//.test(raw) || raw.startsWith('/')) return raw
const r = raw.replace(/^\.\//, '')
if (this.manifestBase) return this.manifestBase.replace(/\/+$/, '') + '/' + r
if (this.base) return this.base.replace(/\/+$/, '') + '/' + r
return '../' + r
},
// The tab is written to the address by the ONE owner (urlOwner in profile-config: 'app' on our own pages,
// 'host' when a host's router writes, absent on a partner's page = read only). A reader's choice is a push;
// first paint is a replace; Back / Forward never write.
select(i, { fromHistory = false, firstPaint = false } = {}) {
const prevIndex = this.activeIndex
this.activeIndex = i
// Release the outgoing heavy pane first, so at most one live map context exists at a time.
if (prevIndex !== i && prevIndex >= 0) {
const prev = this.tabs[prevIndex]
if (prev && prev.heavy && prev.mounted) this.releaseTab(prevIndex, prev)
}
const t = this.tabs[i]
if (!t) return
// Tell the host which plugin is showing (CustomEvent 'tab-change', detail[0] = { id, label }).
if (prevIndex !== i || !this._announced) { this._announced = true; this.$emit('tab-change', { id: t.id, label: this.tabLabel(t) }) }
if (!fromHistory) writeViewKeys({ tab: t.id }, { owner: urlOwnerOf(this.cfg), push: !firstPaint, el: this.$el })
if (!t.scriptUrl) return // applyManifest selects again once the manifest lands
// A heavy tab's slot exists only after its v-if subtree renders, so mount on the next tick.
if (!t.mounted) this.$nextTick(() => { if (this.activeIndex === i) this.mountElement(i) })
},
releaseTab(i, t) {
try { const slot = this._slots && this._slots[i]; if (slot) slot.innerHTML = '' } catch { /* reset either way */ }
if (this._slots) delete this._slots[i]
t.mounted = false; t.state = 'idle'; t.error = ''
},
// THE ONE profile-config every nested element receives (the Research Gallery's contract).
childConfig(t) {
const pc = { profile: t.profile || t.id, instanceId: 'pv-' + t.id }
if (this.effectiveLocale) pc.locale = this.effectiveLocale
const tk = this.tk || this.cfg.tk || this.runtime.token
if (tk) pc.tk = tk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
if (this.effectiveProvider) pc.provider = this.effectiveProvider
if (this.base) pc.treeBase = this.base
if (this.assetsBase) pc.assetsBase = this.assetsBase
// The host owns its URL, hash and title: a nested plugin must not write them.
pc.urlSync = false
// The plugin's own name for its print header: this tab's localized label.
pc.mapTitle = this.tabLabel(t)
return pc
},
// Page globals a generated leaf page bakes and a foreign host does not. Set ONLY when the page has
// not set them: the host's own value always wins. MAP_APP_API_URL has no profile-config path, and
// a composite's inner maps read MAP_TOKEN / MAP_PROVIDER rather than their parent's config.
applyPageGlobals() {
if (typeof window === 'undefined') return
const api = String(this.apiBaseUrl || this.cfg.apiBaseUrl || this.runtime.apiBaseUrl || '').trim()
if (api && !window.MAP_APP_API_URL) window.MAP_APP_API_URL = api.replace(/\/+$/, '')
const tk = this.tk || this.cfg.tk || this.runtime.token
if (tk && !window.MAP_TOKEN) window.MAP_TOKEN = tk
if (this.effectiveProvider && !window.MAP_PROVIDER) window.MAP_PROVIDER = this.effectiveProvider
},
// Inject the bundle's script once, wait for its TAG to register (onload fires even when a dev module
// fails to execute), then create the element in the pane's empty slot.
mountElement(i) {
const t = this.tabs[i]
t.mounted = true
t.state = 'loading'
loadScriptOnce(this.resolve(t.scriptUrl)).then(() => whenTagDefined(t.tag)).then(() => {
try {
// Deactivated (and released) while its script loaded: mounting now would leak a map context.
if (t.heavy && this.activeIndex !== i) { t.mounted = false; t.state = 'idle'; return }
const slot = this._slots && this._slots[i]
if (!slot) { t.state = 'error'; t.error = 'no slot'; return }
slot.innerHTML = ''
const el = document.createElement(t.tag)
el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
// Also as a plain attribute: an element without a ProfileLoader reads tree-base as a prop.
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
// FIXED-VIEWPORT (the default): pin the element to the pane. min-height:0 is load-bearing: a
// child's own floor (420-600px on these plugins) otherwise wins over height:100% and the pane
// scrolls the whole plugin. SCROLL rows (t.scroll, a natural-height page) are NOT pinned: they
// take their natural height and the pane (overflow-y:auto) scrolls them.
el.style.cssText = t.scroll
? 'display:block;position:static;width:100%;height:auto;'
: 'display:block;position:absolute;inset:0;width:100%;height:100%;min-height:0;'
slot.appendChild(el)
t.state = 'ready'
} catch (e) {
t.state = 'error'
t.error = 'mount failed: ' + ((e && e.message) || String(e))
}
}).catch((e) => {
t.state = 'error'
t.error = (e && e.message) || String(e)
})
},
},
})
</script>
<style>
/* The element is its own containing block with a real height, so the layout (.vtl, position:absolute)
stays inside the box the host placed it in. A host's own height or --posterverse-height overrides it. */
:host { display: block; position: relative; height: var(--posterverse-height, 85vh); min-height: 480px; }
/* Shadow DOM isolates selectors, not inheritance: reset the host page's typography at the box. */
:host {
font-size: 16px; line-height: normal; font-weight: 400; font-style: normal; font-variant: normal;
font-stretch: normal; letter-spacing: normal; word-spacing: normal; text-transform: none;
text-align: start; text-indent: 0; text-shadow: none; white-space: normal; hyphens: manual;
visibility: visible; cursor: auto;
}
:host([hidden]) { display: none; }
/* The pane fills the layout's content area and owns the scroll. --profile-min-h:0 opts every child out of
ProfileLoader's 100dvh floor, which measures the viewport while this pane measures its host. */
.pv-pane { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; --profile-min-h: 0; }
.pv-slot { position: relative; min-height: 100%; }
.pv-hint {
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
color: #6d766e; font-size: 13px; padding: 20px;
}
.pv-hint.err { color: #b3261e; }
.pv-hint small { color: #6d766e; }
</style>