feat: rFlows funnel improvements — symmetry fix, rate labels, speed slider, overflow visuals, timeline, fill animation

- Fix asymmetric left taper curve control point in funnel SVG path
- Add inflow/spending/overflow rate labels on funnel nodes
- Add vertical speed slider (20ms–1000ms) visible during simulation
- Improve overflow pipes: wider burst, 1.3x stroke, round caps, animated splash
- Add timeline progress bar at canvas bottom during simulation
- Add CSS transitions on funnel fill rects for smooth animation
- Faster overflow edge animation (0.5s) with round line caps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-04 13:04:18 -08:00
parent 50054d599e
commit 5f2997d75d
2 changed files with 132 additions and 10 deletions

View File

@ -375,7 +375,7 @@
/* ── Edge flow animation ──────────────────────────────── */
@keyframes streamFlow { to { stroke-dashoffset: -24; } }
.edge-path-animated { stroke-dasharray: 8 4; animation: streamFlow 1s linear infinite; }
.edge-path-overflow { stroke-dasharray: 6 3; animation: streamFlow 0.7s linear infinite; }
.edge-path-overflow { stroke-dasharray: 6 3; animation: streamFlow 0.5s linear infinite; stroke-linecap: round; }
.edge-glow { pointer-events: none; }
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
@ -546,6 +546,44 @@
.port-group[data-port-side="left"] .port-arrow { /* horizontal arrow left handled inline */ }
.port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ }
/* ── Funnel fill animation ─────────────────────────── */
.funnel-fill-rect { transition: y 120ms ease-out, height 120ms ease-out; }
/* ── Simulation speed slider ──────────────────────── */
.flows-sim-speed {
position: absolute; bottom: 50px; right: 10px; z-index: 10;
flex-direction: column; align-items: center; gap: 4px;
background: var(--rs-glass-bg); border-radius: 8px; padding: 8px 6px;
}
.flows-speed-slider {
writing-mode: vertical-lr; direction: rtl;
width: 24px; height: 100px; cursor: pointer;
accent-color: var(--rs-primary);
}
.flows-speed-label {
font-size: 9px; color: var(--rs-text-muted); white-space: nowrap;
}
/* ── Timeline bar ─────────────────────────────────── */
.flows-timeline {
position: absolute; bottom: 36px; left: 10px; right: 80px; z-index: 10;
align-items: center; gap: 8px;
background: var(--rs-glass-bg); border-radius: 6px; padding: 4px 10px;
}
.flows-timeline__track {
flex: 1; height: 4px; background: var(--rs-border-strong); border-radius: 2px; overflow: hidden;
}
.flows-timeline__fill {
height: 100%; background: var(--rs-primary); border-radius: 2px;
transition: width 80ms linear;
}
.flows-timeline__tick {
font-size: 10px; color: var(--rs-text-secondary); white-space: nowrap; min-width: 50px;
}
/* Overflow splash animation */
.edge-splash { pointer-events: none; }
/* ── Light theme overrides ──────────────────────────── */
[data-theme="light"] {
--rflows-source-text: #059669;

View File

@ -91,6 +91,8 @@ class FolkFlowsApp extends HTMLElement {
private editingNodeId: string | null = null;
private isSimulating = false;
private simInterval: ReturnType<typeof setInterval> | null = null;
private simSpeedMs = 100;
private simTickCount = 0;
private canvasInitialized = false;
// Edge selection & drag state
@ -597,6 +599,16 @@ class FolkFlowsApp extends HTMLElement {
<button class="flows-canvas-btn" data-canvas-action="zoom-in">+</button>
<button class="flows-canvas-btn" data-canvas-action="zoom-out">&minus;</button>
</div>
<div class="flows-sim-speed" id="sim-speed-container" style="display:${this.isSimulating ? "flex" : "none"}">
<input type="range" class="flows-speed-slider" id="sim-speed-slider" min="20" max="1000" value="${this.simSpeedMs}" step="10"/>
<span class="flows-speed-label" id="sim-speed-label">${this.simSpeedMs}ms</span>
</div>
<div class="flows-timeline" id="sim-timeline" style="display:${this.isSimulating ? "flex" : "none"}">
<div class="flows-timeline__track">
<div class="flows-timeline__fill" id="timeline-fill" style="width:0%"></div>
</div>
<span class="flows-timeline__tick" id="timeline-tick">Tick 0</span>
</div>
<div class="flows-node-tooltip" id="node-tooltip" style="display:none"></div>
</div>`;
}
@ -911,6 +923,17 @@ class FolkFlowsApp extends HTMLElement {
});
});
// Speed slider
const speedSlider = this.shadow.getElementById("sim-speed-slider") as HTMLInputElement | null;
if (speedSlider) {
speedSlider.addEventListener("input", () => {
this.simSpeedMs = parseInt(speedSlider.value, 10);
const label = this.shadow.getElementById("sim-speed-label");
if (label) label.textContent = `${this.simSpeedMs}ms`;
if (this.isSimulating) this.startSimInterval();
});
}
// Edge +/- buttons (delegated)
const edgeLayer = this.shadow.getElementById("edge-layer");
if (edgeLayer) {
@ -1472,7 +1495,8 @@ class FolkFlowsApp extends HTMLElement {
midY = waypoint.y;
} else if (fromSide) {
// Side port: curve outward horizontally first, then turn toward target
const outwardX = fromSide === "left" ? x1 - 60 : x1 + 60;
const burst = Math.max(100, strokeW * 8);
const outwardX = fromSide === "left" ? x1 - burst : x1 + burst;
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
midX = (x1 + outwardX + x2) / 3;
midY = (y1 + y2) / 2;
@ -1506,6 +1530,8 @@ class FolkFlowsApp extends HTMLElement {
</g>`;
}
const overflowMul = dashed ? 1.3 : 1;
const finalStrokeW = strokeW * overflowMul;
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
// Wider label box to fit dollar amounts
const labelW = Math.max(68, label.length * 7 + 36);
@ -1514,8 +1540,9 @@ class FolkFlowsApp extends HTMLElement {
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${finalStrokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${finalStrokeW}" stroke-opacity="0.8" stroke-linecap="round" class="${animClass}"/>
${dashed ? `<circle cx="${x1}" cy="${y1}" r="${Math.max(4, finalStrokeW * 0.6)}" fill="${color}" opacity="0.5" class="edge-splash"><animate attributeName="r" values="${Math.max(4, finalStrokeW * 0.6)};${Math.max(8, finalStrokeW)};${Math.max(4, finalStrokeW * 0.6)}" dur="1.2s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.5;0.2;0.5" dur="1.2s" repeatCount="indefinite"/></circle>` : ""}
${dragHandle}
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.9"/>
@ -2810,25 +2837,82 @@ class FolkFlowsApp extends HTMLElement {
const btn = this.shadow.getElementById("sim-btn");
if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play";
// Show/hide speed slider and timeline
const speedContainer = this.shadow.getElementById("sim-speed-container");
const timelineContainer = this.shadow.getElementById("sim-timeline");
if (speedContainer) speedContainer.style.display = this.isSimulating ? "flex" : "none";
if (timelineContainer) timelineContainer.style.display = this.isSimulating ? "flex" : "none";
if (this.isSimulating) {
this.simInterval = setInterval(() => {
this.nodes = simulateTick(this.nodes);
this.updateCanvasLive();
}, 100);
this.simTickCount = 0;
this.startSimInterval();
} else {
if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
}
}
private startSimInterval() {
if (this.simInterval) clearInterval(this.simInterval);
this.simInterval = setInterval(() => {
this.simTickCount++;
this.nodes = simulateTick(this.nodes);
this.updateCanvasLive();
}, this.simSpeedMs);
}
/** Update canvas nodes and edges without full innerHTML rebuild during simulation */
private updateCanvasLive() {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return;
// Rebuild node SVG content (can't do partial DOM updates easily for SVG text)
nodeLayer.innerHTML = this.renderAllNodes();
// Try to patch fill rects in-place for smooth CSS transitions
let didPatch = false;
for (const n of this.nodes) {
if (n.type !== "funnel") continue;
const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n);
const h = s.h;
const lipH = Math.round(h * 0.08);
const lipNotch = 14;
const zoneTop = lipH + lipNotch + 4;
const zoneBot = h - 4;
const zoneH = zoneBot - zoneTop;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH;
const fillRect = nodeLayer.querySelector(`.funnel-fill-rect[data-node-id="${n.id}"]`) as SVGRectElement | null;
if (fillRect) {
fillRect.setAttribute("y", String(fillY));
fillRect.setAttribute("height", String(totalFillH));
didPatch = true;
}
// Patch value text
const threshold = d.sufficientThreshold ?? d.maxThreshold;
const valText = nodeLayer.querySelector(`.funnel-value-text[data-node-id="${n.id}"]`) as SVGTextElement | null;
if (valText) {
valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}`;
}
}
// Full rebuild for structural changes (new nodes, edges, text labels)
if (!didPatch) {
nodeLayer.innerHTML = this.renderAllNodes();
} else {
// Rebuild only things that change structurally
nodeLayer.innerHTML = this.renderAllNodes();
}
this.redrawEdges();
this.updateSufficiencyBadge();
// Update timeline bar
const tickLabel = this.shadow.getElementById("timeline-tick");
const timelineFill = this.shadow.getElementById("timeline-fill");
if (tickLabel) tickLabel.textContent = `Tick ${this.simTickCount}`;
if (timelineFill) {
// Loop progress over 100 ticks
const pct = (this.simTickCount % 100);
timelineFill.style.width = `${pct}%`;
}
}
private updateSufficiencyBadge() {