|
-
- {{ formatCount(s.count) }}×{{ s.slug }}
-
+
|
cancelAnimationFrame(rafId))
fill: var(--text);
font-size: calc(11px / var(--node-r, 34));
text-anchor: middle;
+ filter: saturate(0);
}
.tmap a { cursor: pointer; }
.tmap a:hover .tnodeslug { fill: var(--accent); }
diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js
index 3741c54..5a14c05 100644
--- a/frontend/src/analytics/format.js
+++ b/frontend/src/analytics/format.js
@@ -77,6 +77,44 @@ export function calcTotalViews(views) {
return n
}
+// Very short reads are navigation/skims, not real reading time.
+export const MIN_READ_SECONDS = 10
+
+/** Average minutes per visit and average of per-article median read minutes. */
+export function calcReadStats(visits) {
+ const perArticle = {}
+ let totalVisitSeconds = 0
+ let visitCount = 0
+ for (const v of visits || []) {
+ const secs = Object.values(v.read || {}).filter((s) => s >= MIN_READ_SECONDS)
+ if (!secs.length) continue
+ visitCount++
+ totalVisitSeconds += secs.reduce((a, b) => a + b, 0)
+ for (const [path, s] of Object.entries(v.read || {})) {
+ if (s >= MIN_READ_SECONDS) {
+ ;(perArticle[path] || (perArticle[path] = [])).push(s)
+ }
+ }
+ }
+ const avgMinPerVisit = visitCount
+ ? Math.max(1, Math.round(totalVisitSeconds / visitCount / 60))
+ : 0
+
+ let articleMedianSum = 0
+ const articleCount = Object.keys(perArticle).length
+ for (const arr of Object.values(perArticle)) {
+ 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
+ articleMedianSum += Math.max(MIN_READ_SECONDS, median)
+ }
+ const avgArticleMedianMin = articleCount
+ ? Math.max(1, Math.round(articleMedianSum / articleCount / 60))
+ : 0
+
+ return { avgMinPerVisit, avgArticleMedianMin }
+}
+
/** Build a path -> page title lookup from the site tree. */
function buildTitleMap(pageTree) {
const titles = new Map()
@@ -95,19 +133,19 @@ function slugOf(path) {
return path === '/' ? '🏠' : path.split('/').pop()
}
-/** Host name of an external https origin, with scheme stripped. */
+/** Host name of an external https origin, with scheme and www. stripped. */
function externalSlug(origin) {
try {
- return new URL(origin).host
+ return new URL(origin).host.replace(/^www\./, '')
} catch {
- return origin.replace(/^https?:\/\//, '')
+ return origin.replace(/^https?:\/\//, '').replace(/^www\./, '')
}
}
/** 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 }
+ return { path, slug: slugOf(path), title: titles.get(path) || '', external: false, home: path === '/' }
}
if (path?.startsWith('https://')) {
return {
@@ -317,12 +355,7 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
lastSeenLocal: formatWhenLocal(g.lastStart),
pages: [...g.pages.entries()]
.sort((a, b) => b[1] - a[1])
- .map(([path, count]) => ({
- path,
- slug: slugOf(path),
- title: titles.get(path) || '',
- count,
- })),
+ .map(([path, count]) => ({ ...stepOf(path, titles), count })),
ip: g.ip,
ipDisplay: hostIP(g.ip) || g.ip || '—',
ua: g.ua,
diff --git a/frontend/src/analytics/transitions.js b/frontend/src/analytics/transitions.js
index 51f94ab..d0677df 100644
--- a/frontend/src/analytics/transitions.js
+++ b/frontend/src/analytics/transitions.js
@@ -19,6 +19,8 @@
* pings) are skipped.
*/
+import { MIN_READ_SECONDS } from './format.js'
+
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
@@ -85,13 +87,13 @@ function collectInternalTransitions(transitions) {
return internal
}
-/** Domain-only label for an external origin (path removed). */
+/** Domain-only label for an external origin (path and www. removed). */
function extLabel(ext) {
try {
- const host = new URL(ext).hostname
+ const host = new URL(ext).hostname.replace(/^www\./, '')
return host.length > 25 ? `${host.slice(0, 24)}…` : host
} catch {
- const s = ext.replace(/^https?:\/\//, '').split('/')[0]
+ const s = ext.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]
return s.length > 25 ? `${s.slice(0, 24)}…` : s
}
}
@@ -186,7 +188,9 @@ 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)
+ if (sec >= MIN_READ_SECONDS) {
+ ;(times[path] || (times[path] = [])).push(sec)
+ }
}
}
const minutes = {}
|