Desaturated house emojis
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RANGES } from './analytics/time.js'
|
import { RANGES } from './analytics/time.js'
|
||||||
import {
|
import {
|
||||||
|
calcReadStats,
|
||||||
calcTotalViews,
|
calcTotalViews,
|
||||||
copyIp,
|
copyIp,
|
||||||
copyList,
|
copyList,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
formatVisitRows,
|
formatVisitRows,
|
||||||
} from './analytics/format.js'
|
} from './analytics/format.js'
|
||||||
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
||||||
|
import TrailLink from './TrailLink.vue'
|
||||||
import TransitionGraph from './TransitionGraph.vue'
|
import TransitionGraph from './TransitionGraph.vue'
|
||||||
import VisitorCharts from './VisitorCharts.vue'
|
import VisitorCharts from './VisitorCharts.vue'
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
const visits = computed(() => data.value?.visits || [])
|
const visits = computed(() => data.value?.visits || [])
|
||||||
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
||||||
|
const readStats = computed(() => calcReadStats(visits.value))
|
||||||
|
|
||||||
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
|
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
|
||||||
|
|
||||||
@@ -126,6 +129,8 @@ function countryName(code) {
|
|||||||
<section class="totals">
|
<section class="totals">
|
||||||
<div><strong :title="String(visits.length)">{{ formatCount(visits.length) }}</strong> visits</div>
|
<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 :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>
|
</section>
|
||||||
|
|
||||||
<VisitorCharts :data="data" :range="range" />
|
<VisitorCharts :data="data" :range="range" />
|
||||||
@@ -145,23 +150,9 @@ function countryName(code) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(v, i) in visitRows" :key="i">
|
<tr v-for="(v, i) in visitRows" :key="i">
|
||||||
<td class="trail">
|
<td class="trail">
|
||||||
<template v-if="v.refererStep">
|
<TrailLink v-if="v.refererStep" :step="v.refererStep" @close="$emit('close')" />
|
||||||
<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>
|
|
||||||
<span v-if="v.utm && v.utm !== '—'" class="utm-tag" :title="v.utmTitle">{{ v.utm }}</span>
|
<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"
|
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" @close="$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>
|
</td>
|
||||||
<td class="ip-locale-cell" :class="{ 'host-cell': v.isHost }">
|
<td class="ip-locale-cell" :class="{ 'host-cell': v.isHost }">
|
||||||
<div class="ip-locale-rows">
|
<div class="ip-locale-rows">
|
||||||
@@ -205,11 +196,7 @@ function countryName(code) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(c, i) in crawlerRows" :key="i">
|
<tr v-for="(c, i) in crawlerRows" :key="i">
|
||||||
<td class="trail">
|
<td class="trail">
|
||||||
<a v-for="(s, si) in c.pages" :key="si"
|
<TrailLink v-for="(s, si) in c.pages" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
|
||||||
: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>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="ip-ua-cell">
|
<td class="ip-ua-cell">
|
||||||
<div><span class="clickable-ip"
|
<div><span class="clickable-ip"
|
||||||
@@ -547,8 +534,6 @@ function countryName(code) {
|
|||||||
|
|
||||||
.visit-table .city-name {
|
.visit-table .city-name {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 10ch;
|
|
||||||
font-size: 0.75em;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
fill: var(--text);
|
fill: var(--text);
|
||||||
font-size: calc(11px / var(--node-r, 34));
|
font-size: calc(11px / var(--node-r, 34));
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
|
filter: saturate(0);
|
||||||
}
|
}
|
||||||
.tmap a { cursor: pointer; }
|
.tmap a { cursor: pointer; }
|
||||||
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
||||||
|
|||||||
@@ -77,6 +77,44 @@ export function calcTotalViews(views) {
|
|||||||
return n
|
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. */
|
/** Build a path -> page title lookup from the site tree. */
|
||||||
function buildTitleMap(pageTree) {
|
function buildTitleMap(pageTree) {
|
||||||
const titles = new Map()
|
const titles = new Map()
|
||||||
@@ -95,19 +133,19 @@ function slugOf(path) {
|
|||||||
return path === '/' ? '🏠' : path.split('/').pop()
|
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) {
|
function externalSlug(origin) {
|
||||||
try {
|
try {
|
||||||
return new URL(origin).host
|
return new URL(origin).host.replace(/^www\./, '')
|
||||||
} catch {
|
} catch {
|
||||||
return origin.replace(/^https?:\/\//, '')
|
return origin.replace(/^https?:\/\//, '').replace(/^www\./, '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format one trail step: an internal page or an external https origin. */
|
/** Format one trail step: an internal page or an external https origin. */
|
||||||
function stepOf(path, titles) {
|
function stepOf(path, titles) {
|
||||||
if (path?.startsWith('/')) {
|
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://')) {
|
if (path?.startsWith('https://')) {
|
||||||
return {
|
return {
|
||||||
@@ -317,12 +355,7 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
|||||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||||
pages: [...g.pages.entries()]
|
pages: [...g.pages.entries()]
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.map(([path, count]) => ({
|
.map(([path, count]) => ({ ...stepOf(path, titles), count })),
|
||||||
path,
|
|
||||||
slug: slugOf(path),
|
|
||||||
title: titles.get(path) || '',
|
|
||||||
count,
|
|
||||||
})),
|
|
||||||
ip: g.ip,
|
ip: g.ip,
|
||||||
ipDisplay: hostIP(g.ip) || g.ip || '—',
|
ipDisplay: hostIP(g.ip) || g.ip || '—',
|
||||||
ua: g.ua,
|
ua: g.ua,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
* pings) are skipped.
|
* 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 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
|
export const EXT_R = 34 // external referer/exit nodes use the same full size
|
||||||
|
|
||||||
@@ -85,13 +87,13 @@ function collectInternalTransitions(transitions) {
|
|||||||
return internal
|
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) {
|
function extLabel(ext) {
|
||||||
try {
|
try {
|
||||||
const host = new URL(ext).hostname
|
const host = new URL(ext).hostname.replace(/^www\./, '')
|
||||||
return host.length > 25 ? `${host.slice(0, 24)}…` : host
|
return host.length > 25 ? `${host.slice(0, 24)}…` : host
|
||||||
} catch {
|
} 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
|
return s.length > 25 ? `${s.slice(0, 24)}…` : s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,9 +188,11 @@ function buildReadMinutes(visits) {
|
|||||||
const times = {}
|
const times = {}
|
||||||
for (const v of visits || []) {
|
for (const v of visits || []) {
|
||||||
for (const [path, sec] of Object.entries(v.read || {})) {
|
for (const [path, sec] of Object.entries(v.read || {})) {
|
||||||
|
if (sec >= MIN_READ_SECONDS) {
|
||||||
;(times[path] || (times[path] = [])).push(sec)
|
;(times[path] || (times[path] = [])).push(sec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const minutes = {}
|
const minutes = {}
|
||||||
for (const [path, arr] of Object.entries(times)) {
|
for (const [path, arr] of Object.entries(times)) {
|
||||||
arr.sort((a, b) => a - b)
|
arr.sort((a, b) => a - b)
|
||||||
|
|||||||
Reference in New Issue
Block a user