Restructure flows: overflow sideways, spending downward
- Overflow allocations now flow SIDEWAYS to other funnels (amber) - Spending allocations flow DOWN to outcome nodes (blue/pink) - Added OutcomeNode component for deliverables/outputs - Double-click funnels to open edit modal for min/max thresholds - Separate pie chart for spending, bar chart for overflow - Particle animations show overflow flying sideways, spending dripping down Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
531eb7d373
commit
fb220642de
|
|
@ -16,16 +16,20 @@ import {
|
|||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
import FunnelNode from './nodes/FunnelNode'
|
||||
import type { FlowNode, FlowEdge, FunnelNodeData } from '@/lib/types'
|
||||
import OutcomeNode from './nodes/OutcomeNode'
|
||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
||||
|
||||
const nodeTypes = {
|
||||
funnel: FunnelNode,
|
||||
outcome: OutcomeNode,
|
||||
}
|
||||
|
||||
// Color palette for allocations
|
||||
const COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4']
|
||||
// Colors for allocations
|
||||
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||
|
||||
const initialNodes: FlowNode[] = [
|
||||
// Main Treasury Funnel
|
||||
{
|
||||
id: 'treasury',
|
||||
type: 'funnel',
|
||||
|
|
@ -37,17 +41,24 @@ const initialNodes: FlowNode[] = [
|
|||
maxThreshold: 70000,
|
||||
maxCapacity: 100000,
|
||||
inflowRate: 1000,
|
||||
outflowAllocations: [
|
||||
{ targetId: 'public-goods', percentage: 40, color: COLORS[0] },
|
||||
{ targetId: 'research', percentage: 35, color: COLORS[1] },
|
||||
{ targetId: 'emergency', percentage: 25, color: COLORS[2] },
|
||||
// Overflow goes SIDEWAYS to other funnels
|
||||
overflowAllocations: [
|
||||
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
|
||||
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
|
||||
{ targetId: 'emergency', percentage: 25, color: OVERFLOW_COLORS[2] },
|
||||
],
|
||||
},
|
||||
// Spending goes DOWN to outcomes
|
||||
spendingAllocations: [
|
||||
{ targetId: 'treasury-ops', percentage: 60, color: SPENDING_COLORS[0] },
|
||||
{ targetId: 'treasury-audit', percentage: 40, color: SPENDING_COLORS[1] },
|
||||
],
|
||||
} as FunnelNodeData,
|
||||
},
|
||||
// Sub-funnels (receive overflow from Treasury)
|
||||
{
|
||||
id: 'public-goods',
|
||||
type: 'funnel',
|
||||
position: { x: 100, y: 350 },
|
||||
position: { x: 50, y: 300 },
|
||||
data: {
|
||||
label: 'Public Goods',
|
||||
currentValue: 45000,
|
||||
|
|
@ -55,16 +66,18 @@ const initialNodes: FlowNode[] = [
|
|||
maxThreshold: 50000,
|
||||
maxCapacity: 70000,
|
||||
inflowRate: 400,
|
||||
outflowAllocations: [
|
||||
{ targetId: 'project-alpha', percentage: 60, color: COLORS[0] },
|
||||
{ targetId: 'project-beta', percentage: 40, color: COLORS[1] },
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
|
||||
{ targetId: 'pg-education', percentage: 30, color: SPENDING_COLORS[1] },
|
||||
{ targetId: 'pg-tooling', percentage: 20, color: SPENDING_COLORS[2] },
|
||||
],
|
||||
},
|
||||
} as FunnelNodeData,
|
||||
},
|
||||
{
|
||||
id: 'research',
|
||||
type: 'funnel',
|
||||
position: { x: 400, y: 350 },
|
||||
position: { x: 400, y: 300 },
|
||||
data: {
|
||||
label: 'Research',
|
||||
currentValue: 28000,
|
||||
|
|
@ -72,101 +85,148 @@ const initialNodes: FlowNode[] = [
|
|||
maxThreshold: 45000,
|
||||
maxCapacity: 60000,
|
||||
inflowRate: 350,
|
||||
outflowAllocations: [
|
||||
{ targetId: 'project-gamma', percentage: 70, color: COLORS[0] },
|
||||
{ targetId: 'project-beta', percentage: 30, color: COLORS[1] },
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
|
||||
{ targetId: 'research-papers', percentage: 30, color: SPENDING_COLORS[1] },
|
||||
],
|
||||
},
|
||||
} as FunnelNodeData,
|
||||
},
|
||||
{
|
||||
id: 'emergency',
|
||||
type: 'funnel',
|
||||
position: { x: 700, y: 350 },
|
||||
position: { x: 750, y: 300 },
|
||||
data: {
|
||||
label: 'Emergency',
|
||||
label: 'Emergency Fund',
|
||||
currentValue: 12000,
|
||||
minThreshold: 25000,
|
||||
maxThreshold: 60000,
|
||||
maxCapacity: 80000,
|
||||
inflowRate: 250,
|
||||
outflowAllocations: [
|
||||
{ targetId: 'reserve', percentage: 100, color: COLORS[0] },
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },
|
||||
],
|
||||
},
|
||||
} as FunnelNodeData,
|
||||
},
|
||||
// Outcome nodes (receive spending from funnels)
|
||||
{
|
||||
id: 'treasury-ops',
|
||||
type: 'outcome',
|
||||
position: { x: 350, y: 600 },
|
||||
data: {
|
||||
label: 'Treasury Operations',
|
||||
description: 'Day-to-day treasury management and reporting',
|
||||
fundingReceived: 15000,
|
||||
fundingTarget: 25000,
|
||||
status: 'in-progress',
|
||||
} as OutcomeNodeData,
|
||||
},
|
||||
{
|
||||
id: 'project-alpha',
|
||||
type: 'funnel',
|
||||
position: { x: 0, y: 700 },
|
||||
id: 'treasury-audit',
|
||||
type: 'outcome',
|
||||
position: { x: 550, y: 600 },
|
||||
data: {
|
||||
label: 'Project Alpha',
|
||||
currentValue: 18000,
|
||||
minThreshold: 10000,
|
||||
maxThreshold: 30000,
|
||||
maxCapacity: 40000,
|
||||
inflowRate: 240,
|
||||
outflowAllocations: [],
|
||||
},
|
||||
label: 'Annual Audit',
|
||||
description: 'Third-party financial audit and compliance',
|
||||
fundingReceived: 8000,
|
||||
fundingTarget: 15000,
|
||||
status: 'in-progress',
|
||||
} as OutcomeNodeData,
|
||||
},
|
||||
{
|
||||
id: 'project-beta',
|
||||
type: 'funnel',
|
||||
position: { x: 300, y: 700 },
|
||||
id: 'pg-infra',
|
||||
type: 'outcome',
|
||||
position: { x: -50, y: 600 },
|
||||
data: {
|
||||
label: 'Project Beta',
|
||||
currentValue: 22000,
|
||||
minThreshold: 15000,
|
||||
maxThreshold: 35000,
|
||||
maxCapacity: 45000,
|
||||
inflowRate: 265,
|
||||
outflowAllocations: [],
|
||||
},
|
||||
label: 'Infrastructure',
|
||||
description: 'Core infrastructure development and maintenance',
|
||||
fundingReceived: 22000,
|
||||
fundingTarget: 30000,
|
||||
status: 'in-progress',
|
||||
} as OutcomeNodeData,
|
||||
},
|
||||
{
|
||||
id: 'project-gamma',
|
||||
type: 'funnel',
|
||||
position: { x: 600, y: 700 },
|
||||
id: 'pg-education',
|
||||
type: 'outcome',
|
||||
position: { x: 100, y: 700 },
|
||||
data: {
|
||||
label: 'Project Gamma',
|
||||
currentValue: 8000,
|
||||
minThreshold: 12000,
|
||||
maxThreshold: 28000,
|
||||
maxCapacity: 35000,
|
||||
inflowRate: 245,
|
||||
outflowAllocations: [],
|
||||
},
|
||||
label: 'Education Programs',
|
||||
description: 'Developer education and onboarding materials',
|
||||
fundingReceived: 12000,
|
||||
fundingTarget: 20000,
|
||||
status: 'in-progress',
|
||||
} as OutcomeNodeData,
|
||||
},
|
||||
{
|
||||
id: 'reserve',
|
||||
type: 'funnel',
|
||||
position: { x: 900, y: 700 },
|
||||
id: 'pg-tooling',
|
||||
type: 'outcome',
|
||||
position: { x: 250, y: 600 },
|
||||
data: {
|
||||
label: 'Reserve',
|
||||
currentValue: 5000,
|
||||
minThreshold: 20000,
|
||||
maxThreshold: 50000,
|
||||
maxCapacity: 60000,
|
||||
inflowRate: 250,
|
||||
outflowAllocations: [],
|
||||
},
|
||||
label: 'Dev Tooling',
|
||||
description: 'Open-source developer tools and SDKs',
|
||||
fundingReceived: 5000,
|
||||
fundingTarget: 15000,
|
||||
status: 'not-started',
|
||||
} as OutcomeNodeData,
|
||||
},
|
||||
{
|
||||
id: 'research-grants',
|
||||
type: 'outcome',
|
||||
position: { x: 400, y: 600 },
|
||||
data: {
|
||||
label: 'Research Grants',
|
||||
description: 'Academic research grants for protocol improvements',
|
||||
fundingReceived: 18000,
|
||||
fundingTarget: 25000,
|
||||
status: 'in-progress',
|
||||
} as OutcomeNodeData,
|
||||
},
|
||||
{
|
||||
id: 'research-papers',
|
||||
type: 'outcome',
|
||||
position: { x: 500, y: 700 },
|
||||
data: {
|
||||
label: 'Published Papers',
|
||||
description: 'Peer-reviewed research publications',
|
||||
fundingReceived: 8000,
|
||||
fundingTarget: 10000,
|
||||
status: 'in-progress',
|
||||
} as OutcomeNodeData,
|
||||
},
|
||||
{
|
||||
id: 'emergency-response',
|
||||
type: 'outcome',
|
||||
position: { x: 750, y: 600 },
|
||||
data: {
|
||||
label: 'Emergency Response',
|
||||
description: 'Rapid response fund for critical issues',
|
||||
fundingReceived: 5000,
|
||||
fundingTarget: 50000,
|
||||
status: 'not-started',
|
||||
} as OutcomeNodeData,
|
||||
},
|
||||
]
|
||||
|
||||
// Generate edges from node allocations with proportional thickness
|
||||
// Generate edges from node allocations
|
||||
function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||
const edges: FlowEdge[] = []
|
||||
const maxAllocation = 100 // Max percentage for scaling
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.type !== 'funnel') return
|
||||
const data = node.data as FunnelNodeData
|
||||
data.outflowAllocations.forEach((alloc) => {
|
||||
// Calculate stroke width: min 2px, max 12px based on percentage
|
||||
const strokeWidth = 2 + (alloc.percentage / maxAllocation) * 10
|
||||
|
||||
// OVERFLOW edges - go SIDEWAYS to other funnels
|
||||
data.overflowAllocations?.forEach((alloc, idx) => {
|
||||
const strokeWidth = 2 + (alloc.percentage / 100) * 8
|
||||
const isLeftSide = idx % 2 === 0
|
||||
|
||||
edges.push({
|
||||
id: `e-${node.id}-${alloc.targetId}`,
|
||||
id: `overflow-${node.id}-${alloc.targetId}`,
|
||||
source: node.id,
|
||||
target: alloc.targetId,
|
||||
sourceHandle: isLeftSide ? 'overflow-left' : 'overflow-right',
|
||||
targetHandle: isLeftSide ? 'inflow-right' : 'inflow-left',
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: alloc.color,
|
||||
|
|
@ -176,12 +236,42 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
|||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: alloc.color,
|
||||
width: 15 + alloc.percentage / 10,
|
||||
height: 15 + alloc.percentage / 10,
|
||||
width: 12 + alloc.percentage / 10,
|
||||
height: 12 + alloc.percentage / 10,
|
||||
},
|
||||
data: {
|
||||
allocation: alloc.percentage,
|
||||
color: alloc.color,
|
||||
edgeType: 'overflow' as const,
|
||||
},
|
||||
type: 'smoothstep',
|
||||
})
|
||||
})
|
||||
|
||||
// SPENDING edges - go DOWN to outcomes
|
||||
data.spendingAllocations?.forEach((alloc) => {
|
||||
const strokeWidth = 2 + (alloc.percentage / 100) * 8
|
||||
|
||||
edges.push({
|
||||
id: `spending-${node.id}-${alloc.targetId}`,
|
||||
source: node.id,
|
||||
target: alloc.targetId,
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: alloc.color,
|
||||
strokeWidth,
|
||||
opacity: 0.9,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: alloc.color,
|
||||
width: 12 + alloc.percentage / 10,
|
||||
height: 12 + alloc.percentage / 10,
|
||||
},
|
||||
data: {
|
||||
allocation: alloc.percentage,
|
||||
color: alloc.color,
|
||||
edgeType: 'spending' as const,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -211,23 +301,37 @@ export default function FlowCanvas() {
|
|||
[setEdges]
|
||||
)
|
||||
|
||||
// Simulation effect
|
||||
// Simulation effect - update funnel values and outcome funding
|
||||
useEffect(() => {
|
||||
if (!isSimulating) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) => {
|
||||
const data = node.data as FunnelNodeData
|
||||
// Random walk for demo
|
||||
const change = (Math.random() - 0.45) * 300
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)),
|
||||
},
|
||||
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() * 100
|
||||
const newReceived = Math.min(data.fundingTarget * 1.1, 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)
|
||||
|
|
@ -235,7 +339,7 @@ export default function FlowCanvas() {
|
|||
return () => clearInterval(interval)
|
||||
}, [isSimulating, setNodes])
|
||||
|
||||
// Regenerate edges when nodes change (to update proportions if needed)
|
||||
// Regenerate edges when nodes change
|
||||
useEffect(() => {
|
||||
setEdges(generateEdges(nodes))
|
||||
}, [nodes, setEdges])
|
||||
|
|
@ -259,7 +363,8 @@ export default function FlowCanvas() {
|
|||
{/* Title Panel */}
|
||||
<Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
||||
<h1 className="text-xl font-bold text-slate-800">Threshold-Based Flow Funding</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Drag min/max handles • Line thickness = allocation %</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Overflow → Funnels (sideways) • Spending → Outcomes (down)</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Double-click funnels to edit • Drag thresholds to adjust</p>
|
||||
</Panel>
|
||||
|
||||
{/* Simulation Toggle */}
|
||||
|
|
@ -278,31 +383,31 @@ export default function FlowCanvas() {
|
|||
|
||||
{/* Legend */}
|
||||
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">Funnel Zones</div>
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">Flow Types</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-amber-200 border border-amber-400" />
|
||||
<span className="text-slate-600">Overflow (above MAX)</span>
|
||||
<div className="w-8 h-1 bg-amber-500 rounded" />
|
||||
<span className="text-slate-600">Overflow → Other Funnels</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-emerald-200 border border-emerald-400" />
|
||||
<span className="text-slate-600">Healthy (MIN to MAX)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-red-200 border border-red-400" />
|
||||
<span className="text-slate-600">Critical (below MIN)</span>
|
||||
<div className="w-8 h-1 bg-blue-500 rounded" />
|
||||
<span className="text-slate-600">Spending → Outcomes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 mt-3 pt-3">
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Lines</div>
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Funnel Status</div>
|
||||
<div className="space-y-1 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-1 bg-blue-500 rounded" />
|
||||
<span>Thin = small allocation</span>
|
||||
<div className="w-3 h-3 rounded bg-amber-200 border border-amber-400" />
|
||||
<span>Overflow (above MAX)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-3 bg-blue-500 rounded" />
|
||||
<span>Thick = large allocation</span>
|
||||
<div className="w-3 h-3 rounded bg-emerald-200 border border-emerald-400" />
|
||||
<span>Healthy (MIN to MAX)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-red-200 border border-red-400" />
|
||||
<span>Critical (below MIN)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,16 +5,24 @@ import { Handle, Position } from '@xyflow/react'
|
|||
import type { NodeProps } from '@xyflow/react'
|
||||
import type { FunnelNodeData } from '@/lib/types'
|
||||
|
||||
// Pie chart colors
|
||||
const PIE_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4']
|
||||
// Pie chart colors for spending (cool tones - going DOWN to outcomes)
|
||||
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||
// Overflow colors (warm tones - going SIDEWAYS to other funnels)
|
||||
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||
|
||||
function FunnelNode({ data, selected }: NodeProps) {
|
||||
function FunnelNode({ data, selected, id }: NodeProps) {
|
||||
const nodeData = data as FunnelNodeData
|
||||
const { label, currentValue, maxCapacity, outflowAllocations } = nodeData
|
||||
const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData
|
||||
|
||||
const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold)
|
||||
const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold)
|
||||
const [dragging, setDragging] = useState<'min' | 'max' | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editValues, setEditValues] = useState({
|
||||
minThreshold: nodeData.minThreshold,
|
||||
maxThreshold: nodeData.maxThreshold,
|
||||
label: label,
|
||||
})
|
||||
const sliderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Calculate status
|
||||
|
|
@ -22,10 +30,10 @@ function FunnelNode({ data, selected }: NodeProps) {
|
|||
const isCritical = currentValue < minThreshold
|
||||
|
||||
// Funnel dimensions
|
||||
const width = 180
|
||||
const width = 200
|
||||
const height = 160
|
||||
const topWidth = 160
|
||||
const bottomWidth = 40
|
||||
const topWidth = 180
|
||||
const bottomWidth = 50
|
||||
const padding = 8
|
||||
|
||||
// Calculate Y positions
|
||||
|
|
@ -88,15 +96,36 @@ function FunnelNode({ data, selected }: NodeProps) {
|
|||
}
|
||||
}, [dragging, handleSliderMouseMove, handleSliderMouseUp])
|
||||
|
||||
// Pie chart calculations
|
||||
const pieRadius = 24
|
||||
const pieCenter = { x: pieRadius + 4, y: pieRadius + 4 }
|
||||
// Double-click to edit
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setEditValues({
|
||||
minThreshold,
|
||||
maxThreshold,
|
||||
label,
|
||||
})
|
||||
setIsEditing(true)
|
||||
}, [minThreshold, maxThreshold, label])
|
||||
|
||||
const getPieSlices = () => {
|
||||
if (outflowAllocations.length === 0) return []
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
setMinThreshold(editValues.minThreshold)
|
||||
setMaxThreshold(editValues.maxThreshold)
|
||||
setIsEditing(false)
|
||||
}, [editValues])
|
||||
|
||||
let currentAngle = -90 // Start at top
|
||||
return outflowAllocations.map((alloc, idx) => {
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
}, [])
|
||||
|
||||
// Pie chart calculations for SPENDING (downward to outcomes)
|
||||
const spendingPieRadius = 20
|
||||
const spendingPieCenter = { x: spendingPieRadius + 4, y: spendingPieRadius + 4 }
|
||||
|
||||
const getSpendingPieSlices = () => {
|
||||
if (spendingAllocations.length === 0) return []
|
||||
|
||||
let currentAngle = -90
|
||||
return spendingAllocations.map((alloc, idx) => {
|
||||
const angle = (alloc.percentage / 100) * 360
|
||||
const startAngle = currentAngle
|
||||
const endAngle = currentAngle + angle
|
||||
|
|
@ -105,215 +134,418 @@ function FunnelNode({ data, selected }: NodeProps) {
|
|||
const startRad = (startAngle * Math.PI) / 180
|
||||
const endRad = (endAngle * Math.PI) / 180
|
||||
|
||||
const x1 = pieCenter.x + pieRadius * Math.cos(startRad)
|
||||
const y1 = pieCenter.y + pieRadius * Math.sin(startRad)
|
||||
const x2 = pieCenter.x + pieRadius * Math.cos(endRad)
|
||||
const y2 = pieCenter.y + pieRadius * Math.sin(endRad)
|
||||
const x1 = spendingPieCenter.x + spendingPieRadius * Math.cos(startRad)
|
||||
const y1 = spendingPieCenter.y + spendingPieRadius * Math.sin(startRad)
|
||||
const x2 = spendingPieCenter.x + spendingPieRadius * Math.cos(endRad)
|
||||
const y2 = spendingPieCenter.y + spendingPieRadius * Math.sin(endRad)
|
||||
|
||||
const largeArc = angle > 180 ? 1 : 0
|
||||
|
||||
return {
|
||||
path: `M ${pieCenter.x} ${pieCenter.y} L ${x1} ${y1} A ${pieRadius} ${pieRadius} 0 ${largeArc} 1 ${x2} ${y2} Z`,
|
||||
color: alloc.color || PIE_COLORS[idx % PIE_COLORS.length],
|
||||
path: `M ${spendingPieCenter.x} ${spendingPieCenter.y} L ${x1} ${y1} A ${spendingPieRadius} ${spendingPieRadius} 0 ${largeArc} 1 ${x2} ${y2} Z`,
|
||||
color: alloc.color || SPENDING_COLORS[idx % SPENDING_COLORS.length],
|
||||
percentage: alloc.percentage,
|
||||
targetId: alloc.targetId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const pieSlices = getPieSlices()
|
||||
// Mini bar chart for OVERFLOW (sideways to other funnels)
|
||||
const getOverflowBars = () => {
|
||||
return overflowAllocations.map((alloc, idx) => ({
|
||||
color: alloc.color || OVERFLOW_COLORS[idx % OVERFLOW_COLORS.length],
|
||||
percentage: alloc.percentage,
|
||||
targetId: alloc.targetId,
|
||||
}))
|
||||
}
|
||||
|
||||
const spendingSlices = getSpendingPieSlices()
|
||||
const overflowBars = getOverflowBars()
|
||||
const hasOverflow = overflowAllocations.length > 0
|
||||
const hasSpending = spendingAllocations.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-white rounded-xl shadow-lg border-2 transition-all duration-200
|
||||
${selected ? 'border-blue-500 shadow-blue-200' : 'border-slate-200'}
|
||||
`}
|
||||
style={{ width: width + 100 }}
|
||||
>
|
||||
{/* Top Handle - Inflow */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-top-2"
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
bg-white rounded-xl shadow-lg border-2 transition-all duration-200
|
||||
${selected ? 'border-blue-500 shadow-blue-200' : 'border-slate-200'}
|
||||
`}
|
||||
style={{ width: width + 80 }}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Top Handle - Inflow from parent funnel overflow */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-top-2"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-slate-800 text-sm">{label}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
isOverflowing ? 'bg-amber-100 text-amber-700' :
|
||||
isCritical ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-slate-800 text-sm">{label}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
isOverflowing ? 'bg-amber-100 text-amber-700' :
|
||||
isCritical ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'
|
||||
}`}>
|
||||
{isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex items-start p-2 gap-2">
|
||||
{/* Funnel SVG */}
|
||||
<svg width={width} height={height} className="flex-shrink-0">
|
||||
<defs>
|
||||
<linearGradient id={`fill-${id}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
||||
<stop offset="100%" stopColor={isOverflowing ? '#f59e0b' : isCritical ? '#ef4444' : '#10b981'} />
|
||||
</linearGradient>
|
||||
<clipPath id={`clip-${id}`}>
|
||||
<path d={clipPath} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Zone backgrounds */}
|
||||
<rect x={leftTop} y={padding} width={topWidth} height={maxY - padding} fill="#fef3c7" />
|
||||
<rect x={leftTop} y={maxY} width={topWidth} height={funnelStartY - maxY} fill="#d1fae5" />
|
||||
<path
|
||||
d={`M ${leftTop} ${funnelStartY} L ${leftBottom} ${height - padding - 10} L ${rightBottom} ${height - padding - 10} L ${rightTop} ${funnelStartY} Z`}
|
||||
fill="#fee2e2"
|
||||
/>
|
||||
|
||||
{/* Liquid fill */}
|
||||
<g clipPath={`url(#clip-${id})`}>
|
||||
<rect x={0} y={balanceY} width={width} height={height} fill={`url(#fill-${id})`}>
|
||||
<animate attributeName="y" values={`${balanceY};${balanceY - 1};${balanceY}`} dur="2s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
</g>
|
||||
|
||||
{/* Funnel outline */}
|
||||
<path
|
||||
d={`M ${leftTop} ${padding} L ${leftTop} ${funnelStartY} L ${leftBottom} ${height - padding - 10} L ${leftBottom} ${height - padding}
|
||||
M ${rightBottom} ${height - padding} L ${rightBottom} ${height - padding - 10} L ${rightTop} ${funnelStartY} L ${rightTop} ${padding}`}
|
||||
fill="none" stroke="#64748b" strokeWidth="2"
|
||||
/>
|
||||
<line x1={leftTop} y1={padding} x2={rightTop} y2={padding} stroke="#64748b" strokeWidth="2" />
|
||||
|
||||
{/* Threshold zone indicator */}
|
||||
<rect x={rightTop + 4} y={maxY} width={6} height={minY - maxY} fill="#10b981" rx="2" />
|
||||
<line x1={rightTop + 2} y1={maxY} x2={rightTop + 12} y2={maxY} stroke="#f59e0b" strokeWidth="2" />
|
||||
<line x1={rightTop + 2} y1={minY} x2={rightTop + 12} y2={minY} stroke="#ef4444" strokeWidth="2" />
|
||||
|
||||
{/* Overflow particles - flying to the sides */}
|
||||
{isOverflowing && (
|
||||
<>
|
||||
<circle r="4" fill="#f59e0b">
|
||||
<animate attributeName="cx" values={`${leftTop + 20};${leftTop - 30}`} dur="0.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="cy" values={`${padding + 10};${padding + 30}`} dur="0.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="1;0" dur="0.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle r="4" fill="#f59e0b">
|
||||
<animate attributeName="cx" values={`${rightTop - 20};${rightTop + 30}`} dur="0.7s" repeatCount="indefinite" begin="0.15s" />
|
||||
<animate attributeName="cy" values={`${padding + 10};${padding + 30}`} dur="0.7s" repeatCount="indefinite" begin="0.15s" />
|
||||
<animate attributeName="opacity" values="1;0" dur="0.7s" repeatCount="indefinite" begin="0.15s" />
|
||||
</circle>
|
||||
<circle r="3" fill="#fbbf24">
|
||||
<animate attributeName="cx" values={`${leftTop + 30};${leftTop - 25}`} dur="0.8s" repeatCount="indefinite" begin="0.3s" />
|
||||
<animate attributeName="cy" values={`${padding + 15};${padding + 40}`} dur="0.8s" repeatCount="indefinite" begin="0.3s" />
|
||||
<animate attributeName="opacity" values="1;0" dur="0.8s" repeatCount="indefinite" begin="0.3s" />
|
||||
</circle>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spending flow particles - going down through the funnel */}
|
||||
{hasSpending && currentValue > minThreshold && (
|
||||
<>
|
||||
<circle r="3" fill="#3b82f6">
|
||||
<animate attributeName="cx" values={`${width/2};${width/2}`} dur="1s" repeatCount="indefinite" />
|
||||
<animate attributeName="cy" values={`${height - 30};${height + 10}`} dur="1s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="1;0" dur="1s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle r="2" fill="#8b5cf6">
|
||||
<animate attributeName="cx" values={`${width/2 - 5};${width/2 - 5}`} dur="1.2s" repeatCount="indefinite" begin="0.4s" />
|
||||
<animate attributeName="cy" values={`${height - 30};${height + 10}`} dur="1.2s" repeatCount="indefinite" begin="0.4s" />
|
||||
<animate attributeName="opacity" values="1;0" dur="1.2s" repeatCount="indefinite" begin="0.4s" />
|
||||
</circle>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Right side info */}
|
||||
<div className="flex flex-col gap-2 min-w-[60px]">
|
||||
{/* Spending pie chart (downward) */}
|
||||
{hasSpending && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[9px] text-slate-500 uppercase tracking-wide mb-1">Spend</span>
|
||||
<svg width={spendingPieRadius * 2 + 8} height={spendingPieRadius * 2 + 8}>
|
||||
{spendingSlices.map((slice, idx) => (
|
||||
<path key={idx} d={slice.path} fill={slice.color} stroke="white" strokeWidth="1" />
|
||||
))}
|
||||
<circle cx={spendingPieCenter.x} cy={spendingPieCenter.y} r={spendingPieRadius * 0.35} fill="white" />
|
||||
<text x={spendingPieCenter.x} y={spendingPieCenter.y + 1} textAnchor="middle" dominantBaseline="middle" className="text-[8px] fill-slate-500">
|
||||
↓
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overflow bars (sideways) */}
|
||||
{hasOverflow && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[9px] text-amber-600 uppercase tracking-wide mb-1">Overflow</span>
|
||||
<div className="flex gap-0.5">
|
||||
{overflowBars.map((bar, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-2 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: bar.color,
|
||||
height: `${Math.max(8, bar.percentage / 5)}px`,
|
||||
}}
|
||||
title={`${bar.percentage}%`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[8px] text-slate-400 mt-0.5">→ ←</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Value display */}
|
||||
<div className="px-3 py-1 text-center border-t border-slate-100">
|
||||
<span className={`text-lg font-bold font-mono ${
|
||||
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
||||
}`}>
|
||||
{isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
|
||||
${Math.floor(currentValue).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dual range slider */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex justify-between text-[10px] text-slate-500 mb-1">
|
||||
<span>MIN: <span className="text-red-600 font-mono">${(minThreshold/1000).toFixed(0)}k</span></span>
|
||||
<span>MAX: <span className="text-amber-600 font-mono">${(maxThreshold/1000).toFixed(0)}k</span></span>
|
||||
</div>
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="relative h-4 bg-slate-100 rounded-full cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute inset-y-0 left-0 right-0 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full bg-red-200"
|
||||
style={{ left: 0, width: `${(minThreshold / maxCapacity) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute h-full bg-emerald-300"
|
||||
style={{
|
||||
left: `${(minThreshold / maxCapacity) * 100}%`,
|
||||
width: `${((maxThreshold - minThreshold) / maxCapacity) * 100}%`
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute h-full bg-amber-200"
|
||||
style={{ left: `${(maxThreshold / maxCapacity) * 100}%`, right: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min handle */}
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-red-500 border-2 border-white rounded-full shadow cursor-grab ${dragging === 'min' ? 'cursor-grabbing scale-110' : ''}`}
|
||||
style={{ left: `calc(${(minThreshold / maxCapacity) * 100}% - 8px)` }}
|
||||
onMouseDown={(e) => handleSliderMouseDown(e, 'min')}
|
||||
/>
|
||||
|
||||
{/* Max handle */}
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-amber-500 border-2 border-white rounded-full shadow cursor-grab ${dragging === 'max' ? 'cursor-grabbing scale-110' : ''}`}
|
||||
style={{ left: `calc(${(maxThreshold / maxCapacity) * 100}% - 8px)` }}
|
||||
onMouseDown={(e) => handleSliderMouseDown(e, 'max')}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center mt-1">
|
||||
<span className="text-[9px] text-slate-400">Double-click to edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Handle - Spending outflow to outcomes */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-bottom-2"
|
||||
/>
|
||||
|
||||
{/* Side Handles - Overflow to other funnels */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Left}
|
||||
id="overflow-left"
|
||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
||||
style={{ top: '25%' }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="overflow-right"
|
||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
||||
style={{ top: '25%' }}
|
||||
/>
|
||||
|
||||
{/* Side Handles - Inflow from other funnel overflow */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="inflow-left"
|
||||
className="!w-3 !h-3 !bg-amber-400 !border-2 !border-white"
|
||||
style={{ top: '40%' }}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Right}
|
||||
id="inflow-right"
|
||||
className="!w-3 !h-3 !bg-amber-400 !border-2 !border-white"
|
||||
style={{ top: '40%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content - Funnel and Pie side by side */}
|
||||
<div className="flex items-start p-2 gap-2">
|
||||
{/* Funnel SVG */}
|
||||
<svg width={width} height={height} className="flex-shrink-0">
|
||||
<defs>
|
||||
<linearGradient id={`fill-${label}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
||||
<stop offset="100%" stopColor={isOverflowing ? '#f59e0b' : isCritical ? '#ef4444' : '#10b981'} />
|
||||
</linearGradient>
|
||||
<clipPath id={`clip-${label}`}>
|
||||
<path d={clipPath} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{/* Edit Modal */}
|
||||
{isEditing && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl p-6 min-w-[320px] max-w-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4">Edit {label}</h3>
|
||||
|
||||
{/* Zone backgrounds */}
|
||||
<rect x={leftTop} y={padding} width={topWidth} height={maxY - padding} fill="#fef3c7" />
|
||||
<rect x={leftTop} y={maxY} width={topWidth} height={funnelStartY - maxY} fill="#d1fae5" />
|
||||
<path
|
||||
d={`M ${leftTop} ${funnelStartY} L ${leftBottom} ${height - padding - 10} L ${rightBottom} ${height - padding - 10} L ${rightTop} ${funnelStartY} Z`}
|
||||
fill="#fee2e2"
|
||||
/>
|
||||
|
||||
{/* Liquid fill */}
|
||||
<g clipPath={`url(#clip-${label})`}>
|
||||
<rect x={0} y={balanceY} width={width} height={height} fill={`url(#fill-${label})`}>
|
||||
<animate attributeName="y" values={`${balanceY};${balanceY - 1};${balanceY}`} dur="2s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
</g>
|
||||
|
||||
{/* Funnel outline */}
|
||||
<path
|
||||
d={`M ${leftTop} ${padding} L ${leftTop} ${funnelStartY} L ${leftBottom} ${height - padding - 10} L ${leftBottom} ${height - padding}
|
||||
M ${rightBottom} ${height - padding} L ${rightBottom} ${height - padding - 10} L ${rightTop} ${funnelStartY} L ${rightTop} ${padding}`}
|
||||
fill="none" stroke="#64748b" strokeWidth="2"
|
||||
/>
|
||||
<line x1={leftTop} y1={padding} x2={rightTop} y2={padding} stroke="#64748b" strokeWidth="2" />
|
||||
|
||||
{/* Threshold zone indicator (single bar on right side) */}
|
||||
<rect x={rightTop + 4} y={maxY} width={6} height={minY - maxY} fill="#10b981" rx="2" />
|
||||
<line x1={rightTop + 2} y1={maxY} x2={rightTop + 12} y2={maxY} stroke="#f59e0b" strokeWidth="2" />
|
||||
<line x1={rightTop + 2} y1={minY} x2={rightTop + 12} y2={minY} stroke="#ef4444" strokeWidth="2" />
|
||||
|
||||
{/* Overflow particles */}
|
||||
{isOverflowing && (
|
||||
<>
|
||||
<circle r="3" fill="#f59e0b">
|
||||
<animate attributeName="cx" values={`${leftTop};${leftTop - 20}`} dur="0.7s" repeatCount="indefinite" />
|
||||
<animate attributeName="cy" values={`${padding + 5};${padding + 40}`} dur="0.7s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="1;0" dur="0.7s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle r="3" fill="#f59e0b">
|
||||
<animate attributeName="cx" values={`${rightTop};${rightTop + 20}`} dur="0.8s" repeatCount="indefinite" begin="0.2s" />
|
||||
<animate attributeName="cy" values={`${padding + 5};${padding + 40}`} dur="0.8s" repeatCount="indefinite" begin="0.2s" />
|
||||
<animate attributeName="opacity" values="1;0" dur="0.8s" repeatCount="indefinite" begin="0.2s" />
|
||||
</circle>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Pie chart for outflow allocation */}
|
||||
{outflowAllocations.length > 0 && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-wide mb-1">Outflow</span>
|
||||
<svg width={pieRadius * 2 + 8} height={pieRadius * 2 + 8}>
|
||||
{pieSlices.map((slice, idx) => (
|
||||
<path key={idx} d={slice.path} fill={slice.color} stroke="white" strokeWidth="1" />
|
||||
))}
|
||||
<circle cx={pieCenter.x} cy={pieCenter.y} r={pieRadius * 0.4} fill="white" />
|
||||
</svg>
|
||||
{/* Mini legend */}
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{outflowAllocations.slice(0, 3).map((alloc, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 text-[9px]">
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: alloc.color || PIE_COLORS[idx] }} />
|
||||
<span className="text-slate-600 truncate max-w-[50px]">{alloc.percentage}%</span>
|
||||
<div className="space-y-4">
|
||||
{/* Min Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-600 mb-1">
|
||||
Minimum Threshold
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={editValues.maxThreshold - 1000}
|
||||
value={editValues.minThreshold}
|
||||
onChange={(e) => setEditValues(v => ({ ...v, minThreshold: Number(e.target.value) }))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-mono text-red-600 w-20 text-right">
|
||||
${(editValues.minThreshold / 1000).toFixed(0)}k
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Max Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-600 mb-1">
|
||||
Maximum Threshold
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={editValues.minThreshold + 1000}
|
||||
max={maxCapacity}
|
||||
value={editValues.maxThreshold}
|
||||
onChange={(e) => setEditValues(v => ({ ...v, maxThreshold: Number(e.target.value) }))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-mono text-amber-600 w-20 text-right">
|
||||
${(editValues.maxThreshold / 1000).toFixed(0)}k
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual preview */}
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-2">Threshold Range</div>
|
||||
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-red-300"
|
||||
style={{ width: `${(editValues.minThreshold / maxCapacity) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-emerald-400 -mt-3"
|
||||
style={{
|
||||
marginLeft: `${(editValues.minThreshold / maxCapacity) * 100}%`,
|
||||
width: `${((editValues.maxThreshold - editValues.minThreshold) / maxCapacity) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
|
||||
<span>0</span>
|
||||
<span>${(maxCapacity / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overflow allocations info */}
|
||||
{hasOverflow && (
|
||||
<div className="border-t border-slate-200 pt-3">
|
||||
<div className="text-xs text-slate-500 mb-2">Overflow Allocations (to other funnels)</div>
|
||||
<div className="space-y-1">
|
||||
{overflowAllocations.map((alloc, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: alloc.color || OVERFLOW_COLORS[idx] }}
|
||||
/>
|
||||
<span className="text-slate-600">{alloc.targetId}</span>
|
||||
<span className="text-amber-600 font-mono ml-auto">{alloc.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spending allocations info */}
|
||||
{hasSpending && (
|
||||
<div className="border-t border-slate-200 pt-3">
|
||||
<div className="text-xs text-slate-500 mb-2">Spending Allocations (to outcomes)</div>
|
||||
<div className="space-y-1">
|
||||
{spendingAllocations.map((alloc, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: alloc.color || SPENDING_COLORS[idx] }}
|
||||
/>
|
||||
<span className="text-slate-600">{alloc.targetId}</span>
|
||||
<span className="text-blue-600 font-mono ml-auto">{alloc.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="px-4 py-2 text-sm bg-blue-500 text-white hover:bg-blue-600 rounded-lg transition-colors"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value display */}
|
||||
<div className="px-3 py-1 text-center border-t border-slate-100">
|
||||
<span className={`text-lg font-bold font-mono ${
|
||||
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
||||
}`}>
|
||||
${Math.floor(currentValue).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dual range slider */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex justify-between text-[10px] text-slate-500 mb-1">
|
||||
<span>MIN: <span className="text-red-600 font-mono">${(minThreshold/1000).toFixed(0)}k</span></span>
|
||||
<span>MAX: <span className="text-amber-600 font-mono">${(maxThreshold/1000).toFixed(0)}k</span></span>
|
||||
</div>
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="relative h-4 bg-slate-100 rounded-full cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute inset-y-0 left-0 right-0 rounded-full overflow-hidden">
|
||||
{/* Red zone (0 to min) */}
|
||||
<div
|
||||
className="absolute h-full bg-red-200"
|
||||
style={{ left: 0, width: `${(minThreshold / maxCapacity) * 100}%` }}
|
||||
/>
|
||||
{/* Green zone (min to max) */}
|
||||
<div
|
||||
className="absolute h-full bg-emerald-300"
|
||||
style={{
|
||||
left: `${(minThreshold / maxCapacity) * 100}%`,
|
||||
width: `${((maxThreshold - minThreshold) / maxCapacity) * 100}%`
|
||||
}}
|
||||
/>
|
||||
{/* Amber zone (max to capacity) */}
|
||||
<div
|
||||
className="absolute h-full bg-amber-200"
|
||||
style={{ left: `${(maxThreshold / maxCapacity) * 100}%`, right: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min handle */}
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-red-500 border-2 border-white rounded-full shadow cursor-grab ${dragging === 'min' ? 'cursor-grabbing scale-110' : ''}`}
|
||||
style={{ left: `calc(${(minThreshold / maxCapacity) * 100}% - 8px)` }}
|
||||
onMouseDown={(e) => handleSliderMouseDown(e, 'min')}
|
||||
/>
|
||||
|
||||
{/* Max handle */}
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-amber-500 border-2 border-white rounded-full shadow cursor-grab ${dragging === 'max' ? 'cursor-grabbing scale-110' : ''}`}
|
||||
style={{ left: `calc(${(maxThreshold / maxCapacity) * 100}% - 8px)` }}
|
||||
onMouseDown={(e) => handleSliderMouseDown(e, 'max')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Handle - Outflow */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-bottom-2"
|
||||
/>
|
||||
|
||||
{/* Side Handles - Overflow */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Left}
|
||||
id="overflow-left"
|
||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
||||
style={{ top: '25%' }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="overflow-right"
|
||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
||||
style={{ top: '25%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import type { NodeProps } from '@xyflow/react'
|
||||
import type { OutcomeNodeData } from '@/lib/types'
|
||||
|
||||
function OutcomeNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as OutcomeNodeData
|
||||
const { label, description, fundingReceived, fundingTarget, status } = nodeData
|
||||
|
||||
const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0
|
||||
const isFunded = fundingReceived >= fundingTarget
|
||||
const isPartial = fundingReceived > 0 && fundingReceived < fundingTarget
|
||||
|
||||
// Status colors
|
||||
const statusColors = {
|
||||
'not-started': { bg: 'bg-slate-100', text: 'text-slate-600', border: 'border-slate-300' },
|
||||
'in-progress': { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' },
|
||||
'completed': { bg: 'bg-emerald-100', text: 'text-emerald-700', border: 'border-emerald-300' },
|
||||
'blocked': { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
|
||||
}
|
||||
|
||||
const colors = statusColors[status] || statusColors['not-started']
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
|
||||
transition-all duration-200
|
||||
${selected ? 'border-pink-500 shadow-pink-200' : 'border-slate-200'}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle - Top */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-top-2"
|
||||
/>
|
||||
|
||||
{/* Header with icon */}
|
||||
<div className="px-3 py-2 border-b border-slate-100 bg-gradient-to-r from-pink-50 to-purple-50 rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-pink-500 rounded flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-slate-800 text-sm truncate">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-xs text-slate-500 line-clamp-2">{description}</p>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${colors.bg} ${colors.text}`}>
|
||||
{status.replace('-', ' ')}
|
||||
</span>
|
||||
{isFunded && (
|
||||
<svg className="w-4 h-4 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Funding progress */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Funding</span>
|
||||
<span className="text-xs font-mono text-slate-700">
|
||||
${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
isFunded ? 'bg-emerald-500' : isPartial ? 'bg-blue-500' : 'bg-slate-300'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right mt-0.5">
|
||||
<span className="text-[10px] font-medium text-slate-500">{progress.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(OutcomeNode)
|
||||
27
lib/types.ts
27
lib/types.ts
|
|
@ -1,6 +1,14 @@
|
|||
import type { Node, Edge } from '@xyflow/react'
|
||||
|
||||
export interface OutflowAllocation {
|
||||
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
||||
export interface OverflowAllocation {
|
||||
targetId: string
|
||||
percentage: number // 0-100
|
||||
color: string
|
||||
}
|
||||
|
||||
// Spending allocation - funds flowing DOWN to OUTCOMES/OUTPUTS
|
||||
export interface SpendingAllocation {
|
||||
targetId: string
|
||||
percentage: number // 0-100
|
||||
color: string
|
||||
|
|
@ -13,15 +21,28 @@ export interface FunnelNodeData {
|
|||
maxThreshold: number
|
||||
maxCapacity: number
|
||||
inflowRate: number
|
||||
outflowAllocations: OutflowAllocation[]
|
||||
// Overflow goes SIDEWAYS to other funnels
|
||||
overflowAllocations: OverflowAllocation[]
|
||||
// Spending goes DOWN to outcomes/outputs
|
||||
spendingAllocations: SpendingAllocation[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type FlowNode = Node<FunnelNodeData>
|
||||
export interface OutcomeNodeData {
|
||||
label: string
|
||||
description?: string
|
||||
fundingReceived: number
|
||||
fundingTarget: number
|
||||
status: 'not-started' | 'in-progress' | 'completed' | 'blocked'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
|
||||
|
||||
export interface FlowEdgeData {
|
||||
allocation: number // percentage 0-100
|
||||
color: string
|
||||
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue