Implement analytics feature
Add server-side visit analytics collection, a public-page ping endpoint, and a full-screen AnalyticsView for admins. Backend: - Add pagerite/analytics.py: Analytics/Visit model, Store, and persistence - Wire /_a ping endpoint and GET /_api/analytics into pagerite/app.py Frontend: - Add full-screen AnalyticsView with visitor charts and transition map - Add VisitorCharts and TransitionGraph subcomponents - Add analytics JS helpers in frontend/src/analytics/ - Send navigation pings from frontend/src/pagerite.js - Mount AnalyticsView from frontend/src/main.js - Document the feature in docs/analytics.md and update AGENTS.md
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
<script setup>
|
||||
// Full-screen analytics app (replaces the page chrome while open; opened via
|
||||
// the 📊 pen or directly by URL hash #/analytics/<range>, so refresh and link
|
||||
// sharing work). Fetches the raw collected data from /_api/analytics
|
||||
// (admin-gated by the auth proxy) and renders it: totals, smoothed
|
||||
// visit/views curves over a selectable range, a transition map, and the
|
||||
// recent visit trails. Read-only.
|
||||
// See docs/analytics.md for the data format.
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RANGES } from './analytics/time.js'
|
||||
import { calcTotalViews, formatRecentVisits } from './analytics/format.js'
|
||||
import TransitionGraph from './TransitionGraph.vue'
|
||||
import VisitorCharts from './VisitorCharts.vue'
|
||||
|
||||
const props = defineProps({
|
||||
initialRange: { type: String, default: 'week' },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const data = ref(null)
|
||||
const pageTree = ref(null)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/_api/analytics')
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
data.value = await res.json()
|
||||
} catch {
|
||||
error.value = 'analytics data could not be loaded'
|
||||
}
|
||||
// The site tree for the transition map (all pages in menu order). Not
|
||||
// fatal: without it the map falls back to transition endpoints only.
|
||||
try {
|
||||
const res = await fetch('/_api/pages')
|
||||
if (res.ok) pageTree.value = await res.json()
|
||||
} catch { /* map just narrows to pages seen in transitions */ }
|
||||
})
|
||||
|
||||
function onKeydown(ev) {
|
||||
if (ev.key === 'Escape') emit('close')
|
||||
}
|
||||
onMounted(() => addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => removeEventListener('keydown', onKeydown))
|
||||
|
||||
const visits = computed(() => data.value?.visits || [])
|
||||
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
||||
|
||||
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
|
||||
|
||||
// Keep the URL shareable: the hash names the open view and its range.
|
||||
watch(range, (r) => {
|
||||
if (location.hash.startsWith('#/analytics')) {
|
||||
history.replaceState(null, '', `#/analytics/${r}`)
|
||||
}
|
||||
})
|
||||
|
||||
const recentVisits = computed(() => formatRecentVisits(visits.value, pageTree.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="analytics-view">
|
||||
<div class="analytics-panel">
|
||||
<header>
|
||||
<h1>Analytics</h1>
|
||||
<nav class="ranges">
|
||||
<button v-for="(r, key) in RANGES" :key="key" type="button"
|
||||
:class="{ active: range === key }" @click="range = key">
|
||||
{{ r.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
||||
<p v-else-if="!data" class="loading">loading…</p>
|
||||
<template v-else>
|
||||
<section class="totals">
|
||||
<div><strong>{{ visits.length }}</strong> visits</div>
|
||||
<div><strong>{{ totalViews }}</strong> page views</div>
|
||||
</section>
|
||||
|
||||
<VisitorCharts :data="data" :range="range" />
|
||||
<TransitionGraph :data="data" :range="range" :page-tree="pageTree" @close="emit('close')" />
|
||||
|
||||
<section>
|
||||
<h2>Recent visits</h2>
|
||||
<ul v-if="recentVisits.length" class="visits">
|
||||
<li v-for="(v, i) in recentVisits" :key="i">
|
||||
<span class="when">{{ v.when }}</span>
|
||||
<span class="trail">
|
||||
<a v-for="(s, si) in v.steps" :key="si"
|
||||
:href="s.path" :title="s.title" @click="emit('close')">
|
||||
{{ s.slug }}
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty">no visits recorded yet</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analytics-view {
|
||||
min-height: 100vh;
|
||||
background: var(--bg, Canvas);
|
||||
color: var(--text, CanvasText);
|
||||
}
|
||||
|
||||
.analytics-panel {
|
||||
margin: 0 auto;
|
||||
width: min(60rem, 96vw);
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
}
|
||||
|
||||
.analytics-panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.analytics-panel h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.ranges {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ranges button {
|
||||
padding: 0.2rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ranges button:hover { color: var(--text); }
|
||||
|
||||
.ranges button.active {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.close {
|
||||
padding: 0 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover { color: var(--text); }
|
||||
|
||||
.analytics-panel h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.analytics-panel section {
|
||||
margin-top: 1.8rem;
|
||||
}
|
||||
|
||||
.totals {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.totals strong { font-size: 1.5rem; }
|
||||
|
||||
.visits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.visits li {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.2rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.visits .when {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.visits .trail {
|
||||
font-family: monospace;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.visits .trail a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.visits .trail a:hover { color: var(--accent); }
|
||||
.visits .trail a + a {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.empty, .loading, .error { color: var(--muted); }
|
||||
.error { color: var(--error, #c00); }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* True full screen: while the analytics app is open the page chrome is
|
||||
hidden, so the document itself (not an overlay) scrolls the view. */
|
||||
body.analytics-open #banner,
|
||||
body.analytics-open #content,
|
||||
body.analytics-open > footer {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
/**
|
||||
* Radial transition map filtered to the selected time range.
|
||||
*
|
||||
* The server only stores an all-time transition aggregate, so this component
|
||||
* derives time-filtered transitions from the visits list (which has start
|
||||
* timestamps) and filters the view counts to the same window.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { rangeWindow } from './analytics/time.js'
|
||||
import {
|
||||
TNODE_R,
|
||||
buildTransitionGraph,
|
||||
buildTransitionsFromVisits,
|
||||
filterViewsByRange,
|
||||
} from './analytics/transitions.js'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, default: null },
|
||||
range: { type: String, required: true },
|
||||
pageTree: { type: Array, default: null },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const window = computed(() => rangeWindow(props.range))
|
||||
|
||||
const filteredData = computed(() => {
|
||||
if (!props.data) return null
|
||||
const { t0, t1 } = window.value
|
||||
return {
|
||||
transitions: buildTransitionsFromVisits(props.data.visits, t0, t1),
|
||||
views: filterViewsByRange(props.data.views, t0, t1),
|
||||
}
|
||||
})
|
||||
|
||||
const graph = computed(() =>
|
||||
filteredData.value
|
||||
? buildTransitionGraph(filteredData.value, props.pageTree)
|
||||
: null,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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">
|
||||
<title>{{ e.title }}</title>
|
||||
</path>
|
||||
<g v-for="n in graph.nodes" :key="n.path">
|
||||
<a :href="n.path" :title="n.title" @click="emit('close')">
|
||||
<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>
|
||||
</a>
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Transition map: radial graph of internal page-to-page transitions. */
|
||||
.tmap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.tmap .tconn {
|
||||
fill: var(--accent);
|
||||
opacity: 0.4; /* uniform, not strength-encoded: width carries that */
|
||||
}
|
||||
.tmap .tarc {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.tmap .tnode {
|
||||
fill: var(--bg, Canvas);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.tmap .tnodeslug {
|
||||
fill: var(--text);
|
||||
font-size: 11px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.tmap a { cursor: pointer; }
|
||||
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
||||
.tmap .tnodecount {
|
||||
fill: var(--muted);
|
||||
font-size: 10px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
section { margin-top: 1.8rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup>
|
||||
/**
|
||||
* Visitor and page-view smoothed curves for a single shared time range.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { makeSeries } from './analytics/time.js'
|
||||
import { CHART_H, CHART_W, buildChart, fmtY } from './analytics/chart.js'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, default: null },
|
||||
range: { type: String, required: true },
|
||||
})
|
||||
|
||||
// Views across all pages combined into one raw bucket map.
|
||||
const allViews = computed(() => {
|
||||
const all = {}
|
||||
for (const buckets of Object.values(props.data?.views || {})) {
|
||||
for (const [k, c] of Object.entries(buckets)) all[k] = (all[k] || 0) + c
|
||||
}
|
||||
return all
|
||||
})
|
||||
|
||||
const visitSeries = computed(() => makeSeries(props.data?.site_visits, props.range))
|
||||
const viewSeries = computed(() => makeSeries(allViews.value, props.range))
|
||||
const unit = computed(() => (props.range === 'week' ? 'h' : 'day'))
|
||||
|
||||
const visitChart = computed(() => buildChart(visitSeries.value))
|
||||
const viewChart = computed(() => buildChart(viewSeries.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-for="c in [
|
||||
{ ylabel: 'visitors', chart: visitChart, empty: 'no visits recorded yet' },
|
||||
{ ylabel: 'views', chart: viewChart, empty: 'no views recorded yet' },
|
||||
]" :key="c.ylabel">
|
||||
<template v-if="c.chart">
|
||||
<div class="chartwrap">
|
||||
<div class="plot">
|
||||
<div class="plotarea">
|
||||
<span class="yaxis-label">{{ c.ylabel }}/{{ unit }}</span>
|
||||
<svg class="chart" :viewBox="`0 0 ${CHART_W} ${CHART_H}`"
|
||||
preserveAspectRatio="none" role="img" :aria-label="`${c.ylabel} per ${unit}`">
|
||||
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
|
||||
:x1="0" :x2="CHART_W" :y1="g.y" :y2="g.y" class="major" />
|
||||
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
|
||||
<line v-if="t.line" :x1="t.x" :x2="t.x" :y1="0" :y2="CHART_H"
|
||||
class="minor vertical" />
|
||||
</template>
|
||||
<template v-for="(s, i) in c.chart.series" :key="i">
|
||||
<path v-if="s.area" :d="s.area" class="area" />
|
||||
<path :d="s.line" class="line" :style="{ opacity: s.opacity }" />
|
||||
</template>
|
||||
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
|
||||
class="axis" />
|
||||
</svg>
|
||||
<span v-for="g in c.chart.majors" :key="g.value" class="ylab"
|
||||
:style="{ bottom: g.bottom + '%' }">{{ fmtY(g.value) }}</span>
|
||||
</div>
|
||||
<div class="xlabels">
|
||||
<span v-for="t in c.chart.xticks" :key="t.x" class="xlab"
|
||||
:style="{ left: t.left + '%' }">{{ t.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="c.chart.series.length > 1" class="legend">
|
||||
<span v-for="(s, i) in c.chart.series" :key="i" :style="{ opacity: s.opacity }">
|
||||
● {{ s.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="empty">{{ c.empty }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The svg is stretched (preserveAspectRatio none), so all text lives in
|
||||
HTML overlays positioned by the same fractions the geometry uses. */
|
||||
.chartwrap {
|
||||
padding-left: 2.2rem; /* y labels */
|
||||
}
|
||||
|
||||
.plot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plotarea {
|
||||
position: relative;
|
||||
height: 8rem;
|
||||
}
|
||||
|
||||
.xlabels {
|
||||
position: relative;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ylab {
|
||||
position: absolute;
|
||||
left: -2.2rem;
|
||||
width: 1.9rem;
|
||||
text-align: right;
|
||||
transform: translateY(50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xlab {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xlabels .xlab:first-child { transform: none; }
|
||||
.xlabels .xlab:last-child { transform: translateX(-100%); }
|
||||
|
||||
.chart .minor {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.chart .minor.vertical {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.chart .major {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-dasharray: 3 4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chart .axis {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.chart .area {
|
||||
fill: var(--accent);
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.chart .line {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.legend span { color: var(--accent); }
|
||||
|
||||
.yaxis-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -2.2rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
writing-mode: vertical-rl;
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
section { margin-top: 1.8rem; }
|
||||
|
||||
.empty { color: var(--muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Chart geometry, smoothing, and SVG path generation for analytics charts.
|
||||
*
|
||||
* Fixed 720x180 viewBox, stretched to the panel width; values are per-unit
|
||||
* rates (hour on the week view, day on month+).
|
||||
*/
|
||||
|
||||
import { DAY, HOUR, WEEK, mondayUTC } from './time.js'
|
||||
|
||||
export const CHART_W = 720
|
||||
export const CHART_H = 180
|
||||
export const PAD_TOP = 14 // room above the highest point
|
||||
|
||||
/**
|
||||
* Y always starts at 0; the max is a multiple of a 1-2-5 major step with at
|
||||
* most 5 intervals, so labeled ticks are always round and evenly divided.
|
||||
* Values are per-unit rates, so small scales are legitimate (a lone visit
|
||||
* smoothes to well under 1/unit) — the floor is 1, not 10. Minor lines
|
||||
* subdivide each major step in five when that yields integers.
|
||||
*/
|
||||
export function yScale(maxValue) {
|
||||
let step = 1
|
||||
outer: for (let exp = -3; exp < 8; exp++) {
|
||||
for (const base of [1, 2, 5]) {
|
||||
step = base * 10 ** exp
|
||||
if (Math.ceil(maxValue / step) <= 5) break outer
|
||||
}
|
||||
}
|
||||
let max = Math.ceil(maxValue / step) * step
|
||||
if (max < 1) {
|
||||
max = 1
|
||||
step = 0.5
|
||||
}
|
||||
const minor = step >= 5 && step % 5 === 0 ? step / 5 : null
|
||||
return { max, step, minor }
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge-aware adaptive Gaussian smoothing. A change-point detector first
|
||||
* finds traffic-level shifts (two-unit totals compared on both sides of
|
||||
* each bucket; strong ratio + significance marks a candidate, and each run
|
||||
* of candidates keeps only its best-scoring bucket as an edge). Each
|
||||
* edge-delimited segment is then smoothed independently: a broad two-unit
|
||||
* pilot estimates the local traffic rate, which ramps the Gaussian sigma
|
||||
* from ~0.4 units (isolated events stay narrow, peaking at ~1 event/unit)
|
||||
* up to 1 unit (busy traffic gets full smoothing), and every bucket spreads
|
||||
* its count with its local sigma, clipped to the segment and renormalized
|
||||
* so total visitor count is preserved exactly. The unit is one hour on the
|
||||
* week view and one day on the month+ views, so the smoothing time scale
|
||||
* follows the range (month+ sigmas are 24x the hourly ones). The raw series
|
||||
* is drawn faintly behind the curve for reference. Operates on raw counts.
|
||||
*/
|
||||
export function smooth(counts, binMinutes, unitMinutes, {
|
||||
minSigmaMinutes = unitMinutes / Math.sqrt(2 * Math.PI),
|
||||
maxSigmaMinutes = unitMinutes,
|
||||
pilotSigmaMinutes = 2 * unitMinutes,
|
||||
detectorWindowMinutes = 2 * unitMinutes,
|
||||
// Count thresholds are defined per hour and scale with the unit, so
|
||||
// "low traffic" means the same thing on hourly and daily views
|
||||
// (5-20 events/hour = 120-480/day on the month+ ranges).
|
||||
highTrafficEvents = 10 * unitMinutes / 60,
|
||||
minRatio = 2.5,
|
||||
minSignificance = 4,
|
||||
sigmaRampStart = 5 * unitMinutes / 60,
|
||||
sigmaRampEnd = 20 * unitMinutes / 60,
|
||||
} = {}) {
|
||||
const n = counts.length
|
||||
if (!n) return counts
|
||||
const detectorWindowBins = Math.max(1, Math.round(detectorWindowMinutes / binMinutes))
|
||||
|
||||
const cumsum = new Float64Array(n + 1)
|
||||
for (let i = 0; i < n; i++) cumsum[i + 1] = cumsum[i] + counts[i]
|
||||
|
||||
// Detect abrupt regime changes from aggregated traffic on both sides.
|
||||
// Individual bins are deliberately ignored because even high traffic
|
||||
// produces many 0-1 count bins at five-minute resolution.
|
||||
const score = new Float64Array(n)
|
||||
const candidate = new Uint8Array(n)
|
||||
for (let i = detectorWindowBins; i < n - detectorWindowBins; i++) {
|
||||
const left = cumsum[i] - cumsum[i - detectorWindowBins]
|
||||
const right = cumsum[i + detectorWindowBins] - cumsum[i]
|
||||
const high = Math.max(left, right)
|
||||
const low = Math.min(left, right)
|
||||
if (high < highTrafficEvents) continue
|
||||
const ratio = (high + 1) / (low + 1)
|
||||
const significance = (high - low) / Math.sqrt(high + low + 1)
|
||||
if (ratio >= minRatio && significance >= minSignificance) {
|
||||
candidate[i] = 1
|
||||
score[i] = significance * Math.log(ratio)
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse each continuous detector region to its strongest boundary.
|
||||
const edges = []
|
||||
for (let i = 0; i < n;) {
|
||||
if (!candidate[i]) { i++; continue }
|
||||
let j = i + 1
|
||||
while (j < n && candidate[j]) j++
|
||||
let best = i
|
||||
for (let k = i + 1; k < j; k++) {
|
||||
if (score[k] > score[best]) best = k
|
||||
}
|
||||
edges.push(best)
|
||||
i = j
|
||||
}
|
||||
|
||||
const reflectIndex = (i, length) => {
|
||||
while (i < 0 || i >= length) {
|
||||
i = i < 0 ? -i - 1 : 2 * length - i - 1
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
const gaussianFilterReflect = (values, sigmaBins) => {
|
||||
const length = values.length
|
||||
const radius = Math.ceil(4 * sigmaBins)
|
||||
const kernel = new Float64Array(radius * 2 + 1)
|
||||
let sum = 0
|
||||
for (let k = -radius; k <= radius; k++) {
|
||||
const w = Math.exp(-0.5 * (k / sigmaBins) ** 2)
|
||||
kernel[k + radius] = w
|
||||
sum += w
|
||||
}
|
||||
for (let i = 0; i < kernel.length; i++) kernel[i] /= sum
|
||||
const out = new Float64Array(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
let value = 0
|
||||
for (let k = -radius; k <= radius; k++) {
|
||||
value += values[reflectIndex(i + k, length)] * kernel[k + radius]
|
||||
}
|
||||
out[i] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Process each discontinuity-delimited regime independently so neither
|
||||
// the pilot nor the final Gaussian can see through a detected boundary.
|
||||
const bounds = [0, ...edges, n]
|
||||
const smoothed = new Float64Array(n)
|
||||
for (let b = 0; b < bounds.length - 1; b++) {
|
||||
const lo = bounds[b]
|
||||
const length = bounds[b + 1] - lo
|
||||
const segment = counts.slice(lo, lo + length)
|
||||
|
||||
// Broad pilot estimates only the generic local traffic level used for
|
||||
// choosing sigma; it is not the final displayed curve.
|
||||
const pilot = gaussianFilterReflect(segment, pilotSigmaMinutes / binMinutes)
|
||||
|
||||
// Keep isolated/sparse traffic at the minimum bandwidth through
|
||||
// sigmaRampStart events, then ramp toward maxSigmaMinutes (thresholds
|
||||
// are per-hour rates scaled to the unit: low traffic is low traffic
|
||||
// on every range).
|
||||
const sigmaMinutes = new Float64Array(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
const ratePerUnit = pilot[i] * unitMinutes / binMinutes
|
||||
let mix = (ratePerUnit - sigmaRampStart) / (sigmaRampEnd - sigmaRampStart)
|
||||
mix = Math.sqrt(Math.max(0, Math.min(1, mix)))
|
||||
sigmaMinutes[i] = minSigmaMinutes + mix * (maxSigmaMinutes - minSigmaMinutes)
|
||||
}
|
||||
|
||||
// Each input bin spreads its own count using its local sigma. The
|
||||
// per-bin kernel is renormalized after clipping to the segment,
|
||||
// preserving total visitor count apart from floating-point error.
|
||||
for (let j = 0; j < length; j++) {
|
||||
const count = segment[j]
|
||||
if (!count) continue
|
||||
const sigmaBins = sigmaMinutes[j] / binMinutes
|
||||
const radius = Math.ceil(4 * sigmaBins)
|
||||
const start = Math.max(0, j - radius)
|
||||
const end = Math.min(length, j + radius + 1)
|
||||
let weightSum = 0
|
||||
for (let i = start; i < end; i++) {
|
||||
const d = i - j
|
||||
weightSum += Math.exp(-0.5 * (d / sigmaBins) ** 2)
|
||||
}
|
||||
for (let i = start; i < end; i++) {
|
||||
const d = i - j
|
||||
smoothed[lo + i] += count * Math.exp(-0.5 * (d / sigmaBins) ** 2) / weightSum
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...smoothed]
|
||||
}
|
||||
|
||||
/**
|
||||
* Catmull-Rom spline through the (smoothed) points, control points clamped
|
||||
* to the plot area so the curve can never dip below zero or above the max.
|
||||
*/
|
||||
export function spline(pts) {
|
||||
if (pts.length < 3) {
|
||||
return `M${pts.map((p) => `${p.x},${p.y}`).join('L')}`
|
||||
}
|
||||
const clampY = (y) => Math.min(CHART_H, Math.max(PAD_TOP, y))
|
||||
let d = `M${pts[0].x},${pts[0].y}`
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[i - 1] || pts[i]
|
||||
const p1 = pts[i]
|
||||
const p2 = pts[i + 1]
|
||||
const p3 = pts[i + 2] || p2
|
||||
const c1y = clampY(p1.y + (p2.y - p0.y) / 6)
|
||||
const c2y = clampY(p2.y - (p3.y - p1.y) / 6)
|
||||
d += `C${p1.x + (p2.x - p0.x) / 6},${c1y} `
|
||||
+ `${p2.x - (p3.x - p1.x) / 6},${c2y} ${p2.x},${p2.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/** Build a full chart model from a series descriptor produced by time.js. */
|
||||
export function buildChart(input) {
|
||||
if (!input || !input.series.length) return null
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes } = 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
|
||||
// detector thresholds are count-based), the result is scaled back to rates.
|
||||
const smoothed = series.map((s) =>
|
||||
smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate))
|
||||
// Scale from the current/primary series only; older overlay weeks are drawn
|
||||
// with the same scale and allowed to overflow if they are busier.
|
||||
const highest = Math.max(0, ...smoothed[0])
|
||||
const { max, step, minor } = yScale(highest)
|
||||
const x = (t) => ((t - t0) / (t1 - t0)) * CHART_W
|
||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
||||
const drawn = series.map((s, si) => {
|
||||
const pts = s.points.map((p, i) => ({ x: x(p.t), y: y(smoothed[si][i]) }))
|
||||
const line = spline(pts)
|
||||
const first = pts[0]
|
||||
const last = pts.at(-1)
|
||||
return {
|
||||
...s,
|
||||
line,
|
||||
area: s.area ? `${line}L${last.x},${CHART_H}L${first.x},${CHART_H}Z` : null,
|
||||
}
|
||||
})
|
||||
// Major (labeled) and minor (hairline) y grid ticks.
|
||||
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) })
|
||||
}
|
||||
}
|
||||
// X ticks. Week view: weekday labels centered at midday UTC, no vertical
|
||||
// lines (day boundaries would be misleading in the viewer's timezone).
|
||||
// Month view: likewise lineless, day numbers at noon UTC with the month
|
||||
// name substituted for the 1st (marking the month change). Longer
|
||||
// ranges: boundary lines at Mondays / months / years.
|
||||
const isWeek = t1 - t0 === WEEK
|
||||
const isMonth = !isWeek && t1 - t0 <= 31 * DAY
|
||||
const xticks = isWeek
|
||||
? Array.from({ length: 7 }, (_, d) => {
|
||||
const t = t0 + d * DAY + 12 * HOUR
|
||||
return {
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: new Date(t).toLocaleDateString(undefined, {
|
||||
weekday: 'short', timeZone: 'UTC',
|
||||
}),
|
||||
line: false,
|
||||
}
|
||||
})
|
||||
: isMonth
|
||||
? Array.from(
|
||||
{ length: Math.floor((t1 - Math.ceil(t0 / DAY) * DAY) / DAY) },
|
||||
(_, d) => {
|
||||
const day = Math.ceil(t0 / DAY) * DAY + d * DAY
|
||||
const date = new Date(day)
|
||||
const t = day + 12 * HOUR
|
||||
return {
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: date.getUTCDate() === 1
|
||||
? date.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
|
||||
: String(date.getUTCDate()),
|
||||
line: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
: xticksFor(t0, t1).map((t) => ({
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
return { max, majors, minors, series: drawn, xticks }
|
||||
}
|
||||
|
||||
/** X ticks for year/all: Monday boundaries up to a quarter, UTC month
|
||||
* boundaries up to a few years, then years. */
|
||||
export function xticksFor(t0, t1) {
|
||||
const span = t1 - t0
|
||||
const ticks = []
|
||||
if (span <= 100 * DAY) {
|
||||
for (let t = mondayUTC(t0); t <= t1; t += WEEK) {
|
||||
if (t >= t0) ticks.push(t)
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
if (span <= 4 * 365 * DAY) {
|
||||
const d = new Date(t0)
|
||||
let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)
|
||||
for (; t <= t1; ) {
|
||||
ticks.push(t)
|
||||
const m = new Date(t)
|
||||
t = Date.UTC(m.getUTCFullYear(), m.getUTCMonth() + 1, 1)
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
const d = new Date(t0)
|
||||
for (let yr = d.getUTCFullYear() + 1; Date.UTC(yr, 0, 1) <= t1; yr++) {
|
||||
ticks.push(Date.UTC(yr, 0, 1))
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
export function fmtTick(t, span) {
|
||||
const d = new Date(t)
|
||||
if (span <= 100 * DAY) {
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
if (span <= 4 * 365 * DAY) {
|
||||
return d.getUTCMonth() === 0
|
||||
? d.toLocaleDateString(undefined, { year: 'numeric', timeZone: 'UTC' })
|
||||
: d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
/** Y labels: integers when the step allows, one decimal for fractional steps. */
|
||||
export function fmtY(v) {
|
||||
return Number.isInteger(v) ? String(v) : v.toFixed(1)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Formatters and aggregators for summary sections: totals and the recent
|
||||
* visit trail.
|
||||
*/
|
||||
|
||||
/** Total page views across every page and every bucket. */
|
||||
export function calcTotalViews(views) {
|
||||
let n = 0
|
||||
for (const buckets of Object.values(views || {})) {
|
||||
for (const c of Object.values(buckets)) n += c
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** Build a path -> page title lookup from the site tree. */
|
||||
function buildTitleMap(pageTree) {
|
||||
const titles = new Map()
|
||||
const walk = (items) => {
|
||||
for (const item of items || []) {
|
||||
titles.set(`/${item.path}`, item.title)
|
||||
walk(item.children)
|
||||
}
|
||||
}
|
||||
walk(pageTree)
|
||||
return titles
|
||||
}
|
||||
|
||||
/** Last path segment for display; front page becomes a house icon. */
|
||||
function slugOf(path) {
|
||||
return path === '/' ? '🏠' : path.split('/').pop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format recent visits for display, newest first. Each step is a linked slug
|
||||
* pointing to its article; external referers/origins and direct entries are
|
||||
* omitted. The link title shows the article heading when known.
|
||||
*/
|
||||
export function formatRecentVisits(visits, pageTree, limit = 50) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
return [...visits]
|
||||
.reverse()
|
||||
.map((v) => ({
|
||||
when: new Date(v.start).toLocaleString(),
|
||||
steps: [v.entry, ...(v.trail || [])]
|
||||
.filter((p) => p?.startsWith('/'))
|
||||
.map((p) => ({
|
||||
path: p,
|
||||
slug: slugOf(p),
|
||||
title: titles.get(p) || '',
|
||||
})),
|
||||
}))
|
||||
.filter((v) => v.steps.length)
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Time ranges, week alignment and re-bucketing for analytics charts.
|
||||
*
|
||||
* Raw data comes as sparse 5-minute buckets; the range picks the x window
|
||||
* and a coarser bucket size to keep point counts sane. The week range is
|
||||
* aligned to Monday 00:00 UTC and overlays previous weeks' curves (fading
|
||||
* with age), so weekly patterns compare directly.
|
||||
*/
|
||||
|
||||
export const MIN5 = 5 * 60e3
|
||||
export const HOUR = 3600e3
|
||||
export const DAY = 86400e3
|
||||
export const WEEK = 7 * DAY
|
||||
|
||||
export const RANGES = {
|
||||
week: { label: 'week' },
|
||||
month: { label: 'month', span: 30 * DAY, bucket: 6 * HOUR },
|
||||
year: { label: 'year', span: 365 * DAY, bucket: DAY },
|
||||
all: { label: 'all', span: null, bucket: DAY },
|
||||
}
|
||||
|
||||
/** Monday 00:00 UTC of the week containing t (epoch day 0 was a Thursday). */
|
||||
export function mondayUTC(t) {
|
||||
const d = Math.floor(t / DAY)
|
||||
return (d - ((d + 3) % 7)) * DAY
|
||||
}
|
||||
|
||||
/** Parse sparse timestamp buckets into a { epochMs: count } map. */
|
||||
export function rawTimes(buckets) {
|
||||
const raw = {}
|
||||
// Key by parsed timestamp: Python writes "+00:00", JS ISO uses "Z".
|
||||
for (const [k, c] of Object.entries(buckets || {})) raw[Date.parse(k)] = c
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Sum counts from raw 5-minute buckets between t0 (inclusive) and t1 (exclusive). */
|
||||
export function sumRange(raw, t0, t1) {
|
||||
let n = 0
|
||||
for (let s = t0; s < t1; s += MIN5) n += raw[s] || 0
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* One series per overlaid week: [this week, 1 week ago, ...], at native
|
||||
* 5-minute resolution, up to 8 weeks back (and only weeks that overlap the
|
||||
* recorded data at all). The current week is truncated at the current bucket
|
||||
* — no fake zeroes drawn for the future. Counts are rates per hour
|
||||
* (bucket count * 12): a lone visit in a 5-minute bucket reads as "12/h".
|
||||
* The coarser ranges use per-day rates instead (unitMinutes = 24*60).
|
||||
*/
|
||||
export function weeklySeries(buckets) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const now = Date.now()
|
||||
const thisMonday = mondayUTC(now)
|
||||
const oldest = Math.min(...times)
|
||||
// Weeks back as far as the data reaches: difference in Monday indices.
|
||||
const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1
|
||||
const count = Math.min(available, 8)
|
||||
const out = []
|
||||
for (let back = 0; back < count; back++) {
|
||||
const start = thisMonday - back * WEEK
|
||||
const end = back === 0
|
||||
? Math.min(start + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
|
||||
: start + WEEK
|
||||
const points = []
|
||||
for (let t = start; t < end; t += MIN5) {
|
||||
points.push({ t, count: raw[t] || 0 })
|
||||
}
|
||||
out.push({
|
||||
points,
|
||||
label: back === 0 ? 'this week' : `${back}w ago`,
|
||||
opacity: Math.max(0.15, 1 - back * 0.25),
|
||||
area: back === 0,
|
||||
})
|
||||
}
|
||||
return {
|
||||
series: out,
|
||||
t0: thisMonday,
|
||||
t1: thisMonday + WEEK,
|
||||
rate: HOUR / MIN5,
|
||||
binMinutes: 5,
|
||||
unitMinutes: 60,
|
||||
unit: 'hour',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling window for the non-week ranges (x max = now), counts converted
|
||||
* to per-day rates (the unit the month+ charts are read in).
|
||||
*/
|
||||
export function rollingSeries(buckets, rangeKey) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const { span, bucket } = RANGES[rangeKey]
|
||||
const t1 = Math.floor(Date.now() / bucket) * bucket + bucket
|
||||
const t0 = span != null
|
||||
? t1 - span
|
||||
: Math.floor(Math.min(...times) / bucket) * bucket
|
||||
const points = []
|
||||
for (let t = t0; t < t1; t += bucket) {
|
||||
points.push({ t, count: sumRange(raw, t, t + bucket) })
|
||||
}
|
||||
return {
|
||||
series: [{ points, label: '', opacity: 1, area: true }],
|
||||
t0,
|
||||
t1,
|
||||
rate: DAY / bucket,
|
||||
binMinutes: bucket / 60e3,
|
||||
unitMinutes: 24 * 60,
|
||||
unit: 'day',
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch to weekly or rolling series based on the selected range. */
|
||||
export function makeSeries(buckets, rangeKey) {
|
||||
return rangeKey === 'week'
|
||||
? weeklySeries(buckets)
|
||||
: rollingSeries(buckets, rangeKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute UTC time window for a given range key. Used to filter visits,
|
||||
* transitions and views to the same period the charts are showing.
|
||||
* Returns { t0, t1 } where null means unbounded.
|
||||
*/
|
||||
export function rangeWindow(rangeKey) {
|
||||
const now = Date.now()
|
||||
if (rangeKey === 'week') {
|
||||
const start = mondayUTC(now)
|
||||
return { t0: start, t1: start + WEEK }
|
||||
}
|
||||
if (rangeKey === 'all') {
|
||||
return { t0: null, t1: null }
|
||||
}
|
||||
const { span, bucket } = RANGES[rangeKey]
|
||||
const t1 = Math.floor(now / bucket) * bucket + bucket
|
||||
return { t0: t1 - span, t1 }
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Radial transition map and helpers.
|
||||
*
|
||||
* Radial site map: the front page at the center, each slug level on its own
|
||||
* ring. All pages of the site are shown (from /_api/pages), plus any extra
|
||||
* paths seen in transitions (deleted pages); siblings run clockwise in
|
||||
* navigation order, starting at the top. Internal path -> path transitions
|
||||
* join opposite directions into straight connections (middle width = total
|
||||
* count, wrapping the node circles at both ends); external referers/exits
|
||||
* are not shown (yet). Self-loops (reload pings) are also skipped.
|
||||
*/
|
||||
|
||||
export const TNODE_R = 34 // node circles hold the slug and the view count
|
||||
|
||||
/** Flatten the site tree into navigation order via DFS. */
|
||||
function buildNavigationOrder(pageTree) {
|
||||
const order = new Map()
|
||||
const walk = (items) => {
|
||||
for (const item of items || []) {
|
||||
const p = `/${item.path}`
|
||||
if (!order.has(p)) order.set(p, order.size)
|
||||
walk(item.children)
|
||||
}
|
||||
}
|
||||
walk(pageTree)
|
||||
return order
|
||||
}
|
||||
|
||||
/** Map page paths to their article titles from the site tree. */
|
||||
function buildTitleMap(pageTree) {
|
||||
const titles = new Map()
|
||||
const walk = (items) => {
|
||||
for (const item of items || []) {
|
||||
titles.set(`/${item.path}`, item.title)
|
||||
walk(item.children)
|
||||
}
|
||||
}
|
||||
walk(pageTree)
|
||||
return titles
|
||||
}
|
||||
|
||||
/** Extract internal page-to-page transitions, excluding self-loops. */
|
||||
function collectInternalTransitions(transitions) {
|
||||
const internal = []
|
||||
for (const [fr, tos] of Object.entries(transitions || {})) {
|
||||
if (!fr.startsWith('/')) continue
|
||||
for (const [to, count] of Object.entries(tos)) {
|
||||
if (to.startsWith('/') && to !== fr) internal.push({ fr, to, count })
|
||||
}
|
||||
}
|
||||
return internal
|
||||
}
|
||||
|
||||
/** Build nodes with depth and a path lookup map; children are wired to parents. */
|
||||
function buildNodeTree(internal, navOrder) {
|
||||
const paths = new Set(['/', ...navOrder.keys()])
|
||||
for (const e of internal) { paths.add(e.fr); paths.add(e.to) }
|
||||
|
||||
const depth = (p) => (p === '/' ? 0 : p.split('/').length - 1)
|
||||
const nodes = [...paths].map((p) => ({
|
||||
path: p, depth: depth(p), angle: 0, children: [],
|
||||
}))
|
||||
const byPath = new Map(nodes.map((n) => [n.path, n]))
|
||||
|
||||
// Parent is the nearest ancestor present in the map, front page last.
|
||||
const parentOf = (p) => {
|
||||
let q = p
|
||||
while (q !== '/') {
|
||||
q = q.slice(0, q.lastIndexOf('/')) || '/'
|
||||
if (byPath.has(q)) return byPath.get(q)
|
||||
}
|
||||
return byPath.get('/')
|
||||
}
|
||||
for (const n of nodes) {
|
||||
if (n.path !== '/') parentOf(n.path).children.push(n)
|
||||
}
|
||||
|
||||
return { nodes, byPath, root: byPath.get('/') }
|
||||
}
|
||||
|
||||
/** Sort children by navigation order and compute each subtree's angular weight. */
|
||||
function prepareWeights(root, navOrder) {
|
||||
const byNav = (a, b) =>
|
||||
(navOrder.get(a.path) ?? Infinity) - (navOrder.get(b.path) ?? Infinity)
|
||||
|| a.path.localeCompare(b.path)
|
||||
const weight = (n) =>
|
||||
n.children.length ? n.children.reduce((s, k) => s + weight(k), 0) : 1 / n.depth
|
||||
|
||||
const walkSort = (n) => {
|
||||
n.children.sort(byNav)
|
||||
n.children.forEach(walkSort)
|
||||
}
|
||||
walkSort(root)
|
||||
|
||||
return weight
|
||||
}
|
||||
|
||||
/** Assign angles clockwise starting from the top (-PI/2). */
|
||||
function layoutAngles(root, unit, weight) {
|
||||
const lay = (n, a0) => {
|
||||
n.angle = a0
|
||||
let a = a0
|
||||
for (const k of n.children) {
|
||||
lay(k, a)
|
||||
a += weight(k) * unit
|
||||
}
|
||||
}
|
||||
let a = -Math.PI / 2
|
||||
for (const k of root.children) {
|
||||
lay(k, a)
|
||||
a += weight(k) * unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute radial positions, view counts and labels for each node. */
|
||||
function positionNodes(nodes, maxDepth, unit, viewsData, titles) {
|
||||
// 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
|
||||
// 1/unit on dense ones (keeping arcs at the node clearance).
|
||||
const CLEAR = 2 * TNODE_R + 12
|
||||
const GAP = CLEAR * Math.max(unit, 1 / unit)
|
||||
const radius = (d) => d * GAP
|
||||
|
||||
const viewCount = (p) => {
|
||||
let n = 0
|
||||
for (const c of Object.values(viewsData?.[p] || {})) n += c
|
||||
return n
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const r = radius(n.depth)
|
||||
n.x = Math.cos(n.angle) * r
|
||||
n.y = Math.sin(n.angle) * r
|
||||
n.views = viewCount(n.path)
|
||||
// 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.title = titles.get(n.path) || ''
|
||||
}
|
||||
|
||||
return { radius, GAP }
|
||||
}
|
||||
|
||||
/**
|
||||
* Family structure at a glance: a radial spoke from each parent to its
|
||||
* first child, and a ring arc across each sibling group from first to last
|
||||
* child in navigation (clockwise) order.
|
||||
*/
|
||||
function buildFamilyArcs(nodes, radius) {
|
||||
const arcs = []
|
||||
for (const n of nodes) {
|
||||
if (!n.children.length) continue
|
||||
// The spoke aims along the FIRST CHILD's angle (the node's own angle
|
||||
// coincides with it, except for the center page which has none).
|
||||
const first = n.children[0]
|
||||
const r1 = radius(n.depth) + TNODE_R
|
||||
const r2 = radius(first.depth) - TNODE_R
|
||||
arcs.push({
|
||||
d: `M ${Math.cos(first.angle) * r1} ${Math.sin(first.angle) * r1} `
|
||||
+ `L ${Math.cos(first.angle) * r2} ${Math.sin(first.angle) * r2}`,
|
||||
})
|
||||
if (n.children.length < 2) continue
|
||||
const r = radius(n.children[0].depth)
|
||||
const a0 = n.children[0].angle
|
||||
const a1 = n.children[n.children.length - 1].angle
|
||||
if (a1 - a0 >= 2 * Math.PI - 1e-6) continue // full circle: degenerate arc
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0
|
||||
arcs.push({
|
||||
d: `M ${Math.cos(a0) * r} ${Math.sin(a0) * r} `
|
||||
+ `A ${r} ${r} 0 ${large} 1 ${Math.cos(a1) * r} ${Math.sin(a1) * r}`,
|
||||
})
|
||||
}
|
||||
return arcs
|
||||
}
|
||||
|
||||
/** Collapse opposite transition directions into one unordered pair per page pair. */
|
||||
function aggregatePairs(internal) {
|
||||
const pairs = new Map() // unordered pair key -> [countAB, countBA]
|
||||
for (const e of internal) {
|
||||
const forward = e.fr < e.to
|
||||
const k = forward ? `${e.fr} ${e.to}` : `${e.to} ${e.fr}`
|
||||
const c = pairs.get(k) || [0, 0]
|
||||
c[forward ? 0 : 1] += e.count
|
||||
pairs.set(k, c)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
const fmtPt = (p) => `${p[0].toFixed(2)} ${p[1].toFixed(2)}`
|
||||
|
||||
/** Build one ribbon edge between two nodes with counts ab and ba. */
|
||||
function buildRibbon(a, b, ab, ba) {
|
||||
const count = ab + ba
|
||||
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
|
||||
const nx = -uy
|
||||
const ny = ux
|
||||
|
||||
// Half-width of the thin middle and radius of the node surround.
|
||||
const wMid = 0.75 + 6.75 * (Math.min(count, 100) / 100) ** 1.5
|
||||
const R2 = TNODE_R + 3
|
||||
|
||||
// Attachment points sit somewhat forward from the side of the node,
|
||||
// leaving enough room for the surround to flow naturally into the flare.
|
||||
const BETA = (65 * Math.PI) / 180
|
||||
const END = R2 * Math.cos(BETA)
|
||||
const wEnd = R2 * Math.sin(BETA)
|
||||
|
||||
// Fixed flare length, clamped so the two ends cannot overlap.
|
||||
const FLARE = Math.min(36, Math.max(0, (len - 2 * END) / 2))
|
||||
|
||||
// Point on the connection centerline at distance t from A, offset s
|
||||
// perpendicular to it.
|
||||
const P = (t, s) => [
|
||||
a.x + t * ux + s * nx,
|
||||
a.y + t * uy + s * ny,
|
||||
]
|
||||
|
||||
// Arc around a node from p to q the long way, passing its back side.
|
||||
const wrap = (p, q, node, back) => {
|
||||
const ang = (pt2) =>
|
||||
Math.atan2(pt2[1] - node[1], pt2[0] - node[0])
|
||||
|
||||
const TAU = 2 * Math.PI
|
||||
const da = ((ang(back) - ang(p)) % TAU + TAU) % TAU
|
||||
const db = ((ang(q) - ang(p)) % TAU + TAU) % TAU
|
||||
|
||||
return `A ${R2} ${R2} 0 1 ${da < db ? 1 : 0} ${fmtPt(q)} `
|
||||
}
|
||||
|
||||
// Build one side of a flare in node -> middle order.
|
||||
const flarePoints = (endT, midT, s, dir) => {
|
||||
const span = Math.abs(midT - endT)
|
||||
const pEnd = P(endT, s * wEnd)
|
||||
const pMid = P(midT, s * wMid)
|
||||
|
||||
// At the node, leave tangent to the circular surround.
|
||||
// The circle radius at the attachment is locally:
|
||||
// A: (+END, ±wEnd)
|
||||
// B: (-END, ±wEnd)
|
||||
// A perpendicular tangent pointing into the connection therefore has
|
||||
// these centerline/normal components.
|
||||
const tangentT = dir * wEnd / R2
|
||||
const tangentS = -s * END / R2
|
||||
|
||||
const hEnd = span * 0.65
|
||||
const hMid = span * 0.4
|
||||
|
||||
const cEnd = P(
|
||||
endT + tangentT * hEnd,
|
||||
s * wEnd + tangentS * hEnd,
|
||||
)
|
||||
|
||||
// At the thin end, arrive parallel with the centerline.
|
||||
const cMid = P(
|
||||
midT - dir * hMid,
|
||||
s * wMid,
|
||||
)
|
||||
|
||||
return { pEnd, cEnd, cMid, pMid }
|
||||
}
|
||||
|
||||
// Emit a cubic in either traversal direction. Reversing a cubic requires
|
||||
// swapping its control points, rather than recalculating the geometry.
|
||||
const curve = (f, reverse = false) => {
|
||||
if (!reverse) {
|
||||
return `C ${fmtPt(f.cEnd)} ${fmtPt(f.cMid)} ${fmtPt(f.pMid)} `
|
||||
}
|
||||
return `C ${fmtPt(f.cMid)} ${fmtPt(f.cEnd)} ${fmtPt(f.pEnd)} `
|
||||
}
|
||||
|
||||
const LA = P(END, wEnd)
|
||||
const RA = P(END, -wEnd)
|
||||
const LB = P(len - END, wEnd)
|
||||
const RB = P(len - END, -wEnd)
|
||||
|
||||
const aLeft = flarePoints(END, END + FLARE, 1, 1)
|
||||
const bLeft = flarePoints(len - END, len - END - FLARE, 1, -1)
|
||||
const bRight = flarePoints(len - END, len - END - FLARE, -1, -1)
|
||||
const aRight = flarePoints(END, END + FLARE, -1, 1)
|
||||
|
||||
const d = `M ${fmtPt(LA)} `
|
||||
+ curve(aLeft)
|
||||
+ `L ${fmtPt(bLeft.pMid)} `
|
||||
+ curve(bLeft, true)
|
||||
+ wrap(LB, RB, [b.x, b.y], P(len + R2, 0))
|
||||
+ curve(bRight)
|
||||
+ `L ${fmtPt(aRight.pMid)} `
|
||||
+ curve(aRight, true)
|
||||
+ wrap(RA, LA, [a.x, a.y], P(-R2, 0))
|
||||
+ 'Z'
|
||||
|
||||
return {
|
||||
d,
|
||||
title: `${a.path} ↔ ${b.path}: ${count} (${ab} / ${ba})`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build ribbon edges for every aggregated page-to-page pair. */
|
||||
function buildRibbonEdges(pairs, byPath) {
|
||||
return [...pairs].map(([k, [ab, ba]]) => {
|
||||
const [pf, pt] = k.split(' ')
|
||||
const a = byPath.get(pf)
|
||||
const b = byPath.get(pt)
|
||||
return buildRibbon(a, b, ab, ba)
|
||||
})
|
||||
}
|
||||
|
||||
/** Parse a visit start timestamp, which may already be numeric or an ISO string. */
|
||||
function visitStart(v) {
|
||||
return typeof v.start === 'number' ? v.start : Date.parse(v.start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive internal path -> path transitions from the visits list, optionally
|
||||
* restricted to a time window. This is the only time-filterable source of
|
||||
* transitions (the server-side aggregate has no per-transition timestamps).
|
||||
* Re-visits within the same visit are not recorded in `trail`, so this yields
|
||||
* first-seen navigation chains rather than every ping.
|
||||
*/
|
||||
export function buildTransitionsFromVisits(visits, t0, t1) {
|
||||
const transitions = {}
|
||||
for (const v of visits || []) {
|
||||
const start = visitStart(v)
|
||||
if ((t0 != null && start < t0) || (t1 != null && start >= t1)) continue
|
||||
const path = [v.entry, ...(v.trail || [])].filter((p) => p?.startsWith('/'))
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const fr = path[i]
|
||||
const to = path[i + 1]
|
||||
if (fr === to) continue
|
||||
transitions[fr] = transitions[fr] || {}
|
||||
transitions[fr][to] = (transitions[fr][to] || 0) + 1
|
||||
}
|
||||
}
|
||||
return transitions
|
||||
}
|
||||
|
||||
/** Keep only the 5-minute view buckets that fall inside [t0, t1). */
|
||||
export function filterViewsByRange(views, t0, t1) {
|
||||
const filtered = {}
|
||||
for (const [path, buckets] of Object.entries(views || {})) {
|
||||
const out = {}
|
||||
for (const [k, c] of Object.entries(buckets)) {
|
||||
const t = Date.parse(k)
|
||||
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out[k] = c
|
||||
}
|
||||
if (Object.keys(out).length) filtered[path] = out
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the radial transition map model.
|
||||
* Returns { nodes, edges, arcs, r } or null when there is nothing to show.
|
||||
*/
|
||||
export function buildTransitionGraph(data, pageTree) {
|
||||
const internal = collectInternalTransitions(data?.transitions)
|
||||
const navOrder = buildNavigationOrder(pageTree)
|
||||
const titles = buildTitleMap(pageTree)
|
||||
|
||||
if (!internal.length && !navOrder.size) return null
|
||||
|
||||
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
||||
const weightFn = prepareWeights(root, navOrder)
|
||||
const unit = (2 * Math.PI) / weightFn(root)
|
||||
layoutAngles(root, unit, weightFn)
|
||||
|
||||
const maxDepth = Math.max(1, ...nodes.map((n) => n.depth))
|
||||
const { radius, GAP } = positionNodes(nodes, maxDepth, unit, data?.views, titles)
|
||||
const arcs = buildFamilyArcs(nodes, radius)
|
||||
const pairs = aggregatePairs(internal)
|
||||
const edges = buildRibbonEdges(pairs, byPath)
|
||||
|
||||
// Tight bounding box of the actual nodes; edges and arcs stay within the
|
||||
// node circles, so node bounds plus node radius are sufficient.
|
||||
const pad = 16
|
||||
const xs = nodes.map((n) => n.x)
|
||||
const ys = nodes.map((n) => n.y)
|
||||
const bounds = {
|
||||
x0: Math.min(...xs) - TNODE_R - pad,
|
||||
y0: Math.min(...ys) - TNODE_R - pad,
|
||||
x1: Math.max(...xs) + TNODE_R + pad,
|
||||
y1: Math.max(...ys) + TNODE_R + pad,
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
arcs,
|
||||
bounds,
|
||||
}
|
||||
}
|
||||
@@ -596,7 +596,7 @@ blockquote p {
|
||||
background: color-mix(in srgb, var(--admonition-color, var(--accent)) 7%, transparent);
|
||||
}
|
||||
|
||||
.admonition > :last-child {
|
||||
.admonition> :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -698,12 +698,6 @@ td {
|
||||
transparent 75%);
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) td {
|
||||
background: linear-gradient(160deg,
|
||||
color-mix(in srgb, var(--table-tint, var(--accent)) 9%, transparent),
|
||||
color-mix(in srgb, var(--table-tint, var(--accent)) 3%, transparent) 75%);
|
||||
}
|
||||
|
||||
tbody tr+tr td {
|
||||
border-top: 1px solid color-mix(in srgb, var(--table-tint, var(--accent)) 12%, transparent);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ if (import.meta.env.DEV) {
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import EditorShell from './EditorShell.vue'
|
||||
import AnalyticsView from './AnalyticsView.vue'
|
||||
|
||||
let host = null
|
||||
let app = null
|
||||
@@ -96,3 +97,52 @@ export function closeEditor() {
|
||||
if (!visible && restoreTitle != null) document.title = restoreTitle
|
||||
})
|
||||
}
|
||||
|
||||
// --- Full-screen analytics app ---------------------------------------------
|
||||
// Replaces the page chrome while open (body.analytics-open hides it, see
|
||||
// AnalyticsView.vue); opened from the 📊 pen or directly via the URL hash
|
||||
// #/analytics/<range> so refresh and link sharing stay in analytics.
|
||||
let analyticsHost = null
|
||||
let analyticsApp = null
|
||||
|
||||
function analyticsHashRange() {
|
||||
const m = location.hash.match(/^#\/analytics(?:\/(\w+))?/)
|
||||
return m ? m[1] || 'week' : null
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
if (analyticsHashRange() === null) closeAnalytics()
|
||||
else openAnalytics()
|
||||
}
|
||||
|
||||
export function openAnalytics() {
|
||||
if (analyticsHost) return
|
||||
let r = analyticsHashRange()
|
||||
if (r === null) {
|
||||
r = 'week'
|
||||
// Pushed (not replaced) so the back button exits the app via hashchange.
|
||||
history.pushState(null, '', `#/analytics/${r}`)
|
||||
}
|
||||
analyticsHost = document.createElement('div')
|
||||
document.body.append(analyticsHost)
|
||||
document.body.classList.add('analytics-open')
|
||||
analyticsApp = createApp(AnalyticsView, {
|
||||
initialRange: r,
|
||||
onClose: closeAnalytics,
|
||||
})
|
||||
analyticsApp.mount(analyticsHost)
|
||||
addEventListener('hashchange', onHashChange)
|
||||
}
|
||||
|
||||
export function closeAnalytics() {
|
||||
if (!analyticsHost) return
|
||||
removeEventListener('hashchange', onHashChange)
|
||||
analyticsApp?.unmount()
|
||||
analyticsApp = null
|
||||
analyticsHost?.remove()
|
||||
analyticsHost = null
|
||||
document.body.classList.remove('analytics-open')
|
||||
if (analyticsHashRange() !== null) {
|
||||
history.replaceState(null, '', location.pathname + location.search)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,16 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
if (canEdit) {
|
||||
pens.append(makePen("banner"));
|
||||
pens.append(makePen("site"));
|
||||
if (editorMeta) {
|
||||
// Full-screen analytics view (separate from the docked panel).
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "edit-link analytics-link";
|
||||
btn.title = "analytics";
|
||||
btn.textContent = "📊";
|
||||
btn.dataset.editorSrc = editorMeta.src;
|
||||
pens.append(btn);
|
||||
}
|
||||
}
|
||||
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
||||
banner.after(pens);
|
||||
@@ -115,7 +125,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
|
||||
async function setupAuth() {
|
||||
const src = document.querySelector('meta[name="pagerite:editor-src"]')?.content;
|
||||
if (!src) return;
|
||||
if (!src) { pingEntryOnce(); return; }
|
||||
editorMeta = {
|
||||
src,
|
||||
css: document.querySelector('meta[name="pagerite:editor-css"]')?.content,
|
||||
@@ -138,6 +148,19 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
}
|
||||
|
||||
renderAuthUi();
|
||||
pingEntryOnce();
|
||||
|
||||
// The analytics app is addressable by URL (#/analytics/<range>), so a
|
||||
// refresh or a shared link lands back in it. Only for editors.
|
||||
const openAnalyticsFromHash = () => {
|
||||
if (!location.hash.startsWith("#/analytics")) return;
|
||||
if (!(isAdmin || !ssoAvailable) || !editorMeta) return;
|
||||
import(/* @vite-ignore */ editorMeta.src)
|
||||
.then((m) => m.openAnalytics())
|
||||
.catch((e) => console.error("analytics view load failed:", e));
|
||||
};
|
||||
openAnalyticsFromHash();
|
||||
addEventListener("hashchange", openAnalyticsFromHash);
|
||||
}
|
||||
|
||||
// Returning to the page via history back/forward may restore a cached
|
||||
@@ -326,6 +349,42 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// --- Analytics pings ---------------------------------------------------
|
||||
// Fire-and-forget POST /_a {fr, to}: on the initial page load (starts the
|
||||
// visit — the server counts nothing from the document GET alone), for
|
||||
// internal fetch-navigations and for external https exits. Excluded:
|
||||
// back/forward (popstate never pings) and everything while we know the
|
||||
// user is an admin — but only when SSO is actually in use; with no auth
|
||||
// (dev/test) "admin" is everyone's state and nothing would be recorded —
|
||||
// or has the editor/analytics view open (admin noise, not visits).
|
||||
// See docs/analytics.md.
|
||||
function ping(to, fr = currentPath) {
|
||||
if ((ssoAvailable && isAdmin) || document.body.classList.contains("editing")
|
||||
|| document.body.classList.contains("analytics-open")) return;
|
||||
try {
|
||||
fetch("/_a", {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fr, to }),
|
||||
});
|
||||
} catch { /* analytics must never break navigation */ }
|
||||
}
|
||||
|
||||
// The initial page load pings too — it is what starts the visit and
|
||||
// counts the entry page view (the document GET alone records nothing).
|
||||
// Sent once per load, after the auth probes so the admin gate applies;
|
||||
// the pageshow re-probe must not ping again. Reloads are not visits:
|
||||
// pinging them would double-count the view and log a self-transition.
|
||||
let entryPinged = false;
|
||||
function pingEntryOnce() {
|
||||
if (entryPinged) return;
|
||||
entryPinged = true;
|
||||
const nav = performance.getEntriesByType?.("navigation")[0];
|
||||
if (nav ? nav.type === "reload" : performance.navigation?.type === 1) return;
|
||||
ping(currentPath);
|
||||
}
|
||||
|
||||
// --- Fetch navigation ------------------------------------------------
|
||||
async function load(url, push = true, back = false) {
|
||||
// Navigating with the editor open closes it; unsaved edits are lost
|
||||
@@ -348,12 +407,12 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
doc = new DOMParser().parseFromString(await res.text(), "text/html");
|
||||
} catch {
|
||||
location.href = url; // fall back to a normal navigation
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (REGIONS.some((id) => !doc.getElementById(id))) {
|
||||
location.href = url;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const doit = () => {
|
||||
for (const id of REGIONS) {
|
||||
@@ -410,6 +469,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
currentPath = new URL(finalUrl, location.href).pathname;
|
||||
if (push) history.pushState(null, "", finalUrl);
|
||||
scrollTo(0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
addEventListener("click", (ev) => {
|
||||
@@ -419,6 +479,16 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// the Vue app on demand (with any extra styles) and mount it in place.
|
||||
// Clicking the pen of the already-open tab closes the shell; clicking
|
||||
// another pen switches the shell to that tab.
|
||||
// The 📊 pen opens the full-screen analytics view (its own Vue app,
|
||||
// not a tab of the docked editor shell).
|
||||
const analyticsBtn = ev.target.closest("button.analytics-link");
|
||||
if (analyticsBtn && analyticsBtn.dataset.editorSrc) {
|
||||
ev.preventDefault();
|
||||
import(/* @vite-ignore */ analyticsBtn.dataset.editorSrc)
|
||||
.then((m) => m.openAnalytics())
|
||||
.catch((e) => console.error("analytics view load failed:", e));
|
||||
return;
|
||||
}
|
||||
const editBtn = ev.target.closest("button.edit-link");
|
||||
if (editBtn && editBtn.dataset.editorSrc) {
|
||||
ev.preventDefault();
|
||||
@@ -447,16 +517,27 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
const a = ev.target.closest("a[href]");
|
||||
if (!a || a.target || a.hasAttribute("download")) return;
|
||||
const url = new URL(a.href, location.href);
|
||||
if (url.origin !== location.origin) return;
|
||||
if (url.origin !== location.origin) {
|
||||
// External link: the browser navigates; just record the exit (https
|
||||
// origins only, stripped to the origin part server-side anyway).
|
||||
if (url.protocol === "https:") ping(url.origin);
|
||||
return;
|
||||
}
|
||||
// Same-page anchor links (footnotes etc.): let the browser handle them
|
||||
if (url.pathname === location.pathname && url.hash) return;
|
||||
// Machinery and auth endpoints are never fetch-navigated.
|
||||
if (url.pathname.startsWith("/_") || url.pathname.startsWith("/auth")) return;
|
||||
ev.preventDefault();
|
||||
load(url);
|
||||
// Capture the source now: load() updates currentPath before pinging.
|
||||
const from = currentPath;
|
||||
load(url).then((ok) => { if (ok) ping(url.pathname, from); });
|
||||
});
|
||||
|
||||
addEventListener("popstate", () => load(location.href, false, true));
|
||||
addEventListener("popstate", () => {
|
||||
// Hash-only history entries (the analytics app) are not navigations.
|
||||
if (location.pathname === currentPath) return;
|
||||
load(location.href, false, true);
|
||||
});
|
||||
|
||||
// --- Task-list checkboxes ------------------------------------------------
|
||||
// Checkboxes in the rendered article are live: toggling them edits the
|
||||
|
||||
@@ -9,13 +9,14 @@ const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
|
||||
|
||||
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
|
||||
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
|
||||
// backend's /_ prefix. /_api and /_f are handled by the fastapi-vue plugin.
|
||||
// backend's /_ prefix. /_api, /_f, /_themes and the /_a analytics ping are
|
||||
// handled by the fastapi-vue plugin.
|
||||
const CONTENT_PROXY = '^\\/(?!_|@|src|node_modules|__)(?:[^./?]+(?:\\/[^./?]+)*)?(?:\\?.*)?$'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes"] }),
|
||||
fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_a"] }),
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user