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