The popup pan clears the map's bottom controls, not just the clip

Clearing the clip is not the same as being clickable. 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. Both pins panned, both looked fixed, neither was.

Neither the popup nor the control is at fault. 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.

panPlanFor now takes a bottomInset and fits against containerH - bottomInset.
On the measured case the plan changes from -77 (pan up, popup below the pin,
buttons under the attribution) to +119 (pan down, popup above the pin, clear of
the band) -- the planner flips direction because with the band reserved, fitting
above became the smaller move. That is the existing "pick the smaller move" rule
doing its job on a corrected frame, not a new rule.

The inset is 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 band.
Only controls that intercept pointer events are measured: the corner container
is pointer-events:none and spans nearly the full map width at 390, so measuring
the container would over-reserve the whole strip.

Unchanged where it was already right: a pin whose anchor fits still returns 0
(rega measured pan 0 on those in both arms), a popup taller than the usable
frame still refuses rather than jumping the camera, and bottomInset 0 is
byte-identical to the previous behaviour.

7 probe cases, with the post-pan fit asserted from the geometry rather than by
re-calling the planner -- a self-consistent oracle cannot see an error that
shifts both sides.
This commit is contained in:
Doxa Maps 2026-09-29 11:56:18 +00:00
parent 79b02f962a
commit 46266a7ecc

View file

@ -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 */ } }
}