The header hides while the gallery's content is scrolled down and returns at its top. Switching tabs hides the old tab's pane (or removes its map) without any scroll event, so after scrolling a tab down and switching to a map-only tab (Prayer Progress, Cities with Internet, Gospel Resources on a phone) the header stayed hidden, and nothing on that tab could bring it back. The page now remembers which scroller hid the header; when a change in the gallery hides or removes it, the header returns. The new tab starts with the header shown, and if it scrolls, the next scroll hides it again.
235 lines
9.9 KiB
Vue
235 lines
9.9 KiB
Vue
<script setup lang="ts">
|
|
// Deep Research: the Research Gallery (every research map, dashboard and list in one app), filling
|
|
// the page below the header. DX Life asked for the gallery under this name, on a page of the site
|
|
// rather than a subdomain, and kept off the Research page. So this page is HIDDEN: it is in no menu,
|
|
// and robots are told not to index it. It is reached by its address only.
|
|
//
|
|
// The gallery is a web component served from DX-life's OWN origin (public/dx-maps/, a copy of
|
|
// the dx-maps build tree), so this site is its CDN: one script, one element, and no other host for the
|
|
// map code (the engine and base-map tiles come from public services; see DX-MAPS-GALLERY.md).
|
|
// It reads its manifest and every child map from the same /dx-maps/ tree. See
|
|
// DX-MAPS-GALLERY.md at the repo root.
|
|
//
|
|
// The element is created in onMounted rather than written in the template: it is a custom element
|
|
// that only exists in the browser, so this keeps it out of SSR and out of Vue's component resolver
|
|
// without any nuxt.config change.
|
|
|
|
import { mapboxTokenIfChosen } from '~/utils/mapProvider'
|
|
|
|
// ONE scrollbar: no footer here, and the page itself never scrolls; the gallery's own content does.
|
|
// The header still leaves as on every other page: it hides while the gallery is scrolled down
|
|
// (see watchPaneForHeader) and the gallery then takes the whole screen.
|
|
definePageMeta({ hideFooter: true })
|
|
|
|
const { t, locale } = useI18n()
|
|
const runtimeConfig = useRuntimeConfig()
|
|
|
|
// The dx-maps tree: NUXT_PUBLIC_DX_MAPS_BASE when set (a maps CDN), otherwise this site's own
|
|
// /dx-maps/ copy. The script and every embed URL come from the same tree.
|
|
const DX_MAPS_BASE = ((runtimeConfig.public as { dxMapsBase?: string }).dxMapsBase || '').replace(/\/+$/, '')
|
|
const GALLERY_SCRIPT = `${DX_MAPS_BASE || '/dx-maps'}/doxa-maps-spa/doxa-maps-spa.js`
|
|
|
|
useHead({
|
|
title: () => t('Deep Research'),
|
|
meta: [{ name: 'robots', content: 'noindex' }],
|
|
script: [{ src: GALLERY_SCRIPT, defer: true, key: 'research-gallery' }]
|
|
})
|
|
|
|
const host = ref<HTMLElement | null>(null)
|
|
let gallery: HTMLElement | null = null
|
|
let resizeObs: ResizeObserver | null = null
|
|
let unwatchPane: (() => void) | null = null
|
|
|
|
// The gallery fills the viewport below the header. The header's height differs between desktop
|
|
// and phone (and with the language), so it is measured, not assumed.
|
|
function fitBelowHeader() {
|
|
const header = document.getElementById('masthead')
|
|
const h = header ? Math.round(header.getBoundingClientRect().height) : 0
|
|
document.documentElement.style.setProperty('--rg-header-h', h + 'px')
|
|
}
|
|
|
|
// The gallery scrolls inside its own main pane, so a finger (or a wheel) over it scrolls that
|
|
// pane and never the page. The header leaves the way it does on every other page: once the pane
|
|
// is scrolled down a little, <html> gets rg-header-hidden (the header hides and the gallery takes
|
|
// the full screen); back at the pane's top, the header returns. One scroller, so nothing fights
|
|
// the pane's own momentum. Only a scroller at least half the gallery's height counts (its main
|
|
// pane, or a map's own main list), not a small list such as a legend sheet's.
|
|
function watchPaneForHeader(el: HTMLElement, root: ShadowRoot) {
|
|
const cls = document.documentElement.classList
|
|
const large = (n: HTMLElement) => n.clientHeight >= el.clientHeight / 2
|
|
// The scroller whose scroll hid the header.
|
|
let hider: HTMLElement | null = null
|
|
const onScroll = (e: Event) => {
|
|
const pane = e.target
|
|
if (!(pane instanceof HTMLElement) || !large(pane)) return
|
|
if (pane.scrollTop > 8) {
|
|
hider = pane
|
|
cls.add('rg-header-hidden')
|
|
}
|
|
else if (pane.scrollTop <= 0) {
|
|
hider = null
|
|
cls.remove('rg-header-hidden')
|
|
}
|
|
}
|
|
// Scroll events don't bubble, but they do pass the gallery's shadow root in the capture phase.
|
|
root.addEventListener('scroll', onScroll, { capture: true, passive: true })
|
|
// A map inside the gallery may scroll in ITS OWN shadow root (Regions does), and scroll events
|
|
// never cross a shadow root. Touch and wheel events do: on the first one over such a scroller,
|
|
// find it in the event's composed path and listen to its scrolls directly (once each), which
|
|
// also covers the momentum after the finger lifts.
|
|
const watched = new WeakSet<HTMLElement>()
|
|
const discover = (e: Event) => {
|
|
for (const n of e.composedPath()) {
|
|
if (n === el) break
|
|
if (!(n instanceof HTMLElement) || watched.has(n)) continue
|
|
const oy = getComputedStyle(n).overflowY
|
|
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight + 1 && large(n)) {
|
|
watched.add(n)
|
|
n.addEventListener('scroll', onScroll, { passive: true })
|
|
break
|
|
}
|
|
}
|
|
}
|
|
el.addEventListener('touchmove', discover, { passive: true })
|
|
el.addEventListener('wheel', discover, { passive: true })
|
|
// Switching tabs hides the old tab's pane (or removes its map) without any scroll event. If
|
|
// that takes away the scroller that hid the header, the header comes back: a map-only tab has
|
|
// nothing to scroll back to the top, so it could never bring the header back itself.
|
|
let pending = false
|
|
const recheck = () => {
|
|
pending = false
|
|
if (!cls.contains('rg-header-hidden')) return
|
|
if (hider && hider.isConnected && hider.getClientRects().length > 0 && hider.scrollTop > 0) return
|
|
hider = null
|
|
cls.remove('rg-header-hidden')
|
|
}
|
|
const tabSwitch = new MutationObserver(() => {
|
|
if (!pending) {
|
|
pending = true
|
|
requestAnimationFrame(recheck)
|
|
}
|
|
})
|
|
tabSwitch.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] })
|
|
unwatchPane = () => {
|
|
root.removeEventListener('scroll', onScroll, true)
|
|
el.removeEventListener('touchmove', discover)
|
|
el.removeEventListener('wheel', discover)
|
|
tabSwitch.disconnect()
|
|
}
|
|
}
|
|
|
|
// The gallery attaches its shadow root once its script has defined the element.
|
|
function whenShadowRoot(el: HTMLElement, done: (root: ShadowRoot) => void, tries = 50) {
|
|
if (el.shadowRoot) done(el.shadowRoot)
|
|
else if (tries > 0) setTimeout(() => whenShadowRoot(el, done, tries - 1), 100)
|
|
}
|
|
|
|
onMounted(() => {
|
|
fitBelowHeader()
|
|
// The gallery is sized to the viewport below the header; a sub-pixel header height must not add a
|
|
// page scrollbar, so the page does not scroll while the gallery is shown.
|
|
document.documentElement.classList.add('rg-no-page-scroll')
|
|
// The gallery's overlays (its info dialog) must open above the site header; see the style below.
|
|
document.documentElement.classList.add('rg-gallery-mounted')
|
|
const header = document.getElementById('masthead')
|
|
if (header && 'ResizeObserver' in window) {
|
|
resizeObs = new ResizeObserver(fitBelowHeader)
|
|
resizeObs.observe(header)
|
|
}
|
|
|
|
gallery = document.createElement('doxa-maps-spa')
|
|
// Every embed URL is built at RUNTIME: from NUXT_PUBLIC_DX_MAPS_BASE when set, otherwise from the
|
|
// origin serving this page, so the same build works on any host (laptop, staging, production) with
|
|
// nothing hardcoded.
|
|
gallery.setAttribute('tree-base', DX_MAPS_BASE || `${window.location.origin}/dx-maps`)
|
|
// The site's language drives the gallery's (and every map's); it follows the language switcher.
|
|
gallery.setAttribute('language', locale.value)
|
|
// Every section shows, Experimental included (the gallery's default).
|
|
const galleryConfig: Record<string, unknown> = {}
|
|
// MapLibre (keyless) by default; Mapbox only when NUXT_PUBLIC_MAP_PROVIDER=mapbox and a token is set.
|
|
// The provider is always explicit: a token alone never selects Mapbox in dx-maps.
|
|
const token = mapboxTokenIfChosen(runtimeConfig.public)
|
|
const provider = token ? 'mapbox' : 'maplibre'
|
|
galleryConfig.provider = provider
|
|
if (token) galleryConfig.tk = token
|
|
gallery.setAttribute('provider', provider)
|
|
gallery.setAttribute('profile-config', JSON.stringify(galleryConfig))
|
|
host.value?.appendChild(gallery)
|
|
customElements.whenDefined('doxa-maps-spa').then(() => {
|
|
const el = gallery
|
|
if (el) whenShadowRoot(el, root => watchPaneForHeader(el, root))
|
|
})
|
|
})
|
|
|
|
watch(locale, (lang) => {
|
|
gallery?.setAttribute('language', lang)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.documentElement.classList.remove('rg-no-page-scroll', 'rg-gallery-mounted', 'rg-header-hidden')
|
|
resizeObs?.disconnect()
|
|
unwatchPane?.()
|
|
unwatchPane = null
|
|
gallery?.remove()
|
|
gallery = null
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="research-gallery-page">
|
|
<h1 class="visually-hidden">
|
|
{{ t('Deep Research') }}
|
|
</h1>
|
|
<div
|
|
ref="host"
|
|
class="research-gallery-host"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.research-gallery-page {
|
|
/* 100svh: the phone's SMALL viewport, so the browser's own toolbars never cover the gallery. */
|
|
height: calc(100svh - var(--rg-header-h, 0px));
|
|
}
|
|
.research-gallery-host,
|
|
.research-gallery-host :deep(doxa-maps-spa) {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.visually-hidden {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
overflow: hidden;
|
|
clip: rect(0 0 0 0);
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* Not scoped: set on <html> by this page only while the gallery is mounted. */
|
|
html.rg-no-page-scroll,
|
|
html.rg-no-page-scroll body {
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* While the gallery is scrolled down, the header is away and the gallery takes the whole screen,
|
|
as the header leaves on every other page. Back at the gallery's top, the header returns. */
|
|
html.rg-header-hidden #masthead {
|
|
display: none;
|
|
}
|
|
html.rg-header-hidden .research-gallery-page {
|
|
height: 100svh;
|
|
}
|
|
|
|
/* The site header is z-index 1500 (blocks/_header.scss); the gallery's info dialog is a full-viewport
|
|
layer at z-index 1000, full-screen on a phone, so the header covered the dialog's title and close
|
|
button there. While the gallery is shown, the header sits just under the dialog: 999 still keeps it,
|
|
and its dropdowns, above the gallery's ordinary content. The mobile menu is outside .header and
|
|
keeps its own level. */
|
|
html.rg-gallery-mounted .header {
|
|
z-index: 999;
|
|
}
|
|
</style>
|