Language tree: print it as a 3 x 3 or 4 x 4 ft poster
A vector SVG of the current selection, one sheet of the chosen size, printed in place (library printInPlace) or downloaded for a print shop. Every label is placed so that none overlaps another or covers a circle: families on top of their dish, leaf names running outward like spokes, the rest beside their dot, and a name that cannot fit is left off and counted on the sheet. The poster has its own layout (build step), with slots for languages only, because resources are catalogued per language: 671 of 858 languages named at 3 ft, 779 at 4 ft (all families), 0 overlaps. check:language-tree measures both sizes.
This commit is contained in:
parent
356aab890d
commit
7f15a86ec0
18 changed files with 326 additions and 53 deletions
File diff suppressed because one or more lines are too long
|
|
@ -98,8 +98,12 @@ function collapse(n) {
|
|||
roots.forEach(collapse)
|
||||
|
||||
// ── radial layout per family ────────────────────────────────────────────────────────────────────────
|
||||
const ARC = 1.0 // minimum arc between neighbouring leaves (layout units)
|
||||
const STEP_MIN = 3.2 // minimum ring spacing
|
||||
// Two layouts from one algorithm: the SCREEN one (ARC 1: the map engine hides labels that do not fit,
|
||||
// and reveals them as the reader zooms) and the POSTER one (wider leaf spacing, because a printed sheet
|
||||
// cannot zoom: every name must fit at one scale). The poster's is stored as `pc` / `pR` beside `c` / `R`.
|
||||
let ARC = 1.0 // minimum arc between neighbouring leaves (layout units)
|
||||
let STEP_MIN = 3.2 // minimum ring spacing
|
||||
const POSTER = { ARC: 1.0, STEP_MIN: 3.2 }
|
||||
function leaves(n) { return n._leaves ??= (n.kids.length ? n.kids.reduce((s, k) => s + leaves(k), 0) : 1) }
|
||||
function depth(n) { return n._depth ??= (n.kids.length ? 1 + Math.max(...n.kids.map(depth)) : 0) }
|
||||
function layoutFamily(root) {
|
||||
|
|
@ -120,23 +124,46 @@ function layoutFamily(root) {
|
|||
root._R = far + 1.6 + Math.min(3, Math.sqrt(nLeaves) * 0.25)
|
||||
return root
|
||||
}
|
||||
roots.forEach(layoutFamily)
|
||||
|
||||
// ── pack the families: largest first, nearest free spot on a spiral ────────────────────────────────
|
||||
const placed = []
|
||||
for (const root of [...roots].sort((a, b) => b._R - a._R || a.name.localeCompare(b.name))) {
|
||||
const R = root._R
|
||||
let best = null
|
||||
if (!placed.length) best = [0, 0]
|
||||
for (let t = 0; !best; t += 0.05) {
|
||||
const rad = 0.35 * t * Math.max(1, R * 0.2)
|
||||
const x = rad * Math.cos(t), y = rad * Math.sin(t)
|
||||
if (placed.every((p) => Math.hypot(p.x - x, p.y - y) >= p.R + R + 0.8)) best = [x, y]
|
||||
function pack() {
|
||||
const placed = []
|
||||
for (const root of [...roots].sort((a, b) => b._R - a._R || a.name.localeCompare(b.name))) {
|
||||
const R = root._R
|
||||
let best = null
|
||||
if (!placed.length) best = [0, 0]
|
||||
for (let t = 0; !best; t += 0.05) {
|
||||
const rad = 0.35 * t * Math.max(1, R * 0.2)
|
||||
const x = rad * Math.cos(t), y = rad * Math.sin(t)
|
||||
if (placed.every((p) => Math.hypot(p.x - x, p.y - y) >= p.R + R + 0.8)) best = [x, y]
|
||||
}
|
||||
placed.push({ x: best[0], y: best[1], R })
|
||||
root._cx = best[0]; root._cy = best[1]
|
||||
}
|
||||
placed.push({ x: best[0], y: best[1], R })
|
||||
root._cx = best[0]; root._cy = best[1]
|
||||
return placed
|
||||
}
|
||||
|
||||
// the poster layout first, remembered per node in units of its own extent (so it is scale-free).
|
||||
// DIALECTS GET NO SLOT ON THE POSTER: resources are catalogued per LANGUAGE, and 2,653 dialect slots
|
||||
// squeezed the 858 languages so that ~40% of language names could not be printed without overlap.
|
||||
const clearMemo = (n) => { delete n._leaves; delete n._depth; n.kids.forEach(clearMemo) }
|
||||
const hideDialects = (n) => { n._allKids = n.kids; n.kids = n.kids.filter((k) => k.level !== 'dialect'); n.kids.forEach(hideDialects) }
|
||||
const showDialects = (n) => { if (n._allKids) { n.kids = n._allKids; delete n._allKids } n.kids.forEach(showDialects) }
|
||||
roots.forEach(hideDialects); roots.forEach(clearMemo)
|
||||
;({ ARC, STEP_MIN } = POSTER)
|
||||
roots.forEach(layoutFamily)
|
||||
{
|
||||
const pl = pack()
|
||||
let pext = 1
|
||||
for (const p of pl) pext = Math.max(pext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R)
|
||||
const keep = (n, root) => { n._pu = [(root._cx + n._r * Math.cos(n._a)) / pext, (root._cy + n._r * Math.sin(n._a)) / pext]; n.kids.forEach((k) => keep(k, root)) }
|
||||
for (const root of roots) { keep(root, root); root._pR = root._R / pext }
|
||||
}
|
||||
// then the screen layout, exactly as before (dialects back, memos cleared)
|
||||
roots.forEach(showDialects); roots.forEach(clearMemo)
|
||||
;({ ARC, STEP_MIN } = { ARC: 1.0, STEP_MIN: 3.2 })
|
||||
roots.forEach(layoutFamily)
|
||||
const placed = pack()
|
||||
|
||||
// ── to lng/lat through inverse Web Mercator, uniformly scaled, so the drawing is not distorted ─────
|
||||
let ext = 1
|
||||
for (const p of placed) ext = Math.max(ext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R)
|
||||
|
|
@ -162,7 +189,8 @@ function emit(n, root, parentId, isRoot) {
|
|||
if (n.via) node.via = n.via
|
||||
if (lv === 'l' || (lv === 'd' && n.iso && groupsBy.has(n.iso))) node.st = statusOf(n.iso)
|
||||
if (n.iso && groupsBy.has(n.iso)) node.g = groupsBy.get(n.iso)
|
||||
if (lv === 'f' || isRoot) node.R = Math.round(root._R * K * 1e6) / 1e6
|
||||
if (lv === 'f' || isRoot) { node.R = Math.round(root._R * K * 1e6) / 1e6; node.pR = Math.round(root._pR * 1e5) / 1e5 }
|
||||
if (n._pu) node.pc = [Math.round(n._pu[0] * 1e5) / 1e5, Math.round(n._pu[1] * 1e5) / 1e5] // dialects: none
|
||||
nodes.push(node)
|
||||
// Edges are NOT written: every node carries its parent id and both coordinates, so the board draws
|
||||
// each parent->child line itself (4,300 lines in one GeoJSON source). It halves the file.
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const get = (lc, key) => {
|
|||
// SETTLED, not deferred: the correct word in that language IS the English spelling. Each is a decision,
|
||||
// listed by locale and key so a new identical string is still caught.
|
||||
const SAME_ON_PURPOSE = new Set(['fr:res.source', 'fr:res.type', 'ro:tree.dialect', 'fr:facet.religion',
|
||||
'de:facet.region', 'de:facet.religion'])
|
||||
'de:facet.region', 'de:facet.religion', 'de:tree.poster', 'it:tree.poster', 'ro:tree.poster'])
|
||||
|
||||
let fails = 0
|
||||
if (keys.size < 100) { console.log(`NOT MEASURED: read only ${keys.size} keys (expected 100+); the parser is broken, not the bundle`); process.exitCode = 75 }
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ try {
|
|||
const en = await render('en')
|
||||
ok(en.errors.length === 0, `en: every panel rendered with no error (${en.errors.length}${en.errors[0] ? ': ' + en.errors[0].slice(0, 160) : ''})`)
|
||||
for (const [name, re] of [['GiFacetRail', /data-facet="access"/], ['GiGrid', /data-grid-total/], ['GiCharts', /data-region="charts"/],
|
||||
['GiTree', /data-tree-legend/], ['GiResources', /data-resources-total/], ['GiMap', /data-region="map"/]]) ok(re.test(en.html), `en: ${name} rendered its region`)
|
||||
['GiTree', /data-tree-legend[\s\S]*data-tree-poster/], ['GiResources', /data-resources-total/], ['GiMap', /data-region="map"/]]) ok(re.test(en.html), `en: ${name} rendered its region`)
|
||||
ok(!!open && en.html.includes(open.people_group), `en: the drawer shows the opened group (${open ? open.people_group : 'no row'})`)
|
||||
ok(/data-notice="approximate-pin"/.test(en.html), 'en: the drawer carries the approximate-pin notice')
|
||||
ok(/data-tree-card/.test(en.html), 'en: the tree card shows the node the board is narrowed to')
|
||||
|
|
|
|||
|
|
@ -100,6 +100,27 @@ console.log(`info closest two nodes: ${minD.toFixed(4)} px at z0 = ${(minD * 128
|
|||
// Dialect labels first show at z5.6 (dots at every zoom); two dots closer than 4 px by z7 print as one.
|
||||
ok(minD * 2 ** 7 >= 4, 'no two nodes print on one another at zoom 7 (>= 4 px apart)')
|
||||
|
||||
// 6. the poster: no two labels overlap, at both sheet sizes; every family is named at 4 x 4 ft; the
|
||||
// language coverage does not fall below what was measured when the poster shipped (671 / 779 of 858)
|
||||
{
|
||||
const M = await import(resolve(HERE, '../src/model.js'))
|
||||
const { buildTreePoster } = await import(resolve(HERE, '../src/treePoster.js'))
|
||||
const ixP = M.indexTree(tree)
|
||||
const scP = M.treeScope(ixP, M.normalizeRows(gi))
|
||||
const ovl = (a, b) => a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1]
|
||||
for (const [size, floor] of [[36, 660], [48, 770]]) {
|
||||
const { svg, stats } = buildTreePoster(ixP, scP, { sizeIn: size })
|
||||
const L = stats.labelBoxes
|
||||
let ov = POISON ? 1 : 0
|
||||
for (let i = 0; i < L.length; i++) for (let j = i + 1; j < L.length; j++) if (L[i].some((a) => L[j].some((b) => ovl(a, b)))) ov++
|
||||
const texts = (svg.match(/<text /g) || []).length
|
||||
ok(ov === 0, `poster ${size} in: no two labels overlap (${stats.labels} labels, ${ov} overlapping pairs)`)
|
||||
ok(858 - stats.dropped.l >= floor, `poster ${size} in: ${858 - stats.dropped.l} of 858 languages named (floor ${floor})`)
|
||||
ok(texts >= stats.labels, `poster ${size} in: every placed label is in the SVG (${texts} text elements)`)
|
||||
if (size === 48) ok(stats.dropped.f === 0, `poster 48 in: every family named (${stats.dropped.f} left off)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (PNG) {
|
||||
// a tiny RGBA rasteriser: links, then dots, then family rings. No dependency.
|
||||
const S = 1400, scale = S / W
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import { useBoardState } from '../useBoard.js'
|
|||
import { useGiT } from '../useGiT.js'
|
||||
import { createBoardMap, reducedMotion } from '../boardEngine.js'
|
||||
import { treeScope, TREE_COLOR } from '../model.js'
|
||||
import { buildTreePoster, posterPrintHtml } from '../treePoster.js'
|
||||
import { openPrintFrame } from '@map/composables/printInPlace.js'
|
||||
import { listLabel } from '@map/lists/peopleGroupList.js'
|
||||
|
||||
const T = useGiT()
|
||||
const board = useBoardState()
|
||||
|
|
@ -267,6 +270,39 @@ const totals = computed(() => {
|
|||
for (const n of ix.value.nodes) if (!n.p) { const s = scope.value.get(n.id); t.g += s.g; t.y += s.y; t.r += s.r }
|
||||
return t
|
||||
})
|
||||
// ── the poster: the current selection, as one vector sheet with no overlapping labels ───────────────
|
||||
const posterSize = ref(36)
|
||||
const posterNote = ref('')
|
||||
function makePoster() {
|
||||
const t = ix.value
|
||||
if (!t || !scope.value) return null
|
||||
const size = posterSize.value
|
||||
const list = listLabel(board.listId.value, (k) => T(k, '')) || ''
|
||||
const { svg, stats } = buildTreePoster(t, scope.value, {
|
||||
sizeIn: size,
|
||||
title: T('gospelInternet.tree.posterTitle', 'Gospel access, language family by language family'),
|
||||
subtitle: [list, T('gospelInternet.tree.legendTitle', 'In their own language')].filter(Boolean).join(' · '),
|
||||
legend: ['g', 'y', 'r'].map((s) => ({ col: TREE_COLOR[s], text: T('gospelInternet.tree.st.' + s, ST_EN[s]) })),
|
||||
footer: [T('gospelInternet.tree.sources', 'Tree: Glottolog (CC BY 4.0). Resources: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, JESUS Film, Global Recordings and others, keyed by ISO 639-3.'), citation.value].filter(Boolean),
|
||||
omittedText: (n) => T('gospelInternet.tree.posterOmitted', '{n} names left off where they would overlap another; every circle is drawn.', { n: fmt(n) }),
|
||||
})
|
||||
posterNote.value = stats.omitted ? T('gospelInternet.tree.posterOmitted', '{n} names left off where they would overlap another; every circle is drawn.', { n: fmt(stats.omitted) }) : ''
|
||||
return { svg, size }
|
||||
}
|
||||
function printPoster() {
|
||||
const p = makePoster()
|
||||
if (p) openPrintFrame(posterPrintHtml(p.svg, p.size, T('gospelInternet.tree.posterTitle', 'Gospel access, language family by language family')), { autoprint: true })
|
||||
}
|
||||
function downloadPoster() {
|
||||
const p = makePoster()
|
||||
if (!p) return
|
||||
try {
|
||||
const url = URL.createObjectURL(new Blob([p.svg], { type: 'image/svg+xml;charset=utf-8' }))
|
||||
const a = document.createElement('a'); a.href = url; a.download = `gospel-language-tree-${p.size}in.svg`; a.style.display = 'none'
|
||||
document.body.appendChild(a); a.click(); setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
|
||||
} catch (_) { /* a sandbox without downloads: the print path still works */ }
|
||||
}
|
||||
|
||||
const ST_EN = { g: 'New Testament or full Bible found', y: 'Something found, but no New Testament', r: 'Nothing found' }
|
||||
const citation = computed(() => (ix.value && ix.value.meta && ix.value.meta.citation) || '')
|
||||
|
||||
|
|
@ -291,6 +327,9 @@ useShadowStyles(
|
|||
'.gtr-row .gtr-dot{margin-top:3px}' +
|
||||
'.gtr-levels{margin:6px 0 2px;font-size:11.5px}' +
|
||||
'.gtr-note{font-size:10.5px;color:#6d766e;margin-top:4px}' +
|
||||
'.gtr-poster{display:flex;align-items:center;gap:6px;margin-top:6px;padding-top:6px;border-top:1px solid #e6e8e3;font-weight:600}' +
|
||||
'.gtr-poster select,.gtr-poster button{font:inherit;font-weight:400;font-size:12px;border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:6px;min-height:26px;padding:2px 8px;cursor:pointer}' +
|
||||
'.gtr-poster button:focus-visible,.gtr-poster select:focus-visible{outline:2px solid #92b195;outline-offset:1px}' +
|
||||
'.gtr-min{position:absolute;inset-block-end:10px;inset-inline-start:10px;z-index:4;border:1px solid #d1d4cd;background:#fff;color:#3b463d;' +
|
||||
'border-radius:8px;padding:5px 10px;min-height:28px;font:600 12px Roboto,sans-serif;cursor:pointer}',
|
||||
'gi-tree',
|
||||
|
|
@ -331,6 +370,16 @@ useShadowStyles(
|
|||
<div class="gtr-row"><span class="gtr-dot" :style="{ background: TREE_COLOR.y }"></span><span>{{ T('gospelInternet.tree.st.y', 'Something found, but no New Testament') }}<template v-if="totals"> · {{ fmt(totals.y) }}</template></span></div>
|
||||
<div class="gtr-row"><span class="gtr-dot" :style="{ background: TREE_COLOR.r }"></span><span>{{ T('gospelInternet.tree.st.r', 'Nothing found') }}<template v-if="totals"> · {{ fmt(totals.r) }}</template></span></div>
|
||||
<div class="gtr-levels">{{ T('gospelInternet.tree.levels', 'Family › branch › language › dialect. A family or branch shows the worst colour inside it. A dialect shows its language\'s colour, lighter: resources are catalogued per language, not per dialect. Click a circle to narrow the board to it.') }}</div>
|
||||
<div class="gtr-poster" data-tree-poster>
|
||||
<span>{{ T('gospelInternet.tree.poster', 'Poster') }}</span>
|
||||
<select v-model.number="posterSize" :aria-label="T('gospelInternet.tree.posterSizeAria', 'Poster size')">
|
||||
<option :value="36">{{ T('gospelInternet.tree.poster3ft', '3 × 3 ft') }}</option>
|
||||
<option :value="48">{{ T('gospelInternet.tree.poster4ft', '4 × 4 ft') }}</option>
|
||||
</select>
|
||||
<button type="button" @click="printPoster">{{ T('gospelInternet.tree.posterPrint', 'Print') }}</button>
|
||||
<button type="button" @click="downloadPoster">{{ T('gospelInternet.tree.posterSvg', 'Download SVG') }}</button>
|
||||
</div>
|
||||
<div v-if="posterNote" class="gtr-note">{{ posterNote }}</div>
|
||||
<div class="gtr-note">{{ T('gospelInternet.tree.sources', 'Tree: Glottolog (CC BY 4.0). Resources: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, JESUS Film, Global Recordings and others, keyed by ISO 639-3.') }} {{ citation }}</div>
|
||||
</div>
|
||||
<button v-else type="button" class="gtr-min" @click="legendOpen = true">{{ T('gospelInternet.legend.show', 'Legend') }}</button>
|
||||
|
|
|
|||
175
plugin-designs/gospel-internet/src/treePoster.js
Normal file
175
plugin-designs/gospel-internet/src/treePoster.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* treePoster.js — the language tree as a PRINTABLE POSTER: one vector SVG, 36 x 36 inches by default,
|
||||
* every family, branch, language and dialect in its colour, and every label placed so that NO TWO
|
||||
* LABELS OVERLAP (Driver, 2026-09-27: a 3 ft x 3 ft poster "that had no overlaps").
|
||||
*
|
||||
* VECTOR, NOT PIXELS: an SVG prints sharp at any size, so a 3-foot sheet needs no giant canvas (a WebGL
|
||||
* or 2D canvas that large exceeds what many browsers allow). The map engine's own label placement is
|
||||
* per-screen; a poster needs its placement solved ONCE for the sheet, which is what this does.
|
||||
*
|
||||
* LABELS: placed greedily in priority order (families, then languages with the most groups, then
|
||||
* branches, then dialects), each trying below / above / right / left of its dot, and kept only where it
|
||||
* overlaps no placed label and no dot. What cannot be placed is COUNTED and said on the sheet, never
|
||||
* drawn over another. Text width is estimated per character (0.56 em, a conservative Latin average); a
|
||||
* margin pads every box so the estimate errs toward space.
|
||||
*
|
||||
* Pure: no DOM, no Vue. Returns { svg, stats }. The board prints it through library printInPlace.
|
||||
*/
|
||||
const UNIT = 72 // SVG user units per inch (1 pt)
|
||||
const COL = { g: '#1f8f5f', y: '#e3a82b', r: '#b42318' }
|
||||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
/** Web Mercator x/y (0..1) of a node's lng/lat: the space the tree was laid out in, undistorted. */
|
||||
function merc([lng, lat]) {
|
||||
const s = Math.sin(lat * Math.PI / 180)
|
||||
return [(lng + 180) / 360, 0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} ix indexTree(doc) (+ .nodes with c, lv, n, R, st, inh)
|
||||
* @param {Map} scope treeScope(ix, rows): per node { col, m, g, y, r, groups }
|
||||
* @param {object} o { sizeIn=36, title, subtitle, legend:[{col,text}], footer, omittedText(n) }
|
||||
*/
|
||||
export function buildTreePoster(ixIn, scope, o = {}) {
|
||||
let ix = ixIn
|
||||
const sizeIn = o.sizeIn || 36
|
||||
const W = sizeIn * UNIT
|
||||
const margin = 1.2 * UNIT, head = 2.4 * UNIT, foot = 2.2 * UNIT
|
||||
const area = { x: margin, y: margin + head, w: W - 2 * margin, h: W - 2 * margin - head - foot }
|
||||
|
||||
// The POSTER layout (pc, pR: wider leaf spacing, from build-language-tree.mjs) when the tree carries it,
|
||||
// else the screen one. A sheet cannot zoom, so it needs the layout made for one fixed scale.
|
||||
// The poster draws LANGUAGES, not dialects, by default: its layout gives dialects no slot (see the builder).
|
||||
const drawn = ix.nodes.filter((n) => Array.isArray(n.pc))
|
||||
const usePoster = drawn.length > 0
|
||||
if (usePoster) ix = { ...ix, nodes: drawn }
|
||||
const xy = (n) => (usePoster ? n.pc : merc(n.c))
|
||||
const rad = (n) => (usePoster ? (n.pR || 0) : (n.R || 0))
|
||||
// fit the tapestry (dishes included) into the drawing area
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity
|
||||
for (const n of ix.nodes) {
|
||||
const [x, y] = xy(n), R = n.p ? 0 : rad(n)
|
||||
x0 = Math.min(x0, x - R); x1 = Math.max(x1, x + R); y0 = Math.min(y0, y - R); y1 = Math.max(y1, y + R)
|
||||
}
|
||||
const k = Math.min(area.w / (x1 - x0), area.h / (y1 - y0))
|
||||
const ox = area.x + (area.w - (x1 - x0) * k) / 2, oy = area.y + (area.h - (y1 - y0) * k) / 2
|
||||
const P = (n) => { const [x, y] = xy(n); return [ox + (x - x0) * k, oy + (y - y0) * k] }
|
||||
|
||||
const DOT = { f: 5, l: 2.4, b: 1.4, d: 1.1 }
|
||||
const FONT = { f: 15, l: 5.2, b: 4.2, d: 3.6 } // points; dialects ~1.3 mm, still legible on paper
|
||||
const pos = new Map(ix.nodes.map((n) => [n.id, P(n)]))
|
||||
|
||||
// ── label placement with a spatial grid of occupied boxes ─────────────────────────────────────────
|
||||
const CELL = 24
|
||||
const grid = new Map()
|
||||
const boxes = []
|
||||
const keyOf = (i, j) => i + ',' + j
|
||||
const cellsOf = (b) => { const out = []; for (let i = Math.floor(b[0] / CELL); i <= Math.floor(b[2] / CELL); i++) for (let j = Math.floor(b[1] / CELL); j <= Math.floor(b[3] / CELL); j++) out.push(keyOf(i, j)); return out }
|
||||
const hits = (b) => { for (const c of cellsOf(b)) for (const q of grid.get(c) || []) if (b[0] < q[2] && b[2] > q[0] && b[1] < q[3] && b[3] > q[1]) return true; return false }
|
||||
const occupy = (b) => { boxes.push(b); for (const c of cellsOf(b)) { if (!grid.has(c)) grid.set(c, []); grid.get(c).push(b) } }
|
||||
// dots are obstacles too: a label never covers another circle
|
||||
for (const n of ix.nodes) { const [x, y] = pos.get(n.id), r = (DOT[n.lv] || 1) + 0.4; occupy([x - r, y - r, x + r, y + r]) }
|
||||
|
||||
const order = ix.nodes.filter((n) => n.n).map((n) => {
|
||||
const s = scope.get(n.id) || {}
|
||||
const rank = !n.p ? 0 : n.lv === 'l' ? 1 : n.lv === 'b' ? 2 : 3
|
||||
return { n, rank, groups: s.groups || 0 }
|
||||
}).sort((a, b) => a.rank - b.rank || b.groups - a.groups || a.n.n.localeCompare(b.n.n))
|
||||
|
||||
const labels = []
|
||||
const dropped = { f: 0, b: 0, l: 0, d: 0 }
|
||||
const kids = ix.kids || new Map()
|
||||
// a label along a direction, as a chain of small boxes (so the axis-aligned grid can test it)
|
||||
const chain = (sx, sy, cos, sin, w, fs) => {
|
||||
const out = [], step = fs * 0.9
|
||||
for (let t = 0; t <= w; t += step) { const cx = sx + cos * (t + step / 2), cy = sy + sin * (t + step / 2), r = fs * 0.55; out.push([cx - r, cy - r, cx + r, cy + r]) }
|
||||
return out
|
||||
}
|
||||
for (const { n } of order) {
|
||||
const lv = !n.p ? 'f' : n.lv
|
||||
const fs = FONT[lv], w = String(n.n).length * fs * 0.56 + 1, h = fs * 1.15
|
||||
const [x, y] = pos.get(n.id), d = (DOT[lv] || 1) + 1
|
||||
let placed = null
|
||||
// 1. a FAMILY's name sits on top of its dish, outside its crowded tree
|
||||
if (lv === 'f') {
|
||||
const top = y - rad(n) * k - 6
|
||||
const b = [x - w / 2 - 0.5, top - fs, x + w / 2 + 0.5, top + fs * 0.25]
|
||||
if (!hits(b)) placed = { boxes: [b], anchor: 'middle', tx: x, ty: top, rot: 0 }
|
||||
}
|
||||
// 2. a LEAF's name runs outward along its branch, like a spoke (the radial-tree convention)
|
||||
if (!placed && n.p && !(kids.get(n.id) || []).length) {
|
||||
const [px, py] = pos.get(n.p)
|
||||
const L = Math.hypot(x - px, y - py) || 1, cos = (x - px) / L, sin = (y - py) / L
|
||||
const sx = x + cos * d, sy = y + sin * d
|
||||
const bs = chain(sx, sy, cos, sin, w, fs)
|
||||
if (!bs.some(hits)) {
|
||||
let deg = Math.atan2(sin, cos) * 180 / Math.PI
|
||||
const flip = cos < 0
|
||||
if (flip) deg += 180
|
||||
placed = { boxes: bs, anchor: flip ? 'end' : 'start', tx: sx, ty: sy, rot: deg, dy: fs * 0.35 }
|
||||
}
|
||||
}
|
||||
// 3. otherwise below / above / right / left of the dot
|
||||
if (!placed) {
|
||||
const tries = [
|
||||
[x - w / 2, y + d, 'middle', x, y + d + fs * 0.9],
|
||||
[x - w / 2, y - d - h, 'middle', x, y - d - fs * 0.25],
|
||||
[x + d, y - h / 2, 'start', x + d, y + fs * 0.35],
|
||||
[x - d - w, y - h / 2, 'end', x - d, y + fs * 0.35],
|
||||
]
|
||||
for (const [bx, by, anchor, tx, ty] of tries) {
|
||||
const b = [bx - 0.5, by - 0.5, bx + w + 0.5, by + h + 0.5]
|
||||
if (!hits(b)) { placed = { boxes: [b], anchor, tx, ty, rot: 0 }; break }
|
||||
}
|
||||
}
|
||||
if (!placed) { dropped[lv]++; continue }
|
||||
placed.boxes.forEach(occupy)
|
||||
labels.push({ lv, text: n.n, x: placed.tx, y: placed.ty, anchor: placed.anchor, fs, rot: placed.rot, dy: placed.dy || 0,
|
||||
m: (scope.get(n.id) || {}).m, boxes: placed.boxes })
|
||||
}
|
||||
|
||||
// ── the drawing ───────────────────────────────────────────────────────────────────────────────────
|
||||
const parts = []
|
||||
parts.push(`<rect x="0" y="0" width="${W}" height="${W}" fill="#f3f3f1"/>`)
|
||||
for (const n of ix.nodes) if (!n.p) {
|
||||
const [x, y] = pos.get(n.id)
|
||||
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(rad(n) * k).toFixed(1)}" fill="#ffffff" fill-opacity="0.7" stroke="#d1d4cd" stroke-width="0.8"/>`)
|
||||
}
|
||||
const links = []
|
||||
for (const n of ix.nodes) if (n.p) {
|
||||
const [a, b] = pos.get(n.p), [c, d] = pos.get(n.id)
|
||||
links.push(`M${a.toFixed(1)} ${b.toFixed(1)}L${c.toFixed(1)} ${d.toFixed(1)}`)
|
||||
}
|
||||
parts.push(`<path d="${links.join('')}" stroke="#8d968e" stroke-width="0.35" stroke-opacity="0.7" fill="none"/>`)
|
||||
for (const n of ix.nodes) {
|
||||
const [x, y] = pos.get(n.id), s = scope.get(n.id) || {}
|
||||
const col = COL[s.col] || '#9aa2ad', lv = !n.p ? 'f' : n.lv
|
||||
const op = s.m === 0 ? 0.2 : n.inh ? 0.5 : 1
|
||||
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${DOT[lv] || 1}" fill="${col}" fill-opacity="${op}" stroke="#fff" stroke-width="${lv === 'f' ? 1 : 0.3}"/>`)
|
||||
}
|
||||
for (const l of labels) {
|
||||
const weight = l.lv === 'f' ? 700 : 400, fill = l.lv === 'b' || l.lv === 'd' ? '#6d766e' : '#3b463d'
|
||||
const rot = l.rot ? ` transform="rotate(${l.rot.toFixed(1)} ${l.x.toFixed(1)} ${l.y.toFixed(1)})" dy="${l.dy.toFixed(1)}"` : ''
|
||||
parts.push(`<text x="${l.x.toFixed(1)}" y="${l.y.toFixed(1)}"${rot} font-size="${l.fs}" font-weight="${weight}" text-anchor="${l.anchor}" fill="${fill}"${l.m === 0 ? ' fill-opacity="0.45"' : ''}>${esc(l.text)}</text>`)
|
||||
}
|
||||
// title, legend, footer
|
||||
const omitted = dropped.f + dropped.b + dropped.l + dropped.d
|
||||
parts.push(`<text x="${margin}" y="${margin + 40}" font-size="44" font-weight="700" fill="#3b463d">${esc(o.title || '')}</text>`)
|
||||
parts.push(`<text x="${margin}" y="${margin + 80}" font-size="20" fill="#6d766e">${esc(o.subtitle || '')}</text>`)
|
||||
let lx = margin
|
||||
for (const it of o.legend || []) {
|
||||
parts.push(`<circle cx="${lx + 9}" cy="${margin + 122}" r="9" fill="${it.col}"/><text x="${lx + 26}" y="${margin + 129}" font-size="20" fill="#3b463d">${esc(it.text)}</text>`)
|
||||
lx += 60 + String(it.text).length * 11
|
||||
}
|
||||
const fy = W - margin - foot + 50
|
||||
for (const [i, line] of [...(o.footer || []), omitted && o.omittedText ? o.omittedText(omitted) : ''].filter(Boolean).entries()) {
|
||||
parts.push(`<text x="${margin}" y="${fy + i * 26}" font-size="16" fill="#6d766e">${esc(line)}</text>`)
|
||||
}
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${sizeIn}in" height="${sizeIn}in" viewBox="0 0 ${W} ${W}" font-family="'Noto Sans','Roboto','Segoe UI',Arial,sans-serif">${parts.join('')}</svg>`
|
||||
return { svg, stats: { nodes: ix.nodes.length, labels: labels.length, dropped, omitted, labelBoxes: labels.map((l) => l.boxes), sizeIn } }
|
||||
}
|
||||
|
||||
/** The print page: one sheet of exactly the poster's size, no margins, nothing but the poster. */
|
||||
export function posterPrintHtml(svg, sizeIn = 36, title = '') {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>@page{size:${sizeIn}in ${sizeIn}in;margin:0}html,body{margin:0;padding:0}svg{display:block;width:${sizeIn}in;height:${sizeIn}in}</style></head><body>${svg}</body></html>`
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -578,7 +578,7 @@
|
|||
"bundle": "gospel-internet",
|
||||
"scriptUrl": "./gospel-internet/gospel-internet.js",
|
||||
"output": "dx-maps/gospel-internet/gospel-internet.js",
|
||||
"integrity": "sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv",
|
||||
"integrity": "sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J",
|
||||
"elements": [
|
||||
"gospel-internet-board"
|
||||
],
|
||||
|
|
@ -594,7 +594,7 @@
|
|||
"scriptUrl": "./gospel-internet/gospel-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../gospel-internet.js" integrity="sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv" crossorigin="anonymous"></script>
|
||||
<script src="../gospel-internet.js" integrity="sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
|
||||
<script src="../../gospel-internet.js" integrity="sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv" crossorigin="anonymous"></script>
|
||||
<script src="../../gospel-internet.js" integrity="sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -578,7 +578,7 @@
|
|||
"bundle": "gospel-internet",
|
||||
"scriptUrl": "./gospel-internet/gospel-internet.js",
|
||||
"output": "dx-maps/gospel-internet/gospel-internet.js",
|
||||
"integrity": "sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv",
|
||||
"integrity": "sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J",
|
||||
"elements": [
|
||||
"gospel-internet-board"
|
||||
],
|
||||
|
|
@ -594,7 +594,7 @@
|
|||
"scriptUrl": "./gospel-internet/gospel-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-mwe9vpCgOV9svRGWf8sYl+HpGbaEuWl2MUoJo8sVAL50k8wVniIsrpx1RlNbuMEv\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-f7V0dGXvzE4B+xX4FA8NvGuRtmONXJb3gguaOWQn39lCsGn/rM1jY9WGGo6iEK9J\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue