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

+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))
}
})
}