analytics: 24h day view with bar chart for precise realtime stats. Tables redesigned with cleaner layout. Tracking article read times. Adjust connection graph visualizations by time range. Other cleanup and supporting systems.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* rates (hour on the week view, day on month+).
|
||||
*/
|
||||
|
||||
import { DAY, HOUR, WEEK, mondayUTC } from './time.js'
|
||||
import { DAY, HOUR, MIN5, WEEK, mondayUTC } from './time.js'
|
||||
|
||||
export const CHART_W = 720
|
||||
export const CHART_H = 180
|
||||
@@ -206,9 +206,10 @@ export function spline(pts) {
|
||||
}
|
||||
|
||||
/** Build a full chart model from a series descriptor produced by time.js. */
|
||||
export function buildChart(input) {
|
||||
export function buildChart(input, now = Date.now()) {
|
||||
if (!input || !input.series.length) return null
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes } = input
|
||||
if (input.unit === '5min') return buildDayChart(input, now)
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes, unit } = input
|
||||
// Values are per-unit rates (hour on the week view, day on month+); the
|
||||
// y max is derived from the *smoothed* curves so random single-bucket
|
||||
// spikes don't blow up the scale. Smoothing works on raw counts (its edge
|
||||
@@ -283,7 +284,91 @@ export function buildChart(input) {
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
return { max, majors, minors, series: drawn, xticks }
|
||||
return { max, majors, minors, series: drawn, xticks, unit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Day view: 5-minute bars for the last 24 hours. Bars are drawn at raw
|
||||
* counts; the skyline uses a projected full-bucket value for the still-open
|
||||
* final bucket. The y scale is derived from the projected skyline maximum.
|
||||
*/
|
||||
export function buildDayChart(input, now = Date.now()) {
|
||||
const { series, t0, t1 } = input
|
||||
const points = series[0]?.points || []
|
||||
const n = points.length
|
||||
if (!n) return null
|
||||
const bucketMs = (t1 - t0) / n
|
||||
const bucketWidth = CHART_W / n
|
||||
const gap = 0.2
|
||||
const barWidth = Math.max(0.2, bucketWidth - gap)
|
||||
|
||||
const x = (i) => i * bucketWidth + gap / 2
|
||||
const prevRaw = n > 1 ? points[n - 2].count : 0
|
||||
const projected = points.map((p, i) => {
|
||||
if (i !== n - 1) return p.count
|
||||
const bucketStart = t0 + i * bucketMs
|
||||
const elapsed = Math.max(1, Math.min(bucketMs, now - bucketStart))
|
||||
// Blend the observed partial bucket with the previous full bucket:
|
||||
// the longer the current bucket has run, the less we borrow from it.
|
||||
const share = elapsed / bucketMs
|
||||
return p.count + prevRaw * (1 - share)
|
||||
})
|
||||
const highest = Math.max(0, ...projected)
|
||||
const { max, step, minor } = yScale(highest)
|
||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
||||
|
||||
const bars = points.map((p, i) => {
|
||||
const bx = x(i)
|
||||
const by = y(p.count)
|
||||
return {
|
||||
x: bx,
|
||||
y: by,
|
||||
width: barWidth,
|
||||
height: CHART_H - by,
|
||||
raw: p.count,
|
||||
projected: projected[i],
|
||||
}
|
||||
})
|
||||
|
||||
let skyline = ''
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
const b = bars[i]
|
||||
const top = y(b.projected)
|
||||
if (i === 0) {
|
||||
skyline += `M${b.x},${top} H${b.x + b.width}`
|
||||
} else {
|
||||
skyline += ` V${top} H${b.x + b.width}`
|
||||
}
|
||||
}
|
||||
|
||||
const majors = []
|
||||
const minors = []
|
||||
const nMajor = Math.round(max / step)
|
||||
for (let k = 0; k <= nMajor; k++) {
|
||||
const v = k * step
|
||||
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
|
||||
}
|
||||
if (minor) {
|
||||
for (let v = minor; v < max; v += minor) {
|
||||
if (v % step !== 0) minors.push({ y: y(v) })
|
||||
}
|
||||
}
|
||||
|
||||
const xticks = []
|
||||
const tickStep = 3 * HOUR
|
||||
const firstTick = Math.ceil(t0 / tickStep) * tickStep
|
||||
for (let t = firstTick; t < t1; t += tickStep) {
|
||||
if (t < t0) continue
|
||||
const d = new Date(t)
|
||||
xticks.push({
|
||||
x: ((t - t0) / (t1 - t0)) * CHART_W,
|
||||
left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: `${String(d.getUTCHours()).padStart(2, '0')}:00`,
|
||||
line: false,
|
||||
})
|
||||
}
|
||||
|
||||
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
||||
}
|
||||
|
||||
/** X ticks for year/all: Monday boundaries up to a quarter, UTC month
|
||||
|
||||
@@ -25,11 +25,44 @@ export const hostIP = (ip) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy the full IP to the clipboard, ignoring failures. */
|
||||
export async function copyIp(ip) {
|
||||
function showCopiedFeedback(el) {
|
||||
if (!el || typeof document === 'undefined') return
|
||||
const popup = document.createElement('span')
|
||||
popup.textContent = 'Copied!'
|
||||
popup.className = 'copy-popup'
|
||||
popup.style.cssText =
|
||||
'position:absolute;bottom:calc(100% + 0.25rem);left:50%;' +
|
||||
'transform:translateX(-50%);padding:0.15rem 0.4rem;' +
|
||||
'background:var(--text, CanvasText);color:var(--bg, Canvas);' +
|
||||
'border-radius:0.25rem;font-size:0.75rem;white-space:nowrap;' +
|
||||
'pointer-events:none;z-index:10;'
|
||||
el.classList.add('has-copy-popup')
|
||||
el.appendChild(popup)
|
||||
setTimeout(() => {
|
||||
popup.remove()
|
||||
el.classList.remove('has-copy-popup')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
/** Copy the full IP to the clipboard and show a brief "Copied!" popup. */
|
||||
export async function copyIp(ip, event) {
|
||||
if (!ip) return
|
||||
const el = event?.currentTarget
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
showCopiedFeedback(el)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy arbitrary text to the clipboard and show a brief "Copied!" popup. */
|
||||
export async function copyList(text, event) {
|
||||
if (!text) return
|
||||
const el = event?.currentTarget
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showCopiedFeedback(el)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -140,6 +173,33 @@ export function formatWhenTooltip(ts) {
|
||||
return new Date(ts).toISOString().replace('T', ' ').replace('Z', ' UTC')
|
||||
}
|
||||
|
||||
/** Full local timestamp for tooltips, e.g. "21 Aug 2026, 17:38:48". */
|
||||
export function formatWhenLocal(ts) {
|
||||
return new Date(ts).toLocaleString('en-ie', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Preserve locale case with the region/country subtag upper-cased. */
|
||||
export function formatLang(value) {
|
||||
if (!value || value === '—') return value
|
||||
const parts = value.split('-')
|
||||
if (parts.length > 1) {
|
||||
parts[parts.length - 1] = parts[parts.length - 1].toUpperCase()
|
||||
}
|
||||
return parts.join('-')
|
||||
}
|
||||
|
||||
/** ISO 8601 UTC timestamp without subseconds, e.g. "2026-08-21T00:20:48Z". */
|
||||
export function formatWhenIso(ts) {
|
||||
return `${new Date(ts).toISOString().split('.')[0]}Z`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format recent visits for display, newest first. Each step is a linked slug
|
||||
* pointing to its article; external referers/origins are shown as their
|
||||
@@ -241,8 +301,9 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
.sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart)
|
||||
.slice(0, 10)
|
||||
.map((g) => ({
|
||||
when: formatWhen(g.lastStart, now),
|
||||
whenTooltip: formatWhenTooltip(g.lastStart),
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||
pages: [...g.pages.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([path, count]) => ({
|
||||
@@ -259,6 +320,85 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Group abuse hits by IP (never by UA — scanners randomize theirs to
|
||||
* masquerade as legitimate crawlers) and format each group as a row with
|
||||
* the full paths probed, in access order. Flagged paths (the ones that
|
||||
* triggered abuse classification) are lifted to the top, followed by
|
||||
* other 404s, then document GETs from the abuser. UAs are shown raw,
|
||||
* one per line, with their occurrence counts. Paths are shown verbatim
|
||||
* (query string included), not resolved against the page tree.
|
||||
*/
|
||||
export function formatAbuseRows(abuse, now = Date.now()) {
|
||||
const groups = new Map()
|
||||
for (const a of abuse || []) {
|
||||
const g = groups.get(a.ip) || {
|
||||
ip: a.ip || '',
|
||||
pathHits: [],
|
||||
rawUas: [],
|
||||
uaCounts: new Map(),
|
||||
lastStart: 0,
|
||||
}
|
||||
const start = new Date(a.start).getTime()
|
||||
if (start > g.lastStart) g.lastStart = start
|
||||
g.pathHits.push({
|
||||
path: a.path || '',
|
||||
start,
|
||||
flag: a.flag || false,
|
||||
is_404: a.is_404 || false,
|
||||
})
|
||||
const ua = a.ua || '(no UA)'
|
||||
g.rawUas.push(ua)
|
||||
g.uaCounts.set(ua, (g.uaCounts.get(ua) || 0) + 1)
|
||||
groups.set(a.ip, g)
|
||||
}
|
||||
const totalHits = (g) => g.pathHits.length
|
||||
return [...groups.values()]
|
||||
.sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart)
|
||||
.slice(0, 10)
|
||||
.map((g) => {
|
||||
const pathCategory = (p) => (p.flag ? 0 : p.is_404 ? 1 : 2)
|
||||
const paths = [...g.pathHits].sort(
|
||||
(a, b) => pathCategory(a) - pathCategory(b) || a.start - b.start,
|
||||
)
|
||||
const uas = [...g.uaCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
return {
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||
paths: paths.map((p) => ({ path: p.path, flag: p.flag, is_404: p.is_404 })),
|
||||
allPaths: paths.map((p) => p.path).join('\n'),
|
||||
uas: uas.map(([ua, count]) => ({ ua, count })),
|
||||
allUas: uas
|
||||
.map(([ua, count]) => (count > 1 ? `${count}× ${ua}` : ua))
|
||||
.join('\n'),
|
||||
ip: g.ip,
|
||||
ipDisplay: hostIP(g.ip) || g.ip || '—',
|
||||
total: totalHits(g),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a reverse-DNS hostname to its right-most components that fit
|
||||
* within ``limit`` characters. This keeps the meaningful main domain
|
||||
* while avoiding absurdly long subdomains like ``xxx.yyy.zzz...provider.net``.
|
||||
*/
|
||||
function mainDomain(host, limit = 24) {
|
||||
if (!host) return host
|
||||
const labels = host.split('.').filter(Boolean)
|
||||
if (!labels.length) return host
|
||||
const parts = [labels.pop()]
|
||||
while (labels.length) {
|
||||
const next = labels[labels.length - 1]
|
||||
const candidate = `${next}.${parts.join('.')}`
|
||||
if (candidate.length > limit) break
|
||||
parts.unshift(labels.pop())
|
||||
}
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format raw visit records as rows for a technical table. Returns objects
|
||||
* with display strings; missing values become "—". ``trail`` starts with the
|
||||
@@ -268,21 +408,26 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
export function formatVisitRows(visits, pageTree, now = Date.now()) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||
const trail = [v.referer, v.entry, ...(v.trail || [])]
|
||||
const trail = [v.entry, ...(v.trail || [])]
|
||||
.map((p) => stepOf(p, titles))
|
||||
.filter(Boolean)
|
||||
const utm = Object.entries(v.utm || {})
|
||||
.map(([k, value]) => `${k}=${value}`)
|
||||
.join(', ')
|
||||
const dash = (s) => (s || '—')
|
||||
const host = v.host || ''
|
||||
const isHost = !!host
|
||||
return {
|
||||
when: formatWhen(v.start, now),
|
||||
whenTooltip: formatWhenTooltip(v.start),
|
||||
lastSeen: formatWhen(v.start, now),
|
||||
lastSeenIso: formatWhenIso(v.start),
|
||||
lastSeenLocal: formatWhenLocal(v.start),
|
||||
langDisplay: formatLang(v.lang),
|
||||
trail,
|
||||
refererStep: stepOf(v.referer, titles),
|
||||
referer: dash(v.referer),
|
||||
ip: v.ip || '',
|
||||
ipDisplay: v.host || hostIP(v.ip) || v.ip || '—',
|
||||
host: dash(v.host),
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(v.ip) || v.ip || '—',
|
||||
isHost,
|
||||
lang: dash(v.lang),
|
||||
country: dash(v.country),
|
||||
city: dash(v.city),
|
||||
|
||||
@@ -13,6 +13,7 @@ export const DAY = 86400e3
|
||||
export const WEEK = 7 * DAY
|
||||
|
||||
export const RANGES = {
|
||||
day: { label: 'day', span: DAY, bucket: MIN5 },
|
||||
week: { label: 'week' },
|
||||
month: { label: 'month', span: 30 * DAY, bucket: 6 * HOUR },
|
||||
year: { label: 'year', span: 365 * DAY, bucket: DAY },
|
||||
@@ -117,11 +118,36 @@ export function rollingSeries(buckets, rangeKey) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch to weekly or rolling series based on the selected range. */
|
||||
/**
|
||||
* Day view: raw 5-minute bucket counts for the current 24-hour window.
|
||||
* No smoothing or rate conversion is applied; counts are used as-is.
|
||||
*/
|
||||
export function daySeries(buckets) {
|
||||
const raw = rawTimes(buckets)
|
||||
const now = Date.now()
|
||||
const { span, bucket } = RANGES.day
|
||||
const t1 = Math.floor(now / bucket) * bucket + bucket
|
||||
const t0 = t1 - span
|
||||
const points = []
|
||||
for (let t = t0; t < t1; t += bucket) {
|
||||
points.push({ t, count: raw[t] || 0 })
|
||||
}
|
||||
return {
|
||||
series: [{ points, label: '', opacity: 1, area: false }],
|
||||
t0,
|
||||
t1,
|
||||
rate: 1,
|
||||
binMinutes: bucket / 60e3,
|
||||
unitMinutes: bucket / 60e3,
|
||||
unit: '5min',
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch to daily, weekly or rolling series based on the selected range. */
|
||||
export function makeSeries(buckets, rangeKey) {
|
||||
return rangeKey === 'week'
|
||||
? weeklySeries(buckets)
|
||||
: rollingSeries(buckets, rangeKey)
|
||||
if (rangeKey === 'day') return daySeries(buckets)
|
||||
if (rangeKey === 'week') return weeklySeries(buckets)
|
||||
return rollingSeries(buckets, rangeKey)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,22 +23,23 @@ export const TNODE_R = 34 // node circles hold the slug and the view count
|
||||
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
|
||||
// line. There is no cap — growth is slow enough that even very hot
|
||||
// connections stay reasonable. Connections carrying less than
|
||||
// PRUNE_FRACTION of the total traffic are not drawn at all (this also
|
||||
// keeps the number of drawn connections under ~100).
|
||||
const WMID_MIN = 0.5
|
||||
const WIDTH_GROWTH = 1.5
|
||||
// the count. The constants are scaled down by ~10× so busy ranges (day,
|
||||
// year) do not overwhelm the graph with fat connectors. A single recorded
|
||||
// transition still renders as a faint ~0.4 px line. Connections carrying
|
||||
// less than PRUNE_FRACTION of the total traffic are not drawn at all (this
|
||||
// also keeps the graph under ~100 connections).
|
||||
const WMID_MIN = 0.2
|
||||
const WIDTH_GROWTH = 0.15
|
||||
const PRUNE_FRACTION = 0.01
|
||||
|
||||
// Beads: each edge direction emits beads at count * BEAD_RATE beads per
|
||||
// second (linear in the count). The component simulates every bead
|
||||
// independently in JS at BEAD_SPEED along the edge, with no limit on
|
||||
// second (linear in the count). The rate is reduced ~10× across all time
|
||||
// scales to keep the animation lightweight. The component simulates every
|
||||
// bead independently in JS at BEAD_SPEED along the edge, with no limit on
|
||||
// beads in flight.
|
||||
export const BEAD_SPEED = 180 // svg units per second
|
||||
export const BEAD_R = 2.2
|
||||
const BEAD_RATE = 0.12 // beads per second per recorded transition
|
||||
const BEAD_RATE = 0.012 // beads per second per recorded transition
|
||||
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
|
||||
|
||||
const MAX_EXT_IN = 8 // referer nodes in the top row
|
||||
@@ -84,10 +85,15 @@ function collectInternalTransitions(transitions) {
|
||||
return internal
|
||||
}
|
||||
|
||||
/** Short display label for an external origin (protocol stripped). */
|
||||
/** Domain-only label for an external origin (path removed). */
|
||||
function extLabel(ext) {
|
||||
const s = ext.replace(/^https?:\/\//, '')
|
||||
return s.length > 11 ? `${s.slice(0, 10)}…` : s
|
||||
try {
|
||||
const host = new URL(ext).hostname
|
||||
return host.length > 25 ? `${host.slice(0, 24)}…` : host
|
||||
} catch {
|
||||
const s = ext.replace(/^https?:\/\//, '').split('/')[0]
|
||||
return s.length > 25 ? `${s.slice(0, 24)}…` : s
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,8 +181,27 @@ function layoutAngles(root, unit, weight) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute median reading time per article in whole minutes. */
|
||||
function buildReadMinutes(visits) {
|
||||
const times = {}
|
||||
for (const v of visits || []) {
|
||||
for (const [path, sec] of Object.entries(v.read || {})) {
|
||||
;(times[path] || (times[path] = [])).push(sec)
|
||||
}
|
||||
}
|
||||
const minutes = {}
|
||||
for (const [path, arr] of Object.entries(times)) {
|
||||
arr.sort((a, b) => a - b)
|
||||
const mid = Math.floor(arr.length / 2)
|
||||
const median =
|
||||
arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2
|
||||
minutes[path] = Math.max(1, Math.round(median / 60))
|
||||
}
|
||||
return minutes
|
||||
}
|
||||
|
||||
/** Compute radial positions, view counts and labels for each node. */
|
||||
function positionNodes(nodes, maxDepth, unit, viewsData, titles) {
|
||||
function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
|
||||
// Constant radial gap between rings, equal to the arc spacing of nodes
|
||||
// along a ring: leaf arc = unit * GAP, so GAP scales up with `unit` on
|
||||
// sparse trees (where closing the circle forces wider arcs) and with
|
||||
@@ -196,10 +221,14 @@ function positionNodes(nodes, maxDepth, unit, viewsData, titles) {
|
||||
n.x = Math.cos(n.angle) * r
|
||||
n.y = Math.sin(n.angle) * r
|
||||
n.views = viewCount(n.path)
|
||||
n.readMin = readMinutes[n.path] || 0
|
||||
// Slug inside the circle; full title goes on the link title attribute.
|
||||
const slug = n.path === '/' ? '🏠' : n.path.split('/').pop()
|
||||
n.label = slug.length > 11 ? `${slug.slice(0, 10)}…` : slug
|
||||
n.label = slug.length > 16 ? `${slug.slice(0, 15)}…` : slug
|
||||
n.title = titles.get(n.path) || ''
|
||||
// Category (non-leaf) pages with no views in this window are left
|
||||
// blank to keep the layout, but their circle/label is not drawn.
|
||||
n.hidden = n.children.length > 0 && n.views === 0
|
||||
}
|
||||
|
||||
return { radius, GAP }
|
||||
@@ -279,7 +308,7 @@ const fmtPt = (p) => `${p[0].toFixed(2)} ${p[1].toFixed(2)}`
|
||||
* `wMid` is the half-width of the thin middle (already strength-scaled by
|
||||
* the caller); `ra`/`rb` are the radii of the node circles each end wraps.
|
||||
*/
|
||||
function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R) {
|
||||
function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external = false) {
|
||||
const count = ab + ba
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||
const ux = (b.x - a.x) / len
|
||||
@@ -387,6 +416,7 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R) {
|
||||
return {
|
||||
d,
|
||||
title: `${a.path} ↔ ${b.path}: ${count} (${ab} / ${ba})`,
|
||||
external,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +431,7 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R) {
|
||||
* edge run on parallel lanes instead of colliding. The component turns
|
||||
* these into independently simulated beads.
|
||||
*/
|
||||
function buildFlows(a, b, ra, rb, ab, ba) {
|
||||
function buildFlows(a, b, ra, rb, ab, ba, visualScale = 1) {
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||
const ux = (b.x - a.x) / len
|
||||
const uy = (b.y - a.y) / len
|
||||
@@ -422,7 +452,7 @@ function buildFlows(a, b, ra, rb, ab, ba) {
|
||||
x2: a.x + toT * ux + s * rx,
|
||||
y2: a.y + toT * uy + s * ry,
|
||||
len: span,
|
||||
interval: 1 / (count * BEAD_RATE),
|
||||
interval: 1 / (count * BEAD_RATE * visualScale),
|
||||
}
|
||||
}
|
||||
const flows = []
|
||||
@@ -437,14 +467,17 @@ function buildFlows(a, b, ra, rb, ab, ba) {
|
||||
* Absolute on purpose — cool routes stay visible regardless of how hot
|
||||
* the hottest connection is.
|
||||
*/
|
||||
const scaledWidth = (count) => WMID_MIN + WIDTH_GROWTH * Math.log1p(count - 1)
|
||||
const scaledWidth = (count) => {
|
||||
if (count <= 0) return 0
|
||||
return WMID_MIN + WIDTH_GROWTH * Math.log1p(count - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ribbon edges and bead flows for every aggregated page-to-page
|
||||
* pair. Pairs carrying less than PRUNE_FRACTION of the total internal
|
||||
* traffic are pruned (this naturally bounds the graph to ~100 edges).
|
||||
*/
|
||||
function buildInternalEdges(pairs, byPath) {
|
||||
function buildInternalEdges(pairs, byPath, visualScale = 1) {
|
||||
let total = 0
|
||||
for (const [, [ab, ba]] of pairs) total += ab + ba
|
||||
const minCount = total * PRUNE_FRACTION
|
||||
@@ -456,8 +489,10 @@ function buildInternalEdges(pairs, byPath) {
|
||||
const [pf, pt] = k.split(' ')
|
||||
const a = byPath.get(pf)
|
||||
const b = byPath.get(pt)
|
||||
edges.push(buildRibbon(a, b, ab, ba, scaledWidth(ab + ba)))
|
||||
flows.push(...buildFlows(a, b, TNODE_R, TNODE_R, ab, ba))
|
||||
const wMid = scaledWidth((ab + ba) * visualScale)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(a, b, ab, ba, wMid))
|
||||
flows.push(...buildFlows(a, b, TNODE_R, TNODE_R, ab, ba, visualScale))
|
||||
}
|
||||
return { edges, flows }
|
||||
}
|
||||
@@ -507,7 +542,7 @@ export function filterViewsByRange(views, t0, t1) {
|
||||
* Widths and pruning use the same log scale and traffic-share rule as
|
||||
* internal connections.
|
||||
*/
|
||||
function buildExternal(external, byPath, radius, innerBounds) {
|
||||
function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
|
||||
const extNodes = []
|
||||
const edges = []
|
||||
const flows = []
|
||||
@@ -517,7 +552,7 @@ function buildExternal(external, byPath, radius, innerBounds) {
|
||||
const live = external.filter((p) => byPath.has(p.page))
|
||||
if (!live.length) return { extNodes, edges, flows }
|
||||
|
||||
const width = scaledWidth
|
||||
const width = (count) => scaledWidth(count * visualScale)
|
||||
|
||||
const overlaps = (x, y, r) =>
|
||||
[...byPath.values(), ...extNodes].some(
|
||||
@@ -547,8 +582,10 @@ function buildExternal(external, byPath, radius, innerBounds) {
|
||||
extNodes.push(xn)
|
||||
for (const p of ps) {
|
||||
const page = byPath.get(p.page)
|
||||
edges.push(buildRibbon(xn, page, p.in, 0, width(p.in), EXT_R, TNODE_R))
|
||||
flows.push(...buildFlows(xn, page, EXT_R, TNODE_R, p.in, 0))
|
||||
const wMid = width(p.in)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(xn, page, p.in, 0, wMid, EXT_R, TNODE_R, true))
|
||||
flows.push(...buildFlows(xn, page, EXT_R, TNODE_R, p.in, 0, visualScale))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -593,8 +630,10 @@ function buildExternal(external, byPath, radius, innerBounds) {
|
||||
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))
|
||||
const wMid = width(p.out)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(page, xn, p.out, 0, wMid, TNODE_R, EXT_R, true))
|
||||
flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0, visualScale))
|
||||
}
|
||||
|
||||
return { extNodes, edges, flows }
|
||||
@@ -605,11 +644,12 @@ function buildExternal(external, byPath, radius, innerBounds) {
|
||||
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
|
||||
* there is nothing to show.
|
||||
*/
|
||||
export function buildTransitionGraph(data, pageTree) {
|
||||
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
||||
const internal = collectInternalTransitions(data?.transitions)
|
||||
const external = collectExternalPairs(data?.transitions)
|
||||
const navOrder = buildNavigationOrder(pageTree)
|
||||
const titles = buildTitleMap(pageTree)
|
||||
const readMinutes = buildReadMinutes(visits)
|
||||
|
||||
if (!internal.length && !navOrder.size) return null
|
||||
|
||||
@@ -619,10 +659,10 @@ export function buildTransitionGraph(data, pageTree) {
|
||||
layoutAngles(root, unit, weightFn)
|
||||
|
||||
const maxDepth = Math.max(1, ...nodes.map((n) => n.depth))
|
||||
const { radius } = positionNodes(nodes, maxDepth, unit, data?.views, titles)
|
||||
const { radius } = positionNodes(nodes, maxDepth, unit, data?.views, titles, readMinutes)
|
||||
const arcs = buildFamilyArcs(nodes, radius)
|
||||
const pairs = aggregatePairs(internal)
|
||||
const { edges, flows } = buildInternalEdges(pairs, byPath)
|
||||
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
||||
|
||||
// Tight bounding box of the actual page nodes; family ring arcs can sweep
|
||||
// outside the node circle (e.g. a large arc between two siblings on the
|
||||
@@ -646,7 +686,7 @@ export function buildTransitionGraph(data, pageTree) {
|
||||
bounds.y1 = Math.max(bounds.y1, b.y1)
|
||||
}
|
||||
|
||||
const ext = buildExternal(external, byPath, radius, bounds)
|
||||
const ext = buildExternal(external, byPath, radius, bounds, visualScale)
|
||||
for (const xn of ext.extNodes) {
|
||||
bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad)
|
||||
bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)
|
||||
|
||||
Reference in New Issue
Block a user