diff --git a/library/composables/legends/useLegendMapBridge.js b/library/composables/legends/useLegendMapBridge.js index 644467396..dc281c68e 100644 --- a/library/composables/legends/useLegendMapBridge.js +++ b/library/composables/legends/useLegendMapBridge.js @@ -33,6 +33,62 @@ * @param {object} deps.pplrInstance carries .selection (a ref) for the legend bridge * @param {string} deps.mapId this map's id, to ignore other instances' events */ + +import { FULL_PRAYER_THRESHOLD } from '@map/colors/prayer-progress.js' +/** + * Depth-first search of a legend tree. + * + * Module-level, NOT inside the factory below, because it closes over nothing: the tree and the + * predicate are both arguments. A pure function hidden inside a closure can only be reached by + * constructing the whole bridge, which is the difference between a utility and a dependency. + * + * @param {Array} nodes legend tree nodes, each optionally carrying `children` + * @param {Function} predicate called with each node; the first truthy match wins + * @returns {object|null} + */ +export function findNodeInTree(nodes, predicate) { + for (const n of nodes) { + if (predicate(n)) return n + if (n.children?.length) { + const f = findNodeInTree(n.children, predicate) + if (f) return f + } + } + return null +} + +/** + * The map filter expression for one row of a FLAT legend (prayer / engagement / adoption / religion). + * + * ⚠ RETURNS null RATHER THAN A MATCH-NOTHING EXPRESSION for an unknown tier or key, and callers rely + * on that: null means "this legend type has no filter for that row", which leaves the map unfiltered, + * while a match-nothing expression would empty it. An empty map reads as lost data. + * + * @param {object} node the legend row; its `id` is the bucket key + * @param {string} legendType 'prayer' | 'engagement' | 'adoption' | 'religion' + * @returns {Array|null} a MapLibre/Mapbox filter expression, or null + */ +export function buildFlatLegendMatchExpr(node, legendType) { + if (!node || !legendType) return null + const key = node.id + if (legendType === 'prayer') { + const v = ['to-number', ['get', 'peoplePraying']] + if (key === 'noPrayer') return ['==', v, 0] + if (key === 'fullPrayer') return ['>=', v, FULL_PRAYER_THRESHOLD] + if (key === 'hasPrayer') return ['all', ['>', v, 0], ['<', v, FULL_PRAYER_THRESHOLD]] + } else if (legendType === 'engagement') { + if (key === 'hasEngagement') return ['==', ['get', 'engagementStatus'], true] + if (key === 'notEngaged') return ['==', ['get', 'engagementStatus'], false] + } else if (legendType === 'adoption') { + if (key === 'hasAdoption') return ['==', ['get', 'adoptionStatus'], true] + if (key === 'notAdopted') return ['==', ['get', 'adoptionStatus'], false] + } else if (legendType === 'religion') { + // expectedValue is a family letter (C/M/H/B/E/J/S/N/O/U). Match first char. + return ['==', ['slice', ['coalesce', ['get', 'religion'], ''], 0, 1], key] + } + return null +} + export function useLegendMapBridge({ map, activeTab, countryOutline, regionIdle, mapFly, pplrInstance, mapId }) { /** * Drop every highlight the legend may have painted on the country/region layers. diff --git a/library/utils/hostElement.js b/library/utils/hostElement.js new file mode 100644 index 000000000..db36c1dec --- /dev/null +++ b/library/utils/hostElement.js @@ -0,0 +1,28 @@ +/** + * hostElement — the custom element a component is mounted inside. + * + * ★ WHY THIS IS A LIBRARY FUNCTION AND NOT FIVE LINES IN EACH PLUGIN. Every plugin ships through + * Vue's `defineCustomElement`, which mounts into a SHADOW ROOT, and a composite nests two of them + * (`` contains ``). So the element a component can see + * with `$el` is inside the shadow tree, and the thing a host page can style, measure or address is + * the ROOT NODE'S HOST — one hop out. Code that forgets the hop silently operates on the wrong node. + * + * That mistake is the one this codebase makes most often, and it is invisible when you make it: a + * `document.querySelector` for a node inside a shadow root returns null on a perfectly working + * build, which reads as "the feature is broken" rather than "the query was wrong". Three identical + * copies of this logic were three chances to write the wrong version of it. + * + * ⚠ `getRootNode()` is guarded because it does not exist on every node type this can be handed + * (a detached vnode el, a text node), and because a CLOSED shadow root returns no host — in both + * cases the element itself is the honest answer, not a throw. + * + * @param {object} inst a Vue internal instance (`getCurrentInstance()`) + * @returns {Element|null|undefined} the custom element host, or the component's own root element + */ +export function hostElement(inst) { + const root = inst?.vnode?.el || inst?.proxy?.$el + try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {} + return root +} + +export default hostElement diff --git a/library/utils/scrollport.js b/library/utils/scrollport.js new file mode 100644 index 000000000..b0853c69c --- /dev/null +++ b/library/utils/scrollport.js @@ -0,0 +1,33 @@ +/** + * findScrollportAncestor — the nearest ancestor that actually scrolls. + * + * Used to decide where a map should hand the wheel back to the page, and to scroll a selected row + * into view without moving the whole document. + * + * ⚠ THE WALK CROSSES SHADOW BOUNDARIES, and that is the whole reason this is not a two-line + * `closest()`. `parentElement` stops at a shadow root, so a plugin mounted in a custom element would + * find no scrollport at all and conclude the page does not scroll — the ladder therefore steps + * `parentElement` → `parentNode.host` → `parentNode`, which is what carries it out of one shadow + * tree and into the next. A composite nests two. + * + * ⚠ "SCROLLS" MEANS BOTH: overflow allows it AND there is something to scroll. An element with + * `overflow-y: auto` and no excess content is not a scrollport, and treating it as one stops the + * search at the wrong node. The +4 tolerance keeps sub-pixel layout rounding from reading as scrollable. + * + * @param {Node} el the node to search upward from + * @returns {Element|null} + */ +export function findScrollportAncestor(el) { + let node = el && el.parentNode + while (node) { + if (node.nodeType === 1) { + const cs = getComputedStyle(node) + if (/auto|scroll/.test(cs.overflowY) && node.scrollHeight > node.clientHeight + 4) return node + } + node = node.parentElement || node.parentNode?.host || (node.parentNode !== node ? node.parentNode : null) + if (node && node.nodeType === 9) break + } + return null +} + +export default findScrollportAncestor diff --git a/plugin-designs/big-picture/src/components/BpResearchHeader.vue b/plugin-designs/big-picture/src/components/BpResearchHeader.vue index 8025fc90c..db413295c 100644 --- a/plugin-designs/big-picture/src/components/BpResearchHeader.vue +++ b/plugin-designs/big-picture/src/components/BpResearchHeader.vue @@ -41,6 +41,7 @@ import SearchBar from '@map/components/search/SearchBar.vue' import FilterDropdown from '@map/components/search/FilterDropdown.vue' import { tStatic } from '@map/i18n/index.js' import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js' +import { hostElement } from '@map/utils/hostElement.js' // ★ THIS HEADER HAD NO i18n AT ALL, and it is the top of Big Picture. Every label below was an // English literal passed as a PROP — FilterDropdown itself translates nothing, it renders what the @@ -82,11 +83,7 @@ const hasAny = computed(() => Boolean(query.value || sel.wagf_region || sel.wagf // ── native + Vue emit of the unified filter ────────────────────────────────── const inst = getCurrentInstance() -function hostEl() { - const root = inst?.vnode?.el || inst?.proxy?.$el - try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {} - return root -} +const hostEl = () => hostElement(inst) function fire() { const detail = { query: query.value, diff --git a/plugin-designs/country-posters/countries-map.vue b/plugin-designs/country-posters/countries-map.vue index dc0095387..d479e4609 100644 --- a/plugin-designs/country-posters/countries-map.vue +++ b/plugin-designs/country-posters/countries-map.vue @@ -174,7 +174,7 @@ import { useLanguageFamilyLegendData } from '@map/composables/legends/useLanguag // SemanticTreeLegend instance can render either depending on activeLegendType. import { useAffinityBlockLegendData } from '@map/composables/legends/useAffinityBlockLegendData.js' import { useWagfRegionsLegendData } from '@map/composables/legends/useWagfRegionsLegendData.js' -import { useLegendMapBridge } from '@map/composables/legends/useLegendMapBridge.js' +import { useLegendMapBridge, findNodeInTree, buildFlatLegendMatchExpr } from '@map/composables/legends/useLegendMapBridge.js' import { useLegendData as useFlatLegendData } from '@map/composables/legends/useLegendData.js' import { RESEARCH_LEGEND_OPTIONS } from '@map/composables/legends/researchLegendOptions.js' @@ -936,16 +936,6 @@ const legendTitle = computed(() => { if (activeLegendType.value === 'doxa-regions') return t('legend.titleBar.wagfRegions') return t('legend.titleBar.languages') }) -function _findNodeInTree(nodes, predicate) { - for (const n of nodes) { - if (predicate(n)) return n - if (n.children?.length) { - const f = _findNodeInTree(n.children, predicate) - if (f) return f - } - } - return null -} // PPLR's onLegendSelect verbatim semantics: setFilter(node.filter) on select, // setFilter(null) on null. Plus mirror to mapStore for back-compat with the // rest of the app (geocoder, popup, fly-to all read mapStore.selected*). @@ -984,26 +974,6 @@ provide('getActivePinColor', (properties) => { // expression for the camera here. Tree-tab nodes carry usable Mapbox filters // already (fam:/lang:/dial: + region:/block:/country: + bloc:/cluster:/pg:/pgic:), // so this returns null for them and zoomToLegendRow falls back to node.filter. -function _buildFlatLegendMatchExpr(node, legendType) { - if (!node || !legendType) return null - const key = node.id - if (legendType === 'prayer') { - const v = ['to-number', ['get', 'peoplePraying']] - if (key === 'noPrayer') return ['==', v, 0] - if (key === 'fullPrayer') return ['>=', v, FULL_PRAYER_THRESHOLD] - if (key === 'hasPrayer') return ['all', ['>', v, 0], ['<', v, FULL_PRAYER_THRESHOLD]] - } else if (legendType === 'engagement') { - if (key === 'hasEngagement') return ['==', ['get', 'engagementStatus'], true] - if (key === 'notEngaged') return ['==', ['get', 'engagementStatus'], false] - } else if (legendType === 'adoption') { - if (key === 'hasAdoption') return ['==', ['get', 'adoptionStatus'], true] - if (key === 'notAdopted') return ['==', ['get', 'adoptionStatus'], false] - } else if (legendType === 'religion') { - // expectedValue is a family letter (C/M/H/B/E/J/S/N/O/U). Match first char. - return ['==', ['slice', ['coalesce', ['get', 'religion'], ''], 0, 1], key] - } - return null -} function onSemanticTreeSelect(node) { const m = map.value // — uniform legend-row camera: any non-null row click moves @@ -1012,7 +982,7 @@ function onSemanticTreeSelect(node) { // _flat_filter placeholder); tree-tab nodes use node.filter directly. if (node) { const flatExpr = _isFlatTab.value - ? _buildFlatLegendMatchExpr(node, activeLegendType.value) + ? buildFlatLegendMatchExpr(node, activeLegendType.value) : null // doxa-regions tab → fit polygon (sparse regions like Oceania should // zoom TO the region, not OUT to span its few scattered pins). @@ -1173,7 +1143,7 @@ watch(() => uiStore.theme, (t) => { pplrInstance.theme.value = (t === 'dark' ? ' // but the SemanticTreeLegend's _lastInternalId guard catches the round-trip. watch(() => mapStore.selectedFamily, (key) => { if (!key) return _bridgeSelection('fam', null) - const node = _findNodeInTree(langTree.value, n => n.id.startsWith('fam:') && n.label === key) + const node = findNodeInTree(langTree.value, n => n.id.startsWith('fam:') && n.label === key) if (node) _bridgeSelection('fam', { ...node, depth: 0 }) }) watch(() => mapStore.selectedLanguage, (key) => { @@ -1181,7 +1151,7 @@ watch(() => mapStore.selectedLanguage, (key) => { // Match by label across all language nodes — the geocoder gives us "Arabic" // without a family prefix, so we look up the first matching language node // (sorted by population, so the most-populated family wins; usually correct). - const node = _findNodeInTree(langTree.value, n => n.id.startsWith('lang:') && n.label === key) + const node = findNodeInTree(langTree.value, n => n.id.startsWith('lang:') && n.label === key) if (node) _bridgeSelection('lang', { ...node, depth: 1 }) }) watch(() => mapStore.selectedDialect, (dialect) => { @@ -1189,7 +1159,7 @@ watch(() => mapStore.selectedDialect, (dialect) => { // Dialect mapStore keys already include the family prefix // ("family__baseLang__dialect"); langTree dialect ids prepend "dial:". const fullId = `dial:${dialect.key}` - const node = _findNodeInTree(langTree.value, n => n.id === fullId) + const node = findNodeInTree(langTree.value, n => n.id === fullId) if (node) _bridgeSelection('dial', { ...node, depth: 2 }) }) // WAGF Regions — bridge mapStore.selectedRegion → SemanticTreeLegend. @@ -1197,7 +1167,7 @@ watch(() => mapStore.selectedDialect, (dialect) => { // 'region:south_asia' node id in regionsTree. watch(() => mapStore.selectedRegion, (key) => { if (!key) return _bridgeSelection('region', null) - const node = _findNodeInTree(regionsTree.value, n => n.id === `region:${key}`) + const node = findNodeInTree(regionsTree.value, n => n.id === `region:${key}`) if (node) _bridgeSelection('region', { ...node, depth: 0 }) }) @@ -1907,7 +1877,7 @@ function routeRegion(evt, { map: m, filterLayerId }) { const applyRegionSel = () => { // Drop the harsh pin setFilter applied earlier — we want dim, not hide. try { m.setFilter(filterLayerId, null) } catch (_) {} - const node = _findNodeInTree(regionsTree.value, n => + const node = findNodeInTree(regionsTree.value, n => (n.id.startsWith('region:') || n.id.startsWith('block:')) && _rnorm(_stripTier(n.label)) === wantLabel) if (node) { onSemanticTreeSelect(node) @@ -1954,7 +1924,7 @@ function routeCountry(evt) { // regionsTree is lazy → ensure loaded and defer to nextTick so it lands after settle. if (iso && activeTab.value?.id === 'doxa-regions') { const applyCountrySel = () => { - const country = _findNodeInTree(regionsTree.value, n => + const country = findNodeInTree(regionsTree.value, n => n.id.startsWith('country:') && (n.isoCodes || []).includes(iso)) if (country) { onSemanticTreeSelect(country) diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 99462e500..194995a90 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -95,25 +95,13 @@ function _syncHitboxFilter(m, filter) { // document itself scroll? Either one means release scroll-zoom back to the page; neither // means keep it (the standalone fixed-viewport leaf, where wheel-zoom is expected). Nothing // is lost either way — the toolbar zoom buttons and double-click/pinch zoom still work. -function _findScrollportAncestor(el) { - let node = el && el.parentNode - while (node) { - if (node.nodeType === 1) { - const cs = getComputedStyle(node) - if (/auto|scroll/.test(cs.overflowY) && node.scrollHeight > node.clientHeight + 4) return node - } - node = node.parentElement || node.parentNode?.host || (node.parentNode !== node ? node.parentNode : null) - if (node && node.nodeType === 9) break - } - return null -} function _syncScrollZoomToHost(m) { if (!m || !m.scrollZoom) return try { const doc = document.documentElement const docScrolls = doc.scrollHeight > window.innerHeight + 4 const host = (m.getContainer && m.getContainer()) || null - const ancestorScrolls = host ? !!_findScrollportAncestor(host) : false + const ancestorScrolls = host ? !!findScrollportAncestor(host) : false // ★ THE MAP KEEPS THE GESTURE. THE EDGE STRIPS ARE THE WAY PAST IT. // Required 2026-09-24: "two finger scrolling on the map should allow me to interact with the // map, but instead it just scrolls down past the map and does not interact with the map — @@ -211,9 +199,10 @@ import { useLanguageFamilyLegendData } from '@map/composables/legends/useLanguag // SemanticTreeLegend instance can render either depending on activeLegendType. import { useAffinityBlockLegendData } from '@map/composables/legends/useAffinityBlockLegendData.js' import { useWagfRegionsLegendData } from '@map/composables/legends/useWagfRegionsLegendData.js' -import { useLegendMapBridge } from '@map/composables/legends/useLegendMapBridge.js' +import { useLegendMapBridge, findNodeInTree, buildFlatLegendMatchExpr } from '@map/composables/legends/useLegendMapBridge.js' import { useLegendData as useFlatLegendData } from '@map/composables/legends/useLegendData.js' import { RESEARCH_LEGEND_OPTIONS } from '@map/composables/legends/researchLegendOptions.js' +import { findScrollportAncestor } from '@map/utils/scrollport.js' @@ -1072,16 +1061,6 @@ const legendTitle = computed(() => { if (activeLegendType.value === 'doxa-regions') return t('legend.titleBar.wagfRegions') return t('legend.titleBar.languages') }) -function _findNodeInTree(nodes, predicate) { - for (const n of nodes) { - if (predicate(n)) return n - if (n.children?.length) { - const f = _findNodeInTree(n.children, predicate) - if (f) return f - } - } - return null -} // PPLR's onLegendSelect verbatim semantics: setFilter(node.filter) on select, // setFilter(null) on null. Plus mirror to mapStore for back-compat with the // rest of the app (geocoder, popup, fly-to all read mapStore.selected*). @@ -1104,26 +1083,6 @@ provide('getActivePinColor', (properties) => { // expression for the camera here. Tree-tab nodes carry usable Mapbox filters // already (fam:/lang:/dial: + region:/block:/country: + bloc:/cluster:/pg:/pgic:), // so this returns null for them and zoomToLegendRow falls back to node.filter. -function _buildFlatLegendMatchExpr(node, legendType) { - if (!node || !legendType) return null - const key = node.id - if (legendType === 'prayer') { - const v = ['to-number', ['get', 'peoplePraying']] - if (key === 'noPrayer') return ['==', v, 0] - if (key === 'fullPrayer') return ['>=', v, FULL_PRAYER_THRESHOLD] - if (key === 'hasPrayer') return ['all', ['>', v, 0], ['<', v, FULL_PRAYER_THRESHOLD]] - } else if (legendType === 'engagement') { - if (key === 'hasEngagement') return ['==', ['get', 'engagementStatus'], true] - if (key === 'notEngaged') return ['==', ['get', 'engagementStatus'], false] - } else if (legendType === 'adoption') { - if (key === 'hasAdoption') return ['==', ['get', 'adoptionStatus'], true] - if (key === 'notAdopted') return ['==', ['get', 'adoptionStatus'], false] - } else if (legendType === 'religion') { - // expectedValue is a family letter (C/M/H/B/E/J/S/N/O/U). Match first char. - return ['==', ['slice', ['coalesce', ['get', 'religion'], ''], 0, 1], key] - } - return null -} // ─── ONE WRITER FOR THE REGION POLYGONS ────────────────────────────────────── // ★ MEASURED (rega, main 373b7979b): a single WAGF row click produced NINE regions-* paint writes — // fill-opacity, line-color and line-width, three times each inside ~110 ms — because two paths painted @@ -1227,7 +1186,7 @@ function onSemanticTreeSelect(node) { // _flat_filter placeholder); tree-tab nodes use node.filter directly. if (node) { const flatExpr = _isFlatTab.value - ? _buildFlatLegendMatchExpr(node, activeLegendType.value) + ? buildFlatLegendMatchExpr(node, activeLegendType.value) : null // doxa-regions tab → fit polygon (sparse regions like Oceania should // zoom TO the region, not OUT to span its few scattered pins). @@ -1359,7 +1318,7 @@ watch(() => uiStore.theme, (t) => { pplrInstance.theme.value = (t === 'dark' ? ' // but the SemanticTreeLegend's _lastInternalId guard catches the round-trip. watch(() => mapStore.selectedFamily, (key) => { if (!key) return _bridgeSelection('fam', null) - const node = _findNodeInTree(langTree.value, n => n.id.startsWith('fam:') && n.label === key) + const node = findNodeInTree(langTree.value, n => n.id.startsWith('fam:') && n.label === key) if (node) _bridgeSelection('fam', { ...node, depth: 0 }) }) watch(() => mapStore.selectedLanguage, (key) => { @@ -1367,7 +1326,7 @@ watch(() => mapStore.selectedLanguage, (key) => { // Match by label across all language nodes — the geocoder gives us "Arabic" // without a family prefix, so we look up the first matching language node // (sorted by population, so the most-populated family wins; usually correct). - const node = _findNodeInTree(langTree.value, n => n.id.startsWith('lang:') && n.label === key) + const node = findNodeInTree(langTree.value, n => n.id.startsWith('lang:') && n.label === key) if (node) _bridgeSelection('lang', { ...node, depth: 1 }) }) watch(() => mapStore.selectedDialect, (dialect) => { @@ -1375,7 +1334,7 @@ watch(() => mapStore.selectedDialect, (dialect) => { // Dialect mapStore keys already include the family prefix // ("family__baseLang__dialect"); langTree dialect ids prepend "dial:". const fullId = `dial:${dialect.key}` - const node = _findNodeInTree(langTree.value, n => n.id === fullId) + const node = findNodeInTree(langTree.value, n => n.id === fullId) if (node) _bridgeSelection('dial', { ...node, depth: 2 }) }) // WAGF Regions — bridge mapStore.selectedRegion → SemanticTreeLegend. @@ -1383,7 +1342,7 @@ watch(() => mapStore.selectedDialect, (dialect) => { // 'region:south_asia' node id in regionsTree. watch(() => mapStore.selectedRegion, (key) => { if (!key) return _bridgeSelection('region', null) - const node = _findNodeInTree(regionsTree.value, n => n.id === `region:${key}`) + const node = findNodeInTree(regionsTree.value, n => n.id === `region:${key}`) if (node) _bridgeSelection('region', { ...node, depth: 0 }) }) @@ -2210,7 +2169,7 @@ function routeRegion(evt, { map: m, filterLayerId }) { const applyRegionSel = () => { // Drop the harsh pin setFilter applied earlier — we want dim, not hide. try { m.setFilter(filterLayerId, null) } catch (_) {} - const node = _findNodeInTree(regionsTree.value, n => + const node = findNodeInTree(regionsTree.value, n => (n.id.startsWith('region:') || n.id.startsWith('block:')) && _rnorm(_stripTier(n.label)) === wantLabel) if (node) { onSemanticTreeSelect(node) @@ -2257,7 +2216,7 @@ function routeCountry(evt) { // regionsTree is lazy → ensure loaded and defer to nextTick so it lands after settle. if (iso && activeTab.value?.id === 'doxa-regions') { const applyCountrySel = () => { - const country = _findNodeInTree(regionsTree.value, n => + const country = findNodeInTree(regionsTree.value, n => n.id.startsWith('country:') && (n.isoCodes || []).includes(iso)) if (country) { onSemanticTreeSelect(country) @@ -3076,7 +3035,7 @@ onMounted(async () => { _scrollZoomObserver = new ResizeObserver(_resyncScrollZoom) const host = (map.value.getContainer && map.value.getContainer()) || null if (host) _scrollZoomObserver.observe(host) - const port = host ? _findScrollportAncestor(host) : null + const port = host ? findScrollportAncestor(host) : null if (port) _scrollZoomObserver.observe(port) if (document.body) _scrollZoomObserver.observe(document.body) } catch (_) { /* no ResizeObserver: the initial call above still answers for a static page */ } diff --git a/plugin-designs/people-groups-grid/people-groups-grid.vue b/plugin-designs/people-groups-grid/people-groups-grid.vue index a825cf860..60af8f511 100644 --- a/plugin-designs/people-groups-grid/people-groups-grid.vue +++ b/plugin-designs/people-groups-grid/people-groups-grid.vue @@ -59,6 +59,7 @@ import { RELIGION_FAMILIES } from '@map/colors/religion.js' import { canonicalFamilyName } from '@map/colors/language-family.js' import langFamilyByIso from '@map/data/langFamilyByIso.json' import { joinBase } from '@map/composables/joinBase.js' +import { hostElement } from '@map/utils/hostElement.js' // ★ THE HOST'S INLINE STYLE MUST NOT LAND ON OUR ROOT. // This SFC is mounted AS a custom element, so a style attribute the HOST page puts on the tag falls @@ -851,11 +852,7 @@ function filterUUPGs() { // ── emit `groups-filtered` (Vue + native composed CustomEvent) ───────────────── const inst = getCurrentInstance() -function hostEl() { - const root = inst?.vnode?.el || inst?.proxy?.$el - try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {} - return root -} +const hostEl = () => hostElement(inst) function emitFiltered() { const detail = { query: effSearchTerm.value, diff --git a/plugin-designs/regions-dashboard/regions-list.vue b/plugin-designs/regions-dashboard/regions-list.vue index f3f06a8f0..de7b5844b 100644 --- a/plugin-designs/regions-dashboard/regions-list.vue +++ b/plugin-designs/regions-dashboard/regions-list.vue @@ -72,6 +72,7 @@ import { makeCountrySheet } from './src/country-sheet-print.js' import { resolveAssetsBase } from '@map/utils/embedBase.js' import { onBeforeUnmount } from 'vue' import { cyrillicHeading } from '@map/styles/portedGlobals.js' +import { hostElement } from '@map/utils/hostElement.js' const props = defineProps({ profileConfig: { type: String, default: '' }, @@ -211,11 +212,7 @@ function summaryLine(region) { // ── emit (Vue + native composed CustomEvent, same pattern as people-groups-grid) ──────────── const inst = getCurrentInstance() -function hostEl() { - const root = inst?.vnode?.el || inst?.proxy?.$el - try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {} - return root -} +const hostEl = () => hostElement(inst) function dispatch(name, detail) { emit(name, detail) const el = hostEl() diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue index 0bca2bcf1..c6d2cff13 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue @@ -37,6 +37,7 @@ import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@ma // one threshold, one owner. (The dashboard facet already reuses getPrayerLevel for the same reason.) import { applyColor as prayerColorExpr } from '@map/colors/prayer-progress.js' import { resolveAssetsBase } from '@map/utils/embedBase.js' +import { findScrollportAncestor } from '@map/utils/scrollport.js' const dash = inject('dashboardData', null) const bus = inject('selectionBus', null) @@ -1194,22 +1195,6 @@ function onBubbleClick(e) { // // The honest discriminator is therefore "is there a scrollable ANCESTOR?", not "does the document // scroll?" — the document is just one possible answer, and in an embedded host it is the wrong one. -function findScrollportAncestor(el) { - // Walk the COMPOSED tree: `parentElement` stops at a shadow boundary, so hop to the host when it - // does. Every plugin here is a compiled custom element, so a flat walk exits early and reports - // "no scrollport" for a chain that has one. - let node = el && el.parentNode - while (node) { - if (node.nodeType === 1) { - const cs = getComputedStyle(node) - if (/auto|scroll/.test(cs.overflowY) && node.scrollHeight > node.clientHeight + 4) return node - } - node = node.parentElement || node.parentNode?.host || (node.parentNode !== node ? node.parentNode : null) - if (node && node.nodeType === 9) break // reached the document; handled separately below - } - return null -} - // ★ SUPERSEDED BY --rd-map-h (BUG-MAP-ZOOM-BROKEN-BY-THE-SCROLL-FIX, reported from use 2026-09-23). // The blanket disable above was written when a scroll-stack composite pinned this band to ONE // SCREEN (88svh, then a bare 100%) — under THAT layout the map row filled the whole scrollable