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
+85 -25
View File
@@ -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',