diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..beeb55c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +*.md +.env* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b99fe11 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM node:20-alpine AS base + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Dependencies stage +FROM base AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install + +# Build stage +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +# Production stage +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/app/layout.tsx b/app/layout.tsx index a36cde0..04bcb72 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,35 +1,39 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; +import type { Metadata } from 'next' +import localFont from 'next/font/local' +import './globals.css' const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", - weight: "100 900", -}); + src: './fonts/GeistVF.woff', + variable: '--font-geist-sans', + weight: '100 900', +}) const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", - weight: "100 900", -}); + src: './fonts/GeistMonoVF.woff', + variable: '--font-geist-mono', + weight: '100 900', +}) export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; + title: 'rFunds - Threshold-Based Flow Funding', + description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms. Create interconnected funding funnels with overflow routing and outcome tracking.', + openGraph: { + title: 'rFunds - Threshold-Based Flow Funding', + description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms.', + type: 'website', + url: 'https://rfunds.online', + }, +} export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { return ( - + {children} - ); + ) } diff --git a/app/page.tsx b/app/page.tsx index 433c8aa..f3308e0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,101 +1,175 @@ -import Image from "next/image"; +import Link from 'next/link' export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - +
+ {/* Nav */} +
-
- ); + ) } diff --git a/app/space/page.tsx b/app/space/page.tsx new file mode 100644 index 0000000..8a242ba --- /dev/null +++ b/app/space/page.tsx @@ -0,0 +1,237 @@ +'use client' + +import dynamic from 'next/dynamic' +import Link from 'next/link' +import { useState, useCallback, useEffect, useRef } from 'react' +import { starterNodes } from '@/lib/presets' +import { serializeState, deserializeState, saveToLocal, loadFromLocal, listSavedSpaces, deleteFromLocal } from '@/lib/state' +import type { FlowNode, SpaceConfig } from '@/lib/types' + +const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), { + ssr: false, + loading: () => ( +
+
+
+ Loading flow editor... +
+
+ ), +}) + +export default function SpacePage() { + const [currentNodes, setCurrentNodes] = useState(starterNodes) + const [spaceName, setSpaceName] = useState('') + const [showSaveDialog, setShowSaveDialog] = useState(false) + const [showLoadDialog, setShowLoadDialog] = useState(false) + const [savedSpaces, setSavedSpaces] = useState([]) + const [copied, setCopied] = useState(false) + const [loaded, setLoaded] = useState(false) + const nodesRef = useRef(starterNodes) + + // Load from URL hash on mount + useEffect(() => { + if (typeof window === 'undefined') return + const hash = window.location.hash.slice(1) + if (hash.startsWith('s=')) { + const compressed = hash.slice(2) + const state = deserializeState(compressed) + if (state) { + setCurrentNodes(state.nodes) + nodesRef.current = state.nodes + } + } + setLoaded(true) + }, []) + + const handleNodesChange = useCallback((nodes: FlowNode[]) => { + nodesRef.current = nodes + }, []) + + const handleShare = useCallback(() => { + const compressed = serializeState(nodesRef.current) + const url = `${window.location.origin}/space#s=${compressed}` + navigator.clipboard.writeText(url).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + }, []) + + const handleSave = useCallback(() => { + if (!spaceName.trim()) return + saveToLocal(spaceName.trim(), nodesRef.current) + setShowSaveDialog(false) + setSpaceName('') + }, [spaceName]) + + const handleLoadOpen = useCallback(() => { + setSavedSpaces(listSavedSpaces()) + setShowLoadDialog(true) + }, []) + + const handleLoadSpace = useCallback((config: SpaceConfig) => { + setCurrentNodes(config.nodes) + nodesRef.current = config.nodes + setShowLoadDialog(false) + }, []) + + const handleDeleteSpace = useCallback((name: string) => { + deleteFromLocal(name) + setSavedSpaces(listSavedSpaces()) + }, []) + + const handleReset = useCallback(() => { + if (confirm('Reset canvas to a single empty funnel? This cannot be undone.')) { + setCurrentNodes([...starterNodes]) + nodesRef.current = [...starterNodes] + // Clear URL hash + window.history.replaceState(null, '', window.location.pathname) + } + }, []) + + if (!loaded) { + return ( +
+
+
+ Loading... +
+
+ ) + } + + return ( +
+ {/* Toolbar */} +
+
+ +
+ rF +
+ rFunds + + | + Your Space +
+ +
+ + + + +
+
+ + {/* Canvas */} +
+ n.id))} + initialNodes={currentNodes} + mode="space" + onNodesChange={handleNodesChange} + /> +
+ + {/* Save Dialog */} + {showSaveDialog && ( +
setShowSaveDialog(false)}> +
e.stopPropagation()}> +

Save Space

+ setSpaceName(e.target.value)} + placeholder="Space name..." + className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-4 text-slate-800" + autoFocus + onKeyDown={e => e.key === 'Enter' && handleSave()} + /> +
+ + +
+
+
+ )} + + {/* Load Dialog */} + {showLoadDialog && ( +
setShowLoadDialog(false)}> +
e.stopPropagation()}> +

Load Space

+ {savedSpaces.length === 0 ? ( +

No saved spaces yet.

+ ) : ( +
+ {savedSpaces.map((space) => ( +
+ + +
+ ))} +
+ )} + +
+
+ )} +
+ ) +} diff --git a/app/tbff/page.tsx b/app/tbff/page.tsx new file mode 100644 index 0000000..2af50d9 --- /dev/null +++ b/app/tbff/page.tsx @@ -0,0 +1,48 @@ +'use client' + +import dynamic from 'next/dynamic' +import Link from 'next/link' +import { demoNodes } from '@/lib/presets' + +const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), { + ssr: false, + loading: () => ( +
+
+
+ Loading flow editor... +
+
+ ), +}) + +export default function TbffDemo() { + return ( +
+ {/* Demo Banner */} +
+
+ +
+ rF +
+ rFunds + + | + Interactive Demo +
+ + Create Your Own + +
+ + {/* Canvas */} +
+ +
+
+ ) +} diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx new file mode 100644 index 0000000..5a387fa --- /dev/null +++ b/components/FlowCanvas.tsx @@ -0,0 +1,532 @@ +'use client' + +import { useCallback, useState, useEffect, useMemo, useRef } from 'react' +import { + ReactFlow, + Controls, + Background, + BackgroundVariant, + useNodesState, + useEdgesState, + Connection, + MarkerType, + Panel, + useReactFlow, + ReactFlowProvider, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' + +import FunnelNode from './nodes/FunnelNode' +import OutcomeNode from './nodes/OutcomeNode' +import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types' +import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets' + +const nodeTypes = { + funnel: FunnelNode, + outcome: OutcomeNode, +} + +// Generate edges with proportional Sankey-style widths +function generateEdges(nodes: FlowNode[]): FlowEdge[] { + const edges: FlowEdge[] = [] + + const flowValues: number[] = [] + nodes.forEach((node) => { + if (node.type !== 'funnel') return + const data = node.data as FunnelNodeData + const rate = data.inflowRate || 1 + + data.overflowAllocations?.forEach((alloc) => { + flowValues.push((alloc.percentage / 100) * rate) + }) + data.spendingAllocations?.forEach((alloc) => { + flowValues.push((alloc.percentage / 100) * rate) + }) + }) + + const maxFlow = Math.max(...flowValues, 1) + const MIN_WIDTH = 3 + const MAX_WIDTH = 24 + + nodes.forEach((node) => { + if (node.type !== 'funnel') return + const data = node.data as FunnelNodeData + const sourceX = node.position.x + const rate = data.inflowRate || 1 + + data.overflowAllocations?.forEach((alloc) => { + const flowValue = (alloc.percentage / 100) * rate + const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH) + + 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({ + id: `outflow-${node.id}-${alloc.targetId}`, + source: node.id, + target: alloc.targetId, + sourceHandle, + targetHandle: undefined, + animated: true, + style: { + stroke: alloc.color, + strokeWidth, + opacity: 0.7, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: alloc.color, + width: 16, + height: 16, + }, + label: `${alloc.percentage}%`, + labelStyle: { + fontSize: 11, + fontWeight: 600, + fill: alloc.color, + }, + labelBgStyle: { + fill: 'white', + fillOpacity: 0.9, + }, + labelBgPadding: [4, 2] as [number, number], + labelBgBorderRadius: 4, + data: { + allocation: alloc.percentage, + color: alloc.color, + edgeType: 'overflow' as const, + }, + type: 'smoothstep', + }) + }) + + data.spendingAllocations?.forEach((alloc) => { + const flowValue = (alloc.percentage / 100) * rate + const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH) + + edges.push({ + id: `spending-${node.id}-${alloc.targetId}`, + source: node.id, + target: alloc.targetId, + sourceHandle: 'spending-out', + animated: true, + style: { + stroke: alloc.color, + strokeWidth, + opacity: 0.8, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: alloc.color, + width: 16, + height: 16, + }, + label: `${alloc.percentage}%`, + labelStyle: { + fontSize: 11, + fontWeight: 600, + fill: alloc.color, + }, + labelBgStyle: { + fill: 'white', + fillOpacity: 0.9, + }, + labelBgPadding: [4, 2] as [number, number], + labelBgBorderRadius: 4, + data: { + allocation: alloc.percentage, + color: alloc.color, + edgeType: 'spending' as const, + }, + type: 'smoothstep', + }) + }) + }) + + return edges +} + +interface FlowCanvasInnerProps { + initialNodes: FlowNode[] + mode: 'demo' | 'space' + onNodesChange?: (nodes: FlowNode[]) => void +} + +function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowCanvasInnerProps) { + const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes) + const [edges, setEdges, onEdgesChange] = useEdgesState(generateEdges(initNodes)) + const [isSimulating, setIsSimulating] = useState(mode === 'demo') + const edgesRef = useRef(edges) + edgesRef.current = edges + const { screenToFlowPosition } = useReactFlow() + + // Notify parent of node changes for save/share + const nodesRef = useRef(nodes) + nodesRef.current = nodes + useEffect(() => { + if (onNodesChange) { + onNodesChange(nodes as FlowNode[]) + } + }, [nodes, onNodesChange]) + + // Smart edge regeneration + const allocationsKey = useMemo(() => { + return JSON.stringify( + nodes + .filter(n => n.type === 'funnel') + .map(n => { + const d = n.data as FunnelNodeData + return { + id: n.id, + overflow: d.overflowAllocations, + spending: d.spendingAllocations, + rate: d.inflowRate, + } + }) + ) + }, [nodes]) + + useEffect(() => { + setEdges(generateEdges(nodes as FlowNode[])) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allocationsKey]) + + const onConnect = useCallback( + (params: Connection) => { + if (!params.source || !params.target) return + + const isOverflow = params.sourceHandle?.startsWith('outflow') + const isSpending = params.sourceHandle === 'spending-out' + + if (!isOverflow && !isSpending) return + + setNodes((nds) => nds.map((node) => { + if (node.id !== params.source || node.type !== 'funnel') return node + const data = node.data as FunnelNodeData + + if (isOverflow) { + const existing = data.overflowAllocations || [] + if (existing.some(a => a.targetId === params.target)) return node + const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1)) + const redistributed = existing.map(a => ({ + ...a, + percentage: Math.floor(a.percentage * existing.length / (existing.length + 1)) + })) + return { + ...node, + data: { + ...data, + overflowAllocations: [ + ...redistributed, + { + targetId: params.target!, + percentage: newPct, + color: OVERFLOW_COLORS[existing.length % OVERFLOW_COLORS.length], + }, + ], + }, + } + } else { + const existing = data.spendingAllocations || [] + if (existing.some(a => a.targetId === params.target)) return node + const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1)) + const redistributed = existing.map(a => ({ + ...a, + percentage: Math.floor(a.percentage * existing.length / (existing.length + 1)) + })) + return { + ...node, + data: { + ...data, + spendingAllocations: [ + ...redistributed, + { + targetId: params.target!, + percentage: newPct, + color: SPENDING_COLORS[existing.length % SPENDING_COLORS.length], + }, + ], + }, + } + } + })) + }, + [setNodes] + ) + + const onReconnect = useCallback( + (oldEdge: FlowEdge, newConnection: Connection) => { + const edgeData = oldEdge.data + if (!edgeData || !newConnection.target) return + + const oldTargetId = oldEdge.target + const newTargetId = newConnection.target + if (oldTargetId === newTargetId) return + + setNodes((nds) => nds.map((node) => { + if (node.id !== oldEdge.source || node.type !== 'funnel') return node + const data = node.data as FunnelNodeData + + if (edgeData.edgeType === 'overflow') { + return { + ...node, + data: { + ...data, + overflowAllocations: data.overflowAllocations.map(a => + a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a + ), + }, + } + } else { + return { + ...node, + data: { + ...data, + spendingAllocations: data.spendingAllocations.map(a => + a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a + ), + }, + } + } + })) + }, + [setNodes] + ) + + const handleEdgesChange = useCallback( + (changes: Parameters[0]) => { + changes.forEach((change) => { + if (change.type === 'remove') { + const edge = edgesRef.current.find(e => e.id === change.id) + if (edge?.data) { + setNodes((nds) => nds.map((node) => { + if (node.id !== edge.source || node.type !== 'funnel') return node + const data = node.data as FunnelNodeData + + if (edge.data!.edgeType === 'overflow') { + const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target) + const total = filtered.reduce((s, a) => s + a.percentage, 0) + return { + ...node, + data: { + ...data, + overflowAllocations: total > 0 + ? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) + : filtered, + }, + } + } else { + const filtered = data.spendingAllocations.filter(a => a.targetId !== edge.target) + const total = filtered.reduce((s, a) => s + a.percentage, 0) + return { + ...node, + data: { + ...data, + spendingAllocations: total > 0 + ? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) + : filtered, + }, + } + } + })) + } + } + }) + + onEdgesChange(changes) + }, + [onEdgesChange, setNodes] + ) + + // Add funnel node at viewport center + const addFunnel = useCallback(() => { + const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }) + const newId = `funnel-${Date.now()}` + setNodes((nds) => [ + ...nds, + { + id: newId, + type: 'funnel', + position: pos, + data: { + label: 'New Funnel', + currentValue: 0, + minThreshold: 10000, + maxThreshold: 40000, + maxCapacity: 50000, + inflowRate: 0, + overflowAllocations: [], + spendingAllocations: [], + } as FunnelNodeData, + }, + ]) + }, [setNodes, screenToFlowPosition]) + + // Add outcome node at viewport center + const addOutcome = useCallback(() => { + const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 + 100 }) + const newId = `outcome-${Date.now()}` + setNodes((nds) => [ + ...nds, + { + id: newId, + type: 'outcome', + position: pos, + data: { + label: 'New Outcome', + description: '', + fundingReceived: 0, + fundingTarget: 20000, + status: 'not-started', + } as OutcomeNodeData, + }, + ]) + }, [setNodes, screenToFlowPosition]) + + // Simulation + useEffect(() => { + if (!isSimulating) return + + const interval = setInterval(() => { + setNodes((nds) => + nds.map((node) => { + if (node.type === 'funnel') { + const data = node.data as FunnelNodeData + const change = (Math.random() - 0.45) * 300 + return { + ...node, + data: { + ...data, + currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)), + }, + } + } else if (node.type === 'outcome') { + const data = node.data as OutcomeNodeData + const change = Math.random() * 80 + const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change) + return { + ...node, + data: { + ...data, + fundingReceived: newReceived, + status: newReceived >= data.fundingTarget ? 'completed' : + data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status, + }, + } + } + return node + }) + ) + }, 500) + + return () => clearInterval(interval) + }, [isSimulating, setNodes]) + + return ( +
+ connection.source !== connection.target} + defaultEdgeOptions={{ type: 'smoothstep' }} + > + + + + {/* Title Panel */} + +

Threshold-Based Flow Funding

+

+ Inflows (top) • + Overflow (sides) • + Spending (bottom) +

+

+ Drag handles to connect • Double-click funnels to edit • Select + Delete to remove edges +

+
+ + {/* Top-right Controls */} + + {mode === 'space' && ( + <> + + + + )} + + + + {/* Legend */} + +
Flow Types
+
+
+
+ Inflows (top) +
+
+
+ Overflow (sides) +
+
+
+ Spending (bottom) +
+
+ Edge width = relative flow amount +
+
+ + +
+ ) +} + +// Props for the exported component +interface FlowCanvasProps { + initialNodes: FlowNode[] + mode?: 'demo' | 'space' + onNodesChange?: (nodes: FlowNode[]) => void +} + +export default function FlowCanvas({ initialNodes, mode = 'demo', onNodesChange }: FlowCanvasProps) { + return ( + + + + ) +} diff --git a/components/nodes/FunnelNode.tsx b/components/nodes/FunnelNode.tsx new file mode 100644 index 0000000..1be2c75 --- /dev/null +++ b/components/nodes/FunnelNode.tsx @@ -0,0 +1,837 @@ +'use client' + +import { memo, useState, useCallback, useRef, useEffect } from 'react' +import { Handle, Position, useReactFlow } from '@xyflow/react' +import type { NodeProps } from '@xyflow/react' +import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types' + +const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] +const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c'] + +function FunnelNode({ data, selected, id }: NodeProps) { + const nodeData = data as FunnelNodeData + const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData + + const { getNode, setNodes } = useReactFlow() + + const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) + const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) + const [isEditing, setIsEditing] = useState(false) + const [draggingPie, setDraggingPie] = useState<{ type: 'overflow' | 'spending', index: number } | null>(null) + const [localOverflow, setLocalOverflow] = useState(overflowAllocations) + const [localSpending, setLocalSpending] = useState(spendingAllocations) + const [showAddOutflow, setShowAddOutflow] = useState(false) + const [showAddOutcome, setShowAddOutcome] = useState(false) + const [newItemName, setNewItemName] = useState('') + + const sliderRef = useRef(null) + const overflowPieRef = useRef(null) + const spendingPieRef = useRef(null) + + const isOverflowing = currentValue > maxThreshold + const isCritical = currentValue < minThreshold + const fillPercent = Math.min(100, (currentValue / maxCapacity) * 100) + + const width = 160 + const height = 140 + + const minPercent = minThreshold / maxCapacity + const maxPercent = maxThreshold / maxCapacity + + const overflowZoneHeight = (1 - maxPercent) * height * 0.4 + 15 + const healthyZoneHeight = (maxPercent - minPercent) * height * 0.8 + 30 + const drainZoneHeight = height - overflowZoneHeight - healthyZoneHeight + + const topWidth = 130 + const midWidth = 100 + const bottomWidth = 30 + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setLocalOverflow([...overflowAllocations]) + setLocalSpending([...spendingAllocations]) + setIsEditing(true) + }, [overflowAllocations, spendingAllocations]) + + const handleCloseEdit = useCallback(() => { + setNodes((nds) => nds.map((node) => { + if (node.id !== id) return node + const prevData = node.data as FunnelNodeData + return { + ...node, + data: { + ...prevData, + overflowAllocations: localOverflow, + spendingAllocations: localSpending, + minThreshold, + maxThreshold, + }, + } + })) + setIsEditing(false) + setShowAddOutflow(false) + setShowAddOutcome(false) + setNewItemName('') + }, [id, localOverflow, localSpending, minThreshold, maxThreshold, setNodes]) + + const [draggingThreshold, setDraggingThreshold] = useState<'min' | 'max' | null>(null) + + const handleThresholdMouseDown = useCallback((e: React.MouseEvent, type: 'min' | 'max') => { + 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]) + + 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] + 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 + }) + } 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]) + + const handleAddOutflow = useCallback(() => { + if (!newItemName.trim()) return + + const currentNode = getNode(id) + if (!currentNode) return + + const newId = `funnel-${Date.now()}` + const newNodeData: FunnelNodeData = { + label: newItemName, + currentValue: 0, + minThreshold: 10000, + maxThreshold: 40000, + maxCapacity: 50000, + inflowRate: 0, + overflowAllocations: [], + spendingAllocations: [], + } + + setNodes((nodes) => [ + ...nodes, + { + id: newId, + type: 'funnel', + position: { x: currentNode.position.x + 250, y: currentNode.position.y }, + data: newNodeData, + }, + ]) + + const newAllocation = { + targetId: newId, + percentage: localOverflow.length === 0 ? 100 : Math.floor(100 / (localOverflow.length + 1)), + color: OVERFLOW_COLORS[localOverflow.length % OVERFLOW_COLORS.length], + } + + const newOverflow = localOverflow.map(a => ({ + ...a, + percentage: Math.floor(a.percentage * localOverflow.length / (localOverflow.length + 1)) + })) + newOverflow.push(newAllocation) + + setLocalOverflow(newOverflow) + setShowAddOutflow(false) + setNewItemName('') + }, [newItemName, id, getNode, setNodes, localOverflow]) + + const handleAddOutcome = useCallback(() => { + if (!newItemName.trim()) return + + const currentNode = getNode(id) + if (!currentNode) return + + const newId = `outcome-${Date.now()}` + const newNodeData: OutcomeNodeData = { + label: newItemName, + description: '', + fundingReceived: 0, + fundingTarget: 20000, + status: 'not-started', + } + + setNodes((nodes) => [ + ...nodes, + { + id: newId, + type: 'outcome', + position: { x: currentNode.position.x, y: currentNode.position.y + 300 }, + data: newNodeData, + }, + ]) + + const newAllocation = { + targetId: newId, + percentage: localSpending.length === 0 ? 100 : Math.floor(100 / (localSpending.length + 1)), + color: SPENDING_COLORS[localSpending.length % SPENDING_COLORS.length], + } + + const newSpending = localSpending.map(a => ({ + ...a, + percentage: Math.floor(a.percentage * localSpending.length / (localSpending.length + 1)) + })) + newSpending.push(newAllocation) + + setLocalSpending(newSpending) + setShowAddOutcome(false) + setNewItemName('') + }, [newItemName, id, getNode, setNodes, localSpending]) + + const handleRemoveOutflow = useCallback((index: number) => { + setLocalOverflow(prev => { + const newAllocs = prev.filter((_, i) => i !== index) + if (newAllocs.length > 0) { + const total = newAllocs.reduce((s, a) => s + a.percentage, 0) + return newAllocs.map(a => ({ ...a, percentage: Math.round(a.percentage / total * 100) })) + } + return newAllocs + }) + }, []) + + const handleRemoveSpending = useCallback((index: number) => { + setLocalSpending(prev => { + const newAllocs = prev.filter((_, i) => i !== index) + if (newAllocs.length > 0) { + const total = newAllocs.reduce((s, a) => s + a.percentage, 0) + return newAllocs.map(a => ({ ...a, percentage: Math.round(a.percentage / total * 100) })) + } + return newAllocs + }) + }, []) + + 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 + + return allocations.map((alloc, idx) => { + const angle = (alloc.percentage / 100) * 360 + const startAngle = currentAngle + const endAngle = currentAngle + angle + currentAngle = endAngle + + const startRad = (startAngle * Math.PI) / 180 + const endRad = (endAngle * Math.PI) / 180 + + const x1 = center + radius * Math.cos(startRad) + const y1 = center + radius * Math.sin(startRad) + const x2 = center + radius * Math.cos(endRad) + const y2 = center + radius * Math.sin(endRad) + + const largeArc = angle > 180 ? 1 : 0 + + return ( + { + e.stopPropagation() + setDraggingPie({ type, index: idx }) + }} + /> + ) + }) + } + + const renderSimpleBars = (allocations: typeof overflowAllocations, colors: string[], direction: 'horizontal' | 'vertical') => { + if (allocations.length === 0) return null + + return ( +
+ {allocations.map((alloc, idx) => ( +
+ ))} +
+ ) + } + + const hasOverflow = overflowAllocations.length > 0 + const hasSpending = spendingAllocations.length > 0 + + return ( + <> +
+ + +
+
+ {label} + + {isOverflowing ? 'OVER' : isCritical ? 'LOW' : 'OK'} + +
+
+ +
+
+ + + + Inflow + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MAX + + + MIN + + + + {isOverflowing && hasOverflow && ( + <> + + + + + + + + + + + + )} + + {hasSpending && ( + + + + + + )} + + +
+ + ${Math.floor(currentValue / 1000)}k + +
+ +
+ {hasOverflow && ( +
+ ← Out → +
+ {renderSimpleBars(overflowAllocations, OVERFLOW_COLORS, 'horizontal')} +
+
+ )} + + {hasSpending && ( +
+ ↓ Fund +
+ {renderSimpleBars(spendingAllocations, SPENDING_COLORS, 'horizontal')} +
+
+ )} +
+ +
+ Double-click to edit +
+
+ + + + + +
+ + {isEditing && ( +
+
e.stopPropagation()} + > +
+

{label}

+ +
+ +
+ + ${Math.floor(currentValue).toLocaleString()} + + / ${maxCapacity.toLocaleString()} +
+ +
+
+ MIN: ${(minThreshold/1000).toFixed(0)}k + MAX: ${(maxThreshold/1000).toFixed(0)}k +
+
+
+
+
+
+
+ +
+ +
handleThresholdMouseDown(e, 'min')} + /> + +
handleThresholdMouseDown(e, 'max')} + /> +
+
+ $0 + Drag handles to adjust + ${(maxCapacity/1000).toFixed(0)}k +
+
+ +
+
+
+ → Outflows + +
+ + {localOverflow.length > 0 ? ( + <> + + {renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 100)} + + +
+ {localOverflow.map((alloc, idx) => ( +
+
+ {alloc.targetId} + {alloc.percentage}% + +
+ ))} +
+ + ) : ( +

No outflows yet

+ )} + + {showAddOutflow && ( +
+ setNewItemName(e.target.value)} + placeholder="New funnel name..." + className="w-full text-xs px-2 py-1 border border-slate-200 rounded mb-2" + autoFocus + /> +
+ + +
+
+ )} +
+ +
+
+ ↓ Outcomes + +
+ + {localSpending.length > 0 ? ( + <> + + {renderPieChart(localSpending, SPENDING_COLORS, 'spending', 100)} + + +
+ {localSpending.map((alloc, idx) => ( +
+
+ {alloc.targetId} + {alloc.percentage}% + +
+ ))} +
+ + ) : ( +

No outcomes yet

+ )} + + {showAddOutcome && ( +
+ setNewItemName(e.target.value)} + placeholder="New outcome name..." + className="w-full text-xs px-2 py-1 border border-slate-200 rounded mb-2" + autoFocus + /> +
+ + +
+
+ )} +
+
+ +

+ Drag pie slices to adjust • Click + to add new items +

+ +
+ +
+
+
+ )} + + ) +} + +export default memo(FunnelNode) diff --git a/components/nodes/OutcomeNode.tsx b/components/nodes/OutcomeNode.tsx new file mode 100644 index 0000000..ef52501 --- /dev/null +++ b/components/nodes/OutcomeNode.tsx @@ -0,0 +1,90 @@ +'use client' + +import { memo } from 'react' +import { Handle, Position } from '@xyflow/react' +import type { NodeProps } from '@xyflow/react' +import type { OutcomeNodeData } from '@/lib/types' + +function OutcomeNode({ data, selected }: NodeProps) { + const nodeData = data as OutcomeNodeData + const { label, description, fundingReceived, fundingTarget, status } = nodeData + + const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0 + const isFunded = fundingReceived >= fundingTarget + const isPartial = fundingReceived > 0 && fundingReceived < fundingTarget + + const statusColors = { + 'not-started': { bg: 'bg-slate-100', text: 'text-slate-600', border: 'border-slate-300' }, + 'in-progress': { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' }, + 'completed': { bg: 'bg-emerald-100', text: 'text-emerald-700', border: 'border-emerald-300' }, + 'blocked': { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' }, + } + + const colors = statusColors[status] || statusColors['not-started'] + + return ( +
+ + +
+
+
+ + + +
+ {label} +
+
+ +
+ {description && ( +

{description}

+ )} + +
+ + {status.replace('-', ' ')} + + {isFunded && ( + + + + )} +
+ +
+
+ Funding + + ${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()} + +
+
+
+
+
+ {progress.toFixed(0)}% +
+
+
+
+ ) +} + +export default memo(OutcomeNode) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d82659e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + rfunds-online: + build: . + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.rfunds.rule=Host(`rfunds.online`) || Host(`www.rfunds.online`)" + - "traefik.http.services.rfunds.loadbalancer.server.port=3000" + networks: + - traefik-public + +networks: + traefik-public: + external: true diff --git a/lib/presets.ts b/lib/presets.ts new file mode 100644 index 0000000..2e487df --- /dev/null +++ b/lib/presets.ts @@ -0,0 +1,190 @@ +import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types' + +// Colors for allocations +export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] +export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c'] + +// Demo preset: Treasury → 3 sub-funnels → 7 outcomes +export const demoNodes: FlowNode[] = [ + // Main Treasury Funnel (top center) + { + id: 'treasury', + type: 'funnel', + position: { x: 630, y: 0 }, + data: { + label: 'Treasury', + currentValue: 85000, + minThreshold: 20000, + maxThreshold: 70000, + maxCapacity: 100000, + inflowRate: 1000, + overflowAllocations: [ + { targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] }, + { targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] }, + { targetId: 'emergency', percentage: 25, color: OVERFLOW_COLORS[2] }, + ], + spendingAllocations: [ + { targetId: 'treasury-ops', percentage: 100, color: SPENDING_COLORS[0] }, + ], + } as FunnelNodeData, + }, + // Sub-funnels (middle row) + { + id: 'public-goods', + type: 'funnel', + position: { x: 170, y: 450 }, + data: { + label: 'Public Goods', + currentValue: 45000, + minThreshold: 15000, + maxThreshold: 50000, + maxCapacity: 70000, + inflowRate: 400, + overflowAllocations: [], + spendingAllocations: [ + { targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] }, + { targetId: 'pg-education', percentage: 30, color: SPENDING_COLORS[1] }, + { targetId: 'pg-tooling', percentage: 20, color: SPENDING_COLORS[2] }, + ], + } as FunnelNodeData, + }, + { + id: 'research', + type: 'funnel', + position: { x: 975, y: 450 }, + data: { + label: 'Research', + currentValue: 28000, + minThreshold: 20000, + maxThreshold: 45000, + maxCapacity: 60000, + inflowRate: 350, + overflowAllocations: [], + spendingAllocations: [ + { targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] }, + { targetId: 'research-papers', percentage: 30, color: SPENDING_COLORS[1] }, + ], + } as FunnelNodeData, + }, + { + id: 'emergency', + type: 'funnel', + position: { x: 1320, y: 450 }, + data: { + label: 'Emergency', + currentValue: 12000, + minThreshold: 25000, + maxThreshold: 60000, + maxCapacity: 80000, + inflowRate: 250, + overflowAllocations: [], + spendingAllocations: [ + { targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] }, + ], + } as FunnelNodeData, + }, + // Outcome nodes (bottom row) + { + id: 'pg-infra', + type: 'outcome', + position: { x: -50, y: 900 }, + data: { + label: 'Infrastructure', + description: 'Core infrastructure development', + fundingReceived: 22000, + fundingTarget: 30000, + status: 'in-progress', + } as OutcomeNodeData, + }, + { + id: 'pg-education', + type: 'outcome', + position: { x: 180, y: 900 }, + data: { + label: 'Education', + description: 'Developer education programs', + fundingReceived: 12000, + fundingTarget: 20000, + status: 'in-progress', + } as OutcomeNodeData, + }, + { + id: 'pg-tooling', + type: 'outcome', + position: { x: 410, y: 900 }, + data: { + label: 'Dev Tooling', + description: 'Open-source developer tools', + fundingReceived: 5000, + fundingTarget: 15000, + status: 'not-started', + } as OutcomeNodeData, + }, + { + id: 'treasury-ops', + type: 'outcome', + position: { x: 640, y: 900 }, + data: { + label: 'Treasury Ops', + description: 'Day-to-day treasury management', + fundingReceived: 15000, + fundingTarget: 25000, + status: 'in-progress', + } as OutcomeNodeData, + }, + { + id: 'research-grants', + type: 'outcome', + position: { x: 870, y: 900 }, + data: { + label: 'Grants', + description: 'Academic research grants', + fundingReceived: 18000, + fundingTarget: 25000, + status: 'in-progress', + } as OutcomeNodeData, + }, + { + id: 'research-papers', + type: 'outcome', + position: { x: 1100, y: 900 }, + data: { + label: 'Papers', + description: 'Peer-reviewed publications', + fundingReceived: 8000, + fundingTarget: 10000, + status: 'in-progress', + } as OutcomeNodeData, + }, + { + id: 'emergency-response', + type: 'outcome', + position: { x: 1330, y: 900 }, + data: { + label: 'Response Fund', + description: 'Rapid response for critical issues', + fundingReceived: 5000, + fundingTarget: 50000, + status: 'not-started', + } as OutcomeNodeData, + }, +] + +// Empty starter for user-created spaces +export const starterNodes: FlowNode[] = [ + { + id: 'treasury-1', + type: 'funnel', + position: { x: 400, y: 50 }, + data: { + label: 'My Treasury', + currentValue: 50000, + minThreshold: 10000, + maxThreshold: 40000, + maxCapacity: 60000, + inflowRate: 500, + overflowAllocations: [], + spendingAllocations: [], + } as FunnelNodeData, + }, +] diff --git a/lib/state.ts b/lib/state.ts new file mode 100644 index 0000000..df4a230 --- /dev/null +++ b/lib/state.ts @@ -0,0 +1,73 @@ +import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string' +import type { FlowNode, SpaceConfig } from './types' + +const STORAGE_PREFIX = 'rfunds-space-' + +interface SerializableState { + nodes: FlowNode[] +} + +export function serializeState(nodes: FlowNode[]): string { + const state: SerializableState = { nodes } + const json = JSON.stringify(state) + return compressToEncodedURIComponent(json) +} + +export function deserializeState(compressed: string): { nodes: FlowNode[] } | null { + try { + const json = decompressFromEncodedURIComponent(compressed) + if (!json) return null + const state = JSON.parse(json) as SerializableState + if (!state.nodes || !Array.isArray(state.nodes)) return null + return { nodes: state.nodes } + } catch { + return null + } +} + +export function saveToLocal(name: string, nodes: FlowNode[]): void { + const config: SpaceConfig = { + name, + nodes, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + // Check if exists to preserve createdAt + const existing = loadFromLocal(name) + if (existing) { + config.createdAt = existing.createdAt + } + + localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(config)) +} + +export function loadFromLocal(name: string): SpaceConfig | null { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + name) + if (!raw) return null + return JSON.parse(raw) as SpaceConfig + } catch { + return null + } +} + +export function deleteFromLocal(name: string): void { + localStorage.removeItem(STORAGE_PREFIX + name) +} + +export function listSavedSpaces(): SpaceConfig[] { + const spaces: SpaceConfig[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith(STORAGE_PREFIX)) { + try { + const config = JSON.parse(localStorage.getItem(key)!) as SpaceConfig + spaces.push(config) + } catch { + // skip corrupt entries + } + } + } + return spaces.sort((a, b) => b.updatedAt - a.updatedAt) +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..c802919 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,57 @@ +import type { Node, Edge } from '@xyflow/react' + +// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold +export interface OverflowAllocation { + targetId: string + percentage: number // 0-100 + color: string +} + +// Spending allocation - funds flowing DOWN to OUTCOMES/OUTPUTS +export interface SpendingAllocation { + targetId: string + percentage: number // 0-100 + color: string +} + +export interface FunnelNodeData { + label: string + currentValue: number + minThreshold: number + maxThreshold: number + maxCapacity: number + inflowRate: number + // Overflow goes SIDEWAYS to other funnels + overflowAllocations: OverflowAllocation[] + // Spending goes DOWN to outcomes/outputs + spendingAllocations: SpendingAllocation[] + [key: string]: unknown +} + +export interface OutcomeNodeData { + label: string + description?: string + fundingReceived: number + fundingTarget: number + status: 'not-started' | 'in-progress' | 'completed' | 'blocked' + [key: string]: unknown +} + +export type FlowNode = Node + +export interface FlowEdgeData { + allocation: number // percentage 0-100 + color: string + edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward + [key: string]: unknown +} + +export type FlowEdge = Edge + +// Serializable space config (no xyflow internals) +export interface SpaceConfig { + name: string + nodes: FlowNode[] + createdAt: number + updatedAt: number +} diff --git a/next.config.mjs b/next.config.mjs index 4678774..e25a6a2 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + output: 'standalone', +}; export default nextConfig; diff --git a/package.json b/package.json index 6f03cfb..91a931d 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,20 @@ "lint": "next lint" }, "dependencies": { + "@xyflow/react": "^12.10.0", + "lz-string": "^1.5.0", + "next": "14.2.35", "react": "^18", "react-dom": "^18", - "next": "14.2.35" + "zustand": "^5.0.11" }, "devDependencies": { - "typescript": "^5", + "@types/lz-string": "^1.5.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "postcss": "^8", - "tailwindcss": "^3.4.1" + "tailwindcss": "^3.4.1", + "typescript": "^5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4a26db..e5fcbef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@xyflow/react': + specifier: ^12.10.0 + version: 12.10.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lz-string: + specifier: ^1.5.0 + version: 1.5.0 next: specifier: 14.2.35 version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -17,7 +23,13 @@ importers: react-dom: specifier: ^18 version: 18.3.1(react@18.3.1) + zustand: + specifier: ^5.0.11 + version: 5.0.11(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: + '@types/lz-string': + specifier: ^1.5.0 + version: 1.5.0 '@types/node': specifier: ^20 version: 20.19.33 @@ -131,6 +143,28 @@ 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/lz-string@1.5.0': + resolution: {integrity: sha512-s84fKOrzqqNCAPljhVyC5TjAo6BH4jKHw9NRNFNiRUY5QSgZCmVm5XILlWbisiKl+0OcS7eWihmKGS5akc2iQw==} + deprecated: This is a stub types definition. lz-string provides its own type definitions, so you do not need this installed. + '@types/node@20.19.33': resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} @@ -145,6 +179,15 @@ packages: '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@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==} @@ -178,6 +221,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==} @@ -193,6 +239,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==} @@ -280,6 +364,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -497,9 +585,47 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + 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 + + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -566,6 +692,31 @@ 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/lz-string@1.5.0': + dependencies: + lz-string: 1.5.0 + '@types/node@20.19.33': dependencies: undici-types: 6.21.0 @@ -581,6 +732,29 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@xyflow/react@12.10.0(@types/react@18.3.28)(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.28)(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: @@ -616,6 +790,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + classcat@5.0.5: {} + client-only@0.0.1: {} commander@4.1.1: {} @@ -624,6 +800,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: {} @@ -695,6 +907,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lz-string@1.5.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -906,4 +1120,21 @@ snapshots: undici-types@6.21.0: {} + 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.28)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + react: 18.3.1 + + zustand@5.0.11(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.28 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1)