feat(rflows): pipe size = desiredOutflow, flow = inflowRate

River visualization now shows:
- Pipe width = monthly desiredOutflow (what the funnel needs)
- Inner flow height = inflowRate (what it actually receives)
- Underfunded funnels (<95%) shown in red with funding percentage
- Label shows "$inflow → $outflow/mo" for at-a-glance funding health
- Fully funded funnels get sufficiency sparkle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 04:52:55 +00:00
parent 3d4d2112dd
commit d7c1aaae9c
1 changed files with 28 additions and 18 deletions

View File

@ -121,6 +121,9 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
const funnelLayouts: FunnelLayout[] = [];
// Find max desiredOutflow to normalize pipe widths
const maxOutflow = Math.max(1, ...funnelNodes.map((n) => (n.data as FunnelNodeData).desiredOutflow || (n.data as FunnelNodeData).inflowRate || 1));
for (let layer = 0; layer <= maxLayer; layer++) {
const layerNodes = layerGroups.get(layer) || [];
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP);
@ -128,10 +131,13 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
layerNodes.forEach((n, i) => {
const data = n.data as FunnelNodeData;
const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1));
// Pipe width = capacity (always full size), inner flow = fillRatio
const capacityRatio = Math.min(1, (data.maxCapacity || 1) / 90000); // normalize to largest typical capacity
const riverWidth = MIN_RIVER_WIDTH + capacityRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH);
const outflow = data.desiredOutflow || data.inflowRate || 1;
const inflow = data.inflowRate || 0;
// Pipe width = desiredOutflow (what they need)
const outflowRatio = Math.min(1, outflow / maxOutflow);
const riverWidth = MIN_RIVER_WIDTH + outflowRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH);
// Fill ratio = inflowRate / desiredOutflow (how funded they are)
const fillRatio = Math.min(1, inflow / (outflow || 1));
const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2);
const status: "healthy" | "overflow" | "critical" =
data.currentValue > data.maxThreshold ? "overflow" :
@ -335,34 +341,38 @@ function renderFunnel(f: FunnelLayout): string {
const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold;
const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant";
// Pipe capacity = full riverWidth (outer boundary)
// Active flow = inner height proportional to fillRatio
// Pipe = desiredOutflow (what they need), Flow = inflowRate (what they get)
const outflow = f.data.desiredOutflow || f.data.inflowRate || 1;
const inflow = f.data.inflowRate || 0;
const flowHeight = Math.max(2, f.riverWidth * f.fillRatio);
const flowY = f.y + (f.riverWidth - flowHeight); // flow fills from bottom
const flowY = f.y + (f.riverWidth - flowHeight) / 2; // center the flow vertically
const fundingPct = Math.round(f.fillRatio * 100);
const underfunded = f.fillRatio < 0.95;
const flowColor = underfunded ? "#ef4444" : colors[0]; // red tint when underfunded
return `
<defs>
<linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.15"/>
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.2"/>
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.15"/>
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.12"/>
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.18"/>
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.12"/>
</linearGradient>
<linearGradient id="${flowGradId}" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.7"/>
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.9"/>
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.7"/>
<stop offset="0%" stop-color="${flowColor}" stop-opacity="0.6"/>
<stop offset="50%" stop-color="${underfunded ? '#f87171' : (colors[1] || colors[0])}" stop-opacity="0.85"/>
<stop offset="100%" stop-color="${flowColor}" stop-opacity="0.6"/>
</linearGradient>
</defs>
${isSufficient ? `<rect x="${f.x - 4}" y="${f.y - 4}" width="${f.segmentLength + 8}" height="${f.riverWidth + 8}" rx="6" fill="none" stroke="${COLORS.goldenGlow}" stroke-width="2" opacity="0.6" style="animation:shimmer 2s ease-in-out infinite"/>` : ""}
<!-- Pipe capacity (outer boundary) -->
<!-- Pipe = desiredOutflow (outer boundary shows what they need) -->
<rect x="${f.x}" y="${f.y}" width="${f.segmentLength}" height="${f.riverWidth}" rx="4" fill="url(#${gradId})" stroke="${colors[0]}" stroke-width="0.5" stroke-opacity="0.3"/>
<!-- Active flow (inner fill) -->
<!-- Flow = inflowRate (inner fill shows what they actually receive) -->
<rect x="${f.x}" y="${flowY}" width="${f.segmentLength}" height="${flowHeight}" rx="${Math.min(4, flowHeight / 2)}" fill="url(#${flowGradId})"/>
${f.fillRatio > 0.02 ? [0, 1, 2].map((i) => `<rect x="${f.x}" y="${flowY + (flowHeight / 4) * i}" width="${f.segmentLength}" height="${Math.max(2, flowHeight / 4)}" fill="${colors[0]}" opacity="0.1" style="animation:waterFlow ${2 + i * 0.5}s linear infinite;animation-delay:${i * -0.6}s"/>`).join("") : ""}
${f.fillRatio > 0.02 ? [0, 1, 2].map((i) => `<rect x="${f.x}" y="${flowY + (flowHeight / 4) * i}" width="${f.segmentLength}" height="${Math.max(2, flowHeight / 4)}" fill="${flowColor}" opacity="0.1" style="animation:waterFlow ${2 + i * 0.5}s linear infinite;animation-delay:${i * -0.6}s"/>`).join("") : ""}
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 12}" text-anchor="middle" style="fill:${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 2}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10">$${Math.floor(f.data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} ${isSufficient ? "✨" : ""}</text>
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 2}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10">$${Math.floor(inflow).toLocaleString()} $${Math.floor(outflow).toLocaleString()}/mo ${underfunded ? `(${fundingPct}%)` : "✨"}</text>
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength}" height="3" rx="1.5" style="fill:${COLORS.surfaceRaised}"/>
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength * f.fillRatio}" height="3" rx="1.5" fill="${colors[0]}"/>`;
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength * f.fillRatio}" height="3" rx="1.5" fill="${underfunded ? flowColor : colors[0]}"/>`;
}
function renderOutcome(o: OutcomeLayout): string {