diff --git a/.claude/DEMOS_DASHBOARD.md b/.claude/DEMOS_DASHBOARD.md new file mode 100644 index 0000000..cc4e87b --- /dev/null +++ b/.claude/DEMOS_DASHBOARD.md @@ -0,0 +1,276 @@ +# Flow Funding Demos Dashboard + +**Created**: 2025-11-23 +**Location**: `/app/demos/page.tsx` +**Live URL**: `http://localhost:3000/demos` + +--- + +## Overview + +A beautiful, comprehensive dashboard showcasing all Flow Funding interactive demonstrations. Inspired by the infinite-agents demo gallery design pattern. + +## Features + +### Visual Design +- **Gradient Background**: Purple-to-blue gradient matching the project's aesthetic +- **Card Layout**: Modern card-based design with hover effects +- **Status Badges**: Color-coded status indicators (Complete, Beta, Prototype) +- **Stats Dashboard**: Quick overview of total demos, completion status +- **Responsive**: Mobile-first design with grid layouts + +### Functionality +- **Search**: Real-time search across demo names, descriptions, types, and features +- **Category Filters**: Quick filtering by demo category +- **Feature Tags**: Quick-glance feature highlights for each demo +- **Direct Links**: One-click access to each demo + +### Categories + +#### 1. **Threshold-Based Flow Funding (TBFF)** 🎯 +- **TBFF Interactive** (Milestones 1-3 Complete) + - Static visualization with color-coding + - Interactive allocation creation + - Initial distribution algorithm + - Multiple sample networks + +- **TBFF Flow Simulation** (Beta) + - Continuous flow mechanics + - Progressive outflow + +#### 2. **Flow Dynamics V2** 🌊 +- **Continuous Flow Dynamics** (Complete) + - Per-second simulation engine + - Progressive outflow formula (fixed) + - Network overflow node + - 60 FPS rendering + - Animated flow particles + +#### 3. **Interactive Canvas** 🎨 +- **Italism** (Complete) + - Live arrow propagators + - Shape drawing and editing + - Expression-based connections + - Undo/redo functionality + - FolkJS-inspired architecture + +#### 4. **Prototypes** πŸ”¬ +- **Flow Funding (Original)** (Prototype) + - Basic flow mechanics + - Early concept exploration + +## Integration Points + +### Main Landing Page +- Updated hero section with "View Interactive Demos" button +- Primary CTA now links to `/demos` + +### Demo Pages +- Each demo page can link back to dashboard +- Consistent navigation experience + +## Technical Details + +### Component Structure +```tsx +DemosPage (Client Component) +β”œβ”€β”€ Header with title and description +β”œβ”€β”€ Statistics cards (Total, Complete, Beta, Categories) +β”œβ”€β”€ Search and filter controls +└── Category sections + └── Demo cards with: + β”œβ”€β”€ Status badge + β”œβ”€β”€ Title and description + β”œβ”€β”€ Feature tags + β”œβ”€β”€ Type label + └── Launch link +``` + +### Data Model +```typescript +interface Demo { + number: number + title: string + description: string + path: string + type: string + status: 'complete' | 'beta' | 'prototype' + features: string[] + milestone?: string +} +``` + +### State Management +- Local React state for search and filters +- No external dependencies +- Client-side filtering for performance + +## Design Patterns + +### Inspired by infinite-agents +- Category-based organization +- Stats bar at the top +- Search and filter controls +- Card-based demo display +- Hover effects and transitions +- Status badges + +### Improvements Over Original +- React/Next.js instead of vanilla JS +- Type-safe with TypeScript +- Responsive Tailwind CSS +- Status badges (Complete/Beta/Prototype) +- Feature tags for each demo +- Milestone tracking + +## Future Enhancements + +### Short-term +- [ ] Add screenshots for each demo +- [ ] Implement screenshot preview on hover +- [ ] Add "New" badge for recently added demos +- [ ] Add demo tags (e.g., "Interactive", "Simulation", "Educational") + +### Medium-term +- [ ] Add demo ratings/feedback +- [ ] Implement demo bookmarking +- [ ] Add video previews/tours +- [ ] Create guided learning paths +- [ ] Add "What's New" section + +### Long-term +- [ ] User accounts and personalization +- [ ] Demo creation wizard +- [ ] Community contributions +- [ ] Analytics and usage tracking +- [ ] A/B testing for different presentations + +## Usage + +### Accessing the Dashboard +1. Navigate to `http://localhost:3000/demos` +2. Or click "View Interactive Demos" from the home page + +### Searching Demos +- Type in the search box to filter by: + - Demo name + - Description text + - Type + - Feature names + +### Filtering by Category +- Click any category button to show only that category +- Click "All Demos" to reset + +### Launching a Demo +- Click anywhere on a demo card +- Or click the "Launch Demo β†’" link + +## Maintenance + +### Adding New Demos +Edit `/app/demos/page.tsx`: + +```typescript +const demos: Record = { + categoryName: [ + { + number: X, + title: 'Demo Title', + description: 'Demo description...', + path: '/demo-route', + type: 'Demo Type', + status: 'complete' | 'beta' | 'prototype', + features: ['Feature 1', 'Feature 2', ...], + milestone: 'Optional milestone note' + } + ] +} +``` + +### Updating Categories +Add to the `categories` array: + +```typescript +{ + id: 'categoryKey', + label: 'Display Name', + count: demos.categoryKey.length, + icon: 'πŸ”¬' +} +``` + +## Performance Considerations + +### Current Performance +- Minimal bundle size (no heavy dependencies) +- Client-side rendering with static demo data +- Fast search (no API calls) +- Instant category filtering + +### Optimization Opportunities +- Lazy load demo screenshots +- Virtual scrolling for many demos +- Server-side rendering for initial load +- Static generation at build time + +## Accessibility + +### Current Features +- Semantic HTML structure +- Keyboard navigation support +- Focus states on interactive elements +- High contrast color schemes + +### Future Improvements +- ARIA labels for all interactive elements +- Screen reader optimizations +- Keyboard shortcuts +- Focus management + +## Design Philosophy + +### Post-Appitalism Alignment +- **Transparent**: All demos visible and accessible +- **Exploratory**: Encourages browsing and discovery +- **Educational**: Descriptions explain what each demo teaches +- **Beautiful**: Aesthetically compelling design +- **Functional**: No unnecessary complexity + +### User Experience Goals +1. **Immediate Value**: See all demos at a glance +2. **Easy Discovery**: Search and filter make finding demos trivial +3. **Clear Status**: Know which demos are production-ready +4. **Feature Visibility**: Understand what each demo offers +5. **Quick Access**: One click to launch any demo + +--- + +## Metrics for Success + +### User Engagement +- Time spent on dashboard +- Demos launched from dashboard +- Search usage patterns +- Filter usage patterns + +### Content Quality +- Complete demos ratio +- Feature completeness +- Description clarity +- User feedback scores + +### Technical Performance +- Page load time < 2s +- Search response < 100ms +- Filter transition < 300ms +- Mobile responsiveness scores + +--- + +**Status**: βœ… Complete and deployed to development +**Next Steps**: Add screenshots, test with users, gather feedback + +--- + +*"Make the abstract concrete. Make the complex simple. Make it beautiful."* diff --git a/app/demos/page.tsx b/app/demos/page.tsx new file mode 100644 index 0000000..4d4fcc6 --- /dev/null +++ b/app/demos/page.tsx @@ -0,0 +1,365 @@ +'use client' + +import { useState } from 'react' + +interface Demo { + number: number + title: string + description: string + path: string + type: string + status: 'complete' | 'beta' | 'prototype' + features: string[] + milestone?: string + screenshot?: string +} + +const demos: Record = { + tbff: [ + { + number: 1, + title: 'Threshold-Based Flow Funding (Interactive)', + description: 'Complete interactive demo with Milestones 1-3: Static visualization, interactive allocations, and initial distribution algorithm. Create accounts, draw allocation arrows, add funding, and watch resources flow.', + path: '/tbff', + type: 'Interactive Simulation', + status: 'complete', + milestone: 'Milestones 1-3 Complete', + screenshot: '/screenshots/tbff.png', + features: [ + 'Visual threshold-based coloring', + 'Interactive allocation creation', + 'Automatic normalization', + 'Initial distribution algorithm', + 'Multiple sample networks', + 'Real-time balance updates' + ] + }, + { + number: 2, + title: 'TBFF Flow Simulation', + description: 'Alternative implementation exploring continuous flow dynamics with progressive outflow ratios.', + path: '/tbff-flow', + type: 'Flow Simulation', + status: 'beta', + screenshot: '/screenshots/tbff-flow.png', + features: [ + 'Continuous flow mechanics', + 'Progressive outflow', + 'Network equilibrium', + 'Visual flow indicators' + ] + } + ], + flowV2: [ + { + number: 3, + title: 'Flow Funding V2: Continuous Flow Dynamics', + description: 'Redesigned as continuous per-second flow simulation with per-month UI. Features progressive outflow formula ensuring monotonic increase in sharing as accounts approach "enough".', + path: '/flow-v2', + type: 'Continuous Flow', + status: 'complete', + screenshot: '/screenshots/flow-v2.png', + features: [ + 'Per-second simulation engine', + 'Progressive outflow formula (fixed)', + 'Network overflow node', + 'Smooth 60 FPS rendering', + 'Animated flow particles', + 'Time-scale architecture' + ] + } + ], + canvas: [ + { + number: 4, + title: 'Italism: Interactive Canvas with Propagators', + description: 'Original canvas demo with live propagators. Draw shapes, connect them with arrows, and watch data flow through the network. Foundation for malleable software vision.', + path: '/italism', + type: 'Live Programming Canvas', + status: 'complete', + screenshot: '/screenshots/italism.png', + features: [ + 'Live arrow propagators', + 'Shape drawing and editing', + 'Expression-based connections', + 'Undo/redo functionality', + 'Real-time data flow', + 'FolkJS-inspired architecture' + ] + } + ], + prototypes: [ + { + number: 5, + title: 'Flow Funding (Original)', + description: 'Earlier prototype exploring initial flow funding concepts.', + path: '/flowfunding', + type: 'Prototype', + status: 'prototype', + screenshot: '/screenshots/flowfunding.png', + features: [ + 'Basic flow mechanics', + 'Threshold visualization', + 'Network simulation' + ] + } + ] +} + +export default function DemosPage() { + const [searchTerm, setSearchTerm] = useState('') + const [activeFilter, setActiveFilter] = useState('all') + + const allDemos = Object.values(demos).flat() + const totalDemos = allDemos.length + const completeDemos = allDemos.filter(d => d.status === 'complete').length + const betaDemos = allDemos.filter(d => d.status === 'beta').length + + const categories = [ + { id: 'all', label: 'All Demos', count: totalDemos }, + { id: 'tbff', label: 'TBFF Interactive', count: demos.tbff.length, icon: '🎯' }, + { id: 'flowV2', label: 'Flow Dynamics V2', count: demos.flowV2.length, icon: '🌊' }, + { id: 'canvas', label: 'Interactive Canvas', count: demos.canvas.length, icon: '🎨' }, + { id: 'prototypes', label: 'Prototypes', count: demos.prototypes.length, icon: 'πŸ”¬' } + ] + + const getStatusColor = (status: string) => { + switch (status) { + case 'complete': return 'bg-green-500' + case 'beta': return 'bg-yellow-500' + case 'prototype': return 'bg-gray-500' + default: return 'bg-gray-400' + } + } + + const getStatusLabel = (status: string) => { + switch (status) { + case 'complete': return 'Complete' + case 'beta': return 'Beta' + case 'prototype': return 'Prototype' + default: return status + } + } + + return ( +
+
+ {/* Header */} +
+
+

+ πŸ’§ Flow Funding Demos +

+

+ Exploring Threshold-Based Resource Allocation & Post-Appitalism +

+

+ Interactive demonstrations of flow funding mechanisms, from threshold-based redistribution to continuous flow dynamics. + Experience economics as living, breathing systems. +

+
+
+ + {/* Stats */} +
+
+
+ {totalDemos} +
+
Total Demos
+
+
+
+ {completeDemos} +
+
Complete
+
+
+
+ {betaDemos} +
+
Beta
+
+
+
+ 3 +
+
Categories
+
+
+ + {/* Search & Filter */} +
+
+ setSearchTerm(e.target.value)} + /> +
+
+ {categories.map(cat => ( + + ))} +
+
+ + {/* Demo Categories */} + {Object.entries(demos).map(([categoryKey, categoryDemos]) => { + if (activeFilter !== 'all' && activeFilter !== categoryKey) return null + + const categoryInfo = { + tbff: { title: 'Threshold-Based Flow Funding', icon: '🎯', desc: 'Interactive demos with allocation creation and distribution algorithms' }, + flowV2: { title: 'Flow Dynamics V2', icon: '🌊', desc: 'Continuous per-second flow simulation with progressive outflow' }, + canvas: { title: 'Interactive Canvas', icon: '🎨', desc: 'Live programming environment with propagator networks' }, + prototypes: { title: 'Early Prototypes', icon: 'πŸ”¬', desc: 'Initial explorations and concept validation' } + }[categoryKey] || { title: categoryKey, icon: 'πŸ“', desc: '' } + + return ( +
+
+
+
+ {categoryInfo.icon} +
+
+

{categoryInfo.title}

+

{categoryInfo.desc}

+
+
+ {categoryDemos.length} {categoryDemos.length === 1 ? 'demo' : 'demos'} +
+
+
+ +
+ {categoryDemos + .filter(demo => { + if (!searchTerm) return true + const searchLower = searchTerm.toLowerCase() + return ( + demo.title.toLowerCase().includes(searchLower) || + demo.description.toLowerCase().includes(searchLower) || + demo.type.toLowerCase().includes(searchLower) || + demo.features.some(f => f.toLowerCase().includes(searchLower)) + ) + }) + .map(demo => ( + + {/* Screenshot Preview */} + {demo.screenshot && ( +
+ {`${demo.title} +
+ + πŸ‘οΈ Click to view + +
+
+ )} + + {/* Card Header */} +
+ + {/* Card Content */} +
+
+ + #{demo.number} + + + {getStatusLabel(demo.status)} + +
+ +

+ {demo.title} +

+ + {demo.milestone && ( +
+ βœ… {demo.milestone} +
+ )} + +

+ {demo.description} +

+ +
+
+ Key Features: +
+
+ {demo.features.slice(0, 3).map((feature, idx) => ( + + {feature} + + ))} + {demo.features.length > 3 && ( + + +{demo.features.length - 3} more + + )} +
+
+ +
+ {demo.type} + + Launch Demo β†’ + +
+
+
+ ))} +
+
+ ) + })} + + {/* Footer */} + +
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx index d2d3792..e6063f5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,6 +11,14 @@ export const metadata: Metadata = { title: "Project Interlay | Post-Appitalism", description: "Weaving a post-appitalist future. Decomposing the data silos of capitalist business models.", generator: "v0.app", + icons: { + icon: [ + { + url: "data:image/svg+xml,🌊", + type: "image/svg+xml", + }, + ], + }, } export default function RootLayout({ diff --git a/app/tbff-flow/page.tsx b/app/tbff-flow/page.tsx new file mode 100644 index 0000000..665af20 --- /dev/null +++ b/app/tbff-flow/page.tsx @@ -0,0 +1,671 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import Link from "next/link" +import type { FlowNetwork, FlowAllocation, FlowParticle } from "@/lib/tbff-flow/types" +import { renderFlowNetwork } from "@/lib/tbff-flow/rendering" +import { + flowSampleNetworks, + flowNetworkOptions, + getFlowSampleNetwork, +} from "@/lib/tbff-flow/sample-networks" +import { + formatFlow, + getFlowStatusColorClass, + normalizeFlowAllocations, + calculateFlowNetworkTotals, + updateFlowNodeProperties, +} from "@/lib/tbff-flow/utils" +import { propagateFlow, updateFlowParticles } from "@/lib/tbff-flow/algorithms" + +type Tool = 'select' | 'create-allocation' + +export default function TBFFFlowPage() { + const canvasRef = useRef(null) + const animationFrameRef = useRef(null) + + const [network, setNetwork] = useState(flowSampleNetworks.linear) + const [particles, setParticles] = useState([]) + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [selectedAllocationId, setSelectedAllocationId] = useState(null) + const [selectedNetworkKey, setSelectedNetworkKey] = useState('linear') + const [tool, setTool] = useState('select') + const [allocationSourceId, setAllocationSourceId] = useState(null) + const [isAnimating, setIsAnimating] = useState(true) + + // Animation loop + useEffect(() => { + if (!isAnimating) return + + const animate = () => { + // Update particles + setParticles(prev => updateFlowParticles(prev)) + + animationFrameRef.current = requestAnimationFrame(animate) + } + + animationFrameRef.current = requestAnimationFrame(animate) + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, [isAnimating]) + + // Render canvas + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext("2d") + if (!ctx) return + + // Set canvas size + canvas.width = canvas.offsetWidth + canvas.height = canvas.offsetHeight + + // Render the network + renderFlowNetwork( + ctx, + network, + canvas.width, + canvas.height, + particles, + selectedNodeId, + selectedAllocationId + ) + }, [network, particles, selectedNodeId, selectedAllocationId]) + + // Propagate flow when network changes + const handlePropagateFlow = () => { + const result = propagateFlow(network) + setNetwork(result.network) + setParticles(result.particles) + } + + // Set external flow for selected node + const handleSetNodeFlow = (nodeId: string, flow: number) => { + const updatedNodes = network.nodes.map(node => + node.id === nodeId + ? { ...node, externalFlow: Math.max(0, flow) } + : node + ) + + const updatedNetwork = { + ...network, + nodes: updatedNodes, + } + + setNetwork(updatedNetwork) + + // Auto-propagate + setTimeout(() => { + const result = propagateFlow(updatedNetwork) + setNetwork(result.network) + setParticles(result.particles) + }, 100) + } + + // Handle canvas click + const handleCanvasClick = (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + // Find clicked node + const clickedNode = network.nodes.find( + node => + x >= node.x && + x <= node.x + node.width && + y >= node.y && + y <= node.y + node.height + ) + + if (tool === 'select') { + if (clickedNode) { + setSelectedNodeId(clickedNode.id) + setSelectedAllocationId(null) + } else { + // Check if clicked on allocation + const clickedAllocation = findAllocationAtPoint(x, y) + if (clickedAllocation) { + setSelectedAllocationId(clickedAllocation.id) + setSelectedNodeId(null) + } else { + // Deselect all + setSelectedNodeId(null) + setSelectedAllocationId(null) + } + } + } else if (tool === 'create-allocation') { + if (clickedNode) { + if (!allocationSourceId) { + // First click: set source + setAllocationSourceId(clickedNode.id) + } else { + // Second click: create allocation + if (clickedNode.id !== allocationSourceId) { + createAllocation(allocationSourceId, clickedNode.id) + } + setAllocationSourceId(null) + } + } + } + } + + // Find allocation at point + const findAllocationAtPoint = (x: number, y: number): FlowAllocation | null => { + const tolerance = 15 + + for (const allocation of network.allocations) { + const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId) + const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId) + if (!sourceNode || !targetNode) continue + + const sourceCenter = { + x: sourceNode.x + sourceNode.width / 2, + y: sourceNode.y + sourceNode.height / 2, + } + const targetCenter = { + x: targetNode.x + targetNode.width / 2, + y: targetNode.y + targetNode.height / 2, + } + + const distance = pointToLineDistance( + x, y, + sourceCenter.x, sourceCenter.y, + targetCenter.x, targetCenter.y + ) + + if (distance < tolerance) { + return allocation + } + } + + return null + } + + // Point to line distance + const pointToLineDistance = ( + px: number, py: number, + x1: number, y1: number, + x2: number, y2: number + ): number => { + const dot = (px - x1) * (x2 - x1) + (py - y1) * (y2 - y1) + const lenSq = (x2 - x1) ** 2 + (y2 - y1) ** 2 + const param = lenSq !== 0 ? dot / lenSq : -1 + + let xx, yy + if (param < 0) { + [xx, yy] = [x1, y1] + } else if (param > 1) { + [xx, yy] = [x2, y2] + } else { + xx = x1 + param * (x2 - x1) + yy = y1 + param * (y2 - y1) + } + + return Math.sqrt((px - xx) ** 2 + (py - yy) ** 2) + } + + // Create allocation + const createAllocation = (sourceId: string, targetId: string) => { + const newAllocation: FlowAllocation = { + id: `alloc_${Date.now()}`, + sourceNodeId: sourceId, + targetNodeId: targetId, + percentage: 0.5, + } + + const updatedAllocations = [...network.allocations, newAllocation] + const sourceAllocations = updatedAllocations.filter(a => a.sourceNodeId === sourceId) + const normalized = normalizeFlowAllocations(sourceAllocations) + + const finalAllocations = updatedAllocations.map(a => { + const normalizedVersion = normalized.find(n => n.id === a.id) + return normalizedVersion || a + }) + + const updatedNetwork = { + ...network, + allocations: finalAllocations, + } + + setNetwork(updatedNetwork) + setSelectedAllocationId(newAllocation.id) + setTool('select') + + // Re-propagate + setTimeout(() => handlePropagateFlow(), 100) + } + + // Update allocation percentage + const updateAllocationPercentage = (allocationId: string, newPercentage: number) => { + const allocation = network.allocations.find(a => a.id === allocationId) + if (!allocation) return + + const updatedAllocations = network.allocations.map(a => + a.id === allocationId + ? { ...a, percentage: Math.max(0, Math.min(1, newPercentage)) } + : a + ) + + const sourceAllocations = updatedAllocations.filter( + a => a.sourceNodeId === allocation.sourceNodeId + ) + const normalized = normalizeFlowAllocations(sourceAllocations) + + const finalAllocations = updatedAllocations.map(a => { + const normalizedVersion = normalized.find(n => n.id === a.id) + return normalizedVersion || a + }) + + const updatedNetwork = { + ...network, + allocations: finalAllocations, + } + + setNetwork(updatedNetwork) + + // Re-propagate + setTimeout(() => { + const result = propagateFlow(updatedNetwork) + setNetwork(result.network) + setParticles(result.particles) + }, 100) + } + + // Delete allocation + const deleteAllocation = (allocationId: string) => { + const allocation = network.allocations.find(a => a.id === allocationId) + if (!allocation) return + + const updatedAllocations = network.allocations.filter(a => a.id !== allocationId) + + const sourceAllocations = updatedAllocations.filter( + a => a.sourceNodeId === allocation.sourceNodeId + ) + const normalized = normalizeFlowAllocations(sourceAllocations) + + const finalAllocations = updatedAllocations.map(a => { + const normalizedVersion = normalized.find(n => n.id === a.id) + return normalizedVersion || a + }) + + const updatedNetwork = { + ...network, + allocations: finalAllocations, + } + + setNetwork(updatedNetwork) + setSelectedAllocationId(null) + + // Re-propagate + setTimeout(() => handlePropagateFlow(), 100) + } + + // Load network + const handleLoadNetwork = (key: string) => { + setSelectedNetworkKey(key) + const newNetwork = getFlowSampleNetwork(key as keyof typeof flowSampleNetworks) + setNetwork(newNetwork) + setSelectedNodeId(null) + setSelectedAllocationId(null) + setAllocationSourceId(null) + setTool('select') + + // Propagate initial flows + setTimeout(() => handlePropagateFlow(), 100) + } + + // Get selected details + const selectedNode = selectedNodeId + ? network.nodes.find(n => n.id === selectedNodeId) + : null + + const selectedAllocation = selectedAllocationId + ? network.allocations.find(a => a.id === selectedAllocationId) + : null + + const outgoingAllocations = selectedNode + ? network.allocations.filter(a => a.sourceNodeId === selectedNode.id) + : [] + + const selectedAllocationSiblings = selectedAllocation + ? network.allocations.filter(a => a.sourceNodeId === selectedAllocation.sourceNodeId) + : [] + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setTool('select') + setAllocationSourceId(null) + setSelectedNodeId(null) + setSelectedAllocationId(null) + } else if (e.key === 'Delete' && selectedAllocationId) { + deleteAllocation(selectedAllocationId) + } else if (e.key === ' ') { + e.preventDefault() + setIsAnimating(prev => !prev) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [selectedAllocationId]) + + return ( +
+ {/* Header */} +
+
+

+ Flow-Based Flow Funding +

+

+ Resource circulation visualization +

+
+
+ + Stock Model β†’ + + + ← Home + +
+
+ + {/* Main Content */} +
+ {/* Canvas */} +
+ + + {/* Tool indicator */} + {allocationSourceId && ( +
+ Click target node to create allocation +
+ )} + + {/* Animation status */} +
+ {isAnimating ? '▢️ Animating' : '⏸️ Paused'} (Space to toggle) +
+
+ + {/* Sidebar */} +
+ {/* Tools */} +
+

Tools

+
+ + +
+
+ + {/* Network Selector */} +
+

Select Network

+ +
+ + {/* Network Info */} +
+

{network.name}

+
+
+ Nodes: + {network.nodes.filter(n => !n.isOverflowSink).length} +
+
+ Allocations: + {network.allocations.length} +
+
+ Total Inflow: + {formatFlow(network.totalInflow)} +
+
+ Total Absorbed: + {formatFlow(network.totalAbsorbed)} +
+
+ Total Outflow: + {formatFlow(network.totalOutflow)} +
+
+
+ + {/* Set Flow Input */} + {selectedNode && !selectedNode.isOverflowSink && ( +
+

πŸ’§ Set Flow Input

+
+
+ + handleSetNodeFlow(selectedNode.id, parseFloat(e.target.value) || 0)} + className="w-full px-3 py-2 bg-slate-700 rounded text-sm" + min="0" + step="10" + /> +
+
+ Current inflow: {formatFlow(selectedNode.inflow)} +
+ Absorbed: {formatFlow(selectedNode.absorbed)} +
+ Outflow: {formatFlow(selectedNode.outflow)} +
+
+
+ )} + + {/* Selected Allocation Editor */} + {selectedAllocation && ( +
+

Edit Allocation

+
+
+ From: + + {network.nodes.find(n => n.id === selectedAllocation.sourceNodeId)?.name} + +
+
+ To: + + {network.nodes.find(n => n.id === selectedAllocation.targetNodeId)?.name} + +
+
+ + {selectedAllocationSiblings.length === 1 ? ( +
+ Single allocation must be 100%. +
+ ) : ( + + updateAllocationPercentage( + selectedAllocation.id, + parseFloat(e.target.value) / 100 + ) + } + className="w-full" + /> + )} +
+ +
+
+ )} + + {/* Selected Node Details */} + {selectedNode && ( +
+

Node Details

+
+
+ Name: + {selectedNode.name} +
+
+ Status: + + {selectedNode.status.toUpperCase()} + +
+
+
+ Inflow: + {formatFlow(selectedNode.inflow)} +
+
+ Absorbed: + {formatFlow(selectedNode.absorbed)} +
+
+ Outflow: + {formatFlow(selectedNode.outflow)} +
+
+ Min Absorption: + {formatFlow(selectedNode.minAbsorption)} +
+
+ Max Absorption: + {formatFlow(selectedNode.maxAbsorption)} +
+
+ + {/* Outgoing Allocations */} + {outgoingAllocations.length > 0 && ( +
+
Outgoing Allocations:
+ {outgoingAllocations.map((alloc) => { + const target = network.nodes.find(n => n.id === alloc.targetNodeId) + return ( +
setSelectedAllocationId(alloc.id)} + > + β†’ {target?.name} + + {Math.round(alloc.percentage * 100)}% + +
+ ) + })} +
+ )} +
+
+ )} + + {/* Legend */} +
+

Legend

+
+
+
+ Starved - Below minimum absorption +
+
+
+ Minimum - At minimum absorption +
+
+
+ Healthy - Between min and max +
+
+
+ Saturated - At maximum capacity +
+
+
+ Particle - Flow animation +
+
+
+ + {/* Instructions */} +
+

+ Flow-Based Model +

+
    +
  • Click node to select and set flow
  • +
  • Use Create Arrow to draw allocations
  • +
  • Watch flows propagate in real-time
  • +
  • Press Space to pause/play animation
  • +
  • Overflow sink appears automatically if needed
  • +
+
+
+
+
+ ) +} diff --git a/components/hero-section.tsx b/components/hero-section.tsx index b2c6c91..81e9845 100644 --- a/components/hero-section.tsx +++ b/components/hero-section.tsx @@ -36,12 +36,16 @@ export function HeroSection() {

- -
diff --git a/lib/tbff-flow/README.md b/lib/tbff-flow/README.md new file mode 100644 index 0000000..e57ce7f --- /dev/null +++ b/lib/tbff-flow/README.md @@ -0,0 +1,404 @@ +# Flow-Based Flow Funding Module + +**Status**: Initial Implementation Complete βœ… +**Route**: `/tbff-flow` +**Last Updated**: 2025-11-09 + +--- + +## Overview + +This module implements a **flow-based** visualization of Flow Funding, focusing on resource circulation rather than accumulation. Unlike the stock-based model (`/tbff`), this visualizes how resources move through networks in real-time. + +## Core Concepts + +### Flow vs Stock + +**Stock Model** (`/tbff`): +- Accounts have balances (accumulated resources) +- Distribution adds to balances +- Thresholds define states (deficit, healthy, overflow) +- Overflow triggers redistribution + +**Flow Model** (`/tbff-flow`): +- Nodes have flow rates (resources in motion) +- Flow continuously circulates +- Thresholds define absorption capacity +- Real-time animation shows circulation + +### 1. Flow Node + +Each node receives flow, absorbs what it needs, and passes excess onward. + +**Properties**: +- `minAbsorption`: Minimum flow needed to function (survival level) +- `maxAbsorption`: Maximum flow that can be absorbed (capacity) +- `externalFlow`: Flow injected by user (source) +- `inflow`: Total flow entering (external + from allocations) +- `absorbed`: Amount kept (between min and max) +- `outflow`: Excess flow leaving (to allocations) + +**Status**: +- πŸ”΄ **Starved**: absorbed < minAbsorption +- 🟑 **Minimum**: absorbed β‰ˆ minAbsorption +- πŸ”΅ **Healthy**: minAbsorption < absorbed < maxAbsorption +- 🟒 **Saturated**: absorbed β‰₯ maxAbsorption + +### 2. Flow Propagation + +**Algorithm**: +1. Start with external flows (user-set inputs) +2. For each iteration: + - Calculate absorption: `min(inflow, maxAbsorption)` + - Calculate outflow: `max(0, inflow - absorbed)` + - Distribute outflow via allocations + - Update inflows for next iteration +3. Repeat until convergence (flows stabilize) +4. Create overflow sink if needed + +**Convergence**: Flows change by less than 0.01 between iterations + +### 3. Overflow Sink + +**Auto-created** when network has unallocated outflow. + +**Purpose**: Capture excess flow that has nowhere to go + +**Behavior**: +- Appears automatically when needed +- Disappears when no longer needed +- Infinite absorption capacity +- Visualized with "SINK" label + +### 4. Flow Particles + +**Animation**: Particles move along allocation arrows + +**Properties**: +- Position along arrow (0.0 to 1.0) +- Amount (affects size and color) +- Speed (faster for higher flow) +- Continuous loop (resets at end) + +**Purpose**: Visual feedback of resource circulation + +--- + +## Module Structure + +``` +lib/tbff-flow/ +β”œβ”€β”€ types.ts # Flow-based types (FlowNode, FlowNetwork, etc.) +β”œβ”€β”€ utils.ts # Utility functions (absorption, status, etc.) +β”œβ”€β”€ algorithms.ts # Flow propagation algorithm +β”œβ”€β”€ rendering.ts # Canvas rendering with particles +β”œβ”€β”€ sample-networks.ts # Demo networks (linear, split, circular) +└── README.md # This file + +app/tbff-flow/ +└── page.tsx # Main page with real-time animation +``` + +--- + +## Features + +### βœ… Implemented + +1. **Flow Propagation Algorithm** + - Iterative flow distribution + - Convergence detection + - Comprehensive console logging + - Automatic overflow node creation + +2. **Real-Time Animation** + - Continuous particle movement + - Pause/play with spacebar + - Smooth 60fps rendering + - Visual flow indicators + +3. **Interactive Controls** + - Click node to set external flow + - Create allocations with arrow tool + - Edit allocation percentages + - Delete allocations + +4. **Sample Networks** + - Linear Flow (A β†’ B β†’ C) + - Split Flow (Projects + Commons) + - Circular Flow (A ↔ B ↔ C) + - Empty Network (build your own) + +5. **Visual Design** + - Flow bars show inflow/absorption/outflow + - Status colors (red/yellow/blue/green) + - Arrow thickness = flow amount + - Particle density = flow volume + - External flow indicators (green dots) + +--- + +## Usage + +### Setting Flow + +1. Select a node (click on it) +2. Enter external flow value in sidebar +3. Watch flow propagate automatically +4. See particles animate along arrows + +### Creating Allocations + +1. Click "Create Arrow" tool +2. Click source node +3. Click target node +4. Allocation created with 50% default +5. Flow re-propagates automatically + +### Observing Flow + +- **Inflow bars** (blue): Total flow entering +- **Absorption bars** (status color): Amount kept +- **Outflow bars** (green): Excess leaving +- **Particles**: Moving resources +- **Arrow thickness**: Flow amount + +### Keyboard Shortcuts + +- **Space**: Pause/play animation +- **Escape**: Cancel creation, deselect +- **Delete**: Remove selected allocation + +--- + +## Sample Networks + +### 1. Linear Flow + +**Structure**: A β†’ B β†’ C + +**Setup**: Alice receives 100 flow, passes excess to Bob, who passes to Carol + +**Demonstrates**: Sequential absorption and propagation + +### 2. Split Flow + +**Structure**: Source β†’ Projects (A, B) β†’ Commons + +**Setup**: Source splits 60/40 between projects, which merge at Commons + +**Demonstrates**: Branching and merging flows + +### 3. Circular Flow + +**Structure**: A β†’ B β†’ C β†’ A + +**Setup**: Alice injects 50 flow, which circulates continuously + +**Demonstrates**: Circular resource circulation + +### 4. Empty Network + +**Structure**: 3 unconnected nodes + +**Purpose**: Build custom flow patterns from scratch + +--- + +## Algorithm Details + +### Flow Propagation Example + +``` +Initial State: + Alice: externalFlow = 100, maxAbsorption = 50 + Bob: externalFlow = 0, maxAbsorption = 30 + Alice β†’ Bob (100%) + +Iteration 1: + Alice: inflow = 100, absorbed = 50, outflow = 50 + Bob: inflow = 0 (not yet received) + +Iteration 2: + Alice: inflow = 100, absorbed = 50, outflow = 50 + Bob: inflow = 50, absorbed = 30, outflow = 20 + +Iteration 3 onwards: + (Converged - no change) + +Result: + Alice absorbs 50, passes 50 to Bob + Bob absorbs 30, has 20 outflow (needs overflow node) +``` + +### Convergence + +**Condition**: `max(|inflow[i] - inflow[i-1]|) < 0.01` for all nodes + +**Max Iterations**: 100 + +**Typical**: Converges in 10-20 iterations for most networks + +--- + +## Technical Implementation + +### State Management + +```typescript +const [network, setNetwork] = useState(...) +const [particles, setParticles] = useState([]) +const [isAnimating, setIsAnimating] = useState(true) +``` + +### Animation Loop + +```typescript +useEffect(() => { + const animate = () => { + setParticles(prev => updateFlowParticles(prev)) + requestAnimationFrame(animate) + } + requestAnimationFrame(animate) +}, [isAnimating]) +``` + +### Canvas Rendering + +```typescript +useEffect(() => { + renderFlowNetwork(ctx, network, width, height, particles, ...) +}, [network, particles, selectedNodeId, selectedAllocationId]) +``` + +### Flow Propagation Trigger + +- On network load +- On external flow change +- On allocation create/update/delete +- Automatic 100ms after change + +--- + +## Design Decisions + +### 1. Why Separate from Stock Model? + +**Decision**: Create `/tbff-flow` as separate route + +**Reasoning**: +- Different mental models (flow vs stock) +- Different visualizations (particles vs bars) +- Different algorithms (propagation vs distribution) +- Users can compare both approaches + +### 2. Why Real-Time Continuous Animation? + +**Decision**: Particles move continuously at 60fps + +**Reasoning**: +- Emphasizes circulation over states +- More engaging and dynamic +- Matches "flow" concept intuitively +- Educational - see resources in motion + +**Trade-off**: More CPU usage vs better UX + +### 3. Why Auto-Create Overflow Node? + +**Decision**: Automatically create/remove overflow sink + +**Reasoning**: +- Unallocated outflow needs destination +- Prevents "leaking" flow +- Conserves resources (total inflow = absorbed + overflow) +- User shouldn't have to manually manage + +### 4. Why Absorption Thresholds? + +**Decision**: Min/max thresholds define absorption capacity + +**Reasoning**: +- Maps to real resource needs (minimum to function, maximum to benefit) +- Similar to stock model (easy to understand) +- Allows partial absorption (not all-or-nothing) +- Generates meaningful outflow for circulation + +--- + +## Comparison with Stock Model + +| Aspect | Stock Model (`/tbff`) | Flow Model (`/tbff-flow`) | +|--------|----------------------|---------------------------| +| **Core Metric** | Balance (accumulated) | Flow rate (per time) | +| **Visualization** | Fill height | Flow bars + particles | +| **Input** | Add funding (one-time) | Set flow (continuous) | +| **Algorithm** | Initial distribution | Flow propagation | +| **Animation** | Static (for now) | Real-time particles | +| **Overflow** | Triggers redistribution | Continuous outflow | +| **Use Case** | Budget allocation | Resource circulation | + +--- + +## Future Enhancements + +### Phase 2 + +- [ ] Variable particle colors (by source) +- [ ] Flow rate history graphs +- [ ] Equilibrium detection indicator +- [ ] Save/load custom networks +- [ ] Export flow data (CSV, JSON) + +### Phase 3 + +- [ ] Multiple simultaneous external flows +- [ ] Time-varying flows (pulses, waves) +- [ ] Flow constraints (min/max on arrows) +- [ ] Network analysis (bottlenecks, unutilized capacity) + +### Phase 4 + +- [ ] Combine stock and flow models +- [ ] Hybrid visualization +- [ ] Round-based simulation mode +- [ ] Multi-network comparison + +--- + +## Resources + +- **Stock Model**: `/lib/tbff/README.md` +- **Design Session**: `/.claude/journal/FLOW_FUNDING_DESIGN_SESSION.md` +- **Academic Paper**: `../../../threshold-based-flow-funding.md` + +--- + +## Testing Checklist + +- [x] Load default network (Linear Flow) +- [x] Switch between sample networks +- [x] Set external flow on node +- [x] Watch flow propagate +- [x] See particles animate +- [x] Create allocation with arrow tool +- [x] Edit allocation percentage +- [x] Delete allocation +- [x] Pause/play animation with Space +- [x] See overflow node appear when needed +- [x] See overflow node disappear when not needed +- [x] Check console for propagation logs +- [x] Verify convergence +- [x] Test circular flow network + +--- + +**Built with**: TypeScript, React, Next.js, Canvas API, requestAnimationFrame + +**Module Owner**: TBFF Flow Team + +**Philosophy**: "Resources are meant to circulate, not accumulate." + +--- + +*Flow where needed, absorb what's needed, pass on the rest.* diff --git a/lib/tbff-flow/algorithms.ts b/lib/tbff-flow/algorithms.ts new file mode 100644 index 0000000..4a19c6e --- /dev/null +++ b/lib/tbff-flow/algorithms.ts @@ -0,0 +1,267 @@ +/** + * Flow propagation algorithms + * + * Models how flow circulates through the network: + * 1. Flow enters nodes (external + from allocations) + * 2. Nodes absorb what they can (up to max threshold) + * 3. Excess flows out via allocations + * 4. Repeat until steady state + */ + +import type { FlowNetwork, FlowNode, FlowParticle, FlowPropagationResult } from './types' +import { updateFlowNodeProperties, calculateFlowNetworkTotals, createOverflowNode, needsOverflowNode } from './utils' + +const MAX_ITERATIONS = 100 +const CONVERGENCE_THRESHOLD = 0.01 + +/** + * Propagate flow through the network + * + * Algorithm: + * 1. Reset all inflows to external flow only + * 2. For each iteration: + * a. Calculate absorption and outflow for each node + * b. Distribute outflow via allocations + * c. Update inflows for next iteration + * 3. Repeat until flows stabilize or max iterations + * 4. Create overflow node if needed + * 5. Generate flow particles for animation + * + * @param network - Current network state + * @returns Updated network with flow propagation results + */ +export function propagateFlow(network: FlowNetwork): FlowPropagationResult { + console.log('\n🌊 Flow Propagation Started') + console.log('━'.repeat(50)) + + let currentNetwork = { ...network } + let iterations = 0 + let converged = false + + // Initialize: set inflow to external flow + let nodes = currentNetwork.nodes.map(node => ({ + ...node, + inflow: node.externalFlow, + })) + + console.log('Initial external flows:') + nodes.forEach(node => { + if (node.externalFlow > 0) { + console.log(` ${node.name}: ${node.externalFlow.toFixed(1)}`) + } + }) + + // Iterate until convergence + while (iterations < MAX_ITERATIONS && !converged) { + iterations++ + + // Step 1: Calculate absorption and outflow for each node + nodes = nodes.map(updateFlowNodeProperties) + + // Step 2: Calculate new inflows from allocations + const newInflows = new Map() + + // Initialize with external flows + nodes.forEach(node => { + newInflows.set(node.id, node.externalFlow) + }) + + // Add flow from allocations + nodes.forEach(sourceNode => { + if (sourceNode.outflow <= 0) return + + // Get allocations from this node + const allocations = currentNetwork.allocations.filter( + a => a.sourceNodeId === sourceNode.id + ) + + if (allocations.length === 0) { + // No allocations - this flow will need overflow node + return + } + + // Distribute outflow via allocations + allocations.forEach(allocation => { + const flowToTarget = sourceNode.outflow * allocation.percentage + const currentInflow = newInflows.get(allocation.targetNodeId) || 0 + newInflows.set(allocation.targetNodeId, currentInflow + flowToTarget) + }) + }) + + // Step 3: Check for convergence + let maxChange = 0 + nodes.forEach(node => { + const newInflow = newInflows.get(node.id) || node.externalFlow + const change = Math.abs(newInflow - node.inflow) + maxChange = Math.max(maxChange, change) + }) + + converged = maxChange < CONVERGENCE_THRESHOLD + + // Step 4: Update inflows for next iteration + nodes = nodes.map(node => ({ + ...node, + inflow: newInflows.get(node.id) || node.externalFlow, + })) + + if (iterations % 10 === 0 || converged) { + console.log(`Iteration ${iterations}: max change = ${maxChange.toFixed(3)}`) + } + } + + console.log(`\n${converged ? 'βœ“' : '⚠️'} ${converged ? 'Converged' : 'Max iterations reached'} after ${iterations} iterations`) + + // Final property update + nodes = nodes.map(updateFlowNodeProperties) + + // Check if we need an overflow node + let finalNodes = nodes + let overflowNodeId: string | null = currentNetwork.overflowNodeId + + const needsOverflow = needsOverflowNode({ ...currentNetwork, nodes }) + + if (needsOverflow && !overflowNodeId) { + // Create overflow node + const overflowNode = createOverflowNode(600, 300) + finalNodes = [...nodes, overflowNode] + overflowNodeId = overflowNode.id + + console.log('\nπŸ’§ Created overflow sink node') + + // Calculate total unallocated outflow + let totalUnallocated = 0 + nodes.forEach(node => { + const hasAllocations = currentNetwork.allocations.some(a => a.sourceNodeId === node.id) + if (!hasAllocations && node.outflow > 0) { + totalUnallocated += node.outflow + } + }) + + // Update overflow node + const overflowNode2 = finalNodes.find(n => n.id === overflowNodeId)! + const updatedOverflowNode = updateFlowNodeProperties({ + ...overflowNode2, + inflow: totalUnallocated, + }) + + finalNodes = finalNodes.map(n => n.id === overflowNodeId ? updatedOverflowNode : n) + + console.log(` Receiving ${totalUnallocated.toFixed(1)} unallocated flow`) + } else if (!needsOverflow && overflowNodeId) { + // Remove overflow node + finalNodes = nodes.filter(n => !n.isOverflowSink) + overflowNodeId = null + console.log('\nπŸ—‘οΈ Removed overflow sink node (no longer needed)') + } + + // Generate flow particles for animation + const particles = generateFlowParticles(currentNetwork, finalNodes) + + // Build final network + const finalNetwork = calculateFlowNetworkTotals({ + ...currentNetwork, + nodes: finalNodes, + overflowNodeId, + }) + + console.log('\nπŸ“Š Final State:') + console.log(` Total inflow: ${finalNetwork.totalInflow.toFixed(1)}`) + console.log(` Total absorbed: ${finalNetwork.totalAbsorbed.toFixed(1)}`) + console.log(` Total outflow: ${finalNetwork.totalOutflow.toFixed(1)}`) + + console.log('\n🎯 Node States:') + finalNodes.forEach(node => { + if (node.inflow > 0 || node.absorbed > 0 || node.outflow > 0) { + console.log( + ` ${node.name.padEnd(15)} ` + + `in: ${node.inflow.toFixed(1).padStart(6)} ` + + `abs: ${node.absorbed.toFixed(1).padStart(6)} ` + + `out: ${node.outflow.toFixed(1).padStart(6)} ` + + `[${node.status}]` + ) + } + }) + + return { + network: finalNetwork, + iterations, + converged, + particles, + } +} + +/** + * Generate flow particles for animation + * Creates particles traveling along allocations based on flow amount + */ +function generateFlowParticles(network: FlowNetwork, nodes: FlowNode[]): FlowParticle[] { + const particles: FlowParticle[] = [] + let particleId = 0 + + // Create particles for each allocation based on flow amount + network.allocations.forEach(allocation => { + const sourceNode = nodes.find(n => n.id === allocation.sourceNodeId) + if (!sourceNode || sourceNode.outflow <= 0) return + + const flowAmount = sourceNode.outflow * allocation.percentage + + // Create particles proportional to flow amount + // More flow = more particles + const particleCount = Math.min(10, Math.max(1, Math.floor(flowAmount / 10))) + + for (let i = 0; i < particleCount; i++) { + particles.push({ + id: `particle_${particleId++}`, + allocationId: allocation.id, + progress: i / particleCount, // Spread along arrow + amount: flowAmount / particleCount, + speed: 0.01 + (flowAmount / 1000), // Faster for more flow + }) + } + }) + + // Create particles for overflow node flows + nodes.forEach(node => { + if (node.outflow > 0) { + const hasAllocations = network.allocations.some(a => a.sourceNodeId === node.id) + if (!hasAllocations && network.overflowNodeId) { + // Create virtual allocation to overflow node + const particleCount = Math.min(5, Math.max(1, Math.floor(node.outflow / 20))) + for (let i = 0; i < particleCount; i++) { + particles.push({ + id: `particle_overflow_${particleId++}`, + allocationId: `virtual_${node.id}_overflow`, + progress: i / particleCount, + amount: node.outflow / particleCount, + speed: 0.01, + }) + } + } + } + }) + + return particles +} + +/** + * Update particles animation + * Moves particles along their paths + */ +export function updateFlowParticles(particles: FlowParticle[]): FlowParticle[] { + return particles.map(particle => { + const newProgress = particle.progress + particle.speed + + // If particle reached end, reset to beginning + if (newProgress >= 1.0) { + return { + ...particle, + progress: 0, + } + } + + return { + ...particle, + progress: newProgress, + } + }) +} diff --git a/lib/tbff-flow/rendering.ts b/lib/tbff-flow/rendering.ts new file mode 100644 index 0000000..dbd32df --- /dev/null +++ b/lib/tbff-flow/rendering.ts @@ -0,0 +1,319 @@ +/** + * Canvas rendering for flow-based visualization + */ + +import type { FlowNetwork, FlowNode, FlowAllocation, FlowParticle } from './types' +import { getFlowNodeCenter, getFlowStatusColor } from './utils' + +/** + * Render a flow node + * Shows inflow, absorption, and outflow rates + */ +export function renderFlowNode( + ctx: CanvasRenderingContext2D, + node: FlowNode, + isSelected: boolean = false +): void { + const { x, y, width, height } = node + + // Background + ctx.fillStyle = isSelected ? '#1e293b' : '#0f172a' + ctx.fillRect(x, y, width, height) + + // Border (thicker if selected) + ctx.strokeStyle = isSelected ? '#06b6d4' : '#334155' + ctx.lineWidth = isSelected ? 3 : 1 + ctx.strokeRect(x, y, width, height) + + // Flow visualization bars + const barWidth = width - 20 + const barHeight = 8 + const barX = x + 10 + let barY = y + 25 + + // Inflow bar (blue) + if (node.inflow > 0) { + const inflowPercent = Math.min(1, node.inflow / node.maxAbsorption) + ctx.fillStyle = 'rgba(59, 130, 246, 0.7)' + ctx.fillRect(barX, barY, barWidth * inflowPercent, barHeight) + ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)' + ctx.strokeRect(barX, barY, barWidth, barHeight) + } + + barY += barHeight + 4 + + // Absorption bar (status color) + if (node.absorbed > 0) { + const absorbedPercent = Math.min(1, node.absorbed / node.maxAbsorption) + ctx.fillStyle = getFlowStatusColor(node.status, 0.7) + ctx.fillRect(barX, barY, barWidth * absorbedPercent, barHeight) + ctx.strokeStyle = getFlowStatusColor(node.status, 0.5) + ctx.strokeRect(barX, barY, barWidth, barHeight) + } + + barY += barHeight + 4 + + // Outflow bar (green) + if (node.outflow > 0) { + const outflowPercent = Math.min(1, node.outflow / node.maxAbsorption) + ctx.fillStyle = 'rgba(16, 185, 129, 0.7)' + ctx.fillRect(barX, barY, barWidth * outflowPercent, barHeight) + ctx.strokeStyle = 'rgba(16, 185, 129, 0.5)' + ctx.strokeRect(barX, barY, barWidth, barHeight) + } + + // Node name + ctx.fillStyle = '#f1f5f9' + ctx.font = 'bold 14px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(node.name, x + width / 2, y + 16) + + // Flow rates + ctx.font = '10px monospace' + ctx.fillStyle = '#94a3b8' + const textX = x + width / 2 + let textY = y + height - 30 + + if (node.inflow > 0) { + ctx.fillText(`↓ ${node.inflow.toFixed(1)}`, textX, textY) + textY += 12 + } + if (node.absorbed > 0) { + ctx.fillText(`βŠ™ ${node.absorbed.toFixed(1)}`, textX, textY) + textY += 12 + } + if (node.outflow > 0) { + ctx.fillText(`↑ ${node.outflow.toFixed(1)}`, textX, textY) + } + + // External flow indicator + if (node.externalFlow > 0 && !node.isOverflowSink) { + ctx.fillStyle = '#10b981' + ctx.beginPath() + ctx.arc(x + width - 10, y + 10, 5, 0, 2 * Math.PI) + ctx.fill() + } + + // Overflow sink indicator + if (node.isOverflowSink) { + ctx.fillStyle = '#64748b' + ctx.font = 'bold 12px sans-serif' + ctx.textAlign = 'center' + ctx.fillText('SINK', x + width / 2, y + height / 2) + } + + // Center dot for connections + const centerX = x + width / 2 + const centerY = y + height / 2 + ctx.fillStyle = isSelected ? '#06b6d4' : '#475569' + ctx.beginPath() + ctx.arc(centerX, centerY, 4, 0, 2 * Math.PI) + ctx.fill() +} + +/** + * Render a flow allocation arrow + * Thickness represents flow amount + */ +export function renderFlowAllocation( + ctx: CanvasRenderingContext2D, + allocation: FlowAllocation, + sourceNode: FlowNode, + targetNode: FlowNode, + isSelected: boolean = false +): void { + const source = getFlowNodeCenter(sourceNode) + const target = getFlowNodeCenter(targetNode) + + // Calculate arrow properties + const dx = target.x - source.x + const dy = target.y - source.y + const angle = Math.atan2(dy, dx) + const length = Math.sqrt(dx * dx + dy * dy) + + // Shorten arrow to not overlap nodes + const shortenStart = 60 + const shortenEnd = 60 + const startX = source.x + (shortenStart / length) * dx + const startY = source.y + (shortenStart / length) * dy + const endX = target.x - (shortenEnd / length) * dx + const endY = target.y - (shortenEnd / length) * dy + + // Arrow thickness based on flow amount + const flowAmount = sourceNode.outflow * allocation.percentage + const thickness = Math.max(2, Math.min(12, 2 + flowAmount / 10)) + + // Color based on selection and flow amount + const hasFlow = flowAmount > 0.1 + const baseColor = isSelected ? '#06b6d4' : hasFlow ? '#10b981' : '#475569' + const alpha = hasFlow ? 0.8 : 0.3 + + // Draw arrow line + ctx.strokeStyle = baseColor + ctx.globalAlpha = alpha + ctx.lineWidth = thickness + ctx.lineCap = 'round' + + ctx.beginPath() + ctx.moveTo(startX, startY) + ctx.lineTo(endX, endY) + ctx.stroke() + + // Draw arrowhead + const headSize = 10 + thickness + ctx.fillStyle = baseColor + ctx.beginPath() + ctx.moveTo(endX, endY) + ctx.lineTo( + endX - headSize * Math.cos(angle - Math.PI / 6), + endY - headSize * Math.sin(angle - Math.PI / 6) + ) + ctx.lineTo( + endX - headSize * Math.cos(angle + Math.PI / 6), + endY - headSize * Math.sin(angle + Math.PI / 6) + ) + ctx.closePath() + ctx.fill() + + ctx.globalAlpha = 1.0 + + // Label with flow amount + if (hasFlow || isSelected) { + const midX = (startX + endX) / 2 + const midY = (startY + endY) / 2 + + // Background for text + ctx.fillStyle = '#0f172a' + ctx.fillRect(midX - 20, midY - 8, 40, 16) + + // Text + ctx.fillStyle = baseColor + ctx.font = '11px monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(flowAmount.toFixed(1), midX, midY) + } +} + +/** + * Render flow particles moving along allocations + */ +export function renderFlowParticles( + ctx: CanvasRenderingContext2D, + particles: FlowParticle[], + network: FlowNetwork +): void { + particles.forEach(particle => { + // Find the allocation + const allocation = network.allocations.find(a => a.id === particle.allocationId) + if (!allocation) { + // Handle virtual overflow allocations + if (particle.allocationId.startsWith('virtual_')) { + const sourceNodeId = particle.allocationId.split('_')[1] + const sourceNode = network.nodes.find(n => n.id === sourceNodeId) + const overflowNode = network.nodes.find(n => n.isOverflowSink) + if (sourceNode && overflowNode) { + renderParticle(ctx, particle, sourceNode, overflowNode) + } + } + return + } + + const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId) + const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId) + + if (!sourceNode || !targetNode) return + + renderParticle(ctx, particle, sourceNode, targetNode) + }) +} + +/** + * Render a single particle + */ +function renderParticle( + ctx: CanvasRenderingContext2D, + particle: FlowParticle, + sourceNode: FlowNode, + targetNode: FlowNode +): void { + const source = getFlowNodeCenter(sourceNode) + const target = getFlowNodeCenter(targetNode) + + // Interpolate position + const x = source.x + (target.x - source.x) * particle.progress + const y = source.y + (target.y - source.y) * particle.progress + + // Particle size based on amount + const size = Math.max(3, Math.min(8, particle.amount / 10)) + + // Draw particle + ctx.fillStyle = '#10b981' + ctx.globalAlpha = 0.8 + ctx.beginPath() + ctx.arc(x, y, size, 0, 2 * Math.PI) + ctx.fill() + ctx.globalAlpha = 1.0 + + // Glow effect + const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2) + gradient.addColorStop(0, 'rgba(16, 185, 129, 0.3)') + gradient.addColorStop(1, 'rgba(16, 185, 129, 0)') + ctx.fillStyle = gradient + ctx.beginPath() + ctx.arc(x, y, size * 2, 0, 2 * Math.PI) + ctx.fill() +} + +/** + * Render entire flow network + */ +export function renderFlowNetwork( + ctx: CanvasRenderingContext2D, + network: FlowNetwork, + canvasWidth: number, + canvasHeight: number, + particles: FlowParticle[], + selectedNodeId: string | null = null, + selectedAllocationId: string | null = null +): void { + // Clear canvas + ctx.fillStyle = '#0f172a' + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + // Draw allocations (arrows) first + network.allocations.forEach(allocation => { + const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId) + const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId) + if (sourceNode && targetNode) { + renderFlowAllocation( + ctx, + allocation, + sourceNode, + targetNode, + allocation.id === selectedAllocationId + ) + } + }) + + // Draw particles + renderFlowParticles(ctx, particles, network) + + // Draw nodes on top + network.nodes.forEach(node => { + renderFlowNode(ctx, node, node.id === selectedNodeId) + }) + + // Draw network stats in corner + ctx.fillStyle = '#f1f5f9' + ctx.font = '12px monospace' + ctx.textAlign = 'left' + const statsX = 10 + let statsY = 20 + + ctx.fillText(`Inflow: ${network.totalInflow.toFixed(1)}`, statsX, statsY) + statsY += 16 + ctx.fillText(`Absorbed: ${network.totalAbsorbed.toFixed(1)}`, statsX, statsY) + statsY += 16 + ctx.fillText(`Outflow: ${network.totalOutflow.toFixed(1)}`, statsX, statsY) +} diff --git a/lib/tbff-flow/sample-networks.ts b/lib/tbff-flow/sample-networks.ts new file mode 100644 index 0000000..f462563 --- /dev/null +++ b/lib/tbff-flow/sample-networks.ts @@ -0,0 +1,329 @@ +/** + * Sample flow networks for demonstration + */ + +import type { FlowNetwork, FlowNode } from './types' +import { updateFlowNodeProperties, calculateFlowNetworkTotals } from './utils' + +/** + * Create a simple linear flow: A β†’ B β†’ C + * Flow enters A, passes through to C + */ +export function createLinearFlowNetwork(): FlowNetwork { + const nodes: FlowNode[] = [ + { + id: 'alice', + name: 'Alice', + x: 100, + y: 200, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 50, + inflow: 100, // Start with 100 flow + absorbed: 0, + outflow: 0, + status: 'healthy', + externalFlow: 100, + isOverflowSink: false, + }, + { + id: 'bob', + name: 'Bob', + x: 300, + y: 200, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 30, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + { + id: 'carol', + name: 'Carol', + x: 500, + y: 200, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 40, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + ] + + const allocations = [ + { id: 'alloc_1', sourceNodeId: 'alice', targetNodeId: 'bob', percentage: 1.0 }, + { id: 'alloc_2', sourceNodeId: 'bob', targetNodeId: 'carol', percentage: 1.0 }, + ] + + return calculateFlowNetworkTotals({ + name: 'Linear Flow (A β†’ B β†’ C)', + nodes: nodes.map(updateFlowNodeProperties), + allocations, + totalInflow: 0, + totalAbsorbed: 0, + totalOutflow: 0, + overflowNodeId: null, + }) +} + +/** + * Create a split flow: A β†’ B and C + * Flow enters A, splits between B and C + */ +export function createSplitFlowNetwork(): FlowNetwork { + const nodes: FlowNode[] = [ + { + id: 'source', + name: 'Source', + x: 100, + y: 200, + width: 120, + height: 100, + minAbsorption: 5, + maxAbsorption: 20, + inflow: 100, + absorbed: 0, + outflow: 0, + status: 'healthy', + externalFlow: 100, + isOverflowSink: false, + }, + { + id: 'project_a', + name: 'Project A', + x: 300, + y: 100, + width: 120, + height: 100, + minAbsorption: 15, + maxAbsorption: 40, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + { + id: 'project_b', + name: 'Project B', + x: 300, + y: 300, + width: 120, + height: 100, + minAbsorption: 15, + maxAbsorption: 40, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + { + id: 'commons', + name: 'Commons', + x: 500, + y: 200, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 30, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + ] + + const allocations = [ + { id: 'alloc_1', sourceNodeId: 'source', targetNodeId: 'project_a', percentage: 0.6 }, + { id: 'alloc_2', sourceNodeId: 'source', targetNodeId: 'project_b', percentage: 0.4 }, + { id: 'alloc_3', sourceNodeId: 'project_a', targetNodeId: 'commons', percentage: 1.0 }, + { id: 'alloc_4', sourceNodeId: 'project_b', targetNodeId: 'commons', percentage: 1.0 }, + ] + + return calculateFlowNetworkTotals({ + name: 'Split Flow (Source β†’ Projects β†’ Commons)', + nodes: nodes.map(updateFlowNodeProperties), + allocations, + totalInflow: 0, + totalAbsorbed: 0, + totalOutflow: 0, + overflowNodeId: null, + }) +} + +/** + * Create a circular flow: A β†’ B β†’ C β†’ A + * Flow circulates through the network + */ +export function createCircularFlowNetwork(): FlowNetwork { + const centerX = 350 + const centerY = 250 + const radius = 150 + + const nodes: FlowNode[] = [ + { + id: 'alice', + name: 'Alice', + x: centerX + radius * Math.cos(0) - 60, + y: centerY + radius * Math.sin(0) - 50, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 30, + inflow: 50, + absorbed: 0, + outflow: 0, + status: 'healthy', + externalFlow: 50, + isOverflowSink: false, + }, + { + id: 'bob', + name: 'Bob', + x: centerX + radius * Math.cos((2 * Math.PI) / 3) - 60, + y: centerY + radius * Math.sin((2 * Math.PI) / 3) - 50, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 30, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + { + id: 'carol', + name: 'Carol', + x: centerX + radius * Math.cos((4 * Math.PI) / 3) - 60, + y: centerY + radius * Math.sin((4 * Math.PI) / 3) - 50, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 30, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + ] + + const allocations = [ + { id: 'alloc_1', sourceNodeId: 'alice', targetNodeId: 'bob', percentage: 1.0 }, + { id: 'alloc_2', sourceNodeId: 'bob', targetNodeId: 'carol', percentage: 1.0 }, + { id: 'alloc_3', sourceNodeId: 'carol', targetNodeId: 'alice', percentage: 1.0 }, + ] + + return calculateFlowNetworkTotals({ + name: 'Circular Flow (A β†’ B β†’ C β†’ A)', + nodes: nodes.map(updateFlowNodeProperties), + allocations, + totalInflow: 0, + totalAbsorbed: 0, + totalOutflow: 0, + overflowNodeId: null, + }) +} + +/** + * Create an empty network for user to build + */ +export function createEmptyFlowNetwork(): FlowNetwork { + const nodes: FlowNode[] = [ + { + id: 'node1', + name: 'Node 1', + x: 150, + y: 150, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 50, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + { + id: 'node2', + name: 'Node 2', + x: 350, + y: 150, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 50, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + { + id: 'node3', + name: 'Node 3', + x: 250, + y: 300, + width: 120, + height: 100, + minAbsorption: 10, + maxAbsorption: 50, + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'starved', + externalFlow: 0, + isOverflowSink: false, + }, + ] + + return calculateFlowNetworkTotals({ + name: 'Empty Network (Set flows to begin)', + nodes: nodes.map(updateFlowNodeProperties), + allocations: [], + totalInflow: 0, + totalAbsorbed: 0, + totalOutflow: 0, + overflowNodeId: null, + }) +} + +export const flowSampleNetworks = { + linear: createLinearFlowNetwork(), + split: createSplitFlowNetwork(), + circular: createCircularFlowNetwork(), + empty: createEmptyFlowNetwork(), +} + +export const flowNetworkOptions = [ + { value: 'linear', label: 'Linear Flow (A β†’ B β†’ C)' }, + { value: 'split', label: 'Split Flow (Projects + Commons)' }, + { value: 'circular', label: 'Circular Flow (A ↔ B ↔ C)' }, + { value: 'empty', label: 'Empty Network' }, +] + +export function getFlowSampleNetwork(key: keyof typeof flowSampleNetworks): FlowNetwork { + return flowSampleNetworks[key] +} diff --git a/lib/tbff-flow/types.ts b/lib/tbff-flow/types.ts new file mode 100644 index 0000000..529430a --- /dev/null +++ b/lib/tbff-flow/types.ts @@ -0,0 +1,90 @@ +/** + * Flow-based Flow Funding Types + * + * This model focuses on resource circulation rather than accumulation. + * Nodes receive flow, absorb what they need, and pass excess to others. + */ + +export type FlowNodeStatus = 'starved' | 'minimum' | 'healthy' | 'saturated' + +/** + * A node in the flow network + * Flow enters, gets partially absorbed, and excess flows out + */ +export interface FlowNode { + id: string + name: string + + // Position for rendering + x: number + y: number + width: number + height: number + + // Flow thresholds (per time unit) + minAbsorption: number // Minimum flow needed to function + maxAbsorption: number // Maximum flow that can be absorbed + + // Current flow state (computed) + inflow: number // Total flow entering this node + absorbed: number // Amount absorbed (between min and max) + outflow: number // Excess flow leaving this node + status: FlowNodeStatus // Derived from absorbed vs thresholds + + // External flow input (set by user) + externalFlow: number // Flow injected into this node + + // Special node type + isOverflowSink: boolean // True for the auto-created overflow node +} + +/** + * Allocation defines how outflow is distributed + * Same as stock model but applied to outflow instead of overflow + */ +export interface FlowAllocation { + id: string + sourceNodeId: string + targetNodeId: string + percentage: number // 0.0 to 1.0 +} + +/** + * The complete flow network + */ +export interface FlowNetwork { + name: string + nodes: FlowNode[] + allocations: FlowAllocation[] + + // Network-level computed properties + totalInflow: number // Sum of all external flows + totalAbsorbed: number // Sum of all absorbed flow + totalOutflow: number // Sum of all outflow + + // Overflow sink + overflowNodeId: string | null // ID of auto-created overflow node +} + +/** + * Flow particle for animation + * Moves along allocation arrows + */ +export interface FlowParticle { + id: string + allocationId: string // Which arrow it's traveling along + progress: number // 0.0 to 1.0 (position along arrow) + amount: number // Size/color intensity + speed: number // How fast it moves +} + +/** + * Flow propagation result + * Shows how flow moved through the network + */ +export interface FlowPropagationResult { + network: FlowNetwork + iterations: number // How many steps to converge + converged: boolean // Did it reach steady state? + particles: FlowParticle[] // Active particles for animation +} diff --git a/lib/tbff-flow/utils.ts b/lib/tbff-flow/utils.ts new file mode 100644 index 0000000..a9b93b9 --- /dev/null +++ b/lib/tbff-flow/utils.ts @@ -0,0 +1,176 @@ +/** + * Utility functions for flow-based calculations + */ + +import type { FlowNode, FlowNodeStatus, FlowNetwork, FlowAllocation } from './types' + +/** + * Calculate node status based on absorbed flow vs thresholds + */ +export function getFlowNodeStatus(node: FlowNode): FlowNodeStatus { + if (node.absorbed < node.minAbsorption) return 'starved' + if (node.absorbed >= node.maxAbsorption) return 'saturated' + if (Math.abs(node.absorbed - node.minAbsorption) < 0.01) return 'minimum' + return 'healthy' +} + +/** + * Calculate how much flow a node absorbs given inflow + * Absorbs between min and max thresholds + */ +export function calculateAbsorption(inflow: number, minAbsorption: number, maxAbsorption: number): number { + // Absorb as much as possible, up to max + return Math.min(inflow, maxAbsorption) +} + +/** + * Calculate outflow (excess that couldn't be absorbed) + */ +export function calculateOutflow(inflow: number, absorbed: number): number { + return Math.max(0, inflow - absorbed) +} + +/** + * Update all computed properties on a node + */ +export function updateFlowNodeProperties(node: FlowNode): FlowNode { + const absorbed = calculateAbsorption(node.inflow, node.minAbsorption, node.maxAbsorption) + const outflow = calculateOutflow(node.inflow, absorbed) + const status = getFlowNodeStatus({ ...node, absorbed }) + + return { + ...node, + absorbed, + outflow, + status, + } +} + +/** + * Calculate network-level totals + */ +export function calculateFlowNetworkTotals(network: FlowNetwork): FlowNetwork { + const totalInflow = network.nodes.reduce((sum, node) => sum + node.externalFlow, 0) + const totalAbsorbed = network.nodes.reduce((sum, node) => sum + node.absorbed, 0) + const totalOutflow = network.nodes.reduce((sum, node) => sum + node.outflow, 0) + + return { + ...network, + totalInflow, + totalAbsorbed, + totalOutflow, + } +} + +/** + * Normalize allocations so they sum to 1.0 + * Same as stock model + */ +export function normalizeFlowAllocations(allocations: FlowAllocation[]): FlowAllocation[] { + if (allocations.length === 1) { + return allocations.map(a => ({ ...a, percentage: 1.0 })) + } + + const total = allocations.reduce((sum, a) => sum + a.percentage, 0) + + if (total === 0) { + const equalShare = 1.0 / allocations.length + return allocations.map((a) => ({ ...a, percentage: equalShare })) + } + + if (Math.abs(total - 1.0) < 0.0001) { + return allocations + } + + return allocations.map((a) => ({ + ...a, + percentage: a.percentage / total, + })) +} + +/** + * Get center point of a node (for arrow endpoints) + */ +export function getFlowNodeCenter(node: FlowNode): { x: number; y: number } { + return { + x: node.x + node.width / 2, + y: node.y + node.height / 2, + } +} + +/** + * Get status color for rendering + */ +export function getFlowStatusColor(status: FlowNodeStatus, alpha: number = 1): string { + const colors = { + starved: `rgba(239, 68, 68, ${alpha})`, // Red + minimum: `rgba(251, 191, 36, ${alpha})`, // Yellow + healthy: `rgba(99, 102, 241, ${alpha})`, // Blue + saturated: `rgba(16, 185, 129, ${alpha})`, // Green + } + return colors[status] +} + +/** + * Get status color as Tailwind class + */ +export function getFlowStatusColorClass(status: FlowNodeStatus): string { + const classes = { + starved: 'text-red-400', + minimum: 'text-yellow-400', + healthy: 'text-blue-400', + saturated: 'text-green-400', + } + return classes[status] +} + +/** + * Format flow rate for display + */ +export function formatFlow(rate: number): string { + return rate.toFixed(1) +} + +/** + * Format percentage for display + */ +export function formatPercentage(decimal: number): string { + return `${Math.round(decimal * 100)}%` +} + +/** + * Create the overflow sink node + */ +export function createOverflowNode(x: number, y: number): FlowNode { + return { + id: 'overflow-sink', + name: 'Overflow', + x, + y, + width: 120, + height: 80, + minAbsorption: 0, // Can absorb any amount + maxAbsorption: Infinity, // No limit + inflow: 0, + absorbed: 0, + outflow: 0, + status: 'healthy', + externalFlow: 0, + isOverflowSink: true, + } +} + +/** + * Check if network needs an overflow node + * Returns true if any node has outflow with no allocations + */ +export function needsOverflowNode(network: FlowNetwork): boolean { + return network.nodes.some(node => { + if (node.isOverflowSink) return false + + const hasOutflow = node.outflow > 0.01 + const hasAllocations = network.allocations.some(a => a.sourceNodeId === node.id) + + return hasOutflow && !hasAllocations + }) +} diff --git a/package.json b/package.json index 142e10e..c187d90 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "build": "next build", "dev": "next dev", "lint": "eslint .", - "start": "next start" + "start": "next start", + "screenshots": "node scripts/capture-screenshots.mjs" }, "dependencies": { "@folkjs/propagators": "link:../folkjs/packages/propagators", @@ -67,6 +68,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "postcss": "^8.5", + "puppeteer": "^24.31.0", "tailwindcss": "^4.1.9", "tw-animate-css": "1.3.3", "typescript": "^5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dbf16c..934a8e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: postcss: specifier: ^8.5 version: 8.5.0 + puppeteer: + specifier: ^24.31.0 + version: 24.31.0(typescript@5.0.2) tailwindcss: specifier: ^4.1.9 version: 4.1.9 @@ -197,6 +200,14 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -421,6 +432,11 @@ packages: cpu: [x64] os: [win32] + '@puppeteer/browsers@2.10.13': + resolution: {integrity: sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} @@ -1168,6 +1184,9 @@ packages: '@tailwindcss/postcss@4.1.9': resolution: {integrity: sha512-v3DKzHibZO8ioVDmuVHCW1PR0XSM7nS40EjZFJEA1xPuvTuQPaR5flE1LyikU3hu2u1KNWBtEaSe8qsQjX3tyg==} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1204,6 +1223,9 @@ packages: '@types/react@19.0.0': resolution: {integrity: sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@vercel/analytics@1.5.0': resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==} peerDependencies: @@ -1230,10 +1252,29 @@ packages: vue-router: optional: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + autoprefixer@10.4.20: resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} engines: {node: ^10 || ^12 || >=14} @@ -1241,15 +1282,72 @@ packages: peerDependencies: postcss: ^8.1.0 + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.1: + resolution: {integrity: sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.6.2: + resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.7.0: + resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.3.2: + resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + baseline-browser-mapping@2.8.23: resolution: {integrity: sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==} hasBin: true + basic-ftp@5.0.5: + resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} + engines: {node: '>=10.0.0'} + browserslist@4.27.0: resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001752: resolution: {integrity: sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==} @@ -1257,12 +1355,21 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chromium-bidi@11.0.0: + resolution: {integrity: sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==} + peerDependencies: + devtools-protocol: '*' + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1273,6 +1380,22 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1320,15 +1443,32 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1336,6 +1476,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devtools-protocol@0.0.1521046: + resolution: {integrity: sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -1355,31 +1498,100 @@ packages: embla-carousel@8.5.1: resolution: {integrity: sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-equals@5.3.2: resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==} engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + input-otp@1.4.1: resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==} peerDependencies: @@ -1390,6 +1602,17 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1397,6 +1620,13 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -1461,6 +1691,9 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -1468,6 +1701,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lucide-react@0.454.0: resolution: {integrity: sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==} peerDependencies: @@ -1484,11 +1721,21 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + netmask@2.0.2: + resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + engines: {node: '>= 0.4.0'} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -1527,6 +1774,28 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1541,9 +1810,32 @@ packages: resolution: {integrity: sha512-27VKOqrYfPncKA2NrFOVhP5MGAfHKLYn/Q0mz9cNQyRAKYi3VNHwYU2qKKqPCqgBmeeJ0uAFB56NumXZ5ZReXg==} engines: {node: ^10 || ^12 || >=14} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + puppeteer-core@24.31.0: + resolution: {integrity: sha512-pnAohhSZipWQoFpXuGV7xCZfaGhqcBR9C4pVrU0QSrcMi7tQMH9J9lDBqBvyMAHQqe8HCARuREqFuVKRQOgTvg==} + engines: {node: '>=18'} + + puppeteer@24.31.0: + resolution: {integrity: sha512-q8y5yLxLD8xdZdzNWqdOL43NbfvUOp60SYhaLZQwHC9CdKldxQKXOyJAciOr7oUJfyAH/KgB2wKvqT2sFKoVXA==} + engines: {node: '>=18'} + hasBin: true + react-day-picker@9.8.0: resolution: {integrity: sha512-E0yhhg7R+pdgbl/2toTb0xBhsEAtmAx1l7qjIWYfcxOy8w4rTSVfbtBoSzVVhPwKP/5E9iL38LivzoE3AQDhCQ==} engines: {node: '>=18'} @@ -1629,6 +1921,14 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1641,6 +1941,18 @@ packages: resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: @@ -1651,6 +1963,21 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -1679,10 +2006,19 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} engines: {node: '>=18'} + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -1692,6 +2028,9 @@ packages: tw-animate-css@1.3.3: resolution: {integrity: sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q==} + typed-query-selector@2.12.0: + resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==} + typescript@5.0.2: resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==} engines: {node: '>=12.20'} @@ -1740,10 +2079,47 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + webdriver-bidi-protocol@0.3.9: + resolution: {integrity: sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -1756,6 +2132,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/runtime@7.28.4': {} '@date-fns/tz@1.2.0': {} @@ -1919,6 +2303,21 @@ snapshots: '@next/swc-win32-x64-msvc@16.0.0': optional: true + '@puppeteer/browsers@2.10.13': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.3 + tar-fs: 3.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@radix-ui/number@1.1.0': {} '@radix-ui/primitive@1.1.1': {} @@ -2687,6 +3086,8 @@ snapshots: postcss: 8.5.0 tailwindcss: 4.1.9 + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -2723,15 +3124,34 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.0.0 + optional: true + '@vercel/analytics@1.5.0(next@16.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': optionalDependencies: next: 16.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + autoprefixer@10.4.20(postcss@8.5.0): dependencies: browserslist: 4.27.0 @@ -2742,8 +3162,49 @@ snapshots: postcss: 8.5.0 postcss-value-parser: 4.2.0 + b4a@1.7.3: {} + + bare-events@2.8.2: {} + + bare-fs@4.5.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.7.0(bare-events@2.8.2) + bare-url: 2.3.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-os@3.6.2: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.6.2 + optional: true + + bare-stream@2.7.0(bare-events@2.8.2): + dependencies: + streamx: 2.23.0 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-url@2.3.2: + dependencies: + bare-path: 3.0.0 + optional: true + baseline-browser-mapping@2.8.23: {} + basic-ftp@5.0.5: {} + browserslist@4.27.0: dependencies: baseline-browser-mapping: 2.8.23 @@ -2752,16 +3213,32 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.1.4(browserslist@4.27.0) + buffer-crc32@0.2.13: {} + + callsites@3.1.0: {} + caniuse-lite@1.0.30001752: {} chownr@3.0.0: {} + chromium-bidi@11.0.0(devtools-protocol@0.0.1521046): + dependencies: + devtools-protocol: 0.0.1521046 + mitt: 3.0.1 + zod: 3.25.76 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cmdk@1.0.4(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -2776,6 +3253,21 @@ snapshots: - '@types/react' - '@types/react-dom' + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cosmiconfig@9.0.0(typescript@5.0.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.0.2 + csstype@3.1.3: {} d3-array@3.2.4: @@ -2816,16 +3308,30 @@ snapshots: d3-timer@3.0.1: {} + data-uri-to-buffer@6.0.2: {} + date-fns-jalali@4.1.0-0: {} date-fns@4.1.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js-light@2.5.1: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + devtools-protocol@0.0.1521046: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -2845,23 +3351,104 @@ snapshots: embla-carousel@8.5.1: {} + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + escalade@3.2.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + eventemitter3@4.0.7: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-equals@5.3.2: {} + fast-fifo@1.3.2: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fraction.js@4.3.7: {} + get-caller-file@2.0.5: {} + get-nonce@1.0.1: {} + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + + get-uri@6.0.5: + dependencies: + basic-ftp: 5.0.5 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + graceful-fs@4.2.11: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + input-otp@1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 @@ -2869,10 +3456,22 @@ snapshots: internmap@2.0.3: {} + ip-address@10.1.0: {} + + is-arrayish@0.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + jiti@2.6.1: {} js-tokens@4.0.0: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@2.3.1: {} + lightningcss-darwin-arm64@1.30.1: optional: true @@ -2918,12 +3517,16 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + lines-and-columns@1.2.4: {} + lodash@4.17.21: {} loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + lru-cache@7.18.3: {} + lucide-react@0.454.0(react@19.2.0): dependencies: react: 19.2.0 @@ -2938,8 +3541,14 @@ snapshots: dependencies: minipass: 7.1.2 + mitt@3.0.1: {} + + ms@2.1.3: {} + nanoid@3.3.11: {} + netmask@2.0.2: {} + next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 @@ -2974,6 +3583,41 @@ snapshots: object-assign@4.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.0.2 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + pend@1.2.0: {} + picocolors@1.1.1: {} postcss-value-parser@4.2.0: {} @@ -2990,12 +3634,68 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + puppeteer-core@24.31.0: + dependencies: + '@puppeteer/browsers': 2.10.13 + chromium-bidi: 11.0.0(devtools-protocol@0.0.1521046) + debug: 4.4.3 + devtools-protocol: 0.0.1521046 + typed-query-selector: 2.12.0 + webdriver-bidi-protocol: 0.3.9 + ws: 8.18.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + puppeteer@24.31.0(typescript@5.0.2): + dependencies: + '@puppeteer/browsers': 2.10.13 + chromium-bidi: 11.0.0(devtools-protocol@0.0.1521046) + cosmiconfig: 9.0.0(typescript@5.0.2) + devtools-protocol: 0.0.1521046 + puppeteer-core: 24.31.0 + typed-query-selector: 2.12.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - typescript + - utf-8-validate + react-day-picker@9.8.0(react@19.2.0): dependencies: '@date-fns/tz': 1.2.0 @@ -3084,10 +3784,13 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + require-directory@2.1.1: {} + + resolve-from@4.0.0: {} + scheduler@0.27.0: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} sharp@0.34.4: dependencies: @@ -3119,6 +3822,21 @@ snapshots: '@img/sharp-win32-x64': 0.34.4 optional: true + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + sonner@1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 @@ -3126,6 +3844,28 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: + optional: true + + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + styled-jsx@5.1.6(react@19.2.0): dependencies: client-only: 0.0.1 @@ -3141,6 +3881,27 @@ snapshots: tapable@2.3.0: {} + tar-fs@3.1.1: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.5.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@7.5.2: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -3149,12 +3910,20 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + tiny-invariant@1.3.3: {} tslib@2.8.1: {} tw-animate-css@1.3.3: {} + typed-query-selector@2.12.0: {} + typescript@5.0.2: {} undici-types@6.11.1: {} @@ -3210,6 +3979,37 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + webdriver-bidi-protocol@0.3.9: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.18.3: {} + + y18n@5.0.8: {} + yallist@5.0.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + zod@3.25.76: {} diff --git a/public/screenshots/flow-v2.png b/public/screenshots/flow-v2.png new file mode 100644 index 0000000..0741642 Binary files /dev/null and b/public/screenshots/flow-v2.png differ diff --git a/public/screenshots/flowfunding.png b/public/screenshots/flowfunding.png new file mode 100644 index 0000000..0f097ab Binary files /dev/null and b/public/screenshots/flowfunding.png differ diff --git a/public/screenshots/italism.png b/public/screenshots/italism.png new file mode 100644 index 0000000..213ca93 Binary files /dev/null and b/public/screenshots/italism.png differ diff --git a/public/screenshots/tbff-flow.png b/public/screenshots/tbff-flow.png new file mode 100644 index 0000000..32954ad Binary files /dev/null and b/public/screenshots/tbff-flow.png differ diff --git a/public/screenshots/tbff.png b/public/screenshots/tbff.png new file mode 100644 index 0000000..838d750 Binary files /dev/null and b/public/screenshots/tbff.png differ diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..a366599 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,138 @@ +# Screenshot Management + +This directory contains scripts for managing demo screenshots. + +## Capturing Screenshots + +### Prerequisites +- Development server must be running (`pnpm dev`) +- All demo pages should be accessible at their routes + +### Running the Screenshot Script + +```bash +# Make sure dev server is running first +pnpm dev + +# In another terminal, capture screenshots +pnpm screenshots +``` + +This will: +1. Launch a headless Chrome browser +2. Navigate to each demo page +3. Wait for content to load +4. Capture a 1280x800 screenshot +5. Save to `public/screenshots/` + +### Output + +Screenshots are saved as: +- `public/screenshots/tbff.png` +- `public/screenshots/tbff-flow.png` +- `public/screenshots/flow-v2.png` +- `public/screenshots/italism.png` +- `public/screenshots/flowfunding.png` + +### Adding New Demos + +To capture screenshots for new demos, edit `capture-screenshots.mjs`: + +```javascript +const demos = [ + { path: '/your-new-demo', name: 'your-new-demo' }, + // ... existing demos +]; +``` + +Then update `app/demos/page.tsx` to include the screenshot path: + +```typescript +{ + title: 'Your New Demo', + path: '/your-new-demo', + screenshot: '/screenshots/your-new-demo.png', + // ... other properties +} +``` + +## Screenshot Specifications + +- **Viewport**: 1280x800 pixels +- **Format**: PNG +- **Wait Time**: 2 seconds after page load (for animations to settle) +- **Network**: Waits for networkidle2 (most network activity finished) + +## Customization + +### Changing Viewport Size + +Edit the viewport in `capture-screenshots.mjs`: + +```javascript +await page.setViewport({ + width: 1920, // Change width + height: 1080 // Change height +}); +``` + +### Changing Wait Time + +Adjust the timeout if demos need more time to render: + +```javascript +await new Promise(resolve => setTimeout(resolve, 3000)); // 3 seconds +``` + +### Capturing Specific Section + +To capture only part of a page: + +```javascript +const element = await page.$('.demo-container'); +await element.screenshot({ path: screenshotPath }); +``` + +## Troubleshooting + +### Screenshots are blank +- Increase wait time +- Check if content loads in actual browser +- Ensure dev server is running + +### Browser launch fails +- Check if puppeteer installed: `pnpm list puppeteer` +- Reinstall: `pnpm add -D puppeteer` +- Check system dependencies for Chrome + +### Timeout errors +- Increase timeout in script: + ```javascript + timeout: 60000 // 60 seconds + ``` + +## Manual Screenshot Workflow + +If automated screenshots don't work: + +1. Open demo in browser +2. Set window to 1280x800 +3. Use browser screenshot tool (F12 β†’ Device Toolbar β†’ Screenshot) +4. Save to `public/screenshots/[demo-name].png` +5. Update demo card with screenshot path + +## Performance Tips + +- Screenshots are cached by browser +- Total size: ~560KB for 5 demos +- Consider optimizing PNGs with tools like `pngquant` or `imagemin` +- WebP format could reduce size further + +## Future Enhancements + +- [ ] Generate thumbnails in addition to full screenshots +- [ ] Add WebP format support +- [ ] Capture at multiple viewport sizes +- [ ] Add screenshot comparison for regression testing +- [ ] Automate screenshot capture on build +- [ ] Add screenshot update on demo changes (CI/CD) diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs new file mode 100755 index 0000000..cbbbd84 --- /dev/null +++ b/scripts/capture-screenshots.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +/** + * Screenshot Capture Script for Flow Funding Demos + * + * This script captures screenshots of all demo pages using Puppeteer + */ + +import puppeteer from 'puppeteer'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { existsSync, mkdirSync } from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const demos = [ + { path: '/tbff', name: 'tbff' }, + { path: '/tbff-flow', name: 'tbff-flow' }, + { path: '/flow-v2', name: 'flow-v2' }, + { path: '/italism', name: 'italism' }, + { path: '/flowfunding', name: 'flowfunding' }, +]; + +const baseUrl = 'http://localhost:3000'; +const screenshotsDir = join(__dirname, '../public/screenshots'); + +// Ensure screenshots directory exists +if (!existsSync(screenshotsDir)) { + mkdirSync(screenshotsDir, { recursive: true }); +} + +async function captureScreenshots() { + console.log('πŸš€ Starting screenshot capture...\n'); + + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + try { + for (const demo of demos) { + const url = `${baseUrl}${demo.path}`; + console.log(`πŸ“Έ Capturing ${demo.name}...`); + + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + + try { + await page.goto(url, { + waitUntil: 'networkidle2', + timeout: 30000 + }); + + // Wait a bit for animations to settle + await new Promise(resolve => setTimeout(resolve, 2000)); + + const screenshotPath = join(screenshotsDir, `${demo.name}.png`); + await page.screenshot({ + path: screenshotPath, + type: 'png' + }); + + console.log(` βœ… Saved to public/screenshots/${demo.name}.png`); + } catch (error) { + console.error(` ❌ Failed to capture ${demo.name}:`, error.message); + } finally { + await page.close(); + } + } + } finally { + await browser.close(); + } + + console.log('\n✨ Screenshot capture complete!'); +} + +captureScreenshots().catch(console.error);