From 7e86efeb9f95eee0ae92b4a8eff141a5093aaff3 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 23 Sep 2026 07:02:17 +0000 Subject: [PATCH] Place external source/exit pills near their connection targets Replace count-sorted, group-centered rows with a spring-like placement: pills order by their weighted median target x and settle by clamped coordinate descent, minimizing weighted horizontal connection distance while keeping the minimum pill spacing. --- docs/analytics.md | 7 +- frontend/src/analytics/transitions.js | 110 ++++++++++++++++++++------ 2 files changed, 90 insertions(+), 27 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 500217e..64704b0 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -438,8 +438,11 @@ 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 full-size nodes in a matching -row centered below the map, so the site itself stays in the middle), per-page view +came from the same origin. Within the source and exit rows the pills are +not sorted by count; each slides sideways toward the pages it connects to, +minimizing the weighted horizontal connection distance while keeping a +minimum pill spacing. External exits are full-size nodes in a matching +row below the map, so the site itself stays in the middle), 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/analytics/transitions.js b/frontend/src/analytics/transitions.js index 78ae1eb..7862996 100644 --- a/frontend/src/analytics/transitions.js +++ b/frontend/src/analytics/transitions.js @@ -18,7 +18,9 @@ * connections. Animated beads flow along every edge in each direction, * emitted at time intervals inversely proportional (linear) to the * directional count. - * External sources appear as nodes in a row above the map. Sources are + * External sources appear as nodes in a row above the map, each pill + * slid sideways toward the pages it connects to (weighted by count, + * minimum spacing kept). 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 @@ -700,6 +702,51 @@ function buildInternalEdges(pairs, byPath, dayScale = 1) { return { edges, flows } } +/** + * Weighted median of {x, w} targets: the x minimizing Σ w|x − t| — + * the spot where a pill's total horizontal connection pull balances. + */ +function weightedMedian(targets) { + const ts = [...targets].sort((a, b) => a.x - b.x) + let total = 0 + for (const t of ts) total += t.w + let acc = 0 + for (const t of ts) { + acc += t.w + if (acc >= total / 2) return t.x + } + return ts[ts.length - 1].x +} + +/** + * Slide the pills of an external row sideways so each sits as close as + * possible to the pages it connects to: minimize Σ w|pill.x − target.x| + * over all drawn connections (weight = count), subject to a minimum + * center `spacing` (no overlap). Pills are ordered by their weighted + * median target (swapping any inverted adjacent pair can only add + * crossing distance), then positioned by coordinate descent on this + * convex objective: each pass snaps a pill to its weighted median, + * clamped to the spacing window between its current neighbors. + */ +function placeRow(items, spacing) { + items.sort((a, b) => a.anchor - b.anchor) + const n = items.length + for (const it of items) it.x = it.anchor + for (let pass = 0; pass < 40; pass++) { + let moved = 0 + const sweep = (i) => { + const lo = i > 0 ? items[i - 1].x + spacing : -Infinity + const hi = i < n - 1 ? items[i + 1].x - spacing : Infinity + const x = Math.min(Math.max(items[i].anchor, lo), hi) + moved = Math.max(moved, Math.abs(x - items[i].x)) + items[i].x = x + } + for (let i = 0; i < n; i++) sweep(i) + for (let i = n - 1; i >= 0; i--) sweep(i) + if (moved < 0.01) break + } +} + const UTM_PRIORITY = ['utm_campaign', 'utm_source'] const UTM_FALLBACK = ['utm_medium', 'utm_content', 'utm_term', 'utm_id'] @@ -764,10 +811,12 @@ function collectSourcePairs(visits) { * Place external source and exit nodes and build their edges and bead * flows. * 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 form a matching row centered below the map, so + * a row above the map; exits come from the + * transition matrix and form a matching row below the map, so * the site itself stays in the middle. Both rows sit EXT_GAP beyond the - * map's bounds. + * map's bounds. Within a row the pills slide sideways toward the pages + * they connect to (placeRow), minimizing the weighted horizontal + * connection distance while keeping a minimum center spacing. * Widths and pruning use the same log scale and traffic-share rule as * internal connections. */ @@ -785,9 +834,10 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) { const width = (count) => scaledWidth(count * dayScale) - // 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 - // whose connectors are all culled (below MIN_WMID) is dropped itself. + // Incoming: one source node per identified source, in a row 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. Pills + // slide sideways toward their connection targets (see placeRow). 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) || [] @@ -804,20 +854,25 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 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)) + .map((o) => ({ + ...o, + // Anchoring targets: the drawn (non-culled) connections only. + targets: o.ps + .filter((p) => !byPath.get(p.page).hidden && width(p.in) >= MIN_WMID) + .map((p) => ({ x: byPath.get(p.page).x, w: p.in })), + })) + .filter((o) => o.targets.length) if (origins.length) { - const cx = (innerBounds.x0 + innerBounds.x1) / 2 const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP - const spacing = TNODE_W + 44 - const x0 = cx - ((origins.length - 1) * spacing) / 2 - origins.forEach(({ source, ps, total, href, isUtm }, i) => { + for (const o of origins) o.anchor = weightedMedian(o.targets) + placeRow(origins, TNODE_W + 44) + origins.forEach(({ source, ps, total, href, isUtm, x }) => { const label = isUtm ? source : extLabel(source) const xn = { path: source, href, label, // clipped at the pill border on render - x: x0 + i * spacing, + x, y, count: total, kind: 'source', @@ -836,10 +891,11 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) { // Outgoing: one exit node per distinct full URL (so several links to // 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. An exit whose connectors are all - // culled (below MIN_WMID) is dropped itself. + // pages linking to it, in a row below the map mirroring the source row + // above. Each (URL, page) pair contributes an edge from that page. An + // exit whose connectors are all culled (below MIN_WMID) is dropped + // itself. Pills slide sideways toward their connection targets (see + // placeRow). 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: [] } @@ -850,19 +906,23 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 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)) + .map((t) => ({ + ...t, + targets: t.pairs + .filter((p) => !byPath.get(p.page).hidden && width(p.out) >= MIN_WMID) + .map((p) => ({ x: byPath.get(p.page).x, w: p.out })), + })) + .filter((t) => t.targets.length) if (targets.length) { - const cx = (innerBounds.x0 + innerBounds.x1) / 2 const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP - const spacing = TNODE_W + 44 - const x0 = cx - ((targets.length - 1) * spacing) / 2 - targets.forEach(({ ext, out, pairs }, i) => { + for (const t of targets) t.anchor = weightedMedian(t.targets) + placeRow(targets, TNODE_W + 44) + targets.forEach(({ ext, out, pairs, x }) => { const xn = { path: ext, href: ext, label: extLabel(ext), - x: x0 + i * spacing, + x, y, count: out, kind: 'exit',