Add external link (referer/outgoing) display on connection graph.

This commit is contained in:
2026-08-21 02:44:54 +00:00
parent ea069b98da
commit 462e995adc
7 changed files with 114 additions and 63 deletions
+5 -3
View File
@@ -32,9 +32,11 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
- **Internal fetch-navigations**: `to` is the target path, sent only after - **Internal fetch-navigations**: `to` is the target path, sent only after
the swap actually happened (a failed swap falls back to a full load, the swap actually happened (a failed swap falls back to a full load,
whose initial ping counts the view instead — no gap, no double count). whose initial ping counts the view instead — no gap, no double count).
- **External links** (`https` only): `to` is the link's origin. This is the - **External links** (`https` only): `to` is the link's full URL. This is the
exit-link record; the user may continue navigating afterwards (new tab, exit-link record; the user may continue navigating afterwards (new tab,
back), so the exit origin is not necessarily the last trail entry. back), so the exit URL is not necessarily the last trail entry. Outbound
links are stored by full URL so several links to the same domain remain
distinct.
- **Excluded**: back/forward (popstate) navigations, navigation involving - **Excluded**: back/forward (popstate) navigations, navigation involving
the analytics page itself (`/_a`), and everything while the user is known to the analytics page itself (`/_a`), and everything while the user is known to
be an admin *and SSO is actually in use* — with no auth proxy (dev/test) be an admin *and SSO is actually in use* — with no auth proxy (dev/test)
@@ -83,7 +85,7 @@ Each `Visit` record:
- `ip` — visitor IP address (first `X-Forwarded-For` hop, or direct peer), - `ip` — visitor IP address (first `X-Forwarded-For` hop, or direct peer),
- `host` — reverse-DNS host name for `ip` when resolvable, else `""`, - `host` — reverse-DNS host name for `ip` when resolvable, else `""`,
- `trail` — everything seen afterwards in first-seen order: page paths and - `trail` — everything seen afterwards in first-seen order: page paths and
external exit origins. Re-visiting an already seen page (incl. the entry) external exit URLs. Re-visiting an already seen page (incl. the entry)
does not append. does not append.
- `lang` — first `Accept-Language` tag, lowercased (e.g. `en-us`), - `lang` — first `Accept-Language` tag, lowercased (e.g. `en-us`),
- `country` — two-letter country code. Initially derived from the - `country` — two-letter country code. Initially derived from the
+4 -1
View File
@@ -146,7 +146,10 @@ function countryName(code) {
<td class="when" :title="v.whenTooltip">{{ v.when }}</td> <td class="when" :title="v.whenTooltip">{{ v.when }}</td>
<td class="trail"> <td class="trail">
<a v-for="(s, si) in v.trail" :key="si" <a v-for="(s, si) in v.trail" :key="si"
:href="s.path" :title="s.title" @click="$emit('close')"> :href="s.path" :title="s.title"
:target="s.external ? '_blank' : undefined"
:rel="s.external ? 'noopener' : undefined"
@click="(e) => { if (!s.external) $emit('close') }">
{{ s.slug }} {{ s.slug }}
</a> </a>
</td> </td>
+9 -11
View File
@@ -109,10 +109,12 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
<circle v-for="(b, i) in beads" :key="'b' + i" <circle v-for="(b, i) in beads" :key="'b' + i"
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" /> :cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
<g v-for="(x, i) in graph.extNodes" :key="'x' + i"> <g v-for="(x, i) in graph.extNodes" :key="'x' + i">
<circle :cx="x.x" :cy="x.y" :r="x.r" class="txnode"> <a :href="x.path" target="_blank" rel="noopener" :title="x.path">
<title>{{ x.path }}</title> <circle :cx="x.x" :cy="x.y" :r="x.r"
</circle> :class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
<text :x="x.x" :y="x.y + x.r + 11" class="txlabel">{{ x.label }}</text> <text :x="x.x" :y="x.y - 2" class="tnodeslug">{{ x.label }}</text>
<text :x="x.x" :y="x.y + 12" class="tnodecount">{{ x.count }}</text>
</a>
</g> </g>
<g v-for="n in graph.nodes" :key="n.path"> <g v-for="n in graph.nodes" :key="n.path">
<a :href="n.path" :title="n.title"> <a :href="n.path" :title="n.title">
@@ -144,14 +146,10 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
} }
.tmap .txnode { .tmap .txnode {
fill: var(--bg, Canvas); fill: var(--bg, Canvas);
stroke: var(--muted); stroke-width: 1.5;
stroke-width: 1;
}
.tmap .txlabel {
fill: var(--muted);
font-size: 9px;
text-anchor: middle;
} }
.tmap .txnode-source { stroke: var(--text); }
.tmap .txnode-exit { stroke: var(--muted); }
.tmap .tarc { .tmap .tarc {
fill: none; fill: none;
stroke: var(--line); stroke: var(--line);
+37 -18
View File
@@ -62,6 +62,31 @@ function slugOf(path) {
return path === '/' ? '🏠' : path.split('/').pop() return path === '/' ? '🏠' : path.split('/').pop()
} }
/** Host name of an external https origin, with scheme stripped. */
function externalSlug(origin) {
try {
return new URL(origin).host
} catch {
return origin.replace(/^https?:\/\//, '')
}
}
/** Format one trail step: an internal page or an external https origin. */
function stepOf(path, titles) {
if (path?.startsWith('/')) {
return { path, slug: slugOf(path), title: titles.get(path) || '', external: false }
}
if (path?.startsWith('https://')) {
return {
path,
slug: externalSlug(path),
title: 'External site',
external: true,
}
}
return null
}
/** /**
* Human-readable relative timestamp. Adapted from cista-storage: uses * Human-readable relative timestamp. Adapted from cista-storage: uses
* ``Intl.RelativeTimeFormat`` for short intervals and a compact date for * ``Intl.RelativeTimeFormat`` for short intervals and a compact date for
@@ -117,8 +142,9 @@ export function formatWhenTooltip(ts) {
/** /**
* Format recent visits for display, newest first. Each step is a linked slug * Format recent visits for display, newest first. Each step is a linked slug
* pointing to its article; external referers/origins and direct entries are * pointing to its article; external referers/origins are shown as their
* omitted. The link title shows the article heading when known. * domain name with the full origin as the link href. The link title shows the
* article heading when known, or "External site" for origins.
*/ */
export function formatRecentVisits(visits, pageTree, limit = 50) { export function formatRecentVisits(visits, pageTree, limit = 50) {
const titles = buildTitleMap(pageTree) const titles = buildTitleMap(pageTree)
@@ -126,13 +152,9 @@ export function formatRecentVisits(visits, pageTree, limit = 50) {
.reverse() .reverse()
.map((v) => ({ .map((v) => ({
when: new Date(v.start).toLocaleString(), when: new Date(v.start).toLocaleString(),
steps: [v.entry, ...(v.trail || [])] steps: [v.referer, v.entry, ...(v.trail || [])]
.filter((p) => p?.startsWith('/')) .map((p) => stepOf(p, titles))
.map((p) => ({ .filter(Boolean),
path: p,
slug: slugOf(p),
title: titles.get(p) || '',
})),
})) }))
.filter((v) => v.steps.length) .filter((v) => v.steps.length)
.slice(0, limit) .slice(0, limit)
@@ -239,19 +261,16 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
/** /**
* Format raw visit records as rows for a technical table. Returns objects * Format raw visit records as rows for a technical table. Returns objects
* with display strings; missing values become "—". ``trail`` joins page * with display strings; missing values become "—". ``trail`` starts with the
* titles (when known) with " -> ". Only the 20 most recent visits are shown. * external referer (when present), then the entry page and any further internal
* pages or external exit origins. Only the 20 most recent visits are shown.
*/ */
export function formatVisitRows(visits, pageTree, now = Date.now()) { export function formatVisitRows(visits, pageTree, now = Date.now()) {
const titles = buildTitleMap(pageTree) const titles = buildTitleMap(pageTree)
return [...(visits || [])].reverse().slice(0, 20).map((v) => { return [...(visits || [])].reverse().slice(0, 20).map((v) => {
const trail = [v.entry, ...(v.trail || [])] const trail = [v.referer, v.entry, ...(v.trail || [])]
.filter((p) => p?.startsWith('/')) .map((p) => stepOf(p, titles))
.map((p) => ({ .filter(Boolean)
path: p,
slug: slugOf(p),
title: titles.get(p) || '',
}))
const utm = Object.entries(v.utm || {}) const utm = Object.entries(v.utm || {})
.map(([k, value]) => `${k}=${value}`) .map(([k, value]) => `${k}=${value}`)
.join(', ') .join(', ')
+39 -21
View File
@@ -14,12 +14,13 @@
* emitted at time intervals inversely proportional (linear) to the * emitted at time intervals inversely proportional (linear) to the
* directional count. * directional count.
* External referers appear as nodes in a row above the map, external exits * External referers appear as nodes in a row above the map, external exits
* as small nodes just outside their source page, angled away from the * as full-size nodes just outside their source page, angled away from the
* center. Self-loops (reload pings) are skipped. * center. Each distinct full exit URL is its own node. Self-loops (reload
* pings) are skipped.
*/ */
export const TNODE_R = 34 // node circles hold the slug and the view count export const TNODE_R = 34 // node circles hold the slug and the view count
export const EXT_R = 16 // external referer/exit nodes export const EXT_R = 34 // external referer/exit nodes use the same full size
// Edge width (half-width of the thin middle) grows logarithmically with // Edge width (half-width of the thin middle) grows logarithmically with
// the count, anchored so a single recorded transition renders as a ~1 px // the count, anchored so a single recorded transition renders as a ~1 px
@@ -86,7 +87,7 @@ function collectInternalTransitions(transitions) {
/** Short display label for an external origin (protocol stripped). */ /** Short display label for an external origin (protocol stripped). */
function extLabel(ext) { function extLabel(ext) {
const s = ext.replace(/^https?:\/\//, '') const s = ext.replace(/^https?:\/\//, '')
return s.length > 18 ? `${s.slice(0, 17)}` : s return s.length > 11 ? `${s.slice(0, 10)}` : s
} }
/** /**
@@ -541,7 +542,8 @@ function buildExternal(external, byPath, radius, innerBounds) {
const spacing = 2 * EXT_R + 44 const spacing = 2 * EXT_R + 44
const x0 = cx - ((origins.length - 1) * spacing) / 2 const x0 = cx - ((origins.length - 1) * spacing) / 2
origins.forEach(({ ext, ps }, i) => { origins.forEach(({ ext, ps }, i) => {
const xn = { path: ext, label: extLabel(ext), x: x0 + i * spacing, y, r: EXT_R } const total = ps.reduce((s, p) => s + p.in, 0)
const xn = { path: ext, label: extLabel(ext), x: x0 + i * spacing, y, r: EXT_R, count: total, kind: 'source' }
extNodes.push(xn) extNodes.push(xn)
for (const p of ps) { for (const p of ps) {
const page = byPath.get(p.page) const page = byPath.get(p.page)
@@ -551,30 +553,46 @@ function buildExternal(external, byPath, radius, innerBounds) {
}) })
} }
// Outgoing: small exit nodes fanned outwards from the source page. // Outgoing: group by full URL so several links to the same domain stay
// distinct. Each exit node is placed one ring-gap outside its source page
// (same radial spacing internal rings use), fanned around the source angle,
// and shows the total count across all pages that link to that URL.
const GAP = radius(1) - radius(0)
const outgoing = live.filter((p) => p.out >= minCount) const outgoing = live.filter((p) => p.out >= minCount)
.sort((a, b) => b.out - a.out).slice(0, MAX_EXT_OUT) .sort((a, b) => b.out - a.out)
const perPage = new Map() const perPage = new Map()
const selected = []
for (const p of outgoing) { for (const p of outgoing) {
const used = perPage.get(p.page) || 0 const used = perPage.get(p.page) || 0
if (used >= MAX_EXT_OUT_PER_PAGE) continue if (used >= MAX_EXT_OUT_PER_PAGE) continue
perPage.set(p.page, used + 1) perPage.set(p.page, used + 1)
selected.push(p)
if (selected.length >= MAX_EXT_OUT) break
}
const exitNodes = new Map() // full URL -> node
const placedPerPage = new Map() // for angle fanning of the placement anchor
for (const p of selected) {
const page = byPath.get(p.page) const page = byPath.get(p.page)
// Fan multiple exits of one page symmetrically around the outward let xn = exitNodes.get(p.ext)
// direction; the center page has no angle, so its exits point down if (!xn) {
// (the top row above the map belongs to referers). const used = placedPerPage.get(p.page) || 0
const base = page.depth ? page.angle : Math.PI / 2 placedPerPage.set(p.page, used + 1)
const ang = base + [0, 0.4, -0.4][used] const base = page.depth ? page.angle : Math.PI / 2
let dist = TNODE_R + 40 const ang = base + [0, 0.4, -0.4][used]
let x = page.x + Math.cos(ang) * dist let dist = GAP
let y = page.y + Math.sin(ang) * dist let x = page.x + Math.cos(ang) * dist
for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) { let y = page.y + Math.sin(ang) * dist
dist += 24 for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) {
x = page.x + Math.cos(ang) * dist dist += GAP * 0.3
y = page.y + Math.sin(ang) * dist x = page.x + Math.cos(ang) * dist
y = page.y + Math.sin(ang) * dist
}
xn = { path: p.ext, label: extLabel(p.ext), x, y, r: EXT_R, count: 0, kind: 'exit' }
exitNodes.set(p.ext, xn)
extNodes.push(xn)
} }
const xn = { path: p.ext, label: extLabel(p.ext), x, y, r: EXT_R } xn.count += p.out
extNodes.push(xn)
edges.push(buildRibbon(page, xn, p.out, 0, width(p.out), TNODE_R, EXT_R)) edges.push(buildRibbon(page, xn, p.out, 0, width(p.out), TNODE_R, EXT_R))
flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0)) flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0))
} }
+3 -3
View File
@@ -536,9 +536,9 @@ import "overlayscrollbars/overlayscrollbars.css";
if (!a || a.target || a.hasAttribute("download")) return; if (!a || a.target || a.hasAttribute("download")) return;
const url = new URL(a.href, location.href); const url = new URL(a.href, location.href);
if (url.origin !== location.origin) { if (url.origin !== location.origin) {
// External link: the browser navigates; just record the exit (https // External link: the browser navigates; record the full https URL so
// origins only, stripped to the origin part server-side anyway). // different links to the same domain stay distinct in analytics.
if (url.protocol === "https:") ping(url.origin); if (url.protocol === "https:") ping(url.href);
return; return;
} }
// Same-page anchor links (footnotes etc.): let the browser handle them // Same-page anchor links (footnotes etc.): let the browser handle them
+17 -6
View File
@@ -52,7 +52,7 @@ def _compact_user_agent(ua: str) -> str:
class Visit(msgspec.Struct, omit_defaults=True): class Visit(msgspec.Struct, omit_defaults=True):
"""One visit: the initial-load data plus everything seen afterwards. """One visit: the initial-load data plus everything seen afterwards.
``trail`` holds page paths and external exit origins in first-seen ``trail`` holds page paths and external exit URLs in first-seen
order; re-visiting an already seen page does not append. The entry order; re-visiting an already seen page does not append. The entry
page itself is in ``entry``, not in the trail. page itself is in ``entry``, not in the trail.
""" """
@@ -129,6 +129,17 @@ def _origin(url: str) -> str | None:
return f"https://{parsed.netloc}" return f"https://{parsed.netloc}"
def _external_target(url: str) -> str | None:
"""A valid https URL (origin or full page), else None."""
try:
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme != "https" or not parsed.netloc:
return None
return url
_SEGMENT = re.compile(r"[a-z0-9][a-z0-9_-]*") _SEGMENT = re.compile(r"[a-z0-9][a-z0-9_-]*")
@@ -364,9 +375,9 @@ class Store:
) -> int | None: ) -> int | None:
"""Record a client navigation ping ({from, to} from pagerite.js). """Record a client navigation ping ({from, to} from pagerite.js).
``to`` is an internal path ("/...") or an https origin for exit ``to`` is an internal path ("/...") or an https URL for exit links;
links; anything else is ignored. The transition is always counted; anything else is ignored. The transition is always counted; the trail
the trail only grows on first sight of a page within the visit. only grows on first sight of a page within the visit.
A ping with no known session starts a fresh visit, consuming the A ping with no known session starts a fresh visit, consuming the
referer and UTM tags stashed by the document GET if there are any. referer and UTM tags stashed by the document GET if there are any.
@@ -382,8 +393,8 @@ class Store:
if to.startswith("/") and not to.startswith("//"): if to.startswith("/") and not to.startswith("//"):
target = _internal_path(to) or "" target = _internal_path(to) or ""
else: else:
target = _origin(to) or "" target = _external_target(to) or ""
if not target or (not to.startswith("/") and target != to): if not target:
return None return None
key = (ip, ua) key = (ip, ua)
index = self.sessions.get(key) index = self.sessions.get(key)