diff --git a/modules/rflows/components/folk-flow-river.ts b/modules/rflows/components/folk-flow-river.ts index 225bdf4..c2c3f0d 100644 --- a/modules/rflows/components/folk-flow-river.ts +++ b/modules/rflows/components/folk-flow-river.ts @@ -1,13 +1,13 @@ /** - * — animated SVG sankey river visualization. + * — Sankey-style SVG flow 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) + * Source → rounded rect with flow rate label + source-type badge + * Funnel → trapezoid vessel with solid fill bar + overflow threshold line + * Flow → Sankey-style colored band with $ label (width proportional to amount) + * Outcome → rounded rect with horizontal progress bar */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types"; @@ -20,88 +20,55 @@ interface RiverLayout { sources: SourceLayout[]; funnels: FunnelLayout[]; outcomes: OutcomeLayout[]; - sourceWaterfalls: WaterfallLayout[]; - overflowBranches: BranchLayout[]; - spendingWaterfalls: WaterfallLayout[]; + bands: BandLayout[]; width: number; height: number; - maxSourceFlowRate: 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; 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; side: "left" | "right"; isActive: boolean; flowAmount: number; } +interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; w: number; h: number; sourceType: string; } +interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; w: number; h: number; bw: number; fillLevel: number; overflowLevel: number; sufficiency: SufficiencyState; } +interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; w: number; h: number; fillPercent: number; } +interface BandLayout { id: string; sourceId: string; targetId: string; x1: number; y1: number; x2: number; y2: number; width: number; color: string; label: string; kind: "inflow" | "spending" | "overflow"; flowAmount: number; } // ─── Constants ─────────────────────────────────────────── -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 = TAP_HEIGHT; -const MAX_PIPE_WIDTH = 20; +const SOURCE_W = 140; +const SOURCE_H = 60; +const VESSEL_W = 200; +const VESSEL_H = 140; +const VESSEL_BW = 100; // bottom width of trapezoid +const POOL_W = 120; +const POOL_H = 65; +const LAYER_GAP = 200; +const H_GAP = 80; +const MIN_BAND_W = 4; +const MAX_BAND_W = 60; +const PADDING = 100; const COLORS = { - sourceWaterfall: "#10b981", - riverHealthy: ["#0ea5e9", "#06b6d4"], - riverOverflow: ["#f59e0b", "#fbbf24"], - riverCritical: ["#ef4444", "#f87171"], - riverSufficient: ["#fbbf24", "#10b981"], - overflowBranch: "#f59e0b", - spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"], - outcomePool: "#3b82f6", - goldenGlow: "#fbbf24", - metal: ["#64748b", "#94a3b8", "#64748b"], - water: "#38bdf8", + inflow: "#10b981", + spending: "#8b5cf6", + overflow: "#f59e0b", + text: "var(--rs-text-primary)", + textMuted: "var(--rs-text-secondary)", 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, + sourceType: { card: "#10b981", safe_wallet: "#8b5cf6", ridentity: "#3b82f6", metamask: "#f59e0b", unconfigured: "#64748b" } as Record, }; -function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] { - const totalPct = percentages.reduce((s, p) => s + p, 0); - if (totalPct === 0) return percentages.map(() => minWidth); - let widths = percentages.map((p) => (p / totalPct) * totalAvailable); - const belowMin = widths.filter((w) => w < minWidth); - if (belowMin.length > 0 && belowMin.length < widths.length) { - const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0); - const aboveMinTotal = widths.filter((w) => w >= minWidth).reduce((s, w) => s + w, 0); - widths = widths.map((w) => { - if (w < minWidth) return minWidth; - return Math.max(minWidth, w - (w / aboveMinTotal) * deficit); - }); - } - return widths; +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } -/** 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; +function fmtDollars(n: number): string { + return n >= 1000 ? `$${(n / 1000).toFixed(1)}k` : `$${Math.floor(n)}`; +} + +/** Interpolate left/right edges of trapezoid at a given Y fraction (0=top, 1=bottom) */ +function vesselEdgesAtY(x: number, topW: number, bottomW: number, yFrac: number): { left: number; right: number } { const cx = x + topW / 2; - const halfAtY = halfTop + (halfBottom - halfTop) * yFrac; + const halfAtY = topW / 2 + (bottomW / 2 - topW / 2) * yFrac; return { left: cx - halfAtY, right: cx + halfAtY }; } @@ -112,629 +79,279 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { 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(); - - funnelNodes.forEach((n) => { - const data = n.data as FunnelNodeData; - data.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId)); - data.spendingAllocations?.forEach((a) => spendingTargets.add(a.targetId)); - }); - - const rootFunnels = funnelNodes.filter((n) => !overflowTargets.has(n.id)); - - const funnelLayers = new Map(); - rootFunnels.forEach((n) => funnelLayers.set(n.id, 0)); - - const queue = [...rootFunnels]; - while (queue.length > 0) { - const current = queue.shift()!; - const data = current.data as FunnelNodeData; - const parentLayer = funnelLayers.get(current.id) ?? 0; - data.overflowAllocations?.forEach((a) => { - const child = funnelNodes.find((n) => n.id === a.targetId); - if (child && !funnelLayers.has(child.id)) { - funnelLayers.set(child.id, parentLayer + 1); - queue.push(child); - } - }); + // Build layers by Y-position proximity + const funnelsByY = [...funnelNodes].sort((a, b) => a.position.y - b.position.y); + const layers: FlowNode[][] = []; + for (const n of funnelsByY) { + const lastLayer = layers[layers.length - 1]; + if (lastLayer && Math.abs(n.position.y - lastLayer[0].position.y) < 200) { + lastLayer.push(n); + } else { + layers.push([n]); + } } + // Sort each layer by X + layers.forEach((l) => l.sort((a, b) => a.position.x - b.position.x)); - const layerGroups = new Map(); - funnelNodes.forEach((n) => { - const layer = funnelLayers.get(n.id) ?? 0; - if (!layerGroups.has(layer)) layerGroups.set(layer, []); - layerGroups.get(layer)!.push(n); - }); + const funnelLayerMap = new Map(); + layers.forEach((l, i) => l.forEach((n) => funnelLayerMap.set(n.id, i))); - const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0); - const sourceLayerY = GAP; - const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP; - - const funnelLayouts: FunnelLayout[] = []; - - 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 * VESSEL_WIDTH + (layerNodes.length - 1) * GAP * 2; - - layerNodes.forEach((n, i) => { - const data = n.data as FunnelNodeData; - const inflow = data.inflowRate || 0; - 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"; - - // 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 + // Position sources + const sourceStartY = PADDING; + const sourceTotalW = sourceNodes.length * SOURCE_W + (sourceNodes.length - 1) * H_GAP; const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => { const data = n.data as SourceNodeData; - 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" }; + return { + id: n.id, label: data.label, flowRate: data.flowRate, + x: -sourceTotalW / 2 + i * (SOURCE_W + H_GAP), y: sourceStartY, + w: SOURCE_W, h: SOURCE_H, sourceType: data.sourceType || "unconfigured", + }; }); - // Source waterfalls - const inflowsByFunnel = new Map(); - sourceNodes.forEach((sn) => { - const data = sn.data as SourceNodeData; - data.targetAllocations?.forEach((alloc, i) => { - const flowAmount = (alloc.percentage / 100) * data.flowRate; - if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, []); - inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage }); - }); - }); - - const sourceWaterfalls: WaterfallLayout[] = []; - sourceNodes.forEach((sn) => { - const data = sn.data as SourceNodeData; - const sourceLayout = sourceLayouts.find((s) => s.id === sn.id); - if (!sourceLayout) return; - data.targetAllocations?.forEach((alloc, allocIdx) => { - const targetLayout = funnelLayouts.find((f) => f.id === alloc.targetId); - if (!targetLayout) return; - const flowAmount = (alloc.percentage / 100) * data.flowRate; - const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || []; - const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0); - const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1; - 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: targetCenterX, xSource: sourceCenterX, - yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, - width: riverEndWidth, riverEndWidth, farEndWidth, - direction: "inflow", color: COLORS.sourceWaterfall, flowAmount, + // Position funnels + const funnelStartY = sourceStartY + SOURCE_H + LAYER_GAP; + const funnelLayouts: FunnelLayout[] = []; + layers.forEach((layer, layerIdx) => { + const totalW = layer.length * VESSEL_W + (layer.length - 1) * H_GAP; + const layerY = funnelStartY + layerIdx * (VESSEL_H + LAYER_GAP); + layer.forEach((n, i) => { + const data = n.data as FunnelNodeData; + const fillLevel = Math.min(1, data.currentValue / (data.capacity || 1)); + const overflowLevel = data.overflowThreshold / (data.capacity || 1); + funnelLayouts.push({ + id: n.id, label: data.label, data, + x: -totalW / 2 + i * (VESSEL_W + H_GAP), y: layerY, + w: VESSEL_W, h: VESSEL_H, bw: VESSEL_BW, + fillLevel, overflowLevel, + sufficiency: computeSufficiencyState(data), }); }); }); - // Implicit waterfalls for root funnels without source nodes - if (sourceNodes.length === 0) { - rootFunnels.forEach((rn) => { - const data = rn.data as FunnelNodeData; - if (data.inflowRate <= 0) return; - const layout = funnelLayouts.find((f) => f.id === rn.id); - if (!layout) return; - 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 }); - }); - } - - // Distribute inflows side-by-side at each funnel's top (Sankey stacking) - const inflowsPerFunnel = new Map(); - sourceWaterfalls.forEach(wf => { - if (!inflowsPerFunnel.has(wf.targetId)) inflowsPerFunnel.set(wf.targetId, []); - inflowsPerFunnel.get(wf.targetId)!.push(wf); - }); - inflowsPerFunnel.forEach((wfs, targetId) => { - const target = funnelLayouts.find(f => f.id === targetId); - if (!target || wfs.length <= 1) return; - const totalW = wfs.reduce((s, w) => s + w.width, 0); - const cx = target.x + target.vesselWidth / 2; - let offset = -totalW / 2; - wfs.forEach(wf => { - wf.x = cx + offset + wf.width / 2; - offset += wf.width; - }); - }); - - // 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; - 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, (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, - }); - }); - }); - - // Outcome layouts - const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT; - const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP; + // Position outcomes + const maxLayerIdx = layers.length - 1; + const outcomeY = funnelStartY + (maxLayerIdx + 1) * (VESSEL_H + LAYER_GAP); + const outcomeTotalW = outcomeNodes.length * POOL_W + (outcomeNodes.length - 1) * (H_GAP / 2); const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => { const data = n.data as OutcomeNodeData; const fillPercent = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0; - 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: -outcomeTotalW / 2 + i * (POOL_W + H_GAP / 2), y: outcomeY, + w: POOL_W, h: POOL_H, fillPercent, + }; }); - // Spending waterfalls — from vessel bottom drain to outcome pools - const spendingWaterfalls: WaterfallLayout[] = []; - funnelNodes.forEach((n) => { - const data = n.data as FunnelNodeData; - const parentLayout = funnelLayouts.find((f) => f.id === n.id); - if (!parentLayout) return; - const allocations = data.spendingAllocations || []; - if (allocations.length === 0) return; - const percentages = allocations.map((a) => a.percentage); - const drainSpan = parentLayout.vesselBottomWidth * 0.8; - const slotWidths = distributeWidths(percentages, drainSpan, MIN_WATERFALL_WIDTH); - const drainCx = parentLayout.x + parentLayout.vesselWidth / 2; - const startX = drainCx - drainSpan / 2; - let offsetX = 0; - allocations.forEach((alloc, i) => { - const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId); - if (!outcomeLayout) return; - 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 + 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), + // Build flow bands + const bands: BandLayout[] = []; + const nodeCenter = (layouts: { id: string; x: number; w: number; y: number; h: number }[], id: string): { cx: number; top: number; bottom: number } | null => { + const l = layouts.find((n) => n.id === id); + return l ? { cx: l.x + l.w / 2, top: l.y, bottom: l.y + l.h } : null; + }; + const allLayouts = [...sourceLayouts.map((s) => ({ ...s })), ...funnelLayouts.map((f) => ({ ...f })), ...outcomeLayouts.map((o) => ({ ...o }))]; + + // Source → funnel bands + const maxFlowRate = Math.max(1, ...sourceNodes.map((n) => (n.data as SourceNodeData).flowRate)); + sourceNodes.forEach((sn) => { + const data = sn.data as SourceNodeData; + const src = nodeCenter(allLayouts, sn.id); + data.targetAllocations?.forEach((alloc) => { + const tgt = nodeCenter(allLayouts, alloc.targetId); + if (!src || !tgt) return; + const flowAmount = data.flowRate * (alloc.percentage / 100); + const w = Math.max(MIN_BAND_W, (flowAmount / maxFlowRate) * MAX_BAND_W); + bands.push({ + id: `src-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, + x1: src.cx, y1: src.bottom, x2: tgt.cx, y2: tgt.top, + width: w, color: alloc.color || COLORS.inflow, + label: `${fmtDollars(flowAmount)}/mo`, kind: "inflow", flowAmount, }); }); }); - // Compute bounds and normalize - 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]; + // Funnel overflow + spending bands + funnelNodes.forEach((fn) => { + const data = fn.data as FunnelNodeData; + const src = funnelLayouts.find((f) => f.id === fn.id); + if (!src) return; + const srcCenter = src.x + src.w / 2; - const minX = Math.min(...allX, -100); - const maxX = Math.max(...allX, 100); - const maxY = Math.max(...allY, 400); - const padding = 100; + // Overflow bands (from sides) + data.overflowAllocations?.forEach((alloc, i) => { + const tgt = nodeCenter(allLayouts, alloc.targetId); + if (!tgt) return; + const excess = Math.max(0, data.currentValue - data.overflowThreshold); + const flowAmount = excess * (alloc.percentage / 100); + const rateAmount = data.inflowRate > data.drainRate ? (data.inflowRate - data.drainRate) * (alloc.percentage / 100) : 0; + const displayAmount = flowAmount > 0 ? flowAmount : rateAmount; + const w = Math.max(MIN_BAND_W, displayAmount > 0 ? Math.min(MAX_BAND_W, (displayAmount / maxFlowRate) * MAX_BAND_W) : MIN_BAND_W); + const overflowFrac = 1 - src.overflowLevel; + const lipY = src.y + overflowFrac * src.h; + const side = i % 2 === 0 ? -1 : 1; + const edges = vesselEdgesAtY(src.x, src.w, src.bw, overflowFrac); + const lipX = side < 0 ? edges.left : edges.right; + bands.push({ + id: `ovf-${fn.id}-${alloc.targetId}`, sourceId: fn.id, targetId: alloc.targetId, + x1: lipX, y1: lipY, x2: tgt.cx, y2: tgt.top, + width: w, color: alloc.color || COLORS.overflow, + label: displayAmount > 0 ? `${fmtDollars(displayAmount)}/mo` : `${alloc.percentage}%`, + kind: "overflow", flowAmount: displayAmount, + }); + }); - const offsetXGlobal = -minX + padding; - const offsetYGlobal = padding; - - 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; }); + // Spending bands (from bottom) + data.spendingAllocations?.forEach((alloc) => { + const tgt = nodeCenter(allLayouts, alloc.targetId); + if (!tgt) return; + const flowAmount = data.drainRate * (alloc.percentage / 100); + const w = Math.max(MIN_BAND_W, (flowAmount / maxFlowRate) * MAX_BAND_W); + bands.push({ + id: `spd-${fn.id}-${alloc.targetId}`, sourceId: fn.id, targetId: alloc.targetId, + x1: srcCenter, y1: src.y + src.h, x2: tgt.cx, y2: tgt.top, + width: w, color: alloc.color || COLORS.spending, + label: `${fmtDollars(flowAmount)}/mo`, kind: "spending", flowAmount, + }); + }); }); - 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, maxSourceFlowRate }; + // Normalize coordinates + const allX = [...sourceLayouts.flatMap((s) => [s.x, s.x + s.w]), ...funnelLayouts.flatMap((f) => [f.x, f.x + f.w]), ...outcomeLayouts.flatMap((o) => [o.x, o.x + o.w])]; + const allY = [...sourceLayouts.map((s) => s.y + s.h), ...funnelLayouts.map((f) => f.y + f.h), ...outcomeLayouts.map((o) => o.y + o.h + 30)]; + const minX = Math.min(...allX, 0); + const maxX = Math.max(...allX, 0); + const maxY = Math.max(...allY, 400); + + const offsetX = -minX + PADDING; + const offsetY = 0; + sourceLayouts.forEach((s) => { s.x += offsetX; s.y += offsetY; }); + funnelLayouts.forEach((f) => { f.x += offsetX; f.y += offsetY; }); + outcomeLayouts.forEach((o) => { o.x += offsetX; o.y += offsetY; }); + bands.forEach((b) => { b.x1 += offsetX; b.y1 += offsetY; b.x2 += offsetX; b.y2 += offsetY; }); + + return { + sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, bands, + width: maxX - minX + PADDING * 2, height: maxY + offsetY + PADDING, + }; } // ─── SVG Rendering ────────────────────────────────────── -function renderWaterfall(wf: WaterfallLayout): string { - const isInflow = wf.direction === "inflow"; - const height = wf.yEnd - wf.yStart; - if (height <= 0) return ""; - - // Constant width throughout — Sankey-style ribbon (no taper → no S-curve) - const topWidth = wf.width; - const bottomWidth = wf.width; - const topCx = isInflow ? wf.xSource : wf.x; - const bottomCx = isInflow ? wf.x : wf.xSource; - - const tl = topCx - topWidth / 2; - const tr = topCx + topWidth / 2; - const bl = bottomCx - bottomWidth / 2; - const br = bottomCx + bottomWidth / 2; - - // L-shaped paths: vertical drop + horizontal merge (inflow) - // or horizontal departure + vertical drop (outflow) - const hDisplacement = Math.abs(topCx - bottomCx); - const maxR = Math.min(height * 0.2, hDisplacement > 3 ? hDisplacement * 0.4 : height * 0.15, 15); - const r = Math.max(3, maxR); - - let shapePath: string; - let spinePath: string; - let leftEdge: string; - let rightEdge: string; - - if (hDisplacement < 3) { - // Source directly above river — simple tapered rectangle - shapePath = `M ${tl} ${wf.yStart} L ${bl} ${wf.yEnd} L ${br} ${wf.yEnd} L ${tr} ${wf.yStart} Z`; - spinePath = `M ${topCx} ${wf.yStart} L ${bottomCx} ${wf.yEnd}`; - leftEdge = `M ${tl} ${wf.yStart} L ${bl} ${wf.yEnd}`; - rightEdge = `M ${tr} ${wf.yStart} L ${br} ${wf.yEnd}`; - } else if (isInflow) { - // Inflow L-shape: vertical drop → rounded corner → horizontal merge into river - const hDir = Math.sign(bl - tl); // direction of horizontal leg - - shapePath = [ - `M ${tl} ${wf.yStart}`, - `L ${tl} ${wf.yEnd - r}`, - `Q ${tl} ${wf.yEnd}, ${tl + hDir * r} ${wf.yEnd}`, - `L ${bl} ${wf.yEnd}`, - `L ${br} ${wf.yEnd}`, - `L ${tr + hDir * r} ${wf.yEnd}`, - `Q ${tr} ${wf.yEnd}, ${tr} ${wf.yEnd - r}`, - `L ${tr} ${wf.yStart}`, - `Z`, - ].join(" "); - - spinePath = [ - `M ${topCx} ${wf.yStart}`, - `L ${topCx} ${wf.yEnd - r}`, - `Q ${topCx} ${wf.yEnd}, ${topCx + hDir * r} ${wf.yEnd}`, - `L ${bottomCx} ${wf.yEnd}`, - ].join(" "); - - leftEdge = `M ${tl} ${wf.yStart} L ${tl} ${wf.yEnd - r} Q ${tl} ${wf.yEnd}, ${tl + hDir * r} ${wf.yEnd} L ${bl} ${wf.yEnd}`; - rightEdge = `M ${tr} ${wf.yStart} L ${tr} ${wf.yEnd - r} Q ${tr} ${wf.yEnd}, ${tr + hDir * r} ${wf.yEnd} L ${br} ${wf.yEnd}`; - } else { - // Outflow inverted-L: horizontal departure from river → rounded corner → vertical drop - const hDir = Math.sign(tl - bl); // direction from destination back toward river - - shapePath = [ - `M ${tl} ${wf.yStart}`, - `L ${bl + hDir * r} ${wf.yStart}`, - `Q ${bl} ${wf.yStart}, ${bl} ${wf.yStart + r}`, - `L ${bl} ${wf.yEnd}`, - `L ${br} ${wf.yEnd}`, - `L ${br} ${wf.yStart + r}`, - `Q ${br} ${wf.yStart}, ${br + hDir * r} ${wf.yStart}`, - `L ${tr} ${wf.yStart}`, - `Z`, - ].join(" "); - - spinePath = [ - `M ${topCx} ${wf.yStart}`, - `L ${bottomCx + Math.sign(topCx - bottomCx) * r} ${wf.yStart}`, - `Q ${bottomCx} ${wf.yStart}, ${bottomCx} ${wf.yStart + r}`, - `L ${bottomCx} ${wf.yEnd}`, - ].join(" "); - - leftEdge = `M ${tl} ${wf.yStart} L ${bl + hDir * r} ${wf.yStart} Q ${bl} ${wf.yStart}, ${bl} ${wf.yStart + r} L ${bl} ${wf.yEnd}`; - rightEdge = `M ${tr} ${wf.yStart} L ${br + hDir * r} ${wf.yStart} Q ${br} ${wf.yStart}, ${br} ${wf.yStart + r} L ${br} ${wf.yEnd}`; - } - - const clipId = `sankey-clip-${wf.id}`; - const gradId = `sankey-grad-${wf.id}`; - const glowId = `sankey-glow-${wf.id}`; - const pathMinX = Math.min(tl, bl) - 5; - const pathMaxW = Math.max(topWidth, bottomWidth) + 10; - - 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; - - const labelX = isInflow ? topCx : bottomCx; - const labelY = wf.yStart + height * 0.45; - const flowLabel = wf.flowAmount >= 1000 ? `$${(wf.flowAmount / 1000).toFixed(1)}k` : `$${Math.floor(wf.flowAmount)}`; - - return ` - - - - - - - - - - - - - - - - - - - ${[0, 1, 2, 3, 4].map((i) => ``).join("")} - - - - - - - - ${flowLabel}/mo`; -} - -function renderBranch(b: BranchLayout): string { +function renderBand(b: BandLayout): string { + const hw = b.width / 2; const dx = b.x2 - b.x1; const dy = b.y2 - b.y1; - const halfW = b.width / 2; + if (dy <= 0) return ""; - // Pipe exits horizontally from lip, then turns down to target (L-shape) - const dirX = Math.sign(b.x2 - b.x1); - const r = Math.min(12, Math.abs(dy) * 0.2, Math.abs(b.x2 - b.x1) * 0.3); + // Cubic bezier ribbon — L-shaped path for horizontal displacement + const hDisp = Math.abs(dx); + const bendY = hDisp > 20 ? b.y1 + Math.min(dy * 0.3, 40) : b.y1 + dy * 0.4; + const cp1y = b.y1 + dy * 0.15; + const cp2y = b.y2 - dy * 0.15; - const spinePath = r > 2 - ? `M ${b.x1} ${b.y1} L ${b.x2 - dirX * r} ${b.y1} Q ${b.x2} ${b.y1}, ${b.x2} ${b.y1 + r} L ${b.x2} ${b.y2}` - : `M ${b.x1} ${b.y1} L ${b.x2} ${b.y1} L ${b.x2} ${b.y2}`; + // Left edge and right edge of ribbon + const path = [ + `M ${b.x1 - hw} ${b.y1}`, + `C ${b.x1 - hw} ${cp1y}, ${b.x2 - hw} ${cp2y}, ${b.x2 - hw} ${b.y2}`, + `L ${b.x2 + hw} ${b.y2}`, + `C ${b.x2 + hw} ${cp2y}, ${b.x1 + hw} ${cp1y}, ${b.x1 + hw} ${b.y1}`, + `Z`, + ].join(" "); - // Outer wall (dark) - const outerStroke = ``; - // Inner channel (surface) - const innerStroke = ``; - // Water flow (animated, only when active) - const waterFlow = b.isActive ? ` - - - ` : ""; + // Center-line for direction animation + const center = `M ${b.x1} ${b.y1} C ${b.x1} ${cp1y}, ${b.x2} ${cp2y}, ${b.x2} ${b.y2}`; - // Midpoint for label + // Label at midpoint const midX = (b.x1 + b.x2) / 2; - const midY = (b.y1 + b.y2) / 2 - 12; - - return `${outerStroke}${innerStroke}${waterFlow} - ${b.percentage}%`; -} - -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}`; + const midY = (b.y1 + b.y2) / 2; return ` - - - - - - - - - - - - - - - - - - - - - - - - - - ${flowRatio > 0.01 ? ` - - - ${[0, 1, 2].map((i) => ``).join("")}` : ""} - - - $ - - ${esc(s.label)} - $${s.flowRate.toLocaleString()}/mo`; + + + + ${b.label}`; +} + +function renderSource(s: SourceLayout): string { + const cx = s.x + s.w / 2; + const typeColor = COLORS.sourceType[s.sourceType] || COLORS.sourceType.unconfigured; + + return ` + + + ${esc(s.label)} + ${fmtDollars(s.flowRate)}/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 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; + const cx = f.x + f.w / 2; + const tl = f.x, tr = f.x + f.w; + const bl = cx - f.bw / 2, br = cx + f.bw / 2; - // Trapezoid corners - const tl = f.x; - const tr = f.x + vw; - const bl = cx - vbw / 2; - const br = cx + vbw / 2; + // Vessel outline + const vesselPath = `M ${tl} ${f.y} L ${bl} ${f.y + f.h} L ${br} ${f.y + f.h} L ${tr} ${f.y} Z`; + const clipId = `vc-${f.id}`; - const vesselGradId = `vessel-grad-${f.id}`; - const waterGradId = `vessel-water-${f.id}`; - const clipId = `vessel-clip-${f.id}`; + // Fill bar (solid, no water animation) + const fillTop = f.y + (1 - f.fillLevel) * f.h; + const fillEdges = vesselEdgesAtY(f.x, f.w, f.bw, (fillTop - f.y) / f.h); + const fillPath = f.fillLevel > 0.01 + ? `M ${fillEdges.left} ${fillTop} L ${bl} ${f.y + f.h} L ${br} ${f.y + f.h} L ${fillEdges.right} ${fillTop} Z` + : ""; - // 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; + // Overflow threshold line + const ovFrac = 1 - f.overflowLevel; + const ovY = f.y + ovFrac * f.h; + const ovEdges = vesselEdgesAtY(f.x, f.w, f.bw, ovFrac); - // Water fill - const fillTop = f.y + (1 - f.fillLevel) * vh; - const inflow = f.data.inflowRate || 0; - 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; + const isOverflowing = f.sufficiency === "overflowing"; + const fillColor = isOverflowing ? COLORS.overflow : COLORS.inflow; - // 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; + // Value and drain labels + const val = f.data.currentValue; + const drain = f.data.drainRate; return ` - - - - - - - - - - - - - ${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 ? "✨" : ""} - - - `; + + + ${fillPath ? `` : ""} + + ${esc(f.label)} + ${fmtDollars(val)} | drain ${fmtDollars(drain)}/mo + + `; } function renderOutcome(o: OutcomeLayout): string { - const filled = (o.fillPercent / 100) * POOL_HEIGHT; + const cx = o.x + o.w / 2; const color = o.data.status === "completed" ? "#10b981" : o.data.status === "blocked" ? "#ef4444" : "#3b82f6"; + const barW = o.w - 16; + const filledW = barW * Math.min(1, o.fillPercent / 100); return ` - - - ${filled > 5 ? `` : ""} - ${esc(o.label)} - ${Math.round(o.fillPercent)}%`; + + ${esc(o.label)} + + + ${fmtDollars(o.data.fundingReceived)} / ${fmtDollars(o.data.fundingTarget)}`; } function renderSufficiencyBadge(score: number, x: number, y: number): string { const pct = Math.round(score * 100); - const color = pct >= 90 ? COLORS.goldenGlow : pct >= 60 ? "#10b981" : pct >= 30 ? "#f59e0b" : "#ef4444"; - const circumference = 2 * Math.PI * 18; - const dashoffset = circumference * (1 - score); + const color = pct >= 90 ? "#fbbf24" : pct >= 60 ? "#10b981" : pct >= 30 ? "#f59e0b" : "#ef4444"; + const circ = 2 * Math.PI * 18; return ` - - - + + + ${pct}% - ENOUGH + HEALTH `; } -function esc(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -} - // ─── Web Component ────────────────────────────────────── class FolkFlowRiver extends HTMLElement { @@ -747,11 +364,6 @@ 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; @@ -771,9 +383,7 @@ class FolkFlowRiver extends HTMLElement { if (this.simulating) this.startSimulation(); } - disconnectedCallback() { - this.stopSimulation(); - } + disconnectedCallback() { this.stopSimulation(); } attributeChangedCallback(name: string, _: string, newVal: string) { if (name === "simulate") { @@ -791,10 +401,7 @@ class FolkFlowRiver extends HTMLElement { private scheduleRender() { if (this.renderScheduled) return; this.renderScheduled = true; - requestAnimationFrame(() => { - this.renderScheduled = false; - this.render(); - }); + requestAnimationFrame(() => { this.renderScheduled = false; this.render(); }); } private startSimulation() { @@ -852,10 +459,7 @@ class FolkFlowRiver extends HTMLElement { } private closePopover() { - if (this.activePopover) { - this.activePopover.remove(); - this.activePopover = null; - } + if (this.activePopover) { this.activePopover.remove(); this.activePopover = null; } } private updateSourceFlowRate(sourceId: string, flowRate: number, effectiveDate?: string) { @@ -871,7 +475,6 @@ class FolkFlowRiver extends HTMLElement { private render() { const layout = computeLayout(this.nodes); - this.currentLayout = layout; const score = computeSystemSufficiency(this.nodes); this.shadow.innerHTML = ` @@ -893,33 +496,24 @@ class FolkFlowRiver extends HTMLElement { .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; } } + .flow-dash { animation: dashFlow 1s linear infinite; } + @keyframes dashFlow { to { stroke-dashoffset: -14; } }
- ${layout.sourceWaterfalls.map(renderWaterfall).join("")} - ${layout.spendingWaterfalls.map(renderWaterfall).join("")} - ${layout.overflowBranches.map(renderBranch).join("")} - ${layout.sources.map((s) => renderSource(s, layout.maxSourceFlowRate)).join("")} + ${layout.bands.map(renderBand).join("")} + ${layout.sources.map(renderSource).join("")} ${layout.funnels.map(renderFunnel).join("")} ${layout.outcomes.map(renderOutcome).join("")} ${renderSufficiencyBadge(score, layout.width - 70, 10)}
- +
-
Inflow
-
Healthy
-
Overflow
-
Critical
-
Spending
-
Sufficient
+
Inflow
+
Spending
+
Overflow
`; @@ -933,34 +527,20 @@ class FolkFlowRiver extends HTMLElement { // Event delegation for interactive elements + drag-to-pan const container = this.shadow.querySelector(".container") as HTMLElement; - const svg = this.shadow.querySelector("svg") as SVGSVGElement; - if (!container || !svg) return; + if (!container) return; 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); @@ -969,7 +549,6 @@ class FolkFlowRiver extends HTMLElement { } } - // Close popover on click outside this.closePopover(); // Start pan drag @@ -983,52 +562,12 @@ class FolkFlowRiver extends HTMLElement { }); 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); diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index b437c92..2071c61 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -12,7 +12,7 @@ */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation, MortgagePosition, ReinvestmentPosition, BudgetSegment } from "../lib/types"; -import { PORT_DEFS, deriveThresholds } from "../lib/types"; +import { PORT_DEFS, migrateFunnelNodeData } from "../lib/types"; import { TourEngine } from "../../../shared/tour-engine"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { demoNodes, simDemoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; @@ -299,7 +299,10 @@ class FolkFlowsApp extends HTMLElement { const flow = doc.canvasFlows?.[this.currentFlowId]; if (flow && !this.saveTimer) { // Only update if we're not in the middle of saving - this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }))); + this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ + ...n, + data: n.type === "funnel" ? migrateFunnelNodeData(n.data) : { ...n.data }, + }))); this.drawCanvasContent(); } }); @@ -349,7 +352,10 @@ class FolkFlowsApp extends HTMLElement { if (!flow) return; this.currentFlowId = flow.id; this.flowName = flow.name; - this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }))); + this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ + ...n, + data: n.type === "funnel" ? migrateFunnelNodeData(n.data) : { ...n.data }, + }))); this.localFirstClient?.setActiveFlow(flowId); this.restoreViewport(flowId); this.loading = false; @@ -846,19 +852,13 @@ class FolkFlowsApp extends HTMLElement { private renderFunnelCard(data: FunnelNodeData, id: string): string { const sufficiency = computeSufficiencyState(data); - const threshold = data.sufficientThreshold ?? data.maxThreshold; - const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100); - const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100); + const fillPct = Math.min(100, (data.currentValue / (data.capacity || 1)) * 100); + const suffPct = Math.min(100, (data.currentValue / (data.overflowThreshold || 1)) * 100); - const statusClass = sufficiency === "abundant" ? "flows-status--abundant" - : sufficiency === "sufficient" ? "flows-status--sufficient" - : data.currentValue < data.minThreshold ? "flows-status--critical" + const statusClass = sufficiency === "overflowing" ? "flows-status--abundant" : "flows-status--seeking"; - const statusLabel = sufficiency === "abundant" ? "Abundant" - : sufficiency === "sufficient" ? "Sufficient" - : data.currentValue < data.minThreshold ? "Critical" - : "Seeking"; + const statusLabel = sufficiency === "overflowing" ? "Overflowing" : "Seeking"; return `
@@ -869,12 +869,12 @@ class FolkFlowsApp extends HTMLElement {
-
+
$${Math.floor(data.currentValue).toLocaleString()} - / $${Math.floor(threshold).toLocaleString()} + / $${Math.floor(data.overflowThreshold).toLocaleString()}
${Math.round(suffPct)}% @@ -882,9 +882,9 @@ class FolkFlowsApp extends HTMLElement {
- Min: $${Math.floor(data.minThreshold).toLocaleString()} - Max: $${Math.floor(data.maxThreshold).toLocaleString()} - Cap: $${Math.floor(data.maxCapacity).toLocaleString()} + Drain: $${Math.floor(data.drainRate).toLocaleString()}/mo + Overflow: $${Math.floor(data.overflowThreshold).toLocaleString()} + Cap: $${Math.floor(data.capacity).toLocaleString()}
${data.overflowAllocations.length > 0 ? `
@@ -1215,7 +1215,7 @@ class FolkFlowsApp extends HTMLElement { } if (n.type === "funnel") { const d = n.data as FunnelNodeData; - const cap = d.maxCapacity || 9000; + const cap = d.capacity || 9000; const w = Math.round(200 + Math.min(160, (cap / 50000) * 160)); const h = Math.round(200 + Math.min(220, (cap / 50000) * 220)); return { w, h }; @@ -1275,20 +1275,15 @@ class FolkFlowsApp extends HTMLElement { const startY = e.clientY; if (valveG) { - const startOutflow = fd.desiredOutflow || 0; + const startDrain = fd.drainRate || 0; handleG.setPointerCapture(e.pointerId); const label = handleG.querySelector("text"); const onMove = (ev: PointerEvent) => { const deltaX = (ev.clientX - startX) / this.canvasZoom; - let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50; - newOutflow = Math.max(0, Math.min(10000, newOutflow)); - fd.desiredOutflow = newOutflow; - fd.minThreshold = newOutflow; - fd.maxThreshold = newOutflow * 6; - if (fd.maxCapacity < fd.maxThreshold * 1.5) { - fd.maxCapacity = Math.round(fd.maxThreshold * 1.5); - } - if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`; + let newDrain = Math.round((startDrain + (deltaX / s.w) * 10000) / 50) * 50; + newDrain = Math.max(0, Math.min(10000, newDrain)); + fd.drainRate = newDrain; + if (label) label.textContent = `◁ ${this.formatDollar(newDrain)}/mo ▷`; }; const onUp = () => { handleG.removeEventListener("pointermove", onMove as EventListener); @@ -1302,14 +1297,14 @@ class FolkFlowsApp extends HTMLElement { handleG.addEventListener("pointerup", onUp); handleG.addEventListener("lostpointercapture", onUp); } else { - const startCapacity = fd.maxCapacity || 9000; + const startCapacity = fd.capacity || 9000; handleG.setPointerCapture(e.pointerId); const label = handleG.querySelector("text"); const onMove = (ev: PointerEvent) => { const deltaY = (ev.clientY - startY) / this.canvasZoom; let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500; - newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity)); - fd.maxCapacity = newCapacity; + newCapacity = Math.max(Math.round(fd.overflowThreshold * 1.1), Math.min(100000, newCapacity)); + fd.capacity = newCapacity; if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`; }; const onUp = () => { @@ -1844,7 +1839,7 @@ class FolkFlowsApp extends HTMLElement { // Sum overflow from parent funnels if (src.type === "funnel" && src.id !== n.id) { const fd = src.data as FunnelNodeData; - const excess = Math.max(0, fd.currentValue - fd.maxThreshold); + const excess = Math.max(0, fd.currentValue - fd.overflowThreshold); for (const alloc of fd.overflowAllocations) { if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100); } @@ -1987,19 +1982,18 @@ class FolkFlowsApp extends HTMLElement { const d = n.data as FunnelNodeData; const s = this.getNodeSize(n); const x = n.position.x, y = n.position.y, w = s.w, h = s.h; - const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); + const fillPct = Math.min(1, d.currentValue / (d.capacity || 1)); - const isOverflow = d.currentValue > d.maxThreshold; - const isCritical = d.currentValue < d.minThreshold; - const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; + const isOverflow = d.currentValue > d.overflowThreshold; + const borderColorVar = isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; const fillColor = borderColorVar; - const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient"; + const statusLabel = isOverflow ? "Overflow" : "Seeking"; // Vessel shape parameters const r = 10; const drainW = 60; // narrow drain spout at bottom - const outflow = d.desiredOutflow || 0; - const outflowRatio = Math.min(1, outflow / 10000); + const drain = d.drainRate || 0; + const drainRatio = Math.min(1, drain / 10000); // taperAtBottom: how far walls inset at the very bottom (in px) const taperAtBottom = (w - drainW) / 2; @@ -2010,14 +2004,13 @@ class FolkFlowsApp extends HTMLElement { const zoneTop = 36; const zoneBot = h - 6; const zoneH = zoneBot - zoneTop; - const minFrac = d.minThreshold / (d.maxCapacity || 1); - const maxFrac = d.maxThreshold / (d.maxCapacity || 1); + const maxFrac = d.overflowThreshold / (d.capacity || 1); const maxLineY = zoneTop + zoneH * (1 - maxFrac); // Fixed pipe height — animate fill/opacity instead of resizing to prevent frame jumps const pipeH = basePipeH; const pipeY = Math.round(maxLineY - basePipeH / 2); - const excessRatio = isOverflow && d.maxCapacity > d.maxThreshold - ? Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)) + const excessRatio = isOverflow && d.capacity > d.overflowThreshold + ? Math.min(1, (d.currentValue - d.overflowThreshold) / (d.capacity - d.overflowThreshold)) : 0; // Wall inset at pipe Y position for pipe attachment @@ -2092,12 +2085,10 @@ class FolkFlowsApp extends HTMLElement { const clipId = `funnel-clip-${n.id}`; - // Zone dimensions - const criticalPct = minFrac; - const sufficientPct = maxFrac - minFrac; + // Zone dimensions: below overflow = seeking, above overflow = overflowing + const seekingPct = maxFrac; const overflowPct = Math.max(0, 1 - maxFrac); - const criticalH = zoneH * criticalPct; - const sufficientH = zoneH * sufficientPct; + const seekingH = zoneH * seekingPct; const overflowH = zoneH * overflowPct; // Fill path (tapered polygon) @@ -2105,17 +2096,12 @@ class FolkFlowsApp extends HTMLElement { const totalFillH = zoneH * fillPct; const fillY = zoneTop + zoneH - totalFillH; - // Threshold lines with X endpoints computed from wall taper - const minLineY = zoneTop + zoneH * (1 - minFrac); - const minYFrac = (minLineY - zoneTop) / zoneH; - const minInset = this.vesselWallInset(minYFrac, taperAtBottom); + // Threshold line at overflow point const maxInset = this.vesselWallInset(pipeYFrac, taperAtBottom); const thresholdLines = ` - - Min - Max`; + Overflow`; // Water surface shimmer line at fill level const shimmerLine = fillPct > 0.01 ? `` : ""; @@ -2143,17 +2129,16 @@ class FolkFlowsApp extends HTMLElement { const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : ""; - const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))" - : !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : ""; + const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))" : ""; // Rate labels const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`; - const excess = Math.max(0, d.currentValue - d.maxThreshold); + const excess = Math.max(0, d.currentValue - d.overflowThreshold); const overflowLabel = isOverflow ? this.formatDollar(excess) : ""; // Status badge colors - const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)"; - const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b"; + const statusBadgeBg = isOverflow ? "rgba(16,185,129,0.15)" : "rgba(59,130,246,0.15)"; + const statusBadgeColor = isOverflow ? "#10b981" : "#3b82f6"; // Drain spout inset for valve handle positioning const drainInset = taperAtBottom; @@ -2165,8 +2150,7 @@ class FolkFlowsApp extends HTMLElement { ${isOverflow ? `` : ""} - - + ${fillPath ? `` : ""} ${shimmerLine} @@ -2184,7 +2168,7 @@ class FolkFlowsApp extends HTMLElement { - ◁ ${this.formatDollar(outflow)}/mo ▷ + ◁ ${this.formatDollar(drain)}/mo ▷ @@ -2214,13 +2198,12 @@ class FolkFlowsApp extends HTMLElement { ${satLabel} - ${criticalH > 20 ? `CRITICAL` : ""} - ${sufficientH > 20 ? `SUFFICIENT` : ""} + ${seekingH > 20 ? `SEEKING` : ""} ${overflowH > 20 ? `OVERFLOW` : ""} - $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()} - - ${this.formatDollar(outflow)}/mo \u25BE + $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.overflowThreshold).toLocaleString()} + + ${this.formatDollar(drain)}/mo \u25BE ${isOverflow ? `${overflowLabel} ${overflowLabel}` : ""} @@ -2407,17 +2390,13 @@ class FolkFlowsApp extends HTMLElement { } if (n.type === "funnel") { const d = n.data as FunnelNodeData; - const excess = Math.max(0, d.currentValue - d.maxThreshold); + const excess = Math.max(0, d.currentValue - d.overflowThreshold); for (const alloc of d.overflowAllocations) { const flow = excess * (alloc.percentage / 100); nodeFlows.get(n.id)!.totalOutflow += flow; if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow; } - let rateMultiplier: number; - if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; - else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; - else rateMultiplier = 0.1; - const drain = d.inflowRate * rateMultiplier; + const drain = d.drainRate; for (const alloc of d.spendingAllocations) { const flow = drain * (alloc.percentage / 100); nodeFlows.get(n.id)!.totalOutflow += flow; @@ -2458,13 +2437,9 @@ class FolkFlowsApp extends HTMLElement { let spendingFlow = 0; if (n.type === "funnel") { const d = n.data as FunnelNodeData; - const excess = Math.max(0, d.currentValue - d.maxThreshold); + const excess = Math.max(0, d.currentValue - d.overflowThreshold); for (const alloc of d.overflowAllocations) overflowFlow += excess * (alloc.percentage / 100); - let rateMultiplier: number; - if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; - else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; - else rateMultiplier = 0.1; - const drain = d.inflowRate * rateMultiplier; + const drain = d.drainRate; for (const alloc of d.spendingAllocations) spendingFlow += drain * (alloc.percentage / 100); } else if (n.type === "outcome") { const d = n.data as OutcomeNodeData; @@ -2518,7 +2493,7 @@ class FolkFlowsApp extends HTMLElement { for (const alloc of d.overflowAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const excess = Math.max(0, d.currentValue - d.maxThreshold); + const excess = Math.max(0, d.currentValue - d.overflowThreshold); const flowAmount = excess * (alloc.percentage / 100); const side = this.getOverflowSideForTarget(n, target); edges.push({ @@ -2530,16 +2505,11 @@ class FolkFlowsApp extends HTMLElement { waypoint: alloc.waypoint, }); } - // Spending edges — rate-based drain + // Spending edges — flat drain rate for (const alloc of d.spendingAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - let rateMultiplier: number; - if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; - else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; - else rateMultiplier = 0.1; - const drain = d.inflowRate * rateMultiplier; - const flowAmount = drain * (alloc.percentage / 100); + const flowAmount = d.drainRate * (alloc.percentage / 100); edges.push({ fromNode: n, toNode: target, fromPort: "spending", color: "var(--rflows-edge-spending)", flowAmount, @@ -2858,8 +2828,7 @@ class FolkFlowsApp extends HTMLElement { if (n.type === "source") return "var(--rflows-source-border)"; if (n.type === "funnel") { const d = n.data as FunnelNodeData; - return d.currentValue < d.minThreshold ? "var(--rflows-status-critical)" - : d.currentValue > d.maxThreshold ? "var(--rflows-status-overflow)" + return d.currentValue > d.overflowThreshold ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; } const d = n.data as OutcomeNodeData; @@ -2892,7 +2861,7 @@ class FolkFlowsApp extends HTMLElement { const d = node.data as FunnelNodeData; const h = s.h; const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop; - const maxFrac = d.maxThreshold / (d.maxCapacity || 1); + const maxFrac = d.overflowThreshold / (d.capacity || 1); const maxLineY = zoneTop + zoneH * (1 - maxFrac); // X position: fully outside the vessel walls (pipe extends outward) const pipeW = this.getFunnelOverflowPipeW(node); @@ -2921,7 +2890,7 @@ class FolkFlowsApp extends HTMLElement { if (n.type === "funnel" && p.kind === "overflow" && p.side) { const d = n.data as FunnelNodeData; const zoneTop = 36, zoneBot = s.h - 6, zoneH = zoneBot - zoneTop; - const maxFrac = d.maxThreshold / (d.maxCapacity || 1); + const maxFrac = d.overflowThreshold / (d.capacity || 1); cy = zoneTop + zoneH * (1 - maxFrac); const pipeW = this.getFunnelOverflowPipeW(n); cx = p.side === "left" ? -pipeW : s.w + pipeW; @@ -3325,7 +3294,7 @@ class FolkFlowsApp extends HTMLElement { // Funnels: drag handles instead of config panel if (node.type === "funnel") { const d = node.data as FunnelNodeData; - const outflow = d.desiredOutflow || 0; + const drainVal = d.drainRate || 0; // Drain spout width for tapered vessel const drainW = 60; const drainInset = (s.w - drainW) / 2; @@ -3334,7 +3303,7 @@ class FolkFlowsApp extends HTMLElement { - ◁ ${this.formatDollar(outflow)}/mo ▷ + ◁ ${this.formatDollar(drainVal)}/mo ▷ @@ -3425,25 +3394,23 @@ class FolkFlowsApp extends HTMLElement { private renderFunnelConfigTab(node: FlowNode): string { const d = node.data as FunnelNodeData; - const outflow = d.desiredOutflow || 0; + const drainVal = d.drainRate || 0; return `
- $/mo - - ${this.formatDollar(outflow)} + Drain $/mo + + ${this.formatDollar(drainVal)}
-
-
-
+
+
- ${this.formatDollar(d.minThreshold)} - ${this.formatDollar(d.sufficientThreshold ?? d.maxThreshold)} - ${this.formatDollar(d.maxThreshold)} + ${this.formatDollar(d.overflowThreshold)} + ${this.formatDollar(d.capacity)}
`; } @@ -3471,9 +3438,8 @@ class FolkFlowsApp extends HTMLElement { if (node.type === "funnel") { const d = node.data as FunnelNodeData; const suf = computeSufficiencyState(d); - const threshold = d.sufficientThreshold ?? d.maxThreshold; - const fillPct = Math.min(100, Math.round((d.currentValue / (threshold || 1)) * 100)); - const fillColor = suf === "seeking" ? "#3b82f6" : suf === "sufficient" ? "#10b981" : "#f59e0b"; + const fillPct = Math.min(100, Math.round((d.currentValue / (d.overflowThreshold || 1)) * 100)); + const fillColor = suf === "seeking" ? "#3b82f6" : "#f59e0b"; const totalOut = (stats?.totalOutflow || 0) + (stats?.totalOverflow || 0); const outflowPct = totalOut > 0 ? Math.round(((stats?.totalOutflow || 0) / totalOut) * 100) : 50; @@ -3569,7 +3535,7 @@ class FolkFlowsApp extends HTMLElement { const valveHandle = overlay.querySelector(".valve-drag-handle"); const heightHandle = overlay.querySelector(".height-drag-handle"); - // Valve drag (horizontal → desiredOutflow) + // Valve drag (horizontal → drainRate) if (valveHandle) { valveHandle.addEventListener("pointerdown", (e: Event) => { const pe = e as PointerEvent; @@ -3577,23 +3543,18 @@ class FolkFlowsApp extends HTMLElement { pe.preventDefault(); const startX = pe.clientX; const fd = node.data as FunnelNodeData; - const startOutflow = fd.desiredOutflow || 0; + const startDrain = fd.drainRate || 0; (valveHandle as Element).setPointerCapture(pe.pointerId); const onMove = (ev: Event) => { const me = ev as PointerEvent; const deltaX = (me.clientX - startX) / this.canvasZoom; - let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50; - newOutflow = Math.max(0, Math.min(10000, newOutflow)); - fd.desiredOutflow = newOutflow; - fd.minThreshold = newOutflow; - fd.maxThreshold = newOutflow * 6; - if (fd.maxCapacity < fd.maxThreshold * 1.5) { - fd.maxCapacity = Math.round(fd.maxThreshold * 1.5); - } + let newDrain = Math.round((startDrain + (deltaX / s.w) * 10000) / 50) * 50; + newDrain = Math.max(0, Math.min(10000, newDrain)); + fd.drainRate = newDrain; // Update label text only during drag const label = overlay.querySelector(".valve-drag-label"); - if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`; + if (label) label.textContent = `◁ ${this.formatDollar(newDrain)}/mo ▷`; }; const onUp = () => { @@ -3613,7 +3574,7 @@ class FolkFlowsApp extends HTMLElement { }); } - // Height drag (vertical → maxCapacity) + // Height drag (vertical → capacity) if (heightHandle) { heightHandle.addEventListener("pointerdown", (e: Event) => { const pe = e as PointerEvent; @@ -3621,16 +3582,15 @@ class FolkFlowsApp extends HTMLElement { pe.preventDefault(); const startY = pe.clientY; const fd = node.data as FunnelNodeData; - const startCapacity = fd.maxCapacity || 9000; + const startCapacity = fd.capacity || 9000; (heightHandle as Element).setPointerCapture(pe.pointerId); const onMove = (ev: Event) => { const me = ev as PointerEvent; const deltaY = (me.clientY - startY) / this.canvasZoom; - // Down = more capacity, up = less let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500; - newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity)); - fd.maxCapacity = newCapacity; + newCapacity = Math.max(Math.round(fd.overflowThreshold * 1.1), Math.min(100000, newCapacity)); + fd.capacity = newCapacity; // Update label const label = overlay.querySelector(".height-drag-label"); if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`; @@ -3664,12 +3624,11 @@ class FolkFlowsApp extends HTMLElement { const taperAtBottom = (s.w - drainW) / 2; const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ - { key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" }, - { key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-overflow)", label: "Max" }, + { key: "overflowThreshold", value: d.overflowThreshold, color: "var(--rflows-status-overflow)", label: "Overflow" }, ]; for (const t of thresholds) { - const frac = t.value / (d.maxCapacity || 1); + const frac = t.value / (d.capacity || 1); const markerY = zoneTop + zoneH * (1 - frac); const yFrac = (markerY - zoneTop) / zoneH; const inset = this.vesselWallInset(yFrac, taperAtBottom); @@ -3763,7 +3722,7 @@ class FolkFlowsApp extends HTMLElement { const input = el as HTMLInputElement | HTMLSelectElement; const field = input.dataset.icpField!; const handler = () => { - const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"]; + const numFields = ["flowRate", "currentValue", "overflowThreshold", "capacity", "inflowRate", "drainRate", "fundingReceived", "fundingTarget"]; const val = numFields.includes(field) ? parseFloat((input as HTMLInputElement).value) || 0 : input.value; (node.data as any)[field] = val; this.redrawNodeOnly(node); @@ -3813,21 +3772,18 @@ class FolkFlowsApp extends HTMLElement { input.addEventListener("input", () => { const val = parseFloat(input.value) || 0; const fd = node.data as FunnelNodeData; - fd.desiredOutflow = val; - const derived = deriveThresholds(val); - fd.minThreshold = derived.minThreshold; - fd.sufficientThreshold = derived.sufficientThreshold; - fd.maxThreshold = derived.maxThreshold; - fd.maxCapacity = derived.maxCapacity; + fd.drainRate = val; + // Auto-derive thresholds from drain rate (6-month and 12-month multiples) + fd.overflowThreshold = val * 6; + fd.capacity = val * 12; const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement; if (valueSpan) valueSpan.textContent = this.formatDollar(val); // Update threshold bar labels const thresholds = overlay.querySelector("[data-icp-thresholds]"); if (thresholds) { const spans = thresholds.querySelectorAll("span"); - if (spans[0]) spans[0].textContent = this.formatDollar(derived.minThreshold); - if (spans[1]) spans[1].textContent = this.formatDollar(derived.sufficientThreshold); - if (spans[2]) spans[2].textContent = this.formatDollar(derived.maxThreshold); + if (spans[0]) spans[0].textContent = this.formatDollar(fd.overflowThreshold); + if (spans[1]) spans[1].textContent = this.formatDollar(fd.capacity); } this.redrawNodeOnly(node); this.redrawEdges(); @@ -3856,13 +3812,11 @@ class FolkFlowsApp extends HTMLElement { const s = this.getNodeSize(node); const zoneH = s.h - 6 - 36; const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom; - const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1); + const deltaDollars = -(deltaY / zoneH) * (d.capacity || 1); let newVal = this.inlineEditDragStartValue + deltaDollars; - newVal = Math.max(0, Math.min(d.maxCapacity, newVal)); + newVal = Math.max(0, Math.min(d.capacity, newVal)); const key = this.inlineEditDragThreshold; - if (key === "minThreshold") newVal = Math.min(newVal, d.maxThreshold); - if (key === "maxThreshold") newVal = Math.max(newVal, d.minThreshold); - if (key === "sufficientThreshold") newVal = Math.max(d.minThreshold, Math.min(d.maxThreshold, newVal)); + if (key === "overflowThreshold") newVal = Math.min(newVal, d.capacity); (node.data as any)[key] = Math.round(newVal); this.redrawNodeInlineEdit(node); }); @@ -3952,11 +3906,10 @@ class FolkFlowsApp extends HTMLElement { if (node.type === "funnel") { const d = node.data as FunnelNodeData; stats.totalInflow += d.inflowRate; - const threshold = d.sufficientThreshold ?? d.maxThreshold; - if (d.currentValue >= d.maxCapacity) { + if (d.currentValue >= d.capacity) { stats.totalOverflow += d.inflowRate * 0.5; stats.totalOutflow += d.inflowRate * 0.5; - } else if (d.currentValue >= threshold) { + } else if (d.currentValue >= d.overflowThreshold) { stats.totalOutflow += d.inflowRate * 0.3; } stats.fillLevelSum += d.currentValue; @@ -4015,26 +3968,21 @@ class FolkFlowsApp extends HTMLElement { private renderFunnelEditor(n: FlowNode): string { const d = n.data as FunnelNodeData; - const derived = d.desiredOutflow ? deriveThresholds(d.desiredOutflow) : null; return `
-
-
+
+
-
Thresholds ${derived ? "(auto-derived from outflow)" : ""}
-
-
-
-
-
-
-
-
+
Thresholds
+
+
+
+
${this.renderAllocEditor("Overflow Allocations", d.overflowAllocations)} ${this.renderAllocEditor("Spending Allocations", d.spendingAllocations)}`; @@ -4540,22 +4488,8 @@ class FolkFlowsApp extends HTMLElement { const field = (input as HTMLElement).dataset.field; if (!field) return; const val = (input as HTMLInputElement).value; - const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget", "desiredOutflow"]; + const numFields = ["flowRate", "currentValue", "overflowThreshold", "capacity", "inflowRate", "drainRate", "fundingReceived", "fundingTarget"]; (node.data as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; - // Auto-derive thresholds when desiredOutflow changes - if (field === "desiredOutflow" && node.type === "funnel") { - const fd = node.data as FunnelNodeData; - if (fd.desiredOutflow) { - const derived = deriveThresholds(fd.desiredOutflow); - fd.minThreshold = derived.minThreshold; - fd.sufficientThreshold = derived.sufficientThreshold; - fd.maxThreshold = derived.maxThreshold; - fd.maxCapacity = derived.maxCapacity; - // Re-render the editor to reflect updated values - this.openEditor(node.id); - return; - } - } this.drawCanvasContent(); this.updateSufficiencyBadge(); this.scheduleSave(); @@ -4600,8 +4534,8 @@ class FolkFlowsApp extends HTMLElement { } else if (node.type === "funnel") { const d = node.data as FunnelNodeData; const suf = computeSufficiencyState(d); - html += `
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}
`; - html += `
${suf}
`; + html += `
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.overflowThreshold).toLocaleString()}
`; + html += `
${suf}
`; } else { const d = node.data as OutcomeNodeData; const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0; @@ -4976,9 +4910,8 @@ class FolkFlowsApp extends HTMLElement { data = { label: defaultLabel, flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData; } else if (type === "funnel") { data = { - label: "New Funnel", currentValue: 0, desiredOutflow: 5000, - minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000, - maxCapacity: 45000, inflowRate: 0, dynamicOverflow: false, + label: "New Funnel", currentValue: 0, drainRate: 5000, + overflowThreshold: 30000, capacity: 45000, inflowRate: 0, overflowAllocations: [], spendingAllocations: [], } as FunnelNodeData; } else { @@ -5068,7 +5001,7 @@ class FolkFlowsApp extends HTMLElement { const d = n.data as FunnelNodeData; const s = this.getNodeSize(n); const w = s.w, h = s.h; - const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); + const fillPct = Math.min(1, d.currentValue / (d.capacity || 1)); const drainW = 60; const taperAtBottom = (w - drainW) / 2; const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom); @@ -5078,10 +5011,9 @@ class FolkFlowsApp extends HTMLElement { didPatch = true; } // Patch value text - const threshold = d.sufficientThreshold ?? d.maxThreshold; const valText = nodeLayer.querySelector(`.funnel-value-text[data-node-id="${n.id}"]`) as SVGTextElement | null; if (valText) { - valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}`; + valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.overflowThreshold).toLocaleString()}`; } } @@ -5164,7 +5096,7 @@ class FolkFlowsApp extends HTMLElement { if (!json) return; const nodes = JSON.parse(json) as FlowNode[]; if (Array.isArray(nodes) && nodes.length > 0) { - this.nodes = nodes; + this.nodes = nodes.map((n: any) => n.type === "funnel" ? { ...n, data: migrateFunnelNodeData(n.data) } : n); this.drawCanvasContent(); this.fitView(); } @@ -5824,10 +5756,10 @@ class FolkFlowsApp extends HTMLElement { data: { label: 'Budget Pool', currentValue: this.budgetTotalAmount, - minThreshold: this.budgetTotalAmount * 0.2, - maxThreshold: this.budgetTotalAmount * 0.8, - maxCapacity: this.budgetTotalAmount, + overflowThreshold: this.budgetTotalAmount * 0.8, + capacity: this.budgetTotalAmount, inflowRate: 0, + drainRate: 0, overflowAllocations: [], spendingAllocations: [], } as FunnelNodeData, diff --git a/modules/rflows/lib/map-flow.ts b/modules/rflows/lib/map-flow.ts index 4d8579b..799ddef 100644 --- a/modules/rflows/lib/map-flow.ts +++ b/modules/rflows/lib/map-flow.ts @@ -3,7 +3,7 @@ * Shared between folk-flows-app (data loading) and folk-flow-river (rendering). */ -import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "./types"; +import { migrateFunnelNodeData, type FlowNode, type FunnelNodeData, type OutcomeNodeData, type SourceNodeData } from "./types"; const SPENDING_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#06b6d4", "#10b981", "#6366f1"]; const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc2626", "#ea580c"]; @@ -39,15 +39,10 @@ export function mapFlowToNodes(apiData: any): FlowNode[] { id: funnel.id, type: "funnel", position: { x: 0, y: 0 }, - data: { + data: migrateFunnelNodeData({ + ...funnel, label: funnel.label || funnel.name || "Funnel", currentValue: funnel.currentValue ?? funnel.balance ?? 0, - minThreshold: funnel.minThreshold ?? 0, - maxThreshold: funnel.maxThreshold ?? funnel.currentValue ?? 10000, - maxCapacity: funnel.maxCapacity ?? funnel.maxThreshold ?? 100000, - inflowRate: funnel.inflowRate ?? 0, - sufficientThreshold: funnel.sufficientThreshold, - dynamicOverflow: funnel.dynamicOverflow ?? false, overflowAllocations: (funnel.overflowAllocations || []).map((a: any, i: number) => ({ targetId: a.targetId, percentage: a.percentage, @@ -58,7 +53,7 @@ export function mapFlowToNodes(apiData: any): FlowNode[] { percentage: a.percentage, color: a.color || SPENDING_COLORS[i % SPENDING_COLORS.length], })), - } as FunnelNodeData, + }), }); } } diff --git a/modules/rflows/lib/presets.ts b/modules/rflows/lib/presets.ts index 1858539..5d31f42 100644 --- a/modules/rflows/lib/presets.ts +++ b/modules/rflows/lib/presets.ts @@ -44,9 +44,8 @@ export const demoNodes: FlowNode[] = [ { id: "bcrg", type: "funnel", position: { x: 560, y: 0 }, data: { - label: "BCRG Treasury", currentValue: 0, desiredOutflow: 6000, - minThreshold: 6000, sufficientThreshold: 25000, maxThreshold: 35000, - maxCapacity: 50000, inflowRate: 20000, dynamicOverflow: true, + label: "BCRG Treasury", currentValue: 0, drainRate: 6000, + overflowThreshold: 35000, capacity: 50000, inflowRate: 20000, overflowAllocations: [ { targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] }, ], @@ -64,9 +63,8 @@ export const demoNodes: FlowNode[] = [ { id: "programs", type: "funnel", position: { x: 100, y: 600 }, data: { - label: "Programs", currentValue: 0, desiredOutflow: 2500, - minThreshold: 2500, sufficientThreshold: 10000, maxThreshold: 15000, - maxCapacity: 22000, inflowRate: 0, dynamicOverflow: true, + label: "Programs", currentValue: 0, drainRate: 2500, + overflowThreshold: 15000, capacity: 22000, inflowRate: 0, overflowAllocations: [ { targetId: "operations", percentage: 60, color: OVERFLOW_COLORS[1] }, { targetId: "growth", percentage: 40, color: OVERFLOW_COLORS[2] }, @@ -80,9 +78,8 @@ export const demoNodes: FlowNode[] = [ { id: "operations", type: "funnel", position: { x: 560, y: 600 }, data: { - label: "Operations", currentValue: 0, desiredOutflow: 2200, - minThreshold: 2200, sufficientThreshold: 8800, maxThreshold: 13200, - maxCapacity: 20000, inflowRate: 0, dynamicOverflow: true, + label: "Operations", currentValue: 0, drainRate: 2200, + overflowThreshold: 13200, capacity: 20000, inflowRate: 0, overflowAllocations: [ { targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] }, ], @@ -95,9 +92,8 @@ export const demoNodes: FlowNode[] = [ { id: "growth", type: "funnel", position: { x: 1020, y: 600 }, data: { - label: "Growth", currentValue: 0, desiredOutflow: 1500, - minThreshold: 1500, sufficientThreshold: 6000, maxThreshold: 9000, - maxCapacity: 14000, inflowRate: 0, dynamicOverflow: true, + label: "Growth", currentValue: 0, drainRate: 1500, + overflowThreshold: 9000, capacity: 14000, inflowRate: 0, overflowAllocations: [ { targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[3] }, ], @@ -113,9 +109,8 @@ export const demoNodes: FlowNode[] = [ { id: "alice", type: "funnel", position: { x: -100, y: 1250 }, data: { - label: "Alice — Research", currentValue: 0, desiredOutflow: 1200, - minThreshold: 1200, sufficientThreshold: 4800, maxThreshold: 7200, - maxCapacity: 10800, inflowRate: 0, + label: "Alice — Research", currentValue: 0, drainRate: 1200, + overflowThreshold: 7200, capacity: 10800, inflowRate: 0, overflowAllocations: [ { targetId: "bob", percentage: 100, color: OVERFLOW_COLORS[4] }, ], @@ -128,9 +123,8 @@ export const demoNodes: FlowNode[] = [ { id: "bob", type: "funnel", position: { x: 280, y: 1250 }, data: { - label: "Bob — Engineering", currentValue: 0, desiredOutflow: 1300, - minThreshold: 1300, sufficientThreshold: 5200, maxThreshold: 7800, - maxCapacity: 11700, inflowRate: 0, + label: "Bob — Engineering", currentValue: 0, drainRate: 1300, + overflowThreshold: 7800, capacity: 11700, inflowRate: 0, overflowAllocations: [ { targetId: "programs", percentage: 100, color: OVERFLOW_COLORS[5] }, ], @@ -143,9 +137,8 @@ export const demoNodes: FlowNode[] = [ { id: "carol", type: "funnel", position: { x: 660, y: 1250 }, data: { - label: "Carol — Comms", currentValue: 0, desiredOutflow: 1100, - minThreshold: 1100, sufficientThreshold: 4400, maxThreshold: 6600, - maxCapacity: 9900, inflowRate: 0, + label: "Carol — Comms", currentValue: 0, drainRate: 1100, + overflowThreshold: 6600, capacity: 9900, inflowRate: 0, overflowAllocations: [ { targetId: "dave", percentage: 100, color: OVERFLOW_COLORS[0] }, ], @@ -158,9 +151,8 @@ export const demoNodes: FlowNode[] = [ { id: "dave", type: "funnel", position: { x: 1040, y: 1250 }, data: { - label: "Dave — Design", currentValue: 0, desiredOutflow: 1000, - minThreshold: 1000, sufficientThreshold: 4000, maxThreshold: 6000, - maxCapacity: 9000, inflowRate: 0, + label: "Dave — Design", currentValue: 0, drainRate: 1000, + overflowThreshold: 6000, capacity: 9000, inflowRate: 0, overflowAllocations: [ { targetId: "operations", percentage: 100, color: OVERFLOW_COLORS[1] }, ], @@ -173,9 +165,8 @@ export const demoNodes: FlowNode[] = [ { id: "eve", type: "funnel", position: { x: 1420, y: 1250 }, data: { - label: "Eve — Governance", currentValue: 0, desiredOutflow: 900, - minThreshold: 900, sufficientThreshold: 3600, maxThreshold: 5400, - maxCapacity: 8100, inflowRate: 0, + label: "Eve — Governance", currentValue: 0, drainRate: 900, + overflowThreshold: 5400, capacity: 8100, inflowRate: 0, overflowAllocations: [ { targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[2] }, ], @@ -405,9 +396,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "treasury", type: "funnel", position: { x: 500, y: 0 }, data: { - label: "Treasury", currentValue: 0, desiredOutflow: 4000, - minThreshold: 4000, sufficientThreshold: 16000, maxThreshold: 24000, - maxCapacity: 36000, inflowRate: 16000, dynamicOverflow: true, + label: "Treasury", currentValue: 0, drainRate: 4000, + overflowThreshold: 24000, capacity: 36000, inflowRate: 16000, overflowAllocations: [ { targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[0] }, ], @@ -425,9 +415,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "ops", type: "funnel", position: { x: 80, y: 600 }, data: { - label: "Operations", currentValue: 0, desiredOutflow: 1500, - minThreshold: 1500, sufficientThreshold: 6000, maxThreshold: 9000, - maxCapacity: 13500, inflowRate: 0, + label: "Operations", currentValue: 0, drainRate: 1500, + overflowThreshold: 9000, capacity: 13500, inflowRate: 0, overflowAllocations: [ { targetId: "community", percentage: 100, color: OVERFLOW_COLORS[0] }, ], @@ -440,9 +429,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "research", type: "funnel", position: { x: 500, y: 600 }, data: { - label: "Research", currentValue: 0, desiredOutflow: 1400, - minThreshold: 1400, sufficientThreshold: 5600, maxThreshold: 8400, - maxCapacity: 12600, inflowRate: 0, + label: "Research", currentValue: 0, drainRate: 1400, + overflowThreshold: 8400, capacity: 12600, inflowRate: 0, overflowAllocations: [ { targetId: "community", percentage: 100, color: OVERFLOW_COLORS[1] }, ], @@ -455,9 +443,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "community", type: "funnel", position: { x: 920, y: 600 }, data: { - label: "Community", currentValue: 0, desiredOutflow: 1000, - minThreshold: 1000, sufficientThreshold: 4000, maxThreshold: 6000, - maxCapacity: 9000, inflowRate: 0, + label: "Community", currentValue: 0, drainRate: 1000, + overflowThreshold: 6000, capacity: 9000, inflowRate: 0, overflowAllocations: [ { targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[2] }, ], @@ -470,9 +457,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "reserve", type: "funnel", position: { x: 1340, y: 600 }, data: { - label: "Reserve Fund", currentValue: 0, desiredOutflow: 500, - minThreshold: 500, sufficientThreshold: 5000, maxThreshold: 10000, - maxCapacity: 20000, inflowRate: 0, + label: "Reserve Fund", currentValue: 0, drainRate: 500, + overflowThreshold: 10000, capacity: 20000, inflowRate: 0, overflowAllocations: [ { targetId: "treasury", percentage: 100, color: OVERFLOW_COLORS[3] }, ], @@ -488,9 +474,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "infra-team", type: "funnel", position: { x: -100, y: 1250 }, data: { - label: "Infra Team", currentValue: 0, desiredOutflow: 800, - minThreshold: 800, sufficientThreshold: 3200, maxThreshold: 4800, - maxCapacity: 7200, inflowRate: 0, + label: "Infra Team", currentValue: 0, drainRate: 800, + overflowThreshold: 4800, capacity: 7200, inflowRate: 0, overflowAllocations: [ { targetId: "admin-team", percentage: 100, color: OVERFLOW_COLORS[4] }, ], @@ -503,9 +488,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "admin-team", type: "funnel", position: { x: 280, y: 1250 }, data: { - label: "Admin Team", currentValue: 0, desiredOutflow: 700, - minThreshold: 700, sufficientThreshold: 2800, maxThreshold: 4200, - maxCapacity: 6300, inflowRate: 0, + label: "Admin Team", currentValue: 0, drainRate: 700, + overflowThreshold: 4200, capacity: 6300, inflowRate: 0, overflowAllocations: [ { targetId: "ops", percentage: 100, color: OVERFLOW_COLORS[5] }, ], @@ -518,9 +502,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "science-team", type: "funnel", position: { x: 660, y: 1250 }, data: { - label: "Science Team", currentValue: 0, desiredOutflow: 900, - minThreshold: 900, sufficientThreshold: 3600, maxThreshold: 5400, - maxCapacity: 8100, inflowRate: 0, + label: "Science Team", currentValue: 0, drainRate: 900, + overflowThreshold: 5400, capacity: 8100, inflowRate: 0, overflowAllocations: [ { targetId: "tools-team", percentage: 100, color: OVERFLOW_COLORS[0] }, ], @@ -533,9 +516,8 @@ export const simDemoNodes: FlowNode[] = [ { id: "tools-team", type: "funnel", position: { x: 1040, y: 1250 }, data: { - label: "Tools Team", currentValue: 0, desiredOutflow: 600, - minThreshold: 600, sufficientThreshold: 2400, maxThreshold: 3600, - maxCapacity: 5400, inflowRate: 0, + label: "Tools Team", currentValue: 0, drainRate: 600, + overflowThreshold: 3600, capacity: 5400, inflowRate: 0, overflowAllocations: [ { targetId: "research", percentage: 100, color: OVERFLOW_COLORS[1] }, ], diff --git a/modules/rflows/lib/simulation.ts b/modules/rflows/lib/simulation.ts index d8e400f..bad8298 100644 --- a/modules/rflows/lib/simulation.ts +++ b/modules/rflows/lib/simulation.ts @@ -1,68 +1,20 @@ /** * Flow simulation engine — pure function, no framework dependencies. - * Ported from rflows-online/lib/simulation.ts. + * Enforces strict conservation: every dollar is accounted for (in = out). */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "./types"; -import { deriveThresholds } from "./types"; export interface SimulationConfig { tickDivisor: number; - spendingRateHealthy: number; - spendingRateOverflow: number; - spendingRateCritical: number; } export const DEFAULT_CONFIG: SimulationConfig = { tickDivisor: 10, - spendingRateHealthy: 0.5, - spendingRateOverflow: 0.8, - spendingRateCritical: 0.1, }; export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState { - const threshold = data.sufficientThreshold ?? data.maxThreshold; - if (data.currentValue >= data.maxCapacity) return "abundant"; - if (data.currentValue >= threshold) return "sufficient"; - return "seeking"; -} - -export function computeNeedWeights( - targetIds: string[], - allNodes: FlowNode[], -): Map { - const nodeMap = new Map(allNodes.map((n) => [n.id, n])); - const needs = new Map(); - - for (const tid of targetIds) { - const node = nodeMap.get(tid); - if (!node) { needs.set(tid, 0); continue; } - - if (node.type === "funnel") { - const d = node.data as FunnelNodeData; - const threshold = d.sufficientThreshold ?? d.maxThreshold; - const need = Math.max(0, 1 - d.currentValue / (threshold || 1)); - needs.set(tid, need); - } else if (node.type === "outcome") { - const d = node.data as OutcomeNodeData; - const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1)); - needs.set(tid, need); - } else { - needs.set(tid, 0); - } - } - - const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0); - const weights = new Map(); - if (totalNeed === 0) { - const equal = targetIds.length > 0 ? 100 / targetIds.length : 0; - targetIds.forEach((id) => weights.set(id, equal)); - } else { - needs.forEach((need, id) => { - weights.set(id, (need / totalNeed) * 100); - }); - } - return weights; + return data.currentValue >= data.overflowThreshold ? "overflowing" : "seeking"; } export function computeSystemSufficiency(nodes: FlowNode[]): number { @@ -72,8 +24,7 @@ export function computeSystemSufficiency(nodes: FlowNode[]): number { for (const node of nodes) { if (node.type === "funnel") { const d = node.data as FunnelNodeData; - const threshold = d.sufficientThreshold ?? d.maxThreshold; - sum += Math.min(1, d.currentValue / (threshold || 1)); + sum += Math.min(1, d.currentValue / (d.overflowThreshold || 1)); count++; } else if (node.type === "outcome") { const d = node.data as OutcomeNodeData; @@ -110,11 +61,20 @@ export function computeInflowRates(nodes: FlowNode[]): FlowNode[] { }); } +/** + * Conservation-enforcing tick: for each funnel (Y-order), compute: + * 1. inflow = inflowRate / tickDivisor + overflow from upstream + * 2. drain = min(drainRate / tickDivisor, currentValue + inflow) + * 3. newValue = currentValue + inflow - drain + * 4. if newValue > overflowThreshold → route excess to overflow targets + * 5. distribute drain to spending targets + * 6. clamp to [0, capacity] + */ export function simulateTick( nodes: FlowNode[], config: SimulationConfig = DEFAULT_CONFIG, ): FlowNode[] { - const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config; + const { tickDivisor } = config; const funnelNodes = nodes .filter((n) => n.type === "funnel") @@ -128,65 +88,38 @@ export function simulateTick( const src = node.data as FunnelNodeData; const data: FunnelNodeData = { ...src }; - // Auto-derive thresholds from desiredOutflow when present - if (data.desiredOutflow) { - const derived = deriveThresholds(data.desiredOutflow); - data.minThreshold = derived.minThreshold; - data.sufficientThreshold = derived.sufficientThreshold; - data.maxThreshold = derived.maxThreshold; - data.maxCapacity = derived.maxCapacity; + // 1. Inflow: source rate + overflow received from upstream this tick + const inflow = data.inflowRate / tickDivisor + (overflowIncoming.get(node.id) ?? 0); + let value = data.currentValue + inflow; + + // 2. Drain: flat rate capped by available funds + const drain = Math.min(data.drainRate / tickDivisor, value); + value -= drain; + + // 3. Overflow: route excess above threshold to downstream + if (value > data.overflowThreshold && data.overflowAllocations.length > 0) { + const excess = value - data.overflowThreshold; + for (const alloc of data.overflowAllocations) { + const share = excess * (alloc.percentage / 100); + overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); + } + value = data.overflowThreshold; } - let value = data.currentValue + data.inflowRate / tickDivisor; - value += overflowIncoming.get(node.id) ?? 0; - value = Math.min(value, data.maxCapacity); - - if (value > data.maxThreshold && data.overflowAllocations.length > 0) { - const excess = value - data.maxThreshold; - - if (data.dynamicOverflow) { - const targetIds = data.overflowAllocations.map((a) => a.targetId); - const needWeights = computeNeedWeights(targetIds, nodes); - for (const alloc of data.overflowAllocations) { - const weight = needWeights.get(alloc.targetId) ?? 0; - const share = excess * (weight / 100); - overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); - } - } else { - for (const alloc of data.overflowAllocations) { - const share = excess * (alloc.percentage / 100); - overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); - } - } - value = data.maxThreshold; - } - - if (value > 0 && data.spendingAllocations.length > 0) { - let rateMultiplier: number; - if (value > data.maxThreshold) { - rateMultiplier = spendingRateOverflow; - } else if (value >= data.minThreshold) { - rateMultiplier = spendingRateHealthy; - } else { - rateMultiplier = spendingRateCritical; - } - - const baseRate = data.desiredOutflow || data.inflowRate; - let drain = (baseRate / tickDivisor) * rateMultiplier; - drain = Math.min(drain, value); - value -= drain; - + // 4. Distribute drain to spending targets + if (drain > 0 && data.spendingAllocations.length > 0) { for (const alloc of data.spendingAllocations) { const share = drain * (alloc.percentage / 100); spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share); } } - data.currentValue = Math.max(0, value); + // 5. Clamp + data.currentValue = Math.max(0, Math.min(value, data.capacity)); updatedFunnels.set(node.id, data); } - // Process outcomes in Y-order (like funnels) so overflow can cascade + // Process outcomes in Y-order so overflow can cascade const outcomeNodes = nodes .filter((n) => n.type === "outcome") .sort((a, b) => a.position.y - b.position.y); diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 3739ffb..94de185 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -37,30 +37,33 @@ export interface SourceAllocation { waypoint?: { x: number; y: number }; } -export type SufficiencyState = "seeking" | "sufficient" | "abundant"; +export type SufficiencyState = "seeking" | "overflowing"; export interface FunnelNodeData { label: string; - currentValue: number; - minThreshold: number; - maxThreshold: number; - maxCapacity: number; - inflowRate: number; - desiredOutflow?: number; - sufficientThreshold?: number; - dynamicOverflow?: boolean; + currentValue: number; // runtime accumulator, not user-editable + overflowThreshold: number; // triggers overflow routing + capacity: number; // visual max / clamp + inflowRate: number; // computed from upstream + drainRate: number; // flat drain $/mo overflowAllocations: OverflowAllocation[]; spendingAllocations: SpendingAllocation[]; source?: IntegrationSource; [key: string]: unknown; } -export function deriveThresholds(desiredOutflow: number) { +/** Migrate legacy FunnelNodeData (4-tier thresholds) to new 2-tier format. */ +export function migrateFunnelNodeData(d: any): FunnelNodeData { return { - minThreshold: desiredOutflow * 1, // 1 month runway - sufficientThreshold: desiredOutflow * 4, // 4 months runway (1 min + 3 buffer) - maxThreshold: desiredOutflow * 6, // overflow point - maxCapacity: desiredOutflow * 9, // visual max + label: d.label ?? "Funnel", + currentValue: d.currentValue ?? 0, + overflowThreshold: d.overflowThreshold ?? d.maxThreshold ?? 0, + capacity: d.capacity ?? d.maxCapacity ?? 0, + inflowRate: d.inflowRate ?? 0, + drainRate: d.drainRate ?? d.desiredOutflow ?? d.inflowRate ?? 0, + overflowAllocations: d.overflowAllocations ?? [], + spendingAllocations: d.spendingAllocations ?? [], + source: d.source, }; } diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 45689e1..5a1684c 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -785,8 +785,8 @@ routes.post("/api/budgets/segments", async (c) => { const flowsScripts = ` - - `; + + `; const flowsStyles = ``;