diff --git a/docs/analytics.md b/docs/analytics.md index 4576941..7ca688a 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -250,7 +250,8 @@ younger than that, bucket size included) so the chart never collapses to a tiny sliver when 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 +by a top lane labeled 🏠︎ beside the home pill (50% thicker than +the branch lanes, its label font and guide offset scaled along), 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 @@ -258,17 +259,24 @@ 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 +group share exactly one form), each labeled with its branch slug +left-aligned just past the first pill and allowed to run along the lane to +its end, disappearing under later pills when long — 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 +count (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 +pruned, as are those whose thin middle would render below ~0.8 px — +fainter strands are invisible and only their wide end flares would show; beads are simulated one by one in JS (requestAnimationFrame) and +flow along each edge, persisting across data reloads (emitters are keyed +per edge direction and beads tracked by progress, so an unrelated count +change never reshuffles them), emitted at a rate linearly proportional to the directional count with no in-flight limit, opposing directions -offset onto parallel lanes. External sources show as a node row above the +offset onto parallel lanes. External sources and exits whose connectors are +all culled by the width threshold are dropped from their rows themselves +(the site's own page nodes always stay, connected or not). 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 diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index 16c2f1f..200becd 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -13,7 +13,6 @@ import { TNODE_W, TNODE_H, BEAD_R, - BEAD_SPEED, buildTransitionGraph, } from './analytics/transitions.js' @@ -46,65 +45,97 @@ const graph = computed(() => // 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. +// cross their segment in a constant TRAVERSAL_S seconds (speed relative +// to span length) and are dropped at the end. // There is deliberately no cap on beads in flight. +// Emitters persist across data reloads, keyed by flow.key: an unchanged +// link keeps its emission phase and in-flight beads (tracked by progress, +// not absolute time), so a count change elsewhere never reshuffles them. const beads = shallowRef([]) let rafId = 0 +const emitters = new Map() // flow.key -> { flow, interval, next, alive } +const live = [] // { e, p } — beads in flight, p = progress 0..1 +let lastTick = 0 const MAX_BEAD_RATE = 120 // upper bound on total beads per second +const TRAVERSAL_S = 1.5 // seconds to cross any segment, end to end -const startBeads = (flows) => { - cancelAnimationFrame(rafId) - beads.value = [] - if (!flows?.length) return - if (matchMedia('(prefers-reduced-motion: reduce)').matches) return - +const syncBeads = (flows) => { + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches + if (!flows?.length || reduced) { + emitters.clear() + live.length = 0 + beads.value = [] + return + } // Cap the total bead emission rate so a busy range cannot spawn enough // beads to kill the page. Existing per-range time scaling is preserved; // this is only a proportional emergency throttle when the limit is hit. const totalRate = flows.reduce((s, f) => s + 1 / f.interval, 0) const scale = totalRate > MAX_BEAD_RATE ? MAX_BEAD_RATE / totalRate : 1 - const live = [] // { flow, t0 } — one entry per bead in flight const now = performance.now() - const emitters = flows.map((flow) => { + const seen = new Set() + for (const flow of flows) { + seen.add(flow.key) const interval = (flow.interval / scale) * 1000 - // Pre-fill the traversal with evenly spaced beads (random phase), so - // the flow appears already running instead of starting empty. + const e = emitters.get(flow.key) + if (e) { + e.flow = flow // pick up new geometry/rate, keep the phase + e.interval = interval + continue + } + // New emitter: pre-fill the traversal with evenly spaced beads (random + // phase), so the flow appears already running instead of 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 }) + const dp = interval / 1000 / TRAVERSAL_S + const ne = { flow, interval, next: now + phase, alive: true } + for (let p = 1 - phase / 1000 / TRAVERSAL_S; p > 0; p -= dp) { + live.push({ e: ne, p }) } - 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) + emitters.set(flow.key, ne) } + for (const [key, e] of emitters) { + if (!seen.has(key)) { + e.alive = false + emitters.delete(key) + } + } + for (let i = live.length - 1; i >= 0; i--) { + if (!live[i].e.alive) live.splice(i, 1) + } +} + +const tick = (t) => { + const dt = lastTick ? (t - lastTick) / 1000 : 0 + lastTick = t + for (const e of emitters.values()) { + while (e.next <= t) { + live.push({ e, p: 0 }) + e.next += e.interval + } + } + const out = [] + for (let i = live.length - 1; i >= 0; i--) { + const b = live[i] + b.p += dt / TRAVERSAL_S + if (b.p >= 1) { + live.splice(i, 1) + continue + } + const f = b.e.flow + out.push({ x: f.x1 + (f.x2 - f.x1) * b.p, y: f.y1 + (f.y2 - f.y1) * b.p }) + } + beads.value = out rafId = requestAnimationFrame(tick) } -watch(() => graph.value?.flows, startBeads, { immediate: true }) +watch(() => graph.value?.flows, syncBeads, { immediate: true }) +onMounted(() => { + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { + rafId = requestAnimationFrame(tick) + } +}) onBeforeUnmount(() => cancelAnimationFrame(rafId)) // The svg never renders larger than its natural size (1 viewBox unit = 1 @@ -161,7 +192,7 @@ const countLabel = (n) => :id="`tarc${i}`" :d="a.d" :class="['tarc', a.top && 'tarc-top']" /> @@ -237,10 +268,16 @@ const countLabel = (n) => opacity: 0.25; } .tmap .tarc-top { stroke-width: 24; } +/* Lane labels are left-aligned: each guide arc starts just past the source + pill's edge, the earliest point where the text is visible. */ .tmap .tarclabel { fill: var(--muted); font-size: calc(13px / var(--u, 1)); - text-anchor: middle; + text-anchor: start; +} +/* The top lane is 50% thicker; its 🏠︎ label scales along. */ +.tmap .tarclabel-top { + font-size: calc(19.5px / var(--u, 1)); } .tmap .tnode { fill: var(--accent); diff --git a/frontend/src/analytics/transitions.js b/frontend/src/analytics/transitions.js index ccafc1e..9ed6e50 100644 --- a/frontend/src/analytics/transitions.js +++ b/frontend/src/analytics/transitions.js @@ -124,20 +124,22 @@ const pillTangent = (s, margin = 0) => { // Edge width (half-width of the thin middle) grows logarithmically with // the count. The constants are scaled down by ~10× so busy ranges (day, -// year) do not overwhelm the graph with fat connectors. A single recorded -// transition still renders as a faint ~0.4 px line. Connections carrying -// less than PRUNE_FRACTION of the total traffic are not drawn at all (this -// also keeps the graph under ~100 connections). +// year) do not overwhelm the graph with fat connectors. Connections whose +// thin middle would render below MIN_WMID are culled entirely: fainter +// strands are practically invisible and only their wide end flares would +// show. Connections carrying less than PRUNE_FRACTION of the total traffic +// are likewise not drawn (this also keeps the graph under ~100 connections). const WMID_MIN = 0.2 const WIDTH_GROWTH = 0.15 const PRUNE_FRACTION = 0.01 +// ~0.8 px full width at natural size (1 viewBox unit = 1 px). +const MIN_WMID = 0.4 // Beads: each edge direction emits beads at count * BEAD_RATE beads per // second (linear in the count). The rate is reduced ~10× across all time // scales to keep the animation lightweight. The component simulates every -// bead independently in JS at BEAD_SPEED along the edge, with no limit on -// beads in flight. -export const BEAD_SPEED = 180 // svg units per second +// bead independently in JS with a constant traversal time per edge (speed +// relative to span length), with no limit on beads in flight. export const BEAD_R = 3.2 const BEAD_RATE = 0.012 // beads per second per recorded transition const FLOW_OFFSET = 3 // lane offset to the right of the travel direction @@ -379,15 +381,23 @@ function layoutGroups(root) { // INDENT larger per parent level — concentric circles, so all lanes // share exactly one form. Lanes span their branch's nodes plus a // little extra tucked under the first/last pill (so the line caps are - // never visible) and run behind the pills. A separate short arc - // across the first inter-node gap carries the branch slug as a label, - // replacing per-node path crumbs. Hidden (unplaced) index pages still + // never visible) and run behind the pills. A label arc carries the + // branch slug, left-aligned just past the first pill and free to run + // to the lane's end — longer text simply passes under later pills, + // which are drawn on top. Hidden (unplaced) index pages still // define a lane: it follows their promoted children, so lanes reflect // the path structure rather than page existence. const INDENT = 20 // lane spacing (radius) per nesting level (> lane width) const END_TUCK = 22 // arc units tucked under the first/last pill const GAP_TRIM = 32 // label arc clearance from the pills - const LABEL_CHARS = 8 // ~13px glyphs fitting the gap + // Labels are left-aligned on their guide: the guide starts just past the + // source pill's edge, the earliest point where the text is visible. + const LABEL_PAD = 6 + // The label guide rides GUIDE_OFF outward of the lane centerline: the + // text's alphabetic baseline sits on the guide, so this puts the + // glyph middle (not the baseline) on the lane center at any zoom — + // dominant-baseline tricks are em-based and break under downscale. + const GUIDE_OFF = 3.5 const branches = [] groups.forEach((members, gi) => { const g = groupRoots[gi] @@ -427,26 +437,36 @@ function layoutGroups(root) { return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 0 ${x1.toFixed(2)} ${y1.toFixed(2)}` } const d = arc(th(first) + END_TUCK / R, th(last) - END_TUCK / R, R) - // The label guide rides GUIDE_OFF outward of the lane centerline: the - // text's alphabetic baseline sits on the guide, so this puts the - // glyph middle (not the baseline) on the lane center at any zoom — - // dominant-baseline tricks are em-based and break under downscale. - const GUIDE_OFF = 3.5 - const ld = arc(th(first) - GAP_TRIM / R, th(first + 1) + GAP_TRIM / R, R + GUIDE_OFF) + // Start just past the first pill: lanes leave the source node nearly + // vertically, so the pill's extent along the arc is its half height. + // The guide runs to the lane's end so long slugs are never cut off. + const ld = arc(th(first) - (TNODE_H / 2 + LABEL_PAD) / R, + th(last) - END_TUCK / R, R + GUIDE_OFF) arcLeft = Math.min(arcLeft, pt(th(first) + END_TUCK / R, R)[0]) - const label = name.length > LABEL_CHARS ? `${name.slice(0, LABEL_CHARS - 1)}…` : name - return { d, ld, label } + return { d, ld, label: name } }) - // Top lane: an unlabeled arc along the top row's own circle, connecting + // Top lane: an arc along the top row's own circle, connecting // the top nodes of all groups and tucked under the first and last of // them (the arc bottoms at the last item, so it continues rightward - // under its pill). Drawn 50% thicker than branch lanes. + // under its pill). Drawn 50% thicker than branch lanes. A 🏠︎ label + // marks the lane right after the home pill, on a guide arc like + // the branch labels but with the offset and clearance scaled up by the + // same 50% to keep the glyph centered on the wider lane. if (span) { + const d = `M ${(-half - END_TUCK).toFixed(2)} ${topY(-half - END_TUCK).toFixed(2)} ` + + `A ${R_T.toFixed(2)} ${R_T.toFixed(2)} 0 0 0 ${(half + END_TUCK).toFixed(2)} ${topY(half + END_TUCK).toFixed(2)}` + const rG = R_T + GUIDE_OFF * 1.5 + const ptG = (x) => [x, topD - R_T + Math.sqrt(rG * rG - (x - half) ** 2)] + // Left-aligned like the branch labels: the guide starts just past the + // home pill's edge (scaled with the lane thickness). + const g0 = -half + TNODE_W / 2 + LABEL_PAD * 1.5 + const g1 = SLOT - half - TNODE_W / 2 - GAP_TRIM * 1.5 + const [gx0, gy0] = ptG(g0) + const [gx1, gy1] = ptG(g1) arcs.unshift({ - d: `M ${(-half - END_TUCK).toFixed(2)} ${topY(-half - END_TUCK).toFixed(2)} ` - + `A ${R_T.toFixed(2)} ${R_T.toFixed(2)} 0 0 0 ${(half + END_TUCK).toFixed(2)} ${topY(half + END_TUCK).toFixed(2)}`, - ld: null, - label: null, + d, + ld: `M ${gx0.toFixed(2)} ${gy0.toFixed(2)} A ${rG.toFixed(2)} ${rG.toFixed(2)} 0 0 0 ${gx1.toFixed(2)} ${gy1.toFixed(2)}`, + label: '🏠︎', top: true, }) } @@ -636,16 +656,18 @@ function buildFlows(a, b, ab, ba, visualScale = 1) { } } const flows = [] - if (ab) flows.push(flow(ab, t0, t1)) - if (ba) flows.push(flow(ba, t1, t0)) + // Stable key per edge direction so the component's bead simulation can + // match flows across data reloads and keep bead phases/positions. + if (ab) flows.push({ ...flow(ab, t0, t1), key: `${a.path} ${b.path}` }) + if (ba) flows.push({ ...flow(ba, t1, t0), key: `${b.path} ${a.path}` }) return flows } /** * Half-width for a connection middle: logarithmic in the count, anchored - * so a single count lands exactly at WMID_MIN (~1 px line), uncapped. - * Absolute on purpose — cool routes stay visible regardless of how hot - * the hottest connection is. + * at WMID_MIN, uncapped. Absolute on purpose — cool routes stay visible + * regardless of how hot the hottest connection is. Callers cull results + * below MIN_WMID. */ const scaledWidth = (count) => { if (count <= 0) return 0 @@ -671,7 +693,7 @@ function buildInternalEdges(pairs, byPath, visualScale = 1) { 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 + if (wMid < MIN_WMID) continue edges.push(buildRibbon(a, b, ab, ba, wMid)) flows.push(...buildFlows(a, b, ab, ba, visualScale)) } @@ -764,7 +786,8 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1) const width = (count) => scaledWidth(count * visualScale) // Incoming: one source node per identified source, in a row centered - // above the map, with an edge to each page that source led to. + // above the map, with an edge to each page that source led to. A source + // whose connectors are all culled (below MIN_WMID) is dropped itself. 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) || [] @@ -781,6 +804,8 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1) })) .sort((a, b) => b.total - a.total) .slice(0, MAX_EXT_IN) + .filter(({ ps }) => + ps.some((p) => !byPath.get(p.page).hidden && width(p.in) >= MIN_WMID)) if (origins.length) { const cx = (innerBounds.x0 + innerBounds.x1) / 2 const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP @@ -802,7 +827,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1) const page = byPath.get(p.page) if (page.hidden) continue const wMid = width(p.in) - if (wMid <= 0) continue + if (wMid < MIN_WMID) continue edges.push(buildRibbon(xn, page, p.in, 0, wMid, true)) flows.push(...buildFlows(xn, page, p.in, 0, visualScale)) } @@ -813,7 +838,8 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1) // the same domain stay distinct), showing the total count across all // pages linking to it, in a row centered below the map (hottest // first), mirroring the source row above. Each (URL, page) pair - // contributes an edge from that page. + // contributes an edge from that page. An exit whose connectors are all + // culled (below MIN_WMID) is dropped itself. const byExt = new Map() // full URL -> { ext, out, pairs } for (const p of liveExits.filter((p) => p.out >= minCount)) { const g = byExt.get(p.ext) || { ext: p.ext, out: 0, pairs: [] } @@ -824,6 +850,8 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1) const targets = [...byExt.values()] .sort((a, b) => b.out - a.out) .slice(0, MAX_EXT_OUT) + .filter(({ pairs }) => + pairs.some((p) => !byPath.get(p.page).hidden && width(p.out) >= MIN_WMID)) if (targets.length) { const cx = (innerBounds.x0 + innerBounds.x1) / 2 const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP @@ -844,7 +872,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1) const page = byPath.get(p.page) if (page.hidden) continue const wMid = width(p.out) - if (wMid <= 0) continue + if (wMid < MIN_WMID) continue edges.push(buildRibbon(page, xn, p.out, 0, wMid, true)) flows.push(...buildFlows(page, xn, p.out, 0, visualScale)) }