Files
pagerite/frontend/src/analytics/transitions.js
T
2026-08-21 22:03:55 +00:00

710 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Radial transition map and helpers.
*
* Radial site map: the front page at the center, each slug level on its own
* ring. All pages of the site are shown (from /_api/pages), plus any extra
* paths seen in transitions (deleted pages); siblings run clockwise in
* navigation order, starting at the top. Internal path -> path transitions
* join opposite directions into straight connections (middle width = total
* count, wrapping the node circles at both ends). Connection width grows
* logarithmically with the count (a single count renders as a ~1 px
* line, uncapped growth); connections carrying less than 1% of the total
* traffic are pruned, which naturally keeps the graph under ~100
* connections. Animated beads flow along every edge in each direction,
* emitted at time intervals inversely proportional (linear) to the
* directional count.
* External referers appear as nodes in a row above the map, external exits
* as full-size nodes just outside their source page, angled away from the
* center. Each distinct full exit URL is its own node. Self-loops (reload
* pings) are skipped.
*/
import { MIN_READ_SECONDS } from './format.js'
export const TNODE_R = 34 // node circles hold the slug and the view count
export const EXT_R = 34 // external referer/exit nodes use the same full size
// Edge width (half-width of the thin middle) grows logarithmically with
// the count. The constants are scaled down by ~10× so busy ranges (day,
// year) do not overwhelm the graph with fat connectors. A single recorded
// transition still renders as a faint ~0.4 px line. Connections carrying
// less than PRUNE_FRACTION of the total traffic are not drawn at all (this
// also keeps the graph under ~100 connections).
const WMID_MIN = 0.2
const WIDTH_GROWTH = 0.15
const PRUNE_FRACTION = 0.01
// Beads: each edge direction emits beads at count * BEAD_RATE beads per
// second (linear in the count). The rate is reduced ~10× across all time
// scales to keep the animation lightweight. The component simulates every
// bead independently in JS at BEAD_SPEED along the edge, with no limit on
// beads in flight.
export const BEAD_SPEED = 180 // svg units per second
export const BEAD_R = 2.2
const BEAD_RATE = 0.012 // beads per second per recorded transition
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
const MAX_EXT_IN = 8 // referer nodes in the top row
const MAX_EXT_OUT = 12 // exit nodes, at most MAX_EXT_OUT_PER_PAGE per page
const MAX_EXT_OUT_PER_PAGE = 3
/** Flatten the site tree into navigation order via DFS. */
function buildNavigationOrder(pageTree) {
const order = new Map()
const walk = (items) => {
for (const item of items || []) {
const p = `/${item.path}`
if (!order.has(p)) order.set(p, order.size)
walk(item.children)
}
}
walk(pageTree)
return order
}
/** Map page paths to their article titles from the site tree. */
function buildTitleMap(pageTree) {
const titles = new Map()
const walk = (items) => {
for (const item of items || []) {
titles.set(`/${item.path}`, item.title)
walk(item.children)
}
}
walk(pageTree)
return titles
}
/** Extract internal page-to-page transitions, excluding self-loops. */
function collectInternalTransitions(transitions) {
const internal = []
for (const [fr, tos] of Object.entries(transitions || {})) {
if (!fr.startsWith('/')) continue
for (const [to, count] of Object.entries(tos)) {
if (to.startsWith('/') && to !== fr) internal.push({ fr, to, count })
}
}
return internal
}
/** Domain-only label for an external origin (path and www. removed). */
function extLabel(ext) {
try {
const host = new URL(ext).hostname.replace(/^www\./, '')
return host.length > 25 ? `${host.slice(0, 24)}…` : host
} catch {
const s = ext.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]
return s.length > 25 ? `${s.slice(0, 24)}…` : s
}
}
/**
* Collect external transitions: referer origin -> entry page (incoming) and
* page -> exit origin (outgoing). Aggregated per (origin, page) pair, with
* separate directional counts. "(direct)" entries are not links and skipped.
*/
function collectExternalPairs(transitions) {
const pairs = new Map() // `${ext} ${page}` -> {ext, page, in, out}
for (const [fr, tos] of Object.entries(transitions || {})) {
for (const [to, count] of Object.entries(tos)) {
const frExt = !fr.startsWith('/')
const toExt = !to.startsWith('/')
if (frExt === toExt) continue // internal-internal or ext-ext
const ext = frExt ? fr : to
const page = frExt ? to : fr
if (!ext.startsWith('http')) continue
const k = `${ext} ${page}`
const p = pairs.get(k) || { ext, page, in: 0, out: 0 }
p[frExt ? 'in' : 'out'] += count
pairs.set(k, p)
}
}
return [...pairs.values()]
}
/** Build nodes with depth and a path lookup map; children are wired to parents. */
function buildNodeTree(internal, navOrder) {
const paths = new Set(['/', ...navOrder.keys()])
for (const e of internal) { paths.add(e.fr); paths.add(e.to) }
const depth = (p) => (p === '/' ? 0 : p.split('/').length - 1)
const nodes = [...paths].map((p) => ({
path: p, depth: depth(p), angle: 0, children: [],
}))
const byPath = new Map(nodes.map((n) => [n.path, n]))
// Parent is the nearest ancestor present in the map, front page last.
const parentOf = (p) => {
let q = p
while (q !== '/') {
q = q.slice(0, q.lastIndexOf('/')) || '/'
if (byPath.has(q)) return byPath.get(q)
}
return byPath.get('/')
}
for (const n of nodes) {
if (n.path !== '/') parentOf(n.path).children.push(n)
}
return { nodes, byPath, root: byPath.get('/') }
}
/** Sort children by navigation order and compute each subtree's angular weight. */
function prepareWeights(root, navOrder) {
const byNav = (a, b) =>
(navOrder.get(a.path) ?? Infinity) - (navOrder.get(b.path) ?? Infinity)
|| a.path.localeCompare(b.path)
const weight = (n) =>
n.children.length ? n.children.reduce((s, k) => s + weight(k), 0) : 1 / n.depth
const walkSort = (n) => {
n.children.sort(byNav)
n.children.forEach(walkSort)
}
walkSort(root)
return weight
}
/** Assign angles clockwise starting from the top (-PI/2). */
function layoutAngles(root, unit, weight) {
const lay = (n, a0) => {
n.angle = a0
let a = a0
for (const k of n.children) {
lay(k, a)
a += weight(k) * unit
}
}
let a = -Math.PI / 2
for (const k of root.children) {
lay(k, a)
a += weight(k) * unit
}
}
/** Compute median reading time per article in whole minutes. */
function buildReadMinutes(visits) {
const times = {}
for (const v of visits || []) {
for (const [path, sec] of Object.entries(v.read || {})) {
if (sec >= MIN_READ_SECONDS) {
;(times[path] || (times[path] = [])).push(sec)
}
}
}
const minutes = {}
for (const [path, arr] of Object.entries(times)) {
arr.sort((a, b) => a - b)
const mid = Math.floor(arr.length / 2)
const median =
arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2
minutes[path] = Math.max(1, Math.round(median / 60))
}
return minutes
}
/** Compute radial positions, view counts and labels for each node. */
function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
// Constant radial gap between rings, equal to the arc spacing of nodes
// along a ring: leaf arc = unit * GAP, so GAP scales up with `unit` on
// sparse trees (where closing the circle forces wider arcs) and with
// 1/unit on dense ones (keeping arcs at the node clearance).
const CLEAR = 2 * TNODE_R + 12
const GAP = CLEAR * Math.max(unit, 1 / unit)
const radius = (d) => d * GAP
const viewCount = (p) => {
let n = 0
for (const c of Object.values(viewsData?.[p] || {})) n += c
return n
}
for (const n of nodes) {
const r = radius(n.depth)
n.x = Math.cos(n.angle) * r
n.y = Math.sin(n.angle) * r
n.views = viewCount(n.path)
n.readMin = readMinutes[n.path] || 0
// Slug inside the circle; full title goes on the link title attribute.
const slug = n.path === '/' ? '🏠' : n.path.split('/').pop()
n.label = slug.length > 16 ? `${slug.slice(0, 15)}…` : slug
n.title = titles.get(n.path) || ''
// Category (non-leaf) pages with no views in this window are left
// blank to keep the layout, but their circle/label is not drawn.
n.hidden = n.children.length > 0 && n.views === 0
}
return { radius, GAP }
}
/**
* Family structure at a glance: a radial spoke from each parent to its
* first child, and a ring arc across each sibling group from first to last
* child in navigation (clockwise) order.
*/
function buildFamilyArcs(nodes, radius) {
const arcs = []
for (const n of nodes) {
if (!n.children.length) continue
// The spoke aims along the FIRST CHILD's angle (the node's own angle
// coincides with it, except for the center page which has none).
const first = n.children[0]
const r1 = radius(n.depth) + TNODE_R
const r2 = radius(first.depth) - TNODE_R
arcs.push({
d: `M ${Math.cos(first.angle) * r1} ${Math.sin(first.angle) * r1} `
+ `L ${Math.cos(first.angle) * r2} ${Math.sin(first.angle) * r2}`,
})
if (n.children.length < 2) continue
const r = radius(n.children[0].depth)
const a0 = n.children[0].angle
const a1 = n.children[n.children.length - 1].angle
if (a1 - a0 >= 2 * Math.PI - 1e-6) continue // full circle: degenerate arc
const large = a1 - a0 > Math.PI ? 1 : 0
arcs.push({
d: `M ${Math.cos(a0) * r} ${Math.sin(a0) * r} `
+ `A ${r} ${r} 0 ${large} 1 ${Math.cos(a1) * r} ${Math.sin(a1) * r}`,
r,
a0,
a1,
})
}
return arcs
}
/** Bounding box of a circular arc centred at the origin, sampled. */
function arcBounds(r, a0, a1) {
let x0 = Infinity
let y0 = Infinity
let x1 = -Infinity
let y1 = -Infinity
const steps = 36
for (let i = 0; i <= steps; i++) {
const t = a0 + (a1 - a0) * (i / steps)
const x = Math.cos(t) * r
const y = Math.sin(t) * r
if (x < x0) x0 = x
if (y < y0) y0 = y
if (x > x1) x1 = x
if (y > y1) y1 = y
}
return { x0, y0, x1, y1 }
}
/** Collapse opposite transition directions into one unordered pair per page pair. */
function aggregatePairs(internal) {
const pairs = new Map() // unordered pair key -> [countAB, countBA]
for (const e of internal) {
const forward = e.fr < e.to
const k = forward ? `${e.fr} ${e.to}` : `${e.to} ${e.fr}`
const c = pairs.get(k) || [0, 0]
c[forward ? 0 : 1] += e.count
pairs.set(k, c)
}
return pairs
}
const fmtPt = (p) => `${p[0].toFixed(2)} ${p[1].toFixed(2)}`
/**
* Build one ribbon edge between two nodes with counts ab and ba.
* `wMid` is the half-width of the thin middle (already strength-scaled by
* the caller); `ra`/`rb` are the radii of the node circles each end wraps.
*/
function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external = false) {
const count = ab + ba
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
const ux = (b.x - a.x) / len
const uy = (b.y - a.y) / len
const nx = -uy
const ny = ux
// Radius of each node surround and the attachment geometry on it.
const R2A = ra + 3
const R2B = rb + 3
// Attachment points sit somewhat forward from the side of the node,
// leaving enough room for the surround to flow naturally into the flare.
const BETA = (65 * Math.PI) / 180
const ENDA = R2A * Math.cos(BETA)
const wEndA = R2A * Math.sin(BETA)
const ENDB = R2B * Math.cos(BETA)
const wEndB = R2B * Math.sin(BETA)
// Flares take a fair share of the free span while leaving the
// count-scaled thin middle a visible share of the connection length.
const FLARE = Math.min(36, Math.max(0, (len - ENDA - ENDB) * 0.4))
// Point on the connection centerline at distance t from A, offset s
// perpendicular to it.
const P = (t, s) => [
a.x + t * ux + s * nx,
a.y + t * uy + s * ny,
]
// Arc around a node from p to q the long way, passing its back side.
const wrap = (p, q, node, back, R2) => {
const ang = (pt2) =>
Math.atan2(pt2[1] - node[1], pt2[0] - node[0])
const TAU = 2 * Math.PI
const da = ((ang(back) - ang(p)) % TAU + TAU) % TAU
const db = ((ang(q) - ang(p)) % TAU + TAU) % TAU
return `A ${R2} ${R2} 0 1 ${da < db ? 1 : 0} ${fmtPt(q)} `
}
// Build one side of a flare in node -> middle order.
const flarePoints = (endT, midT, s, dir, R2, END, wEnd) => {
const span = Math.abs(midT - endT)
const pEnd = P(endT, s * wEnd)
const pMid = P(midT, s * wMid)
// At the node, leave tangent to the circular surround.
// The circle radius at the attachment is locally:
// A: (+END, ±wEnd)
// B: (-END, ±wEnd)
// A perpendicular tangent pointing into the connection therefore has
// these centerline/normal components.
const tangentT = dir * wEnd / R2
const tangentS = -s * END / R2
const hEnd = span * 0.65
const hMid = span * 0.4
const cEnd = P(
endT + tangentT * hEnd,
s * wEnd + tangentS * hEnd,
)
// At the thin end, arrive parallel with the centerline.
const cMid = P(
midT - dir * hMid,
s * wMid,
)
return { pEnd, cEnd, cMid, pMid }
}
// Emit a cubic in either traversal direction. Reversing a cubic requires
// swapping its control points, rather than recalculating the geometry.
const curve = (f, reverse = false) => {
if (!reverse) {
return `C ${fmtPt(f.cEnd)} ${fmtPt(f.cMid)} ${fmtPt(f.pMid)} `
}
return `C ${fmtPt(f.cMid)} ${fmtPt(f.cEnd)} ${fmtPt(f.pEnd)} `
}
const LA = P(ENDA, wEndA)
const RA = P(ENDA, -wEndA)
const LB = P(len - ENDB, wEndB)
const RB = P(len - ENDB, -wEndB)
const aLeft = flarePoints(ENDA, ENDA + FLARE, 1, 1, R2A, ENDA, wEndA)
const bLeft = flarePoints(len - ENDB, len - ENDB - FLARE, 1, -1, R2B, ENDB, wEndB)
const bRight = flarePoints(len - ENDB, len - ENDB - FLARE, -1, -1, R2B, ENDB, wEndB)
const aRight = flarePoints(ENDA, ENDA + FLARE, -1, 1, R2A, ENDA, wEndA)
const d = `M ${fmtPt(LA)} `
+ curve(aLeft)
+ `L ${fmtPt(bLeft.pMid)} `
+ curve(bLeft, true)
+ wrap(LB, RB, [b.x, b.y], P(len + R2B, 0), R2B)
+ curve(bRight)
+ `L ${fmtPt(aRight.pMid)} `
+ curve(aRight, true)
+ wrap(RA, LA, [a.x, a.y], P(-R2A, 0), R2A)
+ 'Z'
return {
d,
title: `${a.path}${b.path}: ${count} (${ab} / ${ba})`,
external,
}
}
/**
* Flow descriptors for the bead animation, one per edge direction with a
* nonzero count: a straight segment running from inside the source node
* to inside the target node (beads render under the node circles, so
* they emerge from and vanish beneath the nodes rather than popping in
* at the surround), plus the emission interval (seconds between beads,
* inverse of count * BEAD_RATE). Each segment is offset to the
* right-hand side of its travel direction, so opposing flows on the same
* edge run on parallel lanes instead of colliding. The component turns
* these into independently simulated beads.
*/
function buildFlows(a, b, ra, rb, ab, ba, visualScale = 1) {
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
const ux = (b.x - a.x) / len
const uy = (b.y - a.y) / len
const t0 = ra / 3
const t1 = len - rb / 3
if (t1 - t0 < 12) return []
// Unit normal pointing to the visual right of the A -> B direction.
const rx = -uy
const ry = ux
const span = t1 - t0
const flow = (count, fromT, toT) => {
// Each direction shifts to its own right, away from the opposing lane.
const s = fromT < toT ? FLOW_OFFSET : -FLOW_OFFSET
return {
x1: a.x + fromT * ux + s * rx,
y1: a.y + fromT * uy + s * ry,
x2: a.x + toT * ux + s * rx,
y2: a.y + toT * uy + s * ry,
len: span,
interval: 1 / (count * BEAD_RATE * visualScale),
}
}
const flows = []
if (ab) flows.push(flow(ab, t0, t1))
if (ba) flows.push(flow(ba, t1, t0))
return flows
}
/**
* Half-width for a connection middle: logarithmic in the count, anchored
* so a single count lands exactly at WMID_MIN (~1 px line), uncapped.
* Absolute on purpose — cool routes stay visible regardless of how hot
* the hottest connection is.
*/
const scaledWidth = (count) => {
if (count <= 0) return 0
return WMID_MIN + WIDTH_GROWTH * Math.log1p(count - 1)
}
/**
* Build ribbon edges and bead flows for every aggregated page-to-page
* pair. Pairs carrying less than PRUNE_FRACTION of the total internal
* traffic are pruned (this naturally bounds the graph to ~100 edges).
*/
function buildInternalEdges(pairs, byPath, visualScale = 1) {
let total = 0
for (const [, [ab, ba]] of pairs) total += ab + ba
const minCount = total * PRUNE_FRACTION
const edges = []
const flows = []
for (const [k, [ab, ba]] of pairs) {
if (ab + ba < minCount) continue
const [pf, pt] = k.split(' ')
const a = byPath.get(pf)
const b = byPath.get(pt)
const wMid = scaledWidth((ab + ba) * visualScale)
if (wMid <= 0) continue
edges.push(buildRibbon(a, b, ab, ba, wMid))
flows.push(...buildFlows(a, b, TNODE_R, TNODE_R, ab, ba, visualScale))
}
return { edges, flows }
}
/**
* Sum the bucketed transition matrix (from -> to -> bucket ISO -> count)
* into a plain from -> to -> count matrix for the window [t0, t1).
*/
export function filterTransitionsByRange(transitions, t0, t1) {
const out = {}
for (const [fr, tos] of Object.entries(transitions || {})) {
for (const [to, buckets] of Object.entries(tos)) {
let n = 0
for (const [k, c] of Object.entries(buckets)) {
const t = Date.parse(k)
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) n += c
}
if (n) {
out[fr] = out[fr] || {}
out[fr][to] = n
}
}
}
return out
}
/** Keep only the 5-minute view buckets that fall inside [t0, t1). */
export function filterViewsByRange(views, t0, t1) {
const filtered = {}
for (const [path, buckets] of Object.entries(views || {})) {
const out = {}
for (const [k, c] of Object.entries(buckets)) {
const t = Date.parse(k)
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out[k] = c
}
if (Object.keys(out).length) filtered[path] = out
}
return filtered
}
/**
* Place external referer and exit nodes and build their edges and bead
* flows.
* Referers (incoming links) form a row centered above the map, hottest
* first; exits sit just outside their source page, fanned away from the
* center and nudged outwards until they no longer overlap any node.
* Widths and pruning use the same log scale and traffic-share rule as
* internal connections.
*/
function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
const extNodes = []
const edges = []
const flows = []
let extTotal = 0
for (const p of external) extTotal += p.in + p.out
const minCount = extTotal * PRUNE_FRACTION
const live = external.filter((p) => byPath.has(p.page))
if (!live.length) return { extNodes, edges, flows }
const width = (count) => scaledWidth(count * visualScale)
const overlaps = (x, y, r) =>
[...byPath.values(), ...extNodes].some(
(n) => Math.hypot(n.x - x, n.y - y) < (n.r ?? TNODE_R) + r + 10,
)
// Incoming: one referer node per origin, in a row centered above the
// map, with an edge to each page that origin led to.
const byExt = new Map() // ext -> pairs, sorted by total incoming count
for (const p of live.filter((p) => p.in >= minCount)) {
const g = byExt.get(p.ext) || []
g.push(p)
byExt.set(p.ext, g)
}
const origins = [...byExt]
.map(([ext, ps]) => ({ ext, ps, total: ps.reduce((s, p) => s + p.in, 0) }))
.sort((a, b) => b.total - a.total)
.slice(0, MAX_EXT_IN)
if (origins.length) {
const cx = (innerBounds.x0 + innerBounds.x1) / 2
const y = innerBounds.y0 - TNODE_R - 64
const spacing = 2 * EXT_R + 44
const x0 = cx - ((origins.length - 1) * spacing) / 2
origins.forEach(({ ext, ps }, i) => {
const total = ps.reduce((s, p) => s + p.in, 0)
const xn = { path: ext, label: extLabel(ext), x: x0 + i * spacing, y, r: EXT_R, count: total, kind: 'source' }
extNodes.push(xn)
for (const p of ps) {
const page = byPath.get(p.page)
const wMid = width(p.in)
if (wMid <= 0) continue
edges.push(buildRibbon(xn, page, p.in, 0, wMid, EXT_R, TNODE_R, true))
flows.push(...buildFlows(xn, page, EXT_R, TNODE_R, p.in, 0, visualScale))
}
})
}
// Outgoing: group by full URL so several links to the same domain stay
// distinct. Each exit node is placed one ring-gap outside its source page
// (same radial spacing internal rings use), fanned around the source angle,
// and shows the total count across all pages that link to that URL.
const GAP = radius(1) - radius(0)
const outgoing = live.filter((p) => p.out >= minCount)
.sort((a, b) => b.out - a.out)
const perPage = new Map()
const selected = []
for (const p of outgoing) {
const used = perPage.get(p.page) || 0
if (used >= MAX_EXT_OUT_PER_PAGE) continue
perPage.set(p.page, used + 1)
selected.push(p)
if (selected.length >= MAX_EXT_OUT) break
}
const exitNodes = new Map() // full URL -> node
const placedPerPage = new Map() // for angle fanning of the placement anchor
for (const p of selected) {
const page = byPath.get(p.page)
let xn = exitNodes.get(p.ext)
if (!xn) {
const used = placedPerPage.get(p.page) || 0
placedPerPage.set(p.page, used + 1)
const base = page.depth ? page.angle : Math.PI / 2
const ang = base + [0, 0.4, -0.4][used]
let dist = GAP
let x = page.x + Math.cos(ang) * dist
let y = page.y + Math.sin(ang) * dist
for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) {
dist += GAP * 0.3
x = page.x + Math.cos(ang) * dist
y = page.y + Math.sin(ang) * dist
}
xn = { path: p.ext, label: extLabel(p.ext), x, y, r: EXT_R, count: 0, kind: 'exit' }
exitNodes.set(p.ext, xn)
extNodes.push(xn)
}
xn.count += p.out
const wMid = width(p.out)
if (wMid <= 0) continue
edges.push(buildRibbon(page, xn, p.out, 0, wMid, TNODE_R, EXT_R, true))
flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0, visualScale))
}
return { extNodes, edges, flows }
}
/**
* Build the radial transition map model.
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
* there is nothing to show.
*/
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
const internal = collectInternalTransitions(data?.transitions)
const external = collectExternalPairs(data?.transitions)
const navOrder = buildNavigationOrder(pageTree)
const titles = buildTitleMap(pageTree)
const readMinutes = buildReadMinutes(visits)
if (!internal.length && !navOrder.size) return null
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
const weightFn = prepareWeights(root, navOrder)
const unit = (2 * Math.PI) / weightFn(root)
layoutAngles(root, unit, weightFn)
const maxDepth = Math.max(1, ...nodes.map((n) => n.depth))
const { radius } = positionNodes(nodes, maxDepth, unit, data?.views, titles, readMinutes)
const arcs = buildFamilyArcs(nodes, radius)
const pairs = aggregatePairs(internal)
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
// Tight bounding box of the actual page nodes; family ring arcs can sweep
// outside the node circle (e.g. a large arc between two siblings on the
// left side reaching around the right), so their geometry is included too.
// External nodes extend the box below.
const pad = 16
const xs = nodes.map((n) => n.x)
const ys = nodes.map((n) => n.y)
const bounds = {
x0: Math.min(...xs) - TNODE_R - pad,
y0: Math.min(...ys) - TNODE_R - pad,
x1: Math.max(...xs) + TNODE_R + pad,
y1: Math.max(...ys) + TNODE_R + pad,
}
for (const arc of arcs) {
if (arc.a0 == null) continue
const b = arcBounds(arc.r, arc.a0, arc.a1)
bounds.x0 = Math.min(bounds.x0, b.x0)
bounds.y0 = Math.min(bounds.y0, b.y0)
bounds.x1 = Math.max(bounds.x1, b.x1)
bounds.y1 = Math.max(bounds.y1, b.y1)
}
const ext = buildExternal(external, byPath, radius, bounds, visualScale)
for (const xn of ext.extNodes) {
bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad)
bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)
bounds.x1 = Math.max(bounds.x1, xn.x + xn.r + pad)
bounds.y1 = Math.max(bounds.y1, xn.y + xn.r + pad)
}
return {
nodes,
edges: [...edges, ...ext.edges],
flows: [...flows, ...ext.flows],
extNodes: ext.extNodes,
arcs,
bounds,
}
}