Transition map beads: connector-colored elongated pulses

Beads now take their connector's color — accent for internal links,
plain text color for external ones (matching the tconn-exit styling) —
and render as an elongated pulse along the direction of travel instead
of a plain dot: a rounded capsule with a gradient-faded tail, rotated
to the edge's angle (cached per flow). Flow objects carry an 'external'
tag from buildFlows; the gradients live in the svg defs with stop
colors resolved from CSS so theme variables apply.
This commit is contained in:
LeoVasanko committed 2026-09-25 20:23:20 +00:00
1 parent fb2914d853
commit 3c9ef04ffd
3 files changed
+52 -12

No files matched your search

+4 -1
View File
@@ -465,7 +465,10 @@ 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
offset onto parallel lanes. External sources and exits whose connectors are
offset onto parallel lanes; each bead renders as an elongated pulse
(gradient-faded tail) along its direction of travel, colored like its
connector — accent for internal edges, plain text color for external
ones. 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
+37 -5
View File
@@ -13,6 +13,7 @@ import {
TNODE_W,
TNODE_H,
BEAD_R,
BEAD_LEN,
buildTransitionGraph,
} from './analytics/transitions.js'
@@ -145,7 +146,17 @@ const tick = (t) => {
continue
}
const f = b.e.flow
out.push({ x: f.x1 + (f.x2 - f.x1) * b.p, y: f.y1 + (f.y2 - f.y1) * b.p })
// The pulse is elongated along the direction of travel (constant per
// flow) and colored like its connector (external edges use --text).
if (f.deg === undefined) {
f.deg = (Math.atan2(f.y2 - f.y1, f.x2 - f.x1) * 180) / Math.PI
}
out.push({
x: f.x1 + (f.x2 - f.x1) * b.p,
y: f.y1 + (f.y2 - f.y1) * b.p,
deg: f.deg,
ext: f.external,
})
}
beads.value = out
rafId = requestAnimationFrame(tick)
@@ -184,6 +195,21 @@ const countLabel = (n) =>
<section v-if="graph">
<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">
<defs>
<!-- Pulse gradient: opaque head at the bead position (local origin),
fading tail trailing behind it (stop-color comes from CSS so
theme vars resolve). -->
<linearGradient id="tbead-grad">
<stop offset="0" class="tbead-stop" stop-opacity="0" />
<stop offset="0.55" class="tbead-stop" stop-opacity="0.5" />
<stop offset="1" class="tbead-stop" stop-opacity="1" />
</linearGradient>
<linearGradient id="tbead-grad-exit">
<stop offset="0" class="tbead-stop-exit" stop-opacity="0" />
<stop offset="0.55" class="tbead-stop-exit" stop-opacity="0.5" />
<stop offset="1" class="tbead-stop-exit" stop-opacity="1" />
</linearGradient>
</defs>
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
:id="`tarc${i}`" :d="a.d" :class="['tarc', a.top && 'tarc-top']" />
<template v-for="(a, i) in graph.arcs" :key="'t' + i">
@@ -194,8 +220,11 @@ const countLabel = (n) =>
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
<title>{{ e.title }}</title>
</path>
<circle v-for="(b, i) in beads" :key="'b' + i"
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
<rect v-for="(b, i) in beads" :key="'b' + i"
:x="-BEAD_LEN" :y="-BEAD_R" :width="BEAD_LEN" :height="2 * BEAD_R" :rx="BEAD_R"
:fill="b.ext ? 'url(#tbead-grad-exit)' : 'url(#tbead-grad)'"
:class="['tbead', b.ext && 'tbead-exit']"
:transform="`translate(${b.x} ${b.y}) rotate(${b.deg})`" />
<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"
@@ -259,10 +288,13 @@ const countLabel = (n) =>
fill: var(--text);
}
.tmap .tbead {
fill: var(--accent);
opacity: 0.85;
filter: drop-shadow(0 0 2.5px var(--accent));
}
.tmap .tbead-exit {
filter: drop-shadow(0 0 2.5px var(--text));
}
.tmap .tbead-stop { stop-color: var(--accent); }
.tmap .tbead-stop-exit { stop-color: var(--text); }
.tmap .txnode {
/* External source/exit pills: plain white on every theme, with a hairline
so the pill stays visible on a white page. */
+11 -6
View File
@@ -149,8 +149,10 @@ const MIN_WMID = 0.4
// from real time to keep the animation lightweight. The component
// simulates every bead independently in JS with a constant traversal
// time per edge (speed relative to span length), with no limit on beads
// in flight.
// in flight. A bead renders as an elongated pulse trailing its direction
// of travel: BEAD_R is its half-thickness, BEAD_LEN its full length.
export const BEAD_R = 3.2
export const BEAD_LEN = BEAD_R * 5.5
const BEAD_RATE = 0.0084 // beads per second per hit/day
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
@@ -638,10 +640,12 @@ function buildRibbon(a, b, ab, ba, wMid, external = false) {
* at the surround), plus the emission interval (seconds between beads,
* inverse of the daily hit rate * BEAD_RATE). Each segment is offset to the
* right-hand side of its travel direction, so opposing flows on the same
* edge run on parallel lanes instead of colliding. The component turns
* these into independently simulated beads.
* edge run on parallel lanes instead of colliding. `external` tags flows on
* external-link edges so the component can render their beads in the
* connector color (the exit/source connectors use --text, not --accent).
* The component turns these into independently simulated beads.
*/
function buildFlows(a, b, ab, ba, dayScale = 1) {
function buildFlows(a, b, ab, ba, dayScale = 1, external = false) {
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
const ux = (b.x - a.x) / len
const uy = (b.y - a.y) / len
@@ -665,6 +669,7 @@ function buildFlows(a, b, ab, ba, dayScale = 1) {
y2: a.y + toT * uy + s * ry,
len: span,
interval: 1 / (count * BEAD_RATE * dayScale),
external,
}
}
const flows = []
@@ -894,7 +899,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
const wMid = width(p.in)
if (wMid < MIN_WMID) continue
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
flows.push(...buildFlows(xn, page, p.in, 0, dayScale))
flows.push(...buildFlows(xn, page, p.in, 0, dayScale, true))
}
})
}
@@ -944,7 +949,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
const wMid = width(p.out)
if (wMid < MIN_WMID) continue
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
flows.push(...buildFlows(page, xn, p.out, 0, dayScale))
flows.push(...buildFlows(page, xn, p.out, 0, dayScale, true))
}
})
}