(demoNodes)
@@ -209,6 +223,8 @@ export default function RiverPage() {
|
Budget River
+ |
+
{mode === 'live' && connectedChains.length > 0 && (
<>
|
@@ -347,6 +363,10 @@ export default function RiverPage() {
Outcome pool
+
+
+
Sufficient (golden)
+
diff --git a/components/BudgetRiver.tsx b/components/BudgetRiver.tsx
index 095e611..51c6505 100644
--- a/components/BudgetRiver.tsx
+++ b/components/BudgetRiver.tsx
@@ -1,7 +1,8 @@
'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
-import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from '@/lib/types'
+import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from '@/lib/types'
+import { computeSufficiencyState, computeSystemSufficiency } from '@/lib/simulation'
// ─── Layout Types ────────────────────────────────────────
@@ -35,6 +36,7 @@ interface FunnelLayout {
segmentLength: number
layer: number
status: 'healthy' | 'overflow' | 'critical'
+ sufficiency: SufficiencyState
}
interface OutcomeLayout {
@@ -95,9 +97,11 @@ const COLORS = {
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',
bg: '#0f172a',
text: '#e2e8f0',
textMuted: '#94a3b8',
@@ -216,6 +220,7 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
segmentLength: SEGMENT_LENGTH,
layer,
status,
+ sufficiency: computeSufficiencyState(data),
})
})
}
@@ -648,13 +653,26 @@ function SankeyWaterfall({ wf }: { wf: WaterfallLayout }) {
}
function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
- const { id, label, data, x, y, riverWidth, segmentLength, status } = funnel
- const gradColors = status === 'overflow' ? COLORS.riverOverflow :
+ const { id, label, data, x, y, riverWidth, segmentLength, status, sufficiency } = funnel
+ const isSufficientOrAbundant = sufficiency === 'sufficient' || sufficiency === 'abundant'
+ const gradColors = isSufficientOrAbundant ? COLORS.riverSufficient :
+ status === 'overflow' ? COLORS.riverOverflow :
status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy
const thresholdMinY = y + riverWidth * 0.85
const thresholdMaxY = y + riverWidth * 0.15
+ // Sufficiency ring: arc fill from 0 to sufficientThreshold
+ const sufficientThreshold = data.sufficientThreshold ?? data.maxThreshold
+ const sufficiencyFill = Math.min(1, data.currentValue / (sufficientThreshold || 1))
+
+ // Status pill text/color
+ const pillText = isSufficientOrAbundant ? 'ENOUGH' :
+ status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'
+ const pillColor = isSufficientOrAbundant ? COLORS.goldenGlow :
+ status === 'overflow' ? '#f59e0b' : status === 'critical' ? '#ef4444' : '#10b981'
+ const pillWidth = isSufficientOrAbundant ? 42 : 32
+
return (
@@ -666,6 +684,20 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
+ {/* Golden glow for sufficient/abundant funnels */}
+ {isSufficientOrAbundant && (
+
+ )}
+
{/* River glow */}
+ {/* Sufficiency ring: thin progress bar above the river */}
+
+ = 1 ? COLORS.goldenGlow : '#06b6d4'}
+ opacity={0.8}
+ />
+
{/* River body */}
{/* Surface current lines */}
@@ -739,10 +791,24 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
/>
MIN
+ {/* Dynamic overflow indicator */}
+ {data.dynamicOverflow && (
+
+ DYN
+
+ )}
+
{/* Label */}
- {status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'}
+ {pillText}
)
@@ -1023,6 +1089,11 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
}, [isSimulating])
const layout = useMemo(() => computeLayout(animatedNodes), [animatedNodes])
+ const systemSufficiency = useMemo(() => computeSystemSufficiency(animatedNodes), [animatedNodes])
+ const sufficiencyPct = Math.round(systemSufficiency * 100)
+ const sufficiencyColor = sufficiencyPct >= 90 ? '#fbbf24' :
+ sufficiencyPct >= 60 ? '#10b981' :
+ sufficiencyPct >= 30 ? '#f59e0b' : '#ef4444'
return (
@@ -1081,6 +1152,33 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
))}
+ {/* System Enoughness Badge (top-right) */}
+
+ {/* Background circle */}
+
+ {/* Track ring */}
+
+ {/* Progress ring */}
+
+ {/* Percentage text */}
+
+ {sufficiencyPct}%
+
+ {/* Label */}
+
+ ENOUGHNESS
+
+
+
{/* Title watermark */}
= data.maxCapacity) return 'abundant'
+ if (data.currentValue >= threshold) return 'sufficient'
+ return 'seeking'
+}
+
+/**
+ * Compute need-weights for a set of overflow target IDs.
+ * For funnels: need = max(0, 1 - currentValue / sufficientThreshold)
+ * For outcomes: need = max(0, 1 - fundingReceived / fundingTarget)
+ * Returns a Map of targetId → percentage (normalized to 100).
+ */
+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)
+ }
+ }
+
+ // Normalize to percentages summing to 100
+ const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0)
+ const weights = new Map()
+ if (totalNeed === 0) {
+ // Equal distribution when all targets are satisfied
+ 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
+}
+
+/**
+ * Compute system-wide sufficiency score (0-1).
+ * Averages fill ratios of all funnels and progress ratios of all outcomes.
+ */
+export function computeSystemSufficiency(nodes: FlowNode[]): number {
+ let sum = 0
+ let count = 0
+
+ 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))
+ count++
+ } else if (node.type === 'outcome') {
+ const d = node.data as OutcomeNodeData
+ sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1))
+ count++
+ }
+ }
+
+ return count > 0 ? sum / count : 0
+}
+
export function simulateTick(
nodes: FlowNode[],
config: SimulationConfig = DEFAULT_CONFIG,
@@ -55,12 +137,28 @@ export function simulateTick(
// 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,
- )
+
+ if (data.dynamicOverflow) {
+ // Dynamic overflow: route by need-weight instead of fixed percentages
+ 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 {
+ // Fixed-percentage overflow (existing behavior)
+ 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
}
diff --git a/lib/types.ts b/lib/types.ts
index 2d5843b..139e0e5 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -115,6 +115,8 @@ export interface SpendingAllocation {
color: string
}
+export type SufficiencyState = 'seeking' | 'sufficient' | 'abundant'
+
export interface FunnelNodeData {
label: string
currentValue: number
@@ -122,6 +124,9 @@ export interface FunnelNodeData {
maxThreshold: number
maxCapacity: number
inflowRate: number
+ // Sufficiency layer
+ sufficientThreshold?: number // level at which funnel has "enough" (defaults to maxThreshold)
+ dynamicOverflow?: boolean // when true, overflow routes by need instead of fixed %
// Overflow goes SIDEWAYS to other funnels
overflowAllocations: OverflowAllocation[]
// Spending goes DOWN to outcomes/outputs