diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index 0238fbb..128addc 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -23,6 +23,7 @@ import StreamEdge from './edges/StreamEdge' import IntegrationPanel from './IntegrationPanel' import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types' import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets' +import { simulateTick } from '@/lib/simulation' const nodeTypes = { funnel: FunnelNode, @@ -523,41 +524,13 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra ]) }, [setNodes, screenToFlowPosition]) - // Simulation + // Simulation — real flow logic (inflow → overflow → spending → outcomes) useEffect(() => { if (!isSimulating) return const interval = setInterval(() => { - setNodes((nds) => - nds.map((node) => { - if (node.type === 'funnel') { - const data = node.data as FunnelNodeData - const change = (Math.random() - 0.45) * 300 - return { - ...node, - data: { - ...data, - currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)), - }, - } - } else if (node.type === 'outcome') { - const data = node.data as OutcomeNodeData - const change = Math.random() * 80 - const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change) - return { - ...node, - data: { - ...data, - fundingReceived: newReceived, - status: newReceived >= data.fundingTarget ? 'completed' : - data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status, - }, - } - } - return node - }) - ) - }, 500) + setNodes((nds) => simulateTick(nds as FlowNode[])) + }, 1000) return () => clearInterval(interval) }, [isSimulating, setNodes]) diff --git a/components/nodes/OutcomeNode.tsx b/components/nodes/OutcomeNode.tsx index fd25301..ace5cd6 100644 --- a/components/nodes/OutcomeNode.tsx +++ b/components/nodes/OutcomeNode.tsx @@ -45,11 +45,24 @@ function OutcomeNode({ data, selected }: NodeProps) {
{label} - {nodeData.source?.type === 'rvote' && ( + {nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId ? ( + e.stopPropagation()} + > + rVote • score +{nodeData.source.rvoteProposalScore ?? 0} + + + + + ) : nodeData.source?.type === 'rvote' ? ( rVote • score +{nodeData.source.rvoteProposalScore ?? 0} - )} + ) : null}
diff --git a/lib/simulation.ts b/lib/simulation.ts new file mode 100644 index 0000000..499ee06 --- /dev/null +++ b/lib/simulation.ts @@ -0,0 +1,128 @@ +/** + * Flow simulation engine — pure function, no React dependencies. + * + * Replaces the random-noise simulation with actual flow logic: + * inflow → overflow distribution → spending drain → outcome accumulation + */ + +import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types' + +export interface SimulationConfig { + tickDivisor: number // inflowRate divided by this per tick + spendingRateHealthy: number // drain multiplier in healthy zone + spendingRateOverflow: number // drain multiplier above maxThreshold + spendingRateCritical: number // drain multiplier below minThreshold +} + +export const DEFAULT_CONFIG: SimulationConfig = { + tickDivisor: 10, + spendingRateHealthy: 0.5, + spendingRateOverflow: 0.8, + spendingRateCritical: 0.1, +} + +export function simulateTick( + nodes: FlowNode[], + config: SimulationConfig = DEFAULT_CONFIG, +): FlowNode[] { + const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config + + // Separate funnels (sorted top-to-bottom) from outcomes + const funnelNodes = nodes + .filter((n) => n.type === 'funnel') + .sort((a, b) => a.position.y - b.position.y) + + // Accumulators for inter-node transfers + const overflowIncoming = new Map() + const spendingIncoming = new Map() + + // Store updated funnel data + const updatedFunnels = new Map() + + for (const node of funnelNodes) { + const src = node.data as FunnelNodeData + const data: FunnelNodeData = { ...src } + + // 1. Natural inflow + let value = data.currentValue + data.inflowRate / tickDivisor + + // 2. Overflow received from upstream funnels + value += overflowIncoming.get(node.id) ?? 0 + + // 3. Cap at maxCapacity + value = Math.min(value, data.maxCapacity) + + // 4. Distribute overflow when above maxThreshold + if (value > data.maxThreshold && data.overflowAllocations.length > 0) { + const excess = value - data.maxThreshold + 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 + } + + // 5. Spending drain (gated by zone) + 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 + } + + let drain = (data.inflowRate / tickDivisor) * rateMultiplier + drain = Math.min(drain, value) // cannot drain below 0 + + value -= drain + + 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) + updatedFunnels.set(node.id, data) + } + + // Rebuild node array with updated data + return nodes.map((node) => { + if (node.type === 'funnel' && updatedFunnels.has(node.id)) { + return { ...node, data: updatedFunnels.get(node.id)! } + } + + if (node.type === 'outcome') { + const data = node.data as OutcomeNodeData + const incoming = spendingIncoming.get(node.id) ?? 0 + if (incoming <= 0) return node + + const newReceived = Math.min( + data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity, + data.fundingReceived + incoming, + ) + + let newStatus = data.status + if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== 'blocked') { + newStatus = 'completed' + } else if (newReceived > 0 && newStatus === 'not-started') { + newStatus = 'in-progress' + } + + return { + ...node, + data: { ...data, fundingReceived: newReceived, status: newStatus }, + } + } + + return node + }) +}