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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 19:23:46 -07:00
parent aa4a200f32
commit 8b43df5ce9
1 changed files with 24 additions and 4 deletions

View File

@ -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<string, WaterfallLayout[]>();
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;