From 46a12f1769002803cfbf246a4dc1655c3d9e0680 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 20:43:21 +0000 Subject: [PATCH 1/4] Give the charts a real centre column at 768, and fix what that exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board collapsed to a single panel below 760px; the set charts stacked their legend below 900. Between those widths the board still held three columns while a chart inside it had already given up on having room — at 768 the centre column was about 145px, so one figure was crushed to 124 and the other's own minimum made it spill under the right-hand panel. Neither number was wrong alone; the defect was that they were two numbers, with nothing connecting the width at which a chart needs room to the width at which the board gives it any. They are one constant now. Ring labels take four anchors instead of one. A single label at the top meant a reader zooming anywhere else on a ring saw no ring name, which is most of the ring and certainly wherever the dots are dense. The engine drops the extras when they would collide, so low zoom is unchanged. The cross is drawn as geometry rather than as a text glyph. It was a symbol whose label was the character itself, and the layer rendered zero features: a glyph appears only if the font has it, and nothing reports a missing one. Two line segments depend on no font, and are the same mark in every locale — including the right-to-left pages, where a text cross would also have been subject to bidi reordering. Both charts fit their whole extent on load. The default zoom clipped the outer rings top and bottom, so the reader met a drawing already cut off — which reads as a rendering fault rather than as something to zoom out of. --- plugin-designs/gospel-internet/board.vue | 5 +- .../gospel-internet/src/breakpoints.js | 18 ++++++++ .../src/components/GiBoundedSetMap.vue | 28 +++++++++-- .../src/components/GiCenteredSet.vue | 6 ++- .../src/components/GiCenteredSetMap.vue | 19 +++++++- .../gospel-internet/src/setChartFeatures.js | 36 ++++++++++++++- public/dx-maps/doxa-maps-spa/manifest.json | 4 +- .../gospel-internet/board/board/index.html | 2 +- .../dx-maps/gospel-internet/board/index.html | 2 +- .../board/india-china/index.html | 2 +- .../board/language-tree/index.html | 2 +- .../board/platforms/index.html | 2 +- .../board/project-700/index.html | 2 +- .../gospel-internet/board/zero/index.html | 2 +- .../gospel-internet/gospel-internet.js | 46 +++++++++---------- public/dx-maps/manifest.json | 4 +- 16 files changed, 135 insertions(+), 45 deletions(-) create mode 100644 plugin-designs/gospel-internet/src/breakpoints.js diff --git a/plugin-designs/gospel-internet/board.vue b/plugin-designs/gospel-internet/board.vue index 8a4d6ddad..2d2e62e2b 100644 --- a/plugin-designs/gospel-internet/board.vue +++ b/plugin-designs/gospel-internet/board.vue @@ -39,6 +39,7 @@ import GiDrawer from './src/components/GiDrawer.vue' import GiTree from './src/components/GiTree.vue' import GiResources from './src/components/GiResources.vue' import GiCombos from './src/components/GiCombos.vue' +import { isNarrowWidth } from './src/breakpoints.js' const T = useGiT() const board = useBoard() @@ -76,7 +77,7 @@ board.readUrl() // partner's address bar is never rewritten. facetKeys is explicit: its default reads nothing on a cold load. useShareableView(bus, { mode: 'query', write: board.profileConfig.value?.urlState === 'query', facetKeys: SHARE_FACETS }) -// ── responsive: one panel at a time under 760px (ResizeObserver on the shell, not @container: a +// ── responsive: one panel at a time under the shared breakpoint (ResizeObserver on the shell, not @container: a // container query cannot target the container itself, which is unreliable inside a shadow root) ────── const shell = ref(null) const isNarrow = ref(false) @@ -84,7 +85,7 @@ const mobilePanel = ref('map') let ro = null onMounted(() => { if (shell.value && typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(([e]) => { isNarrow.value = e.contentRect.width < 760 }) + ro = new ResizeObserver(([e]) => { isNarrow.value = isNarrowWidth(e.contentRect.width) }) ro.observe(shell.value) } board.load() diff --git a/plugin-designs/gospel-internet/src/breakpoints.js b/plugin-designs/gospel-internet/src/breakpoints.js new file mode 100644 index 000000000..61bdaefc2 --- /dev/null +++ b/plugin-designs/gospel-internet/src/breakpoints.js @@ -0,0 +1,18 @@ +/** + * breakpoints.js — the one width at which this dashboard stops being a three-column board. + * + * ★ IT IS A CONSTANT BECAUSE IT WAS TWO NUMBERS. board.vue collapsed to a single panel below + * 760px; the set charts stacked their legend below 900px. Between those two widths the board + * still held three columns while a chart inside it had already given up on having room — at + * 768 the centre column was about 145px, so the bounded figure was crushed to 124px and the + * centered figure's own minimum made it spill UNDER the right-hand panel. + * + * Neither number was wrong on its own. The defect was that they were separate, and nothing + * connected the width at which a chart needs room to the width at which the board gives it any. + */ +export const NARROW_MAX = 900 + +/** True when the shell is too narrow for side panels beside the centre column. */ +export function isNarrowWidth(px) { + return Number(px) < NARROW_MAX +} diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue index d9a003c4f..b02351bca 100644 --- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue +++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue @@ -26,7 +26,7 @@ import { createBoardMap } 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, crossFeature, TIERS } from '../setChartFeatures.js' const props = defineProps({ groups: { type: Array, default: () => [] } }) @@ -43,11 +43,13 @@ const project = whiteboardProjection({ extent: SPREAD }) const layout = computed(() => layoutBoundedSet(props.groups, { r: R, spread: SPREAD })) const built = computed(() => boundedSetFeatures(layout.value, { population: (p) => p.pop ?? 0 })) -// The cross is a feature, not an overlay, so it moves with the drawing. It is its own tier so -// the label layer renders it at every zoom while group names stay hidden until you are close. +// The cross is drawn as GEOMETRY after the layers (see crossFeature), not as a label feature. +// It was a symbol whose text was the character itself, and the layer rendered zero features +// because a glyph only appears if the font has it — and nothing reports a missing one. +// One point feature is still needed at the centre, because the dish anchors to it. const features = computed(() => [ ...built.value.features, - { id: '__centre', label: '✝', tier: 'centre', k: 3e9, u: 0, v: 0 }, + { id: '__centre', label: '', tier: 'anchor', k: 3e9, u: 0, v: 0 }, ]) const data = computed(() => toFeatureCollection(features.value, project).collection) @@ -70,7 +72,6 @@ onMounted(async () => { data: data.value, sourceId: 'gbsm', tiers: [ - { tier: 'centre', minzoom: 0, weight: 'bold', offset: 0 }, { tier: TIERS.GROUP, minzoom: 5.2, weight: 'regular', offset: 0.7 }, ], // Inside and outside are the whole claim, so they are the whole palette. @@ -85,6 +86,23 @@ onMounted(async () => { }, font: labelFont, })) + + // The cross, as geometry. See crossFeature: a text glyph silently drew nothing. + try { + map.addSource(`${'gbsm'}-cross`, { type: 'geojson', data: crossFeature(project) }) + map.addLayer({ id: `${'gbsm'}-cross`, type: 'line', source: `${'gbsm'}-cross`, + paint: { 'line-color': '#3b463d', 'line-width': 3, 'line-cap': 'square' } }) + } catch (_) { /* the cross is a mark, not the data: never block the chart on it */ } + + // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom — + // the reader met a drawing already cut off, which reads as a rendering fault rather than as + // something to zoom out of. Fitted from the projected extent, so it follows the chart's own + // size rather than a guessed zoom. + try { + const [w, sLat] = project(-SPREAD, -SPREAD) + const [e, nLat] = project(SPREAD, SPREAD) + map.fitBounds([[w, sLat], [e, nLat]], { padding: 24, duration: 0 }) + } catch (_) {} } // ★ 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. diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue index 270544426..626880516 100644 --- a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue +++ b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue @@ -28,6 +28,7 @@ import { layoutCenteredSet, ringTally, inwardArrow, ringRadii } from '@map/compo import GiRingLegend from './GiRingLegend.vue' import GiCenteredSetMap from './GiCenteredSetMap.vue' import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue' +import { NARROW_MAX } from '../breakpoints.js' const T = useGiT() const board = useBoardState() @@ -57,7 +58,10 @@ function applyWidth(m) { function setLegendOpen(v) { chosen = v; legendOpen.value = v } onMounted(() => { try { - mq = window.matchMedia('(max-width: 900px)') + // The same width the BOARD collapses at (NARROW_MAX). If these two ever disagree again, + // the board keeps its side panels while the chart has already given up on room — which is + // exactly the 768px failure. + mq = window.matchMedia(`(max-width: ${NARROW_MAX - 1}px)`) applyWidth(mq) mq.addEventListener?.('change', applyWidth) } catch (_) { /* no matchMedia: stay wide, which is the layout that always fits */ } diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue index b63e4dceb..7bb036d1b 100644 --- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue +++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue @@ -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, crossFeature, TIERS } from '../setChartFeatures.js' const props = defineProps({ groups: { type: Array, default: () => [] }, // { id, label, ring, neighborId } @@ -101,6 +101,23 @@ onMounted(async () => { font: labelFont, onClick: (id, f) => { const r = f?.properties?.ring; if (r != null) emit('select', r) }, })) + + // The cross, as geometry. See crossFeature: a text glyph silently drew nothing. + try { + map.addSource(`${'gcsm'}-cross`, { type: 'geojson', data: crossFeature(project) }) + map.addLayer({ id: `${'gcsm'}-cross`, type: 'line', source: `${'gcsm'}-cross`, + paint: { 'line-color': '#3b463d', 'line-width': 3, 'line-cap': 'square' } }) + } catch (_) { /* the cross is a mark, not the data: never block the chart on it */ } + + // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom — + // the reader met a drawing already cut off, which reads as a rendering fault rather than as + // something to zoom out of. Fitted from the projected extent, so it follows the chart's own + // size rather than a guessed zoom. + try { + const [w, sLat] = project(-EXTENT, -EXTENT) + const [e, nLat] = project(EXTENT, EXTENT) + map.fitBounds([[w, sLat], [e, nLat]], { padding: 24, duration: 0 }) + } catch (_) {} ready.value = true } // ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE. diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js index 66bc15a47..87e9b0c95 100644 --- a/plugin-designs/gospel-internet/src/setChartFeatures.js +++ b/plugin-designs/gospel-internet/src/setChartFeatures.js @@ -41,11 +41,19 @@ export function centeredSetFeatures(placed, radii, { bandLabel, ringLabel, popul // One label per ring, placed on the ring itself at a fixed angle so it does not wander between // renders. Rings outrank groups; the inner rings outrank the outer, because a reader zooming in // meets them in that order. + // ★ FOUR ANCHORS PER RING, NOT ONE. A single label at the top means a reader who zooms in + // anywhere else on the ring — which is most of it, and certainly wherever the dots are dense — + // sees no ring name at all. Measured: zoomed onto a ring-2 dot, zero ring labels were in view. + // Four is enough that any quadrant carries one, and the engine's own collision handling drops + // the extras when they would overlap, so the cost at low zoom is nothing. + const RING_ANCHORS = [[0, -1], [1, 0], [0, 1], [-1, 0]] for (const { ring, r } of radii || []) { const info = ringInfo(ring) if (!info) continue - out.push({ id: `ring-${ring}`, label: ringLabel?.(ring) ?? `Ring ${ring}`, tier: TIERS.RING, - ring, k: 1e9 - ring, u: 0, v: -r }) + RING_ANCHORS.forEach(([dx, dy], i) => { + out.push({ id: `ring-${ring}-${i}`, label: ringLabel?.(ring) ?? `Ring ${ring}`, tier: TIERS.RING, + ring, k: 1e9 - ring, u: dx * r, v: dy * r }) + }) } // One label per band, at the outer edge of the band's own rings. const bands = new Map() @@ -135,3 +143,27 @@ 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)] } + +/** + * The cross at the centre, as GEOMETRY rather than a text glyph. + * + * ★ IT WAS A LABEL AND IT DID NOT RENDER. The bounded set carried the cross as a symbol feature + * whose label was the character itself; the layer existed and drew ZERO features, because a glyph + * only appears if the map's font actually has it, and nothing reports a missing one. Drawn as two + * line segments it depends on no font at all, and it is the same mark in every locale — including + * the RTL pages, where a text cross would also have been subject to bidi reordering. + * + * Sized in layout units so it scales with the drawing, and projected by the caller like everything + * else here. + */ +export function crossFeature(project, { arm = 13, cx = 0, cy = 0 } = {}) { + const vert = [[cx, cy - arm], [cx, cy + arm]].map(([u, v]) => project(u, v)) + const horiz = [[cx - arm * 0.7, cy - arm * 0.35], [cx + arm * 0.7, cy - arm * 0.35]].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 } }, + ], + } +} diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json index e9a932073..606a122ce 100644 --- a/public/dx-maps/doxa-maps-spa/manifest.json +++ b/public/dx-maps/doxa-maps-spa/manifest.json @@ -615,7 +615,7 @@ "bundle": "gospel-internet", "scriptUrl": "./gospel-internet/gospel-internet.js", "output": "dx-maps/gospel-internet/gospel-internet.js", - "integrity": "sha384-llQ4uFRJf84+L8UEW/0K7svB+OUjKabm0/YciS4NT30DmjMB8rrP4C/b1tnM/i4O", + "integrity": "sha384-ZadGj9dZPLGl2d6LuE/+PSPMQzaWg3PqvXgfE19myl3VRUiK7X8/UNe0uD+Ke/kQ", "elements": [ "gospel-internet-board" ], @@ -635,7 +635,7 @@ "scriptUrl": "./gospel-internet/gospel-internet.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n" + "script": "\n\n\n\n\n" }, "instances": [ { diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html index f6739024a..c633ca306 100644 --- a/public/dx-maps/gospel-internet/board/board/index.html +++ b/public/dx-maps/gospel-internet/board/board/index.html @@ -36,7 +36,7 @@ - + - + - + - + - + - + - + \n\n\n" + "script": "\n\n\n\n\n" }, "instances": [ { From 932727be766fb89832fd713760484de0093a0ca6 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 20:50:41 +0000 Subject: [PATCH 2/4] Draw the cross for real, and stop the catch hiding why it wasn't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross source existed and no layer used it. line-cap is a LAYOUT property and it was passed in paint, so addLayer threw — and the catch beside it ate the error. The source was added, the layer never was, and the result was a cross that is simply absent with nothing anywhere saying why. That is the third time today a try/catch turned a broken thing into a silent one, and the second in this one feature. The mark still must not block the chart, so the failure is still caught — but it now says so. A guard that protects the caller should not also protect the bug from being found. The fit takes more padding. It has to hold the labels and not only the points — the outermost band label sat on the canvas edge at 1280 — and a short, wide canvas fits to width and clips top and bottom unless the bounds are given room in the dimension that is scarce, which is what cut the cloud on one narrow layout. --- .../src/components/GiBoundedSetMap.vue | 18 +++++++-- .../src/components/GiCenteredSetMap.vue | 18 +++++++-- public/dx-maps/doxa-maps-spa/manifest.json | 4 +- .../gospel-internet/board/board/index.html | 2 +- .../dx-maps/gospel-internet/board/index.html | 2 +- .../board/india-china/index.html | 2 +- .../board/language-tree/index.html | 2 +- .../board/platforms/index.html | 2 +- .../board/project-700/index.html | 2 +- .../gospel-internet/board/zero/index.html | 2 +- .../gospel-internet/gospel-internet.js | 40 +++++++++---------- public/dx-maps/manifest.json | 4 +- 12 files changed, 61 insertions(+), 37 deletions(-) diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue index b02351bca..d2527cc5b 100644 --- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue +++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue @@ -91,8 +91,16 @@ onMounted(async () => { try { map.addSource(`${'gbsm'}-cross`, { type: 'geojson', data: crossFeature(project) }) map.addLayer({ id: `${'gbsm'}-cross`, type: 'line', source: `${'gbsm'}-cross`, - paint: { 'line-color': '#3b463d', 'line-width': 3, 'line-cap': 'square' } }) - } catch (_) { /* the cross is a mark, not the data: never block the chart on it */ } + layout: { 'line-cap': 'square', 'line-join': 'miter' }, + paint: { 'line-color': '#3b463d', 'line-width': 3 } }) + } catch (e) { + // ★ REPORTED, NOT SWALLOWED. The first version put 'line-cap' in PAINT, where it does not + // belong — it is a LAYOUT property. addLayer threw, this catch ate it, and the source was + // added while the layer never was: a cross that is simply absent, with nothing anywhere + // saying why. That is the third time today a try/catch turned a broken thing into a + // silent one. The mark still must not block the chart, so it is caught — but it says so. + console.warn('[gospel-internet] the centre cross could not be drawn:', e && e.message || e) + } // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom — // the reader met a drawing already cut off, which reads as a rendering fault rather than as @@ -101,7 +109,11 @@ onMounted(async () => { try { const [w, sLat] = project(-SPREAD, -SPREAD) const [e, nLat] = project(SPREAD, SPREAD) - map.fitBounds([[w, sLat], [e, nLat]], { padding: 24, duration: 0 }) + // Padding is generous and symmetric because the fit must hold the LABELS too, not just the + // points: at 1280 the outermost band label sat on the canvas edge. And a short, wide canvas + // (the bounded chart in a narrow column is 796x249) fits to WIDTH and clips top and bottom + // unless the bounds are given room in the dimension that is scarce. + map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 }) } catch (_) {} } // ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE. diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue index 7bb036d1b..de0f28477 100644 --- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue +++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue @@ -106,8 +106,16 @@ onMounted(async () => { try { map.addSource(`${'gcsm'}-cross`, { type: 'geojson', data: crossFeature(project) }) map.addLayer({ id: `${'gcsm'}-cross`, type: 'line', source: `${'gcsm'}-cross`, - paint: { 'line-color': '#3b463d', 'line-width': 3, 'line-cap': 'square' } }) - } catch (_) { /* the cross is a mark, not the data: never block the chart on it */ } + layout: { 'line-cap': 'square', 'line-join': 'miter' }, + paint: { 'line-color': '#3b463d', 'line-width': 3 } }) + } catch (e) { + // ★ REPORTED, NOT SWALLOWED. The first version put 'line-cap' in PAINT, where it does not + // belong — it is a LAYOUT property. addLayer threw, this catch ate it, and the source was + // added while the layer never was: a cross that is simply absent, with nothing anywhere + // saying why. That is the third time today a try/catch turned a broken thing into a + // silent one. The mark still must not block the chart, so it is caught — but it says so. + console.warn('[gospel-internet] the centre cross could not be drawn:', e && e.message || e) + } // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom — // the reader met a drawing already cut off, which reads as a rendering fault rather than as @@ -116,7 +124,11 @@ onMounted(async () => { try { const [w, sLat] = project(-EXTENT, -EXTENT) const [e, nLat] = project(EXTENT, EXTENT) - map.fitBounds([[w, sLat], [e, nLat]], { padding: 24, duration: 0 }) + // Padding is generous and symmetric because the fit must hold the LABELS too, not just the + // points: at 1280 the outermost band label sat on the canvas edge. And a short, wide canvas + // (the bounded chart in a narrow column is 796x249) fits to WIDTH and clips top and bottom + // unless the bounds are given room in the dimension that is scarce. + map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 }) } catch (_) {} ready.value = true } diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json index 606a122ce..c410f8d9b 100644 --- a/public/dx-maps/doxa-maps-spa/manifest.json +++ b/public/dx-maps/doxa-maps-spa/manifest.json @@ -615,7 +615,7 @@ "bundle": "gospel-internet", "scriptUrl": "./gospel-internet/gospel-internet.js", "output": "dx-maps/gospel-internet/gospel-internet.js", - "integrity": "sha384-ZadGj9dZPLGl2d6LuE/+PSPMQzaWg3PqvXgfE19myl3VRUiK7X8/UNe0uD+Ke/kQ", + "integrity": "sha384-ms4BzXmzlKrMiucFY6RR1kcxpNI1KEllXaqE6K4JtxWe0I6VkpkFcf7GulVybnrr", "elements": [ "gospel-internet-board" ], @@ -635,7 +635,7 @@ "scriptUrl": "./gospel-internet/gospel-internet.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n" + "script": "\n\n\n\n\n" }, "instances": [ { diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html index c633ca306..27b8f8a7f 100644 --- a/public/dx-maps/gospel-internet/board/board/index.html +++ b/public/dx-maps/gospel-internet/board/board/index.html @@ -36,7 +36,7 @@ - + - + - + - + - + - + - + \n\n\n" + "script": "\n\n\n\n\n" }, "instances": [ { From 5681f9db2227fc3d00b573f1c478330306ba6a23 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 21:00:15 +0000 Subject: [PATCH 3/4] Fit against the box the chart actually has, and make the cross read as one Raising the padding fixed seven layouts and left the eighth exactly as it was, which was the clue: padding cannot rescue a fit aimed at a box of the wrong shape. That layout is short and wide, and the fit ran against a stale measurement, so it resolved to the width and clipped the cloud top and bottom. The engine is now told to re-read its container before the fit, the fit runs on the next frame so the flex layout can finish, and it runs once more when the map settles for a container still resolving on that frame. None of it depends on a timer. The cross was drawing as a plus. The upper arm was two thirds of the lower, which is a real difference and far too small to see at this size. Latin proportions now: the lower arm is three times the upper and the bar is narrower than the figure is tall, so the shape is legible as what it is at the size it is actually drawn rather than only in the geometry. --- .../src/components/GiBoundedSetMap.vue | 33 +++++++++++-------- .../src/components/GiCenteredSetMap.vue | 33 +++++++++++-------- .../gospel-internet/src/setChartFeatures.js | 9 +++-- public/dx-maps/doxa-maps-spa/manifest.json | 4 +-- .../gospel-internet/board/board/index.html | 2 +- .../dx-maps/gospel-internet/board/index.html | 2 +- .../board/india-china/index.html | 2 +- .../board/language-tree/index.html | 2 +- .../board/platforms/index.html | 2 +- .../board/project-700/index.html | 2 +- .../gospel-internet/board/zero/index.html | 2 +- .../gospel-internet/gospel-internet.js | 26 +++++++-------- public/dx-maps/manifest.json | 4 +-- 13 files changed, 71 insertions(+), 52 deletions(-) diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue index d2527cc5b..54ae341af 100644 --- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue +++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue @@ -102,19 +102,26 @@ onMounted(async () => { console.warn('[gospel-internet] the centre cross could not be drawn:', e && e.message || e) } - // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom — - // the reader met a drawing already cut off, which reads as a rendering fault rather than as - // something to zoom out of. Fitted from the projected extent, so it follows the chart's own - // size rather than a guessed zoom. - try { - const [w, sLat] = project(-SPREAD, -SPREAD) - const [e, nLat] = project(SPREAD, SPREAD) - // Padding is generous and symmetric because the fit must hold the LABELS too, not just the - // points: at 1280 the outermost band label sat on the canvas edge. And a short, wide canvas - // (the bounded chart in a narrow column is 796x249) fits to WIDTH and clips top and bottom - // unless the bounds are given room in the dimension that is scarce. - map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 }) - } catch (_) {} + // ★ FIT AFTER THE BOX HAS SETTLED, AND RESIZE FIRST. The previous version fitted + // immediately: on one narrow layout the canvas was still 796x249 from a stale measurement, + // so the fit was computed against the wrong aspect, resolved to the WIDTH, and clipped the + // cloud top and bottom. Raising the padding did nothing there, because padding cannot fix a + // fit that was aimed at a box of the wrong shape — which is why that one layout was + // 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. + const fitTarget = () => { + try { + 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 */ } + } + 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 (_) {} } // ★ 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. diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue index de0f28477..aa2c669e8 100644 --- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue +++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue @@ -117,19 +117,26 @@ onMounted(async () => { console.warn('[gospel-internet] the centre cross could not be drawn:', e && e.message || e) } - // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom — - // the reader met a drawing already cut off, which reads as a rendering fault rather than as - // something to zoom out of. Fitted from the projected extent, so it follows the chart's own - // size rather than a guessed zoom. - try { - const [w, sLat] = project(-EXTENT, -EXTENT) - const [e, nLat] = project(EXTENT, EXTENT) - // Padding is generous and symmetric because the fit must hold the LABELS too, not just the - // points: at 1280 the outermost band label sat on the canvas edge. And a short, wide canvas - // (the bounded chart in a narrow column is 796x249) fits to WIDTH and clips top and bottom - // unless the bounds are given room in the dimension that is scarce. - map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 }) - } catch (_) {} + // ★ FIT AFTER THE BOX HAS SETTLED, AND RESIZE FIRST. The previous version fitted + // immediately: on one narrow layout the canvas was still 796x249 from a stale measurement, + // so the fit was computed against the wrong aspect, resolved to the WIDTH, and clipped the + // cloud top and bottom. Raising the padding did nothing there, because padding cannot fix a + // fit that was aimed at a box of the wrong shape — which is why that one layout was + // 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. + const fitTarget = () => { + try { + 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 */ } + } + 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 (_) {} ready.value = true } // ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE. diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js index 87e9b0c95..fa78c6f91 100644 --- a/plugin-designs/gospel-internet/src/setChartFeatures.js +++ b/plugin-designs/gospel-internet/src/setChartFeatures.js @@ -157,8 +157,13 @@ export function degreesToPixelRadius(degrees, { minZoom = 0, maxZoom = 22 } = {} * else here. */ export function crossFeature(project, { arm = 13, cx = 0, cy = 0 } = {}) { - const vert = [[cx, cy - arm], [cx, cy + arm]].map(([u, v]) => project(u, v)) - const horiz = [[cx - arm * 0.7, cy - arm * 0.35], [cx + arm * 0.7, cy - arm * 0.35]].map(([u, v]) => project(u, v)) + // 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)) return { type: 'FeatureCollection', features: [ diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json index c410f8d9b..0394f00bd 100644 --- a/public/dx-maps/doxa-maps-spa/manifest.json +++ b/public/dx-maps/doxa-maps-spa/manifest.json @@ -615,7 +615,7 @@ "bundle": "gospel-internet", "scriptUrl": "./gospel-internet/gospel-internet.js", "output": "dx-maps/gospel-internet/gospel-internet.js", - "integrity": "sha384-ms4BzXmzlKrMiucFY6RR1kcxpNI1KEllXaqE6K4JtxWe0I6VkpkFcf7GulVybnrr", + "integrity": "sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn", "elements": [ "gospel-internet-board" ], @@ -635,7 +635,7 @@ "scriptUrl": "./gospel-internet/gospel-internet.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n" + "script": "\n\n\n\n\n" }, "instances": [ { diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html index 27b8f8a7f..bfa643029 100644 --- a/public/dx-maps/gospel-internet/board/board/index.html +++ b/public/dx-maps/gospel-internet/board/board/index.html @@ -36,7 +36,7 @@ - + - + - + - + - + - + - + \n\n\n" + "script": "\n\n\n\n\n" }, "instances": [ { From fc589cef1c1f13b39cb5d24e22e8829d9624df95 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 22:13:28 +0000 Subject: [PATCH 4/4] Point the cross the right way up, and let the fit follow its container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/GiBoundedSetMap.vue | 26 ++++++++++++++----- .../src/components/GiCenteredSetMap.vue | 26 ++++++++++++++----- .../gospel-internet/src/setChartFeatures.js | 19 +++++++++----- public/dx-maps/doxa-maps-spa/manifest.json | 4 +-- .../gospel-internet/board/board/index.html | 2 +- .../dx-maps/gospel-internet/board/index.html | 2 +- .../board/india-china/index.html | 2 +- .../board/language-tree/index.html | 2 +- .../board/platforms/index.html | 2 +- .../board/project-700/index.html | 2 +- .../gospel-internet/board/zero/index.html | 2 +- .../gospel-internet/gospel-internet.js | 26 +++++++++---------- public/dx-maps/manifest.json | 4 +-- 13 files changed, 76 insertions(+), 43 deletions(-) diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue index 54ae341af..4b8417925 100644 --- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue +++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue @@ -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}' + diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue index aa2c669e8..0523d3633 100644 --- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue +++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue @@ -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}' + diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js index fa78c6f91..35114db9a 100644 --- a/plugin-designs/gospel-internet/src/setChartFeatures.js +++ b/plugin-designs/gospel-internet/src/setChartFeatures.js @@ -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: [ diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json index 0394f00bd..813d9fdfa 100644 --- a/public/dx-maps/doxa-maps-spa/manifest.json +++ b/public/dx-maps/doxa-maps-spa/manifest.json @@ -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": "", - "script": "\n\n\n\n\n" + "script": "\n\n\n\n\n" }, "instances": [ { diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html index bfa643029..eaf55b380 100644 --- a/public/dx-maps/gospel-internet/board/board/index.html +++ b/public/dx-maps/gospel-internet/board/board/index.html @@ -36,7 +36,7 @@ - + - + - + - + - + - + - + \n\n\n" + "script": "\n\n\n\n\n" }, "instances": [ {