Demo updates.
This commit is contained in:
parent
cccb6cc20e
commit
a7213aa0d4
|
|
@ -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<string, Demo[]> = {
|
||||||
|
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."*
|
||||||
|
|
@ -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<string, Demo[]> = {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-purple-600 via-indigo-600 to-blue-600">
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white/95 backdrop-blur-lg rounded-3xl p-8 mb-10 shadow-2xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-5xl font-extrabold mb-3 bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
|
||||||
|
💧 Flow Funding Demos
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 mb-4">
|
||||||
|
Exploring Threshold-Based Resource Allocation & Post-Appitalism
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm max-w-3xl mx-auto">
|
||||||
|
Interactive demonstrations of flow funding mechanisms, from threshold-based redistribution to continuous flow dynamics.
|
||||||
|
Experience economics as living, breathing systems.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-10">
|
||||||
|
<div className="bg-white rounded-2xl p-6 text-center shadow-lg hover:shadow-xl transition-all hover:-translate-y-1">
|
||||||
|
<div className="text-4xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
|
||||||
|
{totalDemos}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-sm uppercase tracking-wider mt-2">Total Demos</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-2xl p-6 text-center shadow-lg hover:shadow-xl transition-all hover:-translate-y-1">
|
||||||
|
<div className="text-4xl font-bold bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent">
|
||||||
|
{completeDemos}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-sm uppercase tracking-wider mt-2">Complete</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-2xl p-6 text-center shadow-lg hover:shadow-xl transition-all hover:-translate-y-1">
|
||||||
|
<div className="text-4xl font-bold bg-gradient-to-r from-yellow-600 to-orange-600 bg-clip-text text-transparent">
|
||||||
|
{betaDemos}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-sm uppercase tracking-wider mt-2">Beta</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-2xl p-6 text-center shadow-lg hover:shadow-xl transition-all hover:-translate-y-1">
|
||||||
|
<div className="text-4xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-sm uppercase tracking-wider mt-2">Categories</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filter */}
|
||||||
|
<div className="bg-white rounded-2xl p-6 mb-8 shadow-lg">
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="🔍 Search demos by name, type, or feature..."
|
||||||
|
className="w-full px-6 py-4 border-2 border-gray-200 rounded-xl text-lg focus:outline-none focus:border-purple-500 transition-colors"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setActiveFilter(cat.id)}
|
||||||
|
className={`px-5 py-2.5 rounded-full font-semibold text-sm transition-all ${
|
||||||
|
activeFilter === cat.id
|
||||||
|
? 'bg-purple-600 text-white shadow-lg'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.icon && <span className="mr-2">{cat.icon}</span>}
|
||||||
|
{cat.label} <span className="ml-1 opacity-75">({cat.count})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div key={categoryKey} className="mb-12">
|
||||||
|
<div className="bg-white rounded-2xl p-6 mb-6 shadow-lg">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-3xl shadow-lg">
|
||||||
|
{categoryInfo.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800">{categoryInfo.title}</h2>
|
||||||
|
<p className="text-gray-600 text-sm">{categoryInfo.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-100 px-4 py-2 rounded-full font-semibold text-purple-600">
|
||||||
|
{categoryDemos.length} {categoryDemos.length === 1 ? 'demo' : 'demos'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{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 => (
|
||||||
|
<a
|
||||||
|
key={demo.number}
|
||||||
|
href={demo.path}
|
||||||
|
className="group bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-all hover:-translate-y-2"
|
||||||
|
>
|
||||||
|
{/* Screenshot Preview */}
|
||||||
|
{demo.screenshot && (
|
||||||
|
<div className="relative h-48 bg-gradient-to-br from-purple-100 to-blue-100 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={demo.screenshot}
|
||||||
|
alt={`${demo.title} screenshot`}
|
||||||
|
className="w-full h-full object-cover object-top transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
|
||||||
|
<span className="bg-white text-purple-600 px-4 py-2 rounded-full font-semibold text-sm opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
||||||
|
👁️ Click to view
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card Header */}
|
||||||
|
<div className="h-4 bg-gradient-to-r from-purple-500 to-blue-500"></div>
|
||||||
|
|
||||||
|
{/* Card Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<span className="inline-block bg-purple-600 text-white px-3 py-1 rounded-full text-xs font-bold">
|
||||||
|
#{demo.number}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-block ${getStatusColor(demo.status)} text-white px-3 py-1 rounded-full text-xs font-bold`}>
|
||||||
|
{getStatusLabel(demo.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-bold text-gray-800 mb-2 group-hover:text-purple-600 transition-colors">
|
||||||
|
{demo.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{demo.milestone && (
|
||||||
|
<div className="mb-3 inline-block bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-semibold">
|
||||||
|
✅ {demo.milestone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-sm mb-4 line-clamp-3">
|
||||||
|
{demo.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
Key Features:
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{demo.features.slice(0, 3).map((feature, idx) => (
|
||||||
|
<span key={idx} className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded">
|
||||||
|
{feature}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{demo.features.length > 3 && (
|
||||||
|
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded">
|
||||||
|
+{demo.features.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||||
|
<span className="text-xs text-gray-500 font-medium">{demo.type}</span>
|
||||||
|
<span className="text-purple-600 font-semibold text-sm group-hover:gap-2 flex items-center gap-1 transition-all">
|
||||||
|
Launch Demo →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-white/95 backdrop-blur-lg rounded-3xl p-8 mt-12 shadow-xl text-center">
|
||||||
|
<p className="text-gray-800 font-semibold mb-2">Flow Funding Demos - Post-Appitalism Project</p>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
Exploring threshold-based resource allocation and continuous flow dynamics
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center gap-6 text-sm">
|
||||||
|
<a href="/" className="text-purple-600 font-semibold hover:underline">
|
||||||
|
📖 Project Home
|
||||||
|
</a>
|
||||||
|
<span className="text-gray-400">•</span>
|
||||||
|
<a href="/italism" className="text-purple-600 font-semibold hover:underline">
|
||||||
|
🎨 Interactive Canvas
|
||||||
|
</a>
|
||||||
|
<span className="text-gray-400">•</span>
|
||||||
|
<a href="/tbff" className="text-purple-600 font-semibold hover:underline">
|
||||||
|
🎯 TBFF Demo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,14 @@ export const metadata: Metadata = {
|
||||||
title: "Project Interlay | Post-Appitalism",
|
title: "Project Interlay | Post-Appitalism",
|
||||||
description: "Weaving a post-appitalist future. Decomposing the data silos of capitalist business models.",
|
description: "Weaving a post-appitalist future. Decomposing the data silos of capitalist business models.",
|
||||||
generator: "v0.app",
|
generator: "v0.app",
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{
|
||||||
|
url: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌊</text></svg>",
|
||||||
|
type: "image/svg+xml",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
|
||||||
|
|
@ -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<HTMLCanvasElement>(null)
|
||||||
|
const animationFrameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const [network, setNetwork] = useState<FlowNetwork>(flowSampleNetworks.linear)
|
||||||
|
const [particles, setParticles] = useState<FlowParticle[]>([])
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
|
const [selectedAllocationId, setSelectedAllocationId] = useState<string | null>(null)
|
||||||
|
const [selectedNetworkKey, setSelectedNetworkKey] = useState<string>('linear')
|
||||||
|
const [tool, setTool] = useState<Tool>('select')
|
||||||
|
const [allocationSourceId, setAllocationSourceId] = useState<string | null>(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<HTMLCanvasElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-slate-900 text-white">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-cyan-400">
|
||||||
|
Flow-Based Flow Funding
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Resource circulation visualization
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link href="/tbff" className="text-cyan-400 hover:text-cyan-300 transition-colors">
|
||||||
|
Stock Model →
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="text-cyan-400 hover:text-cyan-300 transition-colors">
|
||||||
|
← Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex h-[calc(100vh-73px)]">
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`w-full h-full ${
|
||||||
|
tool === 'create-allocation' ? 'cursor-crosshair' : 'cursor-pointer'
|
||||||
|
}`}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tool indicator */}
|
||||||
|
{allocationSourceId && (
|
||||||
|
<div className="absolute top-4 left-4 bg-cyan-600 px-4 py-2 rounded-lg text-sm font-medium">
|
||||||
|
Click target node to create allocation
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Animation status */}
|
||||||
|
<div className="absolute top-4 right-4 bg-slate-800 px-4 py-2 rounded-lg text-sm">
|
||||||
|
{isAnimating ? '▶️ Animating' : '⏸️ Paused'} (Space to toggle)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-80 bg-slate-800 p-6 space-y-6 overflow-y-auto">
|
||||||
|
{/* Tools */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-400">Tools</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTool('select')
|
||||||
|
setAllocationSourceId(null)
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 rounded text-sm transition-colors ${
|
||||||
|
tool === 'select'
|
||||||
|
? 'bg-cyan-600 text-white'
|
||||||
|
: 'bg-slate-700 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTool('create-allocation')}
|
||||||
|
className={`px-3 py-2 rounded text-sm transition-colors ${
|
||||||
|
tool === 'create-allocation'
|
||||||
|
? 'bg-cyan-600 text-white'
|
||||||
|
: 'bg-slate-700 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Create Arrow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-400">Select Network</h3>
|
||||||
|
<select
|
||||||
|
value={selectedNetworkKey}
|
||||||
|
onChange={(e) => handleLoadNetwork(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 rounded text-sm"
|
||||||
|
>
|
||||||
|
{flowNetworkOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Info */}
|
||||||
|
<div className="bg-slate-700 p-4 rounded">
|
||||||
|
<h3 className="font-semibold text-cyan-400 mb-3">{network.name}</h3>
|
||||||
|
<div className="text-xs space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Nodes:</span>
|
||||||
|
<span className="text-white">{network.nodes.filter(n => !n.isOverflowSink).length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Allocations:</span>
|
||||||
|
<span className="text-white">{network.allocations.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-blue-400">Total Inflow:</span>
|
||||||
|
<span className="text-blue-400">{formatFlow(network.totalInflow)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-green-400">Total Absorbed:</span>
|
||||||
|
<span className="text-green-400">{formatFlow(network.totalAbsorbed)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-yellow-400">Total Outflow:</span>
|
||||||
|
<span className="text-yellow-400">{formatFlow(network.totalOutflow)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Set Flow Input */}
|
||||||
|
{selectedNode && !selectedNode.isOverflowSink && (
|
||||||
|
<div className="bg-green-900/30 border border-green-500/30 p-4 rounded">
|
||||||
|
<h3 className="font-semibold text-green-400 mb-3">💧 Set Flow Input</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 block mb-1">
|
||||||
|
External Flow for {selectedNode.name}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={selectedNode.externalFlow}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
Current inflow: {formatFlow(selectedNode.inflow)}
|
||||||
|
<br />
|
||||||
|
Absorbed: {formatFlow(selectedNode.absorbed)}
|
||||||
|
<br />
|
||||||
|
Outflow: {formatFlow(selectedNode.outflow)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Allocation Editor */}
|
||||||
|
{selectedAllocation && (
|
||||||
|
<div className="bg-slate-700 p-4 rounded">
|
||||||
|
<h3 className="font-semibold text-cyan-400 mb-3">Edit Allocation</h3>
|
||||||
|
<div className="text-xs space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400">From: </span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{network.nodes.find(n => n.id === selectedAllocation.sourceNodeId)?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400">To: </span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{network.nodes.find(n => n.id === selectedAllocation.targetNodeId)?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 block mb-1">
|
||||||
|
Percentage: {Math.round(selectedAllocation.percentage * 100)}%
|
||||||
|
</label>
|
||||||
|
{selectedAllocationSiblings.length === 1 ? (
|
||||||
|
<div className="text-[10px] text-yellow-400 bg-slate-800 p-2 rounded">
|
||||||
|
Single allocation must be 100%.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={selectedAllocation.percentage * 100}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAllocationPercentage(
|
||||||
|
selectedAllocation.id,
|
||||||
|
parseFloat(e.target.value) / 100
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteAllocation(selectedAllocation.id)}
|
||||||
|
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Delete Allocation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Node Details */}
|
||||||
|
{selectedNode && (
|
||||||
|
<div className="bg-slate-700 p-4 rounded">
|
||||||
|
<h3 className="font-semibold text-cyan-400 mb-3">Node Details</h3>
|
||||||
|
<div className="text-xs space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400">Name: </span>
|
||||||
|
<span className="text-white font-medium">{selectedNode.name}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400">Status: </span>
|
||||||
|
<span className={`font-medium ${getFlowStatusColorClass(selectedNode.status)}`}>
|
||||||
|
{selectedNode.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Inflow:</span>
|
||||||
|
<span className="text-blue-400 font-mono">{formatFlow(selectedNode.inflow)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Absorbed:</span>
|
||||||
|
<span className="text-green-400 font-mono">{formatFlow(selectedNode.absorbed)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Outflow:</span>
|
||||||
|
<span className="text-yellow-400 font-mono">{formatFlow(selectedNode.outflow)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Min Absorption:</span>
|
||||||
|
<span className="text-white font-mono">{formatFlow(selectedNode.minAbsorption)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Max Absorption:</span>
|
||||||
|
<span className="text-white font-mono">{formatFlow(selectedNode.maxAbsorption)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outgoing Allocations */}
|
||||||
|
{outgoingAllocations.length > 0 && (
|
||||||
|
<div className="pt-3 border-t border-slate-600">
|
||||||
|
<div className="text-slate-400 mb-2">Outgoing Allocations:</div>
|
||||||
|
{outgoingAllocations.map((alloc) => {
|
||||||
|
const target = network.nodes.find(n => n.id === alloc.targetNodeId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alloc.id}
|
||||||
|
className="flex justify-between items-center mb-1 cursor-pointer hover:bg-slate-600 p-1 rounded"
|
||||||
|
onClick={() => setSelectedAllocationId(alloc.id)}
|
||||||
|
>
|
||||||
|
<span className="text-white">→ {target?.name}</span>
|
||||||
|
<span className="text-cyan-400 font-mono">
|
||||||
|
{Math.round(alloc.percentage * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="bg-slate-700 p-4 rounded">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-400 mb-3">Legend</h3>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-red-500 rounded"></div>
|
||||||
|
<span>Starved - Below minimum absorption</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
||||||
|
<span>Minimum - At minimum absorption</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||||
|
<span>Healthy - Between min and max</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||||
|
<span>Saturated - At maximum capacity</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-green-400 rounded-full"></div>
|
||||||
|
<span>Particle - Flow animation</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="bg-slate-700 p-4 rounded text-xs text-slate-300">
|
||||||
|
<p className="mb-2">
|
||||||
|
<strong className="text-white">Flow-Based Model</strong>
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 list-disc list-inside">
|
||||||
|
<li>Click node to select and <strong className="text-green-400">set flow</strong></li>
|
||||||
|
<li>Use <strong className="text-cyan-400">Create Arrow</strong> to draw allocations</li>
|
||||||
|
<li>Watch flows <strong className="text-green-400">propagate</strong> in real-time</li>
|
||||||
|
<li>Press <kbd className="px-1 bg-slate-800 rounded">Space</kbd> to pause/play animation</li>
|
||||||
|
<li>Overflow sink appears automatically if needed</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -36,12 +36,16 @@ export function HeroSection() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-8">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-8">
|
||||||
<Button size="lg" className="text-lg px-8 group">
|
<Button size="lg" className="text-lg px-8 group" asChild>
|
||||||
Explore the Vision
|
<a href="/demos">
|
||||||
|
View Interactive Demos
|
||||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" variant="outline" className="text-lg px-8 bg-transparent">
|
<Button size="lg" variant="outline" className="text-lg px-8 bg-transparent" asChild>
|
||||||
Read the Research
|
<a href="#vision">
|
||||||
|
Explore the Vision
|
||||||
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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<FlowNetwork>(...)
|
||||||
|
const [particles, setParticles] = useState<FlowParticle[]>([])
|
||||||
|
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.*
|
||||||
|
|
@ -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<string, number>()
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"screenshots": "node scripts/capture-screenshots.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@folkjs/propagators": "link:../folkjs/packages/propagators",
|
"@folkjs/propagators": "link:../folkjs/packages/propagators",
|
||||||
|
|
@ -67,6 +68,7 @@
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
|
"puppeteer": "^24.31.0",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
"tw-animate-css": "1.3.3",
|
"tw-animate-css": "1.3.3",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|
|
||||||
804
pnpm-lock.yaml
804
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
|
|
@ -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)
|
||||||
|
|
@ -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);
|
||||||
Loading…
Reference in New Issue