/** * Radial transition map and helpers. * * Site map following the menu structure: top-level items in a row at the * top (below the external source row), each item's subtree fanning out * below it in menu order along a slightly circular downward arc. Index * pages with no views are omitted, their children moving up in their * place. All pages of the site are shown (from /_api/pages), plus any * extra paths seen in transitions (deleted pages); these form their own * top-level groups. Internal path -> path transitions join opposite * directions into straight connections (middle width = total * count; connectors flare into the node pills at both ends and wrap * around their backs, surrounding them; the pills are drawn on top). Connection width grows * logarithmically with the daily hit rate (base-2 log, one hit/day * renders zero width, each doubling adds a fixed step, uncapped); * 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 sources appear as nodes in a row above the map. Sources are * identified from visit records in this order: utm_campaign, utm_source, * referer, then other utm_* tags. Visits with a UTM tag are grouped under * that tag's value, not under the referer domain. A UTM source node only * becomes a clickable link when every visit carrying that UTM tag came * from the same referer. External exits are full-size nodes in a row below * the map, mirroring the source row, so the site itself stays in the * middle. Each distinct full exit URL is its own node. Self-loops (reload * pings) are skipped. */ import { MIN_READ_SECONDS, readMapOf } from './format.js' // Nodes are constant-size pills (stadium rects) holding the slug and the // view count on two centered lines. TNODE_BOUND is the pill's bounding // radius, used for layout clearance and placement; connectors and flows // use the exact outline geometry instead (pillContact below). export const TNODE_W = 160 export const TNODE_H = 54 const TNODE_BOUND = Math.hypot(TNODE_W, TNODE_H) / 2 const PILL_R = TNODE_H / 2 // cap radius and straight-section half-height const PILL_OFF = TNODE_W / 2 - PILL_R // x offset of the cap centers /** * Where the ray from a node center along (ux, uy) exits the pill outline * (a capsule: straight top/bottom plus semicircular caps), enlarged by * `margin`. Returns the distance `t` to the contact point and the outline * arc position `s` of that point (see pillPointAt). */ const pillContact = (ux, uy, margin = 0) => { const r = PILL_R + margin const off = PILL_OFF + margin const q = (Math.PI / 2) * r // Straight top/bottom: valid when the crossing lands on the flat section. let tf = Infinity if (Math.abs(uy) > 1e-9) { const t = r / Math.abs(uy) if (Math.abs(t * ux) <= off + 1e-9) tf = t } // Rounded cap on the side the ray points to. const cx = off * (ux >= 0 ? 1 : -1) const disc = r * r - (cx * uy) ** 2 const tc = disc >= 0 ? cx * ux + Math.sqrt(disc) : Infinity if (tf <= tc) { const x = tf * ux return { t: tf, s: uy > 0 ? q + off - x : q + 2 * off + Math.PI * r + x + off } } if (tc < Infinity) { let th = Math.atan2(tc * uy, tc * ux - cx) if (th < 0) th += 2 * Math.PI const s = cx > 0 ? th <= Math.PI / 2 ? th * r : q + 4 * off + Math.PI * r + (th - (3 * Math.PI) / 2) * r : q + 2 * off + (th - Math.PI / 2) * r return { t: tc, s } } return { t: TNODE_BOUND + margin, s: 0 } } /** Total perimeter of the (margined) pill outline. */ const pillPerimeter = (margin = 0) => 4 * (PILL_OFF + margin) + 2 * Math.PI * (PILL_R + margin) /** * Point on the pill outline at arc position `s`, counterclockwise from the * right cap tip: right cap up, top flat right-to-left, left cap down, * bottom flat left-to-right, right cap up to the tip. Pills are never * rotated, so the returned offset from the node center is in absolute * coordinates. */ const pillPointAt = (s, margin = 0) => { const r = PILL_R + margin const off = PILL_OFF + margin const P = pillPerimeter(margin) const q = (Math.PI / 2) * r s = ((s % P) + P) % P if (s < q) { const th = s / r return [off + r * Math.cos(th), r * Math.sin(th)] } s -= q if (s < 2 * off) return [off - s, r] s -= 2 * off if (s < Math.PI * r) { const th = Math.PI / 2 + s / r return [-off + r * Math.cos(th), r * Math.sin(th)] } s -= Math.PI * r if (s < 2 * off) return [-off + s, -r] s -= 2 * off const th = (3 * Math.PI) / 2 + s / r return [off + r * Math.cos(th), r * Math.sin(th)] } /** Unit tangent to the pill outline at arc position `s`, in the direction * of increasing `s` (numeric; exact on both flats and caps). */ const pillTangent = (s, margin = 0) => { const [x1, y1] = pillPointAt(s - 0.5, margin) const [x2, y2] = pillPointAt(s + 0.5, margin) const m = Math.hypot(x2 - x1, y2 - y1) || 1 return [(x2 - x1) / m, (y2 - y1) / m] } // Edge width: half-width = WIDTH_GROWTH * log2(daily / DAILY_REF), // where `daily` is the connection's hit rate in hits/day (callers scale // raw counts by DAY / range). DAILY_REF hits/day renders zero width; // WIDTH_GROWTH is the half-width added per doubling of the rate. // Connections whose thin middle would render below MIN_WMID are culled // entirely (fainter strands are practically invisible), as are those // carrying less than PRUNE_FRACTION of the total traffic (this also // keeps the graph under ~100 connections). const WIDTH_GROWTH = 1.4 // half-width px per doubling of the daily rate const DAILY_REF = 0.8 // hits/day at which the width is zero const PRUNE_FRACTION = 0.01 // ~0.8 px full width at natural size (1 viewBox unit = 1 px). const MIN_WMID = 0.4 // Beads: each edge direction emits beads at dailyRate * BEAD_RATE beads // per second (linear in the daily hit rate). The rate is much reduced // from real time to keep the animation lightweight. The component // simulates every bead independently in JS with a constant traversal // time per edge (speed relative to span length), with no limit on beads // in flight. export const BEAD_R = 3.2 const BEAD_RATE = 0.0084 // beads per second per hit/day 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 in the bottom row const EXT_GAP = 12 // vertical margin of the source/exit rows to the map /** 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). * Pills clip the text at their border; no length cap needed. */ function extLabel(ext) { try { return new URL(ext).hostname.replace(/^www\./, '') } catch { return ext.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0] } } /** * Collect outgoing external transitions: page path -> full exit URL. * Aggregated per (URL, page) pair. Incoming external links are now derived * from visit records (which carry UTM tags), so only exits remain here. */ function collectExitPairs(transitions) { const pairs = new Map() // `${ext} ${page}` -> {ext, page, out} for (const [fr, tos] of Object.entries(transitions || {})) { if (!fr.startsWith('/')) continue // ignore external -> anything for (const [to, count] of Object.entries(tos)) { if (!to.startsWith('http')) continue const k = `${to} ${fr}` const p = pairs.get(k) || { ext: to, page: fr, out: 0 } p.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 each node's children by navigation order, recursively. */ function sortByNav(root, navOrder) { const byNav = (a, b) => (navOrder.get(a.path) ?? Infinity) - (navOrder.get(b.path) ?? Infinity) || a.path.localeCompare(b.path) const walk = (n) => { n.children.sort(byNav) n.children.forEach(walk) } walk(root) } /** Compute median reading time per article in seconds. */ function buildReadSeconds(visits) { const times = {} for (const v of visits || []) { for (const [path, sec] of Object.entries(readMapOf(v))) { if (sec >= MIN_READ_SECONDS) { ; (times[path] || (times[path] = [])).push(sec) } } } const seconds = {} 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 seconds[path] = Math.round(median) } return seconds } /** Compute view counts, labels and hidden flags for each node. */ function annotateNodes(nodes, viewsData, titles, readSeconds) { const viewCount = (p) => { let n = 0 for (const c of Object.values(viewsData?.[p] || {})) n += c return n } for (const n of nodes) { n.views = viewCount(n.path) n.readSec = readSeconds[n.path] || 0 // Article title inside the pill (clipped at the pill border on // render), slug as fallback for pages missing from the site tree. n.label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : n.path.split('/').pop()) n.title = titles.get(n.path) || '' // Category (non-leaf) pages with no views in this window are omitted: // their children move up in their place (see layoutGroups). n.hidden = n.children.length > 0 && n.views === 0 } } /** * Top-down layout following the menu structure: top-level items in an * equally spaced row at the top (right below the external source row), * the row following a shallow circular sag (center lowest) so connections * between neighbors do not overlap the pills in between. Each top item's * whole subtree fans out from it in menu (DFS preorder) order along a * large-radius circular arc that leaves the parent heading straight down * and gradually bends to the right β€” no horizontal space is reserved for fans, they * extend under the slots to their right. Hidden index pages are omitted * from the fan; when the top item itself is hidden, the fan shifts one * slot up, the first visible child taking the top position. Branch lanes * labeled with the branch slug (see the branch-lane pass at the end) * keep the omitted menu levels visible. */ function layoutGroups(root) { // Top slots are spaced well over one pill width apart regardless of // fan sizes. const SLOT = TNODE_W + 100 const CLEAR = TNODE_W * 0.8 // fan spacing per member along the curve // First pass: visible members per group, in menu order. Hidden index // pages are skipped, but their children still appear. The front page // forms its own group. groupRoots keeps each group's subtree root for // the branch-curve pass below. const groups = [] const groupRoots = [] for (const g of [root, ...root.children]) { const members = [] if (g === root) { if (!g.hidden) members.push(g) } else { const walk = (n) => { if (!n.hidden) members.push(n) n.children.forEach(walk) } walk(g) } if (members.length) { groups.push(members) groupRoots.push(g) } } // Top row on a large-radius circular arc whose bottom point is the // LAST top item: each earlier item sits a bit higher (drop = 15% of // the row span). Flat row when there is a single group. const half = ((groups.length - 1) * SLOT) / 2 || 1 const span = (groups.length - 1) * SLOT const topD = span * 0.15 const R_T = span ? (span * span + topD * topD) / (2 * topD) : 0 const topY = span ? (x) => topD - R_T + Math.sqrt(R_T * R_T - (x - half) * (x - half)) : () => 0 // Second pass: place groups. Fan members follow a circular arc of // large radius FAN_R centered at (gx + FAN_R, y0): the trail leaves // the top node heading straight down (vertical tangent) and bends // right gently, member i at arc angle Ο€ βˆ’ iΒ·CLEAR/FAN_R (spaced by // arc length CLEAR). A circle β€” not a spline β€” so the branch lanes // below can be concentric arcs: identical forms, only radii differ. const FAN_R = 1000 groups.forEach((members, gi) => { const gx = gi * SLOT - half const y0 = topY(gx) members[0].x = gx members[0].y = y0 for (let i = 1; i < members.length; i++) { const th = Math.PI - (i * CLEAR) / FAN_R members[i].x = gx + FAN_R * (1 + Math.cos(th)) members[i].y = y0 + FAN_R * Math.sin(th) } }) // Branch lanes: one wide arc per path prefix (slug depth β‰₯ 1) whose // subtree holds at least two visible nodes (a branch's visible nodes // form one contiguous run in the fan's DFS preorder). Every lane of a // group is an arc around the group's fan center with a radius one // INDENT larger per parent level β€” concentric circles, so all lanes // share exactly one form. Lanes span their branch's nodes plus a // little extra tucked under the first/last pill (so the line caps are // never visible) and run behind the pills. A label arc carries the // branch slug, left-aligned just past the first pill and free to run // to the lane's end β€” longer text simply passes under later pills, // which are drawn on top. Hidden (unplaced) index pages still // define a lane: it follows their promoted children, so lanes reflect // the path structure rather than page existence. const INDENT = 20 // lane spacing (radius) per nesting level (> lane width) const END_TUCK = 22 // arc units tucked under the first/last pill const GAP_TRIM = 32 // label arc clearance from the pills // Labels are left-aligned on their guide: the guide starts just past the // source pill's edge, the earliest point where the text is visible. const LABEL_PAD = 6 // The label guide rides GUIDE_OFF outward of the lane centerline: the // text's alphabetic baseline sits on the guide, so this puts the // glyph middle (not the baseline) on the lane center at any zoom β€” // dominant-baseline tricks are em-based and break under downscale. const GUIDE_OFF = 3.5 const branches = [] groups.forEach((members, gi) => { const g = groupRoots[gi] if (g === root || members.length < 2) return const idx = new Map(members.map((n, i) => [n, i])) const C = [gi * SLOT - half + FAN_R, topY(gi * SLOT - half)] const walk = (n) => { let first = Infinity let last = -1 const span = (m) => { const k = idx.get(m) if (k !== undefined) { first = Math.min(first, k) last = Math.max(last, k) } m.children.forEach(span) } span(n) if (n.depth >= 1 && last > first) { branches.push({ depth: n.depth, name: n.path.split('/').pop(), C, first, last }) } n.children.forEach(walk) } walk(g) }) const depthMax = branches.reduce((d, b) => Math.max(d, b.depth), 1) let arcLeft = Infinity // leftmost lane point, for the bounding box const arcs = branches.map(({ depth, name, C, first, last }) => { const R = FAN_R + (depthMax - depth) * INDENT const th = (i) => Math.PI - (i * CLEAR) / FAN_R const pt = (a, r) => [C[0] + r * Math.cos(a), C[1] + r * Math.sin(a)] // Arc from angle a down to angle b (a > b; visually counterclockwise // from the west point downward, hence sweep flag 0). const arc = (a, b, r) => { const [x0, y0] = pt(a, r) const [x1, y1] = pt(b, r) return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 0 ${x1.toFixed(2)} ${y1.toFixed(2)}` } const d = arc(th(first) + END_TUCK / R, th(last) - END_TUCK / R, R) // Start just past the first pill: lanes leave the source node nearly // vertically, so the pill's extent along the arc is its half height. // The guide runs to the lane's end so long slugs are never cut off. const ld = arc(th(first) - (TNODE_H / 2 + LABEL_PAD) / R, th(last) - END_TUCK / R, R + GUIDE_OFF) arcLeft = Math.min(arcLeft, pt(th(first) + END_TUCK / R, R)[0]) return { d, ld, label: name } }) // Top lane: an arc along the top row's own circle, connecting // the top nodes of all groups and tucked under the first and last of // them (the arc bottoms at the last item, so it continues rightward // under its pill). Drawn 50% thicker than branch lanes. A 🏠︎ label // marks the lane right after the home pill, on a guide arc like // the branch labels but with the offset and clearance scaled up by the // same 50% to keep the glyph centered on the wider lane. if (span) { const d = `M ${(-half - END_TUCK).toFixed(2)} ${topY(-half - END_TUCK).toFixed(2)} ` + `A ${R_T.toFixed(2)} ${R_T.toFixed(2)} 0 0 0 ${(half + END_TUCK).toFixed(2)} ${topY(half + END_TUCK).toFixed(2)}` const rG = R_T + GUIDE_OFF * 1.5 const ptG = (x) => [x, topD - R_T + Math.sqrt(rG * rG - (x - half) ** 2)] // Left-aligned like the branch labels: the guide starts just past the // home pill's edge (scaled with the lane thickness). const g0 = -half + TNODE_W / 2 + LABEL_PAD * 1.5 const g1 = SLOT - half - TNODE_W / 2 - GAP_TRIM * 1.5 const [gx0, gy0] = ptG(g0) const [gx1, gy1] = ptG(g1) arcs.unshift({ d, ld: `M ${gx0.toFixed(2)} ${gy0.toFixed(2)} A ${rG.toFixed(2)} ${rG.toFixed(2)} 0 0 0 ${gx1.toFixed(2)} ${gy1.toFixed(2)}`, label: '🏠︎', top: true, }) } return { arcs, arcLeft } } /** 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). Each end flares into the node's pill surround (the outline * enlarged by margin S): the flare contact points follow the pill outline * a constant arc distance to each side of the direct contact point, and * the back of the ribbon wraps all the way around the pill between them, * surrounding the node. The pills themselves are drawn on top. */ function buildRibbon(a, b, ab, ba, wMid, 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 // Direct contact: where the centerline exits each pill's surround. const S = 4 const cA = pillContact(ux, uy, S) const cB = pillContact(-ux, -uy, S) // Flares take a fair share of the free span while leaving the // count-scaled thin middle a visible share of the connection length. // The maximum flare length scales with the contact distance so wide // approach angles still show a wide connector end. const free = Math.max(0, len - cA.t - cB.t) const FLARE = Math.min(Math.max(cA.t, cB.t) * 1.2, free * 0.4) // Flare endpoints: walk the outline a constant arc distance to each // side of the direct contact point (spanning flats and caps alike). const D = (Math.PI / 4) * (PILL_R + S) // Per node: endpoints for the +n (left) and -n (right) flare sides, // each with its arc position, absolute point, and an outline tangent // oriented back toward the direct contact point. const ends = (cx, cy, contact) => { const pick = (s) => { const [px, py] = pillPointAt(s, S) // Outline tangent oriented back toward the direct contact point // (the flare side sweeps from the contact point around to its // endpoint and into the connection), so it can never fork outward. const tan = pillTangent(s, S) if (s > contact.s) { tan[0] = -tan[0]; tan[1] = -tan[1] } return { s, p: [cx + px, cy + py], tan, side: px * nx + py * ny } } const plus = pick(contact.s + D) const minus = pick(contact.s - D) return plus.side >= 0 ? [plus, minus] : [minus, plus] } const [aLeftEnd, aRightEnd] = ends(a.x, a.y, cA) const [bLeftEnd, bRightEnd] = ends(b.x, b.y, cB) // 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, ] // One side of a flare: from the outline endpoint, leaving tangent to // the pill outline, to the connection middle arriving parallel with // the centerline. The tangent pull is clamped so the control point // stays well on its own side of the centerline β€” otherwise a long // flare on a rounded cap crosses the opposite side. const flarePoints = (end, midT, s, dir) => { let hEnd = FLARE * 0.65 const hMid = FLARE * 0.4 const tanS = end.tan[0] * nx + end.tan[1] * ny // inward rate if (tanS * end.side < 0) { hEnd = Math.min(hEnd, (Math.abs(end.side) * 0.6) / Math.abs(tanS)) } return { pEnd: end.p, cEnd: [end.p[0] + end.tan[0] * hEnd, end.p[1] + end.tan[1] * hEnd], cMid: P(midT - dir * hMid, s * wMid), pMid: P(midT, s * wMid), } } // 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)} ` } // Trace the surround outline the long way around (behind the node) from // arc s1 to arc s2. Sampled as a polyline: the visible result is a thin // halo hugging the pill, so exact arc segments are unnecessary. const outlineWrap = (cx, cy, s1, s2) => { const per = pillPerimeter(S) const dPlus = ((s2 - s1) % per + per) % per const total = dPlus > per / 2 ? dPlus : per - dPlus const dir = dPlus > per / 2 ? 1 : -1 const n = Math.max(4, Math.ceil(total / 6)) let out = '' for (let i = 1; i <= n; i++) { const [x, y] = pillPointAt(s1 + (dir * total * i) / n, S) out += `L ${(cx + x).toFixed(2)} ${(cy + y).toFixed(2)} ` } return out } const aLeft = flarePoints(aLeftEnd, cA.t + FLARE, 1, 1) const bLeft = flarePoints(bLeftEnd, len - cB.t - FLARE, 1, -1) const bRight = flarePoints(bRightEnd, len - cB.t - FLARE, -1, -1) const aRight = flarePoints(aRightEnd, cA.t + FLARE, -1, 1) // Each end wraps the full back of the node pill between its two flare // contact points (bLeft -> bRight around B, aRight -> aLeft around A). const d = `M ${fmtPt(aLeft.pEnd)} ` + curve(aLeft) + `L ${fmtPt(bLeft.pMid)} ` + curve(bLeft, true) + outlineWrap(b.x, b.y, bLeftEnd.s, bRightEnd.s) + curve(bRight) + `L ${fmtPt(aRight.pMid)} ` + curve(aRight, true) + outlineWrap(a.x, a.y, aRightEnd.s, aLeftEnd.s) + '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 pills, 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 the daily hit rate * 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, ab, ba, dayScale = 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 rA = pillContact(ux, uy).t const rB = pillContact(-ux, -uy).t 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 * dayScale), } } const flows = [] // Stable key per edge direction so the component's bead simulation can // match flows across data reloads and keep bead phases/positions. if (ab) flows.push({ ...flow(ab, t0, t1), key: `${a.path} ${b.path}` }) if (ba) flows.push({ ...flow(ba, t1, t0), key: `${b.path} ${a.path}` }) return flows } /** * Half-width for a connection middle: base-2 logarithmic in the daily * hit rate, zero at DAILY_REF hits/day, uncapped. Absolute on purpose β€” * cool routes stay visible regardless of how hot the hottest connection * is. Callers cull results below MIN_WMID. */ const scaledWidth = (daily) => { if (daily <= 0) return 0 return WIDTH_GROWTH * Math.log2(daily / DAILY_REF) } /** * 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, dayScale = 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) if (a.hidden || b.hidden) continue // unplaced index pages are omitted const wMid = scaledWidth((ab + ba) * dayScale) if (wMid < MIN_WMID) continue edges.push(buildRibbon(a, b, ab, ba, wMid)) flows.push(...buildFlows(a, b, ab, ba, dayScale)) } return { edges, flows } } const UTM_PRIORITY = ['utm_campaign', 'utm_source'] const UTM_FALLBACK = ['utm_medium', 'utm_content', 'utm_term', 'utm_id'] /** Identify the source of a visit according to the requested priority. */ function identifySource(visit) { const utm = visit.utm || {} for (const k of UTM_PRIORITY) { const v = utm[k] if (v) return { value: v, isUtm: true } } if (visit.referer?.startsWith('http')) { return { value: visit.referer, isUtm: false } } for (const k of UTM_FALLBACK) { const v = utm[k] if (v) return { value: v, isUtm: true } } return null } /** * Collect source -> entry page pairs from visit records. Sources are * identified by UTM campaign/source (then referer, then other UTM tags). * A UTM source only gets a link href when every visit using that source * came from the same referer; referer sources always link to their origin. */ function collectSourcePairs(visits) { const groups = new Map() // `${source}\0${page}` -> pair for (const v of visits || []) { const src = identifySource(v) if (!src) continue const k = `${src.value}\0${v.entry}` const p = groups.get(k) || { source: src.value, page: v.entry, in: 0, refs: new Set(), missingRef: false, href: null, isUtm: src.isUtm, } p.in += 1 if (v.referer?.startsWith('http')) { p.refs.add(v.referer) } else { p.missingRef = true } groups.set(k, p) } for (const p of groups.values()) { if (p.isUtm && !p.missingRef && p.refs.size === 1) { const ref = [...p.refs][0] if (ref.startsWith('http')) p.href = ref } else if (!p.isUtm && p.source.startsWith('http')) { p.href = p.source } } return [...groups.values()] } /** * Place external source and exit nodes and build their edges and bead * flows. * Sources (incoming links) are derived from visit UTM/referer data and form * a row centered above the map, hottest first; exits come from the * transition matrix and form a matching row centered below the map, so * the site itself stays in the middle. Both rows sit EXT_GAP beyond the * map's bounds. * Widths and pruning use the same log scale and traffic-share rule as * internal connections. */ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) { const extNodes = [] const edges = [] const flows = [] let extTotal = 0 for (const p of sources) extTotal += p.in for (const p of exits) extTotal += p.out const minCount = extTotal * PRUNE_FRACTION const liveSources = sources.filter((p) => byPath.has(p.page)) const liveExits = exits.filter((p) => byPath.has(p.page)) if (!liveSources.length && !liveExits.length) return { extNodes, edges, flows } const width = (count) => scaledWidth(count * dayScale) // Incoming: one source node per identified source, in a row centered // above the map, with an edge to each page that source led to. A source // whose connectors are all culled (below MIN_WMID) is dropped itself. const bySource = new Map() // source -> pairs, sorted by total incoming count for (const p of liveSources.filter((p) => p.in >= minCount)) { const g = bySource.get(p.source) || [] g.push(p) bySource.set(p.source, g) } const origins = [...bySource] .map(([source, ps]) => ({ source, ps, total: ps.reduce((s, p) => s + p.in, 0), href: ps[0].href, isUtm: ps[0].isUtm, })) .sort((a, b) => b.total - a.total) .slice(0, MAX_EXT_IN) .filter(({ ps }) => ps.some((p) => !byPath.get(p.page).hidden && width(p.in) >= MIN_WMID)) if (origins.length) { const cx = (innerBounds.x0 + innerBounds.x1) / 2 const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP const spacing = TNODE_W + 44 const x0 = cx - ((origins.length - 1) * spacing) / 2 origins.forEach(({ source, ps, total, href, isUtm }, i) => { const label = isUtm ? source : extLabel(source) const xn = { path: source, href, label, // clipped at the pill border on render x: x0 + i * spacing, y, count: total, kind: 'source', } extNodes.push(xn) for (const p of ps) { const page = byPath.get(p.page) if (page.hidden) continue const wMid = width(p.in) if (wMid < MIN_WMID) continue edges.push(buildRibbon(xn, page, p.in, 0, wMid, true)) flows.push(...buildFlows(xn, page, p.in, 0, dayScale)) } }) } // Outgoing: one exit node per distinct full URL (so several links to // the same domain stay distinct), showing the total count across all // pages linking to it, in a row centered below the map (hottest // first), mirroring the source row above. Each (URL, page) pair // contributes an edge from that page. An exit whose connectors are all // culled (below MIN_WMID) is dropped itself. const byExt = new Map() // full URL -> { ext, out, pairs } for (const p of liveExits.filter((p) => p.out >= minCount)) { const g = byExt.get(p.ext) || { ext: p.ext, out: 0, pairs: [] } g.out += p.out g.pairs.push(p) byExt.set(p.ext, g) } const targets = [...byExt.values()] .sort((a, b) => b.out - a.out) .slice(0, MAX_EXT_OUT) .filter(({ pairs }) => pairs.some((p) => !byPath.get(p.page).hidden && width(p.out) >= MIN_WMID)) if (targets.length) { const cx = (innerBounds.x0 + innerBounds.x1) / 2 const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP const spacing = TNODE_W + 44 const x0 = cx - ((targets.length - 1) * spacing) / 2 targets.forEach(({ ext, out, pairs }, i) => { const xn = { path: ext, href: ext, label: extLabel(ext), x: x0 + i * spacing, y, count: out, kind: 'exit', } extNodes.push(xn) for (const p of pairs) { const page = byPath.get(p.page) if (page.hidden) continue const wMid = width(p.out) if (wMid < MIN_WMID) continue edges.push(buildRibbon(page, xn, p.out, 0, wMid, true)) flows.push(...buildFlows(page, xn, p.out, 0, dayScale)) } }) } return { extNodes, edges, flows } } /** * Build the transition map model. * Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when * there is nothing to show. `arcs` holds the branch curves; `nodes` only * contains placed (visible) nodes. `dayScale` converts raw counts to a * daily hit rate (DAY / range ms) for edge widths and bead rates. */ export function buildTransitionGraph(data, pageTree, visits = [], dayScale = 1) { const internal = collectInternalTransitions(data?.transitions) const sources = collectSourcePairs(visits) const exits = collectExitPairs(data?.transitions) const navOrder = buildNavigationOrder(pageTree) const titles = buildTitleMap(pageTree) const readSeconds = buildReadSeconds(visits) if (!internal.length && !navOrder.size) return null const { nodes, byPath, root } = buildNodeTree(internal, navOrder) sortByNav(root, navOrder) annotateNodes(nodes, data?.views, titles, readSeconds) const { arcs, arcLeft } = layoutGroups(root) const placed = nodes.filter((n) => !n.hidden) const pairs = aggregatePairs(internal) const { edges, flows } = buildInternalEdges(pairs, byPath, dayScale) // Tight bounding box of the placed page nodes, extended to cover the // branch curves running left of the pills; external nodes extend it. // Margins cover just the ribbon surround (pill outline + flare margin // S=4) plus a small pad: the pill's half width/height, not its diagonal // radius, so the map crops tight especially at top and bottom. const pad = 8 const MX = TNODE_W / 2 + 4 + pad const MY = TNODE_H / 2 + 4 + pad const xs = placed.map((n) => n.x) const ys = placed.map((n) => n.y) const bounds = { x0: Math.min(Math.min(...xs) - MX, arcLeft - pad), y0: Math.min(...ys) - MY, x1: Math.max(...xs) + MX, y1: Math.max(...ys) + MY, } const ext = buildExternal({ sources, exits }, byPath, bounds, dayScale) for (const xn of ext.extNodes) { bounds.x0 = Math.min(bounds.x0, xn.x - MX) bounds.y0 = Math.min(bounds.y0, xn.y - MY) bounds.x1 = Math.max(bounds.x1, xn.x + MX) bounds.y1 = Math.max(bounds.y1, xn.y + MY) } return { nodes: placed, edges: [...edges, ...ext.edges], flows: [...flows, ...ext.flows], extNodes: ext.extNodes, arcs, bounds, } }