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:
Doxa Maps 2026-09-29 09:18:56 +00:00
parent f29df44140
commit a71cc4b5e8

View file

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