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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 17:06:45 -07:00
parent 30f037c2a0
commit db1c0ec490
1 changed files with 62 additions and 19 deletions

View File

@ -118,7 +118,7 @@ class FolkFlowsApp extends HTMLElement {
private edgeDragPointerId: number | null = null;
// Sankey flow width pre-pass results
private _currentFlowWidths: Map<string, { totalOutflow: number; totalInflow: number; outflowWidthPx: number; inflowWidthPx: number; inflowFillRatio: number }> = new Map();
private _currentFlowWidths: Map<string, { totalOutflow: number; totalInflow: number; outflowWidthPx: number; inflowWidthPx: number; inflowFillRatio: number; overflowWidthPx: number; spendingWidthPx: number }> = 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 {
<ellipse class="overflow-spill-left" cx="${-pipeW - 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>
<ellipse class="overflow-spill-right" cx="${w + pipeW + 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>` : "";
// 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 ? `
<rect x="${inflowPipeX}" y="26" width="${inflowPipeW}" height="6" rx="3" fill="var(--rs-bg-surface-raised)" opacity="0.3"/>
<rect x="${inflowPipeX}" y="26" width="${Math.round(inflowPipeW * inflowFillRatio)}" height="6" rx="3" fill="var(--rflows-label-inflow)" opacity="0.7"/>` : "";
const inflowPipeH = inflowPipeW;
const inflowPipeIndicator = `
<rect x="${inflowPipeX}" y="${-inflowPipeH}" width="${inflowPipeW}" height="${inflowPipeH}" rx="3" fill="var(--rs-bg-surface-raised)" opacity="0.3"/>
<rect x="${inflowPipeX}" y="${-inflowPipeH}" width="${inflowPipeW}" height="${Math.round(inflowPipeH * inflowFillRatio)}" rx="3" fill="var(--rflows-label-inflow)" opacity="0.7"/>`;
// Inflow satisfaction bar
const satBarY = 50;
@ -2187,6 +2187,8 @@ class FolkFlowsApp extends HTMLElement {
${this.formatDollar(outflow)}/mo
</text>
</g>
<!-- Spending drain pipe stub -->
${(() => { const spW = fwFunnel ? Math.max(4, Math.round(fwFunnel.spendingWidthPx)) : 4; return `<rect x="${(w / 2) - spW / 2}" y="${h}" width="${spW}" height="18" rx="${Math.min(4, spW / 2)}" fill="var(--rflows-edge-spending)" opacity="0.6"/>`; })()}
<!-- Spending split control at drain spout -->
${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 = `<span style="font-size:9px;color:var(--rs-text-secondary)">${unlockedCount}/${d.phases.length} phases</span>`;
}
// 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 `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<defs>
<clipPath id="${clipId}"><path d="${basinClosedPath}"/></clipPath>
</defs>
<!-- Inflow pipe stub above basin -->
<rect x="${oInflowPipeX}" y="${-oInflowPipeH}" width="${oInflowPipeW}" height="${oInflowPipeH}" rx="3" fill="var(--rflows-label-inflow)" opacity="0.4"/>
<!-- Basin outline -->
<path class="node-bg basin-outline" d="${basinPath}" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}" stroke-linecap="round"/>
<!-- Water fill clipped to basin -->
@ -2293,6 +2304,8 @@ class FolkFlowsApp extends HTMLElement {
${phaseMarkers}
</g>
${overflowSplash}
<!-- Overflow pipe stub below basin (when overfunded) -->
${oOverflowPipeW > 0 ? `<rect x="${(w - oOverflowPipeW) / 2}" y="${h}" width="${oOverflowPipeW}" height="18" rx="${Math.min(4, oOverflowPipeW / 2)}" fill="var(--rflows-status-overflow)" opacity="0.6"/>` : ""}
<!-- Header above basin -->
<foreignObject x="-10" y="-32" width="${w + 20}" height="34">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:center;gap:6px;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
@ -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;
}