/** * 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, } }