Fill the chooser's frame: solve the globe's zoom instead of reusing a mercator formula

The chooser's zoom came from log2(width / 512), and the comment above it said why:
"Mercator's world is 512*2^z wide". But the chooser renders under the globe
projection, so that is the same mistake as the region pages framing too wide --
a mercator answer applied to a sphere -- only here it made the frame too SMALL.
Found by looking at the screenshot, not by any assertion: a sphere adrift in
white space.

Measured at 1200x780: the mercator answer of 1.229 put the sphere at 390.61px,
50.1% of the frame's short side, so half the frame was dead space. And the
number was inert -- zoom 0.5, 1.0 and 1.229 all render an IDENTICAL 390.61px
sphere, because Mapbox clamps the globe's apparent size at low zoom, so the
formula's output sat inside the clamp and could have been 0.5 with no visible
difference.

There is no closed form to swap in. Diameter against a clean doubling is 3.6 to
10.5% off above the clamp, because the globe-to-mercator transition is nonlinear
there. What the measurement does establish is monotonicity, which is the only
property a solver needs, so the zoom is now SOLVED against the engine: bisect
the zoom whose limb projection fills 92% of the frame's short side. project() is
pure transform math, every setZoom lands in one synchronous block before a frame
renders, the original zoom is restored in a finally, and the old mercator
formula remains the fallback because a small globe beats a broken chooser.

The first version of the solver measured pole-to-pole and called it the
diameter, which holds only when the centre is on the equator. The chooser
centres on the mean of every people group, latitude 28, so that was a CHORD:
717.6px where the sphere was really 792-835px across, understating it by 1.104x.
The solver over-zoomed and clipped the globe top and bottom -- again visible in
the screenshot while every number still looked right. It now samples the limb,
points at 90 degrees angular distance, and takes the MAX across twelve bearings,
because Mapbox's globe is a perspective camera rather than a true orthographic
one and the silhouette is not a perfect screen-space circle: 396px along the
meridian against 417.3px east and west at one zoom.

Result: the widest axis is 750.6px inside a 780px frame, the whole limb sits in
frame, and dead space falls from 49.9% to under 4%. Pins are correspondingly
bigger click targets. On a phone the ported 1/2 aspect ratio means the globe
fills the WIDTH, which is the right trade -- filling the height would crop it
horizontally and lose pins off both edges -- and that leaves half the height
empty by geometry, which is carded for a Driver decision rather than resolved by
deviating from a clone-faithful rule.

The chooser's behaviour is unchanged and re-proven after the camera moved:
_probe-chooser 15/15 and the new _probe-chooser-pin-opens-country 8/8.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 23:37:47 +00:00
parent 26892e7274
commit 78a7baaa96
7 changed files with 159 additions and 83 deletions

View file

@ -373,21 +373,97 @@ function popupHtml(p) {
// Re-frame and re-source on a new selection instead of tearing the map down: a rebuilt map
// re-downloads tiles and flashes, and the subject changes on every tab-1 click.
/**
* The chooser's zoom has to follow the CONTAINER WIDTH, not be a constant.
* The chooser's zoom has to follow the CONTAINER, and it has to be asked of the GLOBE.
*
* ★ THIS USED TO BE `log2(width / 512)` — mercator's world is 512*2^z wide, so that is the zoom
* which fits one mercator world. But the chooser renders under the GLOBE projection, and the same
* mistake that made the region pages frame too wide (a mercator answer applied to a sphere) made
* this frame too SMALL. Found by LOOKING at _shot-chooser-map-desktop.png — a sphere adrift in
* white space — not by any assertion, and then measured by
* _qa-cycle/02-builder-cycle/_probe-chooser-globe-fill.mjs at 1200x780:
*
* zoom 1.229 (what the mercator formula returned) -> sphere diameter 390.61px = 50.1% of the
* frame's short side. HALF THE FRAME WAS DEAD SPACE.
*
* Worse, the number was inert. That probe measured zoom 0.5, 1.0 and 1.229 all rendering an
* IDENTICAL 390.61px sphere, because Mapbox clamps the globe's apparent size at low zoom: the
* formula's output sat inside the clamp, so it could have returned 0.5 and changed nothing.
*
* There is no closed form to replace it with. The same probe measured the diameter against a clean
* doubling and found 3.6-10.5% error above the clamp, because the globe-to-mercator transition is
* nonlinear there — so `log2` of anything is the wrong shape. What the measurement DID establish
* is that the diameter never decreases as zoom rises, and monotonicity is the only property a
* solver needs. So SOLVE it against the engine instead of modelling it:
* bisect the zoom whose pole-to-pole projection fills FILL of the frame's short side.
*
* project() is pure transform math — no tiles, no paint, no network — and every setZoom here
* lands in ONE synchronous block before any frame renders, so the bisection is invisible; the
* original zoom is restored in a `finally` regardless. If any of it throws, the old mercator
* formula is the fallback, because a slightly small globe beats a broken chooser.
*
* MEASURED: a fixed zoom of 1.25 fills a 1200px desktop frame and shows barely a third of the
* world at 390px — Asia fell off the right edge, on the one screen whose job is "pick a region".
* Mercator's world is 512*2^z wide, so the zoom that fits it is log2(width/512): ~1.23 at 1200px,
* ~-0.39 at 390px. Mapbox will not go below 0, so a phone still crops, but ~24% instead of ~67%,
* and the map pans. Clamped to [0, 2] so a very wide embed does not zoom past the useful range.
* Only the chooser uses this; a selected page is framed by their own bounds/centre/zoom.
*/
const CHOOSER_FILL = 0.92 // leave a margin so the limb is never clipped by the frame
function chooserZoom(m) {
if (props.colorMode !== 'region') return props.zoom
let w = 0
try { w = m.getContainer()?.clientWidth || 0 } catch (_) { w = 0 }
if (!w) return props.zoom
return Math.max(0, Math.min(2, Math.log2(w / 512)))
let W = 0, H = 0
try { const el = m.getContainer(); W = el?.clientWidth || 0; H = el?.clientHeight || 0 } catch (_) { W = 0 }
if (!W) return props.zoom
const mercatorFallback = Math.max(0, Math.min(2, Math.log2(W / 512)))
if (!H) return mercatorFallback
const z0 = m.getZoom()
try {
const c = m.getCenter()
// ★ THE LIMB, NOT THE POLES. The first version of this measured pole-to-pole and called it the
// diameter, which is only true when the centre sits on the equator. The chooser centres on the
// mean of every people group -- latitude 28 -- so pole-to-pole is a CHORD: measured 717.6px
// where the sphere was really 792-835px across, understating it by 1.104x. The solver therefore
// over-zoomed and the globe was clipped top and bottom, which the screenshot showed plainly
// while every number still looked right.
// Under the globe the silhouette is centred on the map centre, so any point at 90 deg ANGULAR
// distance lies on the limb. Sample several bearings and take the MAX, because Mapbox's globe
// is a perspective camera rather than a true orthographic one, so the silhouette is not a
// perfect screen-space circle -- measured radius 396px along the meridian against 417.3px east
// and west at the same zoom. Taking the max is what keeps the widest axis inside the frame.
const RAD = Math.PI / 180, DEG = 180 / Math.PI
const phi0 = c.lat * RAD
// Great-circle destination at angular distance 90 deg on bearing t, which reduces to this
// because cos(90 deg) is 0 and sin(90 deg) is 1.
const limbPoints = []
for (let i = 0; i < 12; i++) {
const t = (i / 12) * 2 * Math.PI
const lat = Math.asin(Math.cos(phi0) * Math.cos(t))
const lng = c.lng + Math.atan2(Math.sin(t) * Math.cos(phi0), -Math.sin(phi0) * Math.sin(lat)) * DEG
limbPoints.push([lng, lat * DEG])
}
const diameter = (z) => {
m.setZoom(z)
const ctr = m.project([c.lng, c.lat])
let max = 0
for (const pt of limbPoints) {
const q = m.project(pt)
const d = Math.hypot(q.x - ctr.x, q.y - ctr.y)
if (Number.isFinite(d) && d > max) max = d
}
return max * 2
}
const want = Math.min(W, H) * CHOOSER_FILL
let lo = 0, hi = 5
if (diameter(hi) < want) return hi // frame bigger than the globe can fill
for (let i = 0; i < 24; i++) { // 24 halvings of [0,5] resolves to ~3e-7 zoom
const mid = (lo + hi) / 2
if (diameter(mid) < want) lo = mid; else hi = mid
}
const solved = (lo + hi) / 2
return Number.isFinite(solved) ? solved : mercatorFallback
} catch (e) {
console.warn('[RegionCountryMap] globe fill solve failed, using the mercator fit:', e && e.message)
return mercatorFallback
} finally {
// Never leave the camera parked on a probe value.
try { m.setZoom(z0) } catch (_) {}
}
}
/**

File diff suppressed because one or more lines are too long

View file

@ -879,7 +879,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-yxWIwZyBb6+xve6pVbhxlLEJn4diVUhzV0fZReHWRYgdIb8U0OZUrAi7SEu9DnpI",
"integrity": "sha384-MM2p8wtopS2RP5NbieFf+KvoBRT6yHnPCHaL/UQ7hxpJdVVc1o7BpXVwEF9PGPJz",
"elements": [
"regions-dashboard"
],
@ -899,7 +899,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-yxWIwZyBb6+xve6pVbhxlLEJn4diVUhzV0fZReHWRYgdIb8U0OZUrAi7SEu9DnpI\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-MM2p8wtopS2RP5NbieFf+KvoBRT6yHnPCHaL/UQ7hxpJdVVc1o7BpXVwEF9PGPJz\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -913,7 +913,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-yxWIwZyBb6+xve6pVbhxlLEJn4diVUhzV0fZReHWRYgdIb8U0OZUrAi7SEu9DnpI\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-MM2p8wtopS2RP5NbieFf+KvoBRT6yHnPCHaL/UQ7hxpJdVVc1o7BpXVwEF9PGPJz\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -927,7 +927,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-yxWIwZyBb6+xve6pVbhxlLEJn4diVUhzV0fZReHWRYgdIb8U0OZUrAi7SEu9DnpI\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-MM2p8wtopS2RP5NbieFf+KvoBRT6yHnPCHaL/UQ7hxpJdVVc1o7BpXVwEF9PGPJz\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-yxWIwZyBb6+xve6pVbhxlLEJn4diVUhzV0fZReHWRYgdIb8U0OZUrAi7SEu9DnpI" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-MM2p8wtopS2RP5NbieFf+KvoBRT6yHnPCHaL/UQ7hxpJdVVc1o7BpXVwEF9PGPJz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-yxWIwZyBb6+xve6pVbhxlLEJn4diVUhzV0fZReHWRYgdIb8U0OZUrAi7SEu9DnpI" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-MM2p8wtopS2RP5NbieFf+KvoBRT6yHnPCHaL/UQ7hxpJdVVc1o7BpXVwEF9PGPJz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-yxWIwZyBb6+xve6pVbhxlLEJn4diVUhzV0fZReHWRYgdIb8U0OZUrAi7SEu9DnpI" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-MM2p8wtopS2RP5NbieFf+KvoBRT6yHnPCHaL/UQ7hxpJdVVc1o7BpXVwEF9PGPJz" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: