feat: add real-time flow simulation engine and clickable rVote proposal links
Replace random-noise simulation with actual flow logic: inflow accumulation, threshold-gated overflow distribution to downstream funnels, spending drain to outcomes, and auto status transitions. Outcomes linked to rVote proposals are now clickable links opening the live proposal on rvote.online. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
216df8c06a
commit
42942cc962
|
|
@ -23,6 +23,7 @@ import StreamEdge from './edges/StreamEdge'
|
||||||
import IntegrationPanel from './IntegrationPanel'
|
import IntegrationPanel from './IntegrationPanel'
|
||||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
||||||
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
||||||
|
import { simulateTick } from '@/lib/simulation'
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
funnel: FunnelNode,
|
funnel: FunnelNode,
|
||||||
|
|
@ -523,41 +524,13 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
])
|
])
|
||||||
}, [setNodes, screenToFlowPosition])
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
// Simulation
|
// Simulation — real flow logic (inflow → overflow → spending → outcomes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSimulating) return
|
if (!isSimulating) return
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setNodes((nds) =>
|
setNodes((nds) => simulateTick(nds as FlowNode[]))
|
||||||
nds.map((node) => {
|
}, 1000)
|
||||||
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)
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [isSimulating, setNodes])
|
}, [isSimulating, setNodes])
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,24 @@ function OutcomeNode({ data, selected }: NodeProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
|
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
|
||||||
{nodeData.source?.type === 'rvote' && (
|
{nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId ? (
|
||||||
|
<a
|
||||||
|
href={`https://rvote.online/s/${nodeData.source.rvoteSpaceSlug}/proposal/${nodeData.source.rvoteProposalId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[9px] text-violet-500 font-medium hover:text-violet-700 hover:underline inline-flex items-center gap-0.5 transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
rVote • score +{nodeData.source.rvoteProposalScore ?? 0}
|
||||||
|
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
) : nodeData.source?.type === 'rvote' ? (
|
||||||
<span className="text-[9px] text-violet-500 font-medium">
|
<span className="text-[9px] text-violet-500 font-medium">
|
||||||
rVote • score +{nodeData.source.rvoteProposalScore ?? 0}
|
rVote • score +{nodeData.source.rvoteProposalScore ?? 0}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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<string, number>()
|
||||||
|
const spendingIncoming = new Map<string, number>()
|
||||||
|
|
||||||
|
// Store updated funnel data
|
||||||
|
const updatedFunnels = new Map<string, FunnelNodeData>()
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue