diff --git a/docs/analytics.md b/docs/analytics.md index 575f23e..1cf213d 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -236,9 +236,13 @@ 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 +offset onto parallel lanes. External sources show as a node row above the +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 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/TrailLink.vue b/frontend/src/TrailLink.vue index 007cfed..a9482e1 100644 --- a/frontend/src/TrailLink.vue +++ b/frontend/src/TrailLink.vue @@ -17,12 +17,6 @@ defineEmits(['close']) :rel="step.external ? 'noopener' : undefined" @click="(e) => { if (!step.external) $emit('close') }"> {{ formatCount(count) }}× - {{ step.slug }} + {{ step.slug }} - - diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index fbd91cb..b65c3f3 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -16,6 +16,7 @@ import { buildTransitionGraph, filterTransitionsByRange, filterViewsByRange, + filterVisitsByRange, } from './analytics/transitions.js' const props = defineProps({ @@ -48,9 +49,13 @@ const filteredData = computed(() => { } }) +const filteredVisits = computed(() => + filterVisitsByRange(props.data?.visits, window.value.t0, window.value.t1), +) + const graph = computed(() => filteredData.value - ? buildTransitionGraph(filteredData.value, props.pageTree, props.data?.visits, visualScale.value) + ? buildTransitionGraph(filteredData.value, props.pageTree, filteredVisits.value, visualScale.value) : null, ) @@ -135,7 +140,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) - + @@ -143,6 +148,14 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) {{ formatCount(x.count) }} + + + + {{ x.label }} + + {{ formatCount(x.count) }} + @@ -209,7 +222,6 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) fill: var(--text); font-size: calc(11px / var(--node-r, 34)); text-anchor: middle; - filter: saturate(0); } .tmap a { cursor: pointer; } .tmap a:hover .tnodeslug { fill: var(--accent); } diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index 8e70663..cab9eb7 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -130,7 +130,7 @@ function buildTitleMap(pageTree) { /** Last path segment for display; front page becomes a house icon. */ function slugOf(path) { - return path === '/' ? '🏠' : path.split('/').pop() + return path === '/' ? '🏠︎' : path.split('/').pop() } /** Host name of an external https origin, with scheme and www. stripped. */ diff --git a/frontend/src/analytics/transitions.js b/frontend/src/analytics/transitions.js index d0677df..4c71442 100644 --- a/frontend/src/analytics/transitions.js +++ b/frontend/src/analytics/transitions.js @@ -13,10 +13,14 @@ * connections. Animated beads flow along every edge in each direction, * emitted at time intervals inversely proportional (linear) to the * directional count. - * External referers appear as nodes in a row above the map, external exits - * as 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. + * 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' @@ -99,23 +103,19 @@ function extLabel(ext) { } /** - * Collect external transitions: referer origin -> entry page (incoming) and - * page -> exit origin (outgoing). Aggregated per (origin, page) pair, with - * separate directional counts. "(direct)" entries are not links and skipped. + * 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 collectExternalPairs(transitions) { - const pairs = new Map() // `${ext} ${page}` -> {ext, page, in, out} +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)) { - const frExt = !fr.startsWith('/') - const toExt = !to.startsWith('/') - if (frExt === toExt) continue // internal-internal or ext-ext - const ext = frExt ? fr : to - const page = frExt ? to : fr - if (!ext.startsWith('http')) continue - const k = `${ext} ${page}` - const p = pairs.get(k) || { ext, page, in: 0, out: 0 } - p[frExt ? 'in' : 'out'] += count + 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) } } @@ -227,7 +227,7 @@ function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) { n.views = viewCount(n.path) n.readMin = readMinutes[n.path] || 0 // Slug inside the circle; full title goes on the link title attribute. - const slug = n.path === '/' ? '🏠' : n.path.split('/').pop() + const slug = n.path === '/' ? '🏠︎' : n.path.split('/').pop() n.label = slug.length > 16 ? `${slug.slice(0, 15)}…` : slug n.title = titles.get(n.path) || '' // Category (non-leaf) pages with no views in this window are left @@ -537,24 +537,96 @@ export function filterViewsByRange(views, t0, t1) { 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 +} + /** - * Place external referer and exit nodes and build their edges and bead + * 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. - * Referers (incoming links) form a row centered above the map, hottest - * first; exits sit just outside their source page, fanned away from the - * center and nudged outwards until they no longer overlap any node. + * 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(external, byPath, radius, innerBounds, visualScale = 1) { +function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualScale = 1) { const extNodes = [] const edges = [] const flows = [] let extTotal = 0 - for (const p of external) extTotal += p.in + p.out + for (const p of sources) extTotal += p.in + for (const p of exits) extTotal += p.out const minCount = extTotal * PRUNE_FRACTION - const live = external.filter((p) => byPath.has(p.page)) - if (!live.length) return { extNodes, edges, flows } + 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) @@ -563,16 +635,22 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) { (n) => Math.hypot(n.x - x, n.y - y) < (n.r ?? TNODE_R) + r + 10, ) - // Incoming: one referer node per origin, in a row centered above the - // map, with an edge to each page that origin led to. - const byExt = new Map() // ext -> pairs, sorted by total incoming count - for (const p of live.filter((p) => p.in >= minCount)) { - const g = byExt.get(p.ext) || [] + // 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) - byExt.set(p.ext, g) + bySource.set(p.source, g) } - const origins = [...byExt] - .map(([ext, ps]) => ({ ext, ps, total: ps.reduce((s, p) => s + p.in, 0) })) + 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) { @@ -580,9 +658,18 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) { const y = innerBounds.y0 - TNODE_R - 64 const spacing = 2 * EXT_R + 44 const x0 = cx - ((origins.length - 1) * spacing) / 2 - origins.forEach(({ ext, ps }, i) => { - const total = ps.reduce((s, p) => s + p.in, 0) - const xn = { path: ext, label: extLabel(ext), x: x0 + i * spacing, y, r: EXT_R, count: total, kind: 'source' } + 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, + r: EXT_R, + count: total, + kind: 'source', + } extNodes.push(xn) for (const p of ps) { const page = byPath.get(p.page) @@ -599,7 +686,7 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) { // (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) - const outgoing = live.filter((p) => p.out >= minCount) + const outgoing = liveExits.filter((p) => p.out >= minCount) .sort((a, b) => b.out - a.out) const perPage = new Map() const selected = [] @@ -629,7 +716,16 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) { x = page.x + Math.cos(ang) * dist y = page.y + Math.sin(ang) * dist } - xn = { path: p.ext, label: extLabel(p.ext), x, y, r: EXT_R, count: 0, kind: 'exit' } + xn = { + path: p.ext, + href: p.ext, + label: extLabel(p.ext), + x, + y, + r: EXT_R, + count: 0, + kind: 'exit', + } exitNodes.set(p.ext, xn) extNodes.push(xn) } @@ -650,7 +746,8 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) { */ export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) { const internal = collectInternalTransitions(data?.transitions) - const external = collectExternalPairs(data?.transitions) + const sources = collectSourcePairs(visits) + const exits = collectExitPairs(data?.transitions) const navOrder = buildNavigationOrder(pageTree) const titles = buildTitleMap(pageTree) const readMinutes = buildReadMinutes(visits) @@ -690,7 +787,7 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale = bounds.y1 = Math.max(bounds.y1, b.y1) } - const ext = buildExternal(external, byPath, radius, bounds, visualScale) + const ext = buildExternal({ sources, exits }, byPath, radius, bounds, visualScale) for (const xn of ext.extNodes) { bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad) bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)