Rebuild with n8n-style node-based interface
Clean, modern interface using React Flow: - SourceNode: Blue header, shows balance and flow rate - ThresholdNode: Purple header with min/max slider controls - RecipientNode: Shows funding progress with visual bar - Animated edges showing flow direction - Light theme matching n8n aesthetic - Simulation toggle (pause/play) - Legend panel explaining node types - Zustand for state management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f613f9f873
commit
8e43f9e383
|
|
@ -3,8 +3,8 @@
|
|||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-rgb: 2, 6, 23;
|
||||
--foreground-rgb: 30, 41, 59;
|
||||
--background-rgb: 248, 250, 252;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -12,6 +12,72 @@ body {
|
|||
background: rgb(var(--background-rgb));
|
||||
}
|
||||
|
||||
/* React Flow Customization - n8n style */
|
||||
.react-flow__node {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.react-flow__node:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.react-flow__node.selected {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.react-flow__controls-button svg {
|
||||
fill: #64748b;
|
||||
}
|
||||
|
||||
/* Range input styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
margin-top: -6px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
input[type="range"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
|
@ -19,14 +85,14 @@ body {
|
|||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
|
|
|||
148
app/page.tsx
148
app/page.tsx
|
|
@ -1,155 +1,23 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const FundingFunnel = dynamic(() => import('@/components/FundingFunnel'), {
|
||||
const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="w-[500px] h-[600px] flex items-center justify-center bg-slate-900 rounded-2xl">
|
||||
<div className="text-slate-400 animate-pulse">Loading visualization...</div>
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-slate-600">Loading flow editor...</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
export default function Home() {
|
||||
const [view, setView] = useState<'single' | 'multi'>('single')
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-8">
|
||||
{/* Header */}
|
||||
<header className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500 bg-clip-text text-transparent">
|
||||
Threshold-Based Flow Funding
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2">
|
||||
Interactive visualization of funding flows with minimum and maximum thresholds
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setView('single')}
|
||||
className={`px-4 py-2 rounded-lg text-sm transition ${
|
||||
view === 'single'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
Single Funnel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('multi')}
|
||||
className={`px-4 py-2 rounded-lg text-sm transition ${
|
||||
view === 'multi'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
Multi-Funnel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mb-8 flex items-center gap-8 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-rose-500/30 border border-rose-500" />
|
||||
<span className="text-slate-400">Critical Zone (below min)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-emerald-500/30 border border-emerald-500" />
|
||||
<span className="text-slate-400">Healthy Zone (min to max)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-amber-500/30 border border-amber-500" />
|
||||
<span className="text-slate-400">Overflow Zone (above max)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Funnel Visualizations */}
|
||||
{view === 'single' ? (
|
||||
<div className="flex justify-center">
|
||||
<FundingFunnel
|
||||
name="Community Treasury"
|
||||
currentBalance={35000}
|
||||
minThreshold={20000}
|
||||
maxThreshold={80000}
|
||||
inflowRate={500}
|
||||
outflowRate={300}
|
||||
maxCapacity={100000}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8 justify-items-center">
|
||||
<FundingFunnel
|
||||
name="Public Goods Fund"
|
||||
currentBalance={45000}
|
||||
minThreshold={25000}
|
||||
maxThreshold={75000}
|
||||
inflowRate={400}
|
||||
outflowRate={350}
|
||||
maxCapacity={100000}
|
||||
/>
|
||||
<FundingFunnel
|
||||
name="Research Grant Pool"
|
||||
currentBalance={12000}
|
||||
minThreshold={30000}
|
||||
maxThreshold={60000}
|
||||
inflowRate={200}
|
||||
outflowRate={150}
|
||||
maxCapacity={80000}
|
||||
/>
|
||||
<FundingFunnel
|
||||
name="Emergency Reserve"
|
||||
currentBalance={85000}
|
||||
minThreshold={50000}
|
||||
maxThreshold={70000}
|
||||
inflowRate={300}
|
||||
outflowRate={100}
|
||||
maxCapacity={100000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-12 max-w-2xl mx-auto">
|
||||
<div className="bg-slate-800/50 rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">How It Works</h3>
|
||||
<ul className="space-y-3 text-slate-300">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-rose-400 font-bold">1.</span>
|
||||
<span>
|
||||
<strong className="text-rose-400">Minimum Threshold</strong> — Below this level,
|
||||
the funnel narrows, restricting outflow. Funds are conserved until the minimum is reached.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-emerald-400 font-bold">2.</span>
|
||||
<span>
|
||||
<strong className="text-emerald-400">Healthy Range</strong> — Between min and max,
|
||||
the funnel has straight walls. Normal operations with balanced in/out flows.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-amber-400 font-bold">3.</span>
|
||||
<span>
|
||||
<strong className="text-amber-400">Maximum Threshold</strong> — Above this level,
|
||||
excess funds overflow and can be redistributed to other pools or purposes.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 font-bold">↕</span>
|
||||
<span>
|
||||
<strong className="text-purple-400">Drag the threshold lines</strong> to adjust
|
||||
minimum and maximum levels interactively.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<main className="h-screen w-screen">
|
||||
<FlowCanvas />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
Connection,
|
||||
MarkerType,
|
||||
Panel,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
import SourceNode from './nodes/SourceNode'
|
||||
import ThresholdNode from './nodes/ThresholdNode'
|
||||
import RecipientNode from './nodes/RecipientNode'
|
||||
import type { FlowNode, FlowEdge, SourceNodeData, ThresholdNodeData, RecipientNodeData } from '@/lib/types'
|
||||
|
||||
const nodeTypes = {
|
||||
source: SourceNode,
|
||||
threshold: ThresholdNode,
|
||||
recipient: RecipientNode,
|
||||
}
|
||||
|
||||
const initialNodes: FlowNode[] = [
|
||||
{
|
||||
id: 'source-1',
|
||||
type: 'source',
|
||||
position: { x: 50, y: 150 },
|
||||
data: {
|
||||
label: 'Treasury',
|
||||
balance: 75000,
|
||||
flowRate: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'threshold-1',
|
||||
type: 'threshold',
|
||||
position: { x: 350, y: 50 },
|
||||
data: {
|
||||
label: 'Public Goods Gate',
|
||||
minThreshold: 10000,
|
||||
maxThreshold: 50000,
|
||||
currentValue: 32000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'threshold-2',
|
||||
type: 'threshold',
|
||||
position: { x: 350, y: 350 },
|
||||
data: {
|
||||
label: 'Research Gate',
|
||||
minThreshold: 5000,
|
||||
maxThreshold: 30000,
|
||||
currentValue: 8500,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recipient-1',
|
||||
type: 'recipient',
|
||||
position: { x: 700, y: 50 },
|
||||
data: {
|
||||
label: 'Project Alpha',
|
||||
received: 24500,
|
||||
target: 30000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recipient-2',
|
||||
type: 'recipient',
|
||||
position: { x: 700, y: 250 },
|
||||
data: {
|
||||
label: 'Project Beta',
|
||||
received: 8000,
|
||||
target: 25000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recipient-3',
|
||||
type: 'recipient',
|
||||
position: { x: 700, y: 450 },
|
||||
data: {
|
||||
label: 'Research Fund',
|
||||
received: 15000,
|
||||
target: 15000,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const initialEdges: FlowEdge[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'source-1',
|
||||
target: 'threshold-1',
|
||||
animated: true,
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' },
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'source-1',
|
||||
target: 'threshold-2',
|
||||
animated: true,
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#3b82f6' },
|
||||
},
|
||||
{
|
||||
id: 'e3',
|
||||
source: 'threshold-1',
|
||||
target: 'recipient-1',
|
||||
animated: true,
|
||||
style: { stroke: '#a855f7', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' },
|
||||
},
|
||||
{
|
||||
id: 'e4',
|
||||
source: 'threshold-1',
|
||||
target: 'recipient-2',
|
||||
animated: true,
|
||||
style: { stroke: '#a855f7', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' },
|
||||
},
|
||||
{
|
||||
id: 'e5',
|
||||
source: 'threshold-2',
|
||||
target: 'recipient-3',
|
||||
animated: true,
|
||||
style: { stroke: '#a855f7', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' },
|
||||
},
|
||||
]
|
||||
|
||||
export default function FlowCanvas() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
||||
const [isSimulating, setIsSimulating] = useState(true)
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) =>
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
{
|
||||
...params,
|
||||
animated: true,
|
||||
style: { stroke: '#64748b', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#64748b' },
|
||||
},
|
||||
eds
|
||||
)
|
||||
),
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
// Simulation effect
|
||||
useEffect(() => {
|
||||
if (!isSimulating) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) => {
|
||||
if (node.type === 'source') {
|
||||
const data = node.data as SourceNodeData
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
balance: Math.max(0, data.balance - data.flowRate / 3600),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (node.type === 'threshold') {
|
||||
const data = node.data as ThresholdNodeData
|
||||
const change = (Math.random() - 0.3) * 100
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
currentValue: Math.max(0, Math.min(100000, data.currentValue + change)),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (node.type === 'recipient') {
|
||||
const data = node.data as RecipientNodeData
|
||||
if (data.received < data.target) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
received: Math.min(data.target, data.received + Math.random() * 50),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
)
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isSimulating, setNodes])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
className="bg-slate-50"
|
||||
defaultEdgeOptions={{
|
||||
animated: true,
|
||||
style: { strokeWidth: 2 },
|
||||
}}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
||||
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
|
||||
|
||||
{/* Top Panel - Title and Controls */}
|
||||
<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 nodes to rearrange • Connect nodes to create flows</p>
|
||||
</Panel>
|
||||
|
||||
{/* Simulation Toggle */}
|
||||
<Panel position="top-right" className="m-4">
|
||||
<button
|
||||
onClick={() => setIsSimulating(!isSimulating)}
|
||||
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all ${
|
||||
isSimulating
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{isSimulating ? '⏸ Pause' : '▶ Start'} Simulation
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
{/* 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">Node Types</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-r from-blue-500 to-blue-600" />
|
||||
<span className="text-sm text-slate-600">Source (Funding Origin)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-r from-purple-500 to-purple-600" />
|
||||
<span className="text-sm text-slate-600">Threshold Gate (Min/Max)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-r from-emerald-500 to-emerald-600" />
|
||||
<span className="text-sm text-slate-600">Recipient (Funded)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-r from-slate-500 to-slate-600" />
|
||||
<span className="text-sm text-slate-600">Recipient (Pending)</span>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,576 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface FundingFunnelProps {
|
||||
name: string
|
||||
currentBalance: number
|
||||
minThreshold: number
|
||||
maxThreshold: number
|
||||
inflowRate: number // per hour
|
||||
outflowRate: number // per hour
|
||||
maxCapacity?: number
|
||||
onMinThresholdChange?: (value: number) => void
|
||||
onMaxThresholdChange?: (value: number) => void
|
||||
}
|
||||
|
||||
export default function FundingFunnel({
|
||||
name,
|
||||
currentBalance,
|
||||
minThreshold: initialMin,
|
||||
maxThreshold: initialMax,
|
||||
inflowRate,
|
||||
outflowRate,
|
||||
maxCapacity = 100000,
|
||||
onMinThresholdChange,
|
||||
onMaxThresholdChange,
|
||||
}: FundingFunnelProps) {
|
||||
const [minThreshold, setMinThreshold] = useState(initialMin)
|
||||
const [maxThreshold, setMaxThreshold] = useState(initialMax)
|
||||
const [balance, setBalance] = useState(currentBalance)
|
||||
const [isDraggingMin, setIsDraggingMin] = useState(false)
|
||||
const [isDraggingMax, setIsDraggingMax] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Funnel dimensions
|
||||
const width = 300
|
||||
const height = 500
|
||||
const funnelTopWidth = 280
|
||||
const funnelNarrowWidth = 80
|
||||
const padding = 10
|
||||
|
||||
// Calculate Y positions for thresholds (inverted - 0 is at bottom)
|
||||
const minY = height - (minThreshold / maxCapacity) * height
|
||||
const maxY = height - (maxThreshold / maxCapacity) * height
|
||||
const balanceY = height - (balance / maxCapacity) * height
|
||||
|
||||
// Funnel shape points
|
||||
const funnelPath = `
|
||||
M ${padding} ${maxY}
|
||||
L ${padding} ${minY}
|
||||
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${width - padding} ${minY}
|
||||
L ${width - padding} ${maxY}
|
||||
L ${padding} ${maxY}
|
||||
`
|
||||
|
||||
// Overflow zone (above max)
|
||||
const overflowPath = `
|
||||
M ${padding} ${padding}
|
||||
L ${padding} ${maxY}
|
||||
L ${width - padding} ${maxY}
|
||||
L ${width - padding} ${padding}
|
||||
Z
|
||||
`
|
||||
|
||||
// Calculate fill path based on current balance
|
||||
const getFillPath = () => {
|
||||
if (balance <= 0) return ''
|
||||
|
||||
const fillY = Math.max(balanceY, padding)
|
||||
|
||||
if (balance >= maxThreshold) {
|
||||
// Overflow zone - straight walls above max
|
||||
const overflowY = Math.max(fillY, padding)
|
||||
return `
|
||||
M ${padding} ${minY}
|
||||
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${width - padding} ${minY}
|
||||
L ${width - padding} ${overflowY}
|
||||
L ${padding} ${overflowY}
|
||||
Z
|
||||
`
|
||||
} else if (balance >= minThreshold) {
|
||||
// Between min and max - straight walls
|
||||
return `
|
||||
M ${padding} ${minY}
|
||||
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${width - padding} ${minY}
|
||||
L ${width - padding} ${fillY}
|
||||
L ${padding} ${fillY}
|
||||
Z
|
||||
`
|
||||
} else {
|
||||
// Below min - in the funnel narrowing section
|
||||
const ratio = balance / minThreshold
|
||||
const bottomWidth = funnelNarrowWidth
|
||||
const topWidth = funnelTopWidth - 2 * padding
|
||||
const currentWidth = bottomWidth + (topWidth - bottomWidth) * ratio
|
||||
const leftX = (width - currentWidth) / 2
|
||||
const rightX = (width + currentWidth) / 2
|
||||
|
||||
return `
|
||||
M ${(width - funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${rightX} ${fillY}
|
||||
L ${leftX} ${fillY}
|
||||
Z
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate balance changes
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setBalance((prev) => {
|
||||
const netFlow = (inflowRate - outflowRate) / 3600 // per second
|
||||
const newBalance = prev + netFlow
|
||||
return Math.max(0, Math.min(maxCapacity * 1.2, newBalance))
|
||||
})
|
||||
}, 100)
|
||||
return () => clearInterval(interval)
|
||||
}, [inflowRate, outflowRate, maxCapacity])
|
||||
|
||||
// Handle threshold dragging
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const y = e.clientY - rect.top
|
||||
const value = Math.max(0, Math.min(maxCapacity, ((height - y) / height) * maxCapacity))
|
||||
|
||||
if (isDraggingMin) {
|
||||
const newMin = Math.min(value, maxThreshold - 1000)
|
||||
setMinThreshold(Math.max(0, newMin))
|
||||
onMinThresholdChange?.(Math.max(0, newMin))
|
||||
} else if (isDraggingMax) {
|
||||
const newMax = Math.max(value, minThreshold + 1000)
|
||||
setMaxThreshold(Math.min(maxCapacity, newMax))
|
||||
onMaxThresholdChange?.(Math.min(maxCapacity, newMax))
|
||||
}
|
||||
},
|
||||
[isDraggingMin, isDraggingMax, maxThreshold, minThreshold, maxCapacity, onMinThresholdChange, onMaxThresholdChange]
|
||||
)
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDraggingMin(false)
|
||||
setIsDraggingMax(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingMin || isDraggingMax) {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isDraggingMin, isDraggingMax, handleMouseMove, handleMouseUp])
|
||||
|
||||
// Determine zone status
|
||||
const getZoneStatus = () => {
|
||||
if (balance < minThreshold) return { zone: 'critical', color: '#F43F5E', label: 'Below Minimum' }
|
||||
if (balance > maxThreshold) return { zone: 'overflow', color: '#F59E0B', label: 'Overflow' }
|
||||
return { zone: 'healthy', color: '#10B981', label: 'Healthy Range' }
|
||||
}
|
||||
|
||||
const status = getZoneStatus()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="text-xl font-bold text-white mb-2">{name}</h3>
|
||||
|
||||
<div className="flex gap-8">
|
||||
{/* Main Funnel Visualization */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative bg-slate-900 rounded-2xl p-4 border border-slate-700"
|
||||
style={{ width: width + 80, height: height + 40 }}
|
||||
>
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<defs>
|
||||
{/* Gradient for the fill */}
|
||||
<linearGradient id={`fill-gradient-${name}`} x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stopColor="#3B82F6" stopOpacity="0.9" />
|
||||
<stop offset="50%" stopColor="#8B5CF6" stopOpacity="0.8" />
|
||||
<stop offset="100%" stopColor="#EC4899" stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow filter */}
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Wave pattern for liquid effect */}
|
||||
<pattern id="wave" x="0" y="0" width="20" height="10" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
d="M0 5 Q5 0 10 5 T20 5"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Background zones */}
|
||||
{/* Overflow zone (above max) */}
|
||||
<rect
|
||||
x={padding}
|
||||
y={padding}
|
||||
width={width - 2 * padding}
|
||||
height={maxY - padding}
|
||||
fill="#F59E0B"
|
||||
fillOpacity="0.1"
|
||||
stroke="#F59E0B"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
|
||||
{/* Healthy zone (between min and max) - straight walls */}
|
||||
<rect
|
||||
x={padding}
|
||||
y={maxY}
|
||||
width={width - 2 * padding}
|
||||
height={minY - maxY}
|
||||
fill="#10B981"
|
||||
fillOpacity="0.1"
|
||||
/>
|
||||
|
||||
{/* Funnel zone (below min) */}
|
||||
<path
|
||||
d={`
|
||||
M ${padding} ${minY}
|
||||
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${width - padding} ${minY}
|
||||
Z
|
||||
`}
|
||||
fill="#F43F5E"
|
||||
fillOpacity="0.1"
|
||||
/>
|
||||
|
||||
{/* Funnel outline */}
|
||||
<path
|
||||
d={`
|
||||
M ${padding} ${padding}
|
||||
L ${padding} ${minY}
|
||||
L ${(width - funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${(width + funnelNarrowWidth) / 2} ${height - padding}
|
||||
L ${width - padding} ${minY}
|
||||
L ${width - padding} ${padding}
|
||||
`}
|
||||
fill="none"
|
||||
stroke="#475569"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Fill (current balance) */}
|
||||
<path
|
||||
d={getFillPath()}
|
||||
fill={`url(#fill-gradient-${name})`}
|
||||
filter="url(#glow)"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.8;1;0.8"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
|
||||
{/* Animated inflow particles */}
|
||||
{inflowRate > 0 && (
|
||||
<>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<circle
|
||||
key={`inflow-${i}`}
|
||||
r="4"
|
||||
fill="#3B82F6"
|
||||
opacity="0.8"
|
||||
>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values={`-10;${balanceY}`}
|
||||
dur={`${1 + i * 0.2}s`}
|
||||
repeatCount="indefinite"
|
||||
begin={`${i * 0.2}s`}
|
||||
/>
|
||||
<animate
|
||||
attributeName="cx"
|
||||
values={`${width / 2 - 20 + i * 10};${width / 2 - 10 + i * 5}`}
|
||||
dur={`${1 + i * 0.2}s`}
|
||||
repeatCount="indefinite"
|
||||
begin={`${i * 0.2}s`}
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.8;0.8;0"
|
||||
dur={`${1 + i * 0.2}s`}
|
||||
repeatCount="indefinite"
|
||||
begin={`${i * 0.2}s`}
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Animated outflow particles */}
|
||||
{outflowRate > 0 && balance > 0 && (
|
||||
<>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<circle
|
||||
key={`outflow-${i}`}
|
||||
r="3"
|
||||
fill="#EC4899"
|
||||
opacity="0.8"
|
||||
>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values={`${height - padding};${height + 30}`}
|
||||
dur={`${0.8 + i * 0.15}s`}
|
||||
repeatCount="indefinite"
|
||||
begin={`${i * 0.25}s`}
|
||||
/>
|
||||
<animate
|
||||
attributeName="cx"
|
||||
values={`${width / 2 - 10 + i * 10};${width / 2 - 15 + i * 15}`}
|
||||
dur={`${0.8 + i * 0.15}s`}
|
||||
repeatCount="indefinite"
|
||||
begin={`${i * 0.25}s`}
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.8;0.6;0"
|
||||
dur={`${0.8 + i * 0.15}s`}
|
||||
repeatCount="indefinite"
|
||||
begin={`${i * 0.25}s`}
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Max threshold line (draggable) */}
|
||||
<g
|
||||
className="cursor-ns-resize"
|
||||
onMouseDown={() => setIsDraggingMax(true)}
|
||||
>
|
||||
<line
|
||||
x1={0}
|
||||
y1={maxY}
|
||||
x2={width}
|
||||
y2={maxY}
|
||||
stroke="#F59E0B"
|
||||
strokeWidth={isDraggingMax ? 4 : 2}
|
||||
strokeDasharray="8 4"
|
||||
/>
|
||||
<rect
|
||||
x={width - 8}
|
||||
y={maxY - 12}
|
||||
width={16}
|
||||
height={24}
|
||||
rx={4}
|
||||
fill="#F59E0B"
|
||||
className="cursor-ns-resize"
|
||||
/>
|
||||
<text
|
||||
x={width + 12}
|
||||
y={maxY + 5}
|
||||
fill="#F59E0B"
|
||||
fontSize="12"
|
||||
fontFamily="monospace"
|
||||
>
|
||||
MAX ${maxThreshold.toLocaleString()}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Min threshold line (draggable) */}
|
||||
<g
|
||||
className="cursor-ns-resize"
|
||||
onMouseDown={() => setIsDraggingMin(true)}
|
||||
>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={minY}
|
||||
x2={width - padding}
|
||||
y2={minY}
|
||||
stroke="#F43F5E"
|
||||
strokeWidth={isDraggingMin ? 4 : 2}
|
||||
strokeDasharray="8 4"
|
||||
/>
|
||||
<rect
|
||||
x={width - 8}
|
||||
y={minY - 12}
|
||||
width={16}
|
||||
height={24}
|
||||
rx={4}
|
||||
fill="#F43F5E"
|
||||
className="cursor-ns-resize"
|
||||
/>
|
||||
<text
|
||||
x={width + 12}
|
||||
y={minY + 5}
|
||||
fill="#F43F5E"
|
||||
fontSize="12"
|
||||
fontFamily="monospace"
|
||||
>
|
||||
MIN ${minThreshold.toLocaleString()}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Current balance indicator */}
|
||||
<g>
|
||||
<line
|
||||
x1={0}
|
||||
y1={balanceY}
|
||||
x2={padding - 2}
|
||||
y2={balanceY}
|
||||
stroke={status.color}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<polygon
|
||||
points={`${padding - 2},${balanceY - 6} ${padding - 2},${balanceY + 6} ${padding + 6},${balanceY}`}
|
||||
fill={status.color}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Zone labels */}
|
||||
<div
|
||||
className="absolute text-xs text-amber-400/60 font-medium"
|
||||
style={{ right: 8, top: maxY / 2 + 20 }}
|
||||
>
|
||||
OVERFLOW
|
||||
</div>
|
||||
<div
|
||||
className="absolute text-xs text-emerald-400/60 font-medium"
|
||||
style={{ right: 8, top: (maxY + minY) / 2 + 20 }}
|
||||
>
|
||||
HEALTHY
|
||||
</div>
|
||||
<div
|
||||
className="absolute text-xs text-rose-400/60 font-medium"
|
||||
style={{ right: 8, top: (minY + height) / 2 + 10 }}
|
||||
>
|
||||
CRITICAL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Panel */}
|
||||
<div className="flex flex-col gap-4 min-w-[200px]">
|
||||
{/* Current Balance */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||
<div className="text-sm text-slate-400 mb-1">Current Balance</div>
|
||||
<div className="text-3xl font-bold font-mono" style={{ color: status.color }}>
|
||||
${balance.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm mt-2 px-2 py-1 rounded-full inline-block"
|
||||
style={{ backgroundColor: `${status.color}20`, color: status.color }}
|
||||
>
|
||||
{status.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Rates */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||
<div className="text-sm text-slate-400 mb-3">Flow Rates</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-slate-300">Inflow</span>
|
||||
</div>
|
||||
<span className="font-mono text-blue-400">+${inflowRate}/hr</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-pink-500" />
|
||||
<span className="text-slate-300">Outflow</span>
|
||||
</div>
|
||||
<span className="font-mono text-pink-400">-${outflowRate}/hr</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-600 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-300">Net Flow</span>
|
||||
<span
|
||||
className={`font-mono font-bold ${
|
||||
inflowRate - outflowRate >= 0 ? 'text-emerald-400' : 'text-rose-400'
|
||||
}`}
|
||||
>
|
||||
{inflowRate - outflowRate >= 0 ? '+' : ''}${inflowRate - outflowRate}/hr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thresholds */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||
<div className="text-sm text-slate-400 mb-3">Thresholds</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-rose-400">Minimum</span>
|
||||
<span className="font-mono text-rose-400">${minThreshold.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Drag the red line to adjust
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-amber-400">Maximum</span>
|
||||
<span className="font-mono text-amber-400">${maxThreshold.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Drag the yellow line to adjust
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress to thresholds */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||
<div className="text-sm text-slate-400 mb-3">Progress</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-400">To Minimum</span>
|
||||
<span className="text-slate-400">
|
||||
{Math.min(100, (balance / minThreshold) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-rose-500 transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, (balance / minThreshold) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-400">To Maximum</span>
|
||||
<span className="text-slate-400">
|
||||
{Math.min(100, (balance / maxThreshold) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, (balance / maxThreshold) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import type { NodeProps } from '@xyflow/react'
|
||||
import type { RecipientNodeData } from '@/lib/types'
|
||||
|
||||
function RecipientNode({ data, selected }: NodeProps) {
|
||||
const { label, received, target } = data as RecipientNodeData
|
||||
const progress = Math.min(100, (received / target) * 100)
|
||||
const isFunded = received >= target
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-white rounded-lg shadow-lg border-2 min-w-[200px]
|
||||
transition-all duration-200
|
||||
${selected ? 'border-emerald-500 shadow-emerald-100' : 'border-slate-200'}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!w-3 !h-3 !bg-slate-400 !border-2 !border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className={`px-4 py-2 rounded-t-md bg-gradient-to-r ${
|
||||
isFunded ? 'from-emerald-500 to-emerald-600' : 'from-slate-500 to-slate-600'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-white/20 rounded flex items-center justify-center">
|
||||
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-white font-medium text-sm">{label}</span>
|
||||
{isFunded && (
|
||||
<svg className="w-4 h-4 text-white ml-auto" 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>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-500 text-xs uppercase tracking-wide">Received</span>
|
||||
<span className="font-mono font-semibold text-slate-800">
|
||||
${received.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-slate-500 text-xs">Progress</span>
|
||||
<span className="text-xs font-medium text-slate-600">{progress.toFixed(0)}%</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' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 border-t border-slate-100">
|
||||
<span className="text-slate-500 text-xs uppercase tracking-wide">Target</span>
|
||||
<span className="font-mono text-slate-600">
|
||||
${target.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(RecipientNode)
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import type { NodeProps } from '@xyflow/react'
|
||||
import type { SourceNodeData } from '@/lib/types'
|
||||
|
||||
function SourceNode({ data, selected }: NodeProps) {
|
||||
const { label, balance, flowRate } = data as SourceNodeData
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-white rounded-lg shadow-lg border-2 min-w-[200px]
|
||||
transition-all duration-200
|
||||
${selected ? 'border-blue-500 shadow-blue-100' : 'border-slate-200'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-2 rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-white/20 rounded flex items-center justify-center">
|
||||
<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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-white font-medium text-sm">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-500 text-xs uppercase tracking-wide">Balance</span>
|
||||
<span className="font-mono font-semibold text-slate-800">
|
||||
${balance.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-500 text-xs uppercase tracking-wide">Flow Rate</span>
|
||||
<span className="font-mono text-blue-600">
|
||||
${flowRate}/hr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-white"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SourceNode)
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
'use client'
|
||||
|
||||
import { memo, useState, useCallback } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import type { NodeProps } from '@xyflow/react'
|
||||
import type { ThresholdNodeData } from '@/lib/types'
|
||||
|
||||
function ThresholdNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as ThresholdNodeData
|
||||
const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold)
|
||||
const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold)
|
||||
const currentValue = nodeData.currentValue
|
||||
|
||||
// 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 status = getStatus()
|
||||
const fillPercent = Math.min(100, Math.max(0, ((currentValue - minThreshold) / (maxThreshold - minThreshold)) * 100))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-white rounded-lg shadow-lg border-2 min-w-[240px]
|
||||
transition-all duration-200
|
||||
${selected ? 'border-purple-500 shadow-purple-100' : 'border-slate-200'}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!w-3 !h-3 !bg-slate-400 !border-2 !border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-purple-500 to-purple-600 px-4 py-2 rounded-t-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-white/20 rounded flex items-center justify-center">
|
||||
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-white font-medium text-sm">{nodeData.label}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium text-white ${status.bg}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Current Value Display */}
|
||||
<div className="text-center">
|
||||
<span className="text-2xl font-bold font-mono text-slate-800">
|
||||
${currentValue.toLocaleString()}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500 mt-1">Current Value</p>
|
||||
</div>
|
||||
|
||||
{/* Visual Bar */}
|
||||
<div className="relative">
|
||||
<div className="h-8 bg-slate-100 rounded-lg overflow-hidden relative">
|
||||
{/* Fill */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 h-full transition-all duration-500 ${
|
||||
currentValue < minThreshold ? 'bg-red-400' :
|
||||
currentValue > maxThreshold ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
}`}
|
||||
style={{
|
||||
width: currentValue < minThreshold
|
||||
? `${(currentValue / minThreshold) * 33}%`
|
||||
: currentValue > maxThreshold
|
||||
? '100%'
|
||||
: `${33 + fillPercent * 0.67}%`
|
||||
}}
|
||||
/>
|
||||
{/* Min marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-red-500"
|
||||
style={{ left: '33%' }}
|
||||
/>
|
||||
{/* Max marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-amber-500"
|
||||
style={{ left: '100%', transform: 'translateX(-2px)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-slate-500">
|
||||
<span>$0</span>
|
||||
<span className="text-red-500">Min</span>
|
||||
<span className="text-amber-500">Max</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threshold Controls */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Min Threshold</label>
|
||||
<span className="font-mono text-sm text-red-600">${minThreshold.toLocaleString()}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxThreshold - 1000}
|
||||
value={minThreshold}
|
||||
onChange={(e) => setMinThreshold(Number(e.target.value))}
|
||||
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-red-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Max Threshold</label>
|
||||
<span className="font-mono text-sm text-amber-600">${maxThreshold.toLocaleString()}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={minThreshold + 1000}
|
||||
max="100000"
|
||||
value={maxThreshold}
|
||||
onChange={(e) => setMaxThreshold(Number(e.target.value))}
|
||||
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-white"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ThresholdNode)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { Node, Edge } from '@xyflow/react'
|
||||
|
||||
export interface SourceNodeData {
|
||||
label: string
|
||||
balance: number
|
||||
flowRate: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ThresholdNodeData {
|
||||
label: string
|
||||
minThreshold: number
|
||||
maxThreshold: number
|
||||
currentValue: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface RecipientNodeData {
|
||||
label: string
|
||||
received: number
|
||||
target: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type FlowNode = Node<SourceNodeData | ThresholdNodeData | RecipientNodeData>
|
||||
export type FlowEdge = Edge<{ animated?: boolean }>
|
||||
|
|
@ -11,7 +11,9 @@
|
|||
"dependencies": {
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react-dom": "^18.3.0",
|
||||
"@xyflow/react": "^12.0.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
|
|
|
|||
187
pnpm-lock.yaml
187
pnpm-lock.yaml
|
|
@ -8,6 +8,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@xyflow/react':
|
||||
specifier: ^12.0.0
|
||||
version: 12.10.0(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next:
|
||||
specifier: ^14.2.0
|
||||
version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -17,6 +20,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
zustand:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
|
|
@ -134,6 +140,24 @@ packages:
|
|||
'@swc/helpers@0.5.5':
|
||||
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-selection@3.0.11':
|
||||
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||
|
||||
'@types/d3-transition@3.0.9':
|
||||
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||
|
||||
'@types/node@20.19.30':
|
||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||
|
||||
|
|
@ -148,6 +172,15 @@ packages:
|
|||
'@types/react@18.3.27':
|
||||
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
|
||||
|
||||
'@xyflow/react@12.10.0':
|
||||
resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
|
||||
'@xyflow/system@0.0.74':
|
||||
resolution: {integrity: sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
|
|
@ -197,6 +230,9 @@ packages:
|
|||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
classcat@5.0.5:
|
||||
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
|
|
@ -212,6 +248,44 @@ packages:
|
|||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
|
|
@ -535,9 +609,29 @@ packages:
|
|||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
zustand@4.5.7:
|
||||
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
|
@ -604,6 +698,27 @@ snapshots:
|
|||
'@swc/counter': 0.1.3
|
||||
tslib: 2.8.1
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-selection@3.0.11': {}
|
||||
|
||||
'@types/d3-transition@3.0.9':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
dependencies:
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/node@20.19.30':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
|
@ -619,6 +734,29 @@ snapshots:
|
|||
'@types/prop-types': 15.7.15
|
||||
csstype: 3.2.3
|
||||
|
||||
'@xyflow/react@12.10.0(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@xyflow/system': 0.0.74
|
||||
classcat: 5.0.5
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
zustand: 4.5.7(@types/react@18.3.27)(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
|
||||
'@xyflow/system@0.0.74':
|
||||
dependencies:
|
||||
'@types/d3-drag': 3.0.7
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
|
|
@ -673,6 +811,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
|
@ -681,6 +821,42 @@ snapshots:
|
|||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
|
@ -977,4 +1153,15 @@ snapshots:
|
|||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
use-sync-external-store@1.6.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
zustand@4.5.7(@types/react@18.3.27)(react@18.3.1):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
react: 18.3.1
|
||||
|
|
|
|||
Loading…
Reference in New Issue