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