Adjusted connector thickness and bead animations to better deal with highly varying rates.

This commit is contained in:
2026-08-26 14:34:10 +00:00
parent 1702281dbf
commit eddb3f22f3
3 changed files with 52 additions and 47 deletions
+2 -2
View File
@@ -276,8 +276,8 @@ its end, disappearing under later pills when long — so the lanes reflect
the path the path
structure even where index pages are omitted — opposite transition structure even where index pages are omitted — opposite transition
directions joined into organic directions joined into organic
tapered connections whose middle width grows logarithmically with the tapered connections whose middle width grows logarithmically (base 2)
count (uncapped), connections with the daily hit rate (uncapped), connections
carrying less than 1% of the total traffic carrying less than 1% of the total traffic
pruned, as are those whose thin middle would render below ~0.8 px — 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 fainter strands are invisible and only their wide end flares would show; beads are simulated one by one in JS (requestAnimationFrame) and
+7 -6
View File
@@ -7,7 +7,7 @@
* so the visual scale can normalize against a one-week reference. * so the visual scale can normalize against a one-week reference.
*/ */
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
import { DAY, WEEK } from './analytics/time.js' import { DAY } from './analytics/time.js'
import { formatCount } from './analytics/format.js' import { formatCount } from './analytics/format.js'
import { import {
TNODE_W, TNODE_W,
@@ -22,9 +22,10 @@ const props = defineProps({
pageTree: { type: Array, default: null }, pageTree: { type: Array, default: null },
}) })
const visualScale = computed(() => { const dayScale = computed(() => {
const { t0, t1 } = props.window const { t0, t1 } = props.window
if (t0 != null && t1 != null) return WEEK / (t1 - t0) // Convert raw counts to a daily hit rate (hits/day).
if (t0 != null && t1 != null) return DAY / (t1 - t0)
// 'all': scale by the actual data span, but never less than the 30-day // 'all': scale by the actual data span, but never less than the 30-day
// minimum the plot enforces, so sparse young data is not over-amplified. // minimum the plot enforces, so sparse young data is not over-amplified.
const times = new Set() const times = new Set()
@@ -34,12 +35,12 @@ const visualScale = computed(() => {
const arr = [...times] const arr = [...times]
if (arr.length < 2) return 1 if (arr.length < 2) return 1
const span = Math.max(...arr) - Math.min(...arr) const span = Math.max(...arr) - Math.min(...arr)
return WEEK / Math.max(span, 30 * DAY) return DAY / Math.max(span, 30 * DAY)
}) })
const graph = computed(() => const graph = computed(() =>
props.data props.data
? buildTransitionGraph(props.data, props.pageTree, props.data.visits || [], visualScale.value) ? buildTransitionGraph(props.data, props.pageTree, props.data.visits || [], dayScale.value)
: null, : null,
) )
@@ -58,7 +59,7 @@ const live = [] // { e, p } — beads in flight, p = progress 0..1
let lastTick = 0 let lastTick = 0
const MAX_BEAD_RATE = 120 // upper bound on total beads per second 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 TRAVERSAL_S = 0.4 // seconds to cross any segment, end to end
const syncBeads = (flows) => { const syncBeads = (flows) => {
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
+43 -39
View File
@@ -11,8 +11,9 @@
* directions into straight connections (middle width = total * directions into straight connections (middle width = total
* count; connectors flare into the node pills at both ends and wrap * count; connectors flare into the node pills at both ends and wrap
* around their backs, surrounding them; the pills are drawn on top). Connection width grows * around their backs, surrounding them; the pills are drawn on top). Connection width grows
* logarithmically with the count (a single count renders as a ~1 px * logarithmically with the daily hit rate (base-2 log, one hit/day
* line, uncapped growth); connections carrying less than 1% of the total * renders zero width, each doubling adds a fixed step, uncapped);
* connections carrying less than 1% of the total
* traffic are pruned, which naturally keeps the graph under ~100 * traffic are pruned, which naturally keeps the graph under ~100
* 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
@@ -122,26 +123,28 @@ const pillTangent = (s, margin = 0) => {
return [(x2 - x1) / m, (y2 - y1) / m] return [(x2 - x1) / m, (y2 - y1) / m]
} }
// Edge width (half-width of the thin middle) grows logarithmically with // Edge width: half-width = WIDTH_GROWTH * log2(daily / DAILY_REF),
// the count. The constants are scaled down by ~10× so busy ranges (day, // where `daily` is the connection's hit rate in hits/day (callers scale
// year) do not overwhelm the graph with fat connectors. Connections whose // raw counts by DAY / range). DAILY_REF hits/day renders zero width;
// thin middle would render below MIN_WMID are culled entirely: fainter // WIDTH_GROWTH is the half-width added per doubling of the rate.
// strands are practically invisible and only their wide end flares would // Connections whose thin middle would render below MIN_WMID are culled
// show. Connections carrying less than PRUNE_FRACTION of the total traffic // entirely (fainter strands are practically invisible), as are those
// are likewise not drawn (this also keeps the graph under ~100 connections). // carrying less than PRUNE_FRACTION of the total traffic (this also
const WMID_MIN = 0.2 // keeps the graph under ~100 connections).
const WIDTH_GROWTH = 0.15 const WIDTH_GROWTH = 1.4 // half-width px per doubling of the daily rate
const DAILY_REF = 0.8 // hits/day at which the width is zero
const PRUNE_FRACTION = 0.01 const PRUNE_FRACTION = 0.01
// ~0.8 px full width at natural size (1 viewBox unit = 1 px). // ~0.8 px full width at natural size (1 viewBox unit = 1 px).
const MIN_WMID = 0.4 const MIN_WMID = 0.4
// Beads: each edge direction emits beads at count * BEAD_RATE beads per // Beads: each edge direction emits beads at dailyRate * BEAD_RATE beads
// second (linear in the count). The rate is reduced ~10× across all time // per second (linear in the daily hit rate). The rate is much reduced
// scales to keep the animation lightweight. The component simulates every // from real time to keep the animation lightweight. The component
// bead independently in JS with a constant traversal time per edge (speed // simulates every bead independently in JS with a constant traversal
// relative to span length), with no limit on beads in flight. // time per edge (speed relative to span length), with no limit on beads
// in flight.
export const BEAD_R = 3.2 export const BEAD_R = 3.2
const BEAD_RATE = 0.012 // beads per second per recorded transition const BEAD_RATE = 0.0084 // beads per second per hit/day
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
const MAX_EXT_IN = 8 // referer nodes in the top row const MAX_EXT_IN = 8 // referer nodes in the top row
@@ -262,7 +265,7 @@ function buildReadMinutes(visits) {
for (const v of visits || []) { for (const v of visits || []) {
for (const [path, sec] of Object.entries(v.read || {})) { for (const [path, sec] of Object.entries(v.read || {})) {
if (sec >= MIN_READ_SECONDS) { if (sec >= MIN_READ_SECONDS) {
;(times[path] || (times[path] = [])).push(sec) ; (times[path] || (times[path] = [])).push(sec)
} }
} }
} }
@@ -621,12 +624,12 @@ function buildRibbon(a, b, ab, ba, wMid, external = false) {
* to inside the target node (beads render under the node pills, so * to inside the target node (beads render under the node pills, so
* they emerge from and vanish beneath the nodes rather than popping in * they emerge from and vanish beneath the nodes rather than popping in
* at the surround), plus the emission interval (seconds between beads, * at the surround), plus the emission interval (seconds between beads,
* inverse of count * BEAD_RATE). Each segment is offset to the * inverse of the daily hit rate * BEAD_RATE). Each segment is offset to the
* right-hand side of its travel direction, so opposing flows on the same * right-hand side of its travel direction, so opposing flows on the same
* edge run on parallel lanes instead of colliding. The component turns * edge run on parallel lanes instead of colliding. The component turns
* these into independently simulated beads. * these into independently simulated beads.
*/ */
function buildFlows(a, b, ab, ba, visualScale = 1) { function buildFlows(a, b, ab, ba, dayScale = 1) {
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1 const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
const ux = (b.x - a.x) / len const ux = (b.x - a.x) / len
const uy = (b.y - a.y) / len const uy = (b.y - a.y) / len
@@ -649,7 +652,7 @@ function buildFlows(a, b, ab, ba, visualScale = 1) {
x2: a.x + toT * ux + s * rx, x2: a.x + toT * ux + s * rx,
y2: a.y + toT * uy + s * ry, y2: a.y + toT * uy + s * ry,
len: span, len: span,
interval: 1 / (count * BEAD_RATE * visualScale), interval: 1 / (count * BEAD_RATE * dayScale),
} }
} }
const flows = [] const flows = []
@@ -661,14 +664,14 @@ function buildFlows(a, b, ab, ba, visualScale = 1) {
} }
/** /**
* Half-width for a connection middle: logarithmic in the count, anchored * Half-width for a connection middle: base-2 logarithmic in the daily
* at WMID_MIN, uncapped. Absolute on purpose — cool routes stay visible * hit rate, zero at DAILY_REF hits/day, uncapped. Absolute on purpose —
* regardless of how hot the hottest connection is. Callers cull results * cool routes stay visible regardless of how hot the hottest connection
* below MIN_WMID. * is. Callers cull results below MIN_WMID.
*/ */
const scaledWidth = (count) => { const scaledWidth = (daily) => {
if (count <= 0) return 0 if (daily <= 0) return 0
return WMID_MIN + WIDTH_GROWTH * Math.log1p(count - 1) return WIDTH_GROWTH * Math.log2(daily / DAILY_REF)
} }
/** /**
@@ -676,7 +679,7 @@ const scaledWidth = (count) => {
* pair. Pairs carrying less than PRUNE_FRACTION of the total internal * pair. Pairs carrying less than PRUNE_FRACTION of the total internal
* traffic are pruned (this naturally bounds the graph to ~100 edges). * traffic are pruned (this naturally bounds the graph to ~100 edges).
*/ */
function buildInternalEdges(pairs, byPath, visualScale = 1) { function buildInternalEdges(pairs, byPath, dayScale = 1) {
let total = 0 let total = 0
for (const [, [ab, ba]] of pairs) total += ab + ba for (const [, [ab, ba]] of pairs) total += ab + ba
const minCount = total * PRUNE_FRACTION const minCount = total * PRUNE_FRACTION
@@ -689,10 +692,10 @@ function buildInternalEdges(pairs, byPath, visualScale = 1) {
const a = byPath.get(pf) const a = byPath.get(pf)
const b = byPath.get(pt) const b = byPath.get(pt)
if (a.hidden || b.hidden) continue // unplaced index pages are omitted if (a.hidden || b.hidden) continue // unplaced index pages are omitted
const wMid = scaledWidth((ab + ba) * visualScale) const wMid = scaledWidth((ab + ba) * dayScale)
if (wMid < MIN_WMID) continue if (wMid < MIN_WMID) continue
edges.push(buildRibbon(a, b, ab, ba, wMid)) edges.push(buildRibbon(a, b, ab, ba, wMid))
flows.push(...buildFlows(a, b, ab, ba, visualScale)) flows.push(...buildFlows(a, b, ab, ba, dayScale))
} }
return { edges, flows } return { edges, flows }
} }
@@ -768,7 +771,7 @@ function collectSourcePairs(visits) {
* 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({ sources, exits }, byPath, innerBounds, visualScale = 1) { function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
const extNodes = [] const extNodes = []
const edges = [] const edges = []
const flows = [] const flows = []
@@ -780,7 +783,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1)
const liveExits = exits.filter((p) => byPath.has(p.page)) const liveExits = exits.filter((p) => byPath.has(p.page))
if (!liveSources.length && !liveExits.length) return { extNodes, edges, flows } if (!liveSources.length && !liveExits.length) return { extNodes, edges, flows }
const width = (count) => scaledWidth(count * visualScale) const width = (count) => scaledWidth(count * dayScale)
// Incoming: one source node per identified source, in a row centered // Incoming: one source node per identified source, in a row centered
// above the map, with an edge to each page that source led to. A source // above the map, with an edge to each page that source led to. A source
@@ -826,7 +829,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1)
const wMid = width(p.in) const wMid = width(p.in)
if (wMid < MIN_WMID) continue if (wMid < MIN_WMID) continue
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true)) edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
flows.push(...buildFlows(xn, page, p.in, 0, visualScale)) flows.push(...buildFlows(xn, page, p.in, 0, dayScale))
} }
}) })
} }
@@ -871,7 +874,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1)
const wMid = width(p.out) const wMid = width(p.out)
if (wMid < MIN_WMID) continue if (wMid < MIN_WMID) continue
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true)) edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
flows.push(...buildFlows(page, xn, p.out, 0, visualScale)) flows.push(...buildFlows(page, xn, p.out, 0, dayScale))
} }
}) })
} }
@@ -883,9 +886,10 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1)
* Build the transition map model. * Build the transition map model.
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when * Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
* there is nothing to show. `arcs` holds the branch curves; `nodes` only * there is nothing to show. `arcs` holds the branch curves; `nodes` only
* contains placed (visible) nodes. * contains placed (visible) nodes. `dayScale` converts raw counts to a
* daily hit rate (DAY / range ms) for edge widths and bead rates.
*/ */
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) { export function buildTransitionGraph(data, pageTree, visits = [], dayScale = 1) {
const internal = collectInternalTransitions(data?.transitions) const internal = collectInternalTransitions(data?.transitions)
const sources = collectSourcePairs(visits) const sources = collectSourcePairs(visits)
const exits = collectExitPairs(data?.transitions) const exits = collectExitPairs(data?.transitions)
@@ -901,7 +905,7 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
const { arcs, arcLeft } = layoutGroups(root) const { arcs, arcLeft } = layoutGroups(root)
const placed = nodes.filter((n) => !n.hidden) const placed = nodes.filter((n) => !n.hidden)
const pairs = aggregatePairs(internal) const pairs = aggregatePairs(internal)
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale) const { edges, flows } = buildInternalEdges(pairs, byPath, dayScale)
// Tight bounding box of the placed page nodes, extended to cover the // Tight bounding box of the placed page nodes, extended to cover the
// branch curves running left of the pills; external nodes extend it. // branch curves running left of the pills; external nodes extend it.
@@ -920,7 +924,7 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
y1: Math.max(...ys) + MY, y1: Math.max(...ys) + MY,
} }
const ext = buildExternal({ sources, exits }, byPath, bounds, visualScale) const ext = buildExternal({ sources, exits }, byPath, bounds, dayScale)
for (const xn of ext.extNodes) { for (const xn of ext.extNodes) {
bounds.x0 = Math.min(bounds.x0, xn.x - MX) bounds.x0 = Math.min(bounds.x0, xn.x - MX)
bounds.y0 = Math.min(bounds.y0, xn.y - MY) bounds.y0 = Math.min(bounds.y0, xn.y - MY)