Support for UTM tags in transition graph as source sites.

This commit is contained in:
2026-08-21 23:50:01 +00:00
parent 375b4b6bdb
commit 6199e5a69e
5 changed files with 163 additions and 56 deletions
+7 -3
View File
@@ -236,9 +236,13 @@ carrying less than 1% of the total traffic
pruned; beads are simulated one by one in JS (requestAnimationFrame) and pruned; beads are simulated one by one in JS (requestAnimationFrame) and
flow along each edge, emitted at a rate linearly proportional flow along each edge, emitted at a rate linearly proportional
to the directional count with no in-flight limit, opposing directions to the directional count with no in-flight limit, opposing directions
offset onto parallel lanes. External referers show as a node row above the offset onto parallel lanes. External sources show as a node row above the
map, external exits as small nodes fanned outwards from their source map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
page), per-page view 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 counts, the top transitions and the 50 most recent visit trails. Data is
streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest
JSON snapshot on connect and again whenever the analytics file is updated JSON snapshot on connect and again whenever the analytics file is updated
+1 -7
View File
@@ -17,12 +17,6 @@ defineEmits(['close'])
:rel="step.external ? 'noopener' : undefined" :rel="step.external ? 'noopener' : undefined"
@click="(e) => { if (!step.external) $emit('close') }"> @click="(e) => { if (!step.external) $emit('close') }">
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small> <small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
<span :class="{ desat: step.home }">{{ step.slug }}</span> <span>{{ step.slug }}</span>
</a> </a>
</template> </template>
<style scoped>
.desat {
filter: saturate(0);
}
</style>
+15 -3
View File
@@ -16,6 +16,7 @@ import {
buildTransitionGraph, buildTransitionGraph,
filterTransitionsByRange, filterTransitionsByRange,
filterViewsByRange, filterViewsByRange,
filterVisitsByRange,
} from './analytics/transitions.js' } from './analytics/transitions.js'
const props = defineProps({ 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(() => const graph = computed(() =>
filteredData.value filteredData.value
? buildTransitionGraph(filteredData.value, props.pageTree, props.data?.visits, visualScale.value) ? buildTransitionGraph(filteredData.value, props.pageTree, filteredVisits.value, visualScale.value)
: null, : null,
) )
@@ -135,7 +140,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
<circle v-for="(b, i) in beads" :key="'b' + i" <circle v-for="(b, i) in beads" :key="'b' + i"
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" /> :cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
<g v-for="(x, i) in graph.extNodes" :key="'x' + i"> <g v-for="(x, i) in graph.extNodes" :key="'x' + i">
<a :href="x.path" target="_blank" rel="noopener" :title="x.path"> <a v-if="x.href" :href="x.href" target="_blank" rel="noopener" :title="x.path">
<circle :cx="x.x" :cy="x.y" :r="x.r" <circle :cx="x.x" :cy="x.y" :r="x.r"
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" /> :class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }"> <text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
@@ -143,6 +148,14 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
</text> </text>
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text> <text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text>
</a> </a>
<g v-else :title="x.path">
<circle :cx="x.x" :cy="x.y" :r="x.r"
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
</text>
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text>
</g>
</g> </g>
<g v-for="n in graph.nodes" :key="n.path"> <g v-for="n in graph.nodes" :key="n.path">
<a v-if="!n.hidden" :href="n.path" :title="n.title"> <a v-if="!n.hidden" :href="n.path" :title="n.title">
@@ -209,7 +222,6 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
fill: var(--text); fill: var(--text);
font-size: calc(11px / var(--node-r, 34)); font-size: calc(11px / var(--node-r, 34));
text-anchor: middle; text-anchor: middle;
filter: saturate(0);
} }
.tmap a { cursor: pointer; } .tmap a { cursor: pointer; }
.tmap a:hover .tnodeslug { fill: var(--accent); } .tmap a:hover .tnodeslug { fill: var(--accent); }
+1 -1
View File
@@ -130,7 +130,7 @@ function buildTitleMap(pageTree) {
/** Last path segment for display; front page becomes a house icon. */ /** Last path segment for display; front page becomes a house icon. */
function slugOf(path) { 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. */ /** Host name of an external https origin, with scheme and www. stripped. */
+139 -42
View File
@@ -13,10 +13,14 @@
* connections. Animated beads flow along every edge in each direction, * connections. Animated beads flow along every edge in each direction,
* emitted at time intervals inversely proportional (linear) to the * emitted at time intervals inversely proportional (linear) to the
* directional count. * directional count.
* External referers appear as nodes in a row above the map, external exits * External sources appear as nodes in a row above the map. Sources are
* as full-size nodes just outside their source page, angled away from the * identified from visit records in this order: utm_campaign, utm_source,
* center. Each distinct full exit URL is its own node. Self-loops (reload * referer, then other utm_* tags. Visits with a UTM tag are grouped under
* pings) are skipped. * 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' import { MIN_READ_SECONDS } from './format.js'
@@ -99,23 +103,19 @@ function extLabel(ext) {
} }
/** /**
* Collect external transitions: referer origin -> entry page (incoming) and * Collect outgoing external transitions: page path -> full exit URL.
* page -> exit origin (outgoing). Aggregated per (origin, page) pair, with * Aggregated per (URL, page) pair. Incoming external links are now derived
* separate directional counts. "(direct)" entries are not links and skipped. * from visit records (which carry UTM tags), so only exits remain here.
*/ */
function collectExternalPairs(transitions) { function collectExitPairs(transitions) {
const pairs = new Map() // `${ext} ${page}` -> {ext, page, in, out} const pairs = new Map() // `${ext} ${page}` -> {ext, page, out}
for (const [fr, tos] of Object.entries(transitions || {})) { for (const [fr, tos] of Object.entries(transitions || {})) {
if (!fr.startsWith('/')) continue // ignore external -> anything
for (const [to, count] of Object.entries(tos)) { for (const [to, count] of Object.entries(tos)) {
const frExt = !fr.startsWith('/') if (!to.startsWith('http')) continue
const toExt = !to.startsWith('/') const k = `${to} ${fr}`
if (frExt === toExt) continue // internal-internal or ext-ext const p = pairs.get(k) || { ext: to, page: fr, out: 0 }
const ext = frExt ? fr : to p.out += count
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
pairs.set(k, p) pairs.set(k, p)
} }
} }
@@ -227,7 +227,7 @@ function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
n.views = viewCount(n.path) n.views = viewCount(n.path)
n.readMin = readMinutes[n.path] || 0 n.readMin = readMinutes[n.path] || 0
// Slug inside the circle; full title goes on the link title attribute. // 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.label = slug.length > 16 ? `${slug.slice(0, 15)}` : slug
n.title = titles.get(n.path) || '' n.title = titles.get(n.path) || ''
// Category (non-leaf) pages with no views in this window are left // Category (non-leaf) pages with no views in this window are left
@@ -537,24 +537,96 @@ export function filterViewsByRange(views, t0, t1) {
return filtered 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. * flows.
* Referers (incoming links) form a row centered above the map, hottest * Sources (incoming links) are derived from visit UTM/referer data and form
* first; exits sit just outside their source page, fanned away from the * a row centered above the map, hottest first; exits come from the
* center and nudged outwards until they no longer overlap any node. * transition matrix and sit just outside their source page.
* Widths and pruning use the same log scale and traffic-share rule as * Widths and pruning use the same log scale and traffic-share rule as
* internal connections. * internal connections.
*/ */
function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) { function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualScale = 1) {
const extNodes = [] const extNodes = []
const edges = [] const edges = []
const flows = [] const flows = []
let extTotal = 0 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 minCount = extTotal * PRUNE_FRACTION
const live = external.filter((p) => byPath.has(p.page)) const liveSources = sources.filter((p) => byPath.has(p.page))
if (!live.length) return { extNodes, edges, flows } const liveExits = exits.filter((p) => byPath.has(p.page))
if (!liveSources.length && !liveExits.length) return { extNodes, edges, flows }
const width = (count) => scaledWidth(count * visualScale) 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, (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 // Incoming: one source node per identified source, in a row centered
// map, with an edge to each page that origin led to. // above the map, with an edge to each page that source led to.
const byExt = new Map() // ext -> pairs, sorted by total incoming count const bySource = new Map() // source -> pairs, sorted by total incoming count
for (const p of live.filter((p) => p.in >= minCount)) { for (const p of liveSources.filter((p) => p.in >= minCount)) {
const g = byExt.get(p.ext) || [] const g = bySource.get(p.source) || []
g.push(p) g.push(p)
byExt.set(p.ext, g) bySource.set(p.source, g)
} }
const origins = [...byExt] const origins = [...bySource]
.map(([ext, ps]) => ({ ext, ps, total: ps.reduce((s, p) => s + p.in, 0) })) .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) .sort((a, b) => b.total - a.total)
.slice(0, MAX_EXT_IN) .slice(0, MAX_EXT_IN)
if (origins.length) { if (origins.length) {
@@ -580,9 +658,18 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
const y = innerBounds.y0 - TNODE_R - 64 const y = innerBounds.y0 - TNODE_R - 64
const spacing = 2 * EXT_R + 44 const spacing = 2 * EXT_R + 44
const x0 = cx - ((origins.length - 1) * spacing) / 2 const x0 = cx - ((origins.length - 1) * spacing) / 2
origins.forEach(({ ext, ps }, i) => { origins.forEach(({ source, ps, total, href, isUtm }, i) => {
const total = ps.reduce((s, p) => s + p.in, 0) const label = isUtm ? source : extLabel(source)
const xn = { path: ext, label: extLabel(ext), x: x0 + i * spacing, y, r: EXT_R, count: total, kind: '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) extNodes.push(xn)
for (const p of ps) { for (const p of ps) {
const page = byPath.get(p.page) 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, // (same radial spacing internal rings use), fanned around the source angle,
// and shows the total count across all pages that link to that URL. // and shows the total count across all pages that link to that URL.
const GAP = radius(1) - radius(0) 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) .sort((a, b) => b.out - a.out)
const perPage = new Map() const perPage = new Map()
const selected = [] const selected = []
@@ -629,7 +716,16 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
x = page.x + Math.cos(ang) * dist x = page.x + Math.cos(ang) * dist
y = page.y + Math.sin(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) exitNodes.set(p.ext, xn)
extNodes.push(xn) extNodes.push(xn)
} }
@@ -650,7 +746,8 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
*/ */
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) { export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
const internal = collectInternalTransitions(data?.transitions) const internal = collectInternalTransitions(data?.transitions)
const external = collectExternalPairs(data?.transitions) const sources = collectSourcePairs(visits)
const exits = collectExitPairs(data?.transitions)
const navOrder = buildNavigationOrder(pageTree) const navOrder = buildNavigationOrder(pageTree)
const titles = buildTitleMap(pageTree) const titles = buildTitleMap(pageTree)
const readMinutes = buildReadMinutes(visits) const readMinutes = buildReadMinutes(visits)
@@ -690,7 +787,7 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
bounds.y1 = Math.max(bounds.y1, b.y1) 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) { for (const xn of ext.extNodes) {
bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad) bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad)
bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad) bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)