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:
parent
a71cc4b5e8
commit
07eb74c33c
9 changed files with 143 additions and 121 deletions
|
|
@ -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.
|
||||
|
|
|
|||
28
library/utils/hostElement.js
Normal file
28
library/utils/hostElement.js
Normal 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
|
||||
33
library/utils/scrollport.js
Normal file
33
library/utils/scrollport.js
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue