Simplify funnel view, add drag-editable modal on double-click
Flow architecture: - INFLOWS enter from TOP (emerald handles) - OUTFLOWS leave from SIDES to other funnels (amber handles) - OUTCOMES/DELIVERABLES flow from BOTTOM (blue handles) Simplified default view: - Clean funnel shape with liquid fill animation - Compact value display - Simple allocation bars (Out/Spend) - Status badge (OK/LOW/OVER) Double-click edit modal: - Draggable MIN/MAX threshold handles on single line - Current value indicator - Draggable pie chart slices for allocation editing - Outflows pie (to funnels) and Spending pie (to outcomes) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb220642de
commit
9ca745756e
|
|
@ -29,11 +29,11 @@ const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981',
|
||||||
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||||
|
|
||||||
const initialNodes: FlowNode[] = [
|
const initialNodes: FlowNode[] = [
|
||||||
// Main Treasury Funnel
|
// Main Treasury Funnel (receives inflows from external sources)
|
||||||
{
|
{
|
||||||
id: 'treasury',
|
id: 'treasury',
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
position: { x: 400, y: 0 },
|
position: { x: 350, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Treasury',
|
label: 'Treasury',
|
||||||
currentValue: 85000,
|
currentValue: 85000,
|
||||||
|
|
@ -41,24 +41,23 @@ const initialNodes: FlowNode[] = [
|
||||||
maxThreshold: 70000,
|
maxThreshold: 70000,
|
||||||
maxCapacity: 100000,
|
maxCapacity: 100000,
|
||||||
inflowRate: 1000,
|
inflowRate: 1000,
|
||||||
// Overflow goes SIDEWAYS to other funnels
|
// OUTFLOWS go SIDEWAYS to other funnels
|
||||||
overflowAllocations: [
|
overflowAllocations: [
|
||||||
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
|
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
|
||||||
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
|
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
|
||||||
{ targetId: 'emergency', percentage: 25, color: OVERFLOW_COLORS[2] },
|
{ targetId: 'emergency', percentage: 25, color: OVERFLOW_COLORS[2] },
|
||||||
],
|
],
|
||||||
// Spending goes DOWN to outcomes
|
// SPENDING goes DOWN to outcomes
|
||||||
spendingAllocations: [
|
spendingAllocations: [
|
||||||
{ targetId: 'treasury-ops', percentage: 60, color: SPENDING_COLORS[0] },
|
{ targetId: 'treasury-ops', percentage: 100, color: SPENDING_COLORS[0] },
|
||||||
{ targetId: 'treasury-audit', percentage: 40, color: SPENDING_COLORS[1] },
|
|
||||||
],
|
],
|
||||||
} as FunnelNodeData,
|
} as FunnelNodeData,
|
||||||
},
|
},
|
||||||
// Sub-funnels (receive overflow from Treasury)
|
// Sub-funnels (receive INFLOWS from Treasury outflows)
|
||||||
{
|
{
|
||||||
id: 'public-goods',
|
id: 'public-goods',
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
position: { x: 50, y: 300 },
|
position: { x: 50, y: 250 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Public Goods',
|
label: 'Public Goods',
|
||||||
currentValue: 45000,
|
currentValue: 45000,
|
||||||
|
|
@ -77,7 +76,7 @@ const initialNodes: FlowNode[] = [
|
||||||
{
|
{
|
||||||
id: 'research',
|
id: 'research',
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
position: { x: 400, y: 300 },
|
position: { x: 350, y: 250 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Research',
|
label: 'Research',
|
||||||
currentValue: 28000,
|
currentValue: 28000,
|
||||||
|
|
@ -95,9 +94,9 @@ const initialNodes: FlowNode[] = [
|
||||||
{
|
{
|
||||||
id: 'emergency',
|
id: 'emergency',
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
position: { x: 750, y: 300 },
|
position: { x: 650, y: 250 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Emergency Fund',
|
label: 'Emergency',
|
||||||
currentValue: 12000,
|
currentValue: 12000,
|
||||||
minThreshold: 25000,
|
minThreshold: 25000,
|
||||||
maxThreshold: 60000,
|
maxThreshold: 60000,
|
||||||
|
|
@ -109,38 +108,26 @@ const initialNodes: FlowNode[] = [
|
||||||
],
|
],
|
||||||
} as FunnelNodeData,
|
} as FunnelNodeData,
|
||||||
},
|
},
|
||||||
// Outcome nodes (receive spending from funnels)
|
// Outcome nodes (receive SPENDING from funnels via BOTTOM)
|
||||||
{
|
{
|
||||||
id: 'treasury-ops',
|
id: 'treasury-ops',
|
||||||
type: 'outcome',
|
type: 'outcome',
|
||||||
position: { x: 350, y: 600 },
|
position: { x: 350, y: 500 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Treasury Operations',
|
label: 'Treasury Ops',
|
||||||
description: 'Day-to-day treasury management and reporting',
|
description: 'Day-to-day treasury management',
|
||||||
fundingReceived: 15000,
|
fundingReceived: 15000,
|
||||||
fundingTarget: 25000,
|
fundingTarget: 25000,
|
||||||
status: 'in-progress',
|
status: 'in-progress',
|
||||||
} as OutcomeNodeData,
|
} as OutcomeNodeData,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'treasury-audit',
|
|
||||||
type: 'outcome',
|
|
||||||
position: { x: 550, y: 600 },
|
|
||||||
data: {
|
|
||||||
label: 'Annual Audit',
|
|
||||||
description: 'Third-party financial audit and compliance',
|
|
||||||
fundingReceived: 8000,
|
|
||||||
fundingTarget: 15000,
|
|
||||||
status: 'in-progress',
|
|
||||||
} as OutcomeNodeData,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'pg-infra',
|
id: 'pg-infra',
|
||||||
type: 'outcome',
|
type: 'outcome',
|
||||||
position: { x: -50, y: 600 },
|
position: { x: -20, y: 500 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Infrastructure',
|
label: 'Infrastructure',
|
||||||
description: 'Core infrastructure development and maintenance',
|
description: 'Core infrastructure development',
|
||||||
fundingReceived: 22000,
|
fundingReceived: 22000,
|
||||||
fundingTarget: 30000,
|
fundingTarget: 30000,
|
||||||
status: 'in-progress',
|
status: 'in-progress',
|
||||||
|
|
@ -149,10 +136,10 @@ const initialNodes: FlowNode[] = [
|
||||||
{
|
{
|
||||||
id: 'pg-education',
|
id: 'pg-education',
|
||||||
type: 'outcome',
|
type: 'outcome',
|
||||||
position: { x: 100, y: 700 },
|
position: { x: 50, y: 620 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Education Programs',
|
label: 'Education',
|
||||||
description: 'Developer education and onboarding materials',
|
description: 'Developer education programs',
|
||||||
fundingReceived: 12000,
|
fundingReceived: 12000,
|
||||||
fundingTarget: 20000,
|
fundingTarget: 20000,
|
||||||
status: 'in-progress',
|
status: 'in-progress',
|
||||||
|
|
@ -161,10 +148,10 @@ const initialNodes: FlowNode[] = [
|
||||||
{
|
{
|
||||||
id: 'pg-tooling',
|
id: 'pg-tooling',
|
||||||
type: 'outcome',
|
type: 'outcome',
|
||||||
position: { x: 250, y: 600 },
|
position: { x: 180, y: 500 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Dev Tooling',
|
label: 'Dev Tooling',
|
||||||
description: 'Open-source developer tools and SDKs',
|
description: 'Open-source developer tools',
|
||||||
fundingReceived: 5000,
|
fundingReceived: 5000,
|
||||||
fundingTarget: 15000,
|
fundingTarget: 15000,
|
||||||
status: 'not-started',
|
status: 'not-started',
|
||||||
|
|
@ -173,10 +160,10 @@ const initialNodes: FlowNode[] = [
|
||||||
{
|
{
|
||||||
id: 'research-grants',
|
id: 'research-grants',
|
||||||
type: 'outcome',
|
type: 'outcome',
|
||||||
position: { x: 400, y: 600 },
|
position: { x: 300, y: 500 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Research Grants',
|
label: 'Grants',
|
||||||
description: 'Academic research grants for protocol improvements',
|
description: 'Academic research grants',
|
||||||
fundingReceived: 18000,
|
fundingReceived: 18000,
|
||||||
fundingTarget: 25000,
|
fundingTarget: 25000,
|
||||||
status: 'in-progress',
|
status: 'in-progress',
|
||||||
|
|
@ -185,10 +172,10 @@ const initialNodes: FlowNode[] = [
|
||||||
{
|
{
|
||||||
id: 'research-papers',
|
id: 'research-papers',
|
||||||
type: 'outcome',
|
type: 'outcome',
|
||||||
position: { x: 500, y: 700 },
|
position: { x: 420, y: 500 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Published Papers',
|
label: 'Papers',
|
||||||
description: 'Peer-reviewed research publications',
|
description: 'Peer-reviewed publications',
|
||||||
fundingReceived: 8000,
|
fundingReceived: 8000,
|
||||||
fundingTarget: 10000,
|
fundingTarget: 10000,
|
||||||
status: 'in-progress',
|
status: 'in-progress',
|
||||||
|
|
@ -197,10 +184,10 @@ const initialNodes: FlowNode[] = [
|
||||||
{
|
{
|
||||||
id: 'emergency-response',
|
id: 'emergency-response',
|
||||||
type: 'outcome',
|
type: 'outcome',
|
||||||
position: { x: 750, y: 600 },
|
position: { x: 650, y: 500 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Emergency Response',
|
label: 'Response Fund',
|
||||||
description: 'Rapid response fund for critical issues',
|
description: 'Rapid response for critical issues',
|
||||||
fundingReceived: 5000,
|
fundingReceived: 5000,
|
||||||
fundingTarget: 50000,
|
fundingTarget: 50000,
|
||||||
status: 'not-started',
|
status: 'not-started',
|
||||||
|
|
@ -212,21 +199,30 @@ const initialNodes: FlowNode[] = [
|
||||||
function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
const edges: FlowEdge[] = []
|
const edges: FlowEdge[] = []
|
||||||
|
|
||||||
|
// Track which side to use for each target
|
||||||
|
const targetSides: Record<string, 'left' | 'right'> = {}
|
||||||
|
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
if (node.type !== 'funnel') return
|
if (node.type !== 'funnel') return
|
||||||
const data = node.data as FunnelNodeData
|
const data = node.data as FunnelNodeData
|
||||||
|
const sourceX = node.position.x
|
||||||
|
|
||||||
// OVERFLOW edges - go SIDEWAYS to other funnels
|
// OUTFLOW edges - go SIDEWAYS from source to target's TOP (inflow)
|
||||||
data.overflowAllocations?.forEach((alloc, idx) => {
|
data.overflowAllocations?.forEach((alloc, idx) => {
|
||||||
const strokeWidth = 2 + (alloc.percentage / 100) * 8
|
const strokeWidth = 2 + (alloc.percentage / 100) * 6
|
||||||
const isLeftSide = idx % 2 === 0
|
const targetNode = nodes.find(n => n.id === alloc.targetId)
|
||||||
|
if (!targetNode) return
|
||||||
|
|
||||||
|
const targetX = targetNode.position.x
|
||||||
|
const goingRight = targetX > sourceX
|
||||||
|
const sourceHandle = goingRight ? 'outflow-right' : 'outflow-left'
|
||||||
|
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `overflow-${node.id}-${alloc.targetId}`,
|
id: `outflow-${node.id}-${alloc.targetId}`,
|
||||||
source: node.id,
|
source: node.id,
|
||||||
target: alloc.targetId,
|
target: alloc.targetId,
|
||||||
sourceHandle: isLeftSide ? 'overflow-left' : 'overflow-right',
|
sourceHandle: sourceHandle,
|
||||||
targetHandle: isLeftSide ? 'inflow-right' : 'inflow-left',
|
targetHandle: undefined, // Goes to top (default target)
|
||||||
animated: true,
|
animated: true,
|
||||||
style: {
|
style: {
|
||||||
stroke: alloc.color,
|
stroke: alloc.color,
|
||||||
|
|
@ -236,8 +232,8 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
color: alloc.color,
|
color: alloc.color,
|
||||||
width: 12 + alloc.percentage / 10,
|
width: 12,
|
||||||
height: 12 + alloc.percentage / 10,
|
height: 12,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
allocation: alloc.percentage,
|
allocation: alloc.percentage,
|
||||||
|
|
@ -248,14 +244,15 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// SPENDING edges - go DOWN to outcomes
|
// SPENDING edges - go DOWN from BOTTOM to outcomes
|
||||||
data.spendingAllocations?.forEach((alloc) => {
|
data.spendingAllocations?.forEach((alloc) => {
|
||||||
const strokeWidth = 2 + (alloc.percentage / 100) * 8
|
const strokeWidth = 2 + (alloc.percentage / 100) * 6
|
||||||
|
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `spending-${node.id}-${alloc.targetId}`,
|
id: `spending-${node.id}-${alloc.targetId}`,
|
||||||
source: node.id,
|
source: node.id,
|
||||||
target: alloc.targetId,
|
target: alloc.targetId,
|
||||||
|
sourceHandle: undefined, // Default bottom
|
||||||
animated: true,
|
animated: true,
|
||||||
style: {
|
style: {
|
||||||
stroke: alloc.color,
|
stroke: alloc.color,
|
||||||
|
|
@ -265,8 +262,8 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
color: alloc.color,
|
color: alloc.color,
|
||||||
width: 12 + alloc.percentage / 10,
|
width: 12,
|
||||||
height: 12 + alloc.percentage / 10,
|
height: 12,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
allocation: alloc.percentage,
|
allocation: alloc.percentage,
|
||||||
|
|
@ -292,7 +289,7 @@ export default function FlowCanvas() {
|
||||||
{
|
{
|
||||||
...params,
|
...params,
|
||||||
animated: true,
|
animated: true,
|
||||||
style: { stroke: '#64748b', strokeWidth: 4 },
|
style: { stroke: '#64748b', strokeWidth: 3 },
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#64748b' },
|
markerEnd: { type: MarkerType.ArrowClosed, color: '#64748b' },
|
||||||
},
|
},
|
||||||
eds
|
eds
|
||||||
|
|
@ -301,7 +298,7 @@ export default function FlowCanvas() {
|
||||||
[setEdges]
|
[setEdges]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Simulation effect - update funnel values and outcome funding
|
// Simulation effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSimulating) return
|
if (!isSimulating) return
|
||||||
|
|
||||||
|
|
@ -320,14 +317,15 @@ export default function FlowCanvas() {
|
||||||
}
|
}
|
||||||
} else if (node.type === 'outcome') {
|
} else if (node.type === 'outcome') {
|
||||||
const data = node.data as OutcomeNodeData
|
const data = node.data as OutcomeNodeData
|
||||||
const change = Math.random() * 100
|
const change = Math.random() * 80
|
||||||
const newReceived = Math.min(data.fundingTarget * 1.1, data.fundingReceived + change)
|
const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change)
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
fundingReceived: newReceived,
|
fundingReceived: newReceived,
|
||||||
status: newReceived >= data.fundingTarget ? 'completed' : data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status,
|
status: newReceived >= data.fundingTarget ? 'completed' :
|
||||||
|
data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +352,7 @@ export default function FlowCanvas() {
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.1 }}
|
fitViewOptions={{ padding: 0.15 }}
|
||||||
className="bg-slate-50"
|
className="bg-slate-50"
|
||||||
>
|
>
|
||||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
||||||
|
|
@ -362,16 +360,20 @@ export default function FlowCanvas() {
|
||||||
|
|
||||||
{/* Title Panel */}
|
{/* Title Panel */}
|
||||||
<Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
<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>
|
<h1 className="text-lg font-bold text-slate-800">Threshold-Based Flow Funding</h1>
|
||||||
<p className="text-sm text-slate-500 mt-1">Overflow → Funnels (sideways) • Spending → Outcomes (down)</p>
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
<p className="text-xs text-slate-400 mt-1">Double-click funnels to edit • Drag thresholds to adjust</p>
|
<span className="text-emerald-600">↓ Inflows</span> (top) •
|
||||||
|
<span className="text-amber-600 ml-1">→ Outflows</span> (sides) •
|
||||||
|
<span className="text-blue-600 ml-1">↓ Outcomes</span> (bottom)
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-1">Double-click funnels to edit allocations</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Simulation Toggle */}
|
{/* Simulation Toggle */}
|
||||||
<Panel position="top-right" className="m-4">
|
<Panel position="top-right" className="m-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSimulating(!isSimulating)}
|
onClick={() => setIsSimulating(!isSimulating)}
|
||||||
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all ${
|
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all text-sm ${
|
||||||
isSimulating
|
isSimulating
|
||||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||||
|
|
@ -382,33 +384,20 @@ export default function FlowCanvas() {
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
|
||||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">Flow Types</div>
|
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-1.5 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-1 bg-amber-500 rounded" />
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
<span className="text-slate-600">Overflow → Other Funnels</span>
|
<span className="text-slate-600">Inflows (top)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-1 bg-blue-500 rounded" />
|
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||||
<span className="text-slate-600">Spending → Outcomes</span>
|
<span className="text-slate-600">Outflows (sides)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="border-t border-slate-200 mt-3 pt-3">
|
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Funnel Status</div>
|
<span className="text-slate-600">Outcomes (bottom)</span>
|
||||||
<div className="space-y-1 text-xs text-slate-600">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<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-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>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ import { Handle, Position } from '@xyflow/react'
|
||||||
import type { NodeProps } from '@xyflow/react'
|
import type { NodeProps } from '@xyflow/react'
|
||||||
import type { FunnelNodeData } from '@/lib/types'
|
import type { FunnelNodeData } from '@/lib/types'
|
||||||
|
|
||||||
// Pie chart colors for spending (cool tones - going DOWN to outcomes)
|
// Colors
|
||||||
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
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']
|
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||||
|
|
||||||
function FunnelNode({ data, selected, id }: NodeProps) {
|
function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
|
|
@ -16,116 +15,136 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
|
|
||||||
const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold)
|
const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold)
|
||||||
const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold)
|
const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold)
|
||||||
const [dragging, setDragging] = useState<'min' | 'max' | null>(null)
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editValues, setEditValues] = useState({
|
const [draggingPie, setDraggingPie] = useState<{ type: 'overflow' | 'spending', index: number } | null>(null)
|
||||||
minThreshold: nodeData.minThreshold,
|
const [localOverflow, setLocalOverflow] = useState(overflowAllocations)
|
||||||
maxThreshold: nodeData.maxThreshold,
|
const [localSpending, setLocalSpending] = useState(spendingAllocations)
|
||||||
label: label,
|
|
||||||
})
|
|
||||||
const sliderRef = useRef<HTMLDivElement>(null)
|
const sliderRef = useRef<HTMLDivElement>(null)
|
||||||
|
const overflowPieRef = useRef<SVGSVGElement>(null)
|
||||||
|
const spendingPieRef = useRef<SVGSVGElement>(null)
|
||||||
|
|
||||||
// Calculate status
|
// Calculate status
|
||||||
const isOverflowing = currentValue > maxThreshold
|
const isOverflowing = currentValue > maxThreshold
|
||||||
const isCritical = currentValue < minThreshold
|
const isCritical = currentValue < minThreshold
|
||||||
|
const fillPercent = Math.min(100, (currentValue / maxCapacity) * 100)
|
||||||
|
|
||||||
// Funnel dimensions
|
// Simplified funnel dimensions
|
||||||
const width = 200
|
const width = 140
|
||||||
const height = 160
|
const height = 100
|
||||||
const topWidth = 180
|
const topWidth = 120
|
||||||
const bottomWidth = 50
|
const bottomWidth = 40
|
||||||
const padding = 8
|
|
||||||
|
|
||||||
// Calculate Y positions
|
|
||||||
const scaleY = (value: number) => padding + ((maxCapacity - value) / maxCapacity) * (height * 0.65)
|
|
||||||
const maxY = scaleY(maxThreshold)
|
|
||||||
const minY = scaleY(minThreshold)
|
|
||||||
const funnelStartY = minY + 10
|
|
||||||
const balanceY = Math.max(padding, scaleY(Math.min(currentValue, maxCapacity * 1.1)))
|
|
||||||
|
|
||||||
// Funnel shape
|
|
||||||
const leftTop = (width - topWidth) / 2
|
|
||||||
const rightTop = (width + topWidth) / 2
|
|
||||||
const leftBottom = (width - bottomWidth) / 2
|
|
||||||
const rightBottom = (width + bottomWidth) / 2
|
|
||||||
|
|
||||||
const clipPath = `
|
|
||||||
M ${leftTop} ${padding}
|
|
||||||
L ${rightTop} ${padding}
|
|
||||||
L ${rightTop} ${funnelStartY}
|
|
||||||
L ${rightBottom} ${height - padding - 10}
|
|
||||||
L ${rightBottom} ${height - padding}
|
|
||||||
L ${leftBottom} ${height - padding}
|
|
||||||
L ${leftBottom} ${height - padding - 10}
|
|
||||||
L ${leftTop} ${funnelStartY}
|
|
||||||
Z
|
|
||||||
`
|
|
||||||
|
|
||||||
// Dual range slider logic
|
|
||||||
const handleSliderMouseDown = useCallback((e: React.MouseEvent, type: 'min' | 'max') => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDragging(type)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSliderMouseMove = useCallback((e: MouseEvent) => {
|
|
||||||
if (!dragging || !sliderRef.current) return
|
|
||||||
|
|
||||||
const rect = sliderRef.current.getBoundingClientRect()
|
|
||||||
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
|
||||||
const value = Math.round((x / rect.width) * maxCapacity)
|
|
||||||
|
|
||||||
if (dragging === 'min') {
|
|
||||||
setMinThreshold(Math.min(value, maxThreshold - 1000))
|
|
||||||
} else {
|
|
||||||
setMaxThreshold(Math.max(value, minThreshold + 1000))
|
|
||||||
}
|
|
||||||
}, [dragging, maxCapacity, minThreshold, maxThreshold])
|
|
||||||
|
|
||||||
const handleSliderMouseUp = useCallback(() => {
|
|
||||||
setDragging(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dragging) {
|
|
||||||
window.addEventListener('mousemove', handleSliderMouseMove)
|
|
||||||
window.addEventListener('mouseup', handleSliderMouseUp)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('mousemove', handleSliderMouseMove)
|
|
||||||
window.removeEventListener('mouseup', handleSliderMouseUp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [dragging, handleSliderMouseMove, handleSliderMouseUp])
|
|
||||||
|
|
||||||
// Double-click to edit
|
// Double-click to edit
|
||||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setEditValues({
|
setLocalOverflow([...overflowAllocations])
|
||||||
minThreshold,
|
setLocalSpending([...spendingAllocations])
|
||||||
maxThreshold,
|
|
||||||
label,
|
|
||||||
})
|
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
}, [minThreshold, maxThreshold, label])
|
}, [overflowAllocations, spendingAllocations])
|
||||||
|
|
||||||
const handleSaveEdit = useCallback(() => {
|
const handleCloseEdit = useCallback(() => {
|
||||||
setMinThreshold(editValues.minThreshold)
|
|
||||||
setMaxThreshold(editValues.maxThreshold)
|
|
||||||
setIsEditing(false)
|
|
||||||
}, [editValues])
|
|
||||||
|
|
||||||
const handleCancelEdit = useCallback(() => {
|
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Pie chart calculations for SPENDING (downward to outcomes)
|
// Threshold slider drag
|
||||||
const spendingPieRadius = 20
|
const [draggingThreshold, setDraggingThreshold] = useState<'min' | 'max' | null>(null)
|
||||||
const spendingPieCenter = { x: spendingPieRadius + 4, y: spendingPieRadius + 4 }
|
|
||||||
|
|
||||||
const getSpendingPieSlices = () => {
|
const handleThresholdMouseDown = useCallback((e: React.MouseEvent, type: 'min' | 'max') => {
|
||||||
if (spendingAllocations.length === 0) return []
|
e.stopPropagation()
|
||||||
|
setDraggingThreshold(type)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggingThreshold || !sliderRef.current) return
|
||||||
|
|
||||||
|
const handleMove = (e: MouseEvent) => {
|
||||||
|
const rect = sliderRef.current!.getBoundingClientRect()
|
||||||
|
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||||
|
const value = Math.round((x / rect.width) * maxCapacity)
|
||||||
|
|
||||||
|
if (draggingThreshold === 'min') {
|
||||||
|
setMinThreshold(Math.min(value, maxThreshold - 1000))
|
||||||
|
} else {
|
||||||
|
setMaxThreshold(Math.max(value, minThreshold + 1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUp = () => setDraggingThreshold(null)
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMove)
|
||||||
|
window.addEventListener('mouseup', handleUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove)
|
||||||
|
window.removeEventListener('mouseup', handleUp)
|
||||||
|
}
|
||||||
|
}, [draggingThreshold, maxCapacity, minThreshold, maxThreshold])
|
||||||
|
|
||||||
|
// Pie chart drag editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggingPie) return
|
||||||
|
|
||||||
|
const handleMove = (e: MouseEvent) => {
|
||||||
|
const pieRef = draggingPie.type === 'overflow' ? overflowPieRef.current : spendingPieRef.current
|
||||||
|
if (!pieRef) return
|
||||||
|
|
||||||
|
const rect = pieRef.getBoundingClientRect()
|
||||||
|
const centerX = rect.left + rect.width / 2
|
||||||
|
const centerY = rect.top + rect.height / 2
|
||||||
|
|
||||||
|
const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI) + 90
|
||||||
|
const normalizedAngle = ((angle % 360) + 360) % 360
|
||||||
|
const percentage = Math.round((normalizedAngle / 360) * 100)
|
||||||
|
|
||||||
|
if (draggingPie.type === 'overflow') {
|
||||||
|
setLocalOverflow(prev => {
|
||||||
|
const newAllocs = [...prev]
|
||||||
|
const total = newAllocs.reduce((sum, a) => sum + a.percentage, 0)
|
||||||
|
const diff = percentage - newAllocs[draggingPie.index].percentage
|
||||||
|
|
||||||
|
// Redistribute to maintain 100% total
|
||||||
|
if (newAllocs.length > 1) {
|
||||||
|
const otherIdx = (draggingPie.index + 1) % newAllocs.length
|
||||||
|
const newOther = Math.max(5, newAllocs[otherIdx].percentage - diff)
|
||||||
|
const newCurrent = Math.max(5, Math.min(95, percentage))
|
||||||
|
newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent }
|
||||||
|
newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) }
|
||||||
|
}
|
||||||
|
return newAllocs
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setLocalSpending(prev => {
|
||||||
|
const newAllocs = [...prev]
|
||||||
|
if (newAllocs.length > 1) {
|
||||||
|
const otherIdx = (draggingPie.index + 1) % newAllocs.length
|
||||||
|
const newCurrent = Math.max(5, Math.min(95, percentage))
|
||||||
|
newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent }
|
||||||
|
newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) }
|
||||||
|
}
|
||||||
|
return newAllocs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUp = () => setDraggingPie(null)
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMove)
|
||||||
|
window.addEventListener('mouseup', handleUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove)
|
||||||
|
window.removeEventListener('mouseup', handleUp)
|
||||||
|
}
|
||||||
|
}, [draggingPie])
|
||||||
|
|
||||||
|
// Pie chart rendering helper
|
||||||
|
const renderPieChart = (allocations: typeof overflowAllocations, colors: string[], type: 'overflow' | 'spending', size: number) => {
|
||||||
|
if (allocations.length === 0) return null
|
||||||
|
|
||||||
|
const center = size / 2
|
||||||
|
const radius = size / 2 - 4
|
||||||
let currentAngle = -90
|
let currentAngle = -90
|
||||||
return spendingAllocations.map((alloc, idx) => {
|
|
||||||
|
return allocations.map((alloc, idx) => {
|
||||||
const angle = (alloc.percentage / 100) * 360
|
const angle = (alloc.percentage / 100) * 360
|
||||||
const startAngle = currentAngle
|
const startAngle = currentAngle
|
||||||
const endAngle = currentAngle + angle
|
const endAngle = currentAngle + angle
|
||||||
|
|
@ -134,33 +153,50 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
const startRad = (startAngle * Math.PI) / 180
|
const startRad = (startAngle * Math.PI) / 180
|
||||||
const endRad = (endAngle * Math.PI) / 180
|
const endRad = (endAngle * Math.PI) / 180
|
||||||
|
|
||||||
const x1 = spendingPieCenter.x + spendingPieRadius * Math.cos(startRad)
|
const x1 = center + radius * Math.cos(startRad)
|
||||||
const y1 = spendingPieCenter.y + spendingPieRadius * Math.sin(startRad)
|
const y1 = center + radius * Math.sin(startRad)
|
||||||
const x2 = spendingPieCenter.x + spendingPieRadius * Math.cos(endRad)
|
const x2 = center + radius * Math.cos(endRad)
|
||||||
const y2 = spendingPieCenter.y + spendingPieRadius * Math.sin(endRad)
|
const y2 = center + radius * Math.sin(endRad)
|
||||||
|
|
||||||
const largeArc = angle > 180 ? 1 : 0
|
const largeArc = angle > 180 ? 1 : 0
|
||||||
|
|
||||||
return {
|
return (
|
||||||
path: `M ${spendingPieCenter.x} ${spendingPieCenter.y} L ${x1} ${y1} A ${spendingPieRadius} ${spendingPieRadius} 0 ${largeArc} 1 ${x2} ${y2} Z`,
|
<path
|
||||||
color: alloc.color || SPENDING_COLORS[idx % SPENDING_COLORS.length],
|
key={idx}
|
||||||
percentage: alloc.percentage,
|
d={`M ${center} ${center} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`}
|
||||||
targetId: alloc.targetId,
|
fill={alloc.color || colors[idx % colors.length]}
|
||||||
}
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
className={isEditing ? 'cursor-grab hover:opacity-80' : ''}
|
||||||
|
onMouseDown={isEditing ? (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDraggingPie({ type, index: idx })
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini bar chart for OVERFLOW (sideways to other funnels)
|
// Simple bar representation for allocations
|
||||||
const getOverflowBars = () => {
|
const renderSimpleBars = (allocations: typeof overflowAllocations, colors: string[], direction: 'horizontal' | 'vertical') => {
|
||||||
return overflowAllocations.map((alloc, idx) => ({
|
if (allocations.length === 0) return null
|
||||||
color: alloc.color || OVERFLOW_COLORS[idx % OVERFLOW_COLORS.length],
|
|
||||||
percentage: alloc.percentage,
|
return (
|
||||||
targetId: alloc.targetId,
|
<div className={`flex ${direction === 'horizontal' ? 'flex-row h-2' : 'flex-col w-2'} rounded overflow-hidden`}>
|
||||||
}))
|
{allocations.map((alloc, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: alloc.color || colors[idx % colors.length],
|
||||||
|
[direction === 'horizontal' ? 'width' : 'height']: `${alloc.percentage}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const spendingSlices = getSpendingPieSlices()
|
|
||||||
const overflowBars = getOverflowBars()
|
|
||||||
const hasOverflow = overflowAllocations.length > 0
|
const hasOverflow = overflowAllocations.length > 0
|
||||||
const hasSpending = spendingAllocations.length > 0
|
const hasSpending = spendingAllocations.length > 0
|
||||||
|
|
||||||
|
|
@ -170,246 +206,164 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
className={`
|
className={`
|
||||||
bg-white rounded-xl shadow-lg border-2 transition-all duration-200
|
bg-white rounded-xl shadow-lg border-2 transition-all duration-200
|
||||||
${selected ? 'border-blue-500 shadow-blue-200' : 'border-slate-200'}
|
${selected ? 'border-blue-500 shadow-blue-200' : 'border-slate-200'}
|
||||||
|
${isEditing ? 'ring-2 ring-blue-400 ring-offset-2' : ''}
|
||||||
`}
|
`}
|
||||||
style={{ width: width + 80 }}
|
style={{ width: width + 40 }}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
{/* Top Handle - Inflow from parent funnel overflow */}
|
{/* TOP Handle - INFLOWS */}
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-top-2"
|
className="!w-4 !h-4 !bg-emerald-500 !border-2 !border-white !-top-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-3 py-2 border-b border-slate-100">
|
<div className="px-3 py-2 border-b border-slate-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-semibold text-slate-800 text-sm">{label}</span>
|
<span className="font-semibold text-slate-800 text-sm">{label}</span>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||||
isOverflowing ? 'bg-amber-100 text-amber-700' :
|
isOverflowing ? 'bg-amber-100 text-amber-700' :
|
||||||
isCritical ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'
|
isCritical ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'
|
||||||
}`}>
|
}`}>
|
||||||
{isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
|
{isOverflowing ? 'OVER' : isCritical ? 'LOW' : 'OK'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Simplified Funnel View */}
|
||||||
<div className="flex items-start p-2 gap-2">
|
<div className="p-3">
|
||||||
{/* Funnel SVG */}
|
{/* Inflow indicator */}
|
||||||
<svg width={width} height={height} className="flex-shrink-0">
|
<div className="flex items-center justify-center gap-1 mb-2">
|
||||||
|
<span className="text-[9px] text-emerald-600 uppercase">In</span>
|
||||||
|
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 4l-8 8h5v8h6v-8h5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple funnel shape with fill */}
|
||||||
|
<svg width={width} height={height} className="mx-auto">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`fill-${id}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
<linearGradient id={`fill-${id}`} x1="0%" y1="100%" x2="0%" y2="0%">
|
||||||
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
||||||
<stop offset="100%" stopColor={isOverflowing ? '#f59e0b' : isCritical ? '#ef4444' : '#10b981'} />
|
<stop offset="100%" stopColor={isOverflowing ? '#fde68a' : isCritical ? '#fca5a5' : '#6ee7b7'} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<clipPath id={`clip-${id}`}>
|
<clipPath id={`funnel-${id}`}>
|
||||||
<path d={clipPath} />
|
<path d={`
|
||||||
|
M ${(width - topWidth) / 2} 0
|
||||||
|
L ${(width + topWidth) / 2} 0
|
||||||
|
L ${(width + bottomWidth) / 2} ${height}
|
||||||
|
L ${(width - bottomWidth) / 2} ${height}
|
||||||
|
Z
|
||||||
|
`} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Zone backgrounds */}
|
{/* Funnel background */}
|
||||||
<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
|
<path
|
||||||
d={`M ${leftTop} ${funnelStartY} L ${leftBottom} ${height - padding - 10} L ${rightBottom} ${height - padding - 10} L ${rightTop} ${funnelStartY} Z`}
|
d={`
|
||||||
fill="#fee2e2"
|
M ${(width - topWidth) / 2} 0
|
||||||
|
L ${(width + topWidth) / 2} 0
|
||||||
|
L ${(width + bottomWidth) / 2} ${height}
|
||||||
|
L ${(width - bottomWidth) / 2} ${height}
|
||||||
|
Z
|
||||||
|
`}
|
||||||
|
fill="#f1f5f9"
|
||||||
|
stroke="#94a3b8"
|
||||||
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Liquid fill */}
|
{/* Fill level */}
|
||||||
<g clipPath={`url(#clip-${id})`}>
|
<g clipPath={`url(#funnel-${id})`}>
|
||||||
<rect x={0} y={balanceY} width={width} height={height} fill={`url(#fill-${id})`}>
|
<rect
|
||||||
<animate attributeName="y" values={`${balanceY};${balanceY - 1};${balanceY}`} dur="2s" repeatCount="indefinite" />
|
x={0}
|
||||||
|
y={height - (height * fillPercent / 100)}
|
||||||
|
width={width}
|
||||||
|
height={height * fillPercent / 100}
|
||||||
|
fill={`url(#fill-${id})`}
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="y"
|
||||||
|
values={`${height - (height * fillPercent / 100)};${height - (height * fillPercent / 100) - 2};${height - (height * fillPercent / 100)}`}
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
</rect>
|
</rect>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Funnel outline */}
|
{/* Funnel outline */}
|
||||||
<path
|
<path
|
||||||
d={`M ${leftTop} ${padding} L ${leftTop} ${funnelStartY} L ${leftBottom} ${height - padding - 10} L ${leftBottom} ${height - padding}
|
d={`
|
||||||
M ${rightBottom} ${height - padding} L ${rightBottom} ${height - padding - 10} L ${rightTop} ${funnelStartY} L ${rightTop} ${padding}`}
|
M ${(width - topWidth) / 2} 0
|
||||||
fill="none" stroke="#64748b" strokeWidth="2"
|
L ${(width + topWidth) / 2} 0
|
||||||
|
L ${(width + bottomWidth) / 2} ${height}
|
||||||
|
L ${(width - bottomWidth) / 2} ${height}
|
||||||
|
Z
|
||||||
|
`}
|
||||||
|
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>
|
</svg>
|
||||||
|
|
||||||
{/* Right side info */}
|
{/* Value */}
|
||||||
<div className="flex flex-col gap-2 min-w-[60px]">
|
<div className="text-center mt-2">
|
||||||
{/* Spending pie chart (downward) */}
|
<span className={`text-base font-bold font-mono ${
|
||||||
{hasSpending && (
|
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
||||||
<div className="flex flex-col items-center">
|
}`}>
|
||||||
<span className="text-[9px] text-slate-500 uppercase tracking-wide mb-1">Spend</span>
|
${Math.floor(currentValue / 1000)}k
|
||||||
<svg width={spendingPieRadius * 2 + 8} height={spendingPieRadius * 2 + 8}>
|
</span>
|
||||||
{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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Value display */}
|
{/* Simplified allocation bars */}
|
||||||
<div className="px-3 py-1 text-center border-t border-slate-100">
|
<div className="flex items-center justify-between mt-3 gap-2">
|
||||||
<span className={`text-lg font-bold font-mono ${
|
{/* Outflow (left side) */}
|
||||||
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
<div className="flex flex-col items-center flex-1">
|
||||||
}`}>
|
<span className="text-[8px] text-amber-600 uppercase mb-1">Out</span>
|
||||||
${Math.floor(currentValue).toLocaleString()}
|
{hasOverflow ? (
|
||||||
</span>
|
renderSimpleBars(overflowAllocations, OVERFLOW_COLORS, 'horizontal')
|
||||||
</div>
|
) : (
|
||||||
|
<div className="h-2 w-full bg-slate-100 rounded" />
|
||||||
{/* 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>
|
</div>
|
||||||
|
|
||||||
{/* Min handle */}
|
{/* Outcomes (bottom indicator) */}
|
||||||
<div
|
<div className="flex flex-col items-center flex-1">
|
||||||
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' : ''}`}
|
<span className="text-[8px] text-blue-600 uppercase mb-1">Spend</span>
|
||||||
style={{ left: `calc(${(minThreshold / maxCapacity) * 100}% - 8px)` }}
|
{hasSpending ? (
|
||||||
onMouseDown={(e) => handleSliderMouseDown(e, 'min')}
|
renderSimpleBars(spendingAllocations, SPENDING_COLORS, 'horizontal')
|
||||||
/>
|
) : (
|
||||||
|
<div className="h-2 w-full bg-slate-100 rounded" />
|
||||||
{/* Max handle */}
|
)}
|
||||||
<div
|
</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>
|
||||||
<div className="text-center mt-1">
|
|
||||||
<span className="text-[9px] text-slate-400">Double-click to edit</span>
|
<div className="text-center mt-2">
|
||||||
|
<span className="text-[8px] text-slate-400">Double-click to edit</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Handle - Spending outflow to outcomes */}
|
{/* SIDE Handles - OUTFLOWS to other funnels */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Left}
|
||||||
|
id="outflow-left"
|
||||||
|
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
||||||
|
style={{ top: '50%' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="outflow-right"
|
||||||
|
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
||||||
|
style={{ top: '50%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* BOTTOM Handle - OUTCOMES/DELIVERABLES */}
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-bottom-2"
|
className="!w-4 !h-4 !bg-blue-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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -417,109 +371,142 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
onClick={handleCancelEdit}
|
onClick={handleCloseEdit}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-white rounded-xl shadow-2xl p-6 min-w-[320px] max-w-md"
|
className="bg-white rounded-2xl shadow-2xl p-6 min-w-[400px] max-w-lg"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-bold text-slate-800 mb-4">Edit {label}</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseEdit}
|
||||||
|
className="text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Current Value Display */}
|
||||||
{/* Min Threshold */}
|
<div className="text-center mb-4">
|
||||||
<div>
|
<span className={`text-3xl font-bold font-mono ${
|
||||||
<label className="block text-sm font-medium text-slate-600 mb-1">
|
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
||||||
Minimum Threshold
|
}`}>
|
||||||
</label>
|
${Math.floor(currentValue).toLocaleString()}
|
||||||
<div className="flex items-center gap-2">
|
</span>
|
||||||
<input
|
<span className="text-slate-400 text-sm ml-2">/ ${maxCapacity.toLocaleString()}</span>
|
||||||
type="range"
|
</div>
|
||||||
min={0}
|
|
||||||
max={editValues.maxThreshold - 1000}
|
{/* MIN/MAX Threshold Slider */}
|
||||||
value={editValues.minThreshold}
|
<div className="mb-6">
|
||||||
onChange={(e) => setEditValues(v => ({ ...v, minThreshold: Number(e.target.value) }))}
|
<div className="flex justify-between text-xs text-slate-500 mb-2">
|
||||||
className="flex-1"
|
<span>MIN: <span className="text-red-600 font-mono font-medium">${(minThreshold/1000).toFixed(0)}k</span></span>
|
||||||
/>
|
<span>MAX: <span className="text-amber-600 font-mono font-medium">${(maxThreshold/1000).toFixed(0)}k</span></span>
|
||||||
<span className="text-sm font-mono text-red-600 w-20 text-right">
|
|
||||||
${(editValues.minThreshold / 1000).toFixed(0)}k
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
{/* Max Threshold */}
|
ref={sliderRef}
|
||||||
<div>
|
className="relative h-6 bg-slate-100 rounded-full cursor-pointer"
|
||||||
<label className="block text-sm font-medium text-slate-600 mb-1">
|
>
|
||||||
Maximum Threshold
|
{/* Zone colors */}
|
||||||
</label>
|
<div className="absolute inset-0 rounded-full overflow-hidden">
|
||||||
<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
|
<div
|
||||||
className="h-full bg-red-300"
|
className="absolute h-full bg-red-200"
|
||||||
style={{ width: `${(editValues.minThreshold / maxCapacity) * 100}%` }}
|
style={{ left: 0, width: `${(minThreshold / maxCapacity) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="h-full bg-emerald-400 -mt-3"
|
className="absolute h-full bg-emerald-200"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: `${(editValues.minThreshold / maxCapacity) * 100}%`,
|
left: `${(minThreshold / maxCapacity) * 100}%`,
|
||||||
width: `${((editValues.maxThreshold - editValues.minThreshold) / maxCapacity) * 100}%`
|
width: `${((maxThreshold - minThreshold) / maxCapacity) * 100}%`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-amber-200"
|
||||||
|
style={{ left: `${(maxThreshold / maxCapacity) * 100}%`, right: 0 }}
|
||||||
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Current value indicator */}
|
||||||
{hasOverflow && (
|
<div
|
||||||
<div className="border-t border-slate-200 pt-3">
|
className="absolute top-0 bottom-0 w-1 bg-slate-800 rounded"
|
||||||
<div className="text-xs text-slate-500 mb-2">Overflow Allocations (to other funnels)</div>
|
style={{ left: `${Math.min(100, (currentValue / maxCapacity) * 100)}%` }}
|
||||||
<div className="space-y-1">
|
/>
|
||||||
{overflowAllocations.map((alloc, idx) => (
|
|
||||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
{/* Min handle */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-red-500 border-2 border-white rounded-full shadow-lg cursor-grab ${draggingThreshold === 'min' ? 'cursor-grabbing scale-110' : 'hover:scale-105'}`}
|
||||||
|
style={{ left: `calc(${(minThreshold / maxCapacity) * 100}% - 10px)` }}
|
||||||
|
onMouseDown={(e) => handleThresholdMouseDown(e, 'min')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Max handle */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-amber-500 border-2 border-white rounded-full shadow-lg cursor-grab ${draggingThreshold === 'max' ? 'cursor-grabbing scale-110' : 'hover:scale-105'}`}
|
||||||
|
style={{ left: `calc(${(maxThreshold / maxCapacity) * 100}% - 10px)` }}
|
||||||
|
onMouseDown={(e) => handleThresholdMouseDown(e, 'max')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
|
||||||
|
<span>$0</span>
|
||||||
|
<span className="text-slate-600 font-medium">Drag handles to adjust</span>
|
||||||
|
<span>${(maxCapacity/1000).toFixed(0)}k</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pie Charts Row */}
|
||||||
|
<div className="flex gap-6 justify-center">
|
||||||
|
{/* Outflows Pie */}
|
||||||
|
{localOverflow.length > 0 && (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-xs text-amber-600 font-medium uppercase tracking-wide mb-2">
|
||||||
|
Outflows (to Funnels)
|
||||||
|
</span>
|
||||||
|
<svg ref={overflowPieRef} width={120} height={120} className="cursor-pointer">
|
||||||
|
{renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 120)}
|
||||||
|
<circle cx={60} cy={60} r={25} fill="white" />
|
||||||
|
<text x={60} y={64} textAnchor="middle" className="text-xs fill-slate-500 font-medium">
|
||||||
|
→
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{localOverflow.map((alloc, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 text-xs">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded"
|
className="w-3 h-3 rounded"
|
||||||
style={{ backgroundColor: alloc.color || OVERFLOW_COLORS[idx] }}
|
style={{ backgroundColor: alloc.color || OVERFLOW_COLORS[idx] }}
|
||||||
/>
|
/>
|
||||||
<span className="text-slate-600">{alloc.targetId}</span>
|
<span className="text-slate-600 truncate max-w-[80px]">{alloc.targetId}</span>
|
||||||
<span className="text-amber-600 font-mono ml-auto">{alloc.percentage}%</span>
|
<span className="text-amber-600 font-mono">{alloc.percentage}%</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spending allocations info */}
|
{/* Spending Pie */}
|
||||||
{hasSpending && (
|
{localSpending.length > 0 && (
|
||||||
<div className="border-t border-slate-200 pt-3">
|
<div className="flex flex-col items-center">
|
||||||
<div className="text-xs text-slate-500 mb-2">Spending Allocations (to outcomes)</div>
|
<span className="text-xs text-blue-600 font-medium uppercase tracking-wide mb-2">
|
||||||
<div className="space-y-1">
|
Spending (to Outcomes)
|
||||||
{spendingAllocations.map((alloc, idx) => (
|
</span>
|
||||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
<svg ref={spendingPieRef} width={120} height={120} className="cursor-pointer">
|
||||||
|
{renderPieChart(localSpending, SPENDING_COLORS, 'spending', 120)}
|
||||||
|
<circle cx={60} cy={60} r={25} fill="white" />
|
||||||
|
<text x={60} y={64} textAnchor="middle" className="text-xs fill-slate-500 font-medium">
|
||||||
|
↓
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{localSpending.map((alloc, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 text-xs">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded"
|
className="w-3 h-3 rounded"
|
||||||
style={{ backgroundColor: alloc.color || SPENDING_COLORS[idx] }}
|
style={{ backgroundColor: alloc.color || SPENDING_COLORS[idx] }}
|
||||||
/>
|
/>
|
||||||
<span className="text-slate-600">{alloc.targetId}</span>
|
<span className="text-slate-600 truncate max-w-[80px]">{alloc.targetId}</span>
|
||||||
<span className="text-blue-600 font-mono ml-auto">{alloc.percentage}%</span>
|
<span className="text-blue-600 font-mono">{alloc.percentage}%</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -527,19 +514,19 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buttons */}
|
{(localOverflow.length > 0 || localSpending.length > 0) && (
|
||||||
<div className="flex justify-end gap-2 mt-6">
|
<p className="text-center text-[10px] text-slate-400 mt-4">
|
||||||
|
Drag pie slices to adjust allocations
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancelEdit}
|
onClick={handleCloseEdit}
|
||||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
className="px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Cancel
|
Done
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue