Point the cross the right way up, and let the fit follow its container

The cross drew inverted. The projection computes Y = 0.5 - v*K, so a larger v is
a HIGHER latitude, and the parts were named on the screen convention where +y
goes down — which put the long arm at the top. That is a different symbol, not a
cosmetic slip. The parts are named by compass now, and the test asserts in
latitude after projection rather than in layout units, which is the only space
where the question means anything.

The fit was bit-identical through three attempts — more padding, a resize, a
refit on idle — and that was the evidence. None of them was wrong; none of them
was the moment the box changed size. These panels are v-show, so a map can be
built while its container is display:none and therefore zero-sized, and the
engine keeps the size it measured then. A fit computed against that lands at the
wrong zoom, which is why one layout opened at 0.64 while an almost identically
shaped one opened at 0.31.

A ResizeObserver is the only thing that knows when the box became real, so the
fit follows the container instead of guessing at a moment, and skips while the
box is still hidden. The failure is reported rather than swallowed, for the same
reason as the cross layer before it.
This commit is contained in:
Doxa Maps 2026-09-28 22:13:28 +00:00
parent 5681f9db22
commit fc589cef1c
13 changed files with 76 additions and 43 deletions

View file

@ -35,6 +35,7 @@ const board = useBoardState()
const el = ref(null)
const mapRef = shallowRef(null)
const handle = shallowRef(null)
let fitRo = null
const failed = ref('')
const R = 120, SPREAD = 260
@ -110,18 +111,31 @@ onMounted(async () => {
// unchanged while the others improved.
// map.resize() makes the engine re-read the container; fitting on the next frame lets the
// flex layout finish first. Both are cheap and neither depends on a timer.
// ★ REFIT WHENEVER THE BOX CHANGES SIZE, because a one-shot fit cannot know when that is.
// These panels are v-show, so a map can be created while its container is display:none and
// therefore zero-sized; the engine keeps whatever size it measured then, and a fit computed
// against it lands at the wrong zoom. That is why one layout opened at 0.64 while an almost
// identically-shaped one opened at 0.31 — and why raising padding, resizing once and
// refitting on idle all left it BIT-IDENTICAL: none of them was the moment the box changed.
// A ResizeObserver is the only thing that knows, so the fit follows the container instead of
// guessing at a moment.
const fitTarget = () => {
try {
const box = el.value
if (!box || !box.clientWidth || !box.clientHeight) return // still hidden: wait to be shown
map.resize()
const [w, sLat] = project(-SPREAD, -SPREAD)
const [e, nLat] = project(SPREAD, SPREAD)
map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
} catch (_) { /* a fit that cannot be computed leaves the default view, which still draws */ }
} catch (e) {
console.warn('[gospel-internet] could not fit the chart to its box:', e && e.message || e)
}
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fitTarget)
else fitTarget()
// and again once the engine settles, for a container that was still resolving on that frame
try { map.once('idle', fitTarget) } catch (_) {}
fitTarget()
try {
fitRo = new ResizeObserver(fitTarget)
if (el.value) fitRo.observe(el.value)
} catch (_) { /* no ResizeObserver: the initial fit stands */ }
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
// createBoardMap only resolves after `await map.once('load')`, so by here the event HAS fired.
@ -138,7 +152,7 @@ onMounted(async () => {
})
watch(data, (d) => { try { mapRef.value?.getSource('gbsm')?.setData(d) } catch (_) {} })
onBeforeUnmount(() => { try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
onBeforeUnmount(() => { try { fitRo?.disconnect() } catch (_) {} ; try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
useShadowStyles(
'.gbsm{position:absolute;inset:0;min-height:0;min-width:0}' +

View file

@ -41,6 +41,7 @@ const board = useBoardState()
const el = ref(null)
const mapRef = shallowRef(null)
const handle = shallowRef(null)
let fitRo = null
const ready = ref(false)
const failed = ref('')
@ -125,18 +126,31 @@ onMounted(async () => {
// unchanged while the others improved.
// map.resize() makes the engine re-read the container; fitting on the next frame lets the
// flex layout finish first. Both are cheap and neither depends on a timer.
// ★ REFIT WHENEVER THE BOX CHANGES SIZE, because a one-shot fit cannot know when that is.
// These panels are v-show, so a map can be created while its container is display:none and
// therefore zero-sized; the engine keeps whatever size it measured then, and a fit computed
// against it lands at the wrong zoom. That is why one layout opened at 0.64 while an almost
// identically-shaped one opened at 0.31 — and why raising padding, resizing once and
// refitting on idle all left it BIT-IDENTICAL: none of them was the moment the box changed.
// A ResizeObserver is the only thing that knows, so the fit follows the container instead of
// guessing at a moment.
const fitTarget = () => {
try {
const box = el.value
if (!box || !box.clientWidth || !box.clientHeight) return // still hidden: wait to be shown
map.resize()
const [w, sLat] = project(-EXTENT, -EXTENT)
const [e, nLat] = project(EXTENT, EXTENT)
map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
} catch (_) { /* a fit that cannot be computed leaves the default view, which still draws */ }
} catch (e) {
console.warn('[gospel-internet] could not fit the chart to its box:', e && e.message || e)
}
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fitTarget)
else fitTarget()
// and again once the engine settles, for a container that was still resolving on that frame
try { map.once('idle', fitTarget) } catch (_) {}
fitTarget()
try {
fitRo = new ResizeObserver(fitTarget)
if (el.value) fitRo.observe(el.value)
} catch (_) { /* no ResizeObserver: the initial fit stands */ }
ready.value = true
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
@ -166,7 +180,7 @@ watch(opacityExpr, (o) => {
try { mapRef.value?.setPaintProperty(id, 'circle-opacity', o) } catch (_) {}
})
onBeforeUnmount(() => { try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
onBeforeUnmount(() => { try { fitRo?.disconnect() } catch (_) {} ; try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
useShadowStyles(
'.gcsm{position:absolute;inset:0;min-height:0;min-width:0}' +

View file

@ -157,13 +157,18 @@ export function degreesToPixelRadius(degrees, { minZoom = 0, maxZoom = 22 } = {}
* else here.
*/
export function crossFeature(project, { arm = 13, cx = 0, cy = 0 } = {}) {
// Latin proportions, not a plus. Reported as reading "+ with equal arms": the upper arm was
// 0.65 of the lower, which is a real difference and too small to see at this size. The upper
// arm is now a third of the lower and the bar is narrower than the height, so the shape is
// legible as a cross at the size it is actually drawn.
const upper = arm * 0.55, lower = arm * 1.65, bar = arm * 0.62
const vert = [[cx, cy - upper], [cx, cy + lower]].map(([u, v]) => project(u, v))
const horiz = [[cx - bar, cy - upper * 0.05], [cx + bar, cy - upper * 0.05]].map(([u, v]) => project(u, v))
// ★ +v IS NORTH, NOT DOWN. The projection computes Y = 0.5 - v*K, so a LARGER v is a HIGHER
// latitude. An earlier version named its parts "upper" and "lower" on the screen convention
// where +y goes down, and therefore drew the LONG arm upward — an inverted cross, which is a
// different symbol rather than a cosmetic slip. Named by compass here so the mistake cannot be
// made silently again: `north` is toward the top of the drawing.
const H = arm * 2.2
const north = cy + H * 0.35 // the short arm, above the bar
const south = cy - H * 0.65 // the long arm, below it
const barV = cy + H * 0.18 // the bar sits high, nearer the top than the middle
const barHalf = arm * 0.62
const vert = [[cx, north], [cx, south]].map(([u, v]) => project(u, v))
const horiz = [[cx - barHalf, barV], [cx + barHalf, barV]].map(([u, v]) => project(u, v))
return {
type: 'FeatureCollection',
features: [

View file

@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn",
"integrity": "sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj",
"elements": [
"gospel-internet-board"
],
@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../gospel-internet.js" integrity="sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn" crossorigin="anonymous"></script>
<script src="../gospel-internet.js" integrity="sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj" 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

@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn",
"integrity": "sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj",
"elements": [
"gospel-internet-board"
],
@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{