From 31000103355f8ce8b2b2f1961929b5cfc71aa26f Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 20 Aug 2026 22:06:29 +0000 Subject: [PATCH] Transition map: count-scaled edges, bead flows, external links - Edge widths grow logarithmically with the connection count (~1 px at a single count, uncapped); connections below 1% of total traffic are pruned, bounding the graph to ~100 edges. - Beads: per-direction flows emitted at a rate linear in the count, each bead simulated independently in JS (no in-flight limit), offset onto right-hand lanes so opposing flows don't collide, running under the node circles with a glow. - External links: referer origins as a node row above the map, exit origins fanned outwards from their source page. - Transitions are now stored per 5-minute bucket (sparse from -> to -> bucket -> count) so the graph filters by time range like the other series; legacy analytics files are discarded. --- docs/analytics.md | 17 +- frontend/src/TransitionGraph.vue | 92 ++++++- frontend/src/analytics/transitions.js | 340 +++++++++++++++++++++----- pagerite/analytics.py | 22 +- 4 files changed, 393 insertions(+), 78 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 91b51cc..167ef88 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -103,8 +103,10 @@ Crawler hits are grouped by User-Agent in the analytics viewer. ## Aggregates -- `transitions`: sparse nested dict `from -> to -> count`. `from` is the - referer origin or `"(direct)"` for initial loads, a page path for pings. +- `transitions`: time series of page transitions, sparse nested dict + `from -> to -> bucket -> count` with the same 5-minute bucketing as + `views`. `from` is the referer origin or `"(direct)"` for initial loads, + a page path for pings. - `views`: time series of page loads, `path -> bucket -> count`, sparse: only non-zero 5-minute buckets exist (bucket key is its floored ISO timestamp). Every load counts, including repeats within a visit; external exit origins @@ -156,7 +158,14 @@ months/years. 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 -tapered connections whose middle width is the total count over the full -recorded timescale; internal navigation only for now), per-page view +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 +pruned; beads are simulated one by one in JS (requestAnimationFrame) and +flow along each edge, emitted at a rate linearly proportional +to the directional count with no in-flight limit, opposing directions +offset onto parallel lanes. External referers show as a node row above the +map, external exits as small nodes fanned outwards from their source +page), per-page view counts, the top transitions and the 50 most recent visit trails. Data comes from `GET /_api/analytics`, which returns the raw JSON file contents. diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index e106fd9..481c39b 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -2,16 +2,18 @@ /** * Radial transition map filtered to the selected time range. * - * The server only stores an all-time transition aggregate, so this component - * derives time-filtered transitions from the visits list (which has start - * timestamps) and filters the view counts to the same window. + * Transitions are stored per 5-minute bucket (from -> to -> bucket -> + * count), so the graph sums the buckets falling inside the selected + * range, exactly like the charts and per-page views do. */ -import { computed } from 'vue' +import { computed, onBeforeUnmount, shallowRef, watch } from 'vue' import { rangeWindow } from './analytics/time.js' import { TNODE_R, + BEAD_R, + BEAD_SPEED, buildTransitionGraph, - buildTransitionsFromVisits, + filterTransitionsByRange, filterViewsByRange, } from './analytics/transitions.js' @@ -28,7 +30,7 @@ const filteredData = computed(() => { if (!props.data) return null const { t0, t1 } = window.value return { - transitions: buildTransitionsFromVisits(props.data.visits, t0, t1), + transitions: filterTransitionsByRange(props.data.transitions, t0, t1), views: filterViewsByRange(props.data.views, t0, t1), } }) @@ -38,6 +40,61 @@ const graph = computed(() => ? buildTransitionGraph(filteredData.value, props.pageTree) : null, ) + +// Bead animation: every bead is simulated independently in JS. Each flow +// (one per edge direction) emits a bead every `interval` seconds; beads +// travel at BEAD_SPEED along the segment and are dropped at the end. +// There is deliberately no cap on beads in flight. +const beads = shallowRef([]) +let rafId = 0 + +const startBeads = (flows) => { + cancelAnimationFrame(rafId) + beads.value = [] + if (!flows?.length) return + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return + + const live = [] // { flow, t0 } — one entry per bead in flight + const now = performance.now() + const emitters = flows.map((flow) => { + const interval = flow.interval * 1000 + // Pre-fill the traversal with evenly spaced beads (random phase), so + // the flow appears already running instead of starting empty. + const phase = Math.random() * interval + for (let t = now - (flow.len / BEAD_SPEED) * 1000 + phase; t <= now; t += interval) { + live.push({ flow, t0: t }) + } + return { flow, interval, next: now + phase } + }) + + const tick = (t) => { + for (const e of emitters) { + while (e.next <= t) { + live.push({ flow: e.flow, t0: e.next }) + e.next += e.interval + } + } + const out = [] + for (let i = live.length - 1; i >= 0; i--) { + const b = live[i] + const p = ((t - b.t0) / 1000) * BEAD_SPEED / b.flow.len + if (p >= 1) { + live.splice(i, 1) + continue + } + out.push({ + x: b.flow.x1 + (b.flow.x2 - b.flow.x1) * p, + y: b.flow.y1 + (b.flow.y2 - b.flow.y1) * p, + }) + } + beads.value = out + rafId = requestAnimationFrame(tick) + } + rafId = requestAnimationFrame(tick) +} + +watch(() => graph.value?.flows, startBeads, { immediate: true }) +onBeforeUnmount(() => cancelAnimationFrame(rafId))