diff --git a/docs/analytics.md b/docs/analytics.md
index efd9358..40783de 100644
--- a/docs/analytics.md
+++ b/docs/analytics.md
@@ -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
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).
-- **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,
- 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
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)
@@ -83,7 +85,7 @@ Each `Visit` record:
- `ip` — visitor IP address (first `X-Forwarded-For` hop, or direct peer),
- `host` — reverse-DNS host name for `ip` when resolvable, else `""`,
- `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.
- `lang` — first `Accept-Language` tag, lowercased (e.g. `en-us`),
- `country` — two-letter country code. Initially derived from the
diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue
index 607382d..d4de75f 100644
--- a/frontend/src/AnalyticsView.vue
+++ b/frontend/src/AnalyticsView.vue
@@ -146,7 +146,10 @@ function countryName(code) {
{{ v.when }} |
+ :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 }}
|
diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue
index 6cb253d..bb58669 100644
--- a/frontend/src/TransitionGraph.vue
+++ b/frontend/src/TransitionGraph.vue
@@ -109,10 +109,12 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
-
- {{ x.path }}
-
- {{ x.label }}
+
+
+ {{ x.label }}
+ {{ x.count }}
+
@@ -144,14 +146,10 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
}
.tmap .txnode {
fill: var(--bg, Canvas);
- stroke: var(--muted);
- stroke-width: 1;
-}
-.tmap .txlabel {
- fill: var(--muted);
- font-size: 9px;
- text-anchor: middle;
+ stroke-width: 1.5;
}
+.tmap .txnode-source { stroke: var(--text); }
+.tmap .txnode-exit { stroke: var(--muted); }
.tmap .tarc {
fill: none;
stroke: var(--line);
diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js
index 281add5..faabba9 100644
--- a/frontend/src/analytics/format.js
+++ b/frontend/src/analytics/format.js
@@ -62,6 +62,31 @@ function slugOf(path) {
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
* ``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
- * pointing to its article; external referers/origins and direct entries are
- * omitted. The link title shows the article heading when known.
+ * pointing to its article; external referers/origins are shown as their
+ * 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) {
const titles = buildTitleMap(pageTree)
@@ -126,13 +152,9 @@ export function formatRecentVisits(visits, pageTree, limit = 50) {
.reverse()
.map((v) => ({
when: new Date(v.start).toLocaleString(),
- steps: [v.entry, ...(v.trail || [])]
- .filter((p) => p?.startsWith('/'))
- .map((p) => ({
- path: p,
- slug: slugOf(p),
- title: titles.get(p) || '',
- })),
+ steps: [v.referer, v.entry, ...(v.trail || [])]
+ .map((p) => stepOf(p, titles))
+ .filter(Boolean),
}))
.filter((v) => v.steps.length)
.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
- * with display strings; missing values become "—". ``trail`` joins page
- * titles (when known) with " -> ". Only the 20 most recent visits are shown.
+ * with display strings; missing values become "—". ``trail`` starts with the
+ * 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()) {
const titles = buildTitleMap(pageTree)
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
- const trail = [v.entry, ...(v.trail || [])]
- .filter((p) => p?.startsWith('/'))
- .map((p) => ({
- path: p,
- slug: slugOf(p),
- title: titles.get(p) || '',
- }))
+ const trail = [v.referer, v.entry, ...(v.trail || [])]
+ .map((p) => stepOf(p, titles))
+ .filter(Boolean)
const utm = Object.entries(v.utm || {})
.map(([k, value]) => `${k}=${value}`)
.join(', ')
diff --git a/frontend/src/analytics/transitions.js b/frontend/src/analytics/transitions.js
index fb77c59..36397c8 100644
--- a/frontend/src/analytics/transitions.js
+++ b/frontend/src/analytics/transitions.js
@@ -14,12 +14,13 @@
* emitted at time intervals inversely proportional (linear) to the
* directional count.
* 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
- * center. Self-loops (reload pings) are skipped.
+ * as full-size nodes just outside their source page, angled away from the
+ * 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 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
// 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). */
function extLabel(ext) {
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 x0 = cx - ((origins.length - 1) * spacing) / 2
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)
for (const p of ps) {
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)
- .sort((a, b) => b.out - a.out).slice(0, MAX_EXT_OUT)
+ .sort((a, b) => b.out - a.out)
const perPage = new Map()
+ const selected = []
for (const p of outgoing) {
const used = perPage.get(p.page) || 0
if (used >= MAX_EXT_OUT_PER_PAGE) continue
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)
- // Fan multiple exits of one page symmetrically around the outward
- // direction; the center page has no angle, so its exits point down
- // (the top row above the map belongs to referers).
- const base = page.depth ? page.angle : Math.PI / 2
- const ang = base + [0, 0.4, -0.4][used]
- let dist = TNODE_R + 40
- let x = page.x + Math.cos(ang) * dist
- let y = page.y + Math.sin(ang) * dist
- for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) {
- dist += 24
- x = page.x + Math.cos(ang) * dist
- y = page.y + Math.sin(ang) * dist
+ let xn = exitNodes.get(p.ext)
+ if (!xn) {
+ const used = placedPerPage.get(p.page) || 0
+ placedPerPage.set(p.page, used + 1)
+ const base = page.depth ? page.angle : Math.PI / 2
+ const ang = base + [0, 0.4, -0.4][used]
+ let dist = GAP
+ let x = page.x + Math.cos(ang) * dist
+ let y = page.y + Math.sin(ang) * dist
+ for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) {
+ dist += GAP * 0.3
+ 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 }
- extNodes.push(xn)
+ xn.count += p.out
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))
}
diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js
index fb383f1..c2645bc 100644
--- a/frontend/src/pagerite.js
+++ b/frontend/src/pagerite.js
@@ -536,9 +536,9 @@ import "overlayscrollbars/overlayscrollbars.css";
if (!a || a.target || a.hasAttribute("download")) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin) {
- // External link: the browser navigates; just record the exit (https
- // origins only, stripped to the origin part server-side anyway).
- if (url.protocol === "https:") ping(url.origin);
+ // External link: the browser navigates; record the full https URL so
+ // different links to the same domain stay distinct in analytics.
+ if (url.protocol === "https:") ping(url.href);
return;
}
// Same-page anchor links (footnotes etc.): let the browser handle them
diff --git a/pagerite/analytics.py b/pagerite/analytics.py
index 710f928..2ce8bd0 100644
--- a/pagerite/analytics.py
+++ b/pagerite/analytics.py
@@ -52,7 +52,7 @@ def _compact_user_agent(ua: str) -> str:
class Visit(msgspec.Struct, omit_defaults=True):
"""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
page itself is in ``entry``, not in the trail.
"""
@@ -129,6 +129,17 @@ def _origin(url: str) -> str | None:
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_-]*")
@@ -364,9 +375,9 @@ class Store:
) -> int | None:
"""Record a client navigation ping ({from, to} from pagerite.js).
- ``to`` is an internal path ("/...") or an https origin for exit
- links; anything else is ignored. The transition is always counted;
- the trail only grows on first sight of a page within the visit.
+ ``to`` is an internal path ("/...") or an https URL for exit links;
+ anything else is ignored. The transition is always counted; the trail
+ only grows on first sight of a page within the visit.
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.
@@ -382,8 +393,8 @@ class Store:
if to.startswith("/") and not to.startswith("//"):
target = _internal_path(to) or ""
else:
- target = _origin(to) or ""
- if not target or (not to.startswith("/") and target != to):
+ target = _external_target(to) or ""
+ if not target:
return None
key = (ip, ua)
index = self.sessions.get(key)