Move four identical bodies into the library

From the fleet census: 620 function bodies indexed across 29 plugin trees,
16 identical in two or more. These are the four cheapest — none closes over
anything beyond its own parameters, so each is a file move plus an import
rather than a new dependency to thread.

hostElement resolves the custom element a component is mounted inside, one
hop out through the shadow root. It was identical in three plugins, and it
is the piece worth sharing for a reason other than line count: every plugin
ships through defineCustomElement, a composite nests two shadow roots, and
code that forgets the hop operates on the wrong node while looking correct.
A document-level query for a node inside a shadow root returns null on a
working build, which reads as a broken feature rather than a wrong query.
Three copies were three chances to write that.

findScrollportAncestor walks up to the nearest ancestor that actually
scrolls, crossing shadow boundaries on the way — parentElement stops at a
root, so a plugin in a custom element would otherwise find no scrollport and
conclude the page does not scroll.

findNodeInTree and buildFlatLegendMatchExpr join useLegendMapBridge, which
already holds four functions from the same seam. They sit at module level
rather than inside the factory because they close over nothing: a pure
function hidden in a closure can only be reached by constructing the whole
bridge.

Call sites keep their old names through a one-line local binding, so no
call changed and the diff is the declaration plus an import.

Rebuild-and-compare, all five affected bundles, before -> after:
  regions-dashboard   1,098,469 -> 1,098,479   +10
  country-posters     1,484,053 -> 1,484,028   -25
  doxa-research-map   1,354,051 -> 1,354,061   +10
  dashboard-plus-grid   915,660 ->   915,644   -16
  big-picture           366,907 ->   366,719  -188
  net -209, and country-posters gains 25 bytes against the 1.5 MB ceiling.
Every behaviour-bearing token counted, not set-compared, and unchanged in
all three map bundles: regions-fill, peoplePraying, engagementStatus,
adoptionStatus, overflowY, scrollHeight, getRootNode, _flat_filter, religion.

Owners cleared their own files first: regions for regions-list.vue, upg100
for countries-map.vue. check:selection-kinds, check:legend-matches-source,
check:legend-data-contract, check:packet-variants, check:no-dead-library and
check:no-cross-plugin-imports all pass.
This commit is contained in:
Doxa Maps 2026-09-29 08:26:59 +00:00
parent a71cc4b5e8
commit 07eb74c33c
9 changed files with 143 additions and 121 deletions

View file

@ -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.

View file

@ -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
* (`<research-map-plus-grid>` contains `<doxa-research-map>`). 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

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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 */ }

View file

@ -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,

View file

@ -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()

View file

@ -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