From db1c0ec490b149828f0a5b644ddd41072817e825 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 17:06:45 -0700 Subject: [PATCH] feat(rflows): proportional flow pipes on all node types Scale source stream, funnel inflow/overflow/spending, and outcome inflow/overflow pipes using the same 8-80px global Sankey scale as edges, replacing fixed-width cosmetic pipes with flow-consistent ones. Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/folk-flows-app.ts | 81 ++++++++++++++++----- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 0d325c7..01d66fe 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -118,7 +118,7 @@ class FolkFlowsApp extends HTMLElement { private edgeDragPointerId: number | null = null; // Sankey flow width pre-pass results - private _currentFlowWidths: Map = new Map(); + private _currentFlowWidths: Map = new Map(); // Split control drag state private _splitDragging = false; @@ -1913,13 +1913,12 @@ class FolkFlowsApp extends HTMLElement { const nozzleEndX = w * 0.75; const nozzleStartY = pipeCY; const nozzleEndY = pipeCY + (nozzleEndX - nozzleStartX) * Math.tan(30 * Math.PI / 180); - const nozzleTopW = 12; // half-width at start - const nozzleBotW = 7; // half-width at end - const nozzlePath = `M ${nozzleStartX},${nozzleStartY - nozzleTopW} L ${nozzleEndX},${nozzleEndY - nozzleBotW} L ${nozzleEndX},${nozzleEndY + nozzleBotW} L ${nozzleStartX},${nozzleStartY + nozzleTopW} Z`; - - // Stream: rect from nozzle tip downward, width from Sankey pre-pass + // Stream width from Sankey pre-pass — nozzle tapers to match const fw = this._currentFlowWidths.get(n.id); - const streamW = fw ? Math.max(4, Math.round(fw.outflowWidthPx * 0.4)) : Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5)); + const streamW = fw ? Math.max(4, Math.round(fw.outflowWidthPx)) : 4; + const nozzleTopW = 12; // half-width at start + const nozzleBotW = Math.max(2, Math.round(streamW / 2)); // taper to match stream + const nozzlePath = `M ${nozzleStartX},${nozzleStartY - nozzleTopW} L ${nozzleEndX},${nozzleEndY - nozzleBotW} L ${nozzleEndX},${nozzleEndY + nozzleBotW} L ${nozzleStartX},${nozzleStartY + nozzleTopW} Z`; const streamX = nozzleEndX; const streamY = nozzleEndY + nozzleBotW; const streamH = h - streamY; @@ -2004,9 +2003,10 @@ class FolkFlowsApp extends HTMLElement { // taperAtBottom: how far walls inset at the very bottom (in px) const taperAtBottom = (w - drainW) / 2; - // Overflow pipe parameters — positioned at max threshold - const pipeW = 28; - const basePipeH = 22; + // Overflow pipe parameters — positioned at max threshold, width from Sankey pre-pass + const fwFunnel = this._currentFlowWidths.get(n.id); + const pipeW = this.getFunnelOverflowPipeW(n); + const basePipeH = Math.max(10, Math.round(pipeW * 0.55)); const zoneTop = 36; const zoneBot = h - 6; const zoneH = zoneBot - zoneTop; @@ -2125,14 +2125,14 @@ class FolkFlowsApp extends HTMLElement { ` : ""; - // Inflow pipe indicator (Sankey-consistent) - const fwFunnel = this._currentFlowWidths.get(n.id); - const inflowPipeW = fwFunnel ? Math.min(w - 20, Math.round(fwFunnel.inflowWidthPx)) : 0; + // Inflow pipe stub (Sankey-consistent) — enters from above + const inflowPipeW = fwFunnel ? Math.max(4, Math.min(w - 20, Math.round(fwFunnel.inflowWidthPx))) : 4; const inflowFillRatio = fwFunnel ? fwFunnel.inflowFillRatio : 0; const inflowPipeX = (w - inflowPipeW) / 2; - const inflowPipeIndicator = inflowPipeW > 0 ? ` - - ` : ""; + const inflowPipeH = inflowPipeW; + const inflowPipeIndicator = ` + + `; // Inflow satisfaction bar const satBarY = 50; @@ -2187,6 +2187,8 @@ class FolkFlowsApp extends HTMLElement { ◁ ${this.formatDollar(outflow)}/mo ▷ + + ${(() => { const spW = fwFunnel ? Math.max(4, Math.round(fwFunnel.spendingWidthPx)) : 4; return ``; })()} ${d.spendingAllocations.length >= 2 ? this.renderSplitControl(n.id, "spending", d.spendingAllocations, w / 2, h + 26, Math.min(w - 20, drainW + 60)) @@ -2280,10 +2282,19 @@ class FolkFlowsApp extends HTMLElement { phaseSeg = `${unlockedCount}/${d.phases.length} phases`; } + // Pipe stubs (Sankey-consistent) + const fwOutcome = this._currentFlowWidths.get(n.id); + const oInflowPipeW = fwOutcome ? Math.max(4, Math.round(fwOutcome.inflowWidthPx)) : 4; + const oInflowPipeH = oInflowPipeW; + const oInflowPipeX = (w - oInflowPipeW) / 2; + const oOverflowPipeW = fwOutcome && isOverfunded ? Math.max(4, Math.round(fwOutcome.overflowWidthPx)) : 0; + return ` + + @@ -2293,6 +2304,8 @@ class FolkFlowsApp extends HTMLElement { ${phaseMarkers} ${overflowSplash} + + ${oOverflowPipeW > 0 ? `` : ""}
@@ -2368,6 +2381,12 @@ class FolkFlowsApp extends HTMLElement { return `$${Math.round(amount)}`; } + /** Get proportional overflow pipe width for a funnel node */ + private getFunnelOverflowPipeW(node: FlowNode): number { + const fw = this._currentFlowWidths.get(node.id); + return fw ? Math.max(4, Math.min(60, Math.round(fw.overflowWidthPx))) : 4; + } + /** Pre-pass: compute per-node flow totals and Sankey-consistent pixel widths */ private computeFlowWidths(): void { const MIN_PX = 8, MAX_PX = 80; @@ -2432,7 +2451,31 @@ class FolkFlowsApp extends HTMLElement { else if (n.type === "outcome") neededInflow = Math.max((n.data as OutcomeNodeData).fundingTarget, 1); const inflowFillRatio = neededInflow > 0 ? Math.min(nf.totalInflow / neededInflow, 1) : 0; const inflowWidthPx = nf.totalInflow > 0 ? MIN_PX + (nf.totalInflow / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX; - this._currentFlowWidths.set(n.id, { totalOutflow: nf.totalOutflow, totalInflow: nf.totalInflow, outflowWidthPx, inflowWidthPx, inflowFillRatio }); + + // Overflow pipe width: excess beyond max threshold (funnels) or funding target (outcomes) + let overflowAmount = 0; + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + overflowAmount = Math.max(0, d.currentValue - d.maxThreshold); + } else if (n.type === "outcome") { + const d = n.data as OutcomeNodeData; + overflowAmount = Math.max(0, d.fundingReceived - d.fundingTarget); + } + const overflowWidthPx = overflowAmount > 0 ? MIN_PX + (overflowAmount / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX; + + // Spending pipe width: drain rate for funnels + let spendingAmount = 0; + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + let rateMultiplier: number; + if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; + else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; + else rateMultiplier = 0.1; + spendingAmount = d.inflowRate * rateMultiplier; + } + const spendingWidthPx = spendingAmount > 0 ? MIN_PX + (spendingAmount / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX; + + this._currentFlowWidths.set(n.id, { totalOutflow: nf.totalOutflow, totalInflow: nf.totalInflow, outflowWidthPx, inflowWidthPx, inflowFillRatio, overflowWidthPx, spendingWidthPx }); } } @@ -2853,7 +2896,7 @@ class FolkFlowsApp extends HTMLElement { const maxFrac = d.maxThreshold / (d.maxCapacity || 1); const maxLineY = zoneTop + zoneH * (1 - maxFrac); // X position: fully outside the vessel walls (pipe extends outward) - const pipeW = 28; + const pipeW = this.getFunnelOverflowPipeW(node); const xPos = def.side === "left" ? node.position.x - pipeW : node.position.x + s.w + pipeW; return { x: xPos, y: node.position.y + maxLineY }; } @@ -2881,7 +2924,7 @@ class FolkFlowsApp extends HTMLElement { const zoneTop = 36, zoneBot = s.h - 6, zoneH = zoneBot - zoneTop; const maxFrac = d.maxThreshold / (d.maxCapacity || 1); cy = zoneTop + zoneH * (1 - maxFrac); - const pipeW = 28; + const pipeW = this.getFunnelOverflowPipeW(n); cx = p.side === "left" ? -pipeW : s.w + pipeW; }