diff --git a/plugin-designs/country-posters/countries-map.vue b/plugin-designs/country-posters/countries-map.vue index 3383fde38..0adee73bc 100644 --- a/plugin-designs/country-posters/countries-map.vue +++ b/plugin-designs/country-posters/countries-map.vue @@ -53,6 +53,9 @@ import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponen // ─── Composables (framework) ───────────────────────────────────────────────── import { useMapInstance } from '@map/composables/useMapInstance.js' +// The SAME dataset the poster wall renders from, so a country's bbox has one source of truth +// rather than being copied through an attribute. Already in this bundle -- the wall imports it too. +import posterData from '@map/data/country-posters.json' import { useMapLayers, PIN_STROKE_WIDTH_LIGHT, buildDarkRingWidth } from '@map/composables/useMapLayers.js' import { useMapTheme, THEME_TOGGLE, FIXED_BASEMAP } from '@map/composables/useMapTheme.js' import { useMapEvents } from '@map/composables/useMapEvents.js' @@ -1441,6 +1444,39 @@ function switchTab(tabId) { // ─── Legend → fly-to wiring ────────────────────────────────────────────────── // LegendRows emits a window event when a row is clicked; we fly the map there. +// ─── "Show on map" FROM THE POSTER WALL ────────────────────────────────────────────────────────── +// ★ THIS LINK DID NOT EXIST. country-posters-mfe's onShowOnMap() switched to the Map view and +// returned, so the camera stayed at world view and nothing was selected -- measured by rega. The +// shell now records the requested country as a `select-country` ATTRIBUTE on this pane element and +// also announces it by event; both arrive here. +// +// RETAINED, because the request routinely arrives before this map can act on it. fitBounds needs a +// live map with a loaded style, and the first click happens while the pane is still coming up. The +// old failure mode in the sibling plugin was exactly this -- apply once against unready state, then +// discard with no record, so the user had to click twice. Here an early request is remembered in +// _pendingCountryId and drained when isMapReady flips. +let _pendingCountryId = null +let _selectCountryObserver = null + +/** The ONE apply path. Returns true when the request was satisfied. */ +function applySelectCountry(countryId) { + if (!countryId) { _pendingCountryId = null; return true } // deselect / cleared attribute + const rec = (posterData.countries || []).find((c) => c.id === countryId || c.iso3 === countryId) + // An id we cannot resolve is NOT a readiness problem -- retaining it would spin forever. + if (!rec || !Array.isArray(rec.bbox) || rec.bbox.length !== 4) { _pendingCountryId = null; return true } + if (!isMapReady.value) { _pendingCountryId = countryId; return false } // not ready -- REMEMBER it + const [w, sLat, e, n] = rec.bbox + // The same call a legend row click makes, so the camera behaves identically however it was asked. + mapFly.fitBounds?.([[w, sLat], [e, n]], 60) + _pendingCountryId = null + return true +} + +function onSelectCountry(evt) { applySelectCountry(evt?.detail?.countryId ?? null) } + +// Drain when the map becomes usable -- the half that makes retention real. +watch(isMapReady, (ready) => { if (ready && _pendingCountryId) applySelectCountry(_pendingCountryId) }) + function onLegendRowClick(evt) { const detail = evt?.detail if (!detail || detail.mapId !== mapId) return @@ -2047,6 +2083,21 @@ onMounted(async () => { // (UX 2026-04-27). The active-tab scrollIntoView watcher above still keeps // tapped tabs visible if the bar overflows. window.addEventListener('doxa:legend-row-click', onLegendRowClick) + // The retained request, read from the ELEMENT rather than waited for: an attribute set before + // this component existed is still here now. Then observe it, since the shell rewrites it on + // every click. Both triggers end at applySelectCountry(). + { + const host = mapContainer.value?.getRootNode?.()?.host + if (host) { + host.addEventListener('doxa:select-country', onSelectCountry) + const requested = host.getAttribute('select-country') + if (requested) applySelectCountry(requested) + _selectCountryObserver = new MutationObserver(() => { + applySelectCountry(host.getAttribute('select-country') || null) + }) + _selectCountryObserver.observe(host, { attributes: true, attributeFilter: ['select-country'] }) + } + } window.addEventListener('legend:highlight', onLegendHighlight) const dataPromise = mapData.loadData().catch(err => { @@ -2081,6 +2132,9 @@ onMounted(async () => { onBeforeUnmount(() => { window.removeEventListener('doxa:legend-row-click', onLegendRowClick) + _selectCountryObserver?.disconnect() + _selectCountryObserver = null + mapContainer.value?.getRootNode?.()?.host?.removeEventListener('doxa:select-country', onSelectCountry) window.removeEventListener('legend:highlight', onLegendHighlight) if (_containerObserver) { _containerObserver.disconnect(); _containerObserver = null } poster.value?.cleanup?.() diff --git a/plugin-designs/country-posters/country-posters-mfe.vue b/plugin-designs/country-posters/country-posters-mfe.vue index 5872f94f8..8b7e2822e 100644 --- a/plugin-designs/country-posters/country-posters-mfe.vue +++ b/plugin-designs/country-posters/country-posters-mfe.vue @@ -133,11 +133,34 @@ function go(id) { // nothing in a dropped embed or a nested composite: the event fired into a document // with no handler. Owning it here is what makes the two tabs one element rather // than two pages that happen to share a bundle. +// Pane elements by id, so the map pane can be addressed after mount. +const paneEls = {} + const SHOW_ON_MAP = 'doxa-country-posters:show-on-map' function onShowOnMap(e) { const d = e?.detail || {} if (d.instanceId && d.instanceId !== instanceId.value) return go('map') + // ★ THE FORWARDING THAT WAS NEVER WRITTEN. This handler used to end at go('map'), so the tab + // changed and the map was never told anything at all -- rega measured exactly that: "the camera + // stays at world view, nothing selected". It is not a timing bug and no amount of waiting fixes + // it; the link simply did not exist. + // + // Same shape as upg-100's: RECORD the request as an attribute, then announce it by event. + // An attribute is state, so it survives custom-element upgrade and is still readable when the + // map pane finally gets there (and when its style finishes loading later still); the event is + // only the live path for a map that is already up. Both end in one applySelectCountry() inside + // the pane, so there is one apply path with two triggers. + // + // The attribute carries the country ID, not its geometry: countries-map resolves the id against + // the same library/data/country-posters.json the wall renders from, so the bbox has exactly one + // source of truth rather than being copied through an attribute. + const countryId = d.listId ?? null + const el = paneEls.map + if (!el) return + if (countryId) el.setAttribute('select-country', String(countryId)) + else el.removeAttribute('select-country') + el.dispatchEvent(new CustomEvent('doxa:select-country', { detail: { countryId } })) } onMounted(() => { @@ -146,7 +169,12 @@ onMounted(() => { if (!column) return for (const pane of column.panes) { if (!pane.config) continue - column.mountEl(pane.id)?.appendChild(paneElement(pane)) + // ★ KEEP THE ELEMENT. It used to be created and dropped on the same line, so there was no + // handle to address the map pane with later -- which is part of why "Show on map" could only + // ever switch tabs here. upg-100-mfe already retained its pane elements; this matches it. + const el = paneElement(pane) + paneEls[pane.id] = el + column.mountEl(pane.id)?.appendChild(el) } // Tabs are a readout as well as a control — follow the user's scroll. offScroll = column.onScroll(() => { diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index ea436918d..fc35463f2 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -1514,6 +1514,32 @@ function attachPinHandlers() { // one page therefore never cross-talk. // detail.listId = a zoneKey to select, or NULL to deselect. let _selectZoneHandler = null; +// ★ A REQUEST THAT ARRIVES TOO EARLY IS NOW REMEMBERED, NOT DROPPED. +// rega measured the defect: the FIRST "Show on map" on a poster card did nothing, while a SECOND +// one after the map had loaded isolated the zone and fitted the camera. So the listener and the +// handler both work -- the first request simply died before this component was ready for it. +// There were two ways for it to die and the fix has to survive both, which is why it retains the +// request rather than trying to attach the listener sooner: +// 1. the listener is attached at the END of an async onMounted, after 5 awaits, and only +// `if (host)` -- an element dispatch before that has no replay and is gone; +// 2. even once attached, the zone id is resolved against legendNodes, which is populated by the +// data fetch. Before that, findLegendNode returns nothing and the old code did +// `if (node) onLegendSelect(node)` with no else -- discarded silently, with no record that +// anything had been asked for. +// Retaining covers both without depending on which one fired. +let _pendingZoneId; // undefined = nothing outstanding +let _selectZoneObserver = null; // watches the retained `select-zone` attribute + +/** The ONE apply path. Returns true when the request was satisfied (or was a deselect). */ +function applySelectZone(zoneId) { + if (!zoneId) { onLegendSelect(null); _pendingZoneId = undefined; return true; } + const node = findLegendNode(legendNodes.value, zoneId); + if (!node) { _pendingZoneId = zoneId; return false; } // not ready -- REMEMBER it + onLegendSelect(node); + _pendingZoneId = undefined; + return true; +} + function _onSelectZone(ev) { const zoneId = ev?.detail?.listId ?? null; // REUSE THE CANONICAL SELECT PATH. onLegendSelect(node) is what a legend row @@ -1526,9 +1552,7 @@ function _onSelectZone(ev) { // to the SAME legend node object and hands it to the SAME function. // null => onLegendSelect(null), the canonical un-isolate that restores the full // tapestry. That is what makes the wall's green toggle honest. - if (!zoneId) { onLegendSelect(null); return; } - const node = findLegendNode(legendNodes.value, zoneId); - if (node) onLegendSelect(node); + applySelectZone(zoneId); } // Depth-first lookup of a legend node by zoneKey/id. The tree is now grouped into @@ -2054,11 +2078,42 @@ onMounted(async () => { if (host) { _selectZoneHandler = _onSelectZone host.addEventListener('doxa:select-zone', _selectZoneHandler) + // ★ THE RETAINED REQUEST, read from the ELEMENT rather than waited for. + // The shell also records the selection as a `select-zone` ATTRIBUTE on this element. An + // attribute survives custom-element upgrade and is readable whenever we get here, so a + // request made before this line ran is still available instead of having been dispatched + // into nothing. That is what makes the FIRST click work; the event above remains the live + // path for a map that is already up, so both triggers end at applySelectZone(). + const requested = host.getAttribute('select-zone') + if (requested) applySelectZone(requested) + // And react to later writes, since the shell sets the attribute on every click. + _selectZoneObserver = new MutationObserver(() => { + applySelectZone(host.getAttribute('select-zone') || null) + }) + _selectZoneObserver.observe(host, { attributes: true, attributeFilter: ['select-zone'] }) } } }) +// When the legend data finally lands, satisfy anything that was asked for too early. This is the +// half that makes retention real -- without it the request is remembered and never applied. +// +// ★ ON nextTick, AND NOT AS A PRECAUTION. upg100-plugin flagged the race from experience with this +// file: watch([batchMode, isDark]) at :222 calls markLegendBatchRows() on nextTick, and this watcher +// would otherwise fire PRE-FLUSH -- so the selection would be applied against the legend rows that +// are about to be replaced by the re-render from these very legendNodes, and its row highlight +// could be stomped. That failure is invisible in the worst way: "the selection happened but you +// cannot see it" looks identical at the rendered layer to "the selection never happened", which is +// the bug this whole change exists to fix. Draining after the flush lets the rows exist and be +// marked first. +watch(legendNodes, () => { + if (!_pendingZoneId) return + nextTick(() => { if (_pendingZoneId) applySelectZone(_pendingZoneId) }) +}) + onBeforeUnmount(() => { + _selectZoneObserver?.disconnect(); + _selectZoneObserver = null; if (_selectZoneHandler) { const host = mapEl.value?.getRootNode?.()?.host; host?.removeEventListener('doxa:select-zone', _selectZoneHandler); diff --git a/plugin-designs/my-upg-100-list/upg-100-mfe.vue b/plugin-designs/my-upg-100-list/upg-100-mfe.vue index dc1b134c9..d44eb0e55 100644 --- a/plugin-designs/my-upg-100-list/upg-100-mfe.vue +++ b/plugin-designs/my-upg-100-list/upg-100-mfe.vue @@ -182,11 +182,24 @@ function onShowOnMap(e) { const d = e?.detail || {} if (d.instanceId && d.instanceId !== instanceId.value) return go('map') - // The element is the target, so a pane that has not mounted yet simply gets nothing rather - // than throwing — the scroll still happens, which is the degradation we want. - paneEls.map?.dispatchEvent(new CustomEvent('doxa:select-zone', { - detail: { listId: d.listId ?? null }, - })) + // ★ RECORD THE REQUEST, THEN ANNOUNCE IT — in that order, and the order is the fix. + // The comment that used to sit here called the miss "the degradation we want": a pane that had + // not mounted got nothing, and the scroll still happened. rega measured what that actually costs + // — the FIRST "Show on map" did nothing at all, and only a SECOND click, after the map had + // loaded, selected the zone. A control the user must press twice reads as broken. + // + // An element dispatch has no replay, so it can only reach a listener that already exists. An + // ATTRIBUTE is state: it survives custom-element upgrade, it is readable whenever the pane + // finally gets there, and it is still readable if the pane's data arrives later still. So the + // selection is written down first and the event is kept purely as the live path for a map that + // is already up. Both triggers end in the same applySelectZone() inside the pane, so there is one + // apply path with two triggers rather than two ways to select. + const zoneId = d.listId ?? null + if (paneEls.map) { + if (zoneId) paneEls.map.setAttribute('select-zone', String(zoneId)) + else paneEls.map.removeAttribute('select-zone') + paneEls.map.dispatchEvent(new CustomEvent('doxa:select-zone', { detail: { listId: zoneId } })) + } } onMounted(() => {