925 lines
33 KiB
JavaScript
925 lines
33 KiB
JavaScript
/**
|
||
* 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 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 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 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'
|
||
|
||
// 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 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 = 3.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 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 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 view counts, labels and hidden flags for each node. */
|
||
function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
||
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.readMin = readMinutes[n.path] || 0
|
||
// Article title inside the pill (shortened with ellipsis as needed),
|
||
// slug as fallback for pages missing from the site tree. The short
|
||
// path (last two segments, no leading /) renders above the pill; the
|
||
// front page shows a home symbol there instead (larger).
|
||
const slug = n.path.split('/').pop()
|
||
const label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : slug)
|
||
n.label = label.length > 24 ? `${label.slice(0, 23)}…` : label
|
||
const segs = n.path.split('/').filter(Boolean)
|
||
n.crumb = n.path === '/'
|
||
? '🏠︎'
|
||
: segs.length > 2 ? `…/${segs.slice(-2).join('/')}` : segs.join('/')
|
||
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
|
||
* parabola 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. The short
|
||
* path shown above each pill (last two segments) keeps the omitted menu
|
||
* level visible.
|
||
* Also returns curved spoke paths tracing each fan: top slot to first
|
||
* member, then member to member in menu order, each bowed to the right.
|
||
*/
|
||
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.
|
||
const groups = []
|
||
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)
|
||
}
|
||
|
||
// Top row on a true circular sag: center lowest, edges raised by SAG.
|
||
const half = ((groups.length - 1) * SLOT) / 2 || 1
|
||
const SAG = TNODE_H * 0.6
|
||
const Rc = (half * half + SAG * SAG) / (2 * SAG)
|
||
const topY = (x) => SAG - Rc + Math.sqrt(Rc * Rc - x * x)
|
||
|
||
// Second pass: place groups. Fan members follow a right-opening cubic
|
||
// p(t) = (gx + B t³, y0 + t): the tangent stays vertical near the
|
||
// parent (leaving almost straight down) and bends right gently,
|
||
// reaching ~50° from vertical at the last member. Member spacing along
|
||
// the curve is the pill clearance (dt integrated against curve speed).
|
||
const spokePairs = [] // [from node, to node] — paths emitted below
|
||
groups.forEach((members, gi) => {
|
||
const gx = gi * SLOT - half
|
||
const y0 = topY(gx)
|
||
members[0].x = gx
|
||
members[0].y = y0
|
||
const m = members.length - 1
|
||
if (!m) return
|
||
const tMax = m * CLEAR * 0.9
|
||
const B = 0.4 / (tMax * tMax)
|
||
let t = 0
|
||
for (let i = 1; i <= m; i++) {
|
||
const bend = 3 * B * t * t
|
||
t += CLEAR / Math.hypot(bend, 1)
|
||
const n = members[i]
|
||
n.x = gx + B * t * t * t
|
||
n.y = y0 + t
|
||
spokePairs.push([members[i - 1], n])
|
||
}
|
||
})
|
||
|
||
// Fan spokes bow to the right via a quadratic control point pushed
|
||
// rightward from the segment midpoint.
|
||
const spokes = spokePairs.map(([p, n]) => {
|
||
const mx = (p.x + n.x) / 2
|
||
const my = (p.y + n.y) / 2
|
||
const bow = Math.hypot(n.x - p.x, n.y - p.y) * 0.18
|
||
return {
|
||
d: `M ${p.x.toFixed(2)} ${p.y.toFixed(2)} `
|
||
+ `Q ${(mx + bow).toFixed(2)} ${my.toFixed(2)} ${n.x.toFixed(2)} ${n.y.toFixed(2)}`,
|
||
}
|
||
})
|
||
return { GAP: TNODE_H * 2.6, spokes }
|
||
}
|
||
|
||
/** 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 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, 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 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 * 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)
|
||
if (a.hidden || b.hidden) continue // unplaced index pages are omitted
|
||
const wMid = scaledWidth((ab + ba) * visualScale)
|
||
if (wMid <= 0) continue
|
||
edges.push(buildRibbon(a, b, ab, ba, wMid))
|
||
flows.push(...buildFlows(a, b, 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
|
||
}
|
||
|
||
/** Keep only visits whose start time falls inside [t0, t1). */
|
||
export function filterVisitsByRange(visits, t0, t1) {
|
||
const out = []
|
||
for (const v of visits || []) {
|
||
const t = Date.parse(v.start)
|
||
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out.push(v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
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 sit just outside their source page.
|
||
* Widths and pruning use the same log scale and traffic-share rule as
|
||
* internal connections.
|
||
*/
|
||
function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale = 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 * visualScale)
|
||
|
||
// Pill-shape overlap test (axis-aligned pills): much tighter than the
|
||
// bounding-circle test, so diagonal placements can sit close.
|
||
const overlaps = (x, y) =>
|
||
[...byPath.values(), ...extNodes].some(
|
||
(n) => !n.hidden
|
||
&& Math.abs(n.x - x) < TNODE_W + 12
|
||
&& Math.abs(n.y - y) < TNODE_H + 12,
|
||
)
|
||
|
||
// Incoming: one source node per identified source, in a row centered
|
||
// above the map, with an edge to each page that source led to.
|
||
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)
|
||
if (origins.length) {
|
||
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
||
const y = innerBounds.y0 - TNODE_BOUND - 64
|
||
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: label.length > 25 ? `${label.slice(0, 24)}…` : label,
|
||
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 <= 0) continue
|
||
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
|
||
flows.push(...buildFlows(xn, page, p.in, 0, visualScale))
|
||
}
|
||
})
|
||
}
|
||
|
||
// Outgoing: group by full URL so several links to the same domain stay
|
||
// distinct; each shows the total count across all pages linking to it.
|
||
// Placement looks for empty space around the source page, always
|
||
// leftward: diagonal down-left first (often right beside the source,
|
||
// no need to drop below the fans), then left, up-left, and steeper
|
||
// fallbacks; the distance grows until a spot is free. Several exits of
|
||
// one page start at different directions.
|
||
const GAP = gap
|
||
const DIRS = [
|
||
(3 * Math.PI) / 4, // diagonal down-left
|
||
Math.PI, // left
|
||
(5 * Math.PI) / 4, // diagonal up-left
|
||
Math.PI / 2 + 0.35, // steep down-left
|
||
Math.PI - 0.35, // shallow up-left
|
||
(3 * Math.PI) / 4 + 0.5, // far down-left
|
||
]
|
||
const outgoing = liveExits.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 the placement direction offset
|
||
for (const p of selected) {
|
||
const page = byPath.get(p.page)
|
||
if (page.hidden) continue
|
||
let xn = exitNodes.get(p.ext)
|
||
if (!xn) {
|
||
const used = placedPerPage.get(p.page) || 0
|
||
placedPerPage.set(p.page, used + 1)
|
||
let x = 0
|
||
let y = 0
|
||
let found = false
|
||
for (let di = 0; di < DIRS.length && !found; di++) {
|
||
const ang = DIRS[(used + di) % DIRS.length]
|
||
for (let dist = GAP; dist <= GAP * 3.5; dist += GAP * 0.4) {
|
||
x = page.x + Math.cos(ang) * dist
|
||
y = page.y + Math.sin(ang) * dist
|
||
if (!overlaps(x, y)) { found = true; break }
|
||
}
|
||
}
|
||
if (!found) continue // no empty space near the page: leave it out
|
||
xn = {
|
||
path: p.ext,
|
||
href: p.ext,
|
||
label: extLabel(p.ext),
|
||
x,
|
||
y,
|
||
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, true))
|
||
flows.push(...buildFlows(page, xn, p.out, 0, visualScale))
|
||
}
|
||
|
||
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 family spokes; `nodes` only
|
||
* contains placed (visible) nodes.
|
||
*/
|
||
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
||
const internal = collectInternalTransitions(data?.transitions)
|
||
const sources = collectSourcePairs(visits)
|
||
const exits = collectExitPairs(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)
|
||
sortByNav(root, navOrder)
|
||
annotateNodes(nodes, data?.views, titles, readMinutes)
|
||
const { GAP, spokes } = layoutGroups(root)
|
||
const placed = nodes.filter((n) => !n.hidden)
|
||
const pairs = aggregatePairs(internal)
|
||
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
||
|
||
// Tight bounding box of the placed page nodes; external nodes extend it.
|
||
const pad = 16
|
||
const xs = placed.map((n) => n.x)
|
||
const ys = placed.map((n) => n.y)
|
||
const bounds = {
|
||
x0: Math.min(...xs) - TNODE_BOUND - pad,
|
||
y0: Math.min(...ys) - TNODE_BOUND - pad,
|
||
x1: Math.max(...xs) + TNODE_BOUND + pad,
|
||
y1: Math.max(...ys) + TNODE_BOUND + pad,
|
||
}
|
||
|
||
const ext = buildExternal({ sources, exits }, byPath, GAP, bounds, visualScale)
|
||
for (const xn of ext.extNodes) {
|
||
bounds.x0 = Math.min(bounds.x0, xn.x - TNODE_BOUND - pad)
|
||
bounds.y0 = Math.min(bounds.y0, xn.y - TNODE_BOUND - pad)
|
||
bounds.x1 = Math.max(bounds.x1, xn.x + TNODE_BOUND + pad)
|
||
bounds.y1 = Math.max(bounds.y1, xn.y + TNODE_BOUND + pad)
|
||
}
|
||
|
||
return {
|
||
nodes: placed,
|
||
edges: [...edges, ...ext.edges],
|
||
flows: [...flows, ...ext.flows],
|
||
extNodes: ext.extNodes,
|
||
arcs: spokes,
|
||
bounds,
|
||
}
|
||
}
|