Pan the map when a popup would open half outside it
The popup is built with no explicit anchor, so the engine puts 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 at their centres then lands on the page behind, though a scripted click still navigates -- so the buttons look present and are not reachable. Moving the PIN is what makes an anchor fit, so the map pans rather than the popup changing. Its design is untouched, which is what keeps this a clone; it is what Leaflet does by default. The alternative, a shorter popup, would have been a visible departure and a product decision. The pan is the smallest one that works, and most pins need none: on a 780px map only a narrow band moves at all, by 74px at the worst position rather than a jump. It is animated over 200ms. It resolves at every width this component renders at. The container is max(780, 16/9 of width) at >=768px and twice the width below that, so even at 320px wide there is 640px of container against a 452px popup -- 164px of headroom, and the popup would have to grow by that much before any width could overflow. So no inner scroll and no residual case. It returns 0 rather than panning when the popup is TALLER than its container: a host can give the map a short slot, and there a pan would only move the clipping around. The failure stays what it is today instead of becoming a clipped popup and a camera that jumped. Unmeasurable inputs -- a map mid-load, a zero-height container, an unprojectable point -- also return 0 rather than throwing, and the pan itself is wrapped, because a pan is a nicety and must never break a click. The offset lives in one constant now. The pan arithmetic has to use the same number the popup was built with, and two literals drift. Proven against the shipping function, lifted out of this file by source rather than restated: 16 checks, including every pin position at 320, 390, 768 and 1440px wide -- none left clipped. The fit check is stated independently of the function, because an earlier version asked the function whether it had succeeded, and that hid a dropped offset: the pan was 12px short and its own test 12px lax, so the two agreed while the button still clipped. Mutation-tested four ways -- dropped offset, zeroed offset constant, flipped sign, removed squeeze guard -- each caught. Both consumers rebuilt: country-map +623 B, regions-dashboard +672 B.
This commit is contained in:
parent
f29df44140
commit
a71cc4b5e8
1 changed files with 52 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue