diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue
index 1f45fd1..5c11029 100644
--- a/frontend/src/TransitionGraph.vue
+++ b/frontend/src/TransitionGraph.vue
@@ -167,14 +167,16 @@ const fitPx = (label) =>
-
+
+ {{ x.path }}
{{ x.label }}
{{ formatCount(x.count) }}
-
+
+ {{ x.path }}
-
+
+ {{ n.title }}
{{ n.label }}
@@ -191,16 +194,8 @@ const fitPx = (label) =>
{{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }}
-
- ➤
- {{ n.label }}
-
+ {{ n.crumb }}
@@ -249,17 +244,18 @@ const fitPx = (label) =>
text-anchor: middle;
}
.tmap a { cursor: pointer; }
-.tmap a:hover .tnodeslug { text-decoration: underline; }
.tmap .tnodecount {
fill: var(--bg, Canvas);
opacity: 0.75;
font-size: calc(13px / var(--u, 1));
text-anchor: middle;
}
-.tmap .tnodehidden {
- fill: var(--text);
- font-size: calc(13px / var(--u, 1));
+.tmap .tnodepath {
+ fill: var(--muted);
+ font-size: calc(11px / var(--u, 1));
+ text-anchor: middle;
}
+.tmap .tnodepath-home { font-size: calc(17px / var(--u, 1)); }
section { margin-top: 1.8rem; }
diff --git a/frontend/src/analytics/transitions.js b/frontend/src/analytics/transitions.js
index 5e455dd..aa7f576 100644
--- a/frontend/src/analytics/transitions.js
+++ b/frontend/src/analytics/transitions.js
@@ -1,11 +1,14 @@
/**
* 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
+ * 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
@@ -239,38 +242,16 @@ function buildNodeTree(internal, navOrder) {
return { nodes, byPath, root: byPath.get('/') }
}
-/** Sort children by navigation order and compute each subtree's angular weight. */
-function prepareWeights(root, navOrder) {
+/** 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 weight = (n) =>
- n.children.length ? n.children.reduce((s, k) => s + weight(k), 0) : 1 / n.depth
-
- const walkSort = (n) => {
+ const walk = (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
+ n.children.forEach(walk)
}
+ walk(root)
}
/** Compute median reading time per article in whole minutes. */
@@ -294,17 +275,8 @@ function buildReadMinutes(visits) {
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).
- // Clearance is pill-width based.
- const CLEAR = TNODE_W + 20
- const GAP = CLEAR * Math.max(unit, 1 / unit)
- const radius = (d) => d * GAP
-
+/** 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
@@ -312,76 +284,110 @@ function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
}
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 pill; 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
+ // 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 left
- // blank to keep the layout, but their pill/label is not drawn.
+ // 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
}
-
- 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.
+ * 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 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 pillR = pillContact(Math.cos(first.angle), Math.sin(first.angle)).t
- const r1 = radius(n.depth) + pillR
- const r2 = radius(first.depth) - pillR
- 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
-}
+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
-/** 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
+ // 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)
}
- return { x0, y0, x1, y1 }
+
+ // 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. */
@@ -600,6 +606,7 @@ function buildInternalEdges(pairs, byPath, visualScale = 1) {
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))
@@ -723,7 +730,7 @@ function collectSourcePairs(visits) {
* Widths and pruning use the same log scale and traffic-share rule as
* internal connections.
*/
-function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualScale = 1) {
+function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale = 1) {
const extNodes = []
const edges = []
const flows = []
@@ -737,9 +744,13 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
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) => Math.hypot(n.x - x, n.y - y) < TNODE_BOUND + TNODE_BOUND + 10,
+ (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
@@ -779,6 +790,7 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
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))
@@ -788,10 +800,21 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
}
// 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)
+ // 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()
@@ -805,23 +828,26 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
}
const exitNodes = new Map() // full URL -> node
- const placedPerPage = new Map() // for angle fanning of the placement anchor
+ 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)
- 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); tries++) {
- dist += GAP * 0.3
- x = page.x + Math.cos(ang) * dist
- y = page.y + Math.sin(ang) * dist
+ 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,
@@ -845,9 +871,10 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
}
/**
- * Build the radial transition map model.
+ * Build the transition map model.
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
- * there is nothing to show.
+ * 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)
@@ -860,39 +887,25 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
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)
+ 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 actual page nodes; family ring arcs can sweep
- // outside the node pills (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.
+ // Tight bounding box of the placed page nodes; external nodes extend it.
const pad = 16
- const xs = nodes.map((n) => n.x)
- const ys = nodes.map((n) => n.y)
+ 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,
}
- 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({ sources, exits }, byPath, radius, bounds, visualScale)
+ 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)
@@ -901,11 +914,11 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
}
return {
- nodes,
+ nodes: placed,
edges: [...edges, ...ext.edges],
flows: [...flows, ...ext.flows],
extNodes: ext.extNodes,
- arcs,
+ arcs: spokes,
bounds,
}
}
diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css
index aede9b5..9f8f01d 100644
--- a/frontend/src/assets/pagerite.css
+++ b/frontend/src/assets/pagerite.css
@@ -65,6 +65,12 @@
box-sizing: border-box;
}
+/* Links never underline — including SVG link text, which the UA stylesheet
+ underlines by default. */
+a {
+ text-decoration: none;
+}
+
html {
scroll-behavior: smooth;
/* Native-scrollbar fallback styling (JS off or before pagerite.js runs):