diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 330f866..3c11a15 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -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; diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 8c92ae8..442f202 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -91,6 +91,8 @@ class FolkFlowsApp extends HTMLElement { private editingNodeId: string | null = null; private isSimulating = false; private simInterval: ReturnType | null = null; + private simSpeedMs = 100; + private simTickCount = 0; private canvasInitialized = false; // Edge selection & drag state @@ -597,6 +599,16 @@ class FolkFlowsApp extends HTMLElement { +
+ + ${this.simSpeedMs}ms +
+
+
+
+
+ Tick 0 +
`; } @@ -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 { `; } + 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 = ``; return ` ${hitPath} - - + + + ${dashed ? `` : ""} ${dragHandle} @@ -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() {