Put the bounded set on the whiteboard too, with its boundary in map units
Both set charts now pan and zoom. The chart still makes one claim — which side of the line a group is on — so nothing here encodes distance or rank; what zoom buys is reading the names, because at two thousand dots the inside is a cluster until you can get close to it. The boundary is the shared dish layer, which is the same layer a language family's circle uses, so it is not a new type for either of us. Its radius takes an exponential-base-2 zoom interpolation rather than a fixed pixel value. circle-radius is screen pixels: a constant would hold its size while the dots spread out from under it, leaving the circle correct at exactly one zoom and wrong at every other — and for a dot near the edge the inside/outside reading would silently invert, which is the only thing this chart says. Pixels per degree double with each zoom level, measured at exactly 2.000000 between adjacent levels, so the interpolation tracks map units exactly rather than approximately. The boundary is drawn as a line with a transparent fill. A filled disc would say the inside is a region, when what the chart means is a threshold. The cross rides in the point collection as its own tier rather than as an overlay, so it pans and zooms with the drawing instead of floating above it.
This commit is contained in:
parent
b68685801e
commit
092585af6c
3 changed files with 136 additions and 19 deletions
|
|
@ -28,7 +28,12 @@ import { formatCount } from '@map/utils/formatCount.js'
|
|||
import { useBoardState } from '../useBoard.js'
|
||||
import { useGiT } from '../useGiT.js'
|
||||
import { loadNearRows, indexFor } from '../nearRows.js'
|
||||
<<<<<<< HEAD
|
||||
import { layoutBoundedSet, boundedSetTally } from '@map/components/charts/boundedSet.js'
|
||||
=======
|
||||
import { layoutBoundedSet, boundedSetTally } from '../boundedSet.js'
|
||||
import GiBoundedSetMap from './GiBoundedSetMap.vue'
|
||||
>>>>>>> ea8dbe45 (Put the bounded set on the whiteboard too, with its boundary in map units)
|
||||
|
||||
const T = useGiT()
|
||||
const board = useBoardState()
|
||||
|
|
@ -73,7 +78,7 @@ useShadowStyles(
|
|||
// can never push the figure out of its pane the way the legend did on the centered set.
|
||||
'.gbs{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
|
||||
'justify-content:center;padding:46px 10px 10px;box-sizing:border-box;overflow:hidden;background:#f3f3f1}' +
|
||||
'.gbs svg{max-width:100%;max-height:100%}' +
|
||||
'.gbs-fig{position:relative;flex:1 1 auto;align-self:stretch;min-height:0;min-width:0}' +
|
||||
'.gbs-ring{fill:none;stroke:#3b463d;stroke-width:1.5}' +
|
||||
'.gbs-in{fill:#92b195}.gbs-out{fill:#6d766e;opacity:.65}' +
|
||||
'.gbs-cross-halo{stroke:var(--gbs-bg,#f3f3f1);stroke-width:7;stroke-linecap:square}' +
|
||||
|
|
@ -91,24 +96,11 @@ useShadowStyles(
|
|||
<span class="gbs-warn" dir="ltr">{{ failed }}</span>
|
||||
</p>
|
||||
<template v-else>
|
||||
<svg viewBox="-280 -280 560 560" role="img"
|
||||
:aria-label="T('gospelInternet.bounded.aria', 'Bounded set: the cross at the centre, reached groups inside the circle, unreached outside')">
|
||||
<circle class="gbs-ring" cx="0" cy="0" r="120" />
|
||||
<circle v-for="p in layout.outside" :key="p.id" class="gbs-out" :cx="p.x" :cy="p.y" r="2.2" />
|
||||
<circle v-for="p in layout.inside" :key="p.id" class="gbs-in" :cx="p.x" :cy="p.y" r="2.2" />
|
||||
<!-- The cross is drawn LAST, over the dots, and carries a background-coloured halo
|
||||
beneath it. Reached groups are laid out across the whole inner disc, so some of
|
||||
them sit exactly where the cross does; the layout is not changed to clear a space
|
||||
for it, because that layout is shared with the printed component and moving dots
|
||||
to suit a glyph would move them for every reader of both. Painting order and a
|
||||
halo solve it where they should be solved — in the drawing. -->
|
||||
<g class="gbs-cross">
|
||||
<line class="gbs-cross-halo" x1="0" y1="-13" x2="0" y2="13" />
|
||||
<line class="gbs-cross-halo" x1="-9" y1="-4" x2="9" y2="-4" />
|
||||
<line class="gbs-cross-ink" x1="0" y1="-13" x2="0" y2="13" />
|
||||
<line class="gbs-cross-ink" x1="-9" y1="-4" x2="9" y2="-4" />
|
||||
</g>
|
||||
</svg>
|
||||
<!-- The whiteboard map replaces the static figure: the reader can pan and zoom, and group
|
||||
names appear once they are close enough for the names to be readable. The chart still
|
||||
claims one thing — which side of the line a group is on — so nothing here encodes
|
||||
distance or rank. The PRINTED component stays SVG; paper has no zoom. -->
|
||||
<div class="gbs-fig"><GiBoundedSetMap :groups="joined" /></div>
|
||||
<p class="gbs-cap">
|
||||
{{ T('gospelInternet.bounded.caption', 'Inside the circle: reached. Outside: unreached.') }}
|
||||
<b dir="ltr">{{ fmt(tally.inside) }} / {{ fmt(tally.outside) }}</b>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
<script setup>
|
||||
/**
|
||||
* GiBoundedSetMap.vue — the bounded set on the whiteboard: one circle, reached inside,
|
||||
* unreached outside, pannable and zoomable like its neighbour.
|
||||
*
|
||||
* The chart still makes exactly one claim — which side of the line a group is on — so nothing
|
||||
* here encodes distance, ranking or gradient. What zoom buys is reading the names: at 2,100
|
||||
* dots the inside is a cluster until you can get close to it.
|
||||
*
|
||||
* ★ THE BOUNDARY IS A DISH, AND ITS RADIUS MUST BE IN MAP UNITS. circle-radius is SCREEN
|
||||
* PIXELS: a fixed value would hold its size on screen while the dots spread out from under it,
|
||||
* so the circle would be correct at exactly one zoom and wrong at every other — and for dots
|
||||
* near the edge the inside/outside reading would silently invert, which is the only thing this
|
||||
* chart says. degreesToPixelRadius converts it with an exponential-base-2 interpolation, which
|
||||
* is exact rather than approximate because pixels-per-degree doubles with every zoom level.
|
||||
*
|
||||
* The cross rides in the point collection as its own tier rather than as an overlay, so it pans
|
||||
* and zooms with the drawing instead of floating over it.
|
||||
*/
|
||||
import { ref, shallowRef, computed, watch, onMounted, onBeforeUnmount, markRaw } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { labelFont } from '@map/maps/label-font.js'
|
||||
import { whiteboardProjection } from '@map/maps/whiteboard-projection.js'
|
||||
import { addTieredPointLayers } from '@map/charts/whiteboard/tiered-points.js'
|
||||
import { createBoardMap } from '../boardEngine.js'
|
||||
import { useBoardState } from '../useBoard.js'
|
||||
import { useGiT } from '../useGiT.js'
|
||||
import { layoutBoundedSet } from '../boundedSet.js'
|
||||
import { boundedSetFeatures, toFeatureCollection, dishRadiusDegrees, degreesToPixelRadius, TIERS } from '../setChartFeatures.js'
|
||||
|
||||
const props = defineProps({ groups: { type: Array, default: () => [] } })
|
||||
|
||||
const T = useGiT()
|
||||
const board = useBoardState()
|
||||
const el = ref(null)
|
||||
const mapRef = shallowRef(null)
|
||||
const handle = shallowRef(null)
|
||||
const failed = ref('')
|
||||
|
||||
const R = 120, SPREAD = 260
|
||||
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.
|
||||
const features = computed(() => [
|
||||
...built.value.features,
|
||||
{ id: '__centre', label: '✝', tier: 'centre', k: 3e9, u: 0, v: 0 },
|
||||
])
|
||||
const data = computed(() => toFeatureCollection(features.value, project).collection)
|
||||
|
||||
const dishDegrees = computed(() => dishRadiusDegrees(R, project))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const made = await createBoardMap(el.value, board.profileConfig?.value || {},
|
||||
{ whiteboard: true, boundaries: false, center: [0, 0], zoom: 1 })
|
||||
const map = markRaw(made.map || made)
|
||||
mapRef.value = map
|
||||
map.on('load', () => {
|
||||
handle.value = markRaw(addTieredPointLayers(map, {
|
||||
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.
|
||||
colorExpr: ['match', ['get', 'side'], 'in', '#92b195', 'out', '#6d766e', '#3b463d'],
|
||||
radiusExpr: ['interpolate', ['linear'], ['zoom'], 1, 1.8, 6, 3.6],
|
||||
opacityExpr: ['case', ['==', ['get', 'side'], 'out'], 0.65, 0.95],
|
||||
dish: {
|
||||
filter: ['==', ['get', 'id'], '__centre'],
|
||||
radiusExpr: degreesToPixelRadius(dishDegrees.value),
|
||||
color: 'rgba(0,0,0,0)', // the boundary is a LINE, not a filled disc:
|
||||
stroke: '#3b463d', // a fill would imply the inside is a region
|
||||
},
|
||||
font: labelFont,
|
||||
}))
|
||||
})
|
||||
} catch (e) {
|
||||
failed.value = String(e?.message || e)
|
||||
}
|
||||
})
|
||||
|
||||
watch(data, (d) => { try { mapRef.value?.getSource('gbsm')?.setData(d) } catch (_) {} })
|
||||
onBeforeUnmount(() => { try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
|
||||
|
||||
useShadowStyles(
|
||||
'.gbsm{position:absolute;inset:0;min-height:0;min-width:0}' +
|
||||
'.gbsm-map{position:absolute;inset:0}' +
|
||||
'.gbsm-fail{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;' +
|
||||
'padding:16px;text-align:center;color:#3b463d;font-size:13px;background:#f3f3f1}',
|
||||
'gi-bounded-set-map',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gbsm">
|
||||
<div ref="el" class="gbsm-map" />
|
||||
<p v-if="failed" class="gbsm-fail">
|
||||
{{ T('gospelInternet.bounded.failed', 'The reached/unreached table could not be loaded, so this chart is not drawn.') }}
|
||||
<span dir="ltr">{{ failed }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -118,3 +118,20 @@ export function toFeatureCollection(features, project) {
|
|||
export function dishRadiusDegrees(r, project) {
|
||||
return Number(r || 0) * (project?.K ?? 0) * 360
|
||||
}
|
||||
|
||||
/**
|
||||
* A zoom expression that keeps a radius fixed in MAP units while the paint wants PIXELS.
|
||||
*
|
||||
* ★ circle-radius IS SCREEN PIXELS, and for the bounded set's boundary that is the wrong unit.
|
||||
* A fixed pixel radius stays the same size on screen while the dots spread out from under it as
|
||||
* the reader zooms — the circle would be right at exactly one zoom level and wrong at every
|
||||
* other, with the inside/outside reading silently inverting for dots near the edge.
|
||||
*
|
||||
* The world is 512 x 2^zoom pixels across 360 degrees, so pixels-per-degree doubles with every
|
||||
* zoom level — measured exactly 2.000000 between adjacent levels. An exponential-base-2
|
||||
* interpolation therefore tracks map units exactly rather than approximately, with two stops.
|
||||
*/
|
||||
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)]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue