feat(rflows): overhaul river view with tap/faucet sources and trapezoid vessel funnels

Replace plain rectangles with tap/faucet SVG graphics for source nodes
(draggable valve handle, metallic gradients, animated water stream) and
trapezoid vessel shapes for funnel nodes (water fill, wave surface,
threshold markers, overflow lips with pour animations). Overflow pipes
now render as 3-layer bezier connections from vessel lips. Add amount
popover with date scheduling, event delegation for interactivity, and
rAF-throttled valve dragging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 17:23:33 -07:00
parent befd70c72b
commit 02b9feb760
2 changed files with 499 additions and 136 deletions

View File

@ -2,6 +2,12 @@
* <folk-flow-river> animated SVG sankey river visualization. * <folk-flow-river> animated SVG sankey river visualization.
* Pure renderer: receives nodes via setNodes() or falls back to demo data. * Pure renderer: receives nodes via setNodes() or falls back to demo data.
* Parent component (folk-flows-app) handles data fetching and mapping. * Parent component (folk-flows-app) handles data fetching and mapping.
*
* Visual vocabulary:
* Source tap/faucet with draggable valve handle
* Funnel trapezoid vessel with water fill + overflow lips
* Branch pipe from vessel lip to downstream vessel
* Outcome pool (unchanged)
*/ */
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types";
@ -19,26 +25,40 @@ interface RiverLayout {
spendingWaterfalls: WaterfallLayout[]; spendingWaterfalls: WaterfallLayout[];
width: number; width: number;
height: number; height: number;
maxSourceFlowRate: number;
} }
interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; } interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; valveAngle: number; sourceType: string; }
interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; riverWidth: number; fillRatio: number; segmentLength: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; } interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; vesselWidth: number; vesselHeight: number; vesselBottomWidth: number; fillLevel: number; overflowLevel: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; leftOverflowPipes: OverflowPipe[]; rightOverflowPipes: OverflowPipe[]; }
interface OverflowPipe { targetId: string; percentage: number; lipY: number; lipX: number; side: "left" | "right"; flowAmount: number; isActive: boolean; }
interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; poolWidth: number; fillPercent: number; } interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; poolWidth: number; fillPercent: number; }
interface WaterfallLayout { id: string; sourceId: string; targetId: string; label: string; percentage: number; x: number; xSource: number; yStart: number; yEnd: number; width: number; riverEndWidth: number; farEndWidth: number; direction: "inflow" | "outflow"; color: string; flowAmount: number; } interface WaterfallLayout { id: string; sourceId: string; targetId: string; label: string; percentage: number; x: number; xSource: number; yStart: number; yEnd: number; width: number; riverEndWidth: number; farEndWidth: number; direction: "inflow" | "outflow"; color: string; flowAmount: number; }
interface BranchLayout { sourceId: string; targetId: string; percentage: number; x1: number; y1: number; x2: number; y2: number; width: number; color: string; } interface BranchLayout { sourceId: string; targetId: string; percentage: number; x1: number; y1: number; x2: number; y2: number; width: number; color: string; side: "left" | "right"; isActive: boolean; flowAmount: number; }
// ─── Constants ─────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────
const LAYER_HEIGHT = 180; const TAP_WIDTH = 140;
const WATERFALL_HEIGHT = 140; const TAP_HEIGHT = 100;
const GAP = 50; const VALVE_RADIUS = 18;
const HANDLE_LENGTH = 24;
const VESSEL_WIDTH = 200;
const VESSEL_HEIGHT = 140;
const VESSEL_BOTTOM_WIDTH = 100;
const OVERFLOW_LIP_WIDTH = 20;
const DRAIN_WIDTH = 24;
const LAYER_HEIGHT = 240;
const WATERFALL_HEIGHT = 160;
const GAP = 60;
const MIN_RIVER_WIDTH = 24; const MIN_RIVER_WIDTH = 24;
const MAX_RIVER_WIDTH = 100; const MAX_RIVER_WIDTH = 100;
const MIN_WATERFALL_WIDTH = 4; const MIN_WATERFALL_WIDTH = 4;
const SEGMENT_LENGTH = 220; const SEGMENT_LENGTH = 220;
const POOL_WIDTH = 110; const POOL_WIDTH = 110;
const POOL_HEIGHT = 65; const POOL_HEIGHT = 65;
const SOURCE_HEIGHT = 45; const SOURCE_HEIGHT = TAP_HEIGHT;
const MAX_PIPE_WIDTH = 20;
const COLORS = { const COLORS = {
sourceWaterfall: "#10b981", sourceWaterfall: "#10b981",
@ -50,11 +70,14 @@ const COLORS = {
spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"], spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"],
outcomePool: "#3b82f6", outcomePool: "#3b82f6",
goldenGlow: "#fbbf24", goldenGlow: "#fbbf24",
metal: ["#64748b", "#94a3b8", "#64748b"],
water: "#38bdf8",
bg: "var(--rs-bg-page)", bg: "var(--rs-bg-page)",
surface: "var(--rs-bg-surface)", surface: "var(--rs-bg-surface)",
surfaceRaised: "var(--rs-bg-surface-raised)", surfaceRaised: "var(--rs-bg-surface-raised)",
text: "var(--rs-text-primary)", text: "var(--rs-text-primary)",
textMuted: "var(--rs-text-secondary)", textMuted: "var(--rs-text-secondary)",
sourceTypeColors: { card: "#10b981", safe_wallet: "#8b5cf6", ridentity: "#3b82f6", metamask: "#f59e0b", unconfigured: "#64748b" } as Record<string, string>,
}; };
function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] { function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] {
@ -73,13 +96,24 @@ function distributeWidths(percentages: number[], totalAvailable: number, minWidt
return widths; return widths;
} }
// ─── Layout engine (faithful port) ────────────────────── /** Interpolate left/right edges of trapezoid vessel at a given Y fraction (0=top, 1=bottom) */
function vesselEdgesAtY(x: number, topW: number, bottomW: number, height: number, yFrac: number): { left: number; right: number } {
const halfTop = topW / 2;
const halfBottom = bottomW / 2;
const cx = x + topW / 2;
const halfAtY = halfTop + (halfBottom - halfTop) * yFrac;
return { left: cx - halfAtY, right: cx + halfAtY };
}
// ─── Layout engine ──────────────────────────────────────
function computeLayout(nodes: FlowNode[]): RiverLayout { function computeLayout(nodes: FlowNode[]): RiverLayout {
const funnelNodes = nodes.filter((n) => n.type === "funnel"); const funnelNodes = nodes.filter((n) => n.type === "funnel");
const outcomeNodes = nodes.filter((n) => n.type === "outcome"); const outcomeNodes = nodes.filter((n) => n.type === "outcome");
const sourceNodes = nodes.filter((n) => n.type === "source"); const sourceNodes = nodes.filter((n) => n.type === "source");
const maxSourceFlowRate = Math.max(1, ...sourceNodes.map((n) => (n.data as SourceNodeData).flowRate));
const overflowTargets = new Set<string>(); const overflowTargets = new Set<string>();
const spendingTargets = new Set<string>(); const spendingTargets = new Set<string>();
@ -121,37 +155,62 @@ 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);
const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2; const totalWidth = layerNodes.length * VESSEL_WIDTH + (layerNodes.length - 1) * GAP * 2;
layerNodes.forEach((n, i) => { layerNodes.forEach((n, i) => {
const data = n.data as FunnelNodeData; const data = n.data as FunnelNodeData;
const outflow = data.desiredOutflow || data.inflowRate || 1;
const inflow = data.inflowRate || 0; const inflow = data.inflowRate || 0;
// Pipe width = desiredOutflow (what they need) const fillLevel = Math.min(1, data.currentValue / (data.maxCapacity || 1));
const outflowRatio = Math.min(1, outflow / maxOutflow); const overflowLevel = data.maxThreshold / (data.maxCapacity || 1);
const riverWidth = MIN_RIVER_WIDTH + outflowRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH); const x = -totalWidth / 2 + i * (VESSEL_WIDTH + GAP * 2);
// 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" = const status: "healthy" | "overflow" | "critical" =
data.currentValue > data.maxThreshold ? "overflow" : data.currentValue > data.maxThreshold ? "overflow" :
data.currentValue < data.minThreshold ? "critical" : "healthy"; data.currentValue < data.minThreshold ? "critical" : "healthy";
funnelLayouts.push({ id: n.id, label: data.label, data, x, y: layerY, riverWidth, fillRatio, segmentLength: SEGMENT_LENGTH, layer, status, sufficiency: computeSufficiencyState(data) }); // Distribute overflow allocations to left/right lips
const overflows = data.overflowAllocations || [];
const leftOverflowPipes: OverflowPipe[] = [];
const rightOverflowPipes: OverflowPipe[] = [];
const lipY = layerY + VESSEL_HEIGHT * (1 - overflowLevel);
const edges = vesselEdgesAtY(x, VESSEL_WIDTH, VESSEL_BOTTOM_WIDTH, VESSEL_HEIGHT, 1 - overflowLevel);
overflows.forEach((alloc, idx) => {
const isActive = data.currentValue > data.maxThreshold;
const excess = isActive ? data.currentValue - data.maxThreshold : 0;
const flowAmt = excess * (alloc.percentage / 100);
const pipe: OverflowPipe = {
targetId: alloc.targetId,
percentage: alloc.percentage,
lipY,
lipX: idx % 2 === 0 ? edges.left : edges.right,
side: idx % 2 === 0 ? "left" : "right",
flowAmount: flowAmt,
isActive,
};
if (pipe.side === "left") leftOverflowPipes.push(pipe);
else rightOverflowPipes.push(pipe);
});
funnelLayouts.push({
id: n.id, label: data.label, data, x, y: layerY,
vesselWidth: VESSEL_WIDTH, vesselHeight: VESSEL_HEIGHT,
vesselBottomWidth: VESSEL_BOTTOM_WIDTH,
fillLevel, overflowLevel, layer, status,
sufficiency: computeSufficiencyState(data),
leftOverflowPipes, rightOverflowPipes,
});
}); });
} }
// Source layouts // Source layouts
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => { const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
const data = n.data as SourceNodeData; const data = n.data as SourceNodeData;
const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP; const totalWidth = sourceNodes.length * TAP_WIDTH + (sourceNodes.length - 1) * GAP;
return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (120 + GAP), y: sourceLayerY, width: 120 }; const valveAngle = (data.flowRate / maxSourceFlowRate) * 90;
return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (TAP_WIDTH + GAP), y: sourceLayerY, width: TAP_WIDTH, valveAngle, sourceType: data.sourceType || "unconfigured" };
}); });
// Source waterfalls // Source waterfalls
@ -177,16 +236,20 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || []; const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || [];
const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0); const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0);
const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1; const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1;
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth); const targetTopWidth = targetLayout.vesselWidth;
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8); const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetTopWidth * 0.4);
const myIndex = allInflowsToTarget.findIndex((i) => i.sourceNodeId === sn.id && i.allocIndex === allocIdx); const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.5);
const inflowWidths = distributeWidths(allInflowsToTarget.map((i) => i.flowAmount), targetLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH); // Target: top-center of vessel
const startX = targetLayout.x + targetLayout.segmentLength * 0.15; const targetCenterX = targetLayout.x + targetTopWidth / 2;
let offsetX = 0;
for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k];
const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2;
const sourceCenterX = sourceLayout.x + sourceLayout.width / 2; const sourceCenterX = sourceLayout.x + sourceLayout.width / 2;
sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "inflow", color: COLORS.sourceWaterfall, flowAmount }); sourceWaterfalls.push({
id: `src-wf-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId,
label: `${alloc.percentage}%`, percentage: alloc.percentage,
x: targetCenterX, xSource: sourceCenterX,
yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y,
width: riverEndWidth, riverEndWidth, farEndWidth,
direction: "inflow", color: COLORS.sourceWaterfall, flowAmount,
});
}); });
}); });
@ -197,21 +260,31 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
if (data.inflowRate <= 0) return; if (data.inflowRate <= 0) return;
const layout = funnelLayouts.find((f) => f.id === rn.id); const layout = funnelLayouts.find((f) => f.id === rn.id);
if (!layout) return; if (!layout) return;
sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: layout.x + layout.segmentLength / 2, xSource: layout.x + layout.segmentLength / 2, yStart: GAP, yEnd: layout.y, width: layout.riverWidth, riverEndWidth: layout.riverWidth, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate }); const cx = layout.x + layout.vesselWidth / 2;
const w = Math.max(MIN_WATERFALL_WIDTH, layout.vesselWidth * 0.3);
sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: cx, xSource: cx, yStart: GAP, yEnd: layout.y, width: w, riverEndWidth: w, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, w * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate });
}); });
} }
// Overflow branches // Overflow branches — from lip positions to target vessel top
const overflowBranches: BranchLayout[] = []; const overflowBranches: BranchLayout[] = [];
funnelNodes.forEach((n) => { funnelNodes.forEach((n) => {
const data = n.data as FunnelNodeData; const data = n.data as FunnelNodeData;
const parentLayout = funnelLayouts.find((f) => f.id === n.id); const parentLayout = funnelLayouts.find((f) => f.id === n.id);
if (!parentLayout) return; if (!parentLayout) return;
data.overflowAllocations?.forEach((alloc) => { const allPipes = [...parentLayout.leftOverflowPipes, ...parentLayout.rightOverflowPipes];
const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId); allPipes.forEach((pipe) => {
const childLayout = funnelLayouts.find((f) => f.id === pipe.targetId);
if (!childLayout) return; if (!childLayout) return;
const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth); const width = Math.max(MIN_WATERFALL_WIDTH, (pipe.percentage / 100) * MAX_PIPE_WIDTH);
overflowBranches.push({ sourceId: n.id, targetId: alloc.targetId, percentage: alloc.percentage, x1: parentLayout.x + parentLayout.segmentLength, y1: parentLayout.y + parentLayout.riverWidth / 2, x2: childLayout.x, y2: childLayout.y + childLayout.riverWidth / 2, width, color: alloc.color || COLORS.overflowBranch }); const targetCx = childLayout.x + childLayout.vesselWidth / 2;
overflowBranches.push({
sourceId: n.id, targetId: pipe.targetId, percentage: pipe.percentage,
x1: pipe.lipX, y1: pipe.lipY,
x2: targetCx, y2: childLayout.y,
width, color: data.overflowAllocations?.find((a) => a.targetId === pipe.targetId)?.color || COLORS.overflowBranch,
side: pipe.side, isActive: pipe.isActive, flowAmount: pipe.flowAmount,
});
}); });
}); });
@ -224,7 +297,7 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
return { id: n.id, label: data.label, data, x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), y: outcomeY, poolWidth: POOL_WIDTH, fillPercent }; return { id: n.id, label: data.label, data, x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), y: outcomeY, poolWidth: POOL_WIDTH, fillPercent };
}); });
// Spending waterfalls // Spending waterfalls — from vessel bottom drain to outcome pools
const spendingWaterfalls: WaterfallLayout[] = []; const spendingWaterfalls: WaterfallLayout[] = [];
funnelNodes.forEach((n) => { funnelNodes.forEach((n) => {
const data = n.data as FunnelNodeData; const data = n.data as FunnelNodeData;
@ -233,42 +306,59 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
const allocations = data.spendingAllocations || []; const allocations = data.spendingAllocations || [];
if (allocations.length === 0) return; if (allocations.length === 0) return;
const percentages = allocations.map((a) => a.percentage); const percentages = allocations.map((a) => a.percentage);
const slotWidths = distributeWidths(percentages, parentLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH); const slotWidths = distributeWidths(percentages, DRAIN_WIDTH * 2, MIN_WATERFALL_WIDTH);
const riverEndWidths = distributeWidths(percentages, parentLayout.riverWidth, MIN_WATERFALL_WIDTH); const drainCx = parentLayout.x + parentLayout.vesselWidth / 2;
const startX = parentLayout.x + parentLayout.segmentLength * 0.15; const startX = drainCx - DRAIN_WIDTH;
let offsetX = 0; let offsetX = 0;
allocations.forEach((alloc, i) => { allocations.forEach((alloc, i) => {
const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId); const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId);
if (!outcomeLayout) return; if (!outcomeLayout) return;
const riverEndWidth = riverEndWidths[i]; const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, slotWidths[i]);
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6); const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6);
const riverCenterX = startX + offsetX + slotWidths[i] / 2; const riverCenterX = startX + offsetX + slotWidths[i] / 2;
offsetX += slotWidths[i]; offsetX += slotWidths[i];
const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2; const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2;
spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, sourceId: n.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: poolCenterX, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "outflow", color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1) }); spendingWaterfalls.push({
id: `spend-wf-${n.id}-${alloc.targetId}`, sourceId: n.id, targetId: alloc.targetId,
label: `${alloc.percentage}%`, percentage: alloc.percentage,
x: riverCenterX, xSource: poolCenterX,
yStart: parentLayout.y + VESSEL_HEIGHT + 4, yEnd: outcomeLayout.y,
width: riverEndWidth, riverEndWidth, farEndWidth,
direction: "outflow",
color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length],
flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1),
});
}); });
}); });
// Compute bounds and normalize // Compute bounds and normalize
const allX = [...funnelLayouts.map((f) => f.x), ...funnelLayouts.map((f) => f.x + f.segmentLength), ...outcomeLayouts.map((o) => o.x), ...outcomeLayouts.map((o) => o.x + o.poolWidth), ...sourceLayouts.map((s) => s.x), ...sourceLayouts.map((s) => s.x + s.width)]; const allX = [
const allY = [...funnelLayouts.map((f) => f.y + f.riverWidth), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY]; ...funnelLayouts.map((f) => f.x), ...funnelLayouts.map((f) => f.x + f.vesselWidth),
...outcomeLayouts.map((o) => o.x), ...outcomeLayouts.map((o) => o.x + o.poolWidth),
...sourceLayouts.map((s) => s.x), ...sourceLayouts.map((s) => s.x + s.width),
];
const allY = [...funnelLayouts.map((f) => f.y + VESSEL_HEIGHT), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY];
const minX = Math.min(...allX, -100); const minX = Math.min(...allX, -100);
const maxX = Math.max(...allX, 100); const maxX = Math.max(...allX, 100);
const maxY = Math.max(...allY, 400); const maxY = Math.max(...allY, 400);
const padding = 80; const padding = 100;
const offsetXGlobal = -minX + padding; const offsetXGlobal = -minX + padding;
const offsetYGlobal = padding; const offsetYGlobal = padding;
funnelLayouts.forEach((f) => { f.x += offsetXGlobal; f.y += offsetYGlobal; }); funnelLayouts.forEach((f) => {
f.x += offsetXGlobal; f.y += offsetYGlobal;
f.leftOverflowPipes.forEach((p) => { p.lipX += offsetXGlobal; p.lipY += offsetYGlobal; });
f.rightOverflowPipes.forEach((p) => { p.lipX += offsetXGlobal; p.lipY += offsetYGlobal; });
});
outcomeLayouts.forEach((o) => { o.x += offsetXGlobal; o.y += offsetYGlobal; }); outcomeLayouts.forEach((o) => { o.x += offsetXGlobal; o.y += offsetYGlobal; });
sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.y += offsetYGlobal; }); sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.y += offsetYGlobal; });
sourceWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; }); sourceWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; });
overflowBranches.forEach((b) => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal; }); overflowBranches.forEach((b) => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal; });
spendingWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; }); spendingWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; });
return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetYGlobal + padding }; return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetYGlobal + padding, maxSourceFlowRate };
} }
// ─── SVG Rendering ────────────────────────────────────── // ─── SVG Rendering ──────────────────────────────────────
@ -300,18 +390,13 @@ function renderWaterfall(wf: WaterfallLayout): string {
const pathMinX = Math.min(tl, bl) - 5; const pathMinX = Math.min(tl, bl) - 5;
const pathMaxW = Math.max(topWidth, bottomWidth) + 10; const pathMaxW = Math.max(topWidth, bottomWidth) + 10;
// Center spine path for the flowing dashes
const spineMidX = (topCx + bottomCx) / 2;
const spinePath = `M ${topCx} ${wf.yStart} C ${topCx} ${cpY1}, ${bottomCx} ${cpY2}, ${bottomCx} ${wf.yEnd}`; const spinePath = `M ${topCx} ${wf.yStart} C ${topCx} ${cpY1}, ${bottomCx} ${cpY2}, ${bottomCx} ${wf.yEnd}`;
// Exit/entry point positions
const exitCx = isInflow ? topCx : bottomCx;
const exitY = isInflow ? wf.yStart : wf.yEnd;
const entryCx = isInflow ? bottomCx : topCx; const entryCx = isInflow ? bottomCx : topCx;
const entryY = isInflow ? wf.yEnd : wf.yStart; const entryY = isInflow ? wf.yEnd : wf.yStart;
const entryWidth = isInflow ? bottomWidth : topWidth; const entryWidth = isInflow ? bottomWidth : topWidth;
const exitCx = isInflow ? topCx : bottomCx;
const exitY = isInflow ? wf.yStart : wf.yEnd;
// Flow amount label
const labelX = (topCx + bottomCx) / 2; const labelX = (topCx + bottomCx) / 2;
const labelY = wf.yStart + height * 0.45; const labelY = wf.yStart + height * 0.45;
const flowLabel = wf.flowAmount >= 1000 ? `$${(wf.flowAmount / 1000).toFixed(1)}k` : `$${Math.floor(wf.flowAmount)}`; const flowLabel = wf.flowAmount >= 1000 ? `$${(wf.flowAmount / 1000).toFixed(1)}k` : `$${Math.floor(wf.flowAmount)}`;
@ -332,95 +417,209 @@ function renderWaterfall(wf: WaterfallLayout): string {
<feGaussianBlur stdDeviation="3"/> <feGaussianBlur stdDeviation="3"/>
</filter> </filter>
</defs> </defs>
<!-- Faint channel background -->
<path d="${shapePath}" fill="${wf.color}" opacity="0.06"/> <path d="${shapePath}" fill="${wf.color}" opacity="0.06"/>
<!-- Main flow body -->
<path d="${shapePath}" fill="url(#${gradId})"/> <path d="${shapePath}" fill="url(#${gradId})"/>
<!-- Animated flow strips (5 layers for denser flow) -->
<g clip-path="url(#${clipId})"> <g clip-path="url(#${clipId})">
${[0, 1, 2, 3, 4].map((i) => `<rect x="${pathMinX}" y="${wf.yStart - height}" width="${pathMaxW}" height="${height * 0.6}" fill="${wf.color}" opacity="${0.15 + i * 0.03}" style="animation:waterFlow ${0.8 + i * 0.25}s linear infinite;animation-delay:${i * -0.15}s"/>`).join("")} ${[0, 1, 2, 3, 4].map((i) => `<rect x="${pathMinX}" y="${wf.yStart - height}" width="${pathMaxW}" height="${height * 0.6}" fill="${wf.color}" opacity="${0.15 + i * 0.03}" style="animation:waterFlow ${0.8 + i * 0.25}s linear infinite;animation-delay:${i * -0.15}s"/>`).join("")}
</g> </g>
<!-- Glowing edge lines -->
<path d="M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1.5" opacity="0.4"/> <path d="M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1.5" opacity="0.4"/>
<path d="M ${tr} ${wf.yStart} C ${tr} ${cpY1}, ${br} ${cpY2}, ${br} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1.5" opacity="0.4"/> <path d="M ${tr} ${wf.yStart} C ${tr} ${cpY1}, ${br} ${cpY2}, ${br} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1.5" opacity="0.4"/>
<!-- Animated flowing dashes along center spine -->
<path d="${spinePath}" fill="none" stroke="white" stroke-width="2" opacity="0.35" stroke-dasharray="6 12" style="animation:riverCurrent 0.8s linear infinite"/> <path d="${spinePath}" fill="none" stroke="white" stroke-width="2" opacity="0.35" stroke-dasharray="6 12" style="animation:riverCurrent 0.8s linear infinite"/>
<path d="${spinePath}" fill="none" stroke="${wf.color}" stroke-width="3" opacity="0.2" stroke-dasharray="3 18" style="animation:riverCurrent 1.2s linear infinite;animation-delay:-0.4s"/> <path d="${spinePath}" fill="none" stroke="${wf.color}" stroke-width="3" opacity="0.2" stroke-dasharray="3 18" style="animation:riverCurrent 1.2s linear infinite;animation-delay:-0.4s"/>
<!-- Entry glow (where flow meets the node) -->
<ellipse cx="${entryCx}" cy="${entryY}" rx="${entryWidth * 0.7}" ry="5" fill="url(#${glowId})" style="animation:entryPulse 1.5s ease-in-out infinite"/> <ellipse cx="${entryCx}" cy="${entryY}" rx="${entryWidth * 0.7}" ry="5" fill="url(#${glowId})" style="animation:entryPulse 1.5s ease-in-out infinite"/>
<!-- Exit glow (where flow leaves the node) -->
<ellipse cx="${exitCx}" cy="${exitY}" rx="${Math.max(topWidth, bottomWidth) * 0.5}" ry="4" fill="url(#${glowId})" opacity="0.6"/> <ellipse cx="${exitCx}" cy="${exitY}" rx="${Math.max(topWidth, bottomWidth) * 0.5}" ry="4" fill="url(#${glowId})" opacity="0.6"/>
<!-- Flow amount label -->
<text x="${labelX}" y="${labelY}" text-anchor="middle" style="fill:${wf.color}" font-size="9" font-weight="600" opacity="0.8">${flowLabel}/mo</text>`; <text x="${labelX}" y="${labelY}" text-anchor="middle" style="fill:${wf.color}" font-size="9" font-weight="600" opacity="0.8">${flowLabel}/mo</text>`;
} }
function renderBranch(b: BranchLayout): string { function renderBranch(b: BranchLayout): string {
const dx = b.x2 - b.x1; const dx = b.x2 - b.x1;
const dy = b.y2 - b.y1; const dy = b.y2 - b.y1;
const cpx = b.x1 + dx * 0.5;
const halfW = b.width / 2; const halfW = b.width / 2;
const branchPath = `M ${b.x1} ${b.y1 - halfW} C ${cpx} ${b.y1 - halfW}, ${cpx} ${b.y2 - halfW}, ${b.x2} ${b.y2 - halfW} L ${b.x2} ${b.y2 + halfW} C ${cpx} ${b.y2 + halfW}, ${cpx} ${b.y1 + halfW}, ${b.x1} ${b.y1 + halfW} Z`;
const spinePath = `M ${b.x1} ${b.y1} C ${cpx} ${b.y1}, ${cpx} ${b.y2}, ${b.x2} ${b.y2}`;
const clipId = `branch-clip-${b.sourceId}-${b.targetId}`;
return ` // Pipe exits horizontally from lip, then curves down to target
<defs><clipPath id="${clipId}"><path d="${branchPath}"/></clipPath></defs> const exitX = b.side === "left" ? b.x1 - 40 : b.x1 + 40;
<path d="${branchPath}" fill="${b.color}" opacity="0.15"/> const cp1x = exitX;
<path d="${branchPath}" fill="${b.color}" opacity="0.25"/> const cp1y = b.y1;
<g clip-path="url(#${clipId})"> const cp2x = b.x2;
${[0, 1, 2].map((i) => `<rect x="${Math.min(b.x1, b.x2) - 5}" y="${Math.min(b.y1, b.y2) - halfW}" width="${Math.abs(dx) + 10}" height="${b.width}" fill="${b.color}" opacity="0.12" style="animation:waterFlow ${1.0 + i * 0.3}s linear infinite;animation-delay:${i * -0.2}s;transform-origin:center"/>`).join("")} const cp2y = b.y1 + (b.y2 - b.y1) * 0.3;
</g>
<path d="${spinePath}" fill="none" stroke="white" stroke-width="1.5" opacity="0.25" stroke-dasharray="4 10" style="animation:riverCurrent 0.9s linear infinite"/> const spinePath = `M ${b.x1} ${b.y1} L ${exitX} ${b.y1} C ${cp1x} ${b.y1 + Math.abs(dy) * 0.4}, ${cp2x} ${cp2y}, ${b.x2} ${b.y2}`;
<circle cx="${b.x2}" cy="${b.y2}" r="${halfW + 2}" fill="${b.color}" opacity="0.15" style="animation:entryPulse 1.5s ease-in-out infinite"/>
<text x="${(b.x1 + b.x2) / 2}" y="${(b.y1 + b.y2) / 2 - 8}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10">${b.percentage}%</text>`; // Outer wall (dark)
const outerStroke = `<path d="${spinePath}" fill="none" stroke="#334155" stroke-width="${b.width + 6}" stroke-linecap="round" opacity="0.5"/>`;
// Inner channel (surface)
const innerStroke = `<path d="${spinePath}" fill="none" stroke="#475569" stroke-width="${b.width + 2}" stroke-linecap="round" opacity="0.4"/>`;
// Water flow (animated, only when active)
const waterFlow = b.isActive ? `
<path d="${spinePath}" fill="none" stroke="${b.color}" stroke-width="${b.width}" stroke-linecap="round" opacity="0.6"/>
<path d="${spinePath}" fill="none" stroke="${b.color}" stroke-width="${Math.max(2, b.width - 2)}" stroke-linecap="round" opacity="0.4" stroke-dasharray="8 16" style="animation:riverCurrent 0.7s linear infinite"/>
<path d="${spinePath}" fill="none" stroke="white" stroke-width="1.5" opacity="0.2" stroke-dasharray="4 12" style="animation:riverCurrent 1s linear infinite;animation-delay:-0.3s"/>` : "";
// Midpoint for label
const midX = (b.x1 + exitX + b.x2) / 3;
const midY = (b.y1 + b.y2) / 2 - 12;
return `${outerStroke}${innerStroke}${waterFlow}
<text x="${midX}" y="${midY}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10">${b.percentage}%</text>`;
} }
function renderSource(s: SourceLayout): string { function renderSource(s: SourceLayout, maxFlowRate: number): string {
const cx = s.x + s.width / 2;
const valveY = s.y + 35;
const nozzleTop = valveY + VALVE_RADIUS;
const nozzleBottom = s.y + TAP_HEIGHT;
const typeColor = COLORS.sourceTypeColors[s.sourceType] || COLORS.sourceTypeColors.unconfigured;
const flowRatio = Math.min(1, s.flowRate / maxFlowRate);
const streamWidth = 4 + flowRatio * 12;
const streamOpacity = 0.3 + flowRatio * 0.5;
const metalGradId = `metal-grad-${s.id}`;
const valveGradId = `valve-grad-${s.id}`;
return ` return `
<rect x="${s.x}" y="${s.y}" width="${s.width}" height="${SOURCE_HEIGHT}" rx="8" style="fill:${COLORS.surface};stroke:${COLORS.surfaceRaised}"/> <defs>
<text x="${s.x + s.width / 2}" y="${s.y + 16}" text-anchor="middle" style="fill:${COLORS.text}" font-size="11" font-weight="600">${esc(s.label)}</text> <linearGradient id="${metalGradId}" x1="0" y1="0" x2="1" y2="0">
<text x="${s.x + s.width / 2}" y="${s.y + 30}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="9">$${s.flowRate.toLocaleString()}/mo</text>`; <stop offset="0%" stop-color="${COLORS.metal[0]}"/>
<stop offset="50%" stop-color="${COLORS.metal[1]}"/>
<stop offset="100%" stop-color="${COLORS.metal[2]}"/>
</linearGradient>
<radialGradient id="${valveGradId}" cx="40%" cy="40%" r="50%">
<stop offset="0%" stop-color="${COLORS.metal[1]}"/>
<stop offset="70%" stop-color="${typeColor}" stop-opacity="0.6"/>
<stop offset="100%" stop-color="${COLORS.metal[0]}"/>
</radialGradient>
</defs>
<!-- Inlet pipe -->
<rect x="${cx - 8}" y="${s.y}" width="16" height="${valveY - s.y}" rx="2" fill="url(#${metalGradId})" opacity="0.9"/>
<rect x="${cx - 6}" y="${s.y}" width="12" height="${valveY - s.y}" rx="1" fill="${COLORS.metal[1]}" opacity="0.15"/>
<!-- Valve body -->
<circle cx="${cx}" cy="${valveY}" r="${VALVE_RADIUS}" fill="url(#${valveGradId})" stroke="${COLORS.metal[0]}" stroke-width="1.5"/>
<!-- Valve handle -->
<g transform="rotate(${s.valveAngle}, ${cx}, ${valveY})" data-interactive="valve" data-source-id="${s.id}" style="cursor:grab">
<line x1="${cx}" y1="${valveY}" x2="${cx}" y2="${valveY - HANDLE_LENGTH - VALVE_RADIUS}" stroke="${COLORS.metal[0]}" stroke-width="4" stroke-linecap="round"/>
<circle cx="${cx}" cy="${valveY - HANDLE_LENGTH - VALVE_RADIUS}" r="5" fill="${typeColor}" stroke="${COLORS.metal[0]}" stroke-width="1.5"/>
</g>
<!-- Nozzle (trapezoid) -->
<path d="M ${cx - 12} ${nozzleTop} L ${cx - 6} ${nozzleBottom} L ${cx + 6} ${nozzleBottom} L ${cx + 12} ${nozzleTop} Z" fill="url(#${metalGradId})" opacity="0.9"/>
<!-- Water stream -->
${flowRatio > 0.01 ? `
<line x1="${cx}" y1="${nozzleBottom}" x2="${cx}" y2="${nozzleBottom + 20}" stroke="${COLORS.water}" stroke-width="${streamWidth}" stroke-linecap="round" opacity="${streamOpacity}"/>
<line x1="${cx}" y1="${nozzleBottom + 5}" x2="${cx}" y2="${nozzleBottom + 20}" stroke="white" stroke-width="1.5" opacity="0.3"/>
${[0, 1, 2].map((i) => `<circle cx="${cx + (i - 1) * 3}" cy="${nozzleBottom + 20 + i * 6}" r="${1.5 - i * 0.3}" fill="${COLORS.water}" opacity="${0.5 - i * 0.15}"><animate attributeName="cy" from="${nozzleBottom + 14}" to="${nozzleBottom + 35}" dur="${0.6 + i * 0.2}s" repeatCount="indefinite"/><animate attributeName="opacity" from="${0.6 - i * 0.1}" to="0" dur="${0.6 + i * 0.2}s" repeatCount="indefinite"/></circle>`).join("")}` : ""}
<!-- Edit icon -->
<circle cx="${s.x + s.width - 14}" cy="${s.y + 14}" r="10" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1" data-interactive="edit-rate" data-source-id="${s.id}" style="cursor:pointer" opacity="0.9"/>
<text x="${s.x + s.width - 14}" y="${s.y + 18}" text-anchor="middle" font-size="11" font-weight="700" style="fill:${typeColor};pointer-events:none">$</text>
<!-- Labels -->
<text x="${cx}" y="${s.y - 6}" text-anchor="middle" style="fill:${COLORS.text}" font-size="11" font-weight="600">${esc(s.label)}</text>
<text x="${cx}" y="${nozzleBottom + 42}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10" font-weight="500">$${s.flowRate.toLocaleString()}/mo</text>`;
} }
function renderFunnel(f: FunnelLayout): string { function renderFunnel(f: FunnelLayout): string {
const colors = f.status === "overflow" ? COLORS.riverOverflow : f.status === "critical" ? COLORS.riverCritical : f.sufficiency === "sufficient" || f.sufficiency === "abundant" ? COLORS.riverSufficient : COLORS.riverHealthy; const colors = f.status === "overflow" ? COLORS.riverOverflow : f.status === "critical" ? COLORS.riverCritical : f.sufficiency === "sufficient" || f.sufficiency === "abundant" ? COLORS.riverSufficient : COLORS.riverHealthy;
const gradId = `river-grad-${f.id}`;
const flowGradId = `river-flow-${f.id}`;
const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold;
const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant"; const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant";
const cx = f.x + f.vesselWidth / 2;
const vw = f.vesselWidth;
const vbw = f.vesselBottomWidth;
const vh = f.vesselHeight;
// Pipe = desiredOutflow (what they need), Flow = inflowRate (what they get) // Trapezoid corners
const outflow = f.data.desiredOutflow || f.data.inflowRate || 1; const tl = f.x;
const tr = f.x + vw;
const bl = cx - vbw / 2;
const br = cx + vbw / 2;
const vesselGradId = `vessel-grad-${f.id}`;
const waterGradId = `vessel-water-${f.id}`;
const clipId = `vessel-clip-${f.id}`;
// Threshold Y positions (from top: 0=top, vh=bottom)
const overflowFrac = 1 - f.overflowLevel;
const overflowY = f.y + overflowFrac * vh;
const minFrac = 1 - (f.data.minThreshold / (f.data.maxCapacity || 1));
const minY = f.y + minFrac * vh;
const suffFrac = 1 - ((f.data.sufficientThreshold ?? f.data.maxThreshold) / (f.data.maxCapacity || 1));
const suffY = f.y + suffFrac * vh;
// Water fill
const fillTop = f.y + (1 - f.fillLevel) * vh;
const inflow = f.data.inflowRate || 0; const inflow = f.data.inflowRate || 0;
const flowHeight = Math.max(2, f.riverWidth * f.fillRatio); const outflow = f.data.desiredOutflow || f.data.inflowRate || 1;
const flowY = f.y + (f.riverWidth - flowHeight) / 2; // center the flow vertically const fundingPct = Math.round(f.fillLevel * (f.data.maxCapacity / (f.data.maxThreshold || 1)) * 100);
const fundingPct = Math.round(f.fillRatio * 100); const underfunded = f.data.currentValue < f.data.minThreshold;
const underfunded = f.fillRatio < 0.95;
const flowColor = underfunded ? "#ef4444" : colors[0]; // red tint when underfunded // Overflow lip positions
const overflowEdges = vesselEdgesAtY(f.x, vw, vbw, vh, overflowFrac);
// Vessel outline path
const vesselPath = `M ${tl} ${f.y} L ${bl} ${f.y + vh} L ${br} ${f.y + vh} L ${tr} ${f.y} Z`;
// Water fill clipped to vessel
const fillEdgesTop = vesselEdgesAtY(f.x, vw, vbw, vh, (fillTop - f.y) / vh);
const fillPathStr = `M ${fillEdgesTop.left} ${fillTop} L ${bl} ${f.y + vh} L ${br} ${f.y + vh} L ${fillEdgesTop.right} ${fillTop} Z`;
// Overflow lips (U-shaped notch cutouts)
const lipH = 12;
const hasLeftLips = f.leftOverflowPipes.length > 0;
const hasRightLips = f.rightOverflowPipes.length > 0;
// Pour animation — only when overflowing
const isOverflowing = f.data.currentValue > f.data.maxThreshold;
const excessRatio = isOverflowing ? Math.min(1, (f.data.currentValue - f.data.maxThreshold) / (f.data.maxCapacity - f.data.maxThreshold || 1)) : 0;
return ` return `
<defs> <defs>
<linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0"> <linearGradient id="${vesselGradId}" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.12"/> <stop offset="0%" stop-color="${COLORS.metal[0]}" stop-opacity="0.4"/>
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.18"/> <stop offset="50%" stop-color="${COLORS.metal[1]}" stop-opacity="0.25"/>
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.12"/> <stop offset="100%" stop-color="${COLORS.metal[0]}" stop-opacity="0.4"/>
</linearGradient> </linearGradient>
<linearGradient id="${flowGradId}" x1="0" y1="0" x2="1" y2="0"> <linearGradient id="${waterGradId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${flowColor}" stop-opacity="0.6"/> <stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.7"/>
<stop offset="50%" stop-color="${underfunded ? '#f87171' : (colors[1] || colors[0])}" stop-opacity="0.85"/> <stop offset="100%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.5"/>
<stop offset="100%" stop-color="${flowColor}" stop-opacity="0.6"/>
</linearGradient> </linearGradient>
<clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
</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 ? `<path d="${vesselPath}" fill="none" stroke="${COLORS.goldenGlow}" stroke-width="3" opacity="0.5" style="animation:shimmer 2s ease-in-out infinite"/>` : ""}
<!-- Pipe = desiredOutflow (outer boundary shows what they need) --> <!-- Vessel outline -->
<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"/> <path d="${vesselPath}" fill="url(#${vesselGradId})" stroke="${COLORS.metal[0]}" stroke-width="1.5" opacity="0.8"/>
<!-- Flow = inflowRate (inner fill shows what they actually receive) --> <!-- Water fill -->
<rect x="${f.x}" y="${flowY}" width="${f.segmentLength}" height="${flowHeight}" rx="${Math.min(4, flowHeight / 2)}" fill="url(#${flowGradId})"/> ${f.fillLevel > 0.01 ? `
${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("") : ""} <g clip-path="url(#${clipId})">
<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> <path d="${fillPathStr}" fill="url(#${waterGradId})"/>
<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> <!-- Wave surface -->
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength}" height="3" rx="1.5" style="fill:${COLORS.surfaceRaised}"/> <path d="M ${fillEdgesTop.left - 2} ${fillTop} Q ${fillEdgesTop.left + (fillEdgesTop.right - fillEdgesTop.left) * 0.25} ${fillTop - 3}, ${fillEdgesTop.left + (fillEdgesTop.right - fillEdgesTop.left) * 0.5} ${fillTop} Q ${fillEdgesTop.left + (fillEdgesTop.right - fillEdgesTop.left) * 0.75} ${fillTop + 3}, ${fillEdgesTop.right + 2} ${fillTop}" fill="none" stroke="${colors[0]}" stroke-width="2" opacity="0.5" style="animation:waveFloat 2s ease-in-out infinite"/>
<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]}"/>`; <!-- Flow animation strips -->
${[0, 1, 2].map((i) => `<rect x="${f.x}" y="${fillTop + i * 15}" width="${vw}" height="8" fill="${colors[0]}" opacity="0.08" style="animation:waterFlow ${2.5 + i * 0.4}s linear infinite;animation-delay:${i * -0.5}s"/>`).join("")}
</g>` : ""}
<!-- Threshold markers -->
${[
{ y: overflowY, label: "max", frac: overflowFrac },
{ y: suffY, label: "suff", frac: suffFrac },
{ y: minY, label: "min", frac: minFrac },
].map((t) => {
if (t.frac < 0 || t.frac > 1) return "";
const edges = vesselEdgesAtY(f.x, vw, vbw, vh, t.frac);
return `<line x1="${edges.left + 4}" y1="${t.y}" x2="${edges.right - 4}" y2="${t.y}" stroke="${COLORS.textMuted}" stroke-width="1" stroke-dasharray="4 3" opacity="0.35"/>`;
}).join("")}
<!-- Overflow lips (left) -->
${hasLeftLips ? `<path d="M ${overflowEdges.left - OVERFLOW_LIP_WIDTH} ${overflowY - lipH / 2} L ${overflowEdges.left - OVERFLOW_LIP_WIDTH} ${overflowY + lipH / 2} L ${overflowEdges.left + 2} ${overflowY + lipH / 2} L ${overflowEdges.left + 2} ${overflowY - lipH / 2} Z" fill="none" stroke="${COLORS.overflowBranch}" stroke-width="1.5" opacity="0.6"/>` : ""}
<!-- Overflow lips (right) -->
${hasRightLips ? `<path d="M ${overflowEdges.right - 2} ${overflowY - lipH / 2} L ${overflowEdges.right - 2} ${overflowY + lipH / 2} L ${overflowEdges.right + OVERFLOW_LIP_WIDTH} ${overflowY + lipH / 2} L ${overflowEdges.right + OVERFLOW_LIP_WIDTH} ${overflowY - lipH / 2} Z" fill="none" stroke="${COLORS.overflowBranch}" stroke-width="1.5" opacity="0.6"/>` : ""}
<!-- Pour animation from lips -->
${isOverflowing && hasLeftLips ? `
<line x1="${overflowEdges.left - OVERFLOW_LIP_WIDTH}" y1="${overflowY}" x2="${overflowEdges.left - OVERFLOW_LIP_WIDTH - 10}" y2="${overflowY + 15}" stroke="${COLORS.overflowBranch}" stroke-width="${3 + excessRatio * 5}" stroke-linecap="round" opacity="${0.3 + excessRatio * 0.4}"/>
${[0, 1].map((i) => `<circle cx="${overflowEdges.left - OVERFLOW_LIP_WIDTH - 8 - i * 4}" cy="${overflowY + 18 + i * 8}" r="${1.5}" fill="${COLORS.overflowBranch}" opacity="${0.4}"><animate attributeName="cy" from="${overflowY + 12}" to="${overflowY + 35}" dur="${0.5 + i * 0.2}s" repeatCount="indefinite"/><animate attributeName="opacity" from="0.5" to="0" dur="${0.5 + i * 0.2}s" repeatCount="indefinite"/></circle>`).join("")}` : ""}
${isOverflowing && hasRightLips ? `
<line x1="${overflowEdges.right + OVERFLOW_LIP_WIDTH}" y1="${overflowY}" x2="${overflowEdges.right + OVERFLOW_LIP_WIDTH + 10}" y2="${overflowY + 15}" stroke="${COLORS.overflowBranch}" stroke-width="${3 + excessRatio * 5}" stroke-linecap="round" opacity="${0.3 + excessRatio * 0.4}"/>
${[0, 1].map((i) => `<circle cx="${overflowEdges.right + OVERFLOW_LIP_WIDTH + 8 + i * 4}" cy="${overflowY + 18 + i * 8}" r="${1.5}" fill="${COLORS.overflowBranch}" opacity="${0.4}"><animate attributeName="cy" from="${overflowY + 12}" to="${overflowY + 35}" dur="${0.5 + i * 0.2}s" repeatCount="indefinite"/><animate attributeName="opacity" from="0.5" to="0" dur="${0.5 + i * 0.2}s" repeatCount="indefinite"/></circle>`).join("")}` : ""}
<!-- Bottom drain -->
<rect x="${cx - DRAIN_WIDTH / 2}" y="${f.y + vh - 2}" width="${DRAIN_WIDTH}" height="6" rx="2" fill="${COLORS.metal[0]}" opacity="0.6"/>
<!-- Labels -->
<text x="${cx}" y="${f.y - 14}" text-anchor="middle" style="fill:${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
<text x="${cx}" y="${f.y - 2}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10">$${Math.floor(inflow).toLocaleString()} $${Math.floor(outflow).toLocaleString()}/mo ${underfunded ? `(${Math.round((f.data.currentValue / (f.data.minThreshold || 1)) * 100)}%)` : isSufficient ? "✨" : ""}</text>
<!-- Funding bar -->
<rect x="${f.x + 10}" y="${f.y + vh + 8}" width="${vw - 20}" height="3" rx="1.5" style="fill:${COLORS.surfaceRaised}"/>
<rect x="${f.x + 10}" y="${f.y + vh + 8}" width="${(vw - 20) * Math.min(1, f.fillLevel * (f.data.maxCapacity / (f.data.maxThreshold || 1)))}" height="3" rx="1.5" fill="${underfunded ? colors[0] : isSufficient ? COLORS.goldenGlow : colors[0]}"/>`;
} }
function renderOutcome(o: OutcomeLayout): string { function renderOutcome(o: OutcomeLayout): string {
@ -467,6 +666,13 @@ class FolkFlowRiver extends HTMLElement {
private dragStartY = 0; private dragStartY = 0;
private scrollStartX = 0; private scrollStartX = 0;
private scrollStartY = 0; private scrollStartY = 0;
// Valve drag state
private valveDragging = false;
private valveDragSourceId: string | null = null;
private currentLayout: RiverLayout | null = null;
// Popover state
private activePopover: HTMLElement | null = null;
private renderScheduled = false;
constructor() { constructor() {
super(); super();
@ -501,6 +707,15 @@ class FolkFlowRiver extends HTMLElement {
this.render(); this.render();
} }
private scheduleRender() {
if (this.renderScheduled) return;
this.renderScheduled = true;
requestAnimationFrame(() => {
this.renderScheduled = false;
this.render();
});
}
private startSimulation() { private startSimulation() {
if (this.simTimer) return; if (this.simTimer) return;
this.simTimer = setInterval(() => { this.simTimer = setInterval(() => {
@ -513,8 +728,69 @@ class FolkFlowRiver extends HTMLElement {
if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; } if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; }
} }
private showAmountPopover(sourceId: string, anchorX: number, anchorY: number) {
this.closePopover();
const sourceNode = this.nodes.find((n) => n.id === sourceId);
if (!sourceNode) return;
const data = sourceNode.data as SourceNodeData;
const popover = document.createElement("div");
popover.className = "amount-popover";
popover.style.left = `${anchorX}px`;
popover.style.top = `${anchorY}px`;
popover.innerHTML = `
<label style="font-size:11px;color:var(--rs-text-secondary)">Flow Rate ($/mo)</label>
<input type="number" class="pop-rate" value="${data.flowRate}" min="0" step="100"/>
<label style="font-size:11px;color:var(--rs-text-secondary);margin-top:4px">Effective Date</label>
<input type="date" class="pop-date" value="${data.effectiveDate || ""}"/>
<div style="display:flex;gap:6px;margin-top:6px">
<button class="pop-apply">Apply</button>
<button class="pop-cancel">Cancel</button>
</div>`;
const container = this.shadow.querySelector(".container") as HTMLElement;
container.appendChild(popover);
this.activePopover = popover;
const rateInput = popover.querySelector(".pop-rate") as HTMLInputElement;
const dateInput = popover.querySelector(".pop-date") as HTMLInputElement;
rateInput.focus();
rateInput.select();
const apply = () => {
const newRate = parseFloat(rateInput.value) || 0;
const newDate = dateInput.value || undefined;
this.updateSourceFlowRate(sourceId, Math.max(0, newRate), newDate);
this.closePopover();
};
popover.querySelector(".pop-apply")!.addEventListener("click", apply);
popover.querySelector(".pop-cancel")!.addEventListener("click", () => this.closePopover());
rateInput.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); if (e.key === "Escape") this.closePopover(); });
dateInput.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); if (e.key === "Escape") this.closePopover(); });
}
private closePopover() {
if (this.activePopover) {
this.activePopover.remove();
this.activePopover = null;
}
}
private updateSourceFlowRate(sourceId: string, flowRate: number, effectiveDate?: string) {
this.nodes = this.nodes.map((n) => {
if (n.id === sourceId && n.type === "source") {
return { ...n, data: { ...n.data, flowRate, effectiveDate } as SourceNodeData };
}
return n;
});
this.dispatchEvent(new CustomEvent("flow-rate-change", { detail: { sourceId, flowRate, effectiveDate }, bubbles: true }));
this.scheduleRender();
}
private render() { private render() {
const layout = computeLayout(this.nodes); const layout = computeLayout(this.nodes);
this.currentLayout = layout;
const score = computeSystemSufficiency(this.nodes); const score = computeSystemSufficiency(this.nodes);
this.shadow.innerHTML = ` this.shadow.innerHTML = `
@ -530,18 +806,25 @@ class FolkFlowRiver extends HTMLElement {
.legend { position: absolute; bottom: 12px; left: 12px; background: var(--rs-glass-bg); border: 1px solid var(--rs-bg-surface-raised); border-radius: 8px; padding: 8px 12px; font-size: 10px; color: var(--rs-text-secondary); } .legend { position: absolute; bottom: 12px; left: 12px; background: var(--rs-glass-bg); border: 1px solid var(--rs-bg-surface-raised); border-radius: 8px; padding: 8px 12px; font-size: 10px; color: var(--rs-text-secondary); }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; } .legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; } .legend-dot { width: 8px; height: 8px; border-radius: 2px; }
.amount-popover { position: absolute; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-bg-surface-raised, #334155); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 100; min-width: 160px; }
.amount-popover input { background: var(--rs-bg-page, #0f172a); border: 1px solid var(--rs-bg-surface-raised, #334155); border-radius: 4px; padding: 4px 8px; color: var(--rs-text-primary, #f1f5f9); font-size: 13px; outline: none; }
.amount-popover input:focus { border-color: var(--rs-primary, #3b82f6); }
.amount-popover button { padding: 4px 10px; border-radius: 4px; border: 1px solid var(--rs-bg-surface-raised, #334155); background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-secondary, #94a3b8); cursor: pointer; font-size: 11px; flex: 1; }
.amount-popover button.pop-apply { background: var(--rs-primary, #3b82f6); color: #fff; border-color: var(--rs-primary, #3b82f6); }
.amount-popover button:hover { opacity: 0.85; }
@keyframes waterFlow { 0% { transform: translateY(0); } 100% { transform: translateY(100%); } } @keyframes waterFlow { 0% { transform: translateY(0); } 100% { transform: translateY(100%); } }
@keyframes riverCurrent { 0% { stroke-dashoffset: 10; } 100% { stroke-dashoffset: 0; } } @keyframes riverCurrent { 0% { stroke-dashoffset: 10; } 100% { stroke-dashoffset: 0; } }
@keyframes shimmer { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } } @keyframes shimmer { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }
@keyframes waveFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } } @keyframes waveFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
@keyframes entryPulse { 0%, 100% { opacity: 0.4; transform: scaleX(0.8); } 50% { opacity: 0.9; transform: scaleX(1.2); } } @keyframes entryPulse { 0%, 100% { opacity: 0.4; transform: scaleX(0.8); } 50% { opacity: 0.9; transform: scaleX(1.2); } }
@keyframes pourFlow { 0% { stroke-dashoffset: 24; } 100% { stroke-dashoffset: 0; } }
</style> </style>
<div class="container"> <div class="container">
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}"> <svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
${layout.sourceWaterfalls.map(renderWaterfall).join("")} ${layout.sourceWaterfalls.map(renderWaterfall).join("")}
${layout.spendingWaterfalls.map(renderWaterfall).join("")} ${layout.spendingWaterfalls.map(renderWaterfall).join("")}
${layout.overflowBranches.map(renderBranch).join("")} ${layout.overflowBranches.map(renderBranch).join("")}
${layout.sources.map(renderSource).join("")} ${layout.sources.map((s) => renderSource(s, layout.maxSourceFlowRate)).join("")}
${layout.funnels.map(renderFunnel).join("")} ${layout.funnels.map(renderFunnel).join("")}
${layout.outcomes.map(renderOutcome).join("")} ${layout.outcomes.map(renderOutcome).join("")}
${renderSufficiencyBadge(score, layout.width - 70, 10)} ${renderSufficiencyBadge(score, layout.width - 70, 10)}
@ -559,6 +842,7 @@ class FolkFlowRiver extends HTMLElement {
</div> </div>
</div>`; </div>`;
// Event: toggle simulation
this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => { this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => {
this.simulating = !this.simulating; this.simulating = !this.simulating;
if (this.simulating) this.startSimulation(); if (this.simulating) this.startSimulation();
@ -566,11 +850,48 @@ class FolkFlowRiver extends HTMLElement {
this.render(); this.render();
}); });
// Drag-to-pan // Event delegation for interactive elements + drag-to-pan
const container = this.shadow.querySelector(".container") as HTMLElement; const container = this.shadow.querySelector(".container") as HTMLElement;
if (container) { const svg = this.shadow.querySelector("svg") as SVGSVGElement;
if (!container || !svg) return;
container.addEventListener("pointerdown", (e: PointerEvent) => { container.addEventListener("pointerdown", (e: PointerEvent) => {
if ((e.target as HTMLElement).closest("button")) return; const target = e.target as Element;
if (target.closest("button")) return;
if (target.closest(".amount-popover")) return;
// Check for interactive SVG elements
const interactive = target.closest("[data-interactive]") as Element | null;
if (interactive) {
const action = interactive.getAttribute("data-interactive");
const sourceId = interactive.getAttribute("data-source-id");
if (action === "valve" && sourceId) {
// Start valve drag
this.valveDragging = true;
this.valveDragSourceId = sourceId;
container.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
if (action === "edit-rate" && sourceId) {
// Show amount popover
const rect = container.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
// Position popover near click
const popX = e.clientX - rect.left + container.scrollLeft + 10;
const popY = e.clientY - rect.top + container.scrollTop + 10;
this.showAmountPopover(sourceId, popX, popY);
e.preventDefault();
return;
}
}
// Close popover on click outside
this.closePopover();
// Start pan drag
this.dragging = true; this.dragging = true;
this.dragStartX = e.clientX; this.dragStartX = e.clientX;
this.dragStartY = e.clientY; this.dragStartY = e.clientY;
@ -579,12 +900,54 @@ class FolkFlowRiver extends HTMLElement {
container.classList.add("dragging"); container.classList.add("dragging");
container.setPointerCapture(e.pointerId); container.setPointerCapture(e.pointerId);
}); });
container.addEventListener("pointermove", (e: PointerEvent) => { container.addEventListener("pointermove", (e: PointerEvent) => {
if (this.valveDragging && this.valveDragSourceId) {
// Map pointer position to valve angle
const sourceLayout = this.currentLayout?.sources.find((s) => s.id === this.valveDragSourceId);
if (!sourceLayout) return;
const svgRect = svg.getBoundingClientRect();
const svgX = (e.clientX - svgRect.left) * (layout.width / svgRect.width);
const svgY = (e.clientY - svgRect.top) * (layout.height / svgRect.height);
const cx = sourceLayout.x + sourceLayout.width / 2;
const valveY = sourceLayout.y + 35;
const angle = Math.atan2(svgX - cx, valveY - svgY) * (180 / Math.PI);
const clampedAngle = Math.max(0, Math.min(90, angle));
const newRate = (clampedAngle / 90) * layout.maxSourceFlowRate;
// Round to nearest 100
const roundedRate = Math.round(newRate / 100) * 100;
this.nodes = this.nodes.map((n) => {
if (n.id === this.valveDragSourceId && n.type === "source") {
return { ...n, data: { ...n.data, flowRate: roundedRate } as SourceNodeData };
}
return n;
});
this.scheduleRender();
return;
}
if (!this.dragging) return; if (!this.dragging) return;
container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX); container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX);
container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY); container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY);
}); });
container.addEventListener("pointerup", (e: PointerEvent) => { container.addEventListener("pointerup", (e: PointerEvent) => {
if (this.valveDragging) {
const sourceId = this.valveDragSourceId;
this.valveDragging = false;
this.valveDragSourceId = null;
container.releasePointerCapture(e.pointerId);
// Dispatch event
if (sourceId) {
const sourceNode = this.nodes.find((n) => n.id === sourceId);
if (sourceNode) {
const data = sourceNode.data as SourceNodeData;
this.dispatchEvent(new CustomEvent("flow-rate-change", { detail: { sourceId, flowRate: data.flowRate, effectiveDate: data.effectiveDate }, bubbles: true }));
}
}
return;
}
this.dragging = false; this.dragging = false;
container.classList.remove("dragging"); container.classList.remove("dragging");
container.releasePointerCapture(e.pointerId); container.releasePointerCapture(e.pointerId);
@ -594,7 +957,6 @@ class FolkFlowRiver extends HTMLElement {
container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2; container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2;
container.scrollTop = 0; container.scrollTop = 0;
} }
}
} }
customElements.define("folk-flow-river", FolkFlowRiver); customElements.define("folk-flow-river", FolkFlowRiver);

View File

@ -96,6 +96,7 @@ export interface SourceNodeData {
chainId?: number; chainId?: number;
safeAddress?: string; safeAddress?: string;
transakOrderId?: string; transakOrderId?: string;
effectiveDate?: string;
[key: string]: unknown; [key: string]: unknown;
} }