"Show on map" switched the view and the map never acted: retain the request

rega measured two broken interop links and read them as one timing bug. They are
two different bugs, and neither is the one hypothesised (that the event fires
before the map pane has mounted -- both shells create and append their pane
elements in their own onMounted, so the element exists at click time).

country-posters: THE FORWARDING WAS NEVER WRITTEN. onShowOnMap() was a guard,
go('map'), return. No dispatch, no attribute, no property -- nothing carried the
selection to the map at all, which is why the camera stayed at world view. Not a
race, and no amount of waiting would have fixed it. The shell also created its
pane elements and dropped them on the same line, so there was no handle to address
the map pane with even if it had wanted to.

upg-100: forwarding existed but was fire-and-forget, losable two ways. The listener
is attached at the END of an async onMounted, after 5 awaits and only `if (host)`,
and an element dispatch has no replay. And once attached, the zone id was resolved
against legendNodes and applied by `if (node) onLegendSelect(node)` with NO else --
so an id arriving before the data was discarded in silence. rega's second-click
measurement settled which one fires: the first request dies before readiness.

ONE PATTERN, and it is deliberately not "attach the listener sooner" -- that would
fix one of the two losses and leave the other. The request is RECORDED as an
attribute on the pane element and only then announced by event. An attribute is
state: it survives custom-element upgrade, and it is still readable when the pane
arrives, and when the pane's data arrives later still. The event remains the live
path for a map that is already up. Both triggers end in one apply function, so
there is one apply path with two triggers rather than two ways to select.

Unreadiness is retained; an UNRESOLVABLE id is not. Retaining a typo'd id would
spin on every readiness change forever, so it is dropped.

The drain runs on nextTick, and that is not caution. upg100-plugin flagged from
experience that watch([batchMode, isDark]) marks legend rows on nextTick, so a
pre-flush drain would apply the selection against rows about to be replaced by the
re-render, and the highlight could be stomped. That failure is invisible in the
worst possible way: "the selection happened but you cannot see it" is
indistinguishable at the rendered layer from "the selection never happened" --
the exact bug being fixed. The fix would have impersonated the defect.

SOURCE ONLY, deliberately. Both bundles were built to prove the source compiles,
then the built artifacts were reverted: these are not my bundles, and rule 5 keeps
rebuilds with the build owner. country-posters and my-upg-100-list both need one.

Verified by _chk-showonmap-selection-is-not-retained.mjs, 30 assertions. It asserts
the ONE APPLY PATH property rather than just the functions' presence, on
upg100-plugin's suggestion -- a later third trigger that bypassed the apply path
would otherwise fail nothing and reproduce the invisible-selection symptom.
This commit is contained in:
Doxa Maps 2026-09-26 03:41:00 +00:00
parent 9fa3b1e484
commit 35a4f58fc2
4 changed files with 159 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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