diff --git a/modules/rflows/components/folk-flow-river.ts b/modules/rflows/components/folk-flow-river.ts index 27b8cec..91d0e15 100644 --- a/modules/rflows/components/folk-flow-river.ts +++ b/modules/rflows/components/folk-flow-river.ts @@ -2,6 +2,12 @@ * — animated SVG sankey river visualization. * Pure renderer: receives nodes via setNodes() or falls back to demo data. * 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"; @@ -19,26 +25,40 @@ interface RiverLayout { spendingWaterfalls: WaterfallLayout[]; width: number; height: number; + maxSourceFlowRate: number; } -interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; } -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 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; 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 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 ─────────────────────────────────────────── -const LAYER_HEIGHT = 180; -const WATERFALL_HEIGHT = 140; -const GAP = 50; +const TAP_WIDTH = 140; +const TAP_HEIGHT = 100; +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 MAX_RIVER_WIDTH = 100; const MIN_WATERFALL_WIDTH = 4; const SEGMENT_LENGTH = 220; const POOL_WIDTH = 110; const POOL_HEIGHT = 65; -const SOURCE_HEIGHT = 45; +const SOURCE_HEIGHT = TAP_HEIGHT; +const MAX_PIPE_WIDTH = 20; const COLORS = { sourceWaterfall: "#10b981", @@ -50,11 +70,14 @@ const COLORS = { spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"], outcomePool: "#3b82f6", goldenGlow: "#fbbf24", + metal: ["#64748b", "#94a3b8", "#64748b"], + water: "#38bdf8", bg: "var(--rs-bg-page)", surface: "var(--rs-bg-surface)", surfaceRaised: "var(--rs-bg-surface-raised)", text: "var(--rs-text-primary)", textMuted: "var(--rs-text-secondary)", + sourceTypeColors: { card: "#10b981", safe_wallet: "#8b5cf6", ridentity: "#3b82f6", metamask: "#f59e0b", unconfigured: "#64748b" } as Record, }; function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] { @@ -73,13 +96,24 @@ function distributeWidths(percentages: number[], totalAvailable: number, minWidt 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 { const funnelNodes = nodes.filter((n) => n.type === "funnel"); const outcomeNodes = nodes.filter((n) => n.type === "outcome"); 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(); const spendingTargets = new Set(); @@ -121,37 +155,62 @@ 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); - 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) => { const data = n.data as FunnelNodeData; - 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 fillLevel = Math.min(1, data.currentValue / (data.maxCapacity || 1)); + const overflowLevel = data.maxThreshold / (data.maxCapacity || 1); + const x = -totalWidth / 2 + i * (VESSEL_WIDTH + GAP * 2); const status: "healthy" | "overflow" | "critical" = data.currentValue > data.maxThreshold ? "overflow" : 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 const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => { const data = n.data as SourceNodeData; - const totalWidth = sourceNodes.length * 120 + (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 totalWidth = sourceNodes.length * TAP_WIDTH + (sourceNodes.length - 1) * GAP; + 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 @@ -177,16 +236,20 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || []; const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0); const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1; - const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth); - const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8); - const myIndex = allInflowsToTarget.findIndex((i) => i.sourceNodeId === sn.id && i.allocIndex === allocIdx); - const inflowWidths = distributeWidths(allInflowsToTarget.map((i) => i.flowAmount), targetLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH); - const startX = targetLayout.x + targetLayout.segmentLength * 0.15; - let offsetX = 0; - for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k]; - const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2; + const targetTopWidth = targetLayout.vesselWidth; + const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetTopWidth * 0.4); + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.5); + // Target: top-center of vessel + const targetCenterX = targetLayout.x + targetTopWidth / 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; const layout = funnelLayouts.find((f) => f.id === rn.id); 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[] = []; funnelNodes.forEach((n) => { const data = n.data as FunnelNodeData; const parentLayout = funnelLayouts.find((f) => f.id === n.id); if (!parentLayout) return; - data.overflowAllocations?.forEach((alloc) => { - const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId); + const allPipes = [...parentLayout.leftOverflowPipes, ...parentLayout.rightOverflowPipes]; + allPipes.forEach((pipe) => { + const childLayout = funnelLayouts.find((f) => f.id === pipe.targetId); if (!childLayout) return; - const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth); - 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 width = Math.max(MIN_WATERFALL_WIDTH, (pipe.percentage / 100) * MAX_PIPE_WIDTH); + 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 }; }); - // Spending waterfalls + // Spending waterfalls — from vessel bottom drain to outcome pools const spendingWaterfalls: WaterfallLayout[] = []; funnelNodes.forEach((n) => { const data = n.data as FunnelNodeData; @@ -233,42 +306,59 @@ 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, parentLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH); - const riverEndWidths = distributeWidths(percentages, parentLayout.riverWidth, MIN_WATERFALL_WIDTH); - const startX = parentLayout.x + parentLayout.segmentLength * 0.15; + const slotWidths = distributeWidths(percentages, DRAIN_WIDTH * 2, MIN_WATERFALL_WIDTH); + const drainCx = parentLayout.x + parentLayout.vesselWidth / 2; + const startX = drainCx - DRAIN_WIDTH; let offsetX = 0; allocations.forEach((alloc, i) => { const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId); 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 riverCenterX = startX + offsetX + slotWidths[i] / 2; offsetX += slotWidths[i]; 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 - 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 allY = [...funnelLayouts.map((f) => f.y + f.riverWidth), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY]; + const allX = [ + ...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 maxX = Math.max(...allX, 100); const maxY = Math.max(...allY, 400); - const padding = 80; + const padding = 100; const offsetXGlobal = -minX + 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; }); sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.y += 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; }); 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 ────────────────────────────────────── @@ -300,18 +390,13 @@ function renderWaterfall(wf: WaterfallLayout): string { const pathMinX = Math.min(tl, bl) - 5; 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}`; - - // Exit/entry point positions - const exitCx = isInflow ? topCx : bottomCx; - const exitY = isInflow ? wf.yStart : wf.yEnd; const entryCx = isInflow ? bottomCx : topCx; const entryY = isInflow ? wf.yEnd : wf.yStart; 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 labelY = wf.yStart + height * 0.45; const flowLabel = wf.flowAmount >= 1000 ? `$${(wf.flowAmount / 1000).toFixed(1)}k` : `$${Math.floor(wf.flowAmount)}`; @@ -332,95 +417,209 @@ function renderWaterfall(wf: WaterfallLayout): string { - - - ${[0, 1, 2, 3, 4].map((i) => ``).join("")} - - - - - ${flowLabel}/mo`; } function renderBranch(b: BranchLayout): string { const dx = b.x2 - b.x1; const dy = b.y2 - b.y1; - const cpx = b.x1 + dx * 0.5; 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 ` - - - - - ${[0, 1, 2].map((i) => ``).join("")} - - - - ${b.percentage}%`; + // Pipe exits horizontally from lip, then curves down to target + const exitX = b.side === "left" ? b.x1 - 40 : b.x1 + 40; + const cp1x = exitX; + const cp1y = b.y1; + const cp2x = b.x2; + const cp2y = b.y1 + (b.y2 - b.y1) * 0.3; + + 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}`; + + // Outer wall (dark) + const outerStroke = ``; + // Inner channel (surface) + const innerStroke = ``; + // Water flow (animated, only when active) + const waterFlow = b.isActive ? ` + + + ` : ""; + + // Midpoint for label + const midX = (b.x1 + exitX + b.x2) / 3; + const midY = (b.y1 + b.y2) / 2 - 12; + + return `${outerStroke}${innerStroke}${waterFlow} + ${b.percentage}%`; } -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 ` - - ${esc(s.label)} - $${s.flowRate.toLocaleString()}/mo`; + + + + + + + + + + + + + + + + + + + + + + + + + + ${flowRatio > 0.01 ? ` + + + ${[0, 1, 2].map((i) => ``).join("")}` : ""} + + + $ + + ${esc(s.label)} + $${s.flowRate.toLocaleString()}/mo`; } 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 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 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) - const outflow = f.data.desiredOutflow || f.data.inflowRate || 1; + // Trapezoid corners + 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 flowHeight = Math.max(2, f.riverWidth * f.fillRatio); - 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 + const outflow = f.data.desiredOutflow || f.data.inflowRate || 1; + const fundingPct = Math.round(f.fillLevel * (f.data.maxCapacity / (f.data.maxThreshold || 1)) * 100); + const underfunded = f.data.currentValue < f.data.minThreshold; + + // 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 ` - - - - + + + + - - - - + + + + - ${isSufficient ? `` : ""} - - - - - ${f.fillRatio > 0.02 ? [0, 1, 2].map((i) => ``).join("") : ""} - ${esc(f.label)} - $${Math.floor(inflow).toLocaleString()} → $${Math.floor(outflow).toLocaleString()}/mo ${underfunded ? `(${fundingPct}%)` : "✨"} - - `; + ${isSufficient ? `` : ""} + + + + ${f.fillLevel > 0.01 ? ` + + + + + + ${[0, 1, 2].map((i) => ``).join("")} + ` : ""} + + ${[ + { 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 ``; + }).join("")} + + ${hasLeftLips ? `` : ""} + + ${hasRightLips ? `` : ""} + + ${isOverflowing && hasLeftLips ? ` + + ${[0, 1].map((i) => ``).join("")}` : ""} + ${isOverflowing && hasRightLips ? ` + + ${[0, 1].map((i) => ``).join("")}` : ""} + + + + ${esc(f.label)} + $${Math.floor(inflow).toLocaleString()} → $${Math.floor(outflow).toLocaleString()}/mo ${underfunded ? `(${Math.round((f.data.currentValue / (f.data.minThreshold || 1)) * 100)}%)` : isSufficient ? "✨" : ""} + + + `; } function renderOutcome(o: OutcomeLayout): string { @@ -467,6 +666,13 @@ class FolkFlowRiver extends HTMLElement { private dragStartY = 0; private scrollStartX = 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() { super(); @@ -501,6 +707,15 @@ class FolkFlowRiver extends HTMLElement { this.render(); } + private scheduleRender() { + if (this.renderScheduled) return; + this.renderScheduled = true; + requestAnimationFrame(() => { + this.renderScheduled = false; + this.render(); + }); + } + private startSimulation() { if (this.simTimer) return; this.simTimer = setInterval(() => { @@ -513,8 +728,69 @@ class FolkFlowRiver extends HTMLElement { 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 = ` + + + + +
+ + +
`; + + 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() { const layout = computeLayout(this.nodes); + this.currentLayout = layout; const score = computeSystemSufficiency(this.nodes); 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-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; } .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 riverCurrent { 0% { stroke-dashoffset: 10; } 100% { stroke-dashoffset: 0; } } @keyframes shimmer { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } } @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 pourFlow { 0% { stroke-dashoffset: 24; } 100% { stroke-dashoffset: 0; } }
${layout.sourceWaterfalls.map(renderWaterfall).join("")} ${layout.spendingWaterfalls.map(renderWaterfall).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.outcomes.map(renderOutcome).join("")} ${renderSufficiencyBadge(score, layout.width - 70, 10)} @@ -559,6 +842,7 @@ class FolkFlowRiver extends HTMLElement {
`; + // Event: toggle simulation this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => { this.simulating = !this.simulating; if (this.simulating) this.startSimulation(); @@ -566,34 +850,112 @@ class FolkFlowRiver extends HTMLElement { this.render(); }); - // Drag-to-pan + // Event delegation for interactive elements + drag-to-pan const container = this.shadow.querySelector(".container") as HTMLElement; - if (container) { - container.addEventListener("pointerdown", (e: PointerEvent) => { - if ((e.target as HTMLElement).closest("button")) return; - this.dragging = true; - this.dragStartX = e.clientX; - this.dragStartY = e.clientY; - this.scrollStartX = container.scrollLeft; - this.scrollStartY = container.scrollTop; - container.classList.add("dragging"); - container.setPointerCapture(e.pointerId); - }); - container.addEventListener("pointermove", (e: PointerEvent) => { - if (!this.dragging) return; - container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX); - container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY); - }); - container.addEventListener("pointerup", (e: PointerEvent) => { - this.dragging = false; - container.classList.remove("dragging"); - container.releasePointerCapture(e.pointerId); - }); + const svg = this.shadow.querySelector("svg") as SVGSVGElement; + if (!container || !svg) return; - // Auto-center on initial render - container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2; - container.scrollTop = 0; - } + container.addEventListener("pointerdown", (e: PointerEvent) => { + 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.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.scrollStartX = container.scrollLeft; + this.scrollStartY = container.scrollTop; + container.classList.add("dragging"); + container.setPointerCapture(e.pointerId); + }); + + 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; + container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX); + container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY); + }); + + 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; + container.classList.remove("dragging"); + container.releasePointerCapture(e.pointerId); + }); + + // Auto-center on initial render + container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2; + container.scrollTop = 0; } } diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 5731ee6..3739ffb 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -96,6 +96,7 @@ export interface SourceNodeData { chainId?: number; safeAddress?: string; transakOrderId?: string; + effectiveDate?: string; [key: string]: unknown; }