Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ebc63690 | ||
|
|
2868843028 | ||
|
|
6fcebaea3f | ||
|
|
4a48d08a19 | ||
|
|
e3e29251ca | ||
|
|
8affc41289 | ||
|
|
2de4717230 | ||
|
|
563e8fcaf2 | ||
|
|
921a5484a2 | ||
|
|
0e2e52fa45 | ||
|
|
075848f782 | ||
|
|
7b8899af92 |
+56
-22
@@ -147,6 +147,8 @@ Each `Visit` record:
|
|||||||
does not append.
|
does not append.
|
||||||
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
||||||
- `read` — active reading time per path (seconds), keyed by path.
|
- `read` — active reading time per path (seconds), keyed by path.
|
||||||
|
- `statuses` — HTTP status of the response when each path was first seen
|
||||||
|
(200 or 404), keyed by path.
|
||||||
|
|
||||||
Each `CrawlerHit` record:
|
Each `CrawlerHit` record:
|
||||||
|
|
||||||
@@ -154,7 +156,9 @@ Each `CrawlerHit` record:
|
|||||||
- `entry` — page path requested,
|
- `entry` — page path requested,
|
||||||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
||||||
- `referer` — external https origin of the request, `""` for direct/none,
|
- `referer` — external https origin of the request, `""` for direct/none,
|
||||||
- `query` — raw query string of the request.
|
- `query` — raw query string of the request,
|
||||||
|
- `status` — HTTP status of the served response (200 for a real page, 404
|
||||||
|
for a category placeholder or missing page).
|
||||||
|
|
||||||
Each `AbuseHit` record:
|
Each `AbuseHit` record:
|
||||||
|
|
||||||
@@ -173,6 +177,10 @@ that triggered classification are lifted to the top, followed by other 404s
|
|||||||
and then document GETs from the abuser. Within each category paths are
|
and then document GETs from the abuser. Within each category paths are
|
||||||
sorted by count descending, then by their earliest hit.
|
sorted by count descending, then by their earliest hit.
|
||||||
|
|
||||||
|
In the visitor and crawler tables, internal paths that returned a 404 status
|
||||||
|
are shown in red and the link title includes the status code, so it is easy
|
||||||
|
to tell misses from real pages at a glance.
|
||||||
|
|
||||||
## Aggregates
|
## Aggregates
|
||||||
|
|
||||||
- `transitions`: time series of page transitions, sparse nested dict
|
- `transitions`: time series of page transitions, sparse nested dict
|
||||||
@@ -216,12 +224,11 @@ spans less than 24 hours, otherwise `week`.
|
|||||||
`AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
|
`AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
|
||||||
page-chrome hiding and `#/analytics/<range>` hash routing have been removed.
|
page-chrome hiding and `#/analytics/<range>` hash routing have been removed.
|
||||||
|
|
||||||
Charts are SVG curves (Catmull-Rom over an edge-aware adaptive Gaussian —
|
Charts are SVG curves (Catmull-Rom over an edge-aware Gaussian — a
|
||||||
a change-point detector splits the series at traffic-level shifts, then
|
change-point detector splits the series at traffic-level shifts, then each
|
||||||
each segment is smoothed with a bandwidth that ramps with a broad pilot
|
segment is smoothed independently with a fixed sigma chosen so N events in
|
||||||
estimate of the local rate: isolated events stay narrow (~0.4-unit sigma,
|
a single bucket peak at N events per unit. The raw series is drawn faint
|
||||||
peaking at ~1 event/unit), busy traffic widens to a 1-unit sigma. The raw
|
underneath). Values are
|
||||||
series is drawn faint underneath). Values are
|
|
||||||
**per-unit rates** — per hour on the week view (5-minute bucket counts × 12,
|
**per-unit rates** — per hour on the week view (5-minute bucket counts × 12,
|
||||||
plotted at native 5-minute resolution), per day on the month+ ranges — and
|
plotted at native 5-minute resolution), per day on the month+ ranges — and
|
||||||
the smoothing time scale follows the unit: the month+ sigmas are 24× the
|
the smoothing time scale follows the unit: the month+ sigmas are 24× the
|
||||||
@@ -232,31 +239,58 @@ labeled intervals, minor lines at fifths when integral; the minimum y-axis
|
|||||||
range is 10 so tiny values such as a single visit are not stretched to a
|
range is 10 so tiny values such as a single visit are not stretched to a
|
||||||
fractional scale).
|
fractional scale).
|
||||||
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
|
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
|
||||||
weeks in the same accent color at decreasing opacity (the current week is
|
weeks in the muted color at decreasing opacity (the current week keeps the
|
||||||
|
accent color and is
|
||||||
truncated at the current bucket, never drawing fake zeroes for the future);
|
truncated at the current bucket, never drawing fake zeroes for the future);
|
||||||
its x labels are weekday names centered at midday UTC, without vertical grid
|
a compact legend inside the top right of the visits chart marks the current
|
||||||
|
ISO week in accent and the overlaid past weeks as "Week M" or "Week M–N" on
|
||||||
|
a muted specimen. Its x labels are weekday names centered at midday UTC, without
|
||||||
|
vertical grid
|
||||||
lines (day boundaries would be misleading in the viewer's timezone). The
|
lines (day boundaries would be misleading in the viewer's timezone). The
|
||||||
month view labels days the same lineless way — day numbers at noon UTC,
|
month view labels days the same lineless way — day numbers at noon UTC,
|
||||||
with the month name substituted for the 1st. Year is a rolling 365-day window ending at now, re-bucketed to daily points,
|
with the month name substituted for the 1st. Month, year and all are
|
||||||
with boundary lines at months/years. All uses the full data reach, but keeps
|
rolling windows ending at now, aligned to UTC day boundaries at the start
|
||||||
at least the past 30 days so the chart never collapses to a tiny sliver when
|
so the labels span the whole range; the bucket size follows the window —
|
||||||
the site is young. Below the charts: a radial **transition map** (all pages from
|
6 hours up to 31 days, daily beyond — with boundary lines at months/years
|
||||||
`/_api/pages` — front page at the center, each slug level on its own ring,
|
on the longer ranges. All uses the full data reach, but keeps
|
||||||
siblings clockwise in navigation order from the top, radial gap equal to
|
at least the past 30 days (identical to the month view when the site is
|
||||||
the arc spacing — opposite transition directions joined into organic
|
younger than that, bucket size included) so the chart never collapses to a
|
||||||
|
tiny sliver when the site is young. Below the charts: a **transition map** (all pages from
|
||||||
|
`/_api/pages` — top-level menu items on a large-radius circular arc whose
|
||||||
|
bottom point is the last item (each earlier item a bit higher), connected
|
||||||
|
by a top lane labeled 🏠︎ beside the home pill (50% thicker than
|
||||||
|
the branch lanes, its label font and guide offset scaled along), each item's
|
||||||
|
subtree fanning out below it in menu order along a large-radius circular
|
||||||
|
arc that leaves heading
|
||||||
|
straight down and gradually bends right, index pages without views omitted
|
||||||
|
and their children promoted in their place. The submenu structure is drawn
|
||||||
|
as wide branch lanes: one per path prefix with at least two visible
|
||||||
|
nodes, running behind the branch's node pills as circle arcs concentric
|
||||||
|
with the fan (parent levels one radius step outward, so all lanes of a
|
||||||
|
group share exactly one form), each labeled with its branch slug
|
||||||
|
left-aligned just past the first pill and allowed to run along the lane to
|
||||||
|
its end, disappearing under later pills when long — so the lanes reflect
|
||||||
|
the path
|
||||||
|
structure even where index pages are omitted — opposite transition
|
||||||
|
directions joined into organic
|
||||||
tapered connections whose middle width grows logarithmically with the
|
tapered connections whose middle width grows logarithmically with the
|
||||||
count (a single count renders as a ~1 px line, uncapped), connections
|
count (uncapped), connections
|
||||||
carrying less than 1% of the total traffic
|
carrying less than 1% of the total traffic
|
||||||
pruned; beads are simulated one by one in JS (requestAnimationFrame) and
|
pruned, as are those whose thin middle would render below ~0.8 px —
|
||||||
flow along each edge, emitted at a rate linearly proportional
|
fainter strands are invisible and only their wide end flares would show; beads are simulated one by one in JS (requestAnimationFrame) and
|
||||||
|
flow along each edge, persisting across data reloads (emitters are keyed
|
||||||
|
per edge direction and beads tracked by progress, so an unrelated count
|
||||||
|
change never reshuffles them), emitted at a rate linearly proportional
|
||||||
to the directional count with no in-flight limit, opposing directions
|
to the directional count with no in-flight limit, opposing directions
|
||||||
offset onto parallel lanes. External sources show as a node row above the
|
offset onto parallel lanes. External sources and exits whose connectors are
|
||||||
|
all culled by the width threshold are dropped from their rows themselves
|
||||||
|
(the site's own page nodes always stay, connected or not). External sources show as a node row above the
|
||||||
map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
|
map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
|
||||||
referer origin, then any other `utm_*` tag, so UTM-tagged visits are grouped
|
referer origin, then any other `utm_*` tag, so UTM-tagged visits are grouped
|
||||||
under their campaign/source value rather than the referer domain. A UTM
|
under their campaign/source value rather than the referer domain. A UTM
|
||||||
source node only links to its referer when every visit carrying that tag
|
source node only links to its referer when every visit carrying that tag
|
||||||
came from the same origin. External exits are small nodes fanned outwards
|
came from the same origin. External exits are full-size nodes in a matching
|
||||||
from their source page), per-page view
|
row centered below the map, so the site itself stays in the middle), per-page view
|
||||||
counts, the top transitions and the 50 most recent visit trails. Data is
|
counts, the top transitions and the 50 most recent visit trails. Data is
|
||||||
streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest
|
streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest
|
||||||
JSON snapshot on connect and again whenever the analytics file is updated
|
JSON snapshot on connect and again whenever the analytics file is updated
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
// visit/crawler tables. Read-only.
|
// visit/crawler tables. Read-only.
|
||||||
// See docs/analytics.md for the data format.
|
// See docs/analytics.md for the data format.
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RANGES } from './analytics/time.js'
|
import {
|
||||||
|
RANGES,
|
||||||
|
rangeWindow,
|
||||||
|
filterRecordsByRange,
|
||||||
|
filterTransitionsByRange,
|
||||||
|
filterViewsByRange,
|
||||||
|
} from './analytics/time.js'
|
||||||
import {
|
import {
|
||||||
calcReadStats,
|
calcReadStats,
|
||||||
calcTotalViews,
|
calcTotalViews,
|
||||||
@@ -20,6 +26,11 @@ import TrailLink from './TrailLink.vue'
|
|||||||
import VisitorCell from './VisitorCell.vue'
|
import VisitorCell from './VisitorCell.vue'
|
||||||
import TransitionGraph from './TransitionGraph.vue'
|
import TransitionGraph from './TransitionGraph.vue'
|
||||||
import VisitorCharts from './VisitorCharts.vue'
|
import VisitorCharts from './VisitorCharts.vue'
|
||||||
|
import { VIEW_W } from './analytics/chart.js'
|
||||||
|
|
||||||
|
// Same centering margin as the charts, so the totals row's left edge
|
||||||
|
// aligns with the chart svg above the natural width.
|
||||||
|
const CHART_MARGIN = `max(0px, calc(50% - ${VIEW_W / 2}px))`
|
||||||
|
|
||||||
const ABUSE_MAX_LINES = 5
|
const ABUSE_MAX_LINES = 5
|
||||||
|
|
||||||
@@ -90,8 +101,25 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const visits = computed(() => data.value?.visits || [])
|
const window = computed(() => rangeWindow(range.value))
|
||||||
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
|
||||||
|
// All non-chart stats follow the selected range; the charts keep their own
|
||||||
|
// range-specific x windows (week overlays previous weeks aligned to Monday).
|
||||||
|
const rangeData = computed(() => {
|
||||||
|
if (!data.value) return null
|
||||||
|
const { t0, t1 } = window.value
|
||||||
|
return {
|
||||||
|
...data.value,
|
||||||
|
transitions: filterTransitionsByRange(data.value.transitions, t0, t1),
|
||||||
|
views: filterViewsByRange(data.value.views, t0, t1),
|
||||||
|
visits: filterRecordsByRange(data.value.visits, t0, t1),
|
||||||
|
crawlers: filterRecordsByRange(data.value.crawlers, t0, t1),
|
||||||
|
abuse: filterRecordsByRange(data.value.abuse, t0, t1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const visits = computed(() => rangeData.value?.visits || [])
|
||||||
|
const totalViews = computed(() => calcTotalViews(rangeData.value?.views))
|
||||||
const readStats = computed(() => calcReadStats(visits.value))
|
const readStats = computed(() => calcReadStats(visits.value))
|
||||||
|
|
||||||
// Keep the URL shareable when the range changes.
|
// Keep the URL shareable when the range changes.
|
||||||
@@ -103,9 +131,9 @@ watch(range, (r) => {
|
|||||||
|
|
||||||
const clients = computed(() => data.value?.clients || {})
|
const clients = computed(() => data.value?.clients || {})
|
||||||
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value))
|
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value))
|
||||||
const crawlers = computed(() => data.value?.crawlers || [])
|
const crawlers = computed(() => rangeData.value?.crawlers || [])
|
||||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value))
|
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value))
|
||||||
const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], clients.value, now.value))
|
const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], clients.value, now.value))
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -125,15 +153,15 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
|||||||
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
||||||
<p v-else-if="!data" class="loading">loading…</p>
|
<p v-else-if="!data" class="loading">loading…</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="totals">
|
<section class="totals" :style="{ marginLeft: CHART_MARGIN }">
|
||||||
<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.avgMinPerVisit }}</strong> min/visit</div>
|
||||||
<div><strong>{{ readStats.avgArticleMedianMin }}</strong> min article read</div>
|
<div><strong>{{ readStats.avgArticleMedianMin }}</strong> min/read</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<VisitorCharts :data="data" :range="range" />
|
<VisitorCharts :data="data" :range="range" />
|
||||||
<TransitionGraph :data="data" :range="range" :page-tree="pageTree" />
|
<TransitionGraph :data="rangeData" :window="window" :page-tree="pageTree" />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Recent visits</h2>
|
<h2>Recent visits</h2>
|
||||||
@@ -265,6 +293,8 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
/* Same 1.25rem side spacing as main's article padding. */
|
/* Same 1.25rem side spacing as main's article padding. */
|
||||||
padding: 1.5rem 1.25rem 4rem;
|
padding: 1.5rem 1.25rem 4rem;
|
||||||
|
/* Container for cqw-based shrink-to-fit (see .totals). */
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-panel header {
|
.analytics-panel header {
|
||||||
@@ -331,12 +361,15 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
|||||||
.analytics-view :deep(.muted) { color: var(--muted); }
|
.analytics-view :deep(.muted) { color: var(--muted); }
|
||||||
.analytics-view :deep(.small) { font-size: 0.75em; }
|
.analytics-view :deep(.small) { font-size: 0.75em; }
|
||||||
|
|
||||||
|
/* One line at any width: the gap shrinks first, then the font (the number
|
||||||
|
scales along in em), both following the panel's container width. */
|
||||||
.totals {
|
.totals {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
gap: clamp(0.5rem, 3cqw, 2rem);
|
||||||
font-size: 1.1rem;
|
font-size: clamp(0.6rem, 2.2cqw, 1.1rem);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.totals strong { font-size: 1.5rem; }
|
.totals strong { font-size: 1.36em; }
|
||||||
|
|
||||||
.visit-table-wrap {
|
.visit-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -392,6 +425,11 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
|||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.analytics-view :deep(.trail-link.error),
|
||||||
|
.analytics-view :deep(.trail-link.error:hover) {
|
||||||
|
color: var(--error, #c00);
|
||||||
|
}
|
||||||
|
|
||||||
.visit-table .utm-tag {
|
.visit-table .utm-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { formatCount } from './analytics/format.js'
|
import { computed } from 'vue'
|
||||||
|
import { formatCount, formatReadTime } from './analytics/format.js'
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
step: { type: Object, required: true },
|
step: { type: Object, required: true },
|
||||||
count: { type: Number, default: 0 },
|
count: { type: Number, default: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['close'])
|
defineEmits(['close'])
|
||||||
|
|
||||||
|
const hasError = computed(() => props.step.status >= 400)
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const parts = [props.step.title]
|
||||||
|
if (props.step.readSeconds > 0) {
|
||||||
|
parts.push(formatReadTime(props.step.readSeconds))
|
||||||
|
}
|
||||||
|
if (hasError.value) {
|
||||||
|
parts.push(`${props.step.status}`)
|
||||||
|
}
|
||||||
|
return parts.filter(Boolean).join(' — ')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a class="trail-link"
|
<a class="trail-link"
|
||||||
|
:class="{ error: hasError }"
|
||||||
:href="step.path"
|
:href="step.path"
|
||||||
:title="count > 1 ? `${step.title} (${count} hits)` : step.title"
|
:title="title"
|
||||||
:target="step.external ? '_blank' : undefined"
|
:target="step.external ? '_blank' : undefined"
|
||||||
:rel="step.external ? 'noopener' : undefined"
|
:rel="step.external ? 'noopener' : undefined"
|
||||||
@click="(e) => { if (!step.external) $emit('close') }">
|
@click="(e) => { if (!step.external) $emit('close') }">
|
||||||
|
|||||||
+136
-112
@@ -1,165 +1,168 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* Radial transition map filtered to the selected time range.
|
* Radial transition map for a pre-filtered time range.
|
||||||
*
|
*
|
||||||
* Transitions are stored per 5-minute bucket (from -> to -> bucket ->
|
* The parent filters transitions, views and visits to the selected range
|
||||||
* count), so the graph sums the buckets falling inside the selected
|
* before passing them in; `window` carries the absolute [t0, t1) window
|
||||||
* range, exactly like the charts and per-page views do.
|
* so the visual scale can normalize against a one-week reference.
|
||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
||||||
import { rangeWindow, WEEK } from './analytics/time.js'
|
import { DAY, WEEK } from './analytics/time.js'
|
||||||
import { formatCount } from './analytics/format.js'
|
import { formatCount } from './analytics/format.js'
|
||||||
import {
|
import {
|
||||||
TNODE_W,
|
TNODE_W,
|
||||||
TNODE_H,
|
TNODE_H,
|
||||||
BEAD_R,
|
BEAD_R,
|
||||||
BEAD_SPEED,
|
|
||||||
buildTransitionGraph,
|
buildTransitionGraph,
|
||||||
filterTransitionsByRange,
|
|
||||||
filterViewsByRange,
|
|
||||||
filterVisitsByRange,
|
|
||||||
} from './analytics/transitions.js'
|
} from './analytics/transitions.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: { type: Object, default: null },
|
data: { type: Object, default: null },
|
||||||
range: { type: String, required: true },
|
window: { type: Object, required: true },
|
||||||
pageTree: { type: Array, default: null },
|
pageTree: { type: Array, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const window = computed(() => rangeWindow(props.range))
|
|
||||||
|
|
||||||
const visualScale = computed(() => {
|
const visualScale = computed(() => {
|
||||||
const { t0, t1 } = window.value
|
const { t0, t1 } = props.window
|
||||||
if (t0 != null && t1 != null) return WEEK / (t1 - t0)
|
if (t0 != null && t1 != null) return WEEK / (t1 - t0)
|
||||||
// 'all': scale by the actual data span.
|
// 'all': scale by the actual data span, but never less than the 30-day
|
||||||
|
// minimum the plot enforces, so sparse young data is not over-amplified.
|
||||||
const times = new Set()
|
const times = new Set()
|
||||||
for (const buckets of Object.values(props.data?.views || {})) {
|
for (const buckets of Object.values(props.data?.views || {})) {
|
||||||
for (const k of Object.keys(buckets)) times.add(Date.parse(k))
|
for (const k of Object.keys(buckets)) times.add(Date.parse(k))
|
||||||
}
|
}
|
||||||
const arr = [...times]
|
const arr = [...times]
|
||||||
if (arr.length < 2) return 1
|
if (arr.length < 2) return 1
|
||||||
return WEEK / (Math.max(...arr) - Math.min(...arr))
|
const span = Math.max(...arr) - Math.min(...arr)
|
||||||
|
return WEEK / Math.max(span, 30 * DAY)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredData = computed(() => {
|
|
||||||
if (!props.data) return null
|
|
||||||
const { t0, t1 } = window.value
|
|
||||||
return {
|
|
||||||
transitions: filterTransitionsByRange(props.data.transitions, t0, t1),
|
|
||||||
views: filterViewsByRange(props.data.views, t0, t1),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredVisits = computed(() =>
|
|
||||||
filterVisitsByRange(props.data?.visits, window.value.t0, window.value.t1),
|
|
||||||
)
|
|
||||||
|
|
||||||
const graph = computed(() =>
|
const graph = computed(() =>
|
||||||
filteredData.value
|
props.data
|
||||||
? buildTransitionGraph(filteredData.value, props.pageTree, filteredVisits.value, visualScale.value)
|
? buildTransitionGraph(props.data, props.pageTree, props.data.visits || [], visualScale.value)
|
||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bead animation: every bead is simulated independently in JS. Each flow
|
// Bead animation: every bead is simulated independently in JS. Each flow
|
||||||
// (one per edge direction) emits a bead every `interval` seconds; beads
|
// (one per edge direction) emits a bead every `interval` seconds; beads
|
||||||
// travel at BEAD_SPEED along the segment and are dropped at the end.
|
// cross their segment in a constant TRAVERSAL_S seconds (speed relative
|
||||||
|
// to span length) and are dropped at the end.
|
||||||
// There is deliberately no cap on beads in flight.
|
// There is deliberately no cap on beads in flight.
|
||||||
|
// Emitters persist across data reloads, keyed by flow.key: an unchanged
|
||||||
|
// link keeps its emission phase and in-flight beads (tracked by progress,
|
||||||
|
// not absolute time), so a count change elsewhere never reshuffles them.
|
||||||
const beads = shallowRef([])
|
const beads = shallowRef([])
|
||||||
let rafId = 0
|
let rafId = 0
|
||||||
|
const emitters = new Map() // flow.key -> { flow, interval, next, alive }
|
||||||
|
const live = [] // { e, p } — beads in flight, p = progress 0..1
|
||||||
|
let lastTick = 0
|
||||||
|
|
||||||
const MAX_BEAD_RATE = 120 // upper bound on total beads per second
|
const MAX_BEAD_RATE = 120 // upper bound on total beads per second
|
||||||
|
const TRAVERSAL_S = 1.5 // seconds to cross any segment, end to end
|
||||||
|
|
||||||
const startBeads = (flows) => {
|
const syncBeads = (flows) => {
|
||||||
cancelAnimationFrame(rafId)
|
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (!flows?.length || reduced) {
|
||||||
|
emitters.clear()
|
||||||
|
live.length = 0
|
||||||
beads.value = []
|
beads.value = []
|
||||||
if (!flows?.length) return
|
return
|
||||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
}
|
||||||
|
|
||||||
// Cap the total bead emission rate so a busy range cannot spawn enough
|
// 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;
|
// beads to kill the page. Existing per-range time scaling is preserved;
|
||||||
// this is only a proportional emergency throttle when the limit is hit.
|
// this is only a proportional emergency throttle when the limit is hit.
|
||||||
const totalRate = flows.reduce((s, f) => s + 1 / f.interval, 0)
|
const totalRate = flows.reduce((s, f) => s + 1 / f.interval, 0)
|
||||||
const scale = totalRate > MAX_BEAD_RATE ? MAX_BEAD_RATE / totalRate : 1
|
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 now = performance.now()
|
||||||
const emitters = flows.map((flow) => {
|
const seen = new Set()
|
||||||
|
for (const flow of flows) {
|
||||||
|
seen.add(flow.key)
|
||||||
const interval = (flow.interval / scale) * 1000
|
const interval = (flow.interval / scale) * 1000
|
||||||
// Pre-fill the traversal with evenly spaced beads (random phase), so
|
const e = emitters.get(flow.key)
|
||||||
// the flow appears already running instead of starting empty.
|
if (e) {
|
||||||
const phase = Math.random() * interval
|
e.flow = flow // pick up new geometry/rate, keep the phase
|
||||||
for (let t = now - (flow.len / BEAD_SPEED) * 1000 + phase; t <= now; t += interval) {
|
e.interval = interval
|
||||||
live.push({ flow, t0: t })
|
continue
|
||||||
}
|
}
|
||||||
return { flow, interval, next: now + phase }
|
// New emitter: pre-fill the traversal with evenly spaced beads (random
|
||||||
})
|
// phase), so the flow appears already running instead of empty.
|
||||||
|
const phase = Math.random() * interval
|
||||||
|
const dp = interval / 1000 / TRAVERSAL_S
|
||||||
|
const ne = { flow, interval, next: now + phase, alive: true }
|
||||||
|
for (let p = 1 - phase / 1000 / TRAVERSAL_S; p > 0; p -= dp) {
|
||||||
|
live.push({ e: ne, p })
|
||||||
|
}
|
||||||
|
emitters.set(flow.key, ne)
|
||||||
|
}
|
||||||
|
for (const [key, e] of emitters) {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
e.alive = false
|
||||||
|
emitters.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = live.length - 1; i >= 0; i--) {
|
||||||
|
if (!live[i].e.alive) live.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tick = (t) => {
|
const tick = (t) => {
|
||||||
for (const e of emitters) {
|
const dt = lastTick ? (t - lastTick) / 1000 : 0
|
||||||
|
lastTick = t
|
||||||
|
for (const e of emitters.values()) {
|
||||||
while (e.next <= t) {
|
while (e.next <= t) {
|
||||||
live.push({ flow: e.flow, t0: e.next })
|
live.push({ e, p: 0 })
|
||||||
e.next += e.interval
|
e.next += e.interval
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const out = []
|
const out = []
|
||||||
for (let i = live.length - 1; i >= 0; i--) {
|
for (let i = live.length - 1; i >= 0; i--) {
|
||||||
const b = live[i]
|
const b = live[i]
|
||||||
const p = ((t - b.t0) / 1000) * BEAD_SPEED / b.flow.len
|
b.p += dt / TRAVERSAL_S
|
||||||
if (p >= 1) {
|
if (b.p >= 1) {
|
||||||
live.splice(i, 1)
|
live.splice(i, 1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out.push({
|
const f = b.e.flow
|
||||||
x: b.flow.x1 + (b.flow.x2 - b.flow.x1) * p,
|
out.push({ x: f.x1 + (f.x2 - f.x1) * b.p, y: f.y1 + (f.y2 - f.y1) * b.p })
|
||||||
y: b.flow.y1 + (b.flow.y2 - b.flow.y1) * p,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
beads.value = out
|
beads.value = out
|
||||||
rafId = requestAnimationFrame(tick)
|
rafId = requestAnimationFrame(tick)
|
||||||
}
|
|
||||||
rafId = requestAnimationFrame(tick)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => graph.value?.flows, startBeads, { immediate: true })
|
watch(() => graph.value?.flows, syncBeads, { immediate: true })
|
||||||
|
onMounted(() => {
|
||||||
|
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
})
|
||||||
onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||||
|
|
||||||
// Text in the graph must render at a constant screen size regardless of
|
// The svg never renders larger than its natural size (1 viewBox unit = 1
|
||||||
// how far the enlarged graph's viewBox is scaled down to fit the panel:
|
// px, max-width below): the layout geometry is designed in pixel-like
|
||||||
// measure the unit→pixel ratio and expose it as --u on the svg, which the
|
// units, and upscaling would blow up the pills around their text. Narrow
|
||||||
// font-size rules divide by. Falls back to 1 (raw units) until measured.
|
// panels scale the graph down to fit (width: 100%), text along with it.
|
||||||
const svgEl = ref(null)
|
|
||||||
const pxPerUnit = ref(1)
|
|
||||||
let resizeObs = null
|
|
||||||
|
|
||||||
function updateScale() {
|
// Pill text is not truncated: text is clipped at the pill's rounded border
|
||||||
const el = svgEl.value
|
// (clipPath per node, inset a few units for padding). Captions center when
|
||||||
if (el && el.viewBox.baseVal.width) {
|
// they fit; overlong ones anchor left so their beginning (not their
|
||||||
pxPerUnit.value = el.clientWidth / el.viewBox.baseVal.width
|
// middle) survives the clip. Width estimate: ~0.52 em per glyph.
|
||||||
}
|
const fitsPill = (label, fontPx = 19) => label.length * 0.52 * fontPx <= TNODE_W - 16
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
const countLabel = (n) =>
|
||||||
resizeObs = new ResizeObserver(updateScale)
|
n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views)
|
||||||
})
|
|
||||||
watch(svgEl, (el) => {
|
|
||||||
resizeObs?.disconnect()
|
|
||||||
if (el) resizeObs?.observe(el)
|
|
||||||
})
|
|
||||||
watch(() => graph.value?.bounds, updateScale)
|
|
||||||
onBeforeUnmount(() => resizeObs?.disconnect())
|
|
||||||
|
|
||||||
// Font size (px) that fits a label inside the pill width at the current
|
|
||||||
// zoom: ~0.52 em average glyph width, 12 px padding per side, capped.
|
|
||||||
const fitPx = (label) =>
|
|
||||||
Math.min(15, (TNODE_W * pxPerUnit.value - 24) / (0.52 * Math.max(label.length, 1)))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="graph">
|
<section v-if="graph">
|
||||||
<svg ref="svgEl" class="tmap" :style="{ '--u': pxPerUnit }" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
|
<svg class="tmap" :style="{ maxWidth: `${graph.bounds.x1 - graph.bounds.x0}px` }" :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">
|
role="img" aria-label="map of transitions between pages">
|
||||||
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||||
:d="a.d" class="tarc" />
|
:id="`tarc${i}`" :d="a.d" :class="['tarc', a.top && 'tarc-top']" />
|
||||||
|
<template v-for="(a, i) in graph.arcs" :key="'t' + i">
|
||||||
|
<path v-if="a.ld" :id="`tarcl${i}`" :d="a.ld" fill="none" stroke="none" />
|
||||||
|
<text v-if="a.ld" class="tarclabel" :class="{ 'tarclabel-top': a.top }"><textPath :href="`#tarcl${i}`" startOffset="0">{{ a.label }}</textPath></text>
|
||||||
|
</template>
|
||||||
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
||||||
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
||||||
<title>{{ e.title }}</title>
|
<title>{{ e.title }}</title>
|
||||||
@@ -167,35 +170,44 @@ const fitPx = (label) =>
|
|||||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
<circle v-for="(b, i) in beads" :key="'b' + i"
|
||||||
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
||||||
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
||||||
|
<clipPath :id="`xclip${i}`">
|
||||||
|
<rect :x="x.x - TNODE_W/2 + 6" :y="x.y - TNODE_H/2" :width="TNODE_W - 12"
|
||||||
|
:height="TNODE_H" :rx="TNODE_H/2 - 4" />
|
||||||
|
</clipPath>
|
||||||
<a v-if="x.href" :href="x.href" target="_blank" rel="noopener">
|
<a v-if="x.href" :href="x.href" target="_blank" rel="noopener">
|
||||||
<title>{{ x.path }}</title>
|
<title>{{ x.path }}</title>
|
||||||
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
||||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||||
<text :x="x.x" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle"
|
<g :clip-path="`url(#xclip${i})`">
|
||||||
:style="{ '--slug-px': `${fitPx(x.label)}px` }">{{ x.label }}</text>
|
<text :x="fitsPill(x.label) ? x.x : x.x - TNODE_W/2 + 8" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(x.label) ? 'middle' : 'start' }">{{ x.label }}</text>
|
||||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||||
|
</g>
|
||||||
</a>
|
</a>
|
||||||
<g v-else>
|
<g v-else>
|
||||||
<title>{{ x.path }}</title>
|
<title>{{ x.path }}</title>
|
||||||
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
||||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||||
<text :x="x.x" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle"
|
<g :clip-path="`url(#xclip${i})`">
|
||||||
:style="{ '--slug-px': `${fitPx(x.label)}px` }">{{ x.label }}</text>
|
<text :x="fitsPill(x.label) ? x.x : x.x - TNODE_W/2 + 8" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(x.label) ? 'middle' : 'start' }">{{ x.label }}</text>
|
||||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<g v-for="n in graph.nodes" :key="n.path">
|
</g>
|
||||||
|
<g v-for="(n, i) in graph.nodes" :key="n.path">
|
||||||
|
<clipPath :id="`nclip${i}`">
|
||||||
|
<rect :x="n.x - TNODE_W/2 + 6" :y="n.y - TNODE_H/2" :width="TNODE_W - 12"
|
||||||
|
:height="TNODE_H" :rx="TNODE_H/2 - 4" />
|
||||||
|
</clipPath>
|
||||||
<a :href="n.path">
|
<a :href="n.path">
|
||||||
<title>{{ n.title }}</title>
|
<title>{{ n.title }}</title>
|
||||||
<rect :x="n.x - TNODE_W/2" :y="n.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2" class="tnode" />
|
<rect :x="n.x - TNODE_W/2" :y="n.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2" class="tnode" />
|
||||||
<text :x="n.x" :y="n.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle"
|
<g :clip-path="`url(#nclip${i})`">
|
||||||
:style="{ '--slug-px': `${fitPx(n.label)}px` }">{{ n.label }}</text>
|
<text :x="fitsPill(n.label) ? n.x : n.x - TNODE_W/2 + 8" :y="n.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(n.label) ? 'middle' : 'start' }">{{ n.label }}</text>
|
||||||
<text :x="n.x" :y="n.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">
|
<text :x="n.x" :y="n.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">
|
||||||
{{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }}
|
{{ countLabel(n) }}
|
||||||
</text>
|
</text>
|
||||||
|
</g>
|
||||||
</a>
|
</a>
|
||||||
<text v-if="n.crumb" :x="n.x" :y="n.y - TNODE_H/2 - 10"
|
|
||||||
:class="['tnodepath', n.path === '/' && 'tnodepath-home']">{{ n.crumb }}</text>
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</section>
|
</section>
|
||||||
@@ -206,7 +218,9 @@ const fitPx = (label) =>
|
|||||||
.tmap {
|
.tmap {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
/* max-width is set inline to the natural content width (px = viewBox
|
||||||
|
units), so wide panels never upscale the graph beyond 1:1. */
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.tmap .tconn {
|
.tmap .tconn {
|
||||||
fill: var(--accent);
|
fill: var(--accent);
|
||||||
@@ -226,36 +240,46 @@ const fitPx = (label) =>
|
|||||||
}
|
}
|
||||||
.tmap .txnode-source { fill: var(--text); }
|
.tmap .txnode-source { fill: var(--text); }
|
||||||
.tmap .txnode-exit { fill: var(--text); }
|
.tmap .txnode-exit { fill: var(--text); }
|
||||||
|
/* Branch lanes: one wide concentric arc per path prefix, running behind
|
||||||
|
the node pills around the fan's circle center; parent levels sit one
|
||||||
|
indent (radius step) outward. Each lane's label follows a short guide
|
||||||
|
arc across the first inter-node gap (the part pills never cover). */
|
||||||
.tmap .tarc {
|
.tmap .tarc {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--line);
|
stroke: var(--muted);
|
||||||
stroke-width: 1;
|
stroke-width: 16;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.tmap .tarc-top { stroke-width: 24; }
|
||||||
|
/* Lane labels are left-aligned: each guide arc starts just past the source
|
||||||
|
pill's edge, the earliest point where the text is visible. */
|
||||||
|
.tmap .tarclabel {
|
||||||
|
fill: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-anchor: start;
|
||||||
|
}
|
||||||
|
/* The top lane is 50% thicker; its 🏠︎ label scales along. */
|
||||||
|
.tmap .tarclabel-top {
|
||||||
|
font-size: 19.5px;
|
||||||
}
|
}
|
||||||
.tmap .tnode {
|
.tmap .tnode {
|
||||||
fill: var(--accent);
|
fill: var(--accent);
|
||||||
stroke: none;
|
stroke: none;
|
||||||
}
|
}
|
||||||
/* Text renders at a constant screen size: --u (set from JS) is the
|
/* Text sizes are viewBox units: they shrink along with the graph on
|
||||||
viewBox-unit → pixel ratio of the rendered svg, so dividing by it makes
|
narrow panels. Overlong labels are clipped at the pill border. */
|
||||||
the sizes independent of how far the graph is scaled down. */
|
|
||||||
.tmap .tnodeslug {
|
.tmap .tnodeslug {
|
||||||
fill: var(--bg, Canvas);
|
fill: var(--bg, Canvas);
|
||||||
font-size: calc(var(--slug-px, 15px) / var(--u, 1));
|
font-size: 19px;
|
||||||
text-anchor: middle;
|
text-anchor: start;
|
||||||
}
|
}
|
||||||
.tmap a { cursor: pointer; }
|
.tmap a { cursor: pointer; }
|
||||||
.tmap .tnodecount {
|
.tmap .tnodecount {
|
||||||
fill: var(--bg, Canvas);
|
fill: var(--bg, Canvas);
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
font-size: calc(13px / var(--u, 1));
|
font-size: 15px;
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
.tmap .tnodepath {
|
|
||||||
fill: var(--muted);
|
|
||||||
font-size: calc(11px / var(--u, 1));
|
|
||||||
text-anchor: middle;
|
|
||||||
}
|
|
||||||
.tmap .tnodepath-home { font-size: calc(17px / var(--u, 1)); }
|
|
||||||
|
|
||||||
section { margin-top: 1.8rem; }
|
section { margin-top: 1.8rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,10 +4,23 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { makeSeries } from './analytics/time.js'
|
import { makeSeries } from './analytics/time.js'
|
||||||
import { CHART_H, CHART_W, buildChart, fmtY } from './analytics/chart.js'
|
import {
|
||||||
|
CHART_H,
|
||||||
|
CHART_W,
|
||||||
|
MARGIN_B,
|
||||||
|
MARGIN_L,
|
||||||
|
VIEW_H,
|
||||||
|
VIEW_W,
|
||||||
|
buildChart,
|
||||||
|
} from './analytics/chart.js'
|
||||||
|
|
||||||
const DAY_REFRESH_MS = 15000
|
const DAY_REFRESH_MS = 15000
|
||||||
|
|
||||||
|
// Keep the whole svg within page bounds: full width below the natural
|
||||||
|
// size, centered with equal side margins above it (max() clamps the
|
||||||
|
// centering margin to 0 at the breakpoint, so the rule is continuous).
|
||||||
|
const CHART_MARGIN = `max(0px, calc(50% - ${VIEW_W / 2}px))`
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: { type: Object, default: null },
|
data: { type: Object, default: null },
|
||||||
range: { type: String, required: true },
|
range: { type: String, required: true },
|
||||||
@@ -29,6 +42,17 @@ function freqLabel(unit) {
|
|||||||
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
|
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vertical axis caption: "visits / 5 min" on the day view, else "hourly visits" style. */
|
||||||
|
function axisLabel(unit, ylabel) {
|
||||||
|
return unit === '5min' ? `${ylabel} / 5 min` : `${freqLabel(unit)} ${ylabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legend label for the overlaid past weeks: "Week M" or "Week M–N". */
|
||||||
|
function pastLabel(series) {
|
||||||
|
const oldest = series.at(-1).label.slice(5) // strip "Week "
|
||||||
|
return series.length > 2 ? `Week ${oldest}–${series[1].label.slice(5)}` : `Week ${oldest}`
|
||||||
|
}
|
||||||
|
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
let refreshInterval = null
|
let refreshInterval = null
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -44,16 +68,13 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-for="c in [
|
<section v-for="c in [
|
||||||
{ ylabel: 'visits', chart: visitChart, empty: 'no visits recorded yet' },
|
{ ylabel: 'visits', chart: visitChart, legend: true, empty: 'no visits recorded yet' },
|
||||||
{ ylabel: 'views', chart: viewChart, empty: 'no views recorded yet' },
|
{ ylabel: 'views', chart: viewChart, legend: false, empty: 'no views recorded yet' },
|
||||||
]" :key="c.ylabel">
|
]" :key="c.ylabel">
|
||||||
<template v-if="c.chart">
|
<template v-if="c.chart">
|
||||||
<div class="chartwrap">
|
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
|
||||||
<div class="plot">
|
:style="{ maxWidth: `${VIEW_W}px`, marginLeft: CHART_MARGIN }"
|
||||||
<div class="plotarea">
|
role="img" :aria-label="axisLabel(c.chart.unit, c.ylabel)">
|
||||||
<span class="yaxis-label">{{ freqLabel(c.chart.unit) }} {{ c.ylabel }}</span>
|
|
||||||
<svg class="chart" :viewBox="`0 0 ${CHART_W} ${CHART_H}`"
|
|
||||||
preserveAspectRatio="none" role="img" :aria-label="`${freqLabel(c.chart.unit)} ${c.ylabel}`">
|
|
||||||
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
|
<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" />
|
: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">
|
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
|
||||||
@@ -66,85 +87,68 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
<path :d="c.chart.skyline" class="line" />
|
<path :d="c.chart.skyline" class="line" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-for="(s, i) in c.chart.series" :key="i">
|
<!-- Oldest overlay weeks first so the current week paints on top. -->
|
||||||
|
<template v-for="(s, i) in [...c.chart.series].reverse()" :key="i">
|
||||||
<path v-if="s.area" :d="s.area" class="area" />
|
<path v-if="s.area" :d="s.area" class="area" />
|
||||||
<path :d="s.line" class="line" :style="{ opacity: s.opacity }" />
|
<path :d="s.line" class="line" :class="{ past: s.past }"
|
||||||
|
:style="{ opacity: s.opacity }" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
|
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
|
||||||
class="axis" />
|
class="axis" />
|
||||||
|
<text v-for="g in c.chart.majors" :key="'y' + g.value" x="-5" :y="g.y"
|
||||||
|
text-anchor="end" dominant-baseline="middle" class="ylab">{{ g.label }}</text>
|
||||||
|
<text :x="-(MARGIN_L - 10)" :y="CHART_H / 2" text-anchor="middle"
|
||||||
|
:transform="`rotate(-90 ${-(MARGIN_L - 10)} ${CHART_H / 2})`"
|
||||||
|
class="yaxis-label">{{ axisLabel(c.chart.unit, c.ylabel) }}</text>
|
||||||
|
<text v-for="t in c.chart.xticks" :key="'x' + t.x" :x="t.x" :y="CHART_H + MARGIN_B - 8"
|
||||||
|
text-anchor="middle" class="xlab">{{ t.label }}</text>
|
||||||
|
<!-- Week overlay legend, top right inside the plot: current week in
|
||||||
|
accent, one muted specimen for the whole past range. -->
|
||||||
|
<g v-if="c.legend && c.chart.series.length > 1">
|
||||||
|
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="10" y2="10" class="line" />
|
||||||
|
<text :x="CHART_W - 72" y="10" dominant-baseline="middle"
|
||||||
|
class="leglab">{{ c.chart.series[0].label }}</text>
|
||||||
|
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="25" y2="25"
|
||||||
|
class="line past" style="opacity: 0.6" />
|
||||||
|
<text :x="CHART_W - 72" y="25" dominant-baseline="middle"
|
||||||
|
class="leglab">{{ pastLabel(c.chart.series) }}</text>
|
||||||
|
</g>
|
||||||
</svg>
|
</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 && 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>
|
</template>
|
||||||
<p v-else class="empty">{{ c.empty }}</p>
|
<p v-else class="empty">{{ c.empty }}</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* The svg is stretched (preserveAspectRatio none), so all text lives in
|
/* Each chart is a self-contained SVG: the viewBox includes the axis label
|
||||||
HTML overlays positioned by the same fractions the geometry uses. */
|
margins, so nothing is positioned with HTML overlays. Never upscale past
|
||||||
.chartwrap {
|
the natural size (1 viewBox unit = 1 px, max-width set inline) — that
|
||||||
padding-left: 2.8rem; /* y labels */
|
would blow up the constant-size text; smaller panels still scale the
|
||||||
}
|
chart down to fit. The margin-left (set inline) centers the chart above
|
||||||
|
its natural width; the svg always stays within page bounds.
|
||||||
.plot {
|
overflow: visible lets wider fonts extend past the viewBox instead of
|
||||||
display: flex;
|
clipping. */
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plotarea {
|
|
||||||
position: relative;
|
|
||||||
height: 8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xlabels {
|
|
||||||
position: relative;
|
|
||||||
height: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ylab {
|
.chart .ylab,
|
||||||
position: absolute;
|
.chart .xlab,
|
||||||
left: -2.8rem;
|
.chart .yaxis-label,
|
||||||
width: 2.6rem;
|
.chart .leglab {
|
||||||
text-align: right;
|
font-family: system-ui, sans-serif; /* theme fonts can be overly styled */
|
||||||
transform: translateY(50%);
|
font-size: 11px;
|
||||||
font-size: 0.75rem;
|
fill: var(--muted);
|
||||||
color: var(--muted);
|
}
|
||||||
|
|
||||||
|
.chart .ylab {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xlab {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.25rem;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xlabels .xlab:first-child { transform: none; }
|
|
||||||
.xlabels .xlab:last-child { transform: translateX(-100%); }
|
|
||||||
|
|
||||||
.chart .minor {
|
.chart .minor {
|
||||||
stroke: var(--line);
|
stroke: var(--line);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
@@ -189,28 +193,10 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
/* Past overlay weeks contrast with the current week's accent color. */
|
||||||
display: flex;
|
.chart .line.past {
|
||||||
gap: 1.2rem;
|
stroke: var(--muted);
|
||||||
margin-top: 0.4rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend span { color: var(--accent); }
|
|
||||||
|
|
||||||
.yaxis-label {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: -2.8rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--muted);
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
white-space: nowrap;
|
|
||||||
transform: translateY(-50%) rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
section { margin-top: 1.8rem; }
|
|
||||||
|
|
||||||
.empty { color: var(--muted); }
|
.empty { color: var(--muted); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Chart geometry, smoothing, and SVG path generation for analytics charts.
|
* Chart geometry, smoothing, and SVG path generation for analytics charts.
|
||||||
*
|
*
|
||||||
* Fixed 720x180 viewBox, stretched to the panel width; values are per-unit
|
* Fixed 720x180 plot area inside a larger viewBox that also holds the axis
|
||||||
* rates (hour on the week view, day on month+).
|
* labels, so each chart SVG is self-contained; values are per-unit rates
|
||||||
|
* (hour on the week view, day on month+).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DAY, HOUR, MIN5, WEEK, mondayUTC } from './time.js'
|
import { DAY, HOUR, MIN5, WEEK, mondayUTC } from './time.js'
|
||||||
import { formatCount } from './format.js'
|
import { formatCount } from './format.js'
|
||||||
|
|
||||||
export const CHART_W = 720
|
export const CHART_W = 1000
|
||||||
export const CHART_H = 180
|
export const CHART_H = 150
|
||||||
export const PAD_TOP = 14 // room above the highest point
|
export const PAD_TOP = 14 // room above the highest point
|
||||||
|
export const MARGIN_L = 40 // y tick labels + vertical axis label
|
||||||
|
export const MARGIN_B = 24 // x tick labels
|
||||||
|
export const VIEW_W = MARGIN_L + CHART_W + 8
|
||||||
|
export const VIEW_H = CHART_H + MARGIN_B
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Y always starts at 0; the max is a multiple of a 1-2-5 major step with at
|
* Y always starts at 0; the max is a multiple of a 1-2-5 major step with at
|
||||||
@@ -37,24 +42,19 @@ export function yScale(maxValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edge-aware adaptive Gaussian smoothing. A change-point detector first
|
* Edge-aware Gaussian smoothing with a fixed bandwidth. A change-point
|
||||||
* finds traffic-level shifts (two-unit totals compared on both sides of
|
* detector first finds traffic-level shifts (two-unit totals compared on
|
||||||
* each bucket; strong ratio + significance marks a candidate, and each run
|
* both sides of each bucket; strong ratio + significance marks a candidate,
|
||||||
* of candidates keeps only its best-scoring bucket as an edge). Each
|
* and each run of candidates keeps only its best-scoring bucket as an
|
||||||
* edge-delimited segment is then smoothed independently: a broad two-unit
|
* edge). Each edge-delimited segment is then smoothed independently: every
|
||||||
* pilot estimates the local traffic rate, which ramps the Gaussian sigma
|
* bucket spreads its count with a fixed Gaussian sigma chosen so N events
|
||||||
* from ~0.4 units (isolated events stay narrow, peaking at ~1 event/unit)
|
* in a single bucket peak at N events per unit, clipped to the segment and
|
||||||
* up to 1 unit (busy traffic gets full smoothing), and every bucket spreads
|
* renormalized so total visitor count is preserved exactly. The unit is
|
||||||
* its count with its local sigma, clipped to the segment and renormalized
|
* one hour on the week view and one day on the month+ views, so the
|
||||||
* so total visitor count is preserved exactly. The unit is one hour on the
|
* smoothing time scale follows the range. The raw series is drawn faintly
|
||||||
* week view and one day on the month+ views, so the smoothing time scale
|
* behind the curve for reference. Operates on raw counts.
|
||||||
* 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, {
|
export function smooth(counts, binMinutes, unitMinutes, {
|
||||||
minSigmaMinutes = unitMinutes / Math.sqrt(2 * Math.PI),
|
|
||||||
maxSigmaMinutes = unitMinutes,
|
|
||||||
pilotSigmaMinutes = 2 * unitMinutes,
|
|
||||||
detectorWindowMinutes = 2 * unitMinutes,
|
detectorWindowMinutes = 2 * unitMinutes,
|
||||||
// Count thresholds are defined per hour and scale with the unit, so
|
// Count thresholds are defined per hour and scale with the unit, so
|
||||||
// "low traffic" means the same thing on hourly and daily views
|
// "low traffic" means the same thing on hourly and daily views
|
||||||
@@ -62,8 +62,6 @@ export function smooth(counts, binMinutes, unitMinutes, {
|
|||||||
highTrafficEvents = 10 * unitMinutes / 60,
|
highTrafficEvents = 10 * unitMinutes / 60,
|
||||||
minRatio = 2.5,
|
minRatio = 2.5,
|
||||||
minSignificance = 4,
|
minSignificance = 4,
|
||||||
sigmaRampStart = 5 * unitMinutes / 60,
|
|
||||||
sigmaRampEnd = 20 * unitMinutes / 60,
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const n = counts.length
|
const n = counts.length
|
||||||
if (!n) return counts
|
if (!n) return counts
|
||||||
@@ -105,68 +103,25 @@ export function smooth(counts, binMinutes, unitMinutes, {
|
|||||||
i = j
|
i = j
|
||||||
}
|
}
|
||||||
|
|
||||||
const reflectIndex = (i, length) => {
|
// Fixed sigma: N events in one bucket peak at N events per unit.
|
||||||
while (i < 0 || i >= length) {
|
// sigma_bins * sqrt(2*pi) = rate = unitMinutes / binMinutes.
|
||||||
i = i < 0 ? -i - 1 : 2 * length - i - 1
|
const sigmaBins = unitMinutes / (binMinutes * Math.sqrt(2 * Math.PI))
|
||||||
}
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
|
|
||||||
const gaussianFilterReflect = (values, sigmaBins) => {
|
|
||||||
const length = values.length
|
|
||||||
const radius = Math.ceil(4 * sigmaBins)
|
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
|
// Process each discontinuity-delimited regime independently so the
|
||||||
// the pilot nor the final Gaussian can see through a detected boundary.
|
// Gaussian cannot see through a detected boundary. Each input bin spreads
|
||||||
|
// its count with the fixed sigma; the kernel is renormalized after
|
||||||
|
// clipping to the segment, preserving total visitor count apart from
|
||||||
|
// floating-point error.
|
||||||
const bounds = [0, ...edges, n]
|
const bounds = [0, ...edges, n]
|
||||||
const smoothed = new Float64Array(n)
|
const smoothed = new Float64Array(n)
|
||||||
for (let b = 0; b < bounds.length - 1; b++) {
|
for (let b = 0; b < bounds.length - 1; b++) {
|
||||||
const lo = bounds[b]
|
const lo = bounds[b]
|
||||||
const length = bounds[b + 1] - lo
|
const length = bounds[b + 1] - lo
|
||||||
const segment = counts.slice(lo, lo + length)
|
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++) {
|
for (let j = 0; j < length; j++) {
|
||||||
const count = segment[j]
|
const count = segment[j]
|
||||||
if (!count) continue
|
if (!count) continue
|
||||||
const sigmaBins = sigmaMinutes[j] / binMinutes
|
|
||||||
const radius = Math.ceil(4 * sigmaBins)
|
|
||||||
const start = Math.max(0, j - radius)
|
const start = Math.max(0, j - radius)
|
||||||
const end = Math.min(length, j + radius + 1)
|
const end = Math.min(length, j + radius + 1)
|
||||||
let weightSum = 0
|
let weightSum = 0
|
||||||
@@ -240,7 +195,7 @@ export function buildChart(input, now = Date.now()) {
|
|||||||
const nMajor = Math.round(max / step)
|
const nMajor = Math.round(max / step)
|
||||||
for (let k = 0; k <= nMajor; k++) {
|
for (let k = 0; k <= nMajor; k++) {
|
||||||
const v = k * step
|
const v = k * step
|
||||||
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
|
majors.push({ value: v, y: y(v), label: fmtY(v) })
|
||||||
}
|
}
|
||||||
if (minor) {
|
if (minor) {
|
||||||
for (let v = minor; v < max; v += minor) {
|
for (let v = minor; v < max; v += minor) {
|
||||||
@@ -254,37 +209,37 @@ export function buildChart(input, now = Date.now()) {
|
|||||||
// ranges: boundary lines at Mondays / months / years.
|
// ranges: boundary lines at Mondays / months / years.
|
||||||
const isWeek = t1 - t0 === WEEK
|
const isWeek = t1 - t0 === WEEK
|
||||||
const isMonth = !isWeek && t1 - t0 <= 31 * DAY
|
const isMonth = !isWeek && t1 - t0 <= 31 * DAY
|
||||||
const xticks = isWeek
|
let xticks
|
||||||
? Array.from({ length: 7 }, (_, d) => {
|
if (isWeek) {
|
||||||
|
xticks = Array.from({ length: 7 }, (_, d) => {
|
||||||
const t = t0 + d * DAY + 12 * HOUR
|
const t = t0 + d * DAY + 12 * HOUR
|
||||||
return {
|
return {
|
||||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
x: x(t),
|
||||||
label: new Date(t).toLocaleDateString(undefined, {
|
label: new Date(t).toLocaleDateString(undefined, {
|
||||||
weekday: 'short', timeZone: 'UTC',
|
weekday: 'short', timeZone: 'UTC',
|
||||||
}),
|
}),
|
||||||
line: false,
|
line: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: isMonth
|
} else if (isMonth) {
|
||||||
? Array.from(
|
// t0 is day-aligned; label every day whose noon falls inside the range.
|
||||||
{ length: Math.floor((t1 - Math.ceil(t0 / DAY) * DAY) / DAY) },
|
xticks = []
|
||||||
(_, d) => {
|
for (let day = t0; day + 12 * HOUR < t1; day += DAY) {
|
||||||
const day = Math.ceil(t0 / DAY) * DAY + d * DAY
|
|
||||||
const date = new Date(day)
|
const date = new Date(day)
|
||||||
const t = day + 12 * HOUR
|
const t = day + 12 * HOUR
|
||||||
return {
|
xticks.push({
|
||||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
x: x(t),
|
||||||
label: date.getUTCDate() === 1
|
label: date.getUTCDate() === 1
|
||||||
? date.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
|
? date.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
|
||||||
: String(date.getUTCDate()),
|
: String(date.getUTCDate()),
|
||||||
line: false,
|
line: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
)
|
xticks = xticksFor(t0, t1).map((t) => ({
|
||||||
: xticksFor(t0, t1).map((t) => ({
|
x: x(t), label: fmtTick(t, t1 - t0), line: true,
|
||||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
|
||||||
label: fmtTick(t, t1 - t0), line: true,
|
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
return { max, majors, minors, series: drawn, xticks, unit }
|
return { max, majors, minors, series: drawn, xticks, unit }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +302,7 @@ export function buildDayChart(input, now = Date.now()) {
|
|||||||
const nMajor = Math.round(max / step)
|
const nMajor = Math.round(max / step)
|
||||||
for (let k = 0; k <= nMajor; k++) {
|
for (let k = 0; k <= nMajor; k++) {
|
||||||
const v = k * step
|
const v = k * step
|
||||||
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
|
majors.push({ value: v, y: y(v), label: fmtY(v) })
|
||||||
}
|
}
|
||||||
if (minor) {
|
if (minor) {
|
||||||
for (let v = minor; v < max; v += minor) {
|
for (let v = minor; v < max; v += minor) {
|
||||||
@@ -363,12 +318,10 @@ export function buildDayChart(input, now = Date.now()) {
|
|||||||
const d = new Date(t)
|
const d = new Date(t)
|
||||||
xticks.push({
|
xticks.push({
|
||||||
x: ((t - t0) / (t1 - t0)) * CHART_W,
|
x: ((t - t0) / (t1 - t0)) * CHART_W,
|
||||||
left: ((t - t0) / (t1 - t0)) * 100,
|
|
||||||
label: `${String(d.getUTCHours()).padStart(2, '0')}:00`,
|
label: `${String(d.getUTCHours()).padStart(2, '0')}:00`,
|
||||||
line: false,
|
line: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +339,7 @@ export function xticksFor(t0, t1) {
|
|||||||
if (span <= 4 * 365 * DAY) {
|
if (span <= 4 * 365 * DAY) {
|
||||||
const d = new Date(t0)
|
const d = new Date(t0)
|
||||||
let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)
|
let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)
|
||||||
for (; t <= t1; ) {
|
for (; t <= t1;) {
|
||||||
ticks.push(t)
|
ticks.push(t)
|
||||||
const m = new Date(t)
|
const m = new Date(t)
|
||||||
t = Date.UTC(m.getUTCFullYear(), m.getUTCMonth() + 1, 1)
|
t = Date.UTC(m.getUTCFullYear(), m.getUTCMonth() + 1, 1)
|
||||||
|
|||||||
@@ -240,6 +240,14 @@ export function formatWhenIso(ts) {
|
|||||||
return `${new Date(ts).toISOString().split('.')[0]}Z`
|
return `${new Date(ts).toISOString().split('.')[0]}Z`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact read time for tooltips: "50s" under a minute, "1m23s" otherwise.
|
||||||
|
*/
|
||||||
|
export function formatReadTime(seconds) {
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
return `${Math.floor(seconds / 60)}m${seconds % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact visitor counts: plain below 1k, then 1.2k / 10k / 1.2M.
|
* Compact visitor counts: plain below 1k, then 1.2k / 10k / 1.2M.
|
||||||
* Truncated, not rounded.
|
* Truncated, not rounded.
|
||||||
@@ -359,13 +367,16 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
|||||||
const start = new Date(c.start).getTime()
|
const start = new Date(c.start).getTime()
|
||||||
if (start > g.lastStart) g.lastStart = start
|
if (start > g.lastStart) g.lastStart = start
|
||||||
if (c.entry?.startsWith('/')) {
|
if (c.entry?.startsWith('/')) {
|
||||||
g.pages.set(c.entry, (g.pages.get(c.entry) || 0) + 1)
|
const existing = g.pages.get(c.entry) || { count: 0, status: c.status || 200 }
|
||||||
|
existing.count += 1
|
||||||
|
if (c.status != null) existing.status = c.status
|
||||||
|
g.pages.set(c.entry, existing)
|
||||||
}
|
}
|
||||||
groups.set(c.client, g)
|
groups.set(c.client, g)
|
||||||
}
|
}
|
||||||
const totalHits = (g) => {
|
const totalHits = (g) => {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (const c of g.pages.values()) n += c
|
for (const p of g.pages.values()) n += p.count
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
return [...groups.values()]
|
return [...groups.values()]
|
||||||
@@ -380,8 +391,8 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
|||||||
lastSeenIso: formatWhenIso(g.lastStart),
|
lastSeenIso: formatWhenIso(g.lastStart),
|
||||||
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].count - a[1].count)
|
||||||
.map(([path, count]) => ({ ...stepOf(path, titles), count })),
|
.map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })),
|
||||||
ip: client.ip || '',
|
ip: client.ip || '',
|
||||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||||
isHost,
|
isHost,
|
||||||
@@ -499,8 +510,17 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
|||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||||
const client = (clients || {})[v.client] || {}
|
const client = (clients || {})[v.client] || {}
|
||||||
|
const read = v.read || {}
|
||||||
|
const statuses = v.statuses || {}
|
||||||
const trail = [v.entry, ...(v.trail || [])]
|
const trail = [v.entry, ...(v.trail || [])]
|
||||||
.map((p) => stepOf(p, titles))
|
.map((p) => {
|
||||||
|
const step = stepOf(p, titles)
|
||||||
|
if (step) {
|
||||||
|
if (read[p]) step.readSeconds = read[p]
|
||||||
|
if (statuses[p]) step.status = statuses[p]
|
||||||
|
}
|
||||||
|
return step
|
||||||
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
||||||
const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean)
|
const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean)
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export function mondayUTC(t) {
|
|||||||
return (d - ((d + 3) % 7)) * DAY
|
return (d - ((d + 3) % 7)) * DAY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ISO 8601 week number of the week containing t (via its Thursday). */
|
||||||
|
export function isoWeek(t) {
|
||||||
|
const d = new Date(t)
|
||||||
|
d.setUTCHours(0, 0, 0, 0)
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7))
|
||||||
|
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
|
||||||
|
return Math.ceil(((d - yearStart) / DAY + 1) / 7)
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse sparse timestamp buckets into a { epochMs: count } map. */
|
/** Parse sparse timestamp buckets into a { epochMs: count } map. */
|
||||||
export function rawTimes(buckets) {
|
export function rawTimes(buckets) {
|
||||||
const raw = {}
|
const raw = {}
|
||||||
@@ -44,7 +53,9 @@ export function sumRange(raw, t0, t1) {
|
|||||||
/**
|
/**
|
||||||
* One series per overlaid week: [this week, 1 week ago, ...], at native
|
* 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
|
* 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
|
* recorded data at all). Each older week's timestamps are shifted forward
|
||||||
|
* onto the current week's axis so all curves overlay inside the plot.
|
||||||
|
* The current week is truncated at the current bucket
|
||||||
* — no fake zeroes drawn for the future. Counts are rates per hour
|
* — 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".
|
* (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).
|
* The coarser ranges use per-day rates instead (unitMinutes = 24*60).
|
||||||
@@ -67,12 +78,13 @@ export function weeklySeries(buckets) {
|
|||||||
: start + WEEK
|
: start + WEEK
|
||||||
const points = []
|
const points = []
|
||||||
for (let t = start; t < end; t += MIN5) {
|
for (let t = start; t < end; t += MIN5) {
|
||||||
points.push({ t, count: raw[t] || 0 })
|
points.push({ t: t + back * WEEK, count: raw[t] || 0 })
|
||||||
}
|
}
|
||||||
out.push({
|
out.push({
|
||||||
points,
|
points,
|
||||||
label: back === 0 ? 'this week' : `${back}w ago`,
|
label: `Week ${isoWeek(start)}`,
|
||||||
opacity: Math.max(0.15, 1 - back * 0.25),
|
opacity: Math.max(0.15, 1 - back * 0.25),
|
||||||
|
past: back > 0,
|
||||||
area: back === 0,
|
area: back === 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -92,27 +104,38 @@ export function weeklySeries(buckets) {
|
|||||||
* to per-day rates (the unit the month+ charts are read in).
|
* to per-day rates (the unit the month+ charts are read in).
|
||||||
* Ranges without a fixed span use the full data reach, but never less than
|
* Ranges without a fixed span use the full data reach, but never less than
|
||||||
* their configured minSpan so the chart keeps a readable minimum x scale.
|
* their configured minSpan so the chart keeps a readable minimum x scale.
|
||||||
|
* t0 is aligned to the UTC day so the x labels cover the whole range;
|
||||||
|
* t1 is now, so the scale never extends into the future. The bucket size
|
||||||
|
* follows the resulting window (6h up to 31 days, daily beyond), so ranges
|
||||||
|
* covering the same window — "all" at its 30-day minimum vs "month" —
|
||||||
|
* render the identical curve.
|
||||||
*/
|
*/
|
||||||
export function rollingSeries(buckets, rangeKey) {
|
export function rollingSeries(buckets, rangeKey) {
|
||||||
const raw = rawTimes(buckets)
|
const raw = rawTimes(buckets)
|
||||||
const times = Object.keys(raw).map(Number)
|
const times = Object.keys(raw).map(Number)
|
||||||
if (!times.length) return null
|
if (!times.length) return null
|
||||||
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
|
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
|
||||||
const t1 = Math.floor(Date.now() / bucket) * bucket + bucket
|
const t1 = Date.now()
|
||||||
const earliest = Math.floor(Math.min(...times) / bucket) * bucket
|
const earliest = Math.min(...times)
|
||||||
const t0 = span != null
|
const t0 = Math.floor((span != null
|
||||||
? t1 - span
|
? t1 - span
|
||||||
: Math.min(earliest, t1 - minSpan)
|
: Math.min(earliest, t1 - minSpan)) / DAY) * DAY
|
||||||
|
// The bucket follows the actual window length, not the range key: when
|
||||||
|
// "all" is capped to its 30-day minimum it covers the very window "month"
|
||||||
|
// does, and daily bins would draw a different curve over the same data
|
||||||
|
// (coarser edge detection, points a day apart plotted at bin starts, the
|
||||||
|
// last point stuck at today's midnight instead of reaching now).
|
||||||
|
const bucketMs = t1 - t0 <= 31 * DAY ? Math.min(bucket, 6 * HOUR) : bucket
|
||||||
const points = []
|
const points = []
|
||||||
for (let t = t0; t < t1; t += bucket) {
|
for (let t = t0; t < t1; t += bucketMs) {
|
||||||
points.push({ t, count: sumRange(raw, t, t + bucket) })
|
points.push({ t, count: sumRange(raw, t, t + bucketMs) })
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
series: [{ points, label: '', opacity: 1, area: true }],
|
series: [{ points, label: '', opacity: 1, area: true }],
|
||||||
t0,
|
t0,
|
||||||
t1,
|
t1,
|
||||||
rate: DAY / bucket,
|
rate: DAY / bucketMs,
|
||||||
binMinutes: bucket / 60e3,
|
binMinutes: bucketMs / 60e3,
|
||||||
unitMinutes: 24 * 60,
|
unitMinutes: 24 * 60,
|
||||||
unit: 'day',
|
unit: 'day',
|
||||||
}
|
}
|
||||||
@@ -152,19 +175,64 @@ export function makeSeries(buckets, rangeKey) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Absolute UTC time window for a given range key. Used to filter visits,
|
* Absolute UTC time window for a given range key. Used to filter visits,
|
||||||
* transitions and views to the same period the charts are showing.
|
* transitions and views for the non-chart stats on the analytics page.
|
||||||
|
* Every bounded range is a rolling span ending at now; the charts instead
|
||||||
|
* align week to Monday 00:00 UTC (overlaying previous weeks) and month+
|
||||||
|
* to UTC day boundaries, so their x windows differ from the stats range
|
||||||
|
* on purpose.
|
||||||
* Returns { t0, t1 } where null means unbounded.
|
* Returns { t0, t1 } where null means unbounded.
|
||||||
*/
|
*/
|
||||||
export function rangeWindow(rangeKey) {
|
export function rangeWindow(rangeKey) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (rangeKey === 'week') {
|
|
||||||
const start = mondayUTC(now)
|
|
||||||
return { t0: start, t1: start + WEEK }
|
|
||||||
}
|
|
||||||
if (rangeKey === 'all') {
|
if (rangeKey === 'all') {
|
||||||
return { t0: null, t1: null }
|
return { t0: null, t1: null }
|
||||||
}
|
}
|
||||||
const { span, bucket } = RANGES[rangeKey]
|
const span = rangeKey === 'week' ? WEEK : RANGES[rangeKey].span
|
||||||
const t1 = Math.floor(now / bucket) * bucket + bucket
|
return { t0: now - span, t1: now }
|
||||||
return { t0: t1 - span, t1 }
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum the bucketed transition matrix (from -> to -> bucket ISO -> count)
|
||||||
|
* into a plain from -> to -> count matrix for the window [t0, t1).
|
||||||
|
*/
|
||||||
|
export function filterTransitionsByRange(transitions, t0, t1) {
|
||||||
|
const out = {}
|
||||||
|
for (const [fr, tos] of Object.entries(transitions || {})) {
|
||||||
|
for (const [to, buckets] of Object.entries(tos)) {
|
||||||
|
let n = 0
|
||||||
|
for (const [k, c] of Object.entries(buckets)) {
|
||||||
|
const t = Date.parse(k)
|
||||||
|
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) n += c
|
||||||
|
}
|
||||||
|
if (n) {
|
||||||
|
out[fr] = out[fr] || {}
|
||||||
|
out[fr][to] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only records whose start time falls inside [t0, t1). */
|
||||||
|
export function filterRecordsByRange(records, t0, t1) {
|
||||||
|
const out = []
|
||||||
|
for (const r of records || []) {
|
||||||
|
const t = Date.parse(r.start)
|
||||||
|
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out.push(r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,10 @@
|
|||||||
* referer, then other utm_* tags. Visits with a UTM tag are grouped under
|
* referer, then other utm_* tags. Visits with a UTM tag are grouped under
|
||||||
* that tag's value, not under the referer domain. A UTM source node only
|
* that tag's value, not under the referer domain. A UTM source node only
|
||||||
* becomes a clickable link when every visit carrying that UTM tag came
|
* becomes a clickable link when every visit carrying that UTM tag came
|
||||||
* from the same referer. External exits are full-size nodes just outside
|
* from the same referer. External exits are full-size nodes in a row below
|
||||||
* their source page, angled away from the center. Each distinct full exit
|
* the map, mirroring the source row, so the site itself stays in the
|
||||||
* URL is its own node. Self-loops (reload pings) are skipped.
|
* middle. Each distinct full exit URL is its own node. Self-loops (reload
|
||||||
|
* pings) are skipped.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MIN_READ_SECONDS } from './format.js'
|
import { MIN_READ_SECONDS } from './format.js'
|
||||||
@@ -123,27 +124,29 @@ const pillTangent = (s, margin = 0) => {
|
|||||||
|
|
||||||
// Edge width (half-width of the thin middle) grows logarithmically with
|
// Edge width (half-width of the thin middle) grows logarithmically with
|
||||||
// the count. The constants are scaled down by ~10× so busy ranges (day,
|
// the count. The constants are scaled down by ~10× so busy ranges (day,
|
||||||
// year) do not overwhelm the graph with fat connectors. A single recorded
|
// year) do not overwhelm the graph with fat connectors. Connections whose
|
||||||
// transition still renders as a faint ~0.4 px line. Connections carrying
|
// thin middle would render below MIN_WMID are culled entirely: fainter
|
||||||
// less than PRUNE_FRACTION of the total traffic are not drawn at all (this
|
// strands are practically invisible and only their wide end flares would
|
||||||
// also keeps the graph under ~100 connections).
|
// show. Connections carrying less than PRUNE_FRACTION of the total traffic
|
||||||
|
// are likewise not drawn (this also keeps the graph under ~100 connections).
|
||||||
const WMID_MIN = 0.2
|
const WMID_MIN = 0.2
|
||||||
const WIDTH_GROWTH = 0.15
|
const WIDTH_GROWTH = 0.15
|
||||||
const PRUNE_FRACTION = 0.01
|
const PRUNE_FRACTION = 0.01
|
||||||
|
// ~0.8 px full width at natural size (1 viewBox unit = 1 px).
|
||||||
|
const MIN_WMID = 0.4
|
||||||
|
|
||||||
// Beads: each edge direction emits beads at count * BEAD_RATE beads per
|
// Beads: each edge direction emits beads at count * BEAD_RATE beads per
|
||||||
// second (linear in the count). The rate is reduced ~10× across all time
|
// second (linear in the count). The rate is reduced ~10× across all time
|
||||||
// scales to keep the animation lightweight. The component simulates every
|
// scales to keep the animation lightweight. The component simulates every
|
||||||
// bead independently in JS at BEAD_SPEED along the edge, with no limit on
|
// bead independently in JS with a constant traversal time per edge (speed
|
||||||
// beads in flight.
|
// relative to span length), with no limit on beads in flight.
|
||||||
export const BEAD_SPEED = 180 // svg units per second
|
|
||||||
export const BEAD_R = 3.2
|
export const BEAD_R = 3.2
|
||||||
const BEAD_RATE = 0.012 // beads per second per recorded transition
|
const BEAD_RATE = 0.012 // beads per second per recorded transition
|
||||||
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
|
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
|
||||||
|
|
||||||
const MAX_EXT_IN = 8 // referer nodes in the top row
|
const MAX_EXT_IN = 8 // referer nodes in the top row
|
||||||
const MAX_EXT_OUT = 12 // exit nodes, at most MAX_EXT_OUT_PER_PAGE per page
|
const MAX_EXT_OUT = 12 // exit nodes in the bottom row
|
||||||
const MAX_EXT_OUT_PER_PAGE = 3
|
const EXT_GAP = 12 // vertical margin of the source/exit rows to the map
|
||||||
|
|
||||||
/** Flatten the site tree into navigation order via DFS. */
|
/** Flatten the site tree into navigation order via DFS. */
|
||||||
function buildNavigationOrder(pageTree) {
|
function buildNavigationOrder(pageTree) {
|
||||||
@@ -184,14 +187,13 @@ function collectInternalTransitions(transitions) {
|
|||||||
return internal
|
return internal
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Domain-only label for an external origin (path and www. removed). */
|
/** Domain-only label for an external origin (path and www. removed).
|
||||||
|
* Pills clip the text at their border; no length cap needed. */
|
||||||
function extLabel(ext) {
|
function extLabel(ext) {
|
||||||
try {
|
try {
|
||||||
const host = new URL(ext).hostname.replace(/^www\./, '')
|
return new URL(ext).hostname.replace(/^www\./, '')
|
||||||
return host.length > 25 ? `${host.slice(0, 24)}…` : host
|
|
||||||
} catch {
|
} catch {
|
||||||
const s = ext.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]
|
return ext.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]
|
||||||
return s.length > 25 ? `${s.slice(0, 24)}…` : s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,17 +288,9 @@ function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
|||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
n.views = viewCount(n.path)
|
n.views = viewCount(n.path)
|
||||||
n.readMin = readMinutes[n.path] || 0
|
n.readMin = readMinutes[n.path] || 0
|
||||||
// Article title inside the pill (shortened with ellipsis as needed),
|
// Article title inside the pill (clipped at the pill border on
|
||||||
// slug as fallback for pages missing from the site tree. The short
|
// render), slug as fallback for pages missing from the site tree.
|
||||||
// path (last two segments, no leading /) renders above the pill; the
|
n.label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
|
||||||
// front page shows a home symbol there instead (larger).
|
|
||||||
const slug = n.path.split('/').pop()
|
|
||||||
const label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : slug)
|
|
||||||
n.label = label.length > 24 ? `${label.slice(0, 23)}…` : label
|
|
||||||
const segs = n.path.split('/').filter(Boolean)
|
|
||||||
n.crumb = n.path === '/'
|
|
||||||
? '🏠︎'
|
|
||||||
: segs.length > 2 ? `…/${segs.slice(-2).join('/')}` : segs.join('/')
|
|
||||||
n.title = titles.get(n.path) || ''
|
n.title = titles.get(n.path) || ''
|
||||||
// Category (non-leaf) pages with no views in this window are omitted:
|
// Category (non-leaf) pages with no views in this window are omitted:
|
||||||
// their children move up in their place (see layoutGroups).
|
// their children move up in their place (see layoutGroups).
|
||||||
@@ -310,15 +304,13 @@ function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
|||||||
* the row following a shallow circular sag (center lowest) so connections
|
* the row following a shallow circular sag (center lowest) so connections
|
||||||
* between neighbors do not overlap the pills in between. Each top item's
|
* between neighbors do not overlap the pills in between. Each top item's
|
||||||
* whole subtree fans out from it in menu (DFS preorder) order along a
|
* whole subtree fans out from it in menu (DFS preorder) order along a
|
||||||
* parabola that leaves the parent heading straight down and gradually
|
* large-radius circular arc that leaves the parent heading straight down
|
||||||
* bends to the right — no horizontal space is reserved for fans, they
|
* and gradually bends to the right — no horizontal space is reserved for fans, they
|
||||||
* extend under the slots to their right. Hidden index pages are omitted
|
* extend under the slots to their right. Hidden index pages are omitted
|
||||||
* from the fan; when the top item itself is hidden, the fan shifts one
|
* from the fan; when the top item itself is hidden, the fan shifts one
|
||||||
* slot up, the first visible child taking the top position. The short
|
* slot up, the first visible child taking the top position. Branch lanes
|
||||||
* path shown above each pill (last two segments) keeps the omitted menu
|
* labeled with the branch slug (see the branch-lane pass at the end)
|
||||||
* level visible.
|
* keep the omitted menu levels visible.
|
||||||
* Also returns curved spoke paths tracing each fan: top slot to first
|
|
||||||
* member, then member to member in menu order, each bowed to the right.
|
|
||||||
*/
|
*/
|
||||||
function layoutGroups(root) {
|
function layoutGroups(root) {
|
||||||
// Top slots are spaced well over one pill width apart regardless of
|
// Top slots are spaced well over one pill width apart regardless of
|
||||||
@@ -328,8 +320,10 @@ function layoutGroups(root) {
|
|||||||
|
|
||||||
// First pass: visible members per group, in menu order. Hidden index
|
// First pass: visible members per group, in menu order. Hidden index
|
||||||
// pages are skipped, but their children still appear. The front page
|
// pages are skipped, but their children still appear. The front page
|
||||||
// forms its own group.
|
// forms its own group. groupRoots keeps each group's subtree root for
|
||||||
|
// the branch-curve pass below.
|
||||||
const groups = []
|
const groups = []
|
||||||
|
const groupRoots = []
|
||||||
for (const g of [root, ...root.children]) {
|
for (const g of [root, ...root.children]) {
|
||||||
const members = []
|
const members = []
|
||||||
if (g === root) {
|
if (g === root) {
|
||||||
@@ -341,53 +335,139 @@ function layoutGroups(root) {
|
|||||||
}
|
}
|
||||||
walk(g)
|
walk(g)
|
||||||
}
|
}
|
||||||
if (members.length) groups.push(members)
|
if (members.length) {
|
||||||
|
groups.push(members)
|
||||||
|
groupRoots.push(g)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top row on a true circular sag: center lowest, edges raised by SAG.
|
// Top row on a large-radius circular arc whose bottom point is the
|
||||||
|
// LAST top item: each earlier item sits a bit higher (drop = 15% of
|
||||||
|
// the row span). Flat row when there is a single group.
|
||||||
const half = ((groups.length - 1) * SLOT) / 2 || 1
|
const half = ((groups.length - 1) * SLOT) / 2 || 1
|
||||||
const SAG = TNODE_H * 0.6
|
const span = (groups.length - 1) * SLOT
|
||||||
const Rc = (half * half + SAG * SAG) / (2 * SAG)
|
const topD = span * 0.15
|
||||||
const topY = (x) => SAG - Rc + Math.sqrt(Rc * Rc - x * x)
|
const R_T = span ? (span * span + topD * topD) / (2 * topD) : 0
|
||||||
|
const topY = span
|
||||||
|
? (x) => topD - R_T + Math.sqrt(R_T * R_T - (x - half) * (x - half))
|
||||||
|
: () => 0
|
||||||
|
|
||||||
// Second pass: place groups. Fan members follow a right-opening cubic
|
// Second pass: place groups. Fan members follow a circular arc of
|
||||||
// p(t) = (gx + B t³, y0 + t): the tangent stays vertical near the
|
// large radius FAN_R centered at (gx + FAN_R, y0): the trail leaves
|
||||||
// parent (leaving almost straight down) and bends right gently,
|
// the top node heading straight down (vertical tangent) and bends
|
||||||
// reaching ~50° from vertical at the last member. Member spacing along
|
// right gently, member i at arc angle π − i·CLEAR/FAN_R (spaced by
|
||||||
// the curve is the pill clearance (dt integrated against curve speed).
|
// arc length CLEAR). A circle — not a spline — so the branch lanes
|
||||||
const spokePairs = [] // [from node, to node] — paths emitted below
|
// below can be concentric arcs: identical forms, only radii differ.
|
||||||
|
const FAN_R = 1000
|
||||||
groups.forEach((members, gi) => {
|
groups.forEach((members, gi) => {
|
||||||
const gx = gi * SLOT - half
|
const gx = gi * SLOT - half
|
||||||
const y0 = topY(gx)
|
const y0 = topY(gx)
|
||||||
members[0].x = gx
|
members[0].x = gx
|
||||||
members[0].y = y0
|
members[0].y = y0
|
||||||
const m = members.length - 1
|
for (let i = 1; i < members.length; i++) {
|
||||||
if (!m) return
|
const th = Math.PI - (i * CLEAR) / FAN_R
|
||||||
const tMax = m * CLEAR * 0.9
|
members[i].x = gx + FAN_R * (1 + Math.cos(th))
|
||||||
const B = 0.4 / (tMax * tMax)
|
members[i].y = y0 + FAN_R * Math.sin(th)
|
||||||
let t = 0
|
|
||||||
for (let i = 1; i <= m; i++) {
|
|
||||||
const bend = 3 * B * t * t
|
|
||||||
t += CLEAR / Math.hypot(bend, 1)
|
|
||||||
const n = members[i]
|
|
||||||
n.x = gx + B * t * t * t
|
|
||||||
n.y = y0 + t
|
|
||||||
spokePairs.push([members[i - 1], n])
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fan spokes bow to the right via a quadratic control point pushed
|
// Branch lanes: one wide arc per path prefix (slug depth ≥ 1) whose
|
||||||
// rightward from the segment midpoint.
|
// subtree holds at least two visible nodes (a branch's visible nodes
|
||||||
const spokes = spokePairs.map(([p, n]) => {
|
// form one contiguous run in the fan's DFS preorder). Every lane of a
|
||||||
const mx = (p.x + n.x) / 2
|
// group is an arc around the group's fan center with a radius one
|
||||||
const my = (p.y + n.y) / 2
|
// INDENT larger per parent level — concentric circles, so all lanes
|
||||||
const bow = Math.hypot(n.x - p.x, n.y - p.y) * 0.18
|
// share exactly one form. Lanes span their branch's nodes plus a
|
||||||
return {
|
// little extra tucked under the first/last pill (so the line caps are
|
||||||
d: `M ${p.x.toFixed(2)} ${p.y.toFixed(2)} `
|
// never visible) and run behind the pills. A label arc carries the
|
||||||
+ `Q ${(mx + bow).toFixed(2)} ${my.toFixed(2)} ${n.x.toFixed(2)} ${n.y.toFixed(2)}`,
|
// branch slug, left-aligned just past the first pill and free to run
|
||||||
|
// to the lane's end — longer text simply passes under later pills,
|
||||||
|
// which are drawn on top. Hidden (unplaced) index pages still
|
||||||
|
// define a lane: it follows their promoted children, so lanes reflect
|
||||||
|
// the path structure rather than page existence.
|
||||||
|
const INDENT = 20 // lane spacing (radius) per nesting level (> lane width)
|
||||||
|
const END_TUCK = 22 // arc units tucked under the first/last pill
|
||||||
|
const GAP_TRIM = 32 // label arc clearance from the pills
|
||||||
|
// Labels are left-aligned on their guide: the guide starts just past the
|
||||||
|
// source pill's edge, the earliest point where the text is visible.
|
||||||
|
const LABEL_PAD = 6
|
||||||
|
// The label guide rides GUIDE_OFF outward of the lane centerline: the
|
||||||
|
// text's alphabetic baseline sits on the guide, so this puts the
|
||||||
|
// glyph middle (not the baseline) on the lane center at any zoom —
|
||||||
|
// dominant-baseline tricks are em-based and break under downscale.
|
||||||
|
const GUIDE_OFF = 3.5
|
||||||
|
const branches = []
|
||||||
|
groups.forEach((members, gi) => {
|
||||||
|
const g = groupRoots[gi]
|
||||||
|
if (g === root || members.length < 2) return
|
||||||
|
const idx = new Map(members.map((n, i) => [n, i]))
|
||||||
|
const C = [gi * SLOT - half + FAN_R, topY(gi * SLOT - half)]
|
||||||
|
const walk = (n) => {
|
||||||
|
let first = Infinity
|
||||||
|
let last = -1
|
||||||
|
const span = (m) => {
|
||||||
|
const k = idx.get(m)
|
||||||
|
if (k !== undefined) {
|
||||||
|
first = Math.min(first, k)
|
||||||
|
last = Math.max(last, k)
|
||||||
}
|
}
|
||||||
|
m.children.forEach(span)
|
||||||
|
}
|
||||||
|
span(n)
|
||||||
|
if (n.depth >= 1 && last > first) {
|
||||||
|
branches.push({ depth: n.depth, name: n.path.split('/').pop(), C, first, last })
|
||||||
|
}
|
||||||
|
n.children.forEach(walk)
|
||||||
|
}
|
||||||
|
walk(g)
|
||||||
})
|
})
|
||||||
return { GAP: TNODE_H * 2.6, spokes }
|
const depthMax = branches.reduce((d, b) => Math.max(d, b.depth), 1)
|
||||||
|
let arcLeft = Infinity // leftmost lane point, for the bounding box
|
||||||
|
const arcs = branches.map(({ depth, name, C, first, last }) => {
|
||||||
|
const R = FAN_R + (depthMax - depth) * INDENT
|
||||||
|
const th = (i) => Math.PI - (i * CLEAR) / FAN_R
|
||||||
|
const pt = (a, r) => [C[0] + r * Math.cos(a), C[1] + r * Math.sin(a)]
|
||||||
|
// Arc from angle a down to angle b (a > b; visually counterclockwise
|
||||||
|
// from the west point downward, hence sweep flag 0).
|
||||||
|
const arc = (a, b, r) => {
|
||||||
|
const [x0, y0] = pt(a, r)
|
||||||
|
const [x1, y1] = pt(b, r)
|
||||||
|
return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 0 ${x1.toFixed(2)} ${y1.toFixed(2)}`
|
||||||
|
}
|
||||||
|
const d = arc(th(first) + END_TUCK / R, th(last) - END_TUCK / R, R)
|
||||||
|
// Start just past the first pill: lanes leave the source node nearly
|
||||||
|
// vertically, so the pill's extent along the arc is its half height.
|
||||||
|
// The guide runs to the lane's end so long slugs are never cut off.
|
||||||
|
const ld = arc(th(first) - (TNODE_H / 2 + LABEL_PAD) / R,
|
||||||
|
th(last) - END_TUCK / R, R + GUIDE_OFF)
|
||||||
|
arcLeft = Math.min(arcLeft, pt(th(first) + END_TUCK / R, R)[0])
|
||||||
|
return { d, ld, label: name }
|
||||||
|
})
|
||||||
|
// Top lane: an arc along the top row's own circle, connecting
|
||||||
|
// the top nodes of all groups and tucked under the first and last of
|
||||||
|
// them (the arc bottoms at the last item, so it continues rightward
|
||||||
|
// under its pill). Drawn 50% thicker than branch lanes. A 🏠︎ label
|
||||||
|
// marks the lane right after the home pill, on a guide arc like
|
||||||
|
// the branch labels but with the offset and clearance scaled up by the
|
||||||
|
// same 50% to keep the glyph centered on the wider lane.
|
||||||
|
if (span) {
|
||||||
|
const d = `M ${(-half - END_TUCK).toFixed(2)} ${topY(-half - END_TUCK).toFixed(2)} `
|
||||||
|
+ `A ${R_T.toFixed(2)} ${R_T.toFixed(2)} 0 0 0 ${(half + END_TUCK).toFixed(2)} ${topY(half + END_TUCK).toFixed(2)}`
|
||||||
|
const rG = R_T + GUIDE_OFF * 1.5
|
||||||
|
const ptG = (x) => [x, topD - R_T + Math.sqrt(rG * rG - (x - half) ** 2)]
|
||||||
|
// Left-aligned like the branch labels: the guide starts just past the
|
||||||
|
// home pill's edge (scaled with the lane thickness).
|
||||||
|
const g0 = -half + TNODE_W / 2 + LABEL_PAD * 1.5
|
||||||
|
const g1 = SLOT - half - TNODE_W / 2 - GAP_TRIM * 1.5
|
||||||
|
const [gx0, gy0] = ptG(g0)
|
||||||
|
const [gx1, gy1] = ptG(g1)
|
||||||
|
arcs.unshift({
|
||||||
|
d,
|
||||||
|
ld: `M ${gx0.toFixed(2)} ${gy0.toFixed(2)} A ${rG.toFixed(2)} ${rG.toFixed(2)} 0 0 0 ${gx1.toFixed(2)} ${gy1.toFixed(2)}`,
|
||||||
|
label: '🏠︎',
|
||||||
|
top: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { arcs, arcLeft }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collapse opposite transition directions into one unordered pair per page pair. */
|
/** Collapse opposite transition directions into one unordered pair per page pair. */
|
||||||
@@ -573,16 +653,18 @@ function buildFlows(a, b, ab, ba, visualScale = 1) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const flows = []
|
const flows = []
|
||||||
if (ab) flows.push(flow(ab, t0, t1))
|
// Stable key per edge direction so the component's bead simulation can
|
||||||
if (ba) flows.push(flow(ba, t1, t0))
|
// match flows across data reloads and keep bead phases/positions.
|
||||||
|
if (ab) flows.push({ ...flow(ab, t0, t1), key: `${a.path} ${b.path}` })
|
||||||
|
if (ba) flows.push({ ...flow(ba, t1, t0), key: `${b.path} ${a.path}` })
|
||||||
return flows
|
return flows
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Half-width for a connection middle: logarithmic in the count, anchored
|
* Half-width for a connection middle: logarithmic in the count, anchored
|
||||||
* so a single count lands exactly at WMID_MIN (~1 px line), uncapped.
|
* at WMID_MIN, uncapped. Absolute on purpose — cool routes stay visible
|
||||||
* Absolute on purpose — cool routes stay visible regardless of how hot
|
* regardless of how hot the hottest connection is. Callers cull results
|
||||||
* the hottest connection is.
|
* below MIN_WMID.
|
||||||
*/
|
*/
|
||||||
const scaledWidth = (count) => {
|
const scaledWidth = (count) => {
|
||||||
if (count <= 0) return 0
|
if (count <= 0) return 0
|
||||||
@@ -608,59 +690,13 @@ function buildInternalEdges(pairs, byPath, visualScale = 1) {
|
|||||||
const b = byPath.get(pt)
|
const b = byPath.get(pt)
|
||||||
if (a.hidden || b.hidden) continue // unplaced index pages are omitted
|
if (a.hidden || b.hidden) continue // unplaced index pages are omitted
|
||||||
const wMid = scaledWidth((ab + ba) * visualScale)
|
const wMid = scaledWidth((ab + ba) * visualScale)
|
||||||
if (wMid <= 0) continue
|
if (wMid < MIN_WMID) continue
|
||||||
edges.push(buildRibbon(a, b, ab, ba, wMid))
|
edges.push(buildRibbon(a, b, ab, ba, wMid))
|
||||||
flows.push(...buildFlows(a, b, ab, ba, visualScale))
|
flows.push(...buildFlows(a, b, ab, ba, visualScale))
|
||||||
}
|
}
|
||||||
return { edges, flows }
|
return { edges, flows }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sum the bucketed transition matrix (from -> to -> bucket ISO -> count)
|
|
||||||
* into a plain from -> to -> count matrix for the window [t0, t1).
|
|
||||||
*/
|
|
||||||
export function filterTransitionsByRange(transitions, t0, t1) {
|
|
||||||
const out = {}
|
|
||||||
for (const [fr, tos] of Object.entries(transitions || {})) {
|
|
||||||
for (const [to, buckets] of Object.entries(tos)) {
|
|
||||||
let n = 0
|
|
||||||
for (const [k, c] of Object.entries(buckets)) {
|
|
||||||
const t = Date.parse(k)
|
|
||||||
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) n += c
|
|
||||||
}
|
|
||||||
if (n) {
|
|
||||||
out[fr] = out[fr] || {}
|
|
||||||
out[fr][to] = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep only visits whose start time falls inside [t0, t1). */
|
|
||||||
export function filterVisitsByRange(visits, t0, t1) {
|
|
||||||
const out = []
|
|
||||||
for (const v of visits || []) {
|
|
||||||
const t = Date.parse(v.start)
|
|
||||||
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out.push(v)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const UTM_PRIORITY = ['utm_campaign', 'utm_source']
|
const UTM_PRIORITY = ['utm_campaign', 'utm_source']
|
||||||
const UTM_FALLBACK = ['utm_medium', 'utm_content', 'utm_term', 'utm_id']
|
const UTM_FALLBACK = ['utm_medium', 'utm_content', 'utm_term', 'utm_id']
|
||||||
|
|
||||||
@@ -726,11 +762,13 @@ function collectSourcePairs(visits) {
|
|||||||
* flows.
|
* flows.
|
||||||
* Sources (incoming links) are derived from visit UTM/referer data and form
|
* Sources (incoming links) are derived from visit UTM/referer data and form
|
||||||
* a row centered above the map, hottest first; exits come from the
|
* a row centered above the map, hottest first; exits come from the
|
||||||
* transition matrix and sit just outside their source page.
|
* transition matrix and form a matching row centered below the map, so
|
||||||
|
* the site itself stays in the middle. Both rows sit EXT_GAP beyond the
|
||||||
|
* map's bounds.
|
||||||
* Widths and pruning use the same log scale and traffic-share rule as
|
* Widths and pruning use the same log scale and traffic-share rule as
|
||||||
* internal connections.
|
* internal connections.
|
||||||
*/
|
*/
|
||||||
function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale = 1) {
|
function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1) {
|
||||||
const extNodes = []
|
const extNodes = []
|
||||||
const edges = []
|
const edges = []
|
||||||
const flows = []
|
const flows = []
|
||||||
@@ -744,17 +782,9 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
|
|
||||||
const width = (count) => scaledWidth(count * visualScale)
|
const width = (count) => scaledWidth(count * visualScale)
|
||||||
|
|
||||||
// Pill-shape overlap test (axis-aligned pills): much tighter than the
|
|
||||||
// bounding-circle test, so diagonal placements can sit close.
|
|
||||||
const overlaps = (x, y) =>
|
|
||||||
[...byPath.values(), ...extNodes].some(
|
|
||||||
(n) => !n.hidden
|
|
||||||
&& Math.abs(n.x - x) < TNODE_W + 12
|
|
||||||
&& Math.abs(n.y - y) < TNODE_H + 12,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Incoming: one source node per identified source, in a row centered
|
// Incoming: one source node per identified source, in a row centered
|
||||||
// above the map, with an edge to each page that source led to.
|
// above the map, with an edge to each page that source led to. A source
|
||||||
|
// whose connectors are all culled (below MIN_WMID) is dropped itself.
|
||||||
const bySource = new Map() // source -> pairs, sorted by total incoming count
|
const bySource = new Map() // source -> pairs, sorted by total incoming count
|
||||||
for (const p of liveSources.filter((p) => p.in >= minCount)) {
|
for (const p of liveSources.filter((p) => p.in >= minCount)) {
|
||||||
const g = bySource.get(p.source) || []
|
const g = bySource.get(p.source) || []
|
||||||
@@ -771,9 +801,11 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.total - a.total)
|
.sort((a, b) => b.total - a.total)
|
||||||
.slice(0, MAX_EXT_IN)
|
.slice(0, MAX_EXT_IN)
|
||||||
|
.filter(({ ps }) =>
|
||||||
|
ps.some((p) => !byPath.get(p.page).hidden && width(p.in) >= MIN_WMID))
|
||||||
if (origins.length) {
|
if (origins.length) {
|
||||||
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
||||||
const y = innerBounds.y0 - TNODE_BOUND - 64
|
const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP
|
||||||
const spacing = TNODE_W + 44
|
const spacing = TNODE_W + 44
|
||||||
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
||||||
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
||||||
@@ -781,7 +813,7 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
const xn = {
|
const xn = {
|
||||||
path: source,
|
path: source,
|
||||||
href,
|
href,
|
||||||
label: label.length > 25 ? `${label.slice(0, 24)}…` : label,
|
label, // clipped at the pill border on render
|
||||||
x: x0 + i * spacing,
|
x: x0 + i * spacing,
|
||||||
y,
|
y,
|
||||||
count: total,
|
count: total,
|
||||||
@@ -792,80 +824,57 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
const page = byPath.get(p.page)
|
const page = byPath.get(p.page)
|
||||||
if (page.hidden) continue
|
if (page.hidden) continue
|
||||||
const wMid = width(p.in)
|
const wMid = width(p.in)
|
||||||
if (wMid <= 0) continue
|
if (wMid < MIN_WMID) continue
|
||||||
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
|
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
|
||||||
flows.push(...buildFlows(xn, page, p.in, 0, visualScale))
|
flows.push(...buildFlows(xn, page, p.in, 0, visualScale))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outgoing: group by full URL so several links to the same domain stay
|
// Outgoing: one exit node per distinct full URL (so several links to
|
||||||
// distinct; each shows the total count across all pages linking to it.
|
// the same domain stay distinct), showing the total count across all
|
||||||
// Placement looks for empty space around the source page, always
|
// pages linking to it, in a row centered below the map (hottest
|
||||||
// leftward: diagonal down-left first (often right beside the source,
|
// first), mirroring the source row above. Each (URL, page) pair
|
||||||
// no need to drop below the fans), then left, up-left, and steeper
|
// contributes an edge from that page. An exit whose connectors are all
|
||||||
// fallbacks; the distance grows until a spot is free. Several exits of
|
// culled (below MIN_WMID) is dropped itself.
|
||||||
// one page start at different directions.
|
const byExt = new Map() // full URL -> { ext, out, pairs }
|
||||||
const GAP = gap
|
for (const p of liveExits.filter((p) => p.out >= minCount)) {
|
||||||
const DIRS = [
|
const g = byExt.get(p.ext) || { ext: p.ext, out: 0, pairs: [] }
|
||||||
(3 * Math.PI) / 4, // diagonal down-left
|
g.out += p.out
|
||||||
Math.PI, // left
|
g.pairs.push(p)
|
||||||
(5 * Math.PI) / 4, // diagonal up-left
|
byExt.set(p.ext, g)
|
||||||
Math.PI / 2 + 0.35, // steep down-left
|
}
|
||||||
Math.PI - 0.35, // shallow up-left
|
const targets = [...byExt.values()]
|
||||||
(3 * Math.PI) / 4 + 0.5, // far down-left
|
|
||||||
]
|
|
||||||
const outgoing = liveExits.filter((p) => p.out >= minCount)
|
|
||||||
.sort((a, b) => b.out - a.out)
|
.sort((a, b) => b.out - a.out)
|
||||||
const perPage = new Map()
|
.slice(0, MAX_EXT_OUT)
|
||||||
const selected = []
|
.filter(({ pairs }) =>
|
||||||
for (const p of outgoing) {
|
pairs.some((p) => !byPath.get(p.page).hidden && width(p.out) >= MIN_WMID))
|
||||||
const used = perPage.get(p.page) || 0
|
if (targets.length) {
|
||||||
if (used >= MAX_EXT_OUT_PER_PAGE) continue
|
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
||||||
perPage.set(p.page, used + 1)
|
const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP
|
||||||
selected.push(p)
|
const spacing = TNODE_W + 44
|
||||||
if (selected.length >= MAX_EXT_OUT) break
|
const x0 = cx - ((targets.length - 1) * spacing) / 2
|
||||||
}
|
targets.forEach(({ ext, out, pairs }, i) => {
|
||||||
|
const xn = {
|
||||||
const exitNodes = new Map() // full URL -> node
|
path: ext,
|
||||||
const placedPerPage = new Map() // for the placement direction offset
|
href: ext,
|
||||||
for (const p of selected) {
|
label: extLabel(ext),
|
||||||
const page = byPath.get(p.page)
|
x: x0 + i * spacing,
|
||||||
if (page.hidden) continue
|
|
||||||
let xn = exitNodes.get(p.ext)
|
|
||||||
if (!xn) {
|
|
||||||
const used = placedPerPage.get(p.page) || 0
|
|
||||||
placedPerPage.set(p.page, used + 1)
|
|
||||||
let x = 0
|
|
||||||
let y = 0
|
|
||||||
let found = false
|
|
||||||
for (let di = 0; di < DIRS.length && !found; di++) {
|
|
||||||
const ang = DIRS[(used + di) % DIRS.length]
|
|
||||||
for (let dist = GAP; dist <= GAP * 3.5; dist += GAP * 0.4) {
|
|
||||||
x = page.x + Math.cos(ang) * dist
|
|
||||||
y = page.y + Math.sin(ang) * dist
|
|
||||||
if (!overlaps(x, y)) { found = true; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) continue // no empty space near the page: leave it out
|
|
||||||
xn = {
|
|
||||||
path: p.ext,
|
|
||||||
href: p.ext,
|
|
||||||
label: extLabel(p.ext),
|
|
||||||
x,
|
|
||||||
y,
|
y,
|
||||||
count: 0,
|
count: out,
|
||||||
kind: 'exit',
|
kind: 'exit',
|
||||||
}
|
}
|
||||||
exitNodes.set(p.ext, xn)
|
|
||||||
extNodes.push(xn)
|
extNodes.push(xn)
|
||||||
}
|
for (const p of pairs) {
|
||||||
xn.count += p.out
|
const page = byPath.get(p.page)
|
||||||
|
if (page.hidden) continue
|
||||||
const wMid = width(p.out)
|
const wMid = width(p.out)
|
||||||
if (wMid <= 0) continue
|
if (wMid < MIN_WMID) continue
|
||||||
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
|
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
|
||||||
flows.push(...buildFlows(page, xn, p.out, 0, visualScale))
|
flows.push(...buildFlows(page, xn, p.out, 0, visualScale))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { extNodes, edges, flows }
|
return { extNodes, edges, flows }
|
||||||
}
|
}
|
||||||
@@ -873,7 +882,7 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
/**
|
/**
|
||||||
* Build the transition map model.
|
* Build the transition map model.
|
||||||
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
|
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
|
||||||
* there is nothing to show. `arcs` holds the family spokes; `nodes` only
|
* there is nothing to show. `arcs` holds the branch curves; `nodes` only
|
||||||
* contains placed (visible) nodes.
|
* contains placed (visible) nodes.
|
||||||
*/
|
*/
|
||||||
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
||||||
@@ -889,28 +898,34 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
|||||||
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
||||||
sortByNav(root, navOrder)
|
sortByNav(root, navOrder)
|
||||||
annotateNodes(nodes, data?.views, titles, readMinutes)
|
annotateNodes(nodes, data?.views, titles, readMinutes)
|
||||||
const { GAP, spokes } = layoutGroups(root)
|
const { arcs, arcLeft } = layoutGroups(root)
|
||||||
const placed = nodes.filter((n) => !n.hidden)
|
const placed = nodes.filter((n) => !n.hidden)
|
||||||
const pairs = aggregatePairs(internal)
|
const pairs = aggregatePairs(internal)
|
||||||
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
||||||
|
|
||||||
// Tight bounding box of the placed page nodes; external nodes extend it.
|
// Tight bounding box of the placed page nodes, extended to cover the
|
||||||
const pad = 16
|
// branch curves running left of the pills; external nodes extend it.
|
||||||
|
// Margins cover just the ribbon surround (pill outline + flare margin
|
||||||
|
// S=4) plus a small pad: the pill's half width/height, not its diagonal
|
||||||
|
// radius, so the map crops tight especially at top and bottom.
|
||||||
|
const pad = 8
|
||||||
|
const MX = TNODE_W / 2 + 4 + pad
|
||||||
|
const MY = TNODE_H / 2 + 4 + pad
|
||||||
const xs = placed.map((n) => n.x)
|
const xs = placed.map((n) => n.x)
|
||||||
const ys = placed.map((n) => n.y)
|
const ys = placed.map((n) => n.y)
|
||||||
const bounds = {
|
const bounds = {
|
||||||
x0: Math.min(...xs) - TNODE_BOUND - pad,
|
x0: Math.min(Math.min(...xs) - MX, arcLeft - pad),
|
||||||
y0: Math.min(...ys) - TNODE_BOUND - pad,
|
y0: Math.min(...ys) - MY,
|
||||||
x1: Math.max(...xs) + TNODE_BOUND + pad,
|
x1: Math.max(...xs) + MX,
|
||||||
y1: Math.max(...ys) + TNODE_BOUND + pad,
|
y1: Math.max(...ys) + MY,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = buildExternal({ sources, exits }, byPath, GAP, bounds, visualScale)
|
const ext = buildExternal({ sources, exits }, byPath, bounds, visualScale)
|
||||||
for (const xn of ext.extNodes) {
|
for (const xn of ext.extNodes) {
|
||||||
bounds.x0 = Math.min(bounds.x0, xn.x - TNODE_BOUND - pad)
|
bounds.x0 = Math.min(bounds.x0, xn.x - MX)
|
||||||
bounds.y0 = Math.min(bounds.y0, xn.y - TNODE_BOUND - pad)
|
bounds.y0 = Math.min(bounds.y0, xn.y - MY)
|
||||||
bounds.x1 = Math.max(bounds.x1, xn.x + TNODE_BOUND + pad)
|
bounds.x1 = Math.max(bounds.x1, xn.x + MX)
|
||||||
bounds.y1 = Math.max(bounds.y1, xn.y + TNODE_BOUND + pad)
|
bounds.y1 = Math.max(bounds.y1, xn.y + MY)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -918,7 +933,7 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
|||||||
edges: [...edges, ...ext.edges],
|
edges: [...edges, ...ext.edges],
|
||||||
flows: [...flows, ...ext.flows],
|
flows: [...flows, ...ext.flows],
|
||||||
extNodes: ext.extNodes,
|
extNodes: ext.extNodes,
|
||||||
arcs: spokes,
|
arcs,
|
||||||
bounds,
|
bounds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-6
@@ -109,6 +109,8 @@ class Visit(msgspec.Struct, omit_defaults=True):
|
|||||||
utm: dict[str, str] = {}
|
utm: dict[str, str] = {}
|
||||||
#: Active reading time per path (seconds), keyed by path.
|
#: Active reading time per path (seconds), keyed by path.
|
||||||
read: dict[str, int] = {}
|
read: dict[str, int] = {}
|
||||||
|
#: HTTP status of the response when the path was first seen (200 or 404).
|
||||||
|
statuses: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -125,6 +127,8 @@ class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
|||||||
referer: str = ""
|
referer: str = ""
|
||||||
#: Raw query string of the landing URL (UTM tags can be parsed from it).
|
#: Raw query string of the landing URL (UTM tags can be parsed from it).
|
||||||
query: str = ""
|
query: str = ""
|
||||||
|
#: HTTP status of the served response (200 or 404 for content pages).
|
||||||
|
status: int = 200
|
||||||
|
|
||||||
|
|
||||||
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -246,15 +250,15 @@ def _utm_tags(query: str) -> dict[str, str]:
|
|||||||
_CRAWLER_TIMEOUT = timedelta(seconds=10)
|
_CRAWLER_TIMEOUT = timedelta(seconds=10)
|
||||||
|
|
||||||
#: UAs of JS-running crawlers, which would register as visitors on their
|
#: UAs of JS-running crawlers, which would register as visitors on their
|
||||||
#: ping. Anything calling itself a "bot" matches; known crawlers without
|
#: ping. Anything calling itself a "bot" or "spider" matches; known crawlers
|
||||||
#: that token (GoogleOther) are listed as extra alternates. No source
|
#: without those tokens (GoogleOther) are listed as extra alternates. No
|
||||||
#: verification: a spoofed bot UA just lands in the crawler list, and
|
#: source verification: a spoofed bot UA just lands in the crawler list, and
|
||||||
#: scanners that probe telltale paths are caught by the abuse rules anyway.
|
#: scanners that probe telltale paths are caught by the abuse rules anyway.
|
||||||
_BOT_UA = re.compile(r"bot|googleother", re.IGNORECASE)
|
_BOT_UA = re.compile(r"bot|spider|googleother", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _is_bot_ua(ua: str) -> bool:
|
def _is_bot_ua(ua: str) -> bool:
|
||||||
"""True when the UA claims a crawler identity (Googlebot, Applebot, ...)."""
|
"""True when the UA claims a crawler identity (bot or spider)."""
|
||||||
return bool(_BOT_UA.search(ua))
|
return bool(_BOT_UA.search(ua))
|
||||||
|
|
||||||
#: Plain-404 count per IP that classifies it as abuse even without a
|
#: Plain-404 count per IP that classifies it as abuse even without a
|
||||||
@@ -324,6 +328,9 @@ class Store:
|
|||||||
#: Document GETs that have not yet been matched by a ping. Kept
|
#: Document GETs that have not yet been matched by a ping. Kept
|
||||||
#: in RAM only; expired entries are written to ``data.crawlers``.
|
#: in RAM only; expired entries are written to ``data.crawlers``.
|
||||||
self.pending_crawlers: list[CrawlerHit] = []
|
self.pending_crawlers: list[CrawlerHit] = []
|
||||||
|
#: client hash -> {path: status} for recent document GETs, consumed
|
||||||
|
#: by the matching ping to record the status of each visited path.
|
||||||
|
self.pending_statuses: dict[bytes, dict[str, int]] = {}
|
||||||
#: ip -> number of plain (non-telltale) 404s seen, in RAM only;
|
#: ip -> number of plain (non-telltale) 404s seen, in RAM only;
|
||||||
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
|
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
|
||||||
self.not_found_counts: dict[str, int] = {}
|
self.not_found_counts: dict[str, int] = {}
|
||||||
@@ -571,6 +578,7 @@ class Store:
|
|||||||
referer: str,
|
referer: str,
|
||||||
client_hash: bytes,
|
client_hash: bytes,
|
||||||
utm: dict[str, str] | None = None,
|
utm: dict[str, str] | None = None,
|
||||||
|
status: int = 200,
|
||||||
) -> Visit:
|
) -> Visit:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
visit = Visit(
|
visit = Visit(
|
||||||
@@ -580,6 +588,7 @@ class Store:
|
|||||||
client=client_hash,
|
client=client_hash,
|
||||||
utm=utm or {},
|
utm=utm or {},
|
||||||
)
|
)
|
||||||
|
visit.statuses[entry] = status
|
||||||
self.data.visits.append(visit)
|
self.data.visits.append(visit)
|
||||||
self.sessions[client_hash] = len(self.data.visits) - 1
|
self.sessions[client_hash] = len(self.data.visits) - 1
|
||||||
self._count(self.data.site_visits, _bucket(now))
|
self._count(self.data.site_visits, _bucket(now))
|
||||||
@@ -595,6 +604,8 @@ class Store:
|
|||||||
ua: str,
|
ua: str,
|
||||||
full_path: str,
|
full_path: str,
|
||||||
accept_language: str = "",
|
accept_language: str = "",
|
||||||
|
*,
|
||||||
|
status: int = 200,
|
||||||
) -> list[bytes]:
|
) -> list[bytes]:
|
||||||
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
|
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
|
||||||
|
|
||||||
@@ -642,8 +653,10 @@ class Store:
|
|||||||
client=client_hash,
|
client=client_hash,
|
||||||
referer=self.pending_referers.get(ip, ""),
|
referer=self.pending_referers.get(ip, ""),
|
||||||
query=query,
|
query=query,
|
||||||
|
status=status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.pending_statuses.setdefault(client_hash, {})[entry] = status
|
||||||
return flushed
|
return flushed
|
||||||
|
|
||||||
def _add_read(self, client_hash: bytes, path: str, seconds: int) -> None:
|
def _add_read(self, client_hash: bytes, path: str, seconds: int) -> None:
|
||||||
@@ -697,8 +710,9 @@ class Store:
|
|||||||
if hide:
|
if hide:
|
||||||
# Admin ping: cancel pending crawler hits and scrub the session.
|
# Admin ping: cancel pending crawler hits and scrub the session.
|
||||||
self.pending_crawlers = [
|
self.pending_crawlers = [
|
||||||
hit for hit in self.pending_crawlers if hit.client == client_hash
|
hit for hit in self.pending_crawlers if hit.client != client_hash
|
||||||
]
|
]
|
||||||
|
self.pending_statuses.pop(client_hash, None)
|
||||||
index = self.sessions.pop(client_hash, None)
|
index = self.sessions.pop(client_hash, None)
|
||||||
if index is not None and index < len(self.data.visits):
|
if index is not None and index < len(self.data.visits):
|
||||||
self._remove_visit(index)
|
self._remove_visit(index)
|
||||||
@@ -730,6 +744,10 @@ class Store:
|
|||||||
return None, flushed
|
return None, flushed
|
||||||
index = self.sessions.get(client_hash)
|
index = self.sessions.get(client_hash)
|
||||||
fr = fr_path or "(direct)"
|
fr = fr_path or "(direct)"
|
||||||
|
statuses = self.pending_statuses.setdefault(client_hash, {})
|
||||||
|
target_status = statuses.pop(target, None) or 200
|
||||||
|
if not statuses:
|
||||||
|
self.pending_statuses.pop(client_hash, None)
|
||||||
if index is None or index >= len(self.data.visits):
|
if index is None or index >= len(self.data.visits):
|
||||||
# No known session: the initial ping of a fresh page load (or
|
# No known session: the initial ping of a fresh page load (or
|
||||||
# missing data after a server restart) — start a visit.
|
# missing data after a server restart) — start a visit.
|
||||||
@@ -740,6 +758,7 @@ class Store:
|
|||||||
self.pending_referers.pop(ip, ""),
|
self.pending_referers.pop(ip, ""),
|
||||||
client_hash,
|
client_hash,
|
||||||
utm=self.pending_utms.pop(ip, {}),
|
utm=self.pending_utms.pop(ip, {}),
|
||||||
|
status=target_status,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
visit = self.data.visits[index]
|
visit = self.data.visits[index]
|
||||||
@@ -750,6 +769,7 @@ class Store:
|
|||||||
# First-seen only: repeat pages and repeated exits don't append.
|
# First-seen only: repeat pages and repeated exits don't append.
|
||||||
if visit.entry != target and target not in visit.trail:
|
if visit.entry != target and target not in visit.trail:
|
||||||
visit.trail.append(target)
|
visit.trail.append(target)
|
||||||
|
visit.statuses[target] = target_status
|
||||||
self._save()
|
self._save()
|
||||||
visit_index = index if index is not None and index < len(self.data.visits) else None
|
visit_index = index if index is not None and index < len(self.data.visits) else None
|
||||||
return visit_index, flushed
|
return visit_index, flushed
|
||||||
|
|||||||
+4
-3
@@ -853,7 +853,7 @@ async def analytics_ping(ping: AnalyticsPing, request: Request) -> None:
|
|||||||
_schedule_client_enrichment(flushed_clients)
|
_schedule_client_enrichment(flushed_clients)
|
||||||
|
|
||||||
|
|
||||||
def _track_entry(path: str, request: Request) -> list[bytes]:
|
def _track_entry(path: str, request: Request, *, status: int = 200) -> list[bytes]:
|
||||||
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
|
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
|
||||||
|
|
||||||
Nothing is counted on the GET itself — the client's /_a ping starts the
|
Nothing is counted on the GET itself — the client's /_a ping starts the
|
||||||
@@ -890,6 +890,7 @@ def _track_entry(path: str, request: Request) -> list[bytes]:
|
|||||||
request.headers.get("user-agent", ""),
|
request.headers.get("user-agent", ""),
|
||||||
full_path,
|
full_path,
|
||||||
request.headers.get("accept-language", ""),
|
request.headers.get("accept-language", ""),
|
||||||
|
status=status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1228,7 +1229,7 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
# Category label without a landing page: placeholder with the pen
|
# Category label without a landing page: placeholder with the pen
|
||||||
# to create it (404 — no page here, but the node is real).
|
# to create it (404 — no page here, but the node is real).
|
||||||
if _is_trackable_path(path):
|
if _is_trackable_path(path):
|
||||||
flushed = _track_entry(path, request)
|
flushed = _track_entry(path, request, status=404)
|
||||||
_schedule_client_enrichment(flushed)
|
_schedule_client_enrichment(flushed)
|
||||||
return _html_response(
|
return _html_response(
|
||||||
request,
|
request,
|
||||||
@@ -1254,6 +1255,6 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
accept_language,
|
accept_language,
|
||||||
)
|
)
|
||||||
asyncio.create_task(_enrich_client(client_hash))
|
asyncio.create_task(_enrich_client(client_hash))
|
||||||
flushed = _track_entry(path, request)
|
flushed = _track_entry(path, request, status=404)
|
||||||
_schedule_client_enrichment(flushed)
|
_schedule_client_enrichment(flushed)
|
||||||
return _html_response(request, "not-found", path, 404)
|
return _html_response(request, "not-found", path, 404)
|
||||||
|
|||||||
Reference in New Issue
Block a user