Transition map: count-scaled edges, bead flows, external links

- Edge widths grow logarithmically with the connection count (~1 px at
  a single count, uncapped); connections below 1% of total traffic are
  pruned, bounding the graph to ~100 edges.
- Beads: per-direction flows emitted at a rate linear in the count,
  each bead simulated independently in JS (no in-flight limit), offset
  onto right-hand lanes so opposing flows don't collide, running under
  the node circles with a glow.
- External links: referer origins as a node row above the map, exit
  origins fanned outwards from their source page.
- Transitions are now stored per 5-minute bucket (sparse
  from -> to -> bucket -> count) so the graph filters by time range
  like the other series; legacy analytics files are discarded.
This commit is contained in:
2026-08-20 22:06:29 +00:00
parent 7556bb7f4f
commit 3100010335
4 changed files with 393 additions and 78 deletions
+13 -9
View File
@@ -98,9 +98,10 @@ class Analytics(msgspec.Struct, omit_defaults=True):
visits: list[Visit] = []
#: Document GETs that never produced a ping, treated as crawler/bot hits.
crawlers: list[CrawlerHit] = []
#: Page transition matrix: from -> to -> count. ``from`` is the referer
#: origin or "(direct)" for initial loads, a page path for pings.
transitions: dict[str, dict[str, int]] = {}
#: Page transitions per 5-minute bucket (sparse):
#: from -> to -> bucket ISO -> count. ``from`` is the referer origin or
#: "(direct)" for initial loads, a page path for pings.
transitions: dict[str, dict[str, dict[str, int]]] = {}
#: Page views per 5-minute bucket: path -> bucket ISO -> count (sparse).
views: dict[str, dict[str, int]] = {}
#: New visits per 5-minute bucket: bucket ISO -> count (sparse).
@@ -179,8 +180,8 @@ class Store:
if path.exists():
try:
self.data = msgspec.json.decode(path.read_bytes(), type=Analytics)
except (msgspec.DecodeError, OSError):
pass # corrupt/unreadable file: start fresh
except msgspec.DecodeError, OSError:
pass # legacy schema / corrupt or unreadable file: start fresh
#: (ip, user-agent) -> index of the current visit in data.visits
self.sessions: dict[tuple[str, str], int] = {}
#: ip -> external https origin of the latest document GET carrying
@@ -226,6 +227,11 @@ class Store:
def _count(self, table: dict[str, int], key: str) -> None:
table[key] = table.get(key, 0) + 1
def _count_transition(self, fr: str, to: str, now: datetime) -> None:
"""Count one transition in its 5-minute bucket (sparse matrix)."""
buckets = self.data.transitions.setdefault(fr, {}).setdefault(to, {})
self._count(buckets, _bucket(now))
def _new_visit(
self,
entry: str,
@@ -253,9 +259,7 @@ class Store:
self.sessions[key] = len(self.data.visits) - 1
self._count(self.data.site_visits, _bucket(now))
self._count(self.data.views.setdefault(entry, {}), _bucket(now))
self._count(
self.data.transitions.setdefault(referer or "(direct)", {}), entry
)
self._count_transition(referer or "(direct)", entry, now)
return visit
def enrich_visit(
@@ -376,7 +380,7 @@ class Store:
now = datetime.now(UTC)
if target.startswith("/"):
self._count(self.data.views.setdefault(target, {}), _bucket(now))
self._count(self.data.transitions.setdefault(fr, {}), target)
self._count_transition(fr, target, now)
# First-seen only: repeat pages and repeated exits don't append.
if visit.entry != target and target not in visit.trail:
visit.trail.append(target)