From d8f9f46515dd84d18853dff126c2b5dca244f113 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 5 Mar 2026 18:18:06 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20rFlows=20visual=20redesign=20=E2=80=94?= =?UTF-8?q?=20HTML=20card=20nodes,=20Sankey=20widths,=20smooth=20drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch source/outcome nodes from SVG shapes to foreignObject HTML cards with white backgrounds, gradient headers, status badges, and progress bars - Add foreignObject text overlay to funnel nodes (keep SVG tank shape) - Fix Sankey edge widths: flow-value-relative formula instead of percentage - Smooth drag: rAF throttle + lightweight edge path patching during drag - Add dot grid canvas background, arrowhead markers, larger port dots - Fixed node sizes: source 220x120, outcome 220x180 Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/flows.css | 19 +- modules/rflows/components/folk-flows-app.ts | 310 ++++++++++++++------ 2 files changed, 231 insertions(+), 98 deletions(-) diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 1572701..593b923 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -239,6 +239,9 @@ .flows-canvas-svg { width: 100%; height: 100%; display: block; cursor: grab; + background-color: #f8fafc; + background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px); + background-size: 20px 20px; } .flows-canvas-svg.panning { cursor: grabbing; } .flows-canvas-svg.dragging { cursor: move; } @@ -267,10 +270,20 @@ /* SVG node styles */ .flow-node { cursor: pointer; } -.flow-node:hover .node-bg { filter: brightness(1.15); } +.flow-node:hover .node-bg { filter: brightness(1.05); } .flow-node.selected .node-bg { stroke: var(--rs-primary-hover); stroke-width: 3; } .node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); } +/* HTML card nodes (foreignObject) */ +.node-card { + background: white; border-radius: 12px; overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + height: 100%; box-sizing: border-box; +} +.source-card { } +.outcome-card { } +.funnel-overlay { pointer-events: none; } + /* Editor panel — right side slide-in */ .flows-editor-panel { position: absolute; top: 0; right: 0; bottom: 0; width: 320px; z-index: 20; @@ -416,8 +429,8 @@ /* ── Port & wiring ──────────────────────────────────── */ .port-group { pointer-events: all; } .port-hit { cursor: crosshair; } -.port-dot { transition: r 0.15s, filter 0.15s; } -.port-group:hover .port-dot { r: 7; filter: drop-shadow(0 0 4px currentColor); } +.port-dot { transition: r 0.15s, filter 0.15s; r: 7; stroke: white; stroke-width: 2; } +.port-group:hover .port-dot { r: 9; filter: drop-shadow(0 0 4px currentColor); } .port-group--wiring-source .port-dot { animation: port-glow 0.8s ease-in-out infinite; } .port-group--wiring-target .port-dot { animation: port-breathe 1s ease-in-out infinite; } diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 7014c75..89eafad 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -141,6 +141,7 @@ class FolkFlowsApp extends HTMLElement { private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; private _boundPointerMove: ((e: PointerEvent) => void) | null = null; private _boundPointerUp: ((e: PointerEvent) => void) | null = null; + private _dragRafId: number | null = null; // Flow storage & switching private localFirstClient: FlowsLocalFirstClient | null = null; @@ -900,6 +901,17 @@ class FolkFlowsApp extends HTMLElement { + + + + + + + + + + + @@ -1030,23 +1042,16 @@ class FolkFlowsApp extends HTMLElement { private getNodeSize(n: FlowNode): { w: number; h: number } { if (n.type === "source") { - const d = n.data as SourceNodeData; - const rate = d.flowRate || 100; - // Low rate → tall & thin (recurring drip), high rate → short & thick (large chunk) - const ratio = Math.min(1, rate / 5000); - const w = 140 + Math.round(ratio * 140); // 140–280 - const h = 110 - Math.round(ratio * 40); // 110–70 - return { w, h }; + return { w: 220, h: 120 }; } if (n.type === "funnel") { const d = n.data as FunnelNodeData; const baseW = 280, baseH = 250; - // Height scales with capacity (draggable), width stays fixed const hRef = d.maxCapacity || 9000; const hScale = 0.8 + Math.log10(Math.max(1, hRef / 5000)) * 0.35; return { w: baseW, h: Math.round(baseH * Math.max(0.75, hScale)) }; } - return { w: 220, h: 120 }; // outcome (basin) + return { w: 220, h: 180 }; // outcome card } // ─── Canvas event wiring ────────────────────────────── @@ -1139,15 +1144,20 @@ class FolkFlowsApp extends HTMLElement { nodeDragStarted = true; svg.classList.add("dragging"); } - const dx = rawDx / this.canvasZoom; - const dy = rawDy / this.canvasZoom; - const node = this.nodes.find((n) => n.id === this.draggingNodeId); - if (node) { - node.position.x = this.dragNodeStartX + dx; - node.position.y = this.dragNodeStartY + dy; - this.updateNodePosition(node); - this.redrawEdges(); - } + // rAF throttle: skip if a frame is already queued + if (this._dragRafId) return; + this._dragRafId = requestAnimationFrame(() => { + this._dragRafId = null; + const dx = (e.clientX - this.dragStartX) / this.canvasZoom; + const dy = (e.clientY - this.dragStartY) / this.canvasZoom; + const node = this.nodes.find((n) => n.id === this.draggingNodeId); + if (node) { + node.position.x = this.dragNodeStartX + dx; + node.position.y = this.dragNodeStartY + dy; + this.updateNodePosition(node); + this.updateEdgesDuringDrag(node.id); + } + }); } }; this._boundPointerUp = (e: PointerEvent) => { @@ -1180,6 +1190,8 @@ class FolkFlowsApp extends HTMLElement { this.draggingNodeId = null; nodeDragStarted = false; svg.classList.remove("dragging"); + // Cancel any pending rAF + if (this._dragRafId) { cancelAnimationFrame(this._dragRafId); this._dragRafId = null; } // Single click = select + open inline editor if (!wasDragged) { @@ -1188,6 +1200,8 @@ class FolkFlowsApp extends HTMLElement { this.updateSelectionHighlight(); this.enterInlineEdit(clickedNodeId); } else { + // Full edge redraw for final accuracy + this.redrawEdges(); this.scheduleSave(); } } @@ -1584,16 +1598,32 @@ class FolkFlowsApp extends HTMLElement { const x = n.position.x, y = n.position.y, w = s.w, h = s.h; const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; const icon = icons[d.sourceType] || "\u{1F4B0}"; - const bodyH = h - 20; - const stubW = 26, stubH = 20; - const isSmall = d.flowRate < 1000; + + // Allocation bar segments as inline HTML + let allocBarHtml = ""; + if (d.targetAllocations && d.targetAllocations.length > 0) { + const segs = d.targetAllocations.map(a => + `
` + ).join(""); + allocBarHtml = `
${segs}
`; + } + return ` - - - ${icon} - ${this.esc(d.label)} - $${d.flowRate.toLocaleString()}/mo - ${this.renderAllocBar(d.targetAllocations, w, bodyH - 4)} + + +
+
+
+ ${icon} + ${this.esc(d.label)} +
+
+
+
$${d.flowRate.toLocaleString()}/mo
+
+ ${allocBarHtml} +
+
${this.renderPortsSvg(n)}
`; } @@ -1686,15 +1716,6 @@ class FolkFlowsApp extends HTMLElement { Max`; - // Zone labels (centered in each zone) - const criticalMidY = zoneTop + zoneH - criticalH / 2; - const sufficientMidY = zoneTop + overflowH + sufficientH / 2; - const overflowMidY = zoneTop + overflowH / 2; - const zoneLabels = ` - ${criticalH > 20 ? `CRITICAL` : ""} - ${sufficientH > 20 ? `SUFFICIENT` : ""} - ${overflowH > 20 ? `OVERFLOW` : ""}`; - // Inflow satisfaction bar const satBarY = 50; const satBarW = w - 48; @@ -1708,17 +1729,20 @@ class FolkFlowsApp extends HTMLElement { : !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : ""; // Rate labels - const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`; + const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`; const baseRate = d.desiredOutflow || d.inflowRate; let rateMultiplier: number; if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; else rateMultiplier = 0.1; const spendingRate = baseRate * rateMultiplier; - const spendingLabel = `\u2193 ${this.formatDollar(spendingRate)}/mo`; const excess = Math.max(0, d.currentValue - d.maxThreshold); const overflowLabel = isOverflow ? this.formatDollar(excess) : ""; + // Status badge colors for HTML + const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)"; + const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b"; + return ` @@ -1731,21 +1755,29 @@ class FolkFlowsApp extends HTMLElement { ${thresholdLines} - ${zoneLabels} - ${inflowLabel} - ${this.esc(d.label)} - ${statusLabel} - ${satLabel} - $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()} - ${this.formatDollar(outflow)}/mo ▾ - ${isOverflow ? `${overflowLabel} - ${overflowLabel}` : ""} + +
+
\u2193 ${inflowLabel}
+
+ ${this.esc(d.label)} + ${statusLabel} +
+
${satLabel}
+ ${criticalH > 20 ? `
CRITICAL
` : ""} + ${sufficientH > 20 ? `
SUFFICIENT
` : ""} + ${overflowH > 20 ? `
OVERFLOW
` : ""} +
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}
+
${this.formatDollar(outflow)}/mo \u25BE
+ ${isOverflow ? `
${overflowLabel}
+
${overflowLabel}
` : ""} +
+
${this.renderPortsSvg(n)} `; } @@ -1755,56 +1787,43 @@ class FolkFlowsApp extends HTMLElement { const s = this.getNodeSize(n); const x = n.position.x, y = n.position.y, w = s.w, h = s.h; const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0; - const statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)" - : d.status === "blocked" ? "var(--rflows-status-blocked)" - : d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)"; + const statusColors: Record = { completed: "#10b981", blocked: "#ef4444", "in-progress": "#3b82f6", "not-started": "#64748b" }; + const statusColor = statusColors[d.status] || "#64748b"; + const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase()); - // Basin shape: slightly flared walls (8px wider at top) - const flare = 8; - const clipId = `basin-clip-${n.id}`; - const basinPath = [ - `M ${-flare},0`, - `L ${w + flare},0`, - `Q ${w + flare},4 ${w + flare - 2},8`, - `L ${w},${h - 8}`, - `Q ${w},${h} ${w - 8},${h}`, - `L 8,${h}`, - `Q 0,${h} 0,${h - 8}`, - `L ${-flare + 2},8`, - `Q ${-flare},4 ${-flare},0`, - `Z`, - ].join(" "); - - // Fill level from bottom - const fillZoneTop = 30; - const fillZoneH = h - fillZoneTop - 4; - const fillH = fillZoneH * fillPct; - const fillY = fillZoneTop + fillZoneH - fillH; - - let phaseBars = ""; + // Phase indicators + let phaseHtml = ""; if (d.phases && d.phases.length > 0) { - const phaseW = (w - 20) / d.phases.length; - phaseBars = d.phases.map((p, i) => { + const unlockedCount = d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length; + const phaseSegs = d.phases.map((p, i) => { const unlocked = d.fundingReceived >= p.fundingThreshold; - return ``; + return `
`; }).join(""); - phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; + phaseHtml = `
${phaseSegs}
+
${unlockedCount}/${d.phases.length} phases unlocked
`; } const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`; return ` - - - - - - - - - ${this.esc(d.label)} - ${Math.round(fillPct * 100)}% — ${dollarLabel} - ${phaseBars} + + +
+
+
+ ${this.esc(d.label)} + ${statusLabel} +
+
+
+
${Math.round(fillPct * 100)}% funded — ${dollarLabel}
+
+
+
+ ${phaseHtml} +
+
+
${this.renderPortsSvg(n)}
`; } @@ -1921,15 +1940,16 @@ class FolkFlowsApp extends HTMLElement { } } - // Second pass: render edges with percentage-proportional widths + // Second pass: render edges with flow-value-relative widths (Sankey-style) const MAX_EDGE_W = 28; const MIN_EDGE_W = 3; + const maxFlow = Math.max(...edges.map(e => e.flowAmount), 1); let html = ""; for (const e of edges) { const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide); const to = this.getPortPosition(e.toNode, "inflow"); const isGhost = e.flowAmount === 0; - const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.pct / 100) * (MAX_EDGE_W - MIN_EDGE_W); + const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.flowAmount / maxFlow) * (MAX_EDGE_W - MIN_EDGE_W); const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`; html += this.renderEdgePath( from.x, from.y, to.x, to.y, @@ -2014,10 +2034,12 @@ class FolkFlowsApp extends HTMLElement { const halfW = labelW / 2; // Drag handle at midpoint const dragHandle = ``; + // Arrow marker + const markerId = edgeType === "overflow" ? "arrowhead-overflow" : edgeType === "spending" ? "arrowhead-spending" : "arrowhead-inflow"; return ` ${hitPath} - + ${dashed ? `` : ""} ${dragHandle} @@ -2040,6 +2062,101 @@ class FolkFlowsApp extends HTMLElement { if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); } + /** Pure path computation — returns { d, midX, midY } */ + private computeEdgePath( + x1: number, y1: number, x2: number, y2: number, + strokeW: number, fromSide?: "left" | "right", + waypoint?: { x: number; y: number }, + ): { d: string; midX: number; midY: number } { + let d: string, midX: number, midY: number; + if (waypoint) { + const cx1 = (4 * waypoint.x - x1 - x2) / 3; + const cy1 = (4 * waypoint.y - y1 - y2) / 3; + const c1x = x1 + (cx1 - x1) * 0.8; + const c1y = y1 + (cy1 - y1) * 0.8; + const c2x = x2 + (cx1 - x2) * 0.8; + const c2y = y2 + (cy1 - y2) * 0.8; + d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`; + midX = waypoint.x; + midY = waypoint.y; + } else if (fromSide) { + 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; + } else { + const cy1 = y1 + (y2 - y1) * 0.4; + const cy2 = y1 + (y2 - y1) * 0.6; + d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; + midX = (x1 + x2) / 2; + midY = (y1 + y2) / 2; + } + return { d, midX, midY }; + } + + /** Lightweight edge update during drag — only patches path `d` attrs and label positions for edges connected to dragged node */ + private updateEdgesDuringDrag(nodeId: string) { + const edgeLayer = this.shadow.getElementById("edge-layer"); + if (!edgeLayer) return; + const groups = edgeLayer.querySelectorAll(`.edge-group[data-from="${nodeId}"], .edge-group[data-to="${nodeId}"]`); + for (const g of groups) { + const el = g as SVGGElement; + const fromId = el.dataset.from!; + const toId = el.dataset.to!; + const edgeType = el.dataset.edgeType || "source"; + const fromNode = this.nodes.find(n => n.id === fromId); + const toNode = this.nodes.find(n => n.id === toId); + if (!fromNode || !toNode) continue; + + // Determine port kinds and side + let fromPort: PortKind = "outflow"; + let fromSide: "left" | "right" | undefined; + if (edgeType === "overflow") { + fromPort = "overflow"; + fromSide = this.getOverflowSideForTarget(fromNode, toNode); + } else if (edgeType === "spending") { + fromPort = "spending"; + } + + const from = this.getPortPosition(fromNode, fromPort, fromSide); + const to = this.getPortPosition(toNode, "inflow"); + + // Get waypoint from allocation + const alloc = this.findEdgeAllocation(fromId, toId, edgeType); + const waypoint = alloc?.waypoint; + + // Compute stroke width (approximate — use existing path width) + const mainPath = el.querySelector(".edge-path-animated, .edge-path-overflow, .edge-ghost"); + const existingStrokeW = mainPath ? parseFloat(mainPath.getAttribute("stroke-width") || "4") : 4; + + const { d, midX, midY } = this.computeEdgePath(from.x, from.y, to.x, to.y, existingStrokeW, fromSide, waypoint); + + // Update all path elements in this group + el.querySelectorAll("path").forEach(path => { + path.setAttribute("d", d); + }); + + // Update label/control group position + const ctrlGroup = el.querySelector(".edge-ctrl-group") as SVGGElement | null; + if (ctrlGroup) ctrlGroup.setAttribute("transform", `translate(${midX},${midY})`); + + // Update drag handle + const dragHandle = el.querySelector(".edge-drag-handle") as SVGCircleElement | null; + if (dragHandle) { + dragHandle.setAttribute("cx", String(midX)); + dragHandle.setAttribute("cy", String(midY - 18)); + } + + // Update splash circle for overflow edges + const splash = el.querySelector(".edge-splash") as SVGCircleElement | null; + if (splash) { + splash.setAttribute("cx", String(from.x)); + splash.setAttribute("cy", String(from.y)); + } + } + } + // ─── Edge waypoint helpers ────────────────────────────── private findEdgeAllocation(fromId: string, toId: string, edgeType: string): (OverflowAllocation | SpendingAllocation | SourceAllocation) | null { @@ -2081,21 +2198,24 @@ class FolkFlowsApp extends HTMLElement { const el = g as SVGGElement; const isSelected = el.dataset.nodeId === this.selectedNodeId; el.classList.toggle("selected", isSelected); + // Update SVG rect stroke const bg = el.querySelector(".node-bg") as SVGElement | null; if (bg) { if (isSelected) { bg.setAttribute("stroke", "var(--rflows-selected)"); bg.setAttribute("stroke-width", "3"); } else { - // Restore original color const node = this.nodes.find((n) => n.id === el.dataset.nodeId); if (node) { const origColor = this.getNodeBorderColor(node); bg.setAttribute("stroke", origColor); - bg.setAttribute("stroke-width", node.type === "outcome" ? "1.5" : "2"); + bg.setAttribute("stroke-width", node.type === "outcome" ? "2" : "2"); } } } + // Update HTML card selected class + const card = el.querySelector(".node-card") as HTMLElement | null; + if (card) card.classList.toggle("selected", isSelected); }); // Edge selection highlight @@ -2185,8 +2305,8 @@ class FolkFlowsApp extends HTMLElement { arrow = ``; } return ` - - + + ${arrow} `; }).join("");