diff --git a/library/components/maps/RegionCountryMap.vue b/library/components/maps/RegionCountryMap.vue index 735d68d5f..dccba3fdc 100644 --- a/library/components/maps/RegionCountryMap.vue +++ b/library/components/maps/RegionCountryMap.vue @@ -381,8 +381,59 @@ function onPinClick(e) { const p = f.properties || {} const html = popupHtml(p) if (popup) popup.remove() - popup = new g.Popup({ closeButton: true, maxWidth: '264px', offset: 12 }) + popup = new g.Popup({ closeButton: true, maxWidth: '264px', offset: POPUP_OFFSET }) .setLngLat(f.geometry.coordinates).setHTML(html).addTo(map.value) + // The box only has a height once it is in the document, so measure on the next frame. + requestAnimationFrame(() => panPopupIntoView(f.geometry.coordinates)) +} + +// Their popup offset, as one constant: the pan arithmetic has to use the SAME number the popup was +// built with, and two literals drift. +const POPUP_OFFSET = 12 + +/** + * How far the map must move, in pixels, for the whole popup to sit inside the map. + * + * ★ WHY PANNING AND NOT A SHORTER POPUP. The popup is built with no explicit anchor, so the engine + * places it above the pin when there is room and below when there is not. MEASURED on the drop-in: + * a 452px popup on a pin in the lower half of a 780px map fits NEITHER way, so it opens below and + * the map's overflow:hidden cuts off the Pray and Full Profile buttons — a real click then lands on + * the page behind. Moving the PIN is what makes the preferred anchor fit; the popup's design is + * untouched, which is what keeps this a clone. It is what Leaflet does by default. + * + * ★ AND IT ALWAYS RESOLVES, at every width this component renders at. Container height is + * max(780, 16/9 of width) at >=768px and 2x width below that, so even at 320px wide the container + * 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. + * + * 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 }) { + 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 + const downToFitAbove = need - pinY + const upToFitBelow = need - (containerH - pinY) + return downToFitAbove <= upToFitBelow ? downToFitAbove : -upToFitBelow +} + +function panPopupIntoView(lngLat) { + const m = map.value + if (!m || !popup) return + let popupH = 0, containerH = 0, pinY = NaN + try { + const el = popup.getElement() + popupH = el ? el.getBoundingClientRect().height : 0 + containerH = m.getContainer()?.clientHeight || 0 + pinY = m.project(lngLat).y + } catch (_) { return } // a map mid-load cannot be measured + const dy = panPlanFor({ containerH, popupH, pinY }) + // 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 */ } } } // THEIR escape, not a shorter one: CountryMap.vue:122-125 also escapes the two quote characters, // and this string is interpolated into href="" and alt="" attributes below, where omitting them is