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.
This commit is contained in:
2026-09-23 07:02:17 +00:00
parent 580ebac06c
commit 7e86efeb9f
2 changed files with 90 additions and 27 deletions
+5 -2
View File
@@ -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 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 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 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 came from the same origin. Within the source and exit rows the pills are
row centered below the map, so the site itself stays in the middle), per-page view 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 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
+85 -25
View File
@@ -18,7 +18,9 @@
* 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 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, * identified from visit records in this order: utm_campaign, utm_source,
* referer, then other utm_* tags. Visits with a UTM tag are grouped under * 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 * 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 } 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_PRIORITY = ['utm_campaign', 'utm_source']
const UTM_FALLBACK = ['utm_medium', 'utm_content', 'utm_term', 'utm_id'] 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 * Place external source and exit nodes and build their edges and bead
* flows. * flows.
* Sources (incoming links) are derived from visit UTM/referer data and form * Sources (incoming links) are derived from visit UTM/referer data and form
* a row centered above the map, hottest first; exits come from the * a row above the map; exits come from the
* transition matrix and form a matching row centered below the map, so * 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 * 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 * Widths and pruning use the same log scale and traffic-share rule as
* internal connections. * internal connections.
*/ */
@@ -785,9 +834,10 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
const width = (count) => scaledWidth(count * dayScale) 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 above the
// above the map, with an edge to each page that source led to. A source // map, with an edge to each page that source led to. A source whose
// whose connectors are all culled (below MIN_WMID) is dropped itself. // 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 const bySource = new Map() // source -> pairs, sorted by total incoming count
for (const p of liveSources.filter((p) => p.in >= minCount)) { for (const p of liveSources.filter((p) => p.in >= minCount)) {
const g = bySource.get(p.source) || [] 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) .sort((a, b) => b.total - a.total)
.slice(0, MAX_EXT_IN) .slice(0, MAX_EXT_IN)
.filter(({ ps }) => .map((o) => ({
ps.some((p) => !byPath.get(p.page).hidden && width(p.in) >= MIN_WMID)) ...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) { if (origins.length) {
const cx = (innerBounds.x0 + innerBounds.x1) / 2
const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP
const spacing = TNODE_W + 44 for (const o of origins) o.anchor = weightedMedian(o.targets)
const x0 = cx - ((origins.length - 1) * spacing) / 2 placeRow(origins, TNODE_W + 44)
origins.forEach(({ source, ps, total, href, isUtm }, i) => { origins.forEach(({ source, ps, total, href, isUtm, x }) => {
const label = isUtm ? source : extLabel(source) const label = isUtm ? source : extLabel(source)
const xn = { const xn = {
path: source, path: source,
href, href,
label, // clipped at the pill border on render label, // clipped at the pill border on render
x: x0 + i * spacing, x,
y, y,
count: total, count: total,
kind: 'source', 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 // Outgoing: one exit node per distinct full URL (so several links to
// the same domain stay distinct), showing the total count across all // the same domain stay distinct), showing the total count across all
// pages linking to it, in a row centered below the map (hottest // pages linking to it, in a row below the map mirroring the source row
// first), mirroring the source row above. Each (URL, page) pair // above. Each (URL, page) pair contributes an edge from that page. An
// contributes an edge from that page. An exit whose connectors are all // exit whose connectors are all culled (below MIN_WMID) is dropped
// culled (below MIN_WMID) is dropped itself. // itself. Pills slide sideways toward their connection targets (see
// placeRow).
const byExt = new Map() // full URL -> { ext, out, pairs } const byExt = new Map() // full URL -> { ext, out, pairs }
for (const p of liveExits.filter((p) => p.out >= minCount)) { for (const p of liveExits.filter((p) => p.out >= minCount)) {
const g = byExt.get(p.ext) || { ext: p.ext, out: 0, pairs: [] } 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()] const targets = [...byExt.values()]
.sort((a, b) => b.out - a.out) .sort((a, b) => b.out - a.out)
.slice(0, MAX_EXT_OUT) .slice(0, MAX_EXT_OUT)
.filter(({ pairs }) => .map((t) => ({
pairs.some((p) => !byPath.get(p.page).hidden && width(p.out) >= MIN_WMID)) ...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) { if (targets.length) {
const cx = (innerBounds.x0 + innerBounds.x1) / 2
const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP
const spacing = TNODE_W + 44 for (const t of targets) t.anchor = weightedMedian(t.targets)
const x0 = cx - ((targets.length - 1) * spacing) / 2 placeRow(targets, TNODE_W + 44)
targets.forEach(({ ext, out, pairs }, i) => { targets.forEach(({ ext, out, pairs, x }) => {
const xn = { const xn = {
path: ext, path: ext,
href: ext, href: ext,
label: extLabel(ext), label: extLabel(ext),
x: x0 + i * spacing, x,
y, y,
count: out, count: out,
kind: 'exit', kind: 'exit',