Lay out the centered-set target, without re-deriving its rings
The opposite claim from the bounded set: there the only fact is which side of a line a group is on, here there is no line and the whole content is distance. The two must not look alike. Ring membership is data, not a derivation. It is computed by the upg-neighbor build and written into near-neighbors.json, and this module reads it rather than working it out again. That is deliberate: the ring rule encodes a contested judgement — whether "dialect" means an ISO code or an Ethnologue-strict level — which belongs to the data owner. A second implementation here would be a second answer, and the first symptom would be two charts quietly disagreeing. The arrow and the radius are taken from the same field for the same reason. The ring records the reached group that set it; the older links list records the nearest neighbour on the E-scale ordering, sorted by believers and distance. Those can differ, and reading the radius from one and the arrow from the other draws a group on ring 3 with an arrow pointing at its ring-8 neighbour, with nothing on screen to say so. A group with no ring is unplaced and keeps a reason, never defaulted onto the outer ring: ring 10 is a real answer, so it must not double as a bin for missing data. A group with no recorded neighbour gets no arrow rather than a guessed one. The check covers the properties where a wrong answer still looks like a chart — a dot on the boundary, an unknown folded into unreached, an arrow overshooting the cross it points at. These are pure functions, so their inputs are their domain and the assertions are about the modules themselves. The binding to the data file is not checked here; it belongs where the data is read.
This commit is contained in:
parent
3f73cf6d9a
commit
afc343a112
1 changed files with 91 additions and 0 deletions
91
plugin-designs/gospel-internet/src/centeredSet.js
Normal file
91
plugin-designs/gospel-internet/src/centeredSet.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* centeredSet.js — the CENTERED-SET TARGET: the cross at the centre, every unreached group
|
||||
* placed on a ring by how close it is to a REACHED neighbour, arrows pointing inward.
|
||||
*
|
||||
* The opposite claim from the bounded set. There the only fact is which side of a line a
|
||||
* group is on; here there is no line at all, and the whole content is DISTANCE — which is
|
||||
* why the two charts must not look alike.
|
||||
*
|
||||
* THE RINGS ARE DATA, NOT A DERIVATION. Ring membership is computed at build time by the
|
||||
* upg-neighbor build and written into near-neighbors.json as `rings[u] = [ring, reachedRow]`.
|
||||
* This module does not re-derive it, and that is deliberate rather than lazy: the ring rule
|
||||
* encodes a contested judgement (whether "dialect" means an ISO code or an Ethnologue-strict
|
||||
* level) that is the data owner's to make. A second implementation here would be a second
|
||||
* answer, and the first symptom would be two charts quietly disagreeing.
|
||||
*
|
||||
* ★ THE ARROW AND THE RADIUS MUST NAME THE SAME NEIGHBOUR. `rings[u][1]` is the reached row
|
||||
* that SET the ring. `links[u][0]` is a different thing — the nearest neighbour on the older
|
||||
* E-scale ordering, sorted by believers and distance. They can disagree. Reading the radius
|
||||
* from one and the arrow from the other draws a group at ring 3 with an arrow pointing at its
|
||||
* ring-8 neighbour, and nothing on screen says so. So this takes both from `rings`.
|
||||
*
|
||||
* Pure: no Vue, no DOM, no fetch.
|
||||
*/
|
||||
|
||||
/** Rings 1..9 are the dialect/language/family x same/near/different matrix; 10 is beyond. */
|
||||
export const RING_MIN = 1
|
||||
export const RING_MAX = 10
|
||||
|
||||
/** Deterministic 0..1 from a string, so a group keeps its angle between renders. */
|
||||
function hash01(s) {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < String(s).length; i++) {
|
||||
h ^= String(s).charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return ((h >>> 0) % 100000) / 100000
|
||||
}
|
||||
|
||||
/**
|
||||
* Place groups on their rings.
|
||||
*
|
||||
* `groups` are { id, ring, neighborId? }. A group whose ring is absent or outside 1..10 is
|
||||
* UNPLACED and returned separately — never defaulted onto the outer ring, because "we do not
|
||||
* know how close this group is" and "this group is very far" are different facts and only one
|
||||
* of them is about the group. The outermost ring is a real answer and must not double as a
|
||||
* bin for missing data.
|
||||
*/
|
||||
export function layoutCenteredSet(groups, { cx = 0, cy = 0, innerR = 26, ringGap = 24 } = {}) {
|
||||
const placed = []
|
||||
const unplaced = []
|
||||
for (const g of groups || []) {
|
||||
const ring = Number(g?.ring)
|
||||
if (!Number.isInteger(ring) || ring < RING_MIN || ring > RING_MAX) {
|
||||
unplaced.push({ ...g, reason: g?.ring == null ? 'no ring' : `ring ${g.ring} out of range` })
|
||||
continue
|
||||
}
|
||||
const a = hash01(String(g.id)) * Math.PI * 2
|
||||
const r = innerR + (ring - 1) * ringGap
|
||||
placed.push({ ...g, ring, angle: a, r, x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r })
|
||||
}
|
||||
return { placed, unplaced, cx, cy, innerR, ringGap, radii: ringRadii({ innerR, ringGap }) }
|
||||
}
|
||||
|
||||
/** The radius of each ring, so the chart and its gridlines cannot drift apart. */
|
||||
export function ringRadii({ innerR = 26, ringGap = 24 } = {}) {
|
||||
const out = []
|
||||
for (let i = RING_MIN; i <= RING_MAX; i++) out.push({ ring: i, r: innerR + (i - 1) * ringGap })
|
||||
return out
|
||||
}
|
||||
|
||||
/** How many groups sit on each ring, including rings with none. */
|
||||
export function ringTally(layout) {
|
||||
const counts = new Map()
|
||||
for (let i = RING_MIN; i <= RING_MAX; i++) counts.set(i, 0)
|
||||
for (const p of layout.placed) counts.set(p.ring, counts.get(p.ring) + 1)
|
||||
return { counts, placed: layout.placed.length, unplaced: layout.unplaced.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* The inward arrow for a group: from the group to the centre, stopping at `innerR` so the
|
||||
* head does not cover the cross. Returns null when there is no neighbour recorded — an arrow
|
||||
* with no named neighbour would imply a relationship the data does not carry.
|
||||
*/
|
||||
export function inwardArrow(p, { cx = 0, cy = 0, innerR = 26 } = {}) {
|
||||
if (!p || p.neighborId == null) return null
|
||||
const len = Math.hypot(p.x - cx, p.y - cy)
|
||||
if (!len) return null
|
||||
// Stop short of the centre by innerR, so the head points at the cross without covering it.
|
||||
const t = innerR / len
|
||||
return { x1: p.x, y1: p.y, x2: cx + (p.x - cx) * t, y2: cy + (p.y - cy) * t, neighborId: p.neighborId }
|
||||
}
|
||||
Loading…
Reference in a new issue