Desaturated house emojis

This commit is contained in:
2026-08-21 22:03:55 +00:00
parent 3be2d08ac9
commit 0798e24d24
4 changed files with 60 additions and 37 deletions
+8 -23
View File
@@ -7,6 +7,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RANGES } from './analytics/time.js'
import {
calcReadStats,
calcTotalViews,
copyIp,
copyList,
@@ -16,6 +17,7 @@ import {
formatVisitRows,
} from './analytics/format.js'
import * as flagSvgs from 'country-flag-icons/string/3x2'
import TrailLink from './TrailLink.vue'
import TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue'
@@ -78,6 +80,7 @@ onUnmounted(() => {
const visits = computed(() => data.value?.visits || [])
const totalViews = computed(() => calcTotalViews(data.value?.views))
const readStats = computed(() => calcReadStats(visits.value))
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
@@ -126,6 +129,8 @@ function countryName(code) {
<section class="totals">
<div><strong :title="String(visits.length)">{{ formatCount(visits.length) }}</strong> visits</div>
<div><strong :title="String(totalViews)">{{ formatCount(totalViews) }}</strong> page views</div>
<div><strong>{{ readStats.avgMinPerVisit }}</strong> min/visit</div>
<div><strong>{{ readStats.avgArticleMedianMin }}</strong> min article read</div>
</section>
<VisitorCharts :data="data" :range="range" />
@@ -145,23 +150,9 @@ function countryName(code) {
<tbody>
<tr v-for="(v, i) in visitRows" :key="i">
<td class="trail">
<template v-if="v.refererStep">
<a class="trail-link"
:href="v.refererStep.path"
:title="v.refererStep.title"
:target="v.refererStep.external ? '_blank' : undefined"
:rel="v.refererStep.external ? 'noopener' : undefined">
{{ v.refererStep.slug }}
</a>
</template>
<TrailLink v-if="v.refererStep" :step="v.refererStep" @close="$emit('close')" />
<span v-if="v.utm && v.utm !== '—'" class="utm-tag" :title="v.utmTitle">{{ v.utm }}</span>
<a v-for="(s, si) in v.trail" :key="si"
: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>
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" @close="$emit('close')" />
</td>
<td class="ip-locale-cell" :class="{ 'host-cell': v.isHost }">
<div class="ip-locale-rows">
@@ -205,11 +196,7 @@ function countryName(code) {
<tbody>
<tr v-for="(c, i) in crawlerRows" :key="i">
<td class="trail">
<a v-for="(s, si) in c.pages" :key="si"
:href="s.path" :title="`${s.title}${s.count > 1 ? ` (${s.count} hits)` : ''}`"
@click="$emit('close')">
<small v-if="s.count > 1" class="muted">{{ formatCount(s.count) }}×</small>{{ s.slug }}
</a>
<TrailLink v-for="(s, si) in c.pages" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
</td>
<td class="ip-ua-cell">
<div><span class="clickable-ip"
@@ -547,8 +534,6 @@ function countryName(code) {
.visit-table .city-name {
display: inline-block;
max-width: 10ch;
font-size: 0.75em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+1
View File
@@ -209,6 +209,7 @@ onBeforeUnmount(() => 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); }
+43 -10
View File
@@ -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,
+7 -3
View File
@@ -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,9 +188,11 @@ function buildReadMinutes(visits) {
const times = {}
for (const v of visits || []) {
for (const [path, sec] of Object.entries(v.read || {})) {
if (sec >= MIN_READ_SECONDS) {
;(times[path] || (times[path] = [])).push(sec)
}
}
}
const minutes = {}
for (const [path, arr] of Object.entries(times)) {
arr.sort((a, b) => a - b)