From 8b43df5ce965027c521e18a67443b18c3a40903a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 19:23:46 -0700 Subject: [PATCH] fix(rflows): Sankey-style constant-width L-shaped ribbons for waterfalls Three fixes for S-curve appearance: - Constant width throughout each waterfall (no taper between source/river) - Stack inflow waterfalls side-by-side at funnel top proportionally - Widen spending drain to 80% of vessel bottom width Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/folk-flow-river.ts | 28 +++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/modules/rflows/components/folk-flow-river.ts b/modules/rflows/components/folk-flow-river.ts index e8831d0..225bdf4 100644 --- a/modules/rflows/components/folk-flow-river.ts +++ b/modules/rflows/components/folk-flow-river.ts @@ -266,6 +266,24 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { }); } + // Distribute inflows side-by-side at each funnel's top (Sankey stacking) + const inflowsPerFunnel = new Map(); + sourceWaterfalls.forEach(wf => { + if (!inflowsPerFunnel.has(wf.targetId)) inflowsPerFunnel.set(wf.targetId, []); + inflowsPerFunnel.get(wf.targetId)!.push(wf); + }); + inflowsPerFunnel.forEach((wfs, targetId) => { + const target = funnelLayouts.find(f => f.id === targetId); + if (!target || wfs.length <= 1) return; + const totalW = wfs.reduce((s, w) => s + w.width, 0); + const cx = target.x + target.vesselWidth / 2; + let offset = -totalW / 2; + wfs.forEach(wf => { + wf.x = cx + offset + wf.width / 2; + offset += wf.width; + }); + }); + // Overflow branches — from lip positions to target vessel top const overflowBranches: BranchLayout[] = []; funnelNodes.forEach((n) => { @@ -306,9 +324,10 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { const allocations = data.spendingAllocations || []; if (allocations.length === 0) return; const percentages = allocations.map((a) => a.percentage); - const slotWidths = distributeWidths(percentages, DRAIN_WIDTH * 2, MIN_WATERFALL_WIDTH); + const drainSpan = parentLayout.vesselBottomWidth * 0.8; + const slotWidths = distributeWidths(percentages, drainSpan, MIN_WATERFALL_WIDTH); const drainCx = parentLayout.x + parentLayout.vesselWidth / 2; - const startX = drainCx - DRAIN_WIDTH; + const startX = drainCx - drainSpan / 2; let offsetX = 0; allocations.forEach((alloc, i) => { const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId); @@ -368,8 +387,9 @@ function renderWaterfall(wf: WaterfallLayout): string { const height = wf.yEnd - wf.yStart; if (height <= 0) return ""; - const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth; - const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth; + // Constant width throughout — Sankey-style ribbon (no taper → no S-curve) + const topWidth = wf.width; + const bottomWidth = wf.width; const topCx = isInflow ? wf.xSource : wf.x; const bottomCx = isInflow ? wf.x : wf.xSource;