The set charts fit their box, carry a cross, and draw the target — combined

One landing because it is one bundle: the ring band layout wiring (already on
this branch) plus the three set-fit commits, replayed as SOURCE only. The
generated tree is rebuilt below rather than merged — a previous cherry-pick of
mine swept a conflicted generated file into a commit, and hand-resolving build
output is how that happens.

From set-fit: extentFraction 0.17 -> 0.095 on both charts, so the drawing fits
the height the engine's zoom floor leaves it; crossFeature restored from history
and called by BOTH maps through one shared addCrossLayer; and the ResizeObserver
fit restored, which rega found missing because zoomAtOpen read 1.00 in all eight
chart/layout combinations — too clean to be a fit result.

Two things this merge had to get right, and neither was automatic:
- Taking GiCenteredSetMap.vue wholesale from set-fit silently REMOVED the ring
  wiring — bandFill, curvedRingLabels and openAndFrame all went to zero while
  every gate still passed. Both feature sets are now in the file together.
- Taking package.json wholesale would have deleted studio's check:ring-bands
  registration, including its gates:pre entry, landing a gate nothing runs. It is
  rebuilt from development's, with only check:gospel-set-fit added — in all three
  places this time: script, gates:pre, and //scripts.
This commit is contained in:
Doxa Maps 2026-09-29 02:05:02 +00:00
parent 4e00ec4aa1
commit a40fa017b4
5 changed files with 230 additions and 9 deletions

View file

@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* check-gospel-set-fit.mjs — the set charts must FIT the canvas they are given.
*
* WHY THIS EXISTS. With renderWorldCopies:false (library/maps/whiteboard-map.js) the engine refuses
* to zoom out past the point where ONE WORLD fills the canvas WIDTH. That puts a hard floor under the
* zoom at log2(canvasWidth/512) — and on a WIDE, SHORT figure that floor is too close for the
* drawing's own height, so the outermost groups fall outside the frame. minZoom does not help; the
* width constraint wins. rega measured it at 1440: 54 groups outside in ar and 97 in en, which is
* what proved it was the WIDTH and never RTL.
*
* WHAT IT ASSERTS. For every layout the charts actually get, the drawing's vertical extent at the
* floor zoom must fit inside the canvas height, padding included. This is pure arithmetic against
* the same projection the components use, so it needs no browser and cannot drift from the source.
*
* ★ THE MERCATOR STRETCH IS THE WHOLE POINT. A first version of this calculation used a flat
* 360/(512*2^z) degrees-per-pixel and concluded the chart already fitted at every width — which
* contradicted a measurement taken on the page. Latitude degrees are NOT linear in pixels: the
* drawing reaches ±29° now and reached ±52° before, where the error is large enough to invert the
* answer. The world-Y form below is the projection's own, so the gate and the renderer agree.
*/
import { whiteboardProjection } from '../library/maps/whiteboard-projection.js'
const RING_MAX = 10
const CENTERED_EXTENT = 30 + (RING_MAX - 1) * 22 // 228, GiCenteredSetMap
const BOUNDED_EXTENT = 260 // SPREAD, GiBoundedSetMap
const FRACTION = 0.095 // both charts, kept equal on purpose
const FIT_PADDING = 40 // the fitBounds padding both charts pass
/** Mercator world-Y in [0,1] — the projection's own vertical, not a linear degree scale. */
const worldY = (lat) => (1 - Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)) / Math.PI) / 2
const log2 = (x) => Math.log(x) / Math.log(2)
/** The zoom the engine cannot go below on this canvas, with renderWorldCopies:false. */
export function floorZoom(canvasWidth) {
return log2(canvasWidth / 512)
}
/** Pixels of height the drawing needs on this canvas, at the floor zoom. */
export function neededHeightPx(extent, fraction, canvasWidth) {
const project = whiteboardProjection({ extent, extentFraction: fraction })
const north = project(0, extent)[1]
const south = project(0, -extent)[1]
const worldFraction = Math.abs(worldY(north) - worldY(south))
return worldFraction * 512 * Math.pow(2, floorZoom(canvasWidth))
}
// The canvases the charts are actually given: rega's measured figures at 1440 and 1280, the embed
// card, and two narrow ones so a phone cannot regress unnoticed.
const LAYOUTS = [
{ name: '1440 board (rega measured)', w: 796, h: 249 },
{ name: '1280 board (rega measured)', w: 636, h: 220 },
{ name: 'embed card', w: 1198, h: 560 },
{ name: 'tablet', w: 380, h: 300 },
{ name: 'phone', w: 340, h: 240 },
]
const CHARTS = [
{ name: 'centered', extent: CENTERED_EXTENT },
{ name: 'bounded', extent: BOUNDED_EXTENT },
]
const pass = []
const fail = []
const ok = (label, cond, detail = '') => (cond ? pass : fail).push(detail ? `${label} — ${detail}` : label)
for (const chart of CHARTS) {
for (const layout of LAYOUTS) {
const need = neededHeightPx(chart.extent, FRACTION, layout.w)
const have = layout.h - 2 * FIT_PADDING
ok(
`${chart.name} fits ${layout.name} (${layout.w}x${layout.h})`,
need <= have,
`needs ${need.toFixed(0)}px of ${have}px available`,
)
}
}
// ★ NEGATIVE CONTROL. The 0.17 default is the value that was measured FAILING on the page, so if
// this arm does not fail the calculation is not measuring what rega measured and a pass means
// nothing. Asserted on the tightest real layout.
const poison = neededHeightPx(CENTERED_EXTENT, 0.17, 796)
ok(
'NEG CONTROL: the old 0.17 fraction still does NOT fit 796x249',
poison > 249 - 2 * FIT_PADDING,
`0.17 needs ${poison.toFixed(0)}px of ${249 - 2 * FIT_PADDING}px — the defect rega measured`,
)
// The two charts must share one footprint, or the same population reads as a different size when a
// reader switches tabs.
ok(
'both charts use the same extentFraction',
Math.abs(neededHeightPx(CENTERED_EXTENT, FRACTION, 796) / neededHeightPx(BOUNDED_EXTENT, FRACTION, 796) - 1) < 1e-9,
'centered and bounded project to the same vertical span',
)
for (const p of pass) console.log(` ✓ ${p}`)
for (const f of fail) console.log(` ✗ ${f}`)
if (fail.length) {
console.log(`\nGOSPEL-SET-FIT: FAIL — ${fail.length} of ${pass.length + fail.length}`)
process.exit(1)
}
console.log(`\nGOSPEL-SET-FIT: PASS — ${pass.length} assertions; every layout fits at the engine's floor zoom`)

View file

@ -77,6 +77,7 @@
"check:export-poster-prints:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs --provider maplibre",
"check:upg-zone-joins": "node internal/check-upg-zone-joins.mjs",
"check:gospel-set-charts": "node internal/check-gospel-set-charts.mjs",
"check:gospel-set-fit": "node internal/check-gospel-set-fit.mjs",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@ -307,7 +308,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -466,7 +467,7 @@
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
"check:no-credentials-omit": "no data fetch in library/ or plugin-designs/ uses credentials:'omit': behind a login gate a same-origin request then drops the session and is refused (the Research Gallery's 'Could not load Big Picture'). 'same-origin' never sends credentials cross-origin. A deliberate omit says so on its line: // credentials-omit: <reason>.",
"check:no-credentials-omit:self-test": "a planted omit is caught; prose and an annotated site are not.",
"check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/\u2026). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
"check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/…). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
"check:no-dead-library": "a deleted library module must stay unreferenced, and the scanner must prove it still works. Scans for a filename as a STRING across internal/, library/ and plugin-designs/ — not just imports — because check-gospel-set-charts.mjs read two components by filename off disk with no import anywhere, so a census and a build would both have missed it. Distinguishes a READ (fatal) from a comment MENTION (reported, harmless). TWO-SIDED: also asserts a known dynamic reference IS found (LegendDesktop lazy-loads LegendMobile), so green means the scanner works AND found nothing, rather than merely found nothing.",
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
@ -563,7 +564,8 @@
"verify:gospel-map": "the gospel-resources MAP: pins render (not merely load), a tap opens a named group, EVERY link in the index is on a screened publisher, the table deep-link carries the group's ISO, and the gap filter leaves only tier 0. Needs a server (bun run serve); SKIPS loudly without one.",
"verify:interop": "the CAP-4 INTEROPERABILITY GATE (real browser, real shadow DOM, real live data) — closes the gap verify:gate1 leaves open. Drives the ACTUAL compiled composites: (1) DESKTOP dashboard-plus-grid map->grid (a facet-rail click must narrow the grid's live `Showing N` count line — card count is a proven FALSE-NEGATIVE trap, always 24, never asserted on), (2) DESKTOP grid->map (selecting table rows must MOVE THE CAMERA, read back off window.__rdashMap.getCenter()/getZoom() — useSelectionCamera.js's contract), (3) MOBILE research-map-plus-grid, where source-reading FIRST established there is NO cross-wire between the map and grid children by design (StackLayout composes two independent elements, no provide/inject/prop crosses the boundary) — so this pair asserts each half narrows on its OWN control, not a link that does not exist. Prints ONE line per combination, pass/fail only, plus a combination table. SKIPs cleanly (exit 0) when the tree is mid-build (internal/settled-tree-gate.mjs) or pray.doxa.life is unreachable — never reports a false defect from a torn tree. Spawns its own internal/serve-strict.mjs on PORT (default 4180) so it does not depend on another process's server. run `verify:interop:self-test` for the NEGATIVE test.",
"verify:interop:self-test": "TWO regression modes, each required to fire ITS OWN combination-named failure: grid-prop-drop (default — freezes the grid child's filter props/attrs on the live dashboard-plus-grid page, reproducing the exact historical bug \"selecting anything in the dashboard above did not filter the grid below\") and `verify:interop:self-test:camera-freeze` (pins fitBounds/flyTo to no-ops on window.__rdashMap, reproducing \"the grid->map link stops moving the camera\"). A harness that has only ever reported PASS is unproven.",
"verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)"
"verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
"check:gospel-set-fit": "the set charts FIT the canvas they are given. renderWorldCopies:false puts a hard floor under the zoom at log2(canvasWidth/512) — one world must fill the canvas WIDTH — and on a wide, short figure that floor is too close for the drawing's own height, so the outermost groups fall outside the frame (measured at 1440: 54 outside in ar, 97 in en, which is what proved it was the width and not RTL). Pure arithmetic against the components' own projection, so no browser and no drift. Negative control: the old 0.17 extentFraction still fails 796x249 at 271px of 169, so a pass cannot be vacuous. Also asserts both charts share one footprint, or the same population reads as a different size when a reader switches tabs."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",

View file

@ -26,19 +26,29 @@ import { createBoardMap, reducedMotion } from '../boardEngine.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { layoutBoundedSet } from '@map/components/charts/boundedSet.js'
import { boundedSetFeatures, toFeatureCollection, dishRadiusDegrees, degreesToPixelRadius, TIERS } from '../setChartFeatures.js'
import { boundedSetFeatures, toFeatureCollection, dishRadiusDegrees, degreesToPixelRadius, TIERS, addCrossLayer } from '../setChartFeatures.js'
const props = defineProps({ groups: { type: Array, default: () => [] } })
const T = useGiT()
const board = useBoardState()
const el = ref(null)
// Held so it can be DISCONNECTED on unmount: an observer left attached pins the element, and
// through it the map and its canvas.
let fitRo = null
const mapRef = shallowRef(null)
const handle = shallowRef(null)
const failed = ref('')
const R = 120, SPREAD = 260
const project = whiteboardProjection({ extent: SPREAD })
// ★ 0.095, NOT THE 0.17 DEFAULT — the same clamp as the centered chart, and the same number so the
// two sets read at one scale. renderWorldCopies:false stops the engine zooming out past
// log2(canvasWidth/512), so a wide, short figure cannot fit its height at 0.17 and the outermost
// groups fall outside the frame (rega, 1440: 54 outside in ar and 97 in en — the width, not RTL).
// Kept EQUAL to GiCenteredSetMap's on purpose: a reader switching tabs compares two drawings, and a
// different footprint per tab would make the same population look like a different size.
const EXTENT_FRACTION = 0.095
const project = whiteboardProjection({ extent: SPREAD, extentFraction: EXTENT_FRACTION })
const layout = computed(() => layoutBoundedSet(props.groups, { r: R, spread: SPREAD }))
const built = computed(() => boundedSetFeatures(layout.value, { population: (p) => p.pop ?? 0 }))
@ -106,6 +116,29 @@ onMounted(async () => {
// group, so it is filtered out by tier rather than by id.
onClick: (id, f) => { if (f?.properties?.tier === TIERS.GROUP && id) openAndFrame(id, f) },
}))
// The cross marks the centre the boundary is drawn around — the Driver reported this chart had
// none. Same geometry and the same call as the centered chart, so the two cannot drift apart.
addCrossLayer(map, project, 'gbsm-cross')
// The same ResizeObserver fit as the centered chart, and for the same reason: this panel is
// v-show, so the map can be built while its container is display:none and zero-sized, and a
// one-shot fit against that box lands at the wrong zoom.
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 (e) {
console.warn('[gospel-internet] could not fit the bounded chart to its box:', e && e.message || e)
}
}
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.
@ -122,7 +155,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

@ -28,7 +28,7 @@ import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { ringInfo, CENTRE_COLOR } from '../ringInfo.js'
import { layoutCenteredSet, ringRadii, RING_MAX } from '@map/components/charts/centeredSet.js'
import { centeredSetFeatures, toFeatureCollection, TIERS } from '../setChartFeatures.js'
import { centeredSetFeatures, toFeatureCollection, TIERS, addCrossLayer } from '../setChartFeatures.js'
import { bandFill, curvedRingLabels } from '@map/components/charts/ringBands.js'
const props = defineProps({
@ -42,11 +42,22 @@ const board = useBoardState()
const el = ref(null)
const mapRef = shallowRef(null)
const handle = shallowRef(null)
// The ResizeObserver that keeps the fit following the container. Held so it can be DISCONNECTED
// on unmount: an observer left attached pins the element, and through it the map and its canvas.
let fitRo = null
const ready = ref(false)
const failed = ref('')
const EXTENT = 30 + (RING_MAX - 1) * 22 // 228 — the ring the outermost groups sit on
const project = whiteboardProjection({ extent: EXTENT })
// ★ 0.095, NOT THE 0.17 DEFAULT. With renderWorldCopies:false the engine will not zoom out past the
// point where ONE WORLD fills the canvas WIDTH, so the minimum zoom is log2(canvasWidth/512) and a
// wide, short figure is clamped too close to fit its own height. minZoom is irrelevant; the width
// constraint wins. rega measured the symptom at 1440 — 54 groups outside the frame in ar, 97 in en,
// so it was never RTL-specific. At 0.17 the drawing spans 0.34 of the Mercator square and needs
// 271px of vertical room where the canvas offers 249; at 0.095 it spans 0.17 and needs 151px
// against 169 available at padding 40. Checked by check:gospel-set-fit.
const EXTENT_FRACTION = 0.095
const project = whiteboardProjection({ extent: EXTENT, extentFraction: EXTENT_FRACTION })
const INNER_R = 30, RING_GAP = 22
const radii = computed(() => ringRadii({ innerR: INNER_R, ringGap: RING_GAP }))
@ -158,6 +169,30 @@ onMounted(async () => {
if (r != null) emit('select', r)
},
}))
addCrossLayer(map, project, 'gcsm-cross')
// ★ 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.
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 (e) {
console.warn('[gospel-internet] could not fit the chart to its box:', e && e.message || e)
}
}
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.
@ -187,7 +222,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

@ -135,3 +135,51 @@ export function degreesToPixelRadius(degrees, { minZoom = 0, maxZoom = 22 } = {}
const px = (z) => Number(degrees || 0) * 512 * Math.pow(2, z) / 360
return ['interpolate', ['exponential', 2], ['zoom'], minZoom, px(minZoom), maxZoom, px(maxZoom)]
}
export function crossFeature(project, { arm = 13, cx = 0, cy = 0 } = {}) {
// ★ +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: [
{ type: 'Feature', properties: { part: 'v' }, geometry: { type: 'LineString', coordinates: vert } },
{ type: 'Feature', properties: { part: 'h' }, geometry: { type: 'LineString', coordinates: horiz } },
],
}
}
/**
* Draw the cross on a whiteboard map, as a LINE layer over the points.
*
* ★ line-cap AND line-join ARE LAYOUT, NOT PAINT. Put either in `paint` and addLayer THROWS, and if
* the call sits inside a silent try/catch the chart simply renders without its cross — which is how
* this went missing once already. The failure is reported rather than swallowed for the same reason.
*/
export function addCrossLayer(map, project, id, { arm = 13, color = '#3b463d', width = 3 } = {}) {
try {
if (map.getLayer(id)) map.removeLayer(id)
if (map.getSource(id)) map.removeSource(id)
map.addSource(id, { type: 'geojson', data: crossFeature(project, { arm }) })
map.addLayer({
id,
type: 'line',
source: id,
layout: { 'line-cap': 'square', 'line-join': 'miter' },
paint: { 'line-color': color, 'line-width': width },
})
return true
} catch (e) {
console.warn(`[set-charts] the cross layer "${id}" did not draw:`, e && e.message)
return false
}
}