From 7b8899af921e9d352a406ebc622e4652a8918556 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 24 Aug 2026 04:47:06 +0000 Subject: [PATCH] Transition map: bounded node scaling, concentric branch lanes, exit row at bottom --- docs/analytics.md | 23 +- frontend/src/TransitionGraph.vue | 82 ++++--- frontend/src/analytics/transitions.js | 295 ++++++++++++++------------ 3 files changed, 232 insertions(+), 168 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 1fed10d..36bd02f 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -240,10 +240,21 @@ month view labels days the same lineless way — day numbers at noon UTC, with the month name substituted for the 1st. Year is a rolling 365-day window ending at now, re-bucketed to daily points, with boundary lines at months/years. All uses the full data reach, but keeps at least the past 30 days so the chart never collapses to a tiny sliver when -the site is young. Below the charts: a radial **transition map** (all pages from -`/_api/pages` — front page at the center, each slug level on its own ring, -siblings clockwise in navigation order from the top, radial gap equal to -the arc spacing — opposite transition directions joined into organic +the site is young. Below the charts: a **transition map** (all pages from +`/_api/pages` — top-level menu items on a large-radius circular arc whose +bottom point is the last item (each earlier item a bit higher), connected +by an unlabeled top lane, each item's +subtree fanning out below it in menu order along a large-radius circular +arc that leaves heading +straight down and gradually bends right, index pages without views omitted +and their children promoted in their place. The submenu structure is drawn +as wide branch lanes: one per path prefix with at least two visible +nodes, running behind the branch's node pills as circle arcs concentric +with the fan (parent levels one radius step outward, so all lanes of a +group share exactly one form), each labeled with its branch slug along the +first inter-node gap — so the lanes reflect the path +structure even where index pages are omitted — opposite transition +directions joined into organic tapered connections whose middle width grows logarithmically with the count (a single count renders as a ~1 px line, uncapped), connections carrying less than 1% of the total traffic @@ -255,8 +266,8 @@ map: each visit is attributed to `utm_campaign`, then `utm_source`, then the referer origin, then any other `utm_*` tag, so UTM-tagged visits are grouped under their campaign/source value rather than the referer domain. A UTM source node only links to its referer when every visit carrying that tag -came from the same origin. External exits are small nodes fanned outwards -from their source page), per-page view +came from the same origin. External exits are full-size nodes in a matching +row centered below the map, so the site itself stays in the middle), per-page view counts, the top transitions and the 50 most recent visit trails. Data is streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest JSON snapshot on connect and again whenever the analytics file is updated diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index 5c11029..1b9950c 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -123,8 +123,11 @@ const startBeads = (flows) => { watch(() => graph.value?.flows, startBeads, { immediate: true }) onBeforeUnmount(() => cancelAnimationFrame(rafId)) -// Text in the graph must render at a constant screen size regardless of -// how far the enlarged graph's viewBox is scaled down to fit the panel: +// The svg never renders larger than its natural size (1 viewBox unit = 1 +// px, max-width below): the layout geometry is designed in pixel-like +// units, and upscaling would blow the pills up around their constant-size +// text. Narrow panels still scale the graph down to fit (width: 100%). +// Text renders at a constant screen size regardless of that downscale: // measure the unit→pixel ratio and expose it as --u on the svg, which the // font-size rules divide by. Falls back to 1 (raw units) until measured. const svgEl = ref(null) @@ -148,18 +151,34 @@ watch(svgEl, (el) => { watch(() => graph.value?.bounds, updateScale) onBeforeUnmount(() => resizeObs?.disconnect()) -// Font size (px) that fits a label inside the pill width at the current -// zoom: ~0.52 em average glyph width, 12 px padding per side, capped. -const fitPx = (label) => - Math.min(15, (TNODE_W * pxPerUnit.value - 24) / (0.52 * Math.max(label.length, 1))) +// Label text shortened to fit inside the pill at the current zoom (fonts +// are fixed screen sizes): drop whole trailing words first, then hard-cut +// with an ellipsis. Width estimate: ~0.52 em per glyph, 12 px padding +// per side. +const fitLabel = (label, fontPx = 15) => { + const budget = Math.max(2, (TNODE_W * pxPerUnit.value - 24) / (0.52 * fontPx)) + if (label.length <= budget) return label + const words = label.split(' ') + while (words.length > 1 && words.join(' ').length + 1 > budget) words.pop() + let out = words.join(' ') + if (out.length + 1 > budget) out = out.slice(0, Math.floor(budget) - 1) + return `${out}…` +} + +const countLabel = (n) => + n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views)