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:
parent
3d4d2112dd
commit
d7c1aaae9c
|
|
@ -121,6 +121,9 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||||
|
|
||||||
const funnelLayouts: FunnelLayout[] = [];
|
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++) {
|
for (let layer = 0; layer <= maxLayer; layer++) {
|
||||||
const layerNodes = layerGroups.get(layer) || [];
|
const layerNodes = layerGroups.get(layer) || [];
|
||||||
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP);
|
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP);
|
||||||
|
|
@ -128,10 +131,13 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||||
|
|
||||||
layerNodes.forEach((n, i) => {
|
layerNodes.forEach((n, i) => {
|
||||||
const data = n.data as FunnelNodeData;
|
const data = n.data as FunnelNodeData;
|
||||||
const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1));
|
const outflow = data.desiredOutflow || data.inflowRate || 1;
|
||||||
// Pipe width = capacity (always full size), inner flow = fillRatio
|
const inflow = data.inflowRate || 0;
|
||||||
const capacityRatio = Math.min(1, (data.maxCapacity || 1) / 90000); // normalize to largest typical capacity
|
// Pipe width = desiredOutflow (what they need)
|
||||||
const riverWidth = MIN_RIVER_WIDTH + capacityRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH);
|
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 x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2);
|
||||||
const status: "healthy" | "overflow" | "critical" =
|
const status: "healthy" | "overflow" | "critical" =
|
||||||
data.currentValue > data.maxThreshold ? "overflow" :
|
data.currentValue > data.maxThreshold ? "overflow" :
|
||||||
|
|
@ -335,34 +341,38 @@ function renderFunnel(f: FunnelLayout): string {
|
||||||
const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold;
|
const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold;
|
||||||
const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant";
|
const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant";
|
||||||
|
|
||||||
// Pipe capacity = full riverWidth (outer boundary)
|
// Pipe = desiredOutflow (what they need), Flow = inflowRate (what they get)
|
||||||
// Active flow = inner height proportional to fillRatio
|
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 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 `
|
return `
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0%" 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.2"/>
|
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.18"/>
|
||||||
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.15"/>
|
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.12"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="${flowGradId}" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="${flowGradId}" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.7"/>
|
<stop offset="0%" stop-color="${flowColor}" stop-opacity="0.6"/>
|
||||||
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.9"/>
|
<stop offset="50%" stop-color="${underfunded ? '#f87171' : (colors[1] || colors[0])}" stop-opacity="0.85"/>
|
||||||
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.7"/>
|
<stop offset="100%" stop-color="${flowColor}" stop-opacity="0.6"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</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"/>` : ""}
|
${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"/>
|
<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})"/>
|
<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 - 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}" 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 {
|
function renderOutcome(o: OutcomeLayout): string {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue