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
+4 -1
View File
@@ -146,7 +146,10 @@ function countryName(code) {
<td class="when" :title="v.whenTooltip">{{ v.when }}</td>
<td class="trail">
<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 }}
</a>
</td>
+9 -11
View File
@@ -109,10 +109,12 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
<circle v-for="(b, i) in beads" :key="'b' + i"
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
<circle :cx="x.x" :cy="x.y" :r="x.r" class="txnode">
<title>{{ x.path }}</title>
</circle>
<text :x="x.x" :y="x.y + x.r + 11" class="txlabel">{{ x.label }}</text>
<a :href="x.path" target="_blank" rel="noopener" :title="x.path">
<circle :cx="x.x" :cy="x.y" :r="x.r"
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
<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 v-for="n in graph.nodes" :key="n.path">
<a :href="n.path" :title="n.title">
@@ -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);
+37 -18
View File
@@ -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(', ')
+39 -21
View File
@@ -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))
}
+3 -3
View File
@@ -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