From 6bf9b0ed193c964e6b45cf9b8aa952b8e2ab5a66 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 29 Jan 2026 18:45:03 +0000 Subject: [PATCH] Replace rectangular nodes with funnel visualizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit n8n-style interface now uses funnel-shaped threshold nodes: - Funnel shape with narrowing bottom (critical zone) - Straight walls between MIN and MAX (healthy zone) - Overflow zone at top with animated particles spilling over sides - Vertical flow layout: Source (top) → Funnels (middle) → Recipients (bottom) - Inflow enters via top handle (blue) - Outflow exits via bottom handle (pink) - Overflow exits via side handles (amber) when above MAX - Interactive MIN/MAX sliders on each funnel - Color-coded zones: amber (overflow), green (healthy), red (critical) Co-Authored-By: Claude Opus 4.5 --- components/FlowCanvas.tsx | 169 ++++++++++----- components/nodes/RecipientNode.tsx | 10 +- components/nodes/SourceNode.tsx | 14 +- components/nodes/ThresholdNode.tsx | 329 ++++++++++++++++++++--------- 4 files changed, 360 insertions(+), 162 deletions(-) diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index f9bcd9f..bd219ca 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -26,43 +26,58 @@ const nodeTypes = { recipient: RecipientNode, } +// Vertical layout - sources at top, recipients at bottom const initialNodes: FlowNode[] = [ + // Top row - Source { id: 'source-1', type: 'source', - position: { x: 50, y: 150 }, + position: { x: 350, y: 0 }, data: { label: 'Treasury', - balance: 75000, - flowRate: 500, + balance: 100000, + flowRate: 1000, }, }, + // Middle row - Threshold funnels { id: 'threshold-1', type: 'threshold', - position: { x: 350, y: 50 }, + position: { x: 100, y: 200 }, data: { - label: 'Public Goods Gate', - minThreshold: 10000, - maxThreshold: 50000, - currentValue: 32000, + label: 'Public Goods', + minThreshold: 15000, + maxThreshold: 60000, + currentValue: 72000, // Overflowing }, }, { id: 'threshold-2', type: 'threshold', - position: { x: 350, y: 350 }, + position: { x: 400, y: 200 }, data: { - label: 'Research Gate', - minThreshold: 5000, - maxThreshold: 30000, - currentValue: 8500, + label: 'Research', + minThreshold: 20000, + maxThreshold: 50000, + currentValue: 35000, // Healthy }, }, + { + id: 'threshold-3', + type: 'threshold', + position: { x: 700, y: 200 }, + data: { + label: 'Emergency', + minThreshold: 30000, + maxThreshold: 80000, + currentValue: 18000, // Critical + }, + }, + // Bottom row - Recipients { id: 'recipient-1', type: 'recipient', - position: { x: 700, y: 50 }, + position: { x: 50, y: 620 }, data: { label: 'Project Alpha', received: 24500, @@ -72,65 +87,103 @@ const initialNodes: FlowNode[] = [ { id: 'recipient-2', type: 'recipient', - position: { x: 700, y: 250 }, + position: { x: 300, y: 620 }, data: { label: 'Project Beta', - received: 8000, + received: 18000, target: 25000, }, }, { id: 'recipient-3', type: 'recipient', - position: { x: 700, y: 450 }, + position: { x: 550, y: 620 }, data: { - label: 'Research Fund', - received: 15000, - target: 15000, + label: 'Research Lab', + received: 12000, + target: 40000, + }, + }, + { + id: 'recipient-4', + type: 'recipient', + position: { x: 800, y: 620 }, + data: { + label: 'Reserve Fund', + received: 5000, + target: 50000, }, }, ] const initialEdges: FlowEdge[] = [ + // Source to thresholds (top to middle) { - id: 'e1', + id: 'e-source-t1', source: 'source-1', target: 'threshold-1', animated: true, - style: { stroke: '#3b82f6', strokeWidth: 2 }, + style: { stroke: '#3b82f6', strokeWidth: 3 }, markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' }, }, { - id: 'e2', + id: 'e-source-t2', source: 'source-1', target: 'threshold-2', animated: true, - style: { stroke: '#3b82f6', strokeWidth: 2 }, + style: { stroke: '#3b82f6', strokeWidth: 3 }, markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' }, }, { - id: 'e3', + id: 'e-source-t3', + source: 'source-1', + target: 'threshold-3', + animated: true, + style: { stroke: '#3b82f6', strokeWidth: 3 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' }, + }, + // Threshold to recipients (middle to bottom) + { + id: 'e-t1-r1', source: 'threshold-1', target: 'recipient-1', animated: true, - style: { stroke: '#a855f7', strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' }, + style: { stroke: '#ec4899', strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' }, }, { - id: 'e4', + id: 'e-t1-r2', source: 'threshold-1', target: 'recipient-2', animated: true, - style: { stroke: '#a855f7', strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' }, + style: { stroke: '#ec4899', strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' }, }, { - id: 'e5', + id: 'e-t2-r3', source: 'threshold-2', target: 'recipient-3', animated: true, - style: { stroke: '#a855f7', strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' }, + style: { stroke: '#ec4899', strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' }, + }, + { + id: 'e-t3-r4', + source: 'threshold-3', + target: 'recipient-4', + animated: true, + style: { stroke: '#ec4899', strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' }, + }, + // Overflow connections (side handles) - from overflowing funnel to neighbors + { + id: 'e-overflow-1', + source: 'threshold-1', + sourceHandle: 'overflow-right', + target: 'threshold-2', + animated: true, + style: { stroke: '#f59e0b', strokeWidth: 2, strokeDasharray: '5 5' }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#f59e0b' }, }, ] @@ -174,7 +227,8 @@ export default function FlowCanvas() { } if (node.type === 'threshold') { const data = node.data as ThresholdNodeData - const change = (Math.random() - 0.3) * 100 + // Random walk for demo + const change = (Math.random() - 0.4) * 200 return { ...node, data: { @@ -190,7 +244,7 @@ export default function FlowCanvas() { ...node, data: { ...data, - received: Math.min(data.target, data.received + Math.random() * 50), + received: Math.min(data.target, data.received + Math.random() * 20), }, } } @@ -198,7 +252,7 @@ export default function FlowCanvas() { return node }) ) - }, 1000) + }, 500) return () => clearInterval(interval) }, [isSimulating, setNodes]) @@ -213,7 +267,7 @@ export default function FlowCanvas() { onConnect={onConnect} nodeTypes={nodeTypes} fitView - fitViewOptions={{ padding: 0.2 }} + fitViewOptions={{ padding: 0.1 }} className="bg-slate-50" defaultEdgeOptions={{ animated: true, @@ -223,10 +277,10 @@ export default function FlowCanvas() { - {/* Top Panel - Title and Controls */} + {/* Title Panel */}

Threshold-Based Flow Funding

-

Drag nodes to rearrange • Connect nodes to create flows

+

Funds flow top→bottom through funnel thresholds

{/* Simulation Toggle */} @@ -239,29 +293,42 @@ export default function FlowCanvas() { : 'bg-slate-200 text-slate-700 hover:bg-slate-300' }`} > - {isSimulating ? '⏸ Pause' : '▶ Start'} Simulation + {isSimulating ? '⏸ Pause' : '▶ Start'} {/* Legend */} -
Node Types
-
+
Flow Types
+
-
- Source (Funding Origin) +
+ Inflow (from source)
-
- Threshold Gate (Min/Max) +
+ Outflow (to recipients)
-
- Recipient (Funded) +
+ Overflow (excess)
-
-
- Recipient (Pending) +
+
+
Funnel Zones
+
+
+
+ Overflow (above MAX) +
+
+
+ Healthy (MIN to MAX) +
+
+
+ Critical (below MIN) +
diff --git a/components/nodes/RecipientNode.tsx b/components/nodes/RecipientNode.tsx index a258db0..ca0d404 100644 --- a/components/nodes/RecipientNode.tsx +++ b/components/nodes/RecipientNode.tsx @@ -13,16 +13,16 @@ function RecipientNode({ data, selected }: NodeProps) { return (
- {/* Input Handle */} + {/* Input Handle - Top for vertical flow */} {/* Header */} @@ -49,7 +49,7 @@ function RecipientNode({ data, selected }: NodeProps) {
Received - ${received.toLocaleString()} + ${Math.floor(received).toLocaleString()}
diff --git a/components/nodes/SourceNode.tsx b/components/nodes/SourceNode.tsx index b5191f6..6cc0c87 100644 --- a/components/nodes/SourceNode.tsx +++ b/components/nodes/SourceNode.tsx @@ -33,7 +33,7 @@ function SourceNode({ data, selected }: NodeProps) {
Balance - ${balance.toLocaleString()} + ${Math.floor(balance).toLocaleString()}
@@ -42,13 +42,19 @@ function SourceNode({ data, selected }: NodeProps) { ${flowRate}/hr
+ {/* Flow indicator */} +
+ + + +
- {/* Output Handle */} + {/* Output Handle - Bottom for vertical flow */}
) diff --git a/components/nodes/ThresholdNode.tsx b/components/nodes/ThresholdNode.tsx index a64d0b7..b2a6375 100644 --- a/components/nodes/ThresholdNode.tsx +++ b/components/nodes/ThresholdNode.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useState, useCallback } from 'react' +import { memo, useState } from 'react' import { Handle, Position } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' import type { ThresholdNodeData } from '@/lib/types' @@ -10,134 +10,259 @@ function ThresholdNode({ data, selected }: NodeProps) { const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) const currentValue = nodeData.currentValue + const maxCapacity = 100000 // Calculate status - const getStatus = () => { - if (currentValue < minThreshold) return { label: 'Below Min', color: 'red', bg: 'bg-red-500' } - if (currentValue > maxThreshold) return { label: 'Overflow', color: 'amber', bg: 'bg-amber-500' } - return { label: 'Active', color: 'green', bg: 'bg-emerald-500' } - } + const isOverflowing = currentValue > maxThreshold + const isCritical = currentValue < minThreshold + const isHealthy = !isOverflowing && !isCritical - const status = getStatus() - const fillPercent = Math.min(100, Math.max(0, ((currentValue - minThreshold) / (maxThreshold - minThreshold)) * 100)) + // Funnel dimensions + const width = 160 + const height = 200 + const topWidth = 140 + const bottomWidth = 30 + const padding = 10 + + // Calculate Y positions for thresholds and fill + const maxY = padding + ((maxCapacity - maxThreshold) / maxCapacity) * (height * 0.6) + const minY = padding + ((maxCapacity - minThreshold) / maxCapacity) * (height * 0.6) + const funnelStartY = minY + 15 + const balanceY = Math.max(padding, padding + ((maxCapacity - Math.min(currentValue, maxCapacity * 1.1)) / maxCapacity) * (height * 0.6)) + + // Funnel shape calculations + const leftTop = (width - topWidth) / 2 + const rightTop = (width + topWidth) / 2 + const leftBottom = (width - bottomWidth) / 2 + const rightBottom = (width + bottomWidth) / 2 + + // Clip path for liquid fill + const clipPath = ` + M ${leftTop} ${padding} + L ${rightTop} ${padding} + L ${rightTop} ${funnelStartY} + L ${rightBottom} ${height - padding - 15} + L ${rightBottom} ${height - padding} + L ${leftBottom} ${height - padding} + L ${leftBottom} ${height - padding - 15} + L ${leftTop} ${funnelStartY} + Z + ` return (
- {/* Input Handle */} + {/* Top Handle - Inflow */} {/* Header */} -
-
-
-
- - - -
- {nodeData.label} -
- - {status.label} - +
+
{nodeData.label}
+
+ {isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'}
- {/* Body */} -
- {/* Current Value Display */} -
- - ${currentValue.toLocaleString()} - -

Current Value

-
+ {/* Funnel SVG */} + + + + + + + + + + - {/* Visual Bar */} -
-
- {/* Fill */} -
maxThreshold ? 'bg-amber-400' : 'bg-emerald-400' - }`} - style={{ - width: currentValue < minThreshold - ? `${(currentValue / minThreshold) * 33}%` - : currentValue > maxThreshold - ? '100%' - : `${33 + fillPercent * 0.67}%` - }} - /> - {/* Min marker */} -
- {/* Max marker */} -
-
-
- $0 - Min - Max -
-
+ {/* Zone backgrounds */} + {/* Overflow zone */} + + {/* Healthy zone */} + + {/* Critical zone (funnel part) */} + - {/* Threshold Controls */} -
-
-
- - ${minThreshold.toLocaleString()} -
- setMinThreshold(Number(e.target.value))} - className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-red-500" + {/* Liquid fill */} + + + -
+ + -
-
- - ${maxThreshold.toLocaleString()} -
- setMaxThreshold(Number(e.target.value))} - className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500" - /> -
+ {/* Funnel outline */} + + {/* Top line */} + + + {/* MAX line */} + + MAX + + {/* MIN line */} + + MIN + + {/* Overflow particles */} + {isOverflowing && ( + <> + + + + + + + + + + + + )} + + + {/* Value display */} +
+
+ ${Math.floor(currentValue).toLocaleString()}
- {/* Output Handle */} + {/* Threshold sliders */} +
+
+
+ Min + ${minThreshold.toLocaleString()} +
+ setMinThreshold(Number(e.target.value))} + className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-red-500" + /> +
+
+
+ Max + ${maxThreshold.toLocaleString()} +
+ setMaxThreshold(Number(e.target.value))} + className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500" + /> +
+
+ + {/* Bottom Handle - Outflow */} + + {/* Side Handles - Overflow */} + {isOverflowing && ( + <> + + + + )}
) }