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:
@@ -7,7 +7,7 @@
|
||||
* range, exactly like the charts and per-page views do.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'
|
||||
import { rangeWindow } from './analytics/time.js'
|
||||
import { rangeWindow, WEEK } from './analytics/time.js'
|
||||
import {
|
||||
TNODE_R,
|
||||
BEAD_R,
|
||||
@@ -25,6 +25,19 @@ const props = defineProps({
|
||||
|
||||
const window = computed(() => rangeWindow(props.range))
|
||||
|
||||
const visualScale = computed(() => {
|
||||
const { t0, t1 } = window.value
|
||||
if (t0 != null && t1 != null) return WEEK / (t1 - t0)
|
||||
// 'all': scale by the actual data span.
|
||||
const times = new Set()
|
||||
for (const buckets of Object.values(props.data?.views || {})) {
|
||||
for (const k of Object.keys(buckets)) times.add(Date.parse(k))
|
||||
}
|
||||
const arr = [...times]
|
||||
if (arr.length < 2) return 1
|
||||
return WEEK / (Math.max(...arr) - Math.min(...arr))
|
||||
})
|
||||
|
||||
const filteredData = computed(() => {
|
||||
if (!props.data) return null
|
||||
const { t0, t1 } = window.value
|
||||
@@ -36,7 +49,7 @@ const filteredData = computed(() => {
|
||||
|
||||
const graph = computed(() =>
|
||||
filteredData.value
|
||||
? buildTransitionGraph(filteredData.value, props.pageTree)
|
||||
? buildTransitionGraph(filteredData.value, props.pageTree, props.data?.visits, visualScale.value)
|
||||
: null,
|
||||
)
|
||||
|
||||
@@ -47,16 +60,24 @@ const graph = computed(() =>
|
||||
const beads = shallowRef([])
|
||||
let rafId = 0
|
||||
|
||||
const MAX_BEAD_RATE = 120 // upper bound on total beads per second
|
||||
|
||||
const startBeads = (flows) => {
|
||||
cancelAnimationFrame(rafId)
|
||||
beads.value = []
|
||||
if (!flows?.length) return
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
// Cap the total bead emission rate so a busy range cannot spawn enough
|
||||
// beads to kill the page. Existing per-range time scaling is preserved;
|
||||
// this is only a proportional emergency throttle when the limit is hit.
|
||||
const totalRate = flows.reduce((s, f) => s + 1 / f.interval, 0)
|
||||
const scale = totalRate > MAX_BEAD_RATE ? MAX_BEAD_RATE / totalRate : 1
|
||||
|
||||
const live = [] // { flow, t0 } — one entry per bead in flight
|
||||
const now = performance.now()
|
||||
const emitters = flows.map((flow) => {
|
||||
const interval = flow.interval * 1000
|
||||
const interval = (flow.interval / scale) * 1000
|
||||
// Pre-fill the traversal with evenly spaced beads (random phase), so
|
||||
// the flow appears already running instead of starting empty.
|
||||
const phase = Math.random() * interval
|
||||
@@ -100,10 +121,14 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
<section v-if="graph">
|
||||
<svg class="tmap" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
|
||||
role="img" aria-label="map of transitions between pages">
|
||||
<defs>
|
||||
<!-- Unit-radius circle; only the portion near the bottom is used. -->
|
||||
<path id="tnode-label-arc" d="M 0,-1 A 1,1 0 1,0 0,1 A 1,1 0 1,0 -0.001,-1" />
|
||||
</defs>
|
||||
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||
:d="a.d" class="tarc" />
|
||||
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
||||
:d="e.d" class="tconn">
|
||||
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
||||
<title>{{ e.title }}</title>
|
||||
</path>
|
||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
||||
@@ -112,16 +137,32 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
<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>
|
||||
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
|
||||
</text>
|
||||
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ x.count }}</text>
|
||||
</a>
|
||||
</g>
|
||||
<g v-for="n in graph.nodes" :key="n.path">
|
||||
<a :href="n.path" :title="n.title">
|
||||
<a v-if="!n.hidden" :href="n.path" :title="n.title">
|
||||
<circle :cx="n.x" :cy="n.y" :r="TNODE_R" class="tnode" />
|
||||
<text :x="n.x" :y="n.y - 2" class="tnodeslug">{{ n.label }}</text>
|
||||
<text :x="n.x" :y="n.y + 12" class="tnodecount">{{ n.views }}</text>
|
||||
<text :transform="`translate(${n.x}, ${n.y}) scale(${TNODE_R - 4})`" class="tnodeslug" :style="{ '--node-r': TNODE_R - 4 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ n.label }}</textPath>
|
||||
</text>
|
||||
<text :x="n.x" :y="n.y + 4" class="tnodecount">
|
||||
{{ n.readMin ? `${n.views}×${n.readMin}m` : n.views }}
|
||||
</text>
|
||||
</a>
|
||||
<template v-else>
|
||||
<text :x="n.x" :y="n.y"
|
||||
:transform="`rotate(${n.angle * 180 / Math.PI}, ${n.x}, ${n.y})`"
|
||||
class="tnodehidden" text-anchor="start" dominant-baseline="middle">➤</text>
|
||||
<text :x="n.x + Math.cos(n.angle) * 10"
|
||||
:y="n.y + Math.sin(n.angle) * 10"
|
||||
:transform="`rotate(${(n.angle + (Math.cos(n.angle) < 0 ? Math.PI : 0)) * 180 / Math.PI}, ${n.x + Math.cos(n.angle) * 10}, ${n.y + Math.sin(n.angle) * 10})`"
|
||||
:text-anchor="Math.cos(n.angle) < 0 ? 'end' : 'start'"
|
||||
class="tnodehidden" dominant-baseline="middle">{{ n.label }}</text>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
@@ -139,6 +180,9 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
fill: var(--accent);
|
||||
opacity: 0.4; /* uniform, not strength-encoded: width carries that */
|
||||
}
|
||||
.tmap .tconn-exit {
|
||||
fill: var(--text);
|
||||
}
|
||||
.tmap .tbead {
|
||||
fill: var(--accent);
|
||||
opacity: 0.85;
|
||||
@@ -149,7 +193,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.tmap .txnode-source { stroke: var(--text); }
|
||||
.tmap .txnode-exit { stroke: var(--muted); }
|
||||
.tmap .txnode-exit { stroke: var(--text); }
|
||||
.tmap .tarc {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
@@ -162,7 +206,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
}
|
||||
.tmap .tnodeslug {
|
||||
fill: var(--text);
|
||||
font-size: 11px;
|
||||
font-size: calc(11px / var(--node-r, 34));
|
||||
text-anchor: middle;
|
||||
}
|
||||
.tmap a { cursor: pointer; }
|
||||
@@ -172,6 +216,10 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
font-size: 10px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.tmap .tnodehidden {
|
||||
fill: var(--text);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
section { margin-top: 1.8rem; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user