Skip to content

Commit 2e96e06

Browse files
committed
feat: active RLCR dynamic progress visualization
When a session is active: 1. Current round node enhancements: - Sweeping gradient bar at top (horizontal scan animation) - Pulsing orange glow shadow - Live blinking dot indicator next to phase tag - Stronger border highlight 2. Ghost "next round" node: - Dashed orange border with breathing opacity animation - Shows "R{N+1} Next" with spinner - "Awaiting..." status text - Positioned as the next node in the snake path 3. Flowing edge animation: - The connector between current round and ghost node has animated dashes (stroke-dashoffset animation) - Orange colored to distinguish from static connectors All animations are CSS-only, no JavaScript timers. Signed-off-by: Chao Liu <chao.liu.zevorn@gmail.com>
1 parent 80fdc00 commit 2e96e06

2 files changed

Lines changed: 122 additions & 7 deletions

File tree

viz/static/css/layout.css

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,82 @@
290290
.pl-node[data-verdict="complete"] { border-left: 4px solid var(--verdict-complete); }
291291
.pl-node[data-verdict="unknown"] { border-left: 4px solid var(--verdict-unknown); }
292292

293+
/* ─── Active Node Enhancements ─── */
294+
.pl-node.active-round {
295+
border-color: var(--accent);
296+
box-shadow: 0 0 20px var(--accent-glow), var(--shadow-md);
297+
animation: pulse-ring 2.5s var(--ease-in-out) infinite;
298+
}
299+
300+
.node-active-bar {
301+
position: absolute;
302+
top: 0;
303+
left: 0;
304+
right: 0;
305+
height: 3px;
306+
background: var(--bg-3);
307+
overflow: hidden;
308+
border-radius: var(--radius-md) var(--radius-md) 0 0;
309+
}
310+
311+
.node-active-bar-fill {
312+
height: 100%;
313+
width: 40%;
314+
background: linear-gradient(90deg, transparent, var(--accent), transparent);
315+
animation: active-bar-sweep 2s ease-in-out infinite;
316+
}
317+
318+
@keyframes active-bar-sweep {
319+
0% { transform: translateX(-100%); }
320+
100% { transform: translateX(350%); }
321+
}
322+
323+
.node-live-dot {
324+
display: inline-block;
325+
width: 6px;
326+
height: 6px;
327+
border-radius: 50%;
328+
background: var(--accent);
329+
animation: live-blink 1.2s ease-in-out infinite;
330+
flex-shrink: 0;
331+
}
332+
333+
@keyframes live-blink {
334+
0%, 100% { opacity: 1; }
335+
50% { opacity: 0.2; }
336+
}
337+
338+
/* ─── Ghost "In Progress" Node ─── */
339+
.pl-ghost-node {
340+
border: 2px dashed var(--accent) !important;
341+
border-left: 4px dashed var(--accent) !important;
342+
background: var(--bg-glow) !important;
343+
opacity: 0.7;
344+
cursor: default !important;
345+
animation: ghost-breathe 3s ease-in-out infinite;
346+
}
347+
348+
.pl-ghost-node:hover {
349+
border-color: var(--accent) !important;
350+
box-shadow: none !important;
351+
transform: none !important;
352+
}
353+
354+
@keyframes ghost-breathe {
355+
0%, 100% { opacity: 0.5; }
356+
50% { opacity: 0.8; }
357+
}
358+
359+
/* ─── Active Edge (flowing dash animation) ─── */
360+
.pl-edge-active {
361+
animation: edge-flow 1s linear infinite;
362+
}
363+
364+
@keyframes edge-flow {
365+
from { stroke-dashoffset: 0; }
366+
to { stroke-dashoffset: -20; }
367+
}
368+
293369

294370
.node-header {
295371
display: flex;

viz/static/js/pipeline.js

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,31 @@ function renderPipeline(container, session) {
2121
return
2222
}
2323

24-
const positions = computePositions(rounds.length)
24+
const isActive = session.status === 'active'
25+
// Total node count: rounds + 1 ghost node for active sessions
26+
const totalNodes = isActive ? rounds.length + 1 : rounds.length
27+
const positions = computePositions(totalNodes)
2528
const totalW = PL.PADDING * 2 + PL.COLS * PL.NODE_W + (PL.COLS - 1) * PL.GAP_X
26-
const rows = Math.ceil(rounds.length / PL.COLS)
29+
const rows = Math.ceil(totalNodes / PL.COLS)
2730
const totalH = PL.PADDING * 2 + rows * PL.NODE_H + (rows - 1) * (PL.GAP_Y + PL.TURN_H)
2831

2932
let svgPaths = ''
30-
for (let i = 0; i < rounds.length - 1; i++) {
31-
svgPaths += buildConnector(positions[i], positions[i + 1])
33+
for (let i = 0; i < totalNodes - 1; i++) {
34+
const isLastEdge = isActive && i === rounds.length - 1
35+
svgPaths += buildConnector(positions[i], positions[i + 1], isLastEdge)
3236
}
3337

3438
let nodesHtml = ''
3539
rounds.forEach((r, idx) => {
3640
nodesHtml += renderNodeCard(r, session, positions[idx])
3741
})
3842

43+
// Ghost "in progress" node for active sessions
44+
if (isActive) {
45+
const ghostPos = positions[rounds.length]
46+
nodesHtml += renderGhostNode(session, ghostPos)
47+
}
48+
3949
_scale = 1; _tx = 0; _ty = 0
4050

4151
container.innerHTML = `
@@ -81,18 +91,19 @@ function computePositions(count) {
8191
return positions
8292
}
8393

84-
function buildConnector(a, b) {
94+
function buildConnector(a, b, animated) {
8595
const ay = a.y + PL.NODE_H / 2
8696
const by = b.y + PL.NODE_H / 2
87-
const style = `fill="none" stroke="var(--border-2)" stroke-width="2" stroke-dasharray="6 4"`
97+
const cls = animated ? 'class="pl-edge-active"' : ''
98+
const color = animated ? 'var(--accent)' : 'var(--border-2)'
99+
const style = `fill="none" stroke="${color}" stroke-width="2" stroke-dasharray="6 4" ${cls}`
88100

89101
if (a.row === b.row) {
90102
const x1 = a.reversed ? a.x : a.x + PL.NODE_W
91103
const x2 = a.reversed ? b.x + PL.NODE_W : b.x
92104
return `<line x1="${x1}" y1="${ay}" x2="${x2}" y2="${ay}" ${style}/>`
93105
}
94106

95-
// U-turn: exit side → down → enter side
96107
const exitX = a.reversed ? a.x : a.x + PL.NODE_W
97108
const enterX = b.reversed ? b.x + PL.NODE_W : b.x
98109
const midY = (a.y + PL.NODE_H + b.y) / 2
@@ -112,14 +123,22 @@ function renderNodeCard(r, session, pos) {
112123
if (r.bitlesson_delta && r.bitlesson_delta !== 'none') stats.push('📚')
113124
if (!hasSummary) stats.push('…')
114125

126+
// Active node gets a progress indicator
127+
const activeIndicator = isActive ? `
128+
<div class="node-active-bar">
129+
<div class="node-active-bar-fill"></div>
130+
</div>` : ''
131+
115132
return `
116133
<div class="pl-node ${isActive ? 'active-round' : ''}" data-verdict="${verdict}" data-round="${r.number}"
117134
style="left:${pos.x}px;top:${pos.y}px;width:${PL.NODE_W}px"
118135
onclick="openFlyout(this, ${r.number})">
136+
${activeIndicator}
119137
<div class="node-header">
120138
<div style="display:flex;align-items:center;gap:6px">
121139
<span class="node-round-num">R${r.number}</span>
122140
<span class="node-phase-tag">${esc(phaseLabel)}</span>
141+
${isActive ? '<span class="node-live-dot"></span>' : ''}
123142
</div>
124143
<div class="node-meta">
125144
<span class="node-verdict-dot" style="background:var(--verdict-${verdict})"></span>
@@ -130,6 +149,26 @@ function renderNodeCard(r, session, pos) {
130149
</div>`
131150
}
132151

152+
function renderGhostNode(session, pos) {
153+
const nextRound = session.current_round + 1
154+
return `
155+
<div class="pl-node pl-ghost-node"
156+
style="left:${pos.x}px;top:${pos.y}px;width:${PL.NODE_W}px">
157+
<div class="node-header">
158+
<div style="display:flex;align-items:center;gap:6px">
159+
<span class="node-round-num" style="opacity:0.5">R${nextRound}</span>
160+
<span class="node-phase-tag" style="opacity:0.5">Next</span>
161+
</div>
162+
<div class="node-meta">
163+
<span class="spinner" style="width:10px;height:10px"></span>
164+
</div>
165+
</div>
166+
<div class="node-mini-stats">
167+
<span style="color:var(--accent)">Awaiting...</span>
168+
</div>
169+
</div>`
170+
}
171+
133172
// ─── Flyout Modal (expand from node to center) ───
134173

135174
function openFlyout(nodeEl, roundNum) {

0 commit comments

Comments
 (0)