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:
+5
-2
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user