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": [
{