feat: add dynamic enoughness layer to Budget River
Funnels can now declare a sufficientThreshold and dynamicOverflow. When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight instead of fixed percentages. Visual layer adds golden glow on sufficient funnels, sufficiency progress bar, ENOUGH status pill, and a system-wide Enoughness Score badge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
15b9ba62a6
commit
4264ac9be2
|
|
@ -7,6 +7,7 @@ import { demoNodes } from '@/lib/presets'
|
|||
import { detectSafeChains, getBalances, computeTransferSummary } from '@/lib/api/safe-client'
|
||||
import { safeBalancesToFunnels } from '@/lib/integrations'
|
||||
import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client'
|
||||
import { computeSystemSufficiency } from '@/lib/simulation'
|
||||
import type { FlowNode, FunnelNodeData } from '@/lib/types'
|
||||
import type { BackendFlow } from '@/lib/api/flows-client'
|
||||
|
||||
|
|
@ -22,6 +23,19 @@ const BudgetRiver = dynamic(() => import('@/components/BudgetRiver'), {
|
|||
),
|
||||
})
|
||||
|
||||
function EnoughnessPill({ nodes }: { nodes: FlowNode[] }) {
|
||||
const score = Math.round(computeSystemSufficiency(nodes) * 100)
|
||||
const color = score >= 90 ? 'bg-amber-500/30 text-amber-300' :
|
||||
score >= 60 ? 'bg-emerald-600/30 text-emerald-300' :
|
||||
score >= 30 ? 'bg-amber-600/30 text-amber-300' :
|
||||
'bg-red-600/30 text-red-300'
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${color}`}>
|
||||
Enoughness: {score}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RiverPage() {
|
||||
const [mode, setMode] = useState<'demo' | 'live'>('demo')
|
||||
const [nodes, setNodes] = useState<FlowNode[]>(demoNodes)
|
||||
|
|
@ -209,6 +223,8 @@ export default function RiverPage() {
|
|||
</Link>
|
||||
<span className="text-slate-500">|</span>
|
||||
<span className="text-cyan-300 font-medium">Budget River</span>
|
||||
<span className="text-slate-500">|</span>
|
||||
<EnoughnessPill nodes={nodes} />
|
||||
{mode === 'live' && connectedChains.length > 0 && (
|
||||
<>
|
||||
<span className="text-slate-500">|</span>
|
||||
|
|
@ -347,6 +363,10 @@ export default function RiverPage() {
|
|||
<div className="w-3 h-3 rounded-md border border-blue-500 bg-blue-500/30" />
|
||||
<span className="text-slate-400">Outcome pool</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-1.5 rounded bg-amber-400" style={{ boxShadow: '0 0 6px #fbbf24' }} />
|
||||
<span className="text-slate-400">Sufficient (golden)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<g>
|
||||
<defs>
|
||||
|
|
@ -666,6 +684,20 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
|||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Golden glow for sufficient/abundant funnels */}
|
||||
{isSufficientOrAbundant && (
|
||||
<rect
|
||||
x={x - 8}
|
||||
y={y - 8}
|
||||
width={segmentLength + 16}
|
||||
height={riverWidth + 16}
|
||||
rx={riverWidth / 2 + 8}
|
||||
fill={COLORS.goldenGlow}
|
||||
opacity={0.15}
|
||||
style={{ animation: 'shimmer 2s ease-in-out infinite' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* River glow */}
|
||||
<rect
|
||||
x={x - 4}
|
||||
|
|
@ -677,6 +709,25 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
|||
opacity={0.1}
|
||||
/>
|
||||
|
||||
{/* Sufficiency ring: thin progress bar above the river */}
|
||||
<rect
|
||||
x={x}
|
||||
y={y - 3}
|
||||
width={segmentLength}
|
||||
height={2}
|
||||
rx={1}
|
||||
fill="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
<rect
|
||||
x={x}
|
||||
y={y - 3}
|
||||
width={segmentLength * sufficiencyFill}
|
||||
height={2}
|
||||
rx={1}
|
||||
fill={sufficiencyFill >= 1 ? COLORS.goldenGlow : '#06b6d4'}
|
||||
opacity={0.8}
|
||||
/>
|
||||
|
||||
{/* River body */}
|
||||
<rect
|
||||
x={x}
|
||||
|
|
@ -685,6 +736,7 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
|||
height={riverWidth}
|
||||
rx={riverWidth / 2}
|
||||
fill={`url(#river-grad-${id})`}
|
||||
style={isSufficientOrAbundant ? { filter: `drop-shadow(0 0 8px ${COLORS.goldenGlow})` } : undefined}
|
||||
/>
|
||||
|
||||
{/* Surface current lines */}
|
||||
|
|
@ -739,10 +791,24 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
|||
/>
|
||||
<text x={x + segmentLength + 6} y={thresholdMinY + 3} fontSize={7} fill="#ef4444" fontWeight="bold">MIN</text>
|
||||
|
||||
{/* Dynamic overflow indicator */}
|
||||
{data.dynamicOverflow && (
|
||||
<text
|
||||
x={x + segmentLength + 6}
|
||||
y={y + riverWidth / 2 + 3}
|
||||
fontSize={7}
|
||||
fill={COLORS.goldenGlow}
|
||||
fontWeight="bold"
|
||||
opacity={0.7}
|
||||
>
|
||||
DYN
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={x + segmentLength / 2}
|
||||
y={y - 10}
|
||||
y={y - 14}
|
||||
textAnchor="middle"
|
||||
fill={COLORS.text}
|
||||
fontSize={12}
|
||||
|
|
@ -766,23 +832,23 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
|||
|
||||
{/* Status pill */}
|
||||
<rect
|
||||
x={x + segmentLength / 2 - 16}
|
||||
x={x + segmentLength / 2 - pillWidth / 2}
|
||||
y={y + riverWidth + 6}
|
||||
width={32}
|
||||
width={pillWidth}
|
||||
height={14}
|
||||
rx={7}
|
||||
fill={status === 'overflow' ? '#f59e0b' : status === 'critical' ? '#ef4444' : '#10b981'}
|
||||
fill={pillColor}
|
||||
opacity={0.9}
|
||||
/>
|
||||
<text
|
||||
x={x + segmentLength / 2}
|
||||
y={y + riverWidth + 16}
|
||||
textAnchor="middle"
|
||||
fill="white"
|
||||
fill={isSufficientOrAbundant ? '#1a1a2e' : 'white'}
|
||||
fontSize={7}
|
||||
fontWeight={700}
|
||||
>
|
||||
{status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'}
|
||||
{pillText}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
|
|
@ -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 (
|
||||
<div className="w-full h-full overflow-auto">
|
||||
|
|
@ -1081,6 +1152,33 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
|
|||
<OutcomePool key={o.id} outcome={o} />
|
||||
))}
|
||||
|
||||
{/* System Enoughness Badge (top-right) */}
|
||||
<g transform={`translate(${layout.width - 80}, 20)`}>
|
||||
{/* Background circle */}
|
||||
<circle cx={30} cy={30} r={28} fill="rgba(15,23,42,0.85)" stroke="rgba(255,255,255,0.1)" strokeWidth={1} />
|
||||
{/* Track ring */}
|
||||
<circle cx={30} cy={30} r={22} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={4} />
|
||||
{/* Progress ring */}
|
||||
<circle
|
||||
cx={30} cy={30} r={22}
|
||||
fill="none"
|
||||
stroke={sufficiencyColor}
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${sufficiencyPct * 1.382} ${138.2 - sufficiencyPct * 1.382}`}
|
||||
strokeDashoffset={34.56}
|
||||
style={{ transition: 'stroke-dasharray 0.5s ease, stroke 0.5s ease' }}
|
||||
/>
|
||||
{/* Percentage text */}
|
||||
<text x={30} y={28} textAnchor="middle" fill={sufficiencyColor} fontSize={12} fontWeight={700} fontFamily="monospace">
|
||||
{sufficiencyPct}%
|
||||
</text>
|
||||
{/* Label */}
|
||||
<text x={30} y={40} textAnchor="middle" fill={COLORS.textMuted} fontSize={6} fontWeight={600} letterSpacing={0.5}>
|
||||
ENOUGHNESS
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Title watermark */}
|
||||
<text
|
||||
x={layout.width / 2}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export const demoNodes: FlowNode[] = [
|
|||
maxThreshold: 70000,
|
||||
maxCapacity: 100000,
|
||||
inflowRate: 1000,
|
||||
sufficientThreshold: 60000,
|
||||
dynamicOverflow: true,
|
||||
overflowAllocations: [
|
||||
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
|
||||
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
|
||||
|
|
@ -55,6 +57,7 @@ export const demoNodes: FlowNode[] = [
|
|||
maxThreshold: 50000,
|
||||
maxCapacity: 70000,
|
||||
inflowRate: 400,
|
||||
sufficientThreshold: 42000,
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
|
||||
|
|
@ -74,6 +77,7 @@ export const demoNodes: FlowNode[] = [
|
|||
maxThreshold: 45000,
|
||||
maxCapacity: 60000,
|
||||
inflowRate: 350,
|
||||
sufficientThreshold: 38000,
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
|
||||
|
|
@ -92,6 +96,7 @@ export const demoNodes: FlowNode[] = [
|
|||
maxThreshold: 60000,
|
||||
maxCapacity: 80000,
|
||||
inflowRate: 250,
|
||||
sufficientThreshold: 50000,
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@
|
|||
*
|
||||
* Replaces the random-noise simulation with actual flow logic:
|
||||
* inflow → overflow distribution → spending drain → outcome accumulation
|
||||
*
|
||||
* Sufficiency layer: funnels can declare a sufficientThreshold and dynamicOverflow.
|
||||
* When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight.
|
||||
*/
|
||||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from './types'
|
||||
|
||||
export interface SimulationConfig {
|
||||
tickDivisor: number // inflowRate divided by this per tick
|
||||
|
|
@ -21,6 +24,85 @@ export const DEFAULT_CONFIG: SimulationConfig = {
|
|||
spendingRateCritical: 0.1,
|
||||
}
|
||||
|
||||
// ─── Sufficiency helpers ────────────────────────────────────
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, number> {
|
||||
const nodeMap = new Map(allNodes.map(n => [n.id, n]))
|
||||
const needs = new Map<string, number>()
|
||||
|
||||
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<string, number>()
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue