diff --git a/library/components/maps/RegionCountryMap.vue b/library/components/maps/RegionCountryMap.vue index 00e9647ea..48e21162e 100644 --- a/library/components/maps/RegionCountryMap.vue +++ b/library/components/maps/RegionCountryMap.vue @@ -412,32 +412,73 @@ const POPUP_OFFSET = 12 * is 640px against a 452px popup — 164px of headroom. The popup would have to grow by 164px before * any width could overflow. So there is no residual case needing an inner scroll. * + * ★ CLEARING THE CLIP IS NOT THE SAME AS BEING CLICKABLE, which is what the first version of this + * got wrong. The bottom controls — the attribution and the pin hint — are painted OVER the canvas + * and take pointer events, so a popup that ends fully inside the frame can still have its buttons + * underneath them. MEASURED at 390 on a 366x732 map: the pan cleared the clip exactly (cut 0), and + * a real click on Full Profile still did nothing, because at y~701 the top element at that point + * was div.maplibregl-ctrl-attrib-inner; on the sibling pin it was span.pin-hint-pill. The popup is + * not at fault and neither is the control: attribution is a LICENCE TERM of the basemap, so raising + * the popup over it is not available to us. The usable frame is simply shorter than the container, + * and the fit test has to say so. + * + * bottomInset is therefore MEASURED from the live controls rather than assumed — a constant would + * be wrong on the next control we add, at a width where the attribution wraps to two lines, or when + * the gallery's clearance transform lifts the whole band. + * * Returns 0 when nothing should move: already fitting, unmeasurable, or a popup taller than its * container. That last one matters — a host can give the map a short slot, and there panning would * only move the clipping around, so the failure stays what it is today rather than becoming a * clipped popup AND a camera that jumped. */ -function panPlanFor({ containerH, popupH, pinY, offset = POPUP_OFFSET }) { +function panPlanFor({ containerH, popupH, pinY, offset = POPUP_OFFSET, bottomInset = 0 }) { if (!(containerH > 0) || !(popupH > 0) || !Number.isFinite(pinY)) return 0 const need = popupH + offset - if (pinY >= need || (containerH - pinY) >= need) return 0 // one anchor already fits - if (need > containerH) return 0 // taller than the frame: unfixable here + // The frame the popup must fit inside is the container MINUS the band the controls occupy. + const usableH = containerH - Math.max(0, bottomInset) + if (pinY >= need || (usableH - pinY) >= need) return 0 // one anchor already fits, clear of the controls + if (need > usableH) return 0 // taller than the usable frame: unfixable here const downToFitAbove = need - pinY - const upToFitBelow = need - (containerH - pinY) + const upToFitBelow = need - (usableH - pinY) return downToFitAbove <= upToFitBelow ? downToFitAbove : -upToFitBelow } +/** + * The height of the bottom control band, in CSS pixels, measured from the live elements. + * + * Both engines are named because the provider prop can swap them. Only controls that actually + * INTERCEPT POINTER EVENTS matter here: the container is pointer-events:none and the control inside + * it is pointer-events:auto, so the band is measured from the controls, not from their corner box — + * which at 390 spans nearly the full map width and would over-reserve the whole strip. + */ +function bottomControlInset(container) { + if (!container) return 0 + let top = Infinity + const box = container.getBoundingClientRect() + const sel = '.maplibregl-ctrl-bottom-left .maplibregl-ctrl, .maplibregl-ctrl-bottom-right .maplibregl-ctrl,' + + '.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl, .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl, .pin-hint-pill' + for (const el of container.querySelectorAll(sel)) { + const r = el.getBoundingClientRect() + if (r.height <= 0 || r.width <= 0) continue // a collapsed control reserves nothing + if (r.top < top) top = r.top + } + if (!Number.isFinite(top)) return 0 + return Math.max(0, Math.round(box.bottom - top)) +} + function panPopupIntoView(lngLat) { const m = map.value if (!m || !popup) return - let popupH = 0, containerH = 0, pinY = NaN + let popupH = 0, containerH = 0, pinY = NaN, bottomInset = 0 try { const el = popup.getElement() popupH = el ? el.getBoundingClientRect().height : 0 - containerH = m.getContainer()?.clientHeight || 0 + const c = m.getContainer() + containerH = c?.clientHeight || 0 + bottomInset = bottomControlInset(c) pinY = m.project(lngLat).y } catch (_) { return } // a map mid-load cannot be measured - const dy = panPlanFor({ containerH, popupH, pinY }) + const dy = panPlanFor({ containerH, popupH, pinY, bottomInset }) // panBy moves the CAMERA, so the sign is inverted: a positive plan moves the PIN down the screen. if (dy) { try { m.panBy([0, -dy], { duration: 200 }) } catch (_) { /* a pan is a nicety, never a failure */ } } }